This commit is contained in:
Manchik
2026-05-08 22:46:47 +03:00
parent 9b7001407f
commit 13dc22f74f
30 changed files with 4107 additions and 843 deletions
+125 -27
View File
@@ -6,17 +6,45 @@ import { AccountRow } from "./AccountRow";
import { useT } from "@/lib/i18n";
const COLUMN_KEYS: (keyof ColumnSettings)[] = [
"browser", "profile", "last_online", "steam_id", "login", "password", "login_pass",
"email", "email_pass", "email_login_pass", "phone", "status", "ban", "twofa", "mafile", "proxy", "notes", "actions",
"browser",
"profile",
"last_online",
"steam_id",
"login",
"password",
"login_pass",
"email",
"email_pass",
"email_login_pass",
"phone",
"status",
"ban",
"twofa",
"mafile",
"proxy",
"notes",
"actions",
];
const COL_LABEL_KEYS: Record<keyof ColumnSettings, string> = {
browser: "col.browser", profile: "col.profile", last_online: "col.lastOnline",
steam_id: "col.steamId", login: "col.login", password: "col.password",
login_pass: "col.loginPass", email: "col.email", email_pass: "col.emailPass",
email_login_pass: "col.emailLoginPass", phone: "col.phone", status: "col.status",
ban: "col.ban", twofa: "col.twofa", mafile: "col.mafile", proxy: "col.proxy",
notes: "col.notes", actions: "col.actions",
browser: "col.browser",
profile: "col.profile",
last_online: "col.lastOnline",
steam_id: "col.steamId",
login: "col.login",
password: "col.password",
login_pass: "col.loginPass",
email: "col.email",
email_pass: "col.emailPass",
email_login_pass: "col.emailLoginPass",
phone: "col.phone",
status: "col.status",
ban: "col.ban",
twofa: "col.twofa",
mafile: "col.mafile",
proxy: "col.proxy",
notes: "col.notes",
actions: "col.actions",
};
interface Props {
@@ -26,19 +54,47 @@ interface Props {
accountSteps: Record<string, { step: number; total: number }>;
onEdit: (id: number) => void;
onDelete: (id: number) => void;
onAction: (id: number, action: string, params: Record<string, string>) => void;
onAction: (
id: number,
action: string,
params: Record<string, string>,
) => void;
onToggleAutoAccept: (id: number) => void;
onOpenBrowser: (id: number) => void;
}
export function AccountTable({ accounts, processingIds, accountResults, accountSteps, onEdit, onDelete, onAction, onToggleAutoAccept, onOpenBrowser }: Props) {
const selectAll = useAccountStore((s) => s.selectAll);
const clearSelection = useAccountStore((s) => s.clearSelection);
export function AccountTable({
accounts,
processingIds,
accountResults,
accountSteps,
onEdit,
onDelete,
onAction,
onToggleAutoAccept,
onOpenBrowser,
}: Props) {
const selectedIds = useAccountStore((s) => s.selectedIds);
const setSelectedIds = useAccountStore((s) => s.setSelectedIds);
const cols = useUiStore((s) => s.columnVisibility);
const t = useT();
const allSelected = accounts.length > 0 && selectedIds.size === accounts.length;
const allFilteredSelected =
accounts.length > 0 && accounts.every((a) => selectedIds.has(a.id));
const toggleSelectAllFiltered = () => {
if (allFilteredSelected) {
// Deselect only the filtered accounts, keep others selected
const next = new Set(selectedIds);
accounts.forEach((a) => next.delete(a.id));
setSelectedIds(next);
} else {
// Add all filtered accounts to selection
const next = new Set(selectedIds);
accounts.forEach((a) => next.add(a.id));
setSelectedIds(next);
}
};
const visibleKeys = COLUMN_KEYS.filter((k) => cols[k]);
const BATCH = 100;
@@ -52,7 +108,10 @@ export function AccountTable({ accounts, processingIds, accountResults, accountS
const toggleSort = (field: SortField) => {
if (sortField === field) {
if (sortDir === "asc") setSortDir("desc");
else { setSortField(null); setSortDir("asc"); }
else {
setSortField(null);
setSortDir("asc");
}
} else {
setSortField(field);
setSortDir("asc");
@@ -64,7 +123,9 @@ export function AccountTable({ accounts, processingIds, accountResults, accountS
const dir = sortDir === "asc" ? 1 : -1;
const EMPTY = Symbol();
return [...accounts].sort((a, b) => {
const parseOnline = (v: string | null | undefined): number | typeof EMPTY => {
const parseOnline = (
v: string | null | undefined,
): number | typeof EMPTY => {
if (!v || v === "\u2014") return EMPTY;
if (v === "online") return -1;
const m = v.match(/^(\d+)([mhd])$/i);
@@ -84,16 +145,25 @@ export function AccountTable({ accounts, processingIds, accountResults, accountS
});
}, [accounts, sortField, sortDir]);
useEffect(() => { setVisibleCount(BATCH); }, [accounts, sortField, sortDir]);
useEffect(() => {
setVisibleCount(BATCH);
}, [accounts, sortField, sortDir]);
const visible = useMemo(() => sorted.slice(0, visibleCount), [sorted, visibleCount]);
const visible = useMemo(
() => sorted.slice(0, visibleCount),
[sorted, visibleCount],
);
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const obs = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) setVisibleCount((c) => Math.min(c + BATCH, sorted.length));
}, { rootMargin: "400px" });
const obs = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting)
setVisibleCount((c) => Math.min(c + BATCH, sorted.length));
},
{ rootMargin: "400px" },
);
obs.observe(el);
return () => obs.disconnect();
}, [sorted.length, visibleCount]);
@@ -135,11 +205,13 @@ export function AccountTable({ accounts, processingIds, accountResults, accountS
<div className="flex items-center gap-1.5">
<input
type="checkbox"
checked={allSelected}
onChange={() => allSelected ? clearSelection() : selectAll()}
checked={allFilteredSelected}
onChange={toggleSelectAllFiltered}
/>
{selectedIds.size > 0 && (
<span className="text-accent font-medium">{selectedIds.size}</span>
<span className="text-accent font-medium">
{selectedIds.size}
</span>
)}
</div>
</th>
@@ -147,12 +219,28 @@ export function AccountTable({ accounts, processingIds, accountResults, accountS
{visibleKeys.map((k) => {
if (k === "last_online") {
return (
<th key={k} className="px-3 py-2 cursor-pointer select-none hover:text-gray-300" onClick={() => toggleSort("last_online")}>
{t(COL_LABEL_KEYS[k])}{sortField === "last_online" ? (sortDir === "asc" ? " ▲" : " ▼") : ""}
<th
key={k}
className="px-3 py-2 cursor-pointer select-none hover:text-gray-300"
onClick={() => toggleSort("last_online")}
>
{t(COL_LABEL_KEYS[k])}
{sortField === "last_online"
? sortDir === "asc"
? " ▲"
: " ▼"
: ""}
</th>
);
}
return <th key={k} className={`px-3 py-2${k === "browser" ? " text-center" : ""}`}>{t(COL_LABEL_KEYS[k])}</th>;
return (
<th
key={k}
className={`px-3 py-2${k === "browser" ? " text-center" : ""}`}
>
{t(COL_LABEL_KEYS[k])}
</th>
);
})}
</tr>
</thead>
@@ -174,7 +262,17 @@ export function AccountTable({ accounts, processingIds, accountResults, accountS
/>
))}
{visibleCount < accounts.length && (
<tr ref={sentinelRef}><td colSpan={visibleKeys.length + 2} className="text-center py-3 text-xs text-gray-500">{t("paging.shown", { visible: visibleCount, total: accounts.length })}</td></tr>
<tr ref={sentinelRef}>
<td
colSpan={visibleKeys.length + 2}
className="text-center py-3 text-xs text-gray-500"
>
{t("paging.shown", {
visible: visibleCount,
total: accounts.length,
})}
</td>
</tr>
)}
</tbody>
</table>
File diff suppressed because it is too large Load Diff
+55 -14
View File
@@ -2,7 +2,7 @@ import { useState, useRef } from "react";
import type { AccountCreate } from "@/api/types";
import { api } from "@/api/client";
import { useUiStore } from "@/stores/uiStore";
import { useAccountStore } from "@/stores/accountStore";
import { IconUpload } from "@/components/shared/Icons";
import { useT } from "@/lib/i18n";
@@ -14,7 +14,7 @@ interface Props {
export function AddModal({ onSave, onClose }: Props) {
const t = useT();
const addToast = useUiStore((s) => s.addToast);
const loadAccounts = useAccountStore((s) => s.loadAccounts);
const [login, setLogin] = useState("");
const [password, setPassword] = useState("");
const [email, setEmail] = useState("");
@@ -63,7 +63,10 @@ export function AddModal({ onSave, onClose }: Props) {
return (
<div className="confirm-overlay" onMouseDown={onClose}>
<div className="confirm-box max-w-md" onMouseDown={(e) => e.stopPropagation()}>
<div
className="confirm-box max-w-md"
onMouseDown={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold mb-4">{t("modal.addAccount")}</h3>
<div className="space-y-3">
<input
@@ -80,14 +83,39 @@ export function AddModal({ onSave, onClose }: Props) {
placeholder={t("ph.login") + " (login:pass)"}
className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm"
/>
<input value={password} onChange={(e) => setPassword(e.target.value)} placeholder={t("ph.password")} className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
<input
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("ph.password")}
className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm"
/>
<div className="grid grid-cols-2 gap-2">
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" className="bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
<input value={emailPassword} onChange={(e) => setEmailPassword(e.target.value)} placeholder={t("ph.emailPassword")} className="bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
className="bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm"
/>
<input
value={emailPassword}
onChange={(e) => setEmailPassword(e.target.value)}
placeholder={t("ph.emailPassword")}
className="bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<input value={proxy} onChange={(e) => setProxy(e.target.value)} placeholder={t("ph.proxyOptional")} className="bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
<input value={notes} onChange={(e) => setNotes(e.target.value)} placeholder={t("ph.notesOptional")} className="bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
<input
value={proxy}
onChange={(e) => setProxy(e.target.value)}
placeholder={t("ph.proxyOptional")}
className="bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm"
/>
<input
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder={t("ph.notesOptional")}
className="bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm"
/>
</div>
<div
@@ -100,19 +128,32 @@ export function AddModal({ onSave, onClose }: Props) {
</span>
{mafile && (
<button
onClick={(e) => { e.stopPropagation(); setMafile(null); }}
onClick={(e) => {
e.stopPropagation();
setMafile(null);
}}
className="text-xs text-red-400 hover:text-red-300 ml-1"
>
</button>
)}
</div>
<input ref={fileRef} type="file" accept=".mafile" className="hidden" onChange={(e) => {
const f = e.target.files?.[0];
if (f) setMafile(f);
}} />
<input
ref={fileRef}
type="file"
accept=".mafile"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) setMafile(f);
}}
/>
<button onClick={handleSave} disabled={!login || !password || saving} className="btn-primary w-full">
<button
onClick={handleSave}
disabled={!login || !password || saving}
className="btn-primary w-full"
>
{saving ? t("import.uploading") : t("btn.add")}
</button>
</div>
@@ -7,9 +7,10 @@ import { useT } from "@/lib/i18n";
interface Props {
onClose: () => void;
onImportDone?: (newIds: number[]) => void;
}
export function ImportModal({ onClose }: Props) {
export function ImportModal({ onClose, onImportDone }: Props) {
const t = useT();
const [file, setFile] = useState<File | null>(null);
const [result, setResult] = useState<string>("");
@@ -28,10 +29,23 @@ export function ImportModal({ onClose }: Props) {
if (!file) return;
setUploading(true);
try {
const idsBefore = new Set(
useAccountStore.getState().accounts.map((a) => a.id),
);
const res = await api.importAccounts(file);
setResult(t("toast.importResult", { imported: res.imported, skipped: res.skipped }));
setResult(
t("toast.importResult", {
imported: res.imported,
skipped: res.skipped,
}),
);
addToast("success", `${res.imported} ok, ${res.skipped} skip`);
await loadAccounts();
const newIds = useAccountStore
.getState()
.accounts.map((a) => a.id)
.filter((id) => !idsBefore.has(id));
onImportDone?.(newIds);
} catch (e: unknown) {
setResult(e instanceof Error ? e.message : t("misc.errorStr"));
} finally {
@@ -41,13 +55,30 @@ export function ImportModal({ onClose }: Props) {
return (
<div className="confirm-overlay" onMouseDown={onClose}>
<div className="confirm-box max-w-lg w-full" onMouseDown={(e) => e.stopPropagation()}>
<h3 className="text-lg font-semibold mb-4">{t("modal.importAccounts")}</h3>
<div
className="confirm-box max-w-lg w-full"
onMouseDown={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold mb-4">
{t("modal.importAccounts")}
</h3>
<div className="text-sm text-gray-400 mb-3 space-y-1">
<p className="font-medium text-gray-300">{t("import.formats")} <span className="text-gray-500 font-normal">({t("import.delimiter")} <code className="text-accent">:</code> {t("import.or")} <code className="text-accent">|</code>)</span></p>
<p className="font-mono text-xs">login:pass:{"{"}mafile{"}"}</p>
<p className="font-mono text-xs">login:pass:email:email_pass:{"{"}mafile{"}"}</p>
<p className="font-mono text-xs">login:pass:email:email_pass:{"{"}mafile{"}"}:notes</p>
<p className="font-medium text-gray-300">
{t("import.formats")}{" "}
<span className="text-gray-500 font-normal">
({t("import.delimiter")} <code className="text-accent">:</code>{" "}
{t("import.or")} <code className="text-accent">|</code>)
</span>
</p>
<p className="font-mono text-xs">
login:pass:{"{"}mafile{"}"}
</p>
<p className="font-mono text-xs">
login:pass:email:email_pass:{"{"}mafile{"}"}
</p>
<p className="font-mono text-xs">
login:pass:email:email_pass:{"{"}mafile{"}"}:notes
</p>
<p className="font-mono text-xs">login:pass:email:email_pass</p>
<p className="font-mono text-xs">login:pass:email:email_pass:notes</p>
</div>
@@ -59,14 +90,26 @@ export function ImportModal({ onClose }: Props) {
>
<IconUpload size={32} className="mx-auto mb-2 text-gray-500" />
<p className="text-sm text-gray-400">
{file ? t("import.selected", { name: file.name }) : t("import.dragTxt")}
{file
? t("import.selected", { name: file.name })
: t("import.dragTxt")}
</p>
</div>
<input ref={inputRef} type="file" accept=".txt" className="hidden" onChange={(e) => {
const f = e.target.files?.[0];
if (f) setFile(f);
}} />
<button onClick={doImport} disabled={!file || uploading} className="btn-primary w-full">
<input
ref={inputRef}
type="file"
accept=".txt"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) setFile(f);
}}
/>
<button
onClick={doImport}
disabled={!file || uploading}
className="btn-primary w-full"
>
{uploading ? t("import.uploading") : t("import.importBtn")}
</button>
{result && <p className="mt-3 text-sm text-gray-300">{result}</p>}
@@ -7,9 +7,10 @@ import { useT } from "@/lib/i18n";
interface Props {
onClose: () => void;
onImportDone?: (newIds: number[]) => void;
}
export function LogpassImportModal({ onClose }: Props) {
export function LogpassImportModal({ onClose, onImportDone }: Props) {
const t = useT();
const [file, setFile] = useState<File | null>(null);
const [result, setResult] = useState("");
@@ -29,12 +30,33 @@ export function LogpassImportModal({ onClose }: Props) {
setUploading(true);
try {
const text = await file.text();
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
if (!lines.length) { setResult(t("toast.fileEmpty")); setUploading(false); return; }
const lines = text
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
if (!lines.length) {
setResult(t("toast.fileEmpty"));
setUploading(false);
return;
}
// Snapshot IDs before import to detect new ones
const idsBefore = new Set(
useLogpassStore.getState().accounts.map((a) => a.id),
);
const res = await api.importLogpass(lines);
setResult(t("toast.importResult", { imported: res.imported, skipped: res.skipped }));
setResult(
t("toast.importResult", {
imported: res.imported,
skipped: res.skipped,
}),
);
addToast("success", `${res.imported} ok, ${res.skipped} skip`);
await loadAccounts();
const newIds = useLogpassStore
.getState()
.accounts.map((a) => a.id)
.filter((id) => !idsBefore.has(id));
onImportDone?.(newIds);
} catch (e: unknown) {
setResult(e instanceof Error ? e.message : t("misc.errorStr"));
} finally {
@@ -44,14 +66,25 @@ export function LogpassImportModal({ onClose }: Props) {
return (
<div className="confirm-overlay" onMouseDown={onClose}>
<div className="confirm-box max-w-lg w-full" onMouseDown={(e) => e.stopPropagation()}>
<h3 className="text-lg font-semibold mb-4">{t("modal.importAccounts")}</h3>
<div
className="confirm-box max-w-lg w-full"
onMouseDown={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold mb-4">
{t("modal.importAccounts")}
</h3>
<div className="text-sm text-gray-400 mb-3 space-y-1">
<p className="font-medium text-gray-300">{t("import.formatsColon")}</p>
<p className="font-medium text-gray-300">
{t("import.formatsColon")}
</p>
<p className="font-mono text-xs">login:pass</p>
<p className="font-mono text-xs">login|pass</p>
<p className="font-mono text-xs">CSV: login,password,steam_id,ban,prime,trophy,behavior,license</p>
<p className="text-xs text-yellow-500/80 mt-1"> Mafile-файлы и JSON не принимаются. Только log:pass / log|pass.</p>
<p className="font-mono text-xs">
CSV: login,password,steam_id,ban,prime,trophy,behavior,license
</p>
<p className="text-xs text-yellow-500/80 mt-1">
Mafile-файлы и JSON не принимаются. Только log:pass / log|pass.
</p>
</div>
<div
onDragOver={(e) => e.preventDefault()}
@@ -61,14 +94,26 @@ export function LogpassImportModal({ onClose }: Props) {
>
<IconUpload size={32} className="mx-auto mb-2 text-gray-500" />
<p className="text-sm text-gray-400">
{file ? t("import.selected", { name: file.name }) : t("import.dragTxtCsv")}
{file
? t("import.selected", { name: file.name })
: t("import.dragTxtCsv")}
</p>
</div>
<input ref={inputRef} type="file" accept=".txt,.csv" className="hidden" onChange={(e) => {
const f = e.target.files?.[0];
if (f) setFile(f);
}} />
<button onClick={doImport} disabled={!file || uploading} className="btn-primary w-full">
<input
ref={inputRef}
type="file"
accept=".txt,.csv"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) setFile(f);
}}
/>
<button
onClick={doImport}
disabled={!file || uploading}
className="btn-primary w-full"
>
{uploading ? t("import.uploading") : t("import.importBtn")}
</button>
{result && <p className="mt-3 text-sm text-gray-300">{result}</p>}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,307 @@
import { useEffect, useState } from "react";
import { api } from "@/api/client";
import type { TokenAccount, TokenCheckData } from "@/api/types";
import { copyText } from "@/lib/clipboard";
import { IconX, IconRefresh, IconLoader } from "@/components/shared/Icons";
interface Props {
account: TokenAccount;
onClose: () => void;
onRecheck: () => void;
}
function Row({
label,
value,
good,
bad,
}: {
label: string;
value: string;
good?: boolean;
bad?: boolean;
}) {
return (
<div className="flex text-[11px] font-mono leading-[1.7]">
<span className="w-[110px] shrink-0 text-gray-500">{label}</span>
<span
className={`min-w-0 break-words ${
good ? "text-green-400" : bad ? "text-red-400" : "text-gray-200"
}`}
>
{value}
</span>
</div>
);
}
function Card({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div className="rounded-lg border border-dark-600 bg-dark-900/40 px-3 py-2 space-y-0">
<div className="text-[9px] font-semibold uppercase tracking-widest text-gray-600 pt-1 pb-0.5">
{title}
</div>
{children}
</div>
);
}
function fmtPlaytime(minutes: number): string {
if (minutes <= 0) return "0ч";
const h = (minutes / 60).toFixed(1);
return `${h}ч`;
}
function fmtCheckedAt(iso: string | null): string {
if (!iso) return "—";
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
export function TokenCheckModal({ account, onClose, onRecheck }: Props) {
const [data, setData] = useState<TokenCheckData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
api
.getTokenCheckData(account.id)
.then((d) => {
if (!cancelled) {
setData(d);
setLoading(false);
}
})
.catch((e: unknown) => {
if (!cancelled) {
setError(e instanceof Error ? e.message : String(e));
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [account.id]);
const handleCopySteamId = () => {
if (data?.steam_id) copyText(data.steam_id);
};
// Close on backdrop click
const handleBackdrop = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) onClose();
};
// Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
return (
<div
className="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
onClick={handleBackdrop}
>
<div className="bg-dark-800 border border-dark-600 rounded-xl w-full max-w-[640px] max-h-[85vh] flex flex-col shadow-2xl mx-4">
{/* Header */}
<div className="px-5 pt-4 pb-3 border-b border-dark-600 flex items-start justify-between shrink-0">
<div className="flex items-start gap-3 min-w-0">
{/* Avatar */}
<div className="h-9 w-9 rounded-lg bg-dark-700 shrink-0 overflow-hidden">
{(data?.avatar_url ?? account.avatar_url) ? (
<img
src={(data?.avatar_url ?? account.avatar_url)!}
alt=""
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-dark-600" />
)}
</div>
{/* Name + meta */}
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-gray-100 truncate">
{data?.display_name ??
account.nickname ??
account.login ??
"—"}
</span>
{(data?.steam_level ?? account.steam_level) != null && (
<span className="rounded bg-accent/15 border border-accent/30 px-1.5 py-0.5 text-[9px] font-bold text-accent">
Lv.{data?.steam_level ?? account.steam_level}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-gray-500 flex-wrap">
{(data?.steam_id ?? account.steam_id) && (
<button
onClick={handleCopySteamId}
className="font-mono hover:text-gray-300 transition"
title="Копировать SteamID"
>
{data?.steam_id ?? account.steam_id}
</button>
)}
{(data?.last_online ?? account.last_online) && (
<>
<span className="text-gray-700">·</span>
<span>{data?.last_online ?? account.last_online}</span>
</>
)}
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 shrink-0 ml-3">
<button
onClick={onRecheck}
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs rounded-lg bg-accent/10 border border-accent/30 text-accent hover:bg-accent/20 transition"
title="Запустить полную проверку"
>
<IconRefresh size={12} />
Обновить
</button>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-300 transition p-1 rounded"
title="Закрыть"
>
<IconX size={16} />
</button>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-auto min-h-0 px-5 py-4">
{loading && (
<div className="flex items-center justify-center py-16">
<IconLoader size={28} className="text-accent" />
</div>
)}
{error && !loading && (
<div className="flex items-center justify-center py-16 text-sm text-red-400">
{error}
</div>
)}
{data && !loading && (
<div className="grid grid-cols-2 gap-3">
{/* Identity */}
<Card title="Identity">
{account.login && <Row label="Логин" value={account.login} />}
{data.display_name && (
<Row label="Имя" value={data.display_name} />
)}
{data.steam_id && <Row label="SteamID" value={data.steam_id} />}
{data.user_country && (
<Row label="Страна" value={data.user_country} />
)}
<Row
label="Телефон"
value={
data.phone_digits
? `заканч. на ${data.phone_digits}`
: "Нет"
}
/>
{data.created_date && (
<Row label="Создан" value={data.created_date} />
)}
</Card>
{/* Security & Status */}
<Card title="Security & Status">
<Row
label="Trade Ban"
value={
data.trade_ban && data.trade_ban !== "None"
? data.trade_ban
: "Нет"
}
good={!data.trade_ban || data.trade_ban === "None"}
bad={!!data.trade_ban && data.trade_ban !== "None"}
/>
<Row
label="Alert"
value={
data.alert_status && data.alert_status !== "None"
? data.alert_status
: "Нет"
}
good={!data.alert_status || data.alert_status === "None"}
bad={!!data.alert_status && data.alert_status !== "None"}
/>
<Row
label="Market Lim"
value={data.market_limited ? "Да" : "Нет"}
good={!data.market_limited}
bad={data.market_limited}
/>
<Row label="Family" value={data.family_group ? "Да" : "Нет"} />
</Card>
{/* Economy & Activity */}
<Card title="Economy & Activity">
{data.balance_raw && (
<Row label="Баланс" value={data.balance_raw} />
)}
{data.steam_level != null && (
<Row label="Уровень" value={String(data.steam_level)} />
)}
{data.playtime_2weeks > 0 && (
<Row
label="Время за 2 нед"
value={fmtPlaytime(data.playtime_2weeks)}
/>
)}
</Card>
{/* Inventory */}
<Card title="Inventory">
<Row
label="CS2"
value={`${data.inventory_cs2} (${data.inventory_cs2_marketable})`}
/>
<Row
label="Dota 2"
value={`${data.inventory_dota2} (${data.inventory_dota2_marketable})`}
/>
<Row
label="TF2"
value={`${data.inventory_tf2} (${data.inventory_tf2_marketable})`}
/>
<Row
label="Rust"
value={`${data.inventory_rust} (${data.inventory_rust_marketable})`}
/>
</Card>
</div>
)}
</div>
{/* Footer */}
<div className="shrink-0 px-5 py-2 border-t border-dark-600 text-[10px] text-gray-600">
{data?.checked_at
? `Последняя проверка: ${fmtCheckedAt(data.checked_at)}`
: "Данные ещё не получены"}
</div>
</div>
</div>
);
}
@@ -7,9 +7,10 @@ import { useT } from "@/lib/i18n";
interface Props {
onClose: () => void;
onImportDone?: (newIds: number[]) => void;
}
export function TokenImportModal({ onClose }: Props) {
export function TokenImportModal({ onClose, onImportDone }: Props) {
const t = useT();
const [file, setFile] = useState<File | null>(null);
const [result, setResult] = useState("");
@@ -29,12 +30,32 @@ export function TokenImportModal({ onClose }: Props) {
setUploading(true);
try {
const text = await file.text();
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
if (!lines.length) { setResult(t("toast.fileEmpty")); setUploading(false); return; }
const lines = text
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
if (!lines.length) {
setResult(t("toast.fileEmpty"));
setUploading(false);
return;
}
const idsBefore = new Set(
useTokenStore.getState().accounts.map((a) => a.id),
);
const res = await api.importTokens(lines);
setResult(t("toast.importResult", { imported: res.imported, skipped: res.skipped }));
setResult(
t("toast.importResult", {
imported: res.imported,
skipped: res.skipped,
}),
);
addToast("success", `${res.imported} ok, ${res.skipped} skip`);
await loadAccounts();
const newIds = useTokenStore
.getState()
.accounts.map((a) => a.id)
.filter((id) => !idsBefore.has(id));
onImportDone?.(newIds);
} catch (e: unknown) {
setResult(e instanceof Error ? e.message : t("misc.errorStr"));
} finally {
@@ -44,10 +65,17 @@ export function TokenImportModal({ onClose }: Props) {
return (
<div className="confirm-overlay" onMouseDown={onClose}>
<div className="confirm-box max-w-lg w-full" onMouseDown={(e) => e.stopPropagation()}>
<h3 className="text-lg font-semibold mb-4">{t("modal.importTokens")}</h3>
<div
className="confirm-box max-w-lg w-full"
onMouseDown={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold mb-4">
{t("modal.importTokens")}
</h3>
<div className="text-sm text-gray-400 mb-3 space-y-1">
<p className="font-medium text-gray-300">{t("import.formatsColon")}</p>
<p className="font-medium text-gray-300">
{t("import.formatsColon")}
</p>
<p className="font-mono text-xs">{t("import.tokenFormat")}</p>
</div>
<div
@@ -58,14 +86,26 @@ export function TokenImportModal({ onClose }: Props) {
>
<IconUpload size={32} className="mx-auto mb-2 text-gray-500" />
<p className="text-sm text-gray-400">
{file ? t("import.selected", { name: file.name }) : t("import.dragTxt")}
{file
? t("import.selected", { name: file.name })
: t("import.dragTxt")}
</p>
</div>
<input ref={inputRef} type="file" accept=".txt" className="hidden" onChange={(e) => {
const f = e.target.files?.[0];
if (f) setFile(f);
}} />
<button onClick={doImport} disabled={!file || uploading} className="btn-primary w-full">
<input
ref={inputRef}
type="file"
accept=".txt"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) setFile(f);
}}
/>
<button
onClick={doImport}
disabled={!file || uploading}
className="btn-primary w-full"
>
{uploading ? t("import.uploading") : t("import.importBtn")}
</button>
{result && <p className="mt-3 text-sm text-gray-300">{result}</p>}
+549 -132
View File
@@ -8,9 +8,21 @@ import { SteamLevelBadge } from "./SteamLevelBadge";
import { TokenImportModal } from "./TokenImportModal";
import { TokenAddModal } from "./TokenAddModal";
import { TokenColumnSettingsDropdown } from "./TokenColumnSettingsDropdown";
import { TokenCheckModal } from "./TokenCheckModal";
import {
IconDownload, IconPlus, IconTrash, IconPlay, IconGlobe, IconRefresh, IconBan,
IconCheckCircle, IconXCircle, IconCheck, IconAlertTriangle,
IconDownload,
IconPlus,
IconTrash,
IconPlay,
IconGlobe,
IconRefresh,
IconBan,
IconCheckCircle,
IconXCircle,
IconCheck,
IconAlertTriangle,
IconScrollText,
IconFileText,
} from "@/components/shared/Icons";
import type { Task } from "@/api/types";
import { useT } from "@/lib/i18n";
@@ -21,7 +33,11 @@ type AccSteps = Record<string, { step: number; total: number }>;
function parseAccResults(raw: Task["account_results"]): AccResults | null {
if (!raw) return null;
if (typeof raw === "string") {
try { return JSON.parse(raw) as AccResults; } catch { return null; }
try {
return JSON.parse(raw) as AccResults;
} catch {
return null;
}
}
return raw;
}
@@ -31,47 +47,63 @@ export function TokenTab() {
const selectedIds = useTokenStore((s) => s.selectedIds);
const loadAccounts = useTokenStore((s) => s.loadAccounts);
const toggleSelect = useTokenStore((s) => s.toggleSelect);
const selectAll = useTokenStore((s) => s.selectAll);
const clearSelection = useTokenStore((s) => s.clearSelection);
const setSelectedIds = useTokenStore((s) => s.setSelectedIds);
const addToast = useUiStore((s) => s.addToast);
const autoProxyOnImport = useUiStore((s) => s.autoProxyOnImportToken);
const autoValidateOnImport = useUiStore((s) => s.autoValidateOnImportToken);
const cols = useUiStore((s) => s.tokenColumnVisibility);
const loadTokenColumnSettings = useUiStore((s) => s.loadTokenColumnSettings);
const t = useT();
const [disclaimerAccepted, setDisclaimerAccepted] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [showImport, setShowImport] = useState(false);
const [showAdd, setShowAdd] = useState(false);
const [confirmModal, setConfirmModal] = useState<{ open: boolean; message: string; onConfirm: () => void }>({ open: false, message: "", onConfirm: () => {} });
const [confirmModal, setConfirmModal] = useState<{
open: boolean;
message: string;
onConfirm: () => void;
}>({ open: false, message: "", onConfirm: () => {} });
const [processingIds, setProcessingIds] = useState<Set<number>>(new Set());
const [accountResults, setAccountResults] = useState<AccResults>({});
const [accountSteps, setAccountSteps] = useState<AccSteps>({});
const [activeTask, setActiveTask] = useState<Task | null>(null);
const [checkModalId, setCheckModalId] = useState<number | null>(null);
useEffect(() => { loadAccounts(); loadTokenColumnSettings(); }, [loadAccounts, loadTokenColumnSettings]);
useEffect(() => {
loadAccounts();
loadTokenColumnSettings();
}, [loadAccounts, loadTokenColumnSettings]);
const confirm = (msg: string, fn: () => void) => setConfirmModal({ open: true, message: msg, onConfirm: fn });
const confirm = (msg: string, fn: () => void) =>
setConfirmModal({ open: true, message: msg, onConfirm: fn });
const watchTask = useCallback((taskId: string) => {
const es = new EventSource(`/api/tasks/${taskId}/stream`);
es.onmessage = (ev) => {
try {
const data = JSON.parse(ev.data) as Task;
setActiveTask(data);
if (data.account_results) {
const r = parseAccResults(data.account_results);
if (r) setAccountResults(r);
const watchTask = useCallback(
(taskId: string) => {
const es = new EventSource(`/api/tasks/${taskId}/stream`);
es.onmessage = (ev) => {
try {
const data = JSON.parse(ev.data) as Task;
setActiveTask(data);
if (data.account_results) {
const r = parseAccResults(data.account_results);
if (r) setAccountResults(r);
}
if (data.account_steps)
setAccountSteps(data.account_steps as AccSteps);
if (data.status !== "running") {
es.close();
setProcessingIds(new Set());
loadAccounts();
}
} catch {
/* ignore */
}
if (data.account_steps) setAccountSteps(data.account_steps as AccSteps);
if (data.status !== "running") {
es.close();
setProcessingIds(new Set());
loadAccounts();
}
} catch { /* ignore */ }
};
es.onerror = () => es.close();
}, [loadAccounts]);
};
es.onerror = () => es.close();
},
[loadAccounts],
);
const handleValidate = useCallback(async () => {
const ids = [...selectedIds];
@@ -82,7 +114,17 @@ export function TokenTab() {
setAccountSteps({});
try {
const { task_id } = await api.validateTokens(ids);
setActiveTask({ id: task_id, type: "token_validate", status: "running", progress: 0, total: ids.length, result: null, error: null, created_at: "", updated_at: "" });
setActiveTask({
id: task_id,
type: "token_validate",
status: "running",
progress: 0,
total: ids.length,
result: null,
error: null,
created_at: "",
updated_at: "",
});
watchTask(task_id);
} catch (err) {
setProcessingIds(new Set());
@@ -90,45 +132,140 @@ export function TokenTab() {
}
}, [selectedIds, clearSelection, watchTask, addToast]);
const handleValidateSingle = useCallback(async (id: number) => {
setProcessingIds(new Set([id]));
setAccountResults((r) => { const n = { ...r }; delete n[String(id)]; return n; });
setAccountSteps((s) => { const n = { ...s }; delete n[String(id)]; return n; });
const handleValidateSingle = useCallback(
async (id: number) => {
setProcessingIds(new Set([id]));
setAccountResults((r) => {
const n = { ...r };
delete n[String(id)];
return n;
});
setAccountSteps((s) => {
const n = { ...s };
delete n[String(id)];
return n;
});
try {
const { task_id } = await api.validateTokens([id]);
setActiveTask({
id: task_id,
type: "token_validate",
status: "running",
progress: 0,
total: 1,
result: null,
error: null,
created_at: "",
updated_at: "",
});
watchTask(task_id);
} catch (err) {
setProcessingIds(new Set());
addToast("error", String(err));
}
},
[watchTask, addToast],
);
const handleFullCheckSingle = useCallback(
async (id: number) => {
setProcessingIds(new Set([id]));
try {
const { task_id } = await api.fullCheckTokens([id]);
setActiveTask({
id: task_id,
type: "token_full_check",
status: "running",
progress: 0,
total: 1,
result: null,
error: null,
created_at: "",
updated_at: "",
});
watchTask(task_id);
} catch (err) {
setProcessingIds(new Set());
addToast("error", String(err));
}
},
[watchTask, addToast],
);
const handleFullCheck = useCallback(async () => {
const ids = [...selectedIds];
if (!ids.length) return;
clearSelection();
setProcessingIds(new Set(ids));
setAccountResults({});
setAccountSteps({});
try {
const { task_id } = await api.validateTokens([id]);
setActiveTask({ id: task_id, type: "token_validate", status: "running", progress: 0, total: 1, result: null, error: null, created_at: "", updated_at: "" });
const { task_id } = await api.fullCheckTokens(ids);
setActiveTask({
id: task_id,
type: "token_full_check",
status: "running",
progress: 0,
total: ids.length,
result: null,
error: null,
created_at: "",
updated_at: "",
});
watchTask(task_id);
} catch (err) {
setProcessingIds(new Set());
addToast("error", String(err));
}
}, [watchTask, addToast]);
}, [selectedIds, clearSelection, watchTask, addToast]);
const handleDownloadCookies = useCallback(
(account: (typeof accounts)[number]) => {
if (!account.has_cookies) {
addToast("warn", "Нет сохранённых куки. Сначала запустите валидацию.");
return;
}
api.downloadTokenCookies(account.id);
},
[addToast],
);
const handleDeleteSelected = () => {
const ids = [...selectedIds];
if (!ids.length) return;
confirm(t("confirm.deleteTokenSelected", { count: ids.length }), async () => {
await api.deleteTokenBulk(ids);
clearSelection();
loadAccounts();
});
confirm(
t("confirm.deleteTokenSelected", { count: ids.length }),
async () => {
await api.deleteTokenBulk(ids);
clearSelection();
loadAccounts();
},
);
};
const handleDeleteAll = () => {
confirm(t("confirm.deleteTokenAll", { count: accounts.length }), async () => {
await api.deleteTokenBulk(accounts.map((a) => a.id));
clearSelection();
loadAccounts();
});
confirm(
t("confirm.deleteTokenAll", { count: accounts.length }),
async () => {
await api.deleteTokenBulk(accounts.map((a) => a.id));
clearSelection();
loadAccounts();
},
);
};
const handleAssignProxies = () => {
confirm(t("confirm.assignProxies"), async () => {
try {
const r = await api.assignTokenProxies();
addToast("success", t("toast.proxiesAssignedShort", { count: r.assigned }));
addToast(
"success",
t("toast.proxiesAssignedShort", { count: r.assigned }),
);
loadAccounts();
} catch (e: unknown) { addToast("error", e instanceof Error ? e.message : String(e)); }
} catch (e: unknown) {
addToast("error", e instanceof Error ? e.message : String(e));
}
});
};
@@ -136,9 +273,14 @@ export function TokenTab() {
confirm(t("confirm.reassignProxiesAll"), async () => {
try {
const r = await api.reassignTokenProxies();
addToast("success", t("toast.proxiesReassignedShort", { count: r.assigned }));
addToast(
"success",
t("toast.proxiesReassignedShort", { count: r.assigned }),
);
loadAccounts();
} catch (e: unknown) { addToast("error", e instanceof Error ? e.message : String(e)); }
} catch (e: unknown) {
addToast("error", e instanceof Error ? e.message : String(e));
}
});
};
@@ -146,9 +288,14 @@ export function TokenTab() {
confirm(t("confirm.clearProxies"), async () => {
try {
const r = await api.clearTokenProxies();
addToast("success", t("toast.proxiesClearedShort", { count: r.cleared }));
addToast(
"success",
t("toast.proxiesClearedShort", { count: r.cleared }),
);
loadAccounts();
} catch (e: unknown) { addToast("error", e instanceof Error ? e.message : String(e)); }
} catch (e: unknown) {
addToast("error", e instanceof Error ? e.message : String(e));
}
});
};
@@ -159,7 +306,17 @@ export function TokenTab() {
addToast("warn", t("toast.cookiesExpiredRevalidating"));
if (result.task_id) {
setProcessingIds(new Set([id]));
setActiveTask({ id: result.task_id, type: "token_validate", status: "running", progress: 0, total: 1, result: null, error: null, created_at: "", updated_at: "" });
setActiveTask({
id: result.task_id,
type: "token_validate",
status: "running",
progress: 0,
total: 1,
result: null,
error: null,
created_at: "",
updated_at: "",
});
watchTask(result.task_id);
}
} else {
@@ -170,7 +327,61 @@ export function TokenTab() {
}
};
const allSelected = accounts.length > 0 && selectedIds.size === accounts.length;
const handleAfterTokenImport = useCallback(
async (newIds: number[]) => {
let proxyOn = autoProxyOnImport;
let validateOn = autoValidateOnImport;
try {
const fresh = await api.getValidationSettings();
proxyOn = fresh.auto_proxy_on_import_token;
validateOn = fresh.auto_validate_on_import_token;
} catch {
/* fall back to stale store values */
}
if (proxyOn) {
try {
const r = await api.assignTokenProxies();
addToast(
"success",
t("toast.proxiesAssignedShort", { count: r.assigned }),
);
await loadAccounts();
} catch {
/* ignore */
}
}
if (validateOn && newIds.length) {
try {
const { task_id } = await api.validateTokens(newIds);
setActiveTask({
id: task_id,
type: "token_validate",
status: "running",
progress: 0,
total: newIds.length,
result: null,
error: null,
created_at: "",
updated_at: "",
});
setProcessingIds(new Set(newIds));
watchTask(task_id);
} catch (e: unknown) {
setProcessingIds(new Set());
addToast("error", e instanceof Error ? e.message : String(e));
}
}
},
[
autoProxyOnImport,
autoValidateOnImport,
addToast,
loadAccounts,
watchTask,
t,
],
);
const filtered = useMemo(() => {
if (!searchQuery.trim()) return accounts;
@@ -192,14 +403,29 @@ export function TokenTab() {
}
const ql = q.toLowerCase();
return accounts.filter((a) =>
(a.login ?? "").toLowerCase().includes(ql) ||
(a.steam_id ?? "").toLowerCase().includes(ql) ||
(a.nickname ?? "").toLowerCase().includes(ql) ||
(a.notes ?? "").toLowerCase().includes(ql)
return accounts.filter(
(a) =>
(a.login ?? "").toLowerCase().includes(ql) ||
(a.steam_id ?? "").toLowerCase().includes(ql) ||
(a.nickname ?? "").toLowerCase().includes(ql) ||
(a.notes ?? "").toLowerCase().includes(ql),
);
}, [accounts, searchQuery]);
const allFilteredSelected =
filtered.length > 0 && filtered.every((a) => selectedIds.has(a.id));
const toggleSelectAllFiltered = () => {
if (allFilteredSelected) {
const next = new Set(selectedIds);
filtered.forEach((a) => next.delete(a.id));
setSelectedIds(next);
} else {
const next = new Set(selectedIds);
filtered.forEach((a) => next.add(a.id));
setSelectedIds(next);
}
};
const proxyLabels = useMemo(() => {
const map = new Map<number, string>();
const unique: string[] = [];
@@ -209,71 +435,80 @@ export function TokenTab() {
const proxyToNum = new Map<string, number>();
unique.forEach((p, i) => proxyToNum.set(p, i + 1));
for (const a of accounts) {
if (a.proxy) map.set(a.id, t("proxy.label", { num: proxyToNum.get(a.proxy) ?? 0 }));
if (a.proxy)
map.set(a.id, t("proxy.label", { num: proxyToNum.get(a.proxy) ?? 0 }));
}
return map;
}, [accounts, t]);
return (
<div className="relative flex flex-col gap-4 h-full min-h-0">
{/* Development warning gate */}
{!disclaimerAccepted && (
<div className="absolute inset-0 z-50 bg-dark-900/80 backdrop-blur-sm flex items-center justify-center">
<div className="bg-dark-800 border border-yellow-500/40 rounded-xl p-6 max-w-lg mx-4 shadow-2xl">
<div className="flex items-center gap-3 mb-4">
<IconAlertTriangle size={28} className="text-yellow-400 shrink-0" />
<h3 className="text-lg font-semibold text-yellow-400">{t("token.disclaimerTitle")}</h3>
</div>
<div className="text-sm text-gray-300 space-y-2 mb-5">
<p dangerouslySetInnerHTML={{ __html: t("token.disclaimerBody1") }} />
<p className="text-red-400 font-medium" dangerouslySetInnerHTML={{ __html: t("token.disclaimerBody2") }} />
<p dangerouslySetInnerHTML={{ __html: t("token.disclaimerBody3") }} />
</div>
<label className="flex items-center gap-2 cursor-pointer select-none group">
<input
type="checkbox"
checked={disclaimerAccepted}
onChange={(e) => setDisclaimerAccepted(e.target.checked)}
className="w-4 h-4 rounded border-yellow-500/50 accent-yellow-500"
/>
<span className="text-sm text-gray-400 group-hover:text-gray-300 transition">{t("token.acceptRisk")}</span>
</label>
</div>
</div>
)}
<div className="flex flex-col gap-4 h-full min-h-0">
{/* Toolbar */}
<div className="bg-dark-800 border border-dark-600 rounded-lg px-3 py-2 flex flex-wrap items-center gap-2 shrink-0">
<button onClick={() => setShowImport(true)} className="btn-primary"><IconDownload size={14} className="inline mr-1" />{t("btn.import")}</button>
<button onClick={() => setShowAdd(true)} className="btn-secondary"><IconPlus size={14} className="inline mr-1" />{t("btn.add")}</button>
<button onClick={() => setShowImport(true)} className="btn-primary">
<IconDownload size={14} className="inline mr-1" />
{t("btn.import")}
</button>
<button onClick={() => setShowAdd(true)} className="btn-secondary">
<IconPlus size={14} className="inline mr-1" />
{t("btn.add")}
</button>
{/* Progress indicator */}
{activeTask && (
<>
<div className="h-5 border-l border-dark-500" />
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs text-gray-400 whitespace-nowrap">{t("task.validate")}</span>
<span className="text-xs text-gray-400 whitespace-nowrap">
{t("task.validate")}
</span>
<div className="w-28 bg-dark-600 rounded-full h-2.5 shrink-0">
<div
className={`h-2.5 rounded-full transition-all duration-300 ${
activeTask.status === "completed" ? "bg-green-500" :
activeTask.status === "failed" ? "bg-red-500" : "bg-accent"
activeTask.status === "completed"
? "bg-green-500"
: activeTask.status === "failed"
? "bg-red-500"
: "bg-accent"
}`}
style={{ width: `${activeTask.total > 1
? (activeTask.total > 0 ? Math.round((activeTask.progress / activeTask.total) * 100) : 0)
: (activeTask.total_steps ? Math.round(((activeTask.step ?? 0) / activeTask.total_steps) * 100) : 0)}%` }}
style={{
width: `${
activeTask.total > 1
? activeTask.total > 0
? Math.round(
(activeTask.progress / activeTask.total) * 100,
)
: 0
: activeTask.total_steps
? Math.round(
((activeTask.step ?? 0) /
activeTask.total_steps) *
100,
)
: 0
}%`,
}}
/>
</div>
<span className="text-xs text-gray-500 whitespace-nowrap">
{activeTask.total > 1
? `${activeTask.progress}/${activeTask.total}${activeTask.active_count ? ` (${activeTask.active_count})` : ""}`
: (activeTask.step_label || `${activeTask.progress}/${activeTask.total}`)}
: activeTask.step_label ||
`${activeTask.progress}/${activeTask.total}`}
{activeTask.total > 1
? ` (${activeTask.total > 0 ? Math.round((activeTask.progress / activeTask.total) * 100) : 0}%)`
: (activeTask.total_steps ? ` (${activeTask.step}/${activeTask.total_steps})` : "")}
: activeTask.total_steps
? ` (${activeTask.step}/${activeTask.total_steps})`
: ""}
</span>
{activeTask.status === "completed" && <IconCheckCircle size={14} className="text-green-400" />}
{activeTask.status === "failed" && <span title={activeTask.error || ""}><IconXCircle size={14} className="text-red-400" /></span>}
{activeTask.status === "completed" && (
<IconCheckCircle size={14} className="text-green-400" />
)}
{activeTask.status === "failed" && (
<span title={activeTask.error || ""}>
<IconXCircle size={14} className="text-red-400" />
</span>
)}
</div>
</>
)}
@@ -281,8 +516,15 @@ export function TokenTab() {
<div className="ml-auto flex items-center gap-2">
<TokenColumnSettingsDropdown />
<div className="h-5 border-l border-dark-500" />
<button onClick={handleDeleteSelected} className="btn-danger-outline text-xs">{t("btn.deleteSelected")}</button>
<button onClick={handleDeleteAll} className="btn-danger text-xs">{t("btn.deleteAll")}</button>
<button
onClick={handleDeleteSelected}
className="btn-danger-outline text-xs"
>
{t("btn.deleteSelected")}
</button>
<button onClick={handleDeleteAll} className="btn-danger text-xs">
{t("btn.deleteAll")}
</button>
</div>
</div>
@@ -295,12 +537,39 @@ export function TokenTab() {
disabled={selectedIds.size === 0 || processingIds.size > 0}
className="btn-accent text-sm disabled:opacity-40"
>
<IconPlay size={12} className="inline mr-1" />{t("btn.validate")}
<IconPlay size={12} className="inline mr-1" />
{t("btn.validate")}
</button>
<button
onClick={handleFullCheck}
disabled={selectedIds.size === 0 || processingIds.size > 0}
className="btn-secondary text-sm disabled:opacity-40"
>
<IconScrollText size={12} className="inline mr-1" />
{t("btn.fullCheck")}
</button>
<div className="h-5 border-l border-dark-500" />
<button onClick={handleAssignProxies} className="btn-secondary text-sm"><IconGlobe size={14} className="inline mr-1" />{t("btn.assignProxies")}</button>
<button onClick={handleReassignProxies} className="btn-secondary text-sm"><IconRefresh size={14} className="inline mr-1" />{t("btn.reassign")}</button>
<button onClick={handleClearProxies} className="btn-danger-outline text-sm"><IconBan size={14} className="inline mr-1" />{t("btn.clearProxies")}</button>
<button
onClick={handleAssignProxies}
className="btn-secondary text-sm"
>
<IconGlobe size={14} className="inline mr-1" />
{t("btn.assignProxies")}
</button>
<button
onClick={handleReassignProxies}
className="btn-secondary text-sm"
>
<IconRefresh size={14} className="inline mr-1" />
{t("btn.reassign")}
</button>
<button
onClick={handleClearProxies}
className="btn-danger-outline text-sm"
>
<IconBan size={14} className="inline mr-1" />
{t("btn.clearProxies")}
</button>
<div className="ml-auto">
<input
type="text"
@@ -319,47 +588,85 @@ export function TokenTab() {
<tr className="text-left text-xs text-gray-500 border-b border-dark-600">
<th className="px-3 py-2">
<div className="flex items-center gap-1.5">
<input type="checkbox" checked={allSelected} onChange={() => allSelected ? clearSelection() : selectAll()} />
{selectedIds.size > 0 && <span className="text-accent font-medium">{selectedIds.size}</span>}
<input
type="checkbox"
checked={allFilteredSelected}
onChange={toggleSelectAllFiltered}
/>
{selectedIds.size > 0 && (
<span className="text-accent font-medium">
{selectedIds.size}
</span>
)}
</div>
</th>
<th className="w-5" />
{cols.browser && <th className="px-3 py-2">{t("col.browser")}</th>}
{cols.profile && <th className="px-3 py-2">{t("col.profile")}</th>}
{cols.last_online && <th className="px-3 py-2">{t("col.lastOnline")}</th>}
{cols.browser && (
<th className="px-3 py-2">{t("col.browser")}</th>
)}
{cols.profile && (
<th className="px-3 py-2">{t("col.profile")}</th>
)}
{cols.last_online && (
<th className="px-3 py-2">{t("col.lastOnline")}</th>
)}
{cols.steam_id && <th className="px-3 py-2">Steam ID</th>}
{cols.login && <th className="px-3 py-2">{t("col.login")}</th>}
{cols.token && <th className="px-3 py-2">{t("col.token")}</th>}
{cols.status && <th className="px-3 py-2">{t("col.status")}</th>}
{cols.status && (
<th className="px-3 py-2">{t("col.status")}</th>
)}
{cols.proxy && <th className="px-3 py-2">{t("col.proxy")}</th>}
{cols.notes && <th className="px-3 py-2">{t("col.notes")}</th>}
{cols.actions && <th className="px-3 py-2">{t("col.actions")}</th>}
{cols.actions && (
<th className="px-3 py-2">{t("col.actions")}</th>
)}
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr><td colSpan={12} className="text-center py-12 text-gray-500">{t("empty.tokens")}</td></tr>
<tr>
<td colSpan={12} className="text-center py-12 text-gray-500">
{t("empty.tokens")}
</td>
</tr>
) : (
filtered.map((a) => {
const isProcessing = processingIds.has(a.id);
const result = accountResults[String(a.id)];
const step = accountSteps[String(a.id)];
return (
<tr key={a.id} className="hover:bg-dark-700/50 transition border-t border-dark-700">
<tr
key={a.id}
className="hover:bg-dark-700/50 transition border-t border-dark-700"
>
<td className="px-3 py-2">
<input type="checkbox" checked={selectedIds.has(a.id)} onChange={() => toggleSelect(a.id)} />
<input
type="checkbox"
checked={selectedIds.has(a.id)}
onChange={() => toggleSelect(a.id)}
/>
</td>
{/* Status icon */}
<td className="px-1 py-2 w-5">
{isProcessing ? (
<div className="flex items-center gap-1">
<div className="w-4 h-4 border-2 border-accent border-t-transparent rounded-full animate-spin shrink-0" />
{step && <span className="text-[10px] text-gray-500 whitespace-nowrap">[{step.step}/{step.total}]</span>}
{step && (
<span className="text-[10px] text-gray-500 whitespace-nowrap">
[{step.step}/{step.total}]
</span>
)}
</div>
) : result?.status === "ok" ? (
<IconCheck size={16} className="text-gray-400" />
) : result?.status === "error" ? (
<span title={result.error}><IconAlertTriangle size={16} className="text-red-400" /></span>
<span title={result.error}>
<IconAlertTriangle
size={16}
className="text-red-400"
/>
</span>
) : null}
</td>
{cols.browser && (
@@ -369,7 +676,9 @@ export function TokenTab() {
onClick={() => handleOpenBrowser(a.id)}
className="px-2 py-0.5 text-xs font-medium rounded bg-blue-600/20 text-blue-400 border border-blue-500/30 hover:bg-blue-600/40 hover:text-blue-300 active:scale-95 transition"
title={t("tip.openBrowser")}
>{t("col.browser")}</button>
>
{t("col.browser")}
</button>
) : (
<span className="text-gray-600 text-xs"></span>
)}
@@ -378,27 +687,69 @@ export function TokenTab() {
{cols.profile && (
<td className="px-3 py-2 whitespace-nowrap">
<div className="flex items-center gap-1.5">
{a.avatar_url && <img src={a.avatar_url} className="avatar-sm" alt="" />}
{a.avatar_url && (
<img
src={a.avatar_url}
className="avatar-sm"
alt=""
/>
)}
<span className="text-xs">{a.nickname || "—"}</span>
{a.steam_level != null && <SteamLevelBadge level={a.steam_level} />}
{a.steam_level != null && (
<SteamLevelBadge level={a.steam_level} />
)}
</div>
</td>
)}
{cols.last_online && <td className="px-3 py-2 text-xs text-gray-400 whitespace-nowrap">{a.last_online || "—"}</td>}
{cols.last_online && (
<td className="px-3 py-2 text-xs text-gray-400 whitespace-nowrap">
{a.last_online || "—"}
</td>
)}
{cols.steam_id && (
<td className="px-3 py-2 text-xs">
{a.steam_id ? (
<a href={`https://steamcommunity.com/profiles/${encodeURIComponent(a.steam_id)}/`} target="_blank" rel="noopener noreferrer" className="text-blue-400 hover:text-blue-300 hover:underline">
<a
href={`https://steamcommunity.com/profiles/${encodeURIComponent(a.steam_id)}/`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:text-blue-300 hover:underline"
>
{a.steam_id}
</a>
) : "—"}
) : (
"—"
)}
</td>
)}
{cols.login && (
<td className="px-3 py-2 font-medium">
{a.login ?? "—"}
</td>
)}
{cols.token && (
<td
className="px-3 py-2 font-mono text-gray-400 truncate max-w-[180px]"
title={a.token}
>
{a.token.slice(0, 24)}
</td>
)}
{cols.status && (
<td className="px-3 py-2">
<StatusBadge status={a.status} />
</td>
)}
{cols.proxy && (
<td className="px-3 py-2 text-xs text-gray-500 whitespace-nowrap">
{proxyLabels.get(a.id) ?? "—"}
</td>
)}
{cols.notes && (
<td className="px-3 py-2 text-xs text-gray-500">
{a.notes ?? "—"}
</td>
)}
{cols.login && <td className="px-3 py-2 font-medium">{a.login ?? "—"}</td>}
{cols.token && <td className="px-3 py-2 font-mono text-gray-400 truncate max-w-[180px]" title={a.token}>{a.token.slice(0, 24)}</td>}
{cols.status && <td className="px-3 py-2"><StatusBadge status={a.status} /></td>}
{cols.proxy && <td className="px-3 py-2 text-xs text-gray-500 whitespace-nowrap">{proxyLabels.get(a.id) ?? "—"}</td>}
{cols.notes && <td className="px-3 py-2 text-xs text-gray-500">{a.notes ?? "—"}</td>}
{cols.actions && (
<td className="px-3 py-2 whitespace-nowrap">
<div className="flex items-center gap-1.5">
@@ -407,12 +758,56 @@ export function TokenTab() {
disabled={isProcessing}
className="text-gray-400 hover:text-accent disabled:opacity-40 transition"
title={t("tip.validate")}
><IconPlay size={14} /></button>
>
<IconPlay size={14} />
</button>
<button
onClick={() => confirm(t("confirm.deleteToken", { name: a.login ?? a.token.slice(0, 16) }), async () => { await api.deleteTokenAccount(a.id); loadAccounts(); })}
onClick={() => handleFullCheckSingle(a.id)}
disabled={isProcessing}
className="text-gray-400 hover:text-accent disabled:opacity-40 transition"
title={t("btn.fullCheck")}
>
<IconScrollText size={14} />
</button>
<button
onClick={() => setCheckModalId(a.id)}
className="text-gray-400 hover:text-blue-400 transition"
title="Детали проверки"
>
<IconFileText size={14} />
</button>
<button
onClick={() => handleDownloadCookies(a)}
className={`transition ${
a.has_cookies
? "text-gray-400 hover:text-green-400"
: "text-gray-700 cursor-not-allowed"
}`}
title={
a.has_cookies
? "Скачать куки"
: "Куки отсутствуют"
}
>
<IconDownload size={14} />
</button>
<button
onClick={() =>
confirm(
t("confirm.deleteToken", {
name: a.login ?? a.token.slice(0, 16),
}),
async () => {
await api.deleteTokenAccount(a.id);
loadAccounts();
},
)
}
className="text-gray-400 hover:text-red-400 transition"
title={t("tip.delete")}
><IconTrash size={14} /></button>
>
<IconTrash size={14} />
</button>
</div>
</td>
)}
@@ -425,12 +820,34 @@ export function TokenTab() {
</div>
</div>
{showImport && <TokenImportModal onClose={() => setShowImport(false)} />}
{showImport && (
<TokenImportModal
onClose={() => setShowImport(false)}
onImportDone={handleAfterTokenImport}
/>
)}
{showAdd && <TokenAddModal onClose={() => setShowAdd(false)} />}
{checkModalId != null &&
(() => {
const acc = accounts.find((a) => a.id === checkModalId);
return acc ? (
<TokenCheckModal
account={acc}
onClose={() => setCheckModalId(null)}
onRecheck={() => {
setCheckModalId(null);
handleFullCheckSingle(acc.id);
}}
/>
) : null;
})()}
{confirmModal.open && (
<ConfirmModal
message={confirmModal.message}
onConfirm={() => { confirmModal.onConfirm(); setConfirmModal((p) => ({ ...p, open: false })); }}
onConfirm={() => {
confirmModal.onConfirm();
setConfirmModal((p) => ({ ...p, open: false }));
}}
onCancel={() => setConfirmModal((p) => ({ ...p, open: false }))}
/>
)}
@@ -5,16 +5,31 @@ import { useUiStore } from "@/stores/uiStore";
import { useT } from "@/lib/i18n";
import { IconSettings } from "@/components/shared/Icons";
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
function Toggle({
checked,
onChange,
}: {
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<label className="toggle-switch">
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
/>
<span className="toggle-track" />
</label>
);
}
function SettingRow({ label, desc, checked, onChange }: {
function SettingRow({
label,
desc,
checked,
onChange,
}: {
label: string;
desc?: string;
checked: boolean;
@@ -31,7 +46,13 @@ function SettingRow({ label, desc, checked, onChange }: {
);
}
function ThreadStepper({ value, onChange }: { value: number; onChange: (v: number) => void }) {
function ThreadStepper({
value,
onChange,
}: {
value: number;
onChange: (v: number) => void;
}) {
const [editing, setEditing] = useState(false);
const [input, setInput] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
@@ -42,14 +63,32 @@ function ThreadStepper({ value, onChange }: { value: number; onChange: (v: numbe
setEditing(false);
};
const btnCls = "h-6 px-1.5 rounded bg-dark-700 border border-dark-600 text-gray-300 hover:bg-dark-600 text-xs flex items-center justify-center select-none leading-none";
const btnCls =
"h-6 px-1.5 rounded bg-dark-700 border border-dark-600 text-gray-300 hover:bg-dark-600 text-xs flex items-center justify-center select-none leading-none";
return (
<div className="flex items-center gap-3 pt-3 mt-0.5">
<span className="text-sm text-gray-400 flex-1" title="Двойной клик на числе для ручного ввода">Max threads</span>
<span
className="text-sm text-gray-400 flex-1"
title="Двойной клик на числе для ручного ввода"
>
Max threads
</span>
<div className="flex items-center gap-1">
<button type="button" className={btnCls} onClick={() => onChange(clamp(value - 10))}>-10</button>
<button type="button" className={btnCls} onClick={() => onChange(clamp(value - 1))}>-1</button>
<button
type="button"
className={btnCls}
onClick={() => onChange(clamp(value - 10))}
>
-10
</button>
<button
type="button"
className={btnCls}
onClick={() => onChange(clamp(value - 1))}
>
-1
</button>
{editing ? (
<input
ref={inputRef}
@@ -57,18 +96,38 @@ function ThreadStepper({ value, onChange }: { value: number; onChange: (v: numbe
value={input}
onChange={(e) => setInput(e.target.value)}
onBlur={commit}
onKeyDown={(e) => { if (e.key === "Enter") commit(); if (e.key === "Escape") setEditing(false); }}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") setEditing(false);
}}
className="w-10 text-center text-sm font-mono bg-dark-900 border border-accent rounded outline-none text-gray-100 py-0.5"
/>
) : (
<span
className="w-10 text-center text-sm font-mono text-gray-100 cursor-pointer select-none"
onDoubleClick={() => { setInput(String(value)); setEditing(true); }}
onDoubleClick={() => {
setInput(String(value));
setEditing(true);
}}
title="Двойной клик для ручного ввода"
>{value}</span>
>
{value}
</span>
)}
<button type="button" className={btnCls} onClick={() => onChange(clamp(value + 1))}>+1</button>
<button type="button" className={btnCls} onClick={() => onChange(clamp(value + 10))}>+10</button>
<button
type="button"
className={btnCls}
onClick={() => onChange(clamp(value + 1))}
>
+1
</button>
<button
type="button"
className={btnCls}
onClick={() => onChange(clamp(value + 10))}
>
+10
</button>
</div>
</div>
);
@@ -81,21 +140,40 @@ export function ValidationSettings() {
check_ban: true,
max_threads: 5,
auto_revalidate_browser: true,
auto_proxy_on_import: false,
auto_validate_on_import: false,
auto_proxy_on_import_mafile: true,
auto_proxy_on_import_logpass: true,
auto_proxy_on_import_token: true,
auto_validate_on_import_mafile: true,
auto_validate_on_import_logpass: true,
auto_validate_on_import_token: true,
});
const [importTab, setImportTab] = useState<"mafile" | "logpass" | "token">(
"mafile",
);
const hidePasswords = useUiStore((s) => s.hidePasswords);
const setHidePasswords = useUiStore((s) => s.setHidePasswords);
const loadImportSettings = useUiStore((s) => s.loadImportSettings);
const addToast = useUiStore((s) => s.addToast);
useEffect(() => {
api.getValidationSettings().then(setSettings).catch(() => {});
api
.getValidationSettings()
.then(setSettings)
.catch(() => {});
}, []);
const save = async () => {
try {
await api.updateValidationSettings(settings);
await loadImportSettings(); // sync stale uiStore values
addToast("success", t("toast.settingsSaved"));
} catch (e: unknown) {
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
addToast(
"error",
t("misc.error", { error: e instanceof Error ? e.message : String(e) }),
);
}
};
@@ -124,10 +202,75 @@ export function ValidationSettings() {
<SettingRow
label={t("tools.autoRevalidateBrowser")}
checked={settings.auto_revalidate_browser}
onChange={(v) => setSettings({ ...settings, auto_revalidate_browser: v })}
onChange={(v) =>
setSettings({ ...settings, auto_revalidate_browser: v })
}
/>
</div>
<ThreadStepper value={settings.max_threads} onChange={(v) => setSettings((s) => ({ ...s, max_threads: v }))} />
<ThreadStepper
value={settings.max_threads}
onChange={(v) => setSettings((s) => ({ ...s, max_threads: v }))}
/>
</div>
<div className="px-5 pt-4 pb-3 border-b border-dark-600">
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-widest mb-1.5">
{t("tools.importSettings")}
</p>
{/* Tab switcher */}
<div className="flex gap-1 mb-3">
{(["mafile", "logpass", "token"] as const).map((tab) => (
<button
key={tab}
type="button"
onClick={() => setImportTab(tab)}
className={`px-2.5 py-1 rounded text-xs font-medium transition-colors ${
importTab === tab
? "bg-accent/20 text-accent border border-accent/30"
: "text-gray-500 hover:text-gray-300 border border-transparent"
}`}
>
{t(
`tools.importTab${tab.charAt(0).toUpperCase() + tab.slice(1)}` as never,
)}
</button>
))}
</div>
{/* Per-tab toggles */}
{(["mafile", "logpass", "token"] as const).map((tab) =>
importTab === tab ? (
<div key={tab} className="divide-y divide-dark-600/60">
<SettingRow
label={t("tools.autoProxyOnImport")}
desc={t("tools.autoProxyOnImportDesc")}
checked={
settings[`auto_proxy_on_import_${tab}` as keyof VS] as boolean
}
onChange={(v) =>
setSettings({
...settings,
[`auto_proxy_on_import_${tab}`]: v,
})
}
/>
<SettingRow
label={t("tools.autoValidateOnImport")}
desc={t("tools.autoValidateOnImportDesc")}
checked={
settings[
`auto_validate_on_import_${tab}` as keyof VS
] as boolean
}
onChange={(v) =>
setSettings({
...settings,
[`auto_validate_on_import_${tab}`]: v,
})
}
/>
</div>
) : null,
)}
</div>
<div className="px-5 pt-4 pb-3">
@@ -142,7 +285,9 @@ export function ValidationSettings() {
</div>
<div className="px-5 py-3 border-t border-dark-600 flex justify-end">
<button onClick={save} className="btn-primary">{t("btn.save")}</button>
<button onClick={save} className="btn-primary">
{t("btn.save")}
</button>
</div>
</div>
);