mirror of
https://github.com/daimyomizukagebay61/SteamPanel.git
synced 2026-07-25 11:44:28 +00:00
v3.1.0
This commit is contained in:
Generated
+7
@@ -8,6 +8,7 @@
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"flag-icons": "^7.5.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
@@ -2342,6 +2343,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/flag-icons": {
|
||||
"version": "7.5.0",
|
||||
"resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz",
|
||||
"integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/flat-cache": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"flag-icons": "^7.5.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
MafileInfo,
|
||||
MafileExportRequest,
|
||||
ActionRequest,
|
||||
ServicesSettings,
|
||||
ValidationSettings,
|
||||
DisplaySettings,
|
||||
ColumnSettings,
|
||||
@@ -193,6 +194,13 @@ export const api = {
|
||||
}),
|
||||
|
||||
// Settings
|
||||
getServicesSettings: () =>
|
||||
request<ServicesSettings>("/settings/services"),
|
||||
updateServicesSettings: (data: ServicesSettings) =>
|
||||
request<ServicesSettings>("/settings/services", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
getValidationSettings: () =>
|
||||
request<ValidationSettings>("/settings/validation"),
|
||||
updateValidationSettings: (data: ValidationSettings) =>
|
||||
@@ -223,6 +231,20 @@ export const api = {
|
||||
// Logs
|
||||
getLogs: () => request<LogEntry[]>("/logs"),
|
||||
|
||||
// Auto-confirm
|
||||
startAutoConfirm: (ids: number[]) =>
|
||||
request<{ started: number[] }>("/auto-confirm/start", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ account_ids: ids }),
|
||||
}),
|
||||
stopAutoConfirm: (ids: number[]) =>
|
||||
request<{ stopped: number[] }>("/auto-confirm/stop", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ account_ids: ids }),
|
||||
}),
|
||||
getAutoConfirmStatus: () =>
|
||||
request<{ running: number[]; errors: Record<string, string> }>("/auto-confirm/status"),
|
||||
|
||||
// Auto-accept
|
||||
startAutoAccept: (ids: number[]) =>
|
||||
request<{ started: number[] }>("/auto-accept/start", {
|
||||
|
||||
@@ -17,7 +17,13 @@ export interface Account {
|
||||
steam_level: number | null;
|
||||
last_online: string | null;
|
||||
auto_accept: number;
|
||||
auto_confirm: number;
|
||||
ban_status: string | null;
|
||||
vac_status: string | null;
|
||||
limit_status: string | null;
|
||||
vac_games: string | null;
|
||||
balance: string | null;
|
||||
country: string | null;
|
||||
has_cookies: boolean;
|
||||
has_revocation_code: boolean;
|
||||
created_at: string;
|
||||
@@ -131,6 +137,11 @@ export interface ValidationSettings {
|
||||
auto_validate_on_import_token: boolean;
|
||||
}
|
||||
|
||||
export interface ServicesSettings {
|
||||
auto_accept_interval: number;
|
||||
auto_confirm_interval: number;
|
||||
}
|
||||
|
||||
export interface DisplaySettings {
|
||||
hide_passwords: boolean;
|
||||
}
|
||||
@@ -148,12 +159,16 @@ export interface ColumnSettings {
|
||||
phone: boolean;
|
||||
status: boolean;
|
||||
ban: boolean;
|
||||
vac: boolean;
|
||||
limit: boolean;
|
||||
twofa: boolean;
|
||||
mafile: boolean;
|
||||
proxy: boolean;
|
||||
notes: boolean;
|
||||
actions: boolean;
|
||||
last_online: boolean;
|
||||
balance: boolean;
|
||||
country: boolean;
|
||||
}
|
||||
|
||||
export interface LogpassColumnSettings {
|
||||
@@ -165,6 +180,8 @@ export interface LogpassColumnSettings {
|
||||
login_pass: boolean;
|
||||
status: boolean;
|
||||
ban: boolean;
|
||||
vac: boolean;
|
||||
limit: boolean;
|
||||
prime: boolean;
|
||||
trophy: boolean;
|
||||
behavior: boolean;
|
||||
@@ -173,6 +190,8 @@ export interface LogpassColumnSettings {
|
||||
notes: boolean;
|
||||
actions: boolean;
|
||||
last_online: boolean;
|
||||
balance: boolean;
|
||||
country: boolean;
|
||||
}
|
||||
|
||||
export interface TokenColumnSettings {
|
||||
@@ -183,9 +202,14 @@ export interface TokenColumnSettings {
|
||||
login: boolean;
|
||||
token: boolean;
|
||||
status: boolean;
|
||||
ban: boolean;
|
||||
vac: boolean;
|
||||
limit: boolean;
|
||||
proxy: boolean;
|
||||
notes: boolean;
|
||||
actions: boolean;
|
||||
balance: boolean;
|
||||
country: boolean;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
@@ -203,6 +227,11 @@ export interface LogpassAccount {
|
||||
proxy: string | null;
|
||||
status: string;
|
||||
ban_status: string | null;
|
||||
vac_status: string | null;
|
||||
limit_status: string | null;
|
||||
vac_games: string | null;
|
||||
balance: string | null;
|
||||
country: string | null;
|
||||
nickname: string | null;
|
||||
steam_level: number | null;
|
||||
prime: string | null;
|
||||
@@ -242,6 +271,11 @@ export interface TokenAccount {
|
||||
proxy: string | null;
|
||||
status: string;
|
||||
ban_status: string | null;
|
||||
vac_status: string | null;
|
||||
limit_status: string | null;
|
||||
vac_games: string | null;
|
||||
balance: string | null;
|
||||
country: string | null;
|
||||
nickname: string | null;
|
||||
steam_level: number | null;
|
||||
avatar_url: string | null;
|
||||
|
||||
@@ -4,10 +4,10 @@ import { useAccountStore } from "@/stores/accountStore";
|
||||
import { copyText } from "@/lib/clipboard";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import { api } from "@/api/client";
|
||||
import { StatusBadge, BanBadge, MafileBadge } from "./Badges";
|
||||
import { StatusBadge, BanBadge, MafileBadge, VacBadge, LimitBadge, CountryBadge } from "./Badges";
|
||||
import { ActionMenu } from "./ActionMenu";
|
||||
import { SteamLevelBadge } from "./SteamLevelBadge";
|
||||
import { IconCheck, IconAlertTriangle, IconEye, IconEyeOff, IconKey, IconRefresh, IconEdit, IconTrash } from "@/components/shared/Icons";
|
||||
import { IconCheck, IconCheckCircle, IconAlertTriangle, IconEye, IconEyeOff, IconKey, IconRefresh, IconEdit, IconTrash } from "@/components/shared/Icons";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
interface Props {
|
||||
@@ -21,10 +21,11 @@ interface Props {
|
||||
onDelete: (id: number) => void;
|
||||
onAction: (id: number, action: string, params: Record<string, string>) => void;
|
||||
onToggleAutoAccept: (id: number) => void;
|
||||
onToggleAutoConfirm: (id: number) => void;
|
||||
onOpenBrowser: (id: number) => void;
|
||||
}
|
||||
|
||||
export function AccountRow({ account: a, visibleColumns: v, proxyLabel, isProcessing, accountResult, accountStep, onEdit, onDelete, onAction, onToggleAutoAccept, onOpenBrowser }: Props) {
|
||||
export function AccountRow({ account: a, visibleColumns: v, proxyLabel, isProcessing, accountResult, accountStep, onEdit, onDelete, onAction, onToggleAutoAccept, onToggleAutoConfirm, onOpenBrowser }: Props) {
|
||||
const selectedIds = useAccountStore((s) => s.selectedIds);
|
||||
const toggleSelect = useAccountStore((s) => s.toggleSelect);
|
||||
const addToast = useUiStore((s) => s.addToast);
|
||||
@@ -192,6 +193,18 @@ export function AccountRow({ account: a, visibleColumns: v, proxyLabel, isProces
|
||||
{v.phone && <td className="px-3 py-2 text-gray-400 text-xs">{a.phone || "—"}</td>}
|
||||
{v.status && <td className="px-3 py-2"><StatusBadge status={a.status} /></td>}
|
||||
{v.ban && <td className="px-3 py-2"><BanBadge status={a.ban_status} /></td>}
|
||||
{v.vac && <td className="px-3 py-2"><VacBadge status={a.vac_status} games={a.vac_games} /></td>}
|
||||
{v.limit && <td className="px-3 py-2"><LimitBadge status={a.limit_status} /></td>}
|
||||
{v.balance && (
|
||||
<td className="px-3 py-2 text-xs font-mono text-gray-300 whitespace-nowrap">
|
||||
{a.balance || "—"}
|
||||
</td>
|
||||
)}
|
||||
{v.country && (
|
||||
<td className="px-3 py-2">
|
||||
<CountryBadge country={a.country} />
|
||||
</td>
|
||||
)}
|
||||
{v.twofa && (
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
{a.shared_secret ? (
|
||||
@@ -269,11 +282,26 @@ export function AccountRow({ account: a, visibleColumns: v, proxyLabel, isProces
|
||||
{v.actions && (
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ActionMenu account={a} onAction={onAction} onToggleAutoAccept={onToggleAutoAccept} />
|
||||
<ActionMenu account={a} onAction={onAction} onToggleAutoAccept={onToggleAutoAccept} onToggleAutoConfirm={onToggleAutoConfirm} />
|
||||
<button onClick={() => onEdit(a.id)} className="text-gray-400 hover:text-accent" title={t("tip.edit")}><IconEdit size={14} /></button>
|
||||
<button onClick={() => onDelete(a.id)} className="text-gray-400 hover:text-red-400" title={t("tip.delete")}><IconTrash size={14} /></button>
|
||||
{a.auto_accept ? (
|
||||
<span title={t("tip.autoAcceptActive")}><IconRefresh size={14} className="text-accent" /></span>
|
||||
<span
|
||||
title={t("tip.autoAcceptActive")}
|
||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-accent/20 border border-accent/50 text-accent text-[10px] font-bold leading-none"
|
||||
>
|
||||
<IconRefresh size={10} />
|
||||
AA
|
||||
</span>
|
||||
) : null}
|
||||
{a.auto_confirm ? (
|
||||
<span
|
||||
title={t("tip.autoConfirmActive")}
|
||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-green-500/20 border border-green-500/50 text-green-400 text-[10px] font-bold leading-none"
|
||||
>
|
||||
<IconCheckCircle size={10} />
|
||||
AC
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -19,6 +19,10 @@ const COLUMN_KEYS: (keyof ColumnSettings)[] = [
|
||||
"phone",
|
||||
"status",
|
||||
"ban",
|
||||
"vac",
|
||||
"limit",
|
||||
"balance",
|
||||
"country",
|
||||
"twofa",
|
||||
"mafile",
|
||||
"proxy",
|
||||
@@ -40,6 +44,10 @@ const COL_LABEL_KEYS: Record<keyof ColumnSettings, string> = {
|
||||
phone: "col.phone",
|
||||
status: "col.status",
|
||||
ban: "col.ban",
|
||||
vac: "col.vac",
|
||||
limit: "col.limit",
|
||||
balance: "col.balance",
|
||||
country: "col.country",
|
||||
twofa: "col.twofa",
|
||||
mafile: "col.mafile",
|
||||
proxy: "col.proxy",
|
||||
@@ -60,6 +68,7 @@ interface Props {
|
||||
params: Record<string, string>,
|
||||
) => void;
|
||||
onToggleAutoAccept: (id: number) => void;
|
||||
onToggleAutoConfirm: (id: number) => void;
|
||||
onOpenBrowser: (id: number) => void;
|
||||
}
|
||||
|
||||
@@ -72,6 +81,7 @@ export function AccountTable({
|
||||
onDelete,
|
||||
onAction,
|
||||
onToggleAutoAccept,
|
||||
onToggleAutoConfirm,
|
||||
onOpenBrowser,
|
||||
}: Props) {
|
||||
const selectedIds = useAccountStore((s) => s.selectedIds);
|
||||
@@ -101,46 +111,56 @@ export function AccountTable({
|
||||
const [visibleCount, setVisibleCount] = useState(BATCH);
|
||||
const sentinelRef = useRef<HTMLTableRowElement>(null);
|
||||
|
||||
type SortField = "last_online" | null;
|
||||
type SortField = "last_online" | "ban" | "vac" | "limit" | null;
|
||||
type SortDir = "asc" | "desc";
|
||||
const [sortField, setSortField] = useState<SortField>(null);
|
||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||
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");
|
||||
}
|
||||
};
|
||||
|
||||
const STATUS_ORDER: Record<string, number> = {
|
||||
BANNED: 0, "GAME BAN": 0, VAC: 1, Lim: 0, NoLim: 1,
|
||||
"NO BAN": 2, CLEAN: 2,
|
||||
};
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (!sortField) return accounts;
|
||||
const dir = sortDir === "asc" ? 1 : -1;
|
||||
const EMPTY = Symbol();
|
||||
const EMPTY = 999;
|
||||
return [...accounts].sort((a, b) => {
|
||||
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);
|
||||
if (!m) return EMPTY;
|
||||
const n = parseInt(m[1], 10);
|
||||
if (m[2] === "m") return n;
|
||||
if (m[2] === "h") return n * 60;
|
||||
return n * 1440;
|
||||
};
|
||||
const va = parseOnline(a.last_online);
|
||||
const vb = parseOnline(b.last_online);
|
||||
if (va === EMPTY && vb === EMPTY) return 0;
|
||||
let va: number, vb: number;
|
||||
if (sortField === "last_online") {
|
||||
const parse = (v: string | null | undefined): number => {
|
||||
if (!v || v === "\u2014") return EMPTY;
|
||||
if (v === "online") return -1;
|
||||
const m = v.match(/^(\d+)([mhd])$/i);
|
||||
if (!m) return EMPTY;
|
||||
const n = parseInt(m[1], 10);
|
||||
if (m[2] === "m") return n;
|
||||
if (m[2] === "h") return n * 60;
|
||||
return n * 1440;
|
||||
};
|
||||
va = parse(a.last_online); vb = parse(b.last_online);
|
||||
} else if (sortField === "ban") {
|
||||
va = STATUS_ORDER[a.ban_status ?? ""] ?? EMPTY;
|
||||
vb = STATUS_ORDER[b.ban_status ?? ""] ?? EMPTY;
|
||||
} else if (sortField === "vac") {
|
||||
va = STATUS_ORDER[a.vac_status ?? ""] ?? EMPTY;
|
||||
vb = STATUS_ORDER[b.vac_status ?? ""] ?? EMPTY;
|
||||
} else {
|
||||
va = STATUS_ORDER[a.limit_status ?? ""] ?? EMPTY;
|
||||
vb = STATUS_ORDER[b.limit_status ?? ""] ?? EMPTY;
|
||||
}
|
||||
if (va === vb) return 0;
|
||||
if (va === EMPTY) return 1;
|
||||
if (vb === EMPTY) return -1;
|
||||
if (va === vb) return 0;
|
||||
return (va < vb ? -1 : 1) * dir;
|
||||
});
|
||||
}, [accounts, sortField, sortDir]);
|
||||
@@ -149,6 +169,7 @@ export function AccountTable({
|
||||
setVisibleCount(BATCH);
|
||||
}, [accounts, sortField, sortDir]);
|
||||
|
||||
|
||||
const visible = useMemo(
|
||||
() => sorted.slice(0, visibleCount),
|
||||
[sorted, visibleCount],
|
||||
@@ -217,19 +238,18 @@ export function AccountTable({
|
||||
</th>
|
||||
<th className="w-5"></th>
|
||||
{visibleKeys.map((k) => {
|
||||
if (k === "last_online") {
|
||||
const sortKey = (k === "ban" || k === "vac" || k === "limit" || k === "last_online")
|
||||
? k as SortField
|
||||
: null;
|
||||
if (sortKey) {
|
||||
return (
|
||||
<th
|
||||
key={k}
|
||||
className="px-3 py-2 cursor-pointer select-none hover:text-gray-300"
|
||||
onClick={() => toggleSort("last_online")}
|
||||
onClick={() => toggleSort(sortKey)}
|
||||
>
|
||||
{t(COL_LABEL_KEYS[k])}
|
||||
{sortField === "last_online"
|
||||
? sortDir === "asc"
|
||||
? " ▲"
|
||||
: " ▼"
|
||||
: ""}
|
||||
{sortField === sortKey ? (sortDir === "asc" ? " ▲" : " ▼") : ""}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
@@ -258,6 +278,7 @@ export function AccountTable({
|
||||
onDelete={onDelete}
|
||||
onAction={onAction}
|
||||
onToggleAutoAccept={onToggleAutoAccept}
|
||||
onToggleAutoConfirm={onToggleAutoConfirm}
|
||||
onOpenBrowser={onOpenBrowser}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -306,11 +306,49 @@ export function AccountsTab() {
|
||||
});
|
||||
}
|
||||
|
||||
const ql = q.toLowerCase();
|
||||
const ql = q.toLowerCase().trim();
|
||||
|
||||
if (ql === "vac") return accounts.filter((a) => a.vac_status === "VAC" || a.vac_status === "GAME BAN");
|
||||
if (ql === "gameban" || ql === "game ban") return accounts.filter((a) => a.vac_status === "GAME BAN");
|
||||
if (ql === "clean") return accounts.filter((a) => a.vac_status === "CLEAN");
|
||||
if (ql === "lim") return accounts.filter((a) => a.limit_status === "Lim");
|
||||
if (ql === "nolim") return accounts.filter((a) => a.limit_status === "NoLim");
|
||||
if (ql === "ban" || ql === "banned") return accounts.filter((a) => a.ban_status === "BANNED");
|
||||
if (ql === "noban" || ql === "no ban") return accounts.filter((a) => a.ban_status === "NO BAN");
|
||||
|
||||
// Country filter: "country:us" or "country:united states"
|
||||
const countryMatch = ql.match(/^country:(.+)$/);
|
||||
if (countryMatch) {
|
||||
const cv = countryMatch[1].trim().toLowerCase();
|
||||
return accounts.filter((a) => a.country?.toLowerCase().includes(cv));
|
||||
}
|
||||
|
||||
// Balance filter: "balance>10", "usd>10", "balance<5", etc.
|
||||
const balMatch = ql.match(/^(?:balance|usd|eur|rub|cny|gbp|cad|aud|brl|try|jpy|krw|inr|pln|nok|sek|dkk|chf|hkd|sgd|nzd|mxn|vnd)([<>]=?)(\d+\.?\d*)$/);
|
||||
if (balMatch) {
|
||||
const op = balMatch[1];
|
||||
const threshold = parseFloat(balMatch[2]);
|
||||
const parseNum = (b: string | null) => {
|
||||
if (!b) return NaN;
|
||||
const m = b.match(/[\d,]+\.?\d*/);
|
||||
return m ? parseFloat(m[0].replace(/,/g, "")) : NaN;
|
||||
};
|
||||
return accounts.filter((a) => {
|
||||
const n = parseNum(a.balance);
|
||||
if (isNaN(n)) return false;
|
||||
if (op === ">") return n > threshold;
|
||||
if (op === ">=") return n >= threshold;
|
||||
if (op === "<") return n < threshold;
|
||||
if (op === "<=") return n <= threshold;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
const words = ql.split(/\s+/).filter(Boolean);
|
||||
return accounts.filter((a) => {
|
||||
if (a.login.toLowerCase().includes(ql)) return true;
|
||||
if (a.steam_id && a.steam_id.toLowerCase().includes(ql)) return true;
|
||||
if (a.country && a.country.toLowerCase().includes(ql)) return true;
|
||||
if (a.notes) {
|
||||
const notesLower = a.notes.toLowerCase();
|
||||
return words.some((w) => notesLower.includes(w));
|
||||
@@ -591,6 +629,26 @@ export function AccountsTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleAutoConfirm = async (id: number) => {
|
||||
const acc = accounts.find((a) => a.id === id);
|
||||
if (!acc) return;
|
||||
const enable = !acc.auto_confirm;
|
||||
try {
|
||||
if (enable) await api.startAutoConfirm([id]);
|
||||
else await api.stopAutoConfirm([id]);
|
||||
addToast(
|
||||
"info",
|
||||
enable ? t("action.enableAutoConfirm") : t("action.disableAutoConfirm"),
|
||||
);
|
||||
await loadAccounts();
|
||||
} catch (e: unknown) {
|
||||
addToast(
|
||||
"error",
|
||||
t("misc.error", { error: e instanceof Error ? e.message : String(e) }),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (id: number) => setEditId(id);
|
||||
|
||||
const handleSaveEdit = async (id: number, data: AccountUpdate) => {
|
||||
@@ -1064,6 +1122,7 @@ export function AccountsTab() {
|
||||
onDelete={handleDelete}
|
||||
onAction={handleAction}
|
||||
onToggleAutoAccept={handleToggleAutoAccept}
|
||||
onToggleAutoConfirm={handleToggleAutoConfirm}
|
||||
onOpenBrowser={handleOpenBrowser}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -38,9 +38,10 @@ interface Props {
|
||||
account: Account;
|
||||
onAction: (id: number, action: string, params: Record<string, string>) => void;
|
||||
onToggleAutoAccept: (id: number) => void;
|
||||
onToggleAutoConfirm: (id: number) => void;
|
||||
}
|
||||
|
||||
export function ActionMenu({ account, onAction, onToggleAutoAccept }: Props) {
|
||||
export function ActionMenu({ account, onAction, onToggleAutoAccept, onToggleAutoConfirm }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [paramPrompt, setParamPrompt] = useState<{ action: string; title: string } | null>(null);
|
||||
const [paramValue, setParamValue] = useState("");
|
||||
@@ -109,6 +110,7 @@ export function ActionMenu({ account, onAction, onToggleAutoAccept }: Props) {
|
||||
};
|
||||
|
||||
const aaLabel = account.auto_accept ? t("action.disableAutoAccept") : t("action.enableAutoAcceptLogin");
|
||||
const acLabel = account.auto_confirm ? t("action.disableAutoConfirm") : t("action.enableAutoConfirm");
|
||||
|
||||
const handleGenerate2FA = async () => {
|
||||
setOpen(false);
|
||||
@@ -207,6 +209,13 @@ export function ActionMenu({ account, onAction, onToggleAutoAccept }: Props) {
|
||||
<button onClick={() => { setOpen(false); onToggleAutoAccept(account.id); }} className="action-menu-item">
|
||||
{aaLabel}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setOpen(false); onToggleAutoConfirm(account.id); }}
|
||||
disabled={!account.identity_secret}
|
||||
className={`action-menu-item ${!account.identity_secret ? "opacity-40 cursor-not-allowed" : ""}`}
|
||||
>
|
||||
{acLabel}
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
import { useState, useRef } from "react";
|
||||
export function CountryBadge({ country }: { country: string | null }) {
|
||||
if (!country) return <span className="text-gray-600 text-xs">—</span>;
|
||||
const isCode = country.length === 2;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-gray-300 whitespace-nowrap">
|
||||
{isCode && (
|
||||
<span
|
||||
className={`fi fi-${country.toLowerCase()}`}
|
||||
style={{ width: "1.33em", height: "1em", borderRadius: "2px" }}
|
||||
/>
|
||||
)}
|
||||
<span>{country}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
import { createPortal } from "react-dom";
|
||||
import { IconLock, IconUnlock } from "@/components/shared/Icons";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
@@ -35,3 +52,58 @@ export function MafileBadge({ hasMafile }: { hasMafile: boolean }) {
|
||||
? <span className="badge badge-ok">✓</span>
|
||||
: <span className="badge badge-unknown">—</span>;
|
||||
}
|
||||
|
||||
export function VacBadge({ status, games }: { status: string | null; games?: string | null }) {
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
|
||||
const gameList: string[] = (() => {
|
||||
if (!games) return [];
|
||||
try { return JSON.parse(games) as string[]; } catch { return []; }
|
||||
})();
|
||||
|
||||
const handleEnter = () => {
|
||||
if (!ref.current || gameList.length === 0) return;
|
||||
const r = ref.current.getBoundingClientRect();
|
||||
setPos({ top: r.top, left: r.right + 6 });
|
||||
};
|
||||
const handleLeave = () => setPos(null);
|
||||
|
||||
if (status === "VAC") return <span className="badge badge-error">Vac</span>;
|
||||
|
||||
if (status === "GAME BAN") {
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={ref}
|
||||
className="badge badge-error cursor-default"
|
||||
onMouseEnter={handleEnter}
|
||||
onMouseLeave={handleLeave}
|
||||
>
|
||||
Vac{gameList.length > 0 && <span className="ml-0.5 opacity-70">({gameList.length})</span>}
|
||||
</span>
|
||||
{pos && gameList.length > 0 && createPortal(
|
||||
<div
|
||||
style={{ position: "fixed", top: pos.top, left: pos.left, zIndex: 9999 }}
|
||||
className="bg-dark-700 border border-dark-600 rounded-lg shadow-xl px-2.5 py-2 min-w-[140px] max-w-[220px] pointer-events-none"
|
||||
>
|
||||
<div className="text-[10px] text-gray-400 mb-1 font-semibold uppercase tracking-wide">Game Bans</div>
|
||||
{gameList.map((g, i) => (
|
||||
<div key={i} className="text-xs text-gray-200 py-0.5 border-b border-dark-600 last:border-0">{g}</div>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "CLEAN") return <span className="badge badge-ok">NoVac</span>;
|
||||
return <span className="badge badge-unknown">—</span>;
|
||||
}
|
||||
|
||||
export function LimitBadge({ status }: { status: string | null }) {
|
||||
if (status === "Lim") return <span className="badge badge-running">Lim</span>;
|
||||
if (status === "NoLim") return <span className="badge badge-ok">NoLim</span>;
|
||||
return <span className="badge badge-unknown">—</span>;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ const COL_LABEL_KEYS: Record<keyof ColumnSettings, string> = {
|
||||
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",
|
||||
ban: "col.ban", vac: "col.vac", limit: "col.limit",
|
||||
balance: "col.balance", country: "col.country",
|
||||
twofa: "col.twofa", mafile: "col.mafile", proxy: "col.proxy",
|
||||
notes: "col.notes", actions: "col.actions",
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ const COL_LABEL_KEYS: Record<keyof LogpassColumnSettings, 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", status: "col.status", ban: "col.ban",
|
||||
vac: "col.vac", limit: "col.limit",
|
||||
balance: "col.balance", country: "col.country",
|
||||
prime: "col.prime", trophy: "col.trophy", behavior: "col.behavior",
|
||||
license: "col.license", proxy: "col.proxy", notes: "col.notes", actions: "col.actions",
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import { LogpassImportModal } from "./LogpassImportModal";
|
||||
import { LogpassAddModal } from "./LogpassAddModal";
|
||||
import { LogpassEditModal } from "./LogpassEditModal";
|
||||
import { LogpassColumnSettingsDropdown } from "./LogpassColumnSettingsDropdown";
|
||||
import { StatusBadge, BanBadge } from "./Badges";
|
||||
import { StatusBadge, BanBadge, VacBadge, LimitBadge, CountryBadge } from "./Badges";
|
||||
import { SteamLevelBadge } from "./SteamLevelBadge";
|
||||
import { copyText } from "@/lib/clipboard";
|
||||
import {
|
||||
@@ -304,12 +304,47 @@ export function LogpassTab() {
|
||||
}
|
||||
|
||||
const ql = q.toLowerCase();
|
||||
|
||||
if (ql === "vac") return accounts.filter((a) => a.vac_status === "VAC" || a.vac_status === "GAME BAN");
|
||||
if (ql === "clean") return accounts.filter((a) => a.vac_status === "CLEAN");
|
||||
if (ql === "lim") return accounts.filter((a) => a.limit_status === "Lim");
|
||||
if (ql === "nolim") return accounts.filter((a) => a.limit_status === "NoLim");
|
||||
if (ql === "ban" || ql === "banned") return accounts.filter((a) => a.ban_status === "BANNED");
|
||||
if (ql === "noban" || ql === "no ban") return accounts.filter((a) => a.ban_status === "NO BAN");
|
||||
|
||||
const countryM = ql.match(/^country:(.+)$/);
|
||||
if (countryM) {
|
||||
const cv = countryM[1].trim();
|
||||
return accounts.filter((a) => a.country?.toLowerCase().includes(cv));
|
||||
}
|
||||
|
||||
const balMatch = ql.match(/^(?:balance|usd|eur|rub|cny|gbp|cad|aud|brl|try|jpy|krw|inr|pln|nok|sek|dkk|chf|hkd|sgd|nzd|mxn|vnd)([<>]=?)(\d+\.?\d*)$/);
|
||||
if (balMatch) {
|
||||
const op = balMatch[1];
|
||||
const threshold = parseFloat(balMatch[2]);
|
||||
const parseNum = (b: string | null) => {
|
||||
if (!b) return NaN;
|
||||
const m = b.match(/[\d,]+\.?\d*/);
|
||||
return m ? parseFloat(m[0].replace(/,/g, "")) : NaN;
|
||||
};
|
||||
return accounts.filter((a) => {
|
||||
const n = parseNum(a.balance);
|
||||
if (isNaN(n)) return false;
|
||||
if (op === ">") return n > threshold;
|
||||
if (op === ">=") return n >= threshold;
|
||||
if (op === "<") return n < threshold;
|
||||
if (op === "<=") return n <= threshold;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
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) ||
|
||||
(a.country ?? "").toLowerCase().includes(ql) ||
|
||||
(a.license ?? "").toLowerCase().includes(ql),
|
||||
);
|
||||
}, [accounts, searchQuery]);
|
||||
@@ -705,6 +740,10 @@ export function LogpassTab() {
|
||||
<th className="px-3 py-2">{t("col.status")}</th>
|
||||
)}
|
||||
{cols.ban && <th className="px-3 py-2">{t("col.ban")}</th>}
|
||||
{cols.vac && <th className="px-3 py-2">{t("col.vac")}</th>}
|
||||
{cols.limit && <th className="px-3 py-2">{t("col.limit")}</th>}
|
||||
{cols.balance && <th className="px-3 py-2">{t("col.balance")}</th>}
|
||||
{cols.country && <th className="px-3 py-2">{t("col.country")}</th>}
|
||||
{cols.prime && (
|
||||
<th
|
||||
className="px-3 py-2 cursor-pointer select-none hover:text-gray-300"
|
||||
@@ -1015,12 +1054,31 @@ function LogpassRow({
|
||||
<StatusBadge status={a.status} />
|
||||
</td>
|
||||
)}
|
||||
{/* Ban */}
|
||||
{cols.ban && (
|
||||
<td className="px-3 py-2">
|
||||
<BanBadge status={a.ban_status} />
|
||||
</td>
|
||||
)}
|
||||
{cols.vac && (
|
||||
<td className="px-3 py-2">
|
||||
<VacBadge status={a.vac_status} games={a.vac_games} />
|
||||
</td>
|
||||
)}
|
||||
{cols.limit && (
|
||||
<td className="px-3 py-2">
|
||||
<LimitBadge status={a.limit_status} />
|
||||
</td>
|
||||
)}
|
||||
{cols.balance && (
|
||||
<td className="px-3 py-2 text-xs font-mono text-gray-300 whitespace-nowrap">
|
||||
{a.balance || "—"}
|
||||
</td>
|
||||
)}
|
||||
{cols.country && (
|
||||
<td className="px-3 py-2">
|
||||
<CountryBadge country={a.country} />
|
||||
</td>
|
||||
)}
|
||||
{/* Prime, Trophy, Behavior */}
|
||||
{cols.prime && (
|
||||
<td className="px-3 py-2 text-xs text-gray-400">{a.prime ?? "—"}</td>
|
||||
|
||||
@@ -7,7 +7,9 @@ import { useT } from "@/lib/i18n";
|
||||
const COL_LABEL_KEYS: Record<keyof TokenColumnSettings, string> = {
|
||||
browser: "col.browser", profile: "col.profile", last_online: "col.lastOnline",
|
||||
steam_id: "col.steamId", login: "col.login", token: "col.token",
|
||||
status: "col.status", proxy: "col.proxy", notes: "col.notes", actions: "col.actions",
|
||||
status: "col.status", ban: "col.ban", vac: "col.vac", limit: "col.limit",
|
||||
balance: "col.balance", country: "col.country",
|
||||
proxy: "col.proxy", notes: "col.notes", actions: "col.actions",
|
||||
};
|
||||
|
||||
export function TokenColumnSettingsDropdown() {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { api } from "@/api/client";
|
||||
import { useTokenStore } from "@/stores/tokenStore";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import { ConfirmModal } from "@/components/shared/Modals";
|
||||
import { StatusBadge } from "./Badges";
|
||||
import { StatusBadge, BanBadge, VacBadge, LimitBadge, CountryBadge } from "./Badges";
|
||||
import { SteamLevelBadge } from "./SteamLevelBadge";
|
||||
import { TokenImportModal } from "./TokenImportModal";
|
||||
import { TokenAddModal } from "./TokenAddModal";
|
||||
@@ -403,11 +403,45 @@ export function TokenTab() {
|
||||
}
|
||||
|
||||
const ql = q.toLowerCase();
|
||||
|
||||
if (ql === "vac") return accounts.filter((a) => a.vac_status === "VAC" || a.vac_status === "GAME BAN");
|
||||
if (ql === "clean") return accounts.filter((a) => a.vac_status === "CLEAN");
|
||||
if (ql === "lim") return accounts.filter((a) => a.limit_status === "Lim");
|
||||
if (ql === "nolim") return accounts.filter((a) => a.limit_status === "NoLim");
|
||||
if (ql === "ban" || ql === "banned") return accounts.filter((a) => a.ban_status === "BANNED");
|
||||
|
||||
const countryM = ql.match(/^country:(.+)$/);
|
||||
if (countryM) {
|
||||
const cv = countryM[1].trim();
|
||||
return accounts.filter((a) => a.country?.toLowerCase().includes(cv));
|
||||
}
|
||||
|
||||
const balMatch = ql.match(/^(?:balance|usd|eur|rub|cny|gbp|cad|aud|brl|try|jpy|krw|inr|pln|nok|sek|dkk|chf|hkd|sgd|nzd|mxn|vnd)([<>]=?)(\d+\.?\d*)$/);
|
||||
if (balMatch) {
|
||||
const op = balMatch[1];
|
||||
const threshold = parseFloat(balMatch[2]);
|
||||
const parseNum = (b: string | null) => {
|
||||
if (!b) return NaN;
|
||||
const m = b.match(/[\d,]+\.?\d*/);
|
||||
return m ? parseFloat(m[0].replace(/,/g, "")) : NaN;
|
||||
};
|
||||
return accounts.filter((a) => {
|
||||
const n = parseNum(a.balance);
|
||||
if (isNaN(n)) return false;
|
||||
if (op === ">") return n > threshold;
|
||||
if (op === ">=") return n >= threshold;
|
||||
if (op === "<") return n < threshold;
|
||||
if (op === "<=") return n <= threshold;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
return accounts.filter(
|
||||
(a) =>
|
||||
(a.login ?? "").toLowerCase().includes(ql) ||
|
||||
(a.steam_id ?? "").toLowerCase().includes(ql) ||
|
||||
(a.nickname ?? "").toLowerCase().includes(ql) ||
|
||||
(a.country ?? "").toLowerCase().includes(ql) ||
|
||||
(a.notes ?? "").toLowerCase().includes(ql),
|
||||
);
|
||||
}, [accounts, searchQuery]);
|
||||
@@ -616,6 +650,11 @@ export function TokenTab() {
|
||||
{cols.status && (
|
||||
<th className="px-3 py-2">{t("col.status")}</th>
|
||||
)}
|
||||
{cols.ban && <th className="px-3 py-2">{t("col.ban")}</th>}
|
||||
{cols.vac && <th className="px-3 py-2">{t("col.vac")}</th>}
|
||||
{cols.limit && <th className="px-3 py-2">{t("col.limit")}</th>}
|
||||
{cols.balance && <th className="px-3 py-2">{t("col.balance")}</th>}
|
||||
{cols.country && <th className="px-3 py-2">{t("col.country")}</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 && (
|
||||
@@ -740,6 +779,31 @@ export function TokenTab() {
|
||||
<StatusBadge status={a.status} />
|
||||
</td>
|
||||
)}
|
||||
{cols.ban && (
|
||||
<td className="px-3 py-2">
|
||||
<BanBadge status={a.ban_status} />
|
||||
</td>
|
||||
)}
|
||||
{cols.vac && (
|
||||
<td className="px-3 py-2">
|
||||
<VacBadge status={a.vac_status} games={a.vac_games} />
|
||||
</td>
|
||||
)}
|
||||
{cols.limit && (
|
||||
<td className="px-3 py-2">
|
||||
<LimitBadge status={a.limit_status} />
|
||||
</td>
|
||||
)}
|
||||
{cols.balance && (
|
||||
<td className="px-3 py-2 text-xs font-mono text-gray-300 whitespace-nowrap">
|
||||
{a.balance || "—"}
|
||||
</td>
|
||||
)}
|
||||
{cols.country && (
|
||||
<td className="px-3 py-2">
|
||||
<CountryBadge country={a.country} />
|
||||
</td>
|
||||
)}
|
||||
{cols.proxy && (
|
||||
<td className="px-3 py-2 text-xs text-gray-500 whitespace-nowrap">
|
||||
{proxyLabels.get(a.id) ?? "—"}
|
||||
|
||||
@@ -6,10 +6,10 @@ export function ToolsTab() {
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto flex flex-col gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<TwoFAGenerator />
|
||||
<ValidationSettings />
|
||||
<ProxyManager />
|
||||
</div>
|
||||
<ProxyManager />
|
||||
<TwoFAGenerator />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function TwoFAGenerator() {
|
||||
const q = search.toLowerCase();
|
||||
return !q || a.login.toLowerCase().includes(q) || (a.steam_id ?? "").includes(q);
|
||||
})
|
||||
.slice(0, 12);
|
||||
.slice(0, 10);
|
||||
|
||||
const generate = async () => {
|
||||
try {
|
||||
@@ -70,68 +70,87 @@ export function TwoFAGenerator() {
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="bg-dark-800 border border-dark-600 rounded-xl overflow-hidden flex flex-col">
|
||||
<div className="px-4 py-3 border-b border-dark-600 flex items-center gap-2">
|
||||
<IconKey size={14} className="text-accent shrink-0" />
|
||||
<div className="bg-dark-800 border border-dark-600 rounded-xl flex flex-col">
|
||||
<div className="px-5 py-3.5 border-b border-dark-600 flex items-center gap-2.5">
|
||||
<IconKey size={15} className="text-accent shrink-0" />
|
||||
<span className="text-sm font-semibold text-gray-100">{t("tools.2faGenerator")}</span>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pt-3 pb-2">
|
||||
<div className="flex gap-1 bg-dark-900 rounded p-0.5 mb-3">
|
||||
<button type="button" className={tabCls("secret")} onClick={() => setMode("secret")}>Shared secret</button>
|
||||
<button type="button" className={tabCls("account")} onClick={() => setMode("account")}>Из Mafile</button>
|
||||
<div className="flex flex-1 gap-0">
|
||||
{/* Left: controls */}
|
||||
<div className="flex-1 px-5 py-5 flex flex-col gap-4">
|
||||
<div className="flex gap-1 bg-dark-900 rounded p-0.5">
|
||||
<button type="button" className={tabCls("secret")} onClick={() => setMode("secret")}>Shared secret</button>
|
||||
<button type="button" className={tabCls("account")} onClick={() => setMode("account")}>Из Mafile</button>
|
||||
</div>
|
||||
|
||||
{mode === "secret" ? (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={secret}
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && generate()}
|
||||
placeholder="shared_secret"
|
||||
className="flex-1 bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
<button type="button" onClick={generate} className="btn-primary text-sm px-4 py-2">
|
||||
{t("btn.generate")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1" ref={dropdownRef}>
|
||||
<input
|
||||
value={selected ? `${selected.login}${selected.steam_id ? " · " + selected.steam_id : ""}` : search}
|
||||
onChange={(e) => { setSearch(e.target.value); setSelected(null); setOpen(true); }}
|
||||
onFocus={() => setOpen(true)}
|
||||
placeholder="Логин или SteamID..."
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
{open && filtered.length > 0 && (
|
||||
<div className="absolute top-full mt-1 left-0 right-0 bg-dark-800 border border-dark-600 rounded-lg shadow-xl z-50 max-h-48 overflow-y-auto">
|
||||
{filtered.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
className="w-full text-left px-3 py-1.5 text-sm hover:bg-dark-600 transition-colors flex items-center justify-between gap-2"
|
||||
onMouseDown={() => { setSelected(a); setSearch(""); setOpen(false); }}
|
||||
>
|
||||
<span className="text-gray-200 truncate">{a.login}</span>
|
||||
{a.steam_id && <span className="text-gray-500 text-xs shrink-0">{a.steam_id}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={generate}
|
||||
disabled={!selected}
|
||||
className="btn-primary text-sm px-4 py-2 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t("btn.generate")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{mode === "secret" ? (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={secret}
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && generate()}
|
||||
placeholder="shared_secret"
|
||||
className="flex-1 bg-dark-700 border border-dark-600 rounded px-3 py-1.5 text-sm"
|
||||
/>
|
||||
<button type="button" onClick={generate} className="btn-primary text-sm px-3 py-1.5">{t("btn.generate")}</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1" ref={dropdownRef}>
|
||||
<input
|
||||
value={selected ? `${selected.login}${selected.steam_id ? " · " + selected.steam_id : ""}` : search}
|
||||
onChange={(e) => { setSearch(e.target.value); setSelected(null); setOpen(true); }}
|
||||
onFocus={() => setOpen(true)}
|
||||
placeholder="Логин или SteamID..."
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-1.5 text-sm"
|
||||
/>
|
||||
{open && filtered.length > 0 && (
|
||||
<div className="absolute top-full mt-1 left-0 right-0 bg-dark-800 border border-dark-600 rounded-lg shadow-xl z-50 max-h-48 overflow-y-auto">
|
||||
{filtered.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
className="w-full text-left px-3 py-1.5 text-sm hover:bg-dark-600 transition-colors flex items-center justify-between gap-2"
|
||||
onMouseDown={() => { setSelected(a); setSearch(""); setOpen(false); }}
|
||||
>
|
||||
<span className="text-gray-200 truncate">{a.login}</span>
|
||||
{a.steam_id && <span className="text-gray-500 text-xs shrink-0">{a.steam_id}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" onClick={generate} disabled={!selected} className="btn-primary text-sm px-3 py-1.5 disabled:opacity-40 disabled:cursor-not-allowed">{t("btn.generate")}</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{code && (
|
||||
<div
|
||||
className="mt-3 text-2xl font-mono text-accent cursor-pointer tabular-nums tracking-widest"
|
||||
onClick={handleCopy}
|
||||
title={t("tools.clickCopy")}
|
||||
>
|
||||
{code}
|
||||
</div>
|
||||
)}
|
||||
{/* Right: code display */}
|
||||
<div
|
||||
className="border-l border-dark-600 flex flex-col items-center justify-center w-52 shrink-0 cursor-pointer select-none group gap-2"
|
||||
onClick={handleCopy}
|
||||
title={t("tools.clickCopy")}
|
||||
>
|
||||
<p className="text-xs text-gray-600">{t("tools.clickCopy")}</p>
|
||||
{code ? (
|
||||
<span className="text-4xl font-mono text-accent tabular-nums tracking-widest group-hover:text-accent/80 transition-colors">
|
||||
{code}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-2xl font-mono text-dark-500 tracking-widest">— — —</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import type { ValidationSettings as VS } from "@/api/types";
|
||||
import type { ValidationSettings as VS, ServicesSettings as SS } from "@/api/types";
|
||||
import { api } from "@/api/client";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import { useT } from "@/lib/i18n";
|
||||
@@ -46,19 +46,24 @@ function SettingRow({
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadStepper({
|
||||
function NumericStepper({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min = 1,
|
||||
steps = [-10, -1, 1, 10],
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (v: number) => void;
|
||||
min?: number;
|
||||
steps?: number[];
|
||||
}) {
|
||||
const t = useT();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [input, setInput] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const clamp = (n: number) => Math.max(1, n);
|
||||
const clamp = (n: number) => Math.max(min, n);
|
||||
const commit = () => {
|
||||
onChange(clamp(parseInt(input) || value));
|
||||
setEditing(false);
|
||||
@@ -73,23 +78,14 @@ function ThreadStepper({
|
||||
className="text-sm text-gray-400 flex-1"
|
||||
title="Двойной клик на числе для ручного ввода"
|
||||
>
|
||||
{t("tools.maxThreads")}
|
||||
{label}
|
||||
</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>
|
||||
{steps.filter((s) => s < 0).map((s) => (
|
||||
<button key={s} type="button" className={btnCls} onClick={() => onChange(clamp(value + s))}>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
{editing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
@@ -115,25 +111,33 @@ function ThreadStepper({
|
||||
{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>
|
||||
{steps.filter((s) => s > 0).map((s) => (
|
||||
<button key={s} type="button" className={btnCls} onClick={() => onChange(clamp(value + s))}>
|
||||
+{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadStepper({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (v: number) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
return (
|
||||
<NumericStepper
|
||||
label={t("tools.maxThreads")}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ValidationSettings() {
|
||||
const t = useT();
|
||||
const [settings, setSettings] = useState<VS>({
|
||||
@@ -150,6 +154,10 @@ export function ValidationSettings() {
|
||||
auto_validate_on_import_logpass: true,
|
||||
auto_validate_on_import_token: true,
|
||||
});
|
||||
const [services, setServices] = useState<SS>({
|
||||
auto_accept_interval: 15,
|
||||
auto_confirm_interval: 30,
|
||||
});
|
||||
const [importTab, setImportTab] = useState<"mafile" | "logpass" | "token">(
|
||||
"mafile",
|
||||
);
|
||||
@@ -159,16 +167,17 @@ export function ValidationSettings() {
|
||||
const addToast = useUiStore((s) => s.addToast);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.getValidationSettings()
|
||||
.then(setSettings)
|
||||
.catch(() => {});
|
||||
api.getValidationSettings().then(setSettings).catch(() => {});
|
||||
api.getServicesSettings().then(setServices).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
await api.updateValidationSettings(settings);
|
||||
await loadImportSettings(); // sync stale uiStore values
|
||||
await Promise.all([
|
||||
api.updateValidationSettings(settings),
|
||||
api.updateServicesSettings(services),
|
||||
]);
|
||||
await loadImportSettings();
|
||||
addToast("success", t("toast.settingsSaved"));
|
||||
} catch (e: unknown) {
|
||||
addToast(
|
||||
@@ -274,7 +283,7 @@ export function ValidationSettings() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-5 pt-4 pb-3">
|
||||
<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-0.5">
|
||||
{t("settings.display")}
|
||||
</p>
|
||||
@@ -285,6 +294,26 @@ export function ValidationSettings() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-5 pt-4 pb-3">
|
||||
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-widest mb-0.5">
|
||||
{t("tools.servicesSettings")}
|
||||
</p>
|
||||
<NumericStepper
|
||||
label={t("tools.autoAcceptInterval")}
|
||||
value={services.auto_accept_interval}
|
||||
onChange={(v) => setServices((s) => ({ ...s, auto_accept_interval: v }))}
|
||||
min={5}
|
||||
steps={[-15, -10, -5, 5, 10, 15]}
|
||||
/>
|
||||
<NumericStepper
|
||||
label={t("tools.autoConfirmInterval")}
|
||||
value={services.auto_confirm_interval}
|
||||
onChange={(v) => setServices((s) => ({ ...s, auto_confirm_interval: v }))}
|
||||
min={5}
|
||||
steps={[-15, -10, -5, 5, 10, 15]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-3 border-t border-dark-600 flex justify-end">
|
||||
<button onClick={save} className="btn-primary">
|
||||
{t("btn.save")}
|
||||
|
||||
@@ -60,6 +60,8 @@ const translations: Translations = {
|
||||
"col.phone": "Телефон",
|
||||
"col.status": "Статус",
|
||||
"col.ban": "Бан",
|
||||
"col.vac": "VAC",
|
||||
"col.limit": "Лимит",
|
||||
"col.twofa": "2FA",
|
||||
"col.mafile": "Mafile",
|
||||
"col.proxy": "Прокси",
|
||||
@@ -70,6 +72,8 @@ const translations: Translations = {
|
||||
"col.behavior": "Behavior",
|
||||
"col.license": "Лицензии",
|
||||
"col.token": "Токен",
|
||||
"col.balance": "Баланс",
|
||||
"col.country": "Страна",
|
||||
// -- Actions --
|
||||
"action.selectAction": "— Действие",
|
||||
"action.validate": "Валидация",
|
||||
@@ -83,6 +87,9 @@ const translations: Translations = {
|
||||
"action.enableAutoAccept": "Вкл. автопринятие",
|
||||
"action.disableAutoAccept": "Выкл. авто-принятие входа",
|
||||
"action.enableAutoAcceptLogin": "Вкл. авто-принятие входа",
|
||||
"action.enableAutoConfirm": "Вкл. авто-подтверждения",
|
||||
"action.disableAutoConfirm": "Выкл. авто-подтверждения",
|
||||
"tip.autoConfirmActive": "Авто-подтверждения включены",
|
||||
"action.generate2fa": "Сгенерировать 2FA код",
|
||||
"action.confirmations": "Подтверждения",
|
||||
// -- Action prompts --
|
||||
@@ -284,6 +291,9 @@ const translations: Translations = {
|
||||
"tools.importTabToken": "Token",
|
||||
"tools.clickCopy": "Клик для копирования",
|
||||
"tools.genErrorStr": "Ошибка",
|
||||
"tools.servicesSettings": "Фоновые сервисы",
|
||||
"tools.autoAcceptInterval": "Интервал auto-accept (сек):",
|
||||
"tools.autoConfirmInterval": "Интервал auto-confirm (сек):",
|
||||
// -- Logs panel --
|
||||
"logs.title": "Лог",
|
||||
"logs.clear": "Очистить",
|
||||
@@ -372,6 +382,8 @@ const translations: Translations = {
|
||||
"col.phone": "Phone",
|
||||
"col.status": "Status",
|
||||
"col.ban": "Ban",
|
||||
"col.vac": "VAC",
|
||||
"col.limit": "Limit",
|
||||
"col.twofa": "2FA",
|
||||
"col.mafile": "Mafile",
|
||||
"col.proxy": "Proxy",
|
||||
@@ -382,6 +394,8 @@ const translations: Translations = {
|
||||
"col.behavior": "Behavior",
|
||||
"col.license": "Licenses",
|
||||
"col.token": "Token",
|
||||
"col.balance": "Balance",
|
||||
"col.country": "Country",
|
||||
// -- Actions --
|
||||
"action.selectAction": "— Action",
|
||||
"action.validate": "Validate",
|
||||
@@ -395,6 +409,9 @@ const translations: Translations = {
|
||||
"action.enableAutoAccept": "Enable auto-accept",
|
||||
"action.disableAutoAccept": "Disable login auto-accept",
|
||||
"action.enableAutoAcceptLogin": "Enable login auto-accept",
|
||||
"action.enableAutoConfirm": "Enable auto-confirm",
|
||||
"action.disableAutoConfirm": "Disable auto-confirm",
|
||||
"tip.autoConfirmActive": "Auto-confirm active",
|
||||
"action.generate2fa": "Generate 2FA code",
|
||||
"action.confirmations": "Confirmations",
|
||||
// -- Action prompts --
|
||||
@@ -594,6 +611,9 @@ const translations: Translations = {
|
||||
"tools.importTabToken": "Token",
|
||||
"tools.clickCopy": "Click to copy",
|
||||
"tools.genErrorStr": "Error",
|
||||
"tools.servicesSettings": "Background Services",
|
||||
"tools.autoAcceptInterval": "Auto-accept interval (sec):",
|
||||
"tools.autoConfirmInterval": "Auto-confirm interval (sec):",
|
||||
// -- Logs panel --
|
||||
"logs.title": "Log",
|
||||
"logs.clear": "Clear",
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ReactNode, ErrorInfo } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
import "flag-icons/css/flag-icons.min.css";
|
||||
|
||||
class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
|
||||
state = { error: null };
|
||||
|
||||
@@ -24,12 +24,16 @@ const DEFAULT_COLUMNS: ColumnSettings = {
|
||||
phone: true,
|
||||
status: true,
|
||||
ban: true,
|
||||
vac: true,
|
||||
limit: true,
|
||||
twofa: true,
|
||||
mafile: true,
|
||||
proxy: true,
|
||||
notes: true,
|
||||
actions: true,
|
||||
last_online: true,
|
||||
balance: true,
|
||||
country: true,
|
||||
};
|
||||
|
||||
const DEFAULT_LOGPASS_COLUMNS: LogpassColumnSettings = {
|
||||
@@ -41,6 +45,8 @@ const DEFAULT_LOGPASS_COLUMNS: LogpassColumnSettings = {
|
||||
login_pass: true,
|
||||
status: true,
|
||||
ban: true,
|
||||
vac: true,
|
||||
limit: true,
|
||||
prime: true,
|
||||
trophy: true,
|
||||
behavior: true,
|
||||
@@ -49,6 +55,8 @@ const DEFAULT_LOGPASS_COLUMNS: LogpassColumnSettings = {
|
||||
notes: true,
|
||||
actions: true,
|
||||
last_online: true,
|
||||
balance: true,
|
||||
country: true,
|
||||
};
|
||||
|
||||
const DEFAULT_TOKEN_COLUMNS: TokenColumnSettings = {
|
||||
@@ -59,9 +67,14 @@ const DEFAULT_TOKEN_COLUMNS: TokenColumnSettings = {
|
||||
login: true,
|
||||
token: true,
|
||||
status: true,
|
||||
ban: true,
|
||||
vac: true,
|
||||
limit: true,
|
||||
proxy: true,
|
||||
notes: true,
|
||||
actions: true,
|
||||
balance: true,
|
||||
country: true,
|
||||
};
|
||||
|
||||
interface UiState {
|
||||
|
||||
Reference in New Issue
Block a user