mirror of
https://github.com/daimyomizukagebay61/SteamPanel.git
synced 2026-07-25 11:44:28 +00:00
v3.0.5
This commit is contained in:
@@ -12,12 +12,16 @@ export default function App() {
|
||||
const activeTab = useUiStore((s) => s.activeTab);
|
||||
const loadDisplaySettings = useUiStore((s) => s.loadDisplaySettings);
|
||||
const loadColumnSettings = useUiStore((s) => s.loadColumnSettings);
|
||||
const loadLogpassColumnSettings = useUiStore((s) => s.loadLogpassColumnSettings);
|
||||
const loadLogpassColumnSettings = useUiStore(
|
||||
(s) => s.loadLogpassColumnSettings,
|
||||
);
|
||||
const loadImportSettings = useUiStore((s) => s.loadImportSettings);
|
||||
|
||||
useEffect(() => {
|
||||
loadDisplaySettings();
|
||||
loadColumnSettings();
|
||||
loadLogpassColumnSettings();
|
||||
loadImportSettings();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
+240
-62
@@ -1,9 +1,24 @@
|
||||
import type {
|
||||
Account, AccountCreate, AccountUpdate, BulkImportResult,
|
||||
Proxy, Task, MafileInfo, MafileExportRequest,
|
||||
ActionRequest, ValidationSettings, DisplaySettings, ColumnSettings, LogpassColumnSettings, LogEntry,
|
||||
LogpassAccount, LogpassAccountCreate, LogpassAccountUpdate,
|
||||
TokenAccount, TokenAccountCreate, TokenColumnSettings,
|
||||
Account,
|
||||
AccountCreate,
|
||||
AccountUpdate,
|
||||
BulkImportResult,
|
||||
Proxy,
|
||||
Task,
|
||||
MafileInfo,
|
||||
MafileExportRequest,
|
||||
ActionRequest,
|
||||
ValidationSettings,
|
||||
DisplaySettings,
|
||||
ColumnSettings,
|
||||
LogpassColumnSettings,
|
||||
LogEntry,
|
||||
LogpassAccount,
|
||||
LogpassAccountCreate,
|
||||
LogpassAccountUpdate,
|
||||
TokenAccount,
|
||||
TokenAccountCreate,
|
||||
TokenColumnSettings,
|
||||
} from "./types";
|
||||
|
||||
const BASE = "/api";
|
||||
@@ -29,76 +44,144 @@ export const api = {
|
||||
getAccounts: () => request<Account[]>("/accounts"),
|
||||
getAccount: (id: number) => request<Account>(`/accounts/${id}`),
|
||||
createAccount: (data: AccountCreate) =>
|
||||
request<Account>("/accounts", { method: "POST", body: JSON.stringify(data) }),
|
||||
request<Account>("/accounts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
updateAccount: (id: number, data: AccountUpdate) =>
|
||||
request<Account>(`/accounts/${id}`, { method: "PUT", body: JSON.stringify(data) }),
|
||||
request<Account>(`/accounts/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
deleteAccount: (id: number) =>
|
||||
request<void>(`/accounts/${id}`, { method: "DELETE" }),
|
||||
deleteBulk: (ids: number[]) =>
|
||||
request<{ deleted: number }>("/accounts/delete-bulk", { method: "POST", body: JSON.stringify({ ids }) }),
|
||||
request<{ deleted: number }>("/accounts/delete-bulk", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ids }),
|
||||
}),
|
||||
assignProxies: () =>
|
||||
request<{ assigned: number; proxies_used: number }>("/accounts/assign-proxies", { method: "POST" }),
|
||||
request<{ assigned: number; proxies_used: number }>(
|
||||
"/accounts/assign-proxies",
|
||||
{ method: "POST" },
|
||||
),
|
||||
reassignProxies: () =>
|
||||
request<{ assigned: number; proxies_used: number }>("/accounts/reassign-proxies", { method: "POST" }),
|
||||
request<{ assigned: number; proxies_used: number }>(
|
||||
"/accounts/reassign-proxies",
|
||||
{ method: "POST" },
|
||||
),
|
||||
clearProxies: () =>
|
||||
request<{ cleared: number }>("/accounts/clear-proxies", { method: "POST" }),
|
||||
importAccounts: (file: File) => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
return fetch(`${BASE}/accounts/import`, { method: "POST", body: form }).then(async (r) => {
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail ?? r.statusText);
|
||||
return fetch(`${BASE}/accounts/import`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
}).then(async (r) => {
|
||||
if (!r.ok)
|
||||
throw new Error(
|
||||
(await r.json().catch(() => ({}))).detail ?? r.statusText,
|
||||
);
|
||||
return r.json() as Promise<BulkImportResult>;
|
||||
});
|
||||
},
|
||||
|
||||
// Actions
|
||||
executeAction: (data: ActionRequest) =>
|
||||
request<{ task_id: string; accounts_count: number }>("/actions", { method: "POST", body: JSON.stringify(data) }),
|
||||
respondToPrompt: (taskId: string, value: string, login?: string) =>
|
||||
request<{ status: string }>(`/tasks/${taskId}/respond`, { method: "POST", body: JSON.stringify({ value, login: login || "" }) }),
|
||||
generate2FA: (shared_secret: string) =>
|
||||
request<{ code: string }>("/actions/generate-2fa", { method: "POST", body: JSON.stringify({ shared_secret }) }),
|
||||
generate2FAByAccount: (account_id: number) =>
|
||||
request<{ code: string }>("/actions/generate-2fa-by-account", { method: "POST", body: JSON.stringify({ account_id }) }),
|
||||
getConfirmations: (account_id: number) =>
|
||||
request<{ success: boolean; confirmations: import("./types").SteamConfirmation[] }>(`/actions/confirmations/${account_id}`),
|
||||
respondConfirmations: (account_id: number, ids: string[], nonces: string[], accept: boolean) =>
|
||||
request<{ success: boolean }>(`/actions/confirmations/${account_id}/respond`, {
|
||||
request<{ task_id: string; accounts_count: number }>("/actions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ids, nonces, accept }),
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
respondToPrompt: (taskId: string, value: string, login?: string) =>
|
||||
request<{ status: string }>(`/tasks/${taskId}/respond`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ value, login: login || "" }),
|
||||
}),
|
||||
generate2FA: (shared_secret: string) =>
|
||||
request<{ code: string }>("/actions/generate-2fa", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ shared_secret }),
|
||||
}),
|
||||
generate2FAByAccount: (account_id: number) =>
|
||||
request<{ code: string }>("/actions/generate-2fa-by-account", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ account_id }),
|
||||
}),
|
||||
getConfirmations: (account_id: number) =>
|
||||
request<{
|
||||
success: boolean;
|
||||
confirmations: import("./types").SteamConfirmation[];
|
||||
}>(`/actions/confirmations/${account_id}`),
|
||||
respondConfirmations: (
|
||||
account_id: number,
|
||||
ids: string[],
|
||||
nonces: string[],
|
||||
accept: boolean,
|
||||
) =>
|
||||
request<{ success: boolean }>(
|
||||
`/actions/confirmations/${account_id}/respond`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ids, nonces, accept }),
|
||||
},
|
||||
),
|
||||
|
||||
// Proxies
|
||||
getProxies: () => request<Proxy[]>("/proxies"),
|
||||
addProxy: (address: string, protocol = "http") =>
|
||||
request<Proxy>("/proxies", { method: "POST", body: JSON.stringify({ address, protocol }) }),
|
||||
request<Proxy>("/proxies", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ address, protocol }),
|
||||
}),
|
||||
deleteProxy: (id: number) =>
|
||||
request<void>(`/proxies/${id}`, { method: "DELETE" }),
|
||||
deleteAllProxies: () =>
|
||||
request<{ deleted: number }>("/proxies/all", { method: "DELETE" }),
|
||||
bulkAddProxies: (proxies: { address: string; protocol: string }[]) =>
|
||||
request<{ added: number }>("/proxies/bulk", { method: "POST", body: JSON.stringify(proxies) }),
|
||||
request<{ added: number }>("/proxies/bulk", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(proxies),
|
||||
}),
|
||||
checkProxies: () =>
|
||||
request<{ total: number; alive: number; dead: number }>("/proxies/check", { method: "POST" }),
|
||||
request<{ total: number; alive: number; dead: number }>("/proxies/check", {
|
||||
method: "POST",
|
||||
}),
|
||||
|
||||
// Tasks
|
||||
getTasks: () => request<Task[]>("/tasks"),
|
||||
getTask: (id: string) => request<Task>(`/tasks/${id}`),
|
||||
cancelTask: (id: string) => request<void>(`/tasks/${id}`, { method: "DELETE" }),
|
||||
cancelTask: (id: string) =>
|
||||
request<void>(`/tasks/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Mafiles
|
||||
getMafiles: () => request<MafileInfo[]>("/mafiles"),
|
||||
uploadMafiles: (files: File[]) => {
|
||||
const form = new FormData();
|
||||
files.forEach((f) => form.append("files", f));
|
||||
return fetch(`${BASE}/mafiles/upload`, { method: "POST", body: form }).then(async (r) => {
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail ?? r.statusText);
|
||||
return r.json() as Promise<{ uploaded: number; errors: string[] }>;
|
||||
});
|
||||
return fetch(`${BASE}/mafiles/upload`, { method: "POST", body: form }).then(
|
||||
async (r) => {
|
||||
if (!r.ok)
|
||||
throw new Error(
|
||||
(await r.json().catch(() => ({}))).detail ?? r.statusText,
|
||||
);
|
||||
return r.json() as Promise<{ uploaded: number; errors: string[] }>;
|
||||
},
|
||||
);
|
||||
},
|
||||
deleteMafile: (filename: string) =>
|
||||
request<void>(`/mafiles/${encodeURIComponent(filename)}`, { method: "DELETE" }),
|
||||
exportSecrets: () => request<{ account_name: string; shared_secret: string; identity_secret: string; steam_id: string }[]>("/mafiles/export/all"),
|
||||
request<void>(`/mafiles/${encodeURIComponent(filename)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
exportSecrets: () =>
|
||||
request<
|
||||
{
|
||||
account_name: string;
|
||||
shared_secret: string;
|
||||
identity_secret: string;
|
||||
steam_id: string;
|
||||
}[]
|
||||
>("/mafiles/export/all"),
|
||||
exportZip: (body: MafileExportRequest) =>
|
||||
fetch(`${BASE}/mafiles/export/zip`, {
|
||||
method: "POST",
|
||||
@@ -110,80 +193,175 @@ export const api = {
|
||||
}),
|
||||
|
||||
// Settings
|
||||
getValidationSettings: () => request<ValidationSettings>("/settings/validation"),
|
||||
getValidationSettings: () =>
|
||||
request<ValidationSettings>("/settings/validation"),
|
||||
updateValidationSettings: (data: ValidationSettings) =>
|
||||
request<ValidationSettings>("/settings/validation", { method: "PUT", body: JSON.stringify(data) }),
|
||||
request<ValidationSettings>("/settings/validation", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
getDisplaySettings: () => request<DisplaySettings>("/settings/display"),
|
||||
updateDisplaySettings: (data: DisplaySettings) =>
|
||||
request<DisplaySettings>("/settings/display", { method: "PUT", body: JSON.stringify(data) }),
|
||||
request<DisplaySettings>("/settings/display", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
getColumnSettings: () => request<ColumnSettings>("/settings/columns"),
|
||||
updateColumnSettings: (data: ColumnSettings) =>
|
||||
request<ColumnSettings>("/settings/columns", { method: "PUT", body: JSON.stringify(data) }),
|
||||
getLogpassColumnSettings: () => request<LogpassColumnSettings>("/settings/logpass-columns"),
|
||||
request<ColumnSettings>("/settings/columns", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
getLogpassColumnSettings: () =>
|
||||
request<LogpassColumnSettings>("/settings/logpass-columns"),
|
||||
updateLogpassColumnSettings: (data: LogpassColumnSettings) =>
|
||||
request<LogpassColumnSettings>("/settings/logpass-columns", { method: "PUT", body: JSON.stringify(data) }),
|
||||
request<LogpassColumnSettings>("/settings/logpass-columns", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
// Logs
|
||||
getLogs: () => request<LogEntry[]>("/logs"),
|
||||
|
||||
// Auto-accept
|
||||
startAutoAccept: (ids: number[]) =>
|
||||
request<{ started: number[] }>("/auto-accept/start", { method: "POST", body: JSON.stringify({ account_ids: ids }) }),
|
||||
request<{ started: number[] }>("/auto-accept/start", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ account_ids: ids }),
|
||||
}),
|
||||
stopAutoAccept: (ids: number[]) =>
|
||||
request<{ stopped: number[] }>("/auto-accept/stop", { method: "POST", body: JSON.stringify({ account_ids: ids }) }),
|
||||
request<{ stopped: number[] }>("/auto-accept/stop", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ account_ids: ids }),
|
||||
}),
|
||||
getAutoAcceptStatus: () =>
|
||||
request<{ running: number[] }>("/auto-accept/status"),
|
||||
|
||||
// Browser
|
||||
openBrowser: (id: number) =>
|
||||
request<{ status: string; message: string; task_id?: string }>(`/accounts/${id}/browser`, { method: "POST" }),
|
||||
request<{ status: string; message: string; task_id?: string }>(
|
||||
`/accounts/${id}/browser`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
|
||||
// Logpass accounts
|
||||
getLogpassAccounts: () => request<LogpassAccount[]>("/logpass"),
|
||||
createLogpassAccount: (data: LogpassAccountCreate) =>
|
||||
request<LogpassAccount>("/logpass", { method: "POST", body: JSON.stringify(data) }),
|
||||
request<LogpassAccount>("/logpass", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
updateLogpassAccount: (id: number, data: LogpassAccountUpdate) =>
|
||||
request<LogpassAccount>(`/logpass/${id}`, { method: "PUT", body: JSON.stringify(data) }),
|
||||
request<LogpassAccount>(`/logpass/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
deleteLogpassAccount: (id: number) =>
|
||||
request<void>(`/logpass/${id}`, { method: "DELETE" }),
|
||||
deleteLogpassBulk: (ids: number[]) =>
|
||||
request<{ deleted: number }>("/logpass/delete-bulk", { method: "POST", body: JSON.stringify({ ids }) }),
|
||||
request<{ deleted: number }>("/logpass/delete-bulk", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ids }),
|
||||
}),
|
||||
importLogpass: (lines: string[]) =>
|
||||
request<BulkImportResult>("/logpass/import", { method: "POST", body: JSON.stringify({ lines }) }),
|
||||
request<BulkImportResult>("/logpass/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ lines }),
|
||||
}),
|
||||
validateLogpass: (account_ids: number[]) =>
|
||||
request<{ task_id: string; accounts_count: number }>("/logpass/validate", { method: "POST", body: JSON.stringify({ account_ids }) }),
|
||||
request<{ task_id: string; accounts_count: number }>("/logpass/validate", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ account_ids }),
|
||||
}),
|
||||
fullParseLogpass: (account_ids: number[]) =>
|
||||
request<{ task_id: string; accounts_count: number }>("/logpass/full-parse", { method: "POST", body: JSON.stringify({ account_ids }) }),
|
||||
request<{ task_id: string; accounts_count: number }>(
|
||||
"/logpass/full-parse",
|
||||
{ method: "POST", body: JSON.stringify({ account_ids }) },
|
||||
),
|
||||
assignLogpassProxies: () =>
|
||||
request<{ assigned: number; proxies_used: number }>("/logpass/assign-proxies", { method: "POST" }),
|
||||
request<{ assigned: number; proxies_used: number }>(
|
||||
"/logpass/assign-proxies",
|
||||
{ method: "POST" },
|
||||
),
|
||||
reassignLogpassProxies: () =>
|
||||
request<{ assigned: number; proxies_used: number }>("/logpass/reassign-proxies", { method: "POST" }),
|
||||
request<{ assigned: number; proxies_used: number }>(
|
||||
"/logpass/reassign-proxies",
|
||||
{ method: "POST" },
|
||||
),
|
||||
clearLogpassProxies: () =>
|
||||
request<{ cleared: number }>("/logpass/clear-proxies", { method: "POST" }),
|
||||
openLogpassBrowser: (id: number) =>
|
||||
request<{ status: string; message: string; task_id?: string }>(`/logpass/${id}/browser`, { method: "POST" }),
|
||||
request<{ status: string; message: string; task_id?: string }>(
|
||||
`/logpass/${id}/browser`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
|
||||
// Token accounts
|
||||
getTokenAccounts: () => request<TokenAccount[]>("/token-accounts"),
|
||||
createTokenAccount: (data: TokenAccountCreate) =>
|
||||
request<TokenAccount>("/token-accounts", { method: "POST", body: JSON.stringify(data) }),
|
||||
request<TokenAccount>("/token-accounts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
deleteTokenAccount: (id: number) =>
|
||||
request<void>(`/token-accounts/${id}`, { method: "DELETE" }),
|
||||
deleteTokenBulk: (ids: number[]) =>
|
||||
request<{ deleted: number }>("/token-accounts/delete-bulk", { method: "POST", body: JSON.stringify({ ids }) }),
|
||||
request<{ deleted: number }>("/token-accounts/delete-bulk", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ids }),
|
||||
}),
|
||||
importTokens: (lines: string[]) =>
|
||||
request<BulkImportResult>("/token-accounts/import", { method: "POST", body: JSON.stringify({ lines }) }),
|
||||
request<BulkImportResult>("/token-accounts/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ lines }),
|
||||
}),
|
||||
validateTokens: (ids: number[]) =>
|
||||
request<{ task_id: string; accounts_count: number }>("/token-accounts/validate", { method: "POST", body: JSON.stringify({ account_ids: ids }) }),
|
||||
request<{ task_id: string; accounts_count: number }>(
|
||||
"/token-accounts/validate",
|
||||
{ method: "POST", body: JSON.stringify({ account_ids: ids }) },
|
||||
),
|
||||
openTokenBrowser: (id: number) =>
|
||||
request<{ status: string; message: string; task_id?: string }>(`/token-accounts/${id}/browser`, { method: "POST" }),
|
||||
request<{ status: string; message: string; task_id?: string }>(
|
||||
`/token-accounts/${id}/browser`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
assignTokenProxies: () =>
|
||||
request<{ assigned: number; proxies_used: number }>("/token-accounts/assign-proxies", { method: "POST" }),
|
||||
request<{ assigned: number; proxies_used: number }>(
|
||||
"/token-accounts/assign-proxies",
|
||||
{ method: "POST" },
|
||||
),
|
||||
reassignTokenProxies: () =>
|
||||
request<{ assigned: number; proxies_used: number }>("/token-accounts/reassign-proxies", { method: "POST" }),
|
||||
request<{ assigned: number; proxies_used: number }>(
|
||||
"/token-accounts/reassign-proxies",
|
||||
{ method: "POST" },
|
||||
),
|
||||
clearTokenProxies: () =>
|
||||
request<{ cleared: number }>("/token-accounts/clear-proxies", { method: "POST" }),
|
||||
getTokenColumnSettings: () => request<TokenColumnSettings>("/settings/token-columns"),
|
||||
request<{ cleared: number }>("/token-accounts/clear-proxies", {
|
||||
method: "POST",
|
||||
}),
|
||||
getTokenColumnSettings: () =>
|
||||
request<TokenColumnSettings>("/settings/token-columns"),
|
||||
updateTokenColumnSettings: (data: TokenColumnSettings) =>
|
||||
request<TokenColumnSettings>("/settings/token-columns", { method: "PUT", body: JSON.stringify(data) }),
|
||||
request<TokenColumnSettings>("/settings/token-columns", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
fullCheckTokens: (ids: number[]) =>
|
||||
request<{ task_id: string; accounts_count: number }>(
|
||||
"/token-accounts/full-check",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ account_ids: ids }),
|
||||
},
|
||||
),
|
||||
getTokenCheckData: (id: number) =>
|
||||
request<import("./types").TokenCheckData>(
|
||||
`/token-accounts/${id}/check-data`,
|
||||
),
|
||||
downloadTokenCookies: (id: number) => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `${BASE}/token-accounts/${id}/cookies`;
|
||||
a.download = "";
|
||||
a.click();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -71,7 +71,10 @@ export interface Task {
|
||||
step_label?: string;
|
||||
active_count?: number;
|
||||
account_ids?: string | null;
|
||||
account_results?: Record<string, { status: string; error?: string }> | string | null;
|
||||
account_results?:
|
||||
| Record<string, { status: string; error?: string }>
|
||||
| string
|
||||
| null;
|
||||
account_steps?: Record<string, { step: number; total: number }>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -118,6 +121,14 @@ export interface ValidationSettings {
|
||||
check_ban: boolean;
|
||||
max_threads: number;
|
||||
auto_revalidate_browser: boolean;
|
||||
auto_proxy_on_import: boolean;
|
||||
auto_validate_on_import: boolean;
|
||||
auto_proxy_on_import_mafile: boolean;
|
||||
auto_proxy_on_import_logpass: boolean;
|
||||
auto_proxy_on_import_token: boolean;
|
||||
auto_validate_on_import_mafile: boolean;
|
||||
auto_validate_on_import_logpass: boolean;
|
||||
auto_validate_on_import_token: boolean;
|
||||
}
|
||||
|
||||
export interface DisplaySettings {
|
||||
@@ -259,6 +270,32 @@ export interface Toast {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface TokenCheckData {
|
||||
steam_id: string | null;
|
||||
display_name: string | null;
|
||||
avatar_url: string | null;
|
||||
steam_level: number | null;
|
||||
last_online: string | null;
|
||||
balance_raw: string | null;
|
||||
user_country: string | null;
|
||||
family_group: boolean;
|
||||
playtime_2weeks: number;
|
||||
inventory_cs2: number;
|
||||
inventory_dota2: number;
|
||||
inventory_tf2: number;
|
||||
inventory_rust: number;
|
||||
inventory_cs2_marketable: number;
|
||||
inventory_dota2_marketable: number;
|
||||
inventory_tf2_marketable: number;
|
||||
inventory_rust_marketable: number;
|
||||
trade_ban: string | null;
|
||||
created_date: string | null;
|
||||
phone_digits: string | null;
|
||||
alert_status: string;
|
||||
market_limited: boolean;
|
||||
checked_at: string | null;
|
||||
}
|
||||
|
||||
export interface SteamConfirmation {
|
||||
id: string;
|
||||
nonce: string;
|
||||
|
||||
@@ -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
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
+62
-17
@@ -7,9 +7,13 @@ const STORAGE_KEY = "steampanel_locale";
|
||||
let _locale: Locale = (localStorage.getItem(STORAGE_KEY) as Locale) || "ru";
|
||||
const _listeners = new Set<() => void>();
|
||||
|
||||
function notify() { _listeners.forEach((l) => l()); }
|
||||
function notify() {
|
||||
_listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
export function getLocale(): Locale { return _locale; }
|
||||
export function getLocale(): Locale {
|
||||
return _locale;
|
||||
}
|
||||
|
||||
export function setLocale(l: Locale) {
|
||||
_locale = l;
|
||||
@@ -19,7 +23,10 @@ export function setLocale(l: Locale) {
|
||||
|
||||
export function useLocale(): [Locale, (l: Locale) => void] {
|
||||
const locale = useSyncExternalStore(
|
||||
(cb) => { _listeners.add(cb); return () => _listeners.delete(cb); },
|
||||
(cb) => {
|
||||
_listeners.add(cb);
|
||||
return () => _listeners.delete(cb);
|
||||
},
|
||||
() => _locale,
|
||||
);
|
||||
return [locale, setLocale];
|
||||
@@ -82,7 +89,8 @@ const translations: Translations = {
|
||||
"prompt.newPassword": "Введите новый пароль",
|
||||
"prompt.newEmail": "Введите новый email",
|
||||
"prompt.newPhone": "Введите новый номер телефона",
|
||||
"prompt.removePhone": "Введите номер телефона (на который переставится старый)",
|
||||
"prompt.removePhone":
|
||||
"Введите номер телефона (на который переставится старый)",
|
||||
"prompt.enterValue": "Введите значение...",
|
||||
// -- Confirm dialogs --
|
||||
"confirm.removeGuard": "Удалить Steam Guard для этого аккаунта?",
|
||||
@@ -123,6 +131,7 @@ const translations: Translations = {
|
||||
"btn.clearProxies": "Очистить прокси",
|
||||
"btn.validate": "Валидировать",
|
||||
"btn.fullParse": "Полный парс",
|
||||
"btn.fullCheck": "Полная проверка",
|
||||
"btn.generate": "Сгенерировать",
|
||||
// -- Toast messages --
|
||||
"toast.copied": "Скопировано",
|
||||
@@ -142,7 +151,8 @@ const translations: Translations = {
|
||||
"toast.autoAcceptOff": "Авто-принятие входа выключено",
|
||||
"toast.taskCancelled": "Задача отменена",
|
||||
"toast.browserOpening": "Браузер открывается...",
|
||||
"toast.cookiesExpiredRevalidating": "Куки устарели. Запущена повторная валидация...",
|
||||
"toast.cookiesExpiredRevalidating":
|
||||
"Куки устарели. Запущена повторная валидация...",
|
||||
"toast.cookiesExpired": "Куки устарели. Провалидируйте аккаунт заново.",
|
||||
"toast.proxyCleared": "Прокси убран у {name}",
|
||||
"toast.proxiesReassigned": "Переназначено {used} прокси на {count} акк.",
|
||||
@@ -249,7 +259,8 @@ const translations: Translations = {
|
||||
"proxy.checking": "Проверка...",
|
||||
"proxy.checkAll": "Проверить все",
|
||||
"proxy.deleteAllBtn": "Удалить все прокси",
|
||||
"proxy.confirmDeleteAll": "Удалить все прокси? Это действие нельзя отменить.",
|
||||
"proxy.confirmDeleteAll":
|
||||
"Удалить все прокси? Это действие нельзя отменить.",
|
||||
"proxy.checkResult": "Всего: {total} | Живых: {alive} | Мёртвых: {dead}",
|
||||
"proxy.removeProxy": "Убрать прокси",
|
||||
"proxy.reassignProxy": "Переназначить прокси",
|
||||
@@ -262,6 +273,15 @@ const translations: Translations = {
|
||||
"tools.checkBans": "Проверять баны",
|
||||
"tools.maxThreads": "Макс. потоков:",
|
||||
"tools.autoRevalidateBrowser": "Автовалидация при мёртвых куках (браузер)",
|
||||
"tools.autoProxyOnImport": "Авто-назначение прокси при импорте",
|
||||
"tools.autoValidateOnImport": "Авто-валидация при импорте",
|
||||
"tools.importSettings": "Настройки импорта",
|
||||
"tools.autoProxyOnImportDesc": "Авто-назначить прокси сразу после импорта",
|
||||
"tools.autoValidateOnImportDesc":
|
||||
"Авто-валидировать аккаунты сразу после импорта",
|
||||
"tools.importTabMafile": "Mafile",
|
||||
"tools.importTabLogpass": "Log:Pass",
|
||||
"tools.importTabToken": "Token",
|
||||
"tools.clickCopy": "Клик для копирования",
|
||||
"tools.genErrorStr": "Ошибка",
|
||||
// -- Logs panel --
|
||||
@@ -293,7 +313,8 @@ const translations: Translations = {
|
||||
"mafile.clearBtn": "Очистить",
|
||||
"mafile.insert": "Вставить {value}",
|
||||
"mafile.emptyTitle": "Нет выбранных аккаунтов",
|
||||
"mafile.emptyHint": "Выберите аккаунты в таблице и отправьте сюда для экспорта mafile",
|
||||
"mafile.emptyHint":
|
||||
"Выберите аккаунты в таблице и отправьте сюда для экспорта mafile",
|
||||
"mafile.accountsCount": "акк.",
|
||||
"mafile.withMafile": "с mafile",
|
||||
"mafile.namingSection": "Шаблоны имён",
|
||||
@@ -305,9 +326,12 @@ const translations: Translations = {
|
||||
"settings.hidePasswords": "Скрывать пароли (спойлер)",
|
||||
// -- Token disclaimer --
|
||||
"token.disclaimerTitle": "Вкладка в разработке",
|
||||
"token.disclaimerBody1": "Функциональность вкладки Token <strong>не завершена</strong> и находится в стадии разработки.",
|
||||
"token.disclaimerBody2": "⚠ Валидация токенов может привести к их инвалидации (убить токены). Используйте на свой страх и риск.",
|
||||
"token.disclaimerBody3": "Импорт, редактирование и любые действия с токенами могут работать некорректно.",
|
||||
"token.disclaimerBody1":
|
||||
"Функциональность вкладки Token <strong>не завершена</strong> и находится в стадии разработки.",
|
||||
"token.disclaimerBody2":
|
||||
"⚠ Валидация токенов может привести к их инвалидации (убить токены). Используйте на свой страх и риск.",
|
||||
"token.disclaimerBody3":
|
||||
"Импорт, редактирование и любые действия с токенами могут работать некорректно.",
|
||||
"token.acceptRisk": "Я понимаю риски и хочу продолжить",
|
||||
// -- 2FA --
|
||||
"twofa.copied": "2FA: {code} (скопировано)",
|
||||
@@ -377,7 +401,8 @@ const translations: Translations = {
|
||||
"prompt.newPassword": "Enter new password",
|
||||
"prompt.newEmail": "Enter new email",
|
||||
"prompt.newPhone": "Enter new phone number",
|
||||
"prompt.removePhone": "Enter phone number (old number will be transferred to it)",
|
||||
"prompt.removePhone":
|
||||
"Enter phone number (old number will be transferred to it)",
|
||||
"prompt.enterValue": "Enter value...",
|
||||
// -- Confirm dialogs --
|
||||
"confirm.removeGuard": "Remove Steam Guard for this account?",
|
||||
@@ -418,6 +443,7 @@ const translations: Translations = {
|
||||
"btn.clearProxies": "Clear proxies",
|
||||
"btn.validate": "Validate",
|
||||
"btn.fullParse": "Full parse",
|
||||
"btn.fullCheck": "Full Check",
|
||||
"btn.generate": "Generate",
|
||||
// -- Toast messages --
|
||||
"toast.copied": "Copied",
|
||||
@@ -557,6 +583,15 @@ const translations: Translations = {
|
||||
"tools.checkBans": "Check bans",
|
||||
"tools.maxThreads": "Max threads:",
|
||||
"tools.autoRevalidateBrowser": "Auto-revalidate on dead cookies (browser)",
|
||||
"tools.autoProxyOnImport": "Auto-assign proxies on import",
|
||||
"tools.autoValidateOnImport": "Auto-validate on import",
|
||||
"tools.importSettings": "Import Settings",
|
||||
"tools.autoProxyOnImportDesc": "Auto-assign proxies right after import",
|
||||
"tools.autoValidateOnImportDesc":
|
||||
"Auto-validate accounts right after import",
|
||||
"tools.importTabMafile": "Mafile",
|
||||
"tools.importTabLogpass": "Log:Pass",
|
||||
"tools.importTabToken": "Token",
|
||||
"tools.clickCopy": "Click to copy",
|
||||
"tools.genErrorStr": "Error",
|
||||
// -- Logs panel --
|
||||
@@ -588,7 +623,8 @@ const translations: Translations = {
|
||||
"mafile.clearBtn": "Clear",
|
||||
"mafile.insert": "Insert {value}",
|
||||
"mafile.emptyTitle": "No accounts selected",
|
||||
"mafile.emptyHint": "Select accounts in the table and send them here for mafile export",
|
||||
"mafile.emptyHint":
|
||||
"Select accounts in the table and send them here for mafile export",
|
||||
"mafile.accountsCount": "accs",
|
||||
"mafile.withMafile": "with mafile",
|
||||
"mafile.namingSection": "Naming templates",
|
||||
@@ -600,9 +636,12 @@ const translations: Translations = {
|
||||
"settings.hidePasswords": "Hide passwords (spoiler)",
|
||||
// -- Token disclaimer --
|
||||
"token.disclaimerTitle": "Tab under development",
|
||||
"token.disclaimerBody1": "Token tab functionality is <strong>not complete</strong> and is under development.",
|
||||
"token.disclaimerBody2": "⚠ Token validation may invalidate (kill) tokens. Use at your own risk.",
|
||||
"token.disclaimerBody3": "Import, editing and any actions with tokens may work incorrectly.",
|
||||
"token.disclaimerBody1":
|
||||
"Token tab functionality is <strong>not complete</strong> and is under development.",
|
||||
"token.disclaimerBody2":
|
||||
"⚠ Token validation may invalidate (kill) tokens. Use at your own risk.",
|
||||
"token.disclaimerBody3":
|
||||
"Import, editing and any actions with tokens may work incorrectly.",
|
||||
"token.acceptRisk": "I understand the risks and want to continue",
|
||||
// -- 2FA --
|
||||
"twofa.copied": "2FA: {code} (copied)",
|
||||
@@ -620,7 +659,10 @@ const translations: Translations = {
|
||||
},
|
||||
};
|
||||
|
||||
export function t(key: string, params?: Record<string, string | number>): string {
|
||||
export function t(
|
||||
key: string,
|
||||
params?: Record<string, string | number>,
|
||||
): string {
|
||||
const dict = translations[_locale] || translations.ru;
|
||||
let text = dict[key] ?? translations.ru[key] ?? key;
|
||||
if (params) {
|
||||
@@ -633,7 +675,10 @@ export function t(key: string, params?: Record<string, string | number>): string
|
||||
|
||||
export function useT() {
|
||||
useSyncExternalStore(
|
||||
(cb) => { _listeners.add(cb); return () => _listeners.delete(cb); },
|
||||
(cb) => {
|
||||
_listeners.add(cb);
|
||||
return () => _listeners.delete(cb);
|
||||
},
|
||||
() => _locale,
|
||||
);
|
||||
return t;
|
||||
|
||||
@@ -11,6 +11,7 @@ interface LogpassState {
|
||||
toggleSelect: (id: number) => void;
|
||||
selectAll: () => void;
|
||||
clearSelection: () => void;
|
||||
setSelectedIds: (ids: Set<number>) => void;
|
||||
}
|
||||
|
||||
export const useLogpassStore = create<LogpassState>((set) => ({
|
||||
@@ -40,4 +41,5 @@ export const useLogpassStore = create<LogpassState>((set) => ({
|
||||
set((s) => ({ selectedIds: new Set(s.accounts.map((a) => a.id)) })),
|
||||
|
||||
clearSelection: () => set({ selectedIds: new Set() }),
|
||||
setSelectedIds: (ids) => set({ selectedIds: ids }),
|
||||
}));
|
||||
|
||||
@@ -11,6 +11,7 @@ interface TokenState {
|
||||
toggleSelect: (id: number) => void;
|
||||
selectAll: () => void;
|
||||
clearSelection: () => void;
|
||||
setSelectedIds: (ids: Set<number>) => void;
|
||||
}
|
||||
|
||||
export const useTokenStore = create<TokenState>((set) => ({
|
||||
@@ -40,4 +41,5 @@ export const useTokenStore = create<TokenState>((set) => ({
|
||||
set((s) => ({ selectedIds: new Set(s.accounts.map((a) => a.id)) })),
|
||||
|
||||
clearSelection: () => set({ selectedIds: new Set() }),
|
||||
setSelectedIds: (ids) => set({ selectedIds: ids }),
|
||||
}));
|
||||
|
||||
+112
-18
@@ -1,26 +1,67 @@
|
||||
import { create } from "zustand";
|
||||
import type { TabId, Toast, ToastType, ColumnSettings, LogpassColumnSettings, TokenColumnSettings } from "@/api/types";
|
||||
import type {
|
||||
TabId,
|
||||
Toast,
|
||||
ToastType,
|
||||
ColumnSettings,
|
||||
LogpassColumnSettings,
|
||||
TokenColumnSettings,
|
||||
} from "@/api/types";
|
||||
import { api } from "@/api/client";
|
||||
|
||||
let _toastId = 0;
|
||||
|
||||
const DEFAULT_COLUMNS: ColumnSettings = {
|
||||
browser: true, profile: true, steam_id: true, login: true, password: true,
|
||||
login_pass: true, email: true, email_pass: false, email_login_pass: false,
|
||||
phone: true, status: true,
|
||||
ban: true, twofa: true, mafile: true, proxy: true, notes: true, actions: true,
|
||||
browser: true,
|
||||
profile: true,
|
||||
steam_id: true,
|
||||
login: true,
|
||||
password: true,
|
||||
login_pass: true,
|
||||
email: true,
|
||||
email_pass: false,
|
||||
email_login_pass: false,
|
||||
phone: true,
|
||||
status: true,
|
||||
ban: true,
|
||||
twofa: true,
|
||||
mafile: true,
|
||||
proxy: true,
|
||||
notes: true,
|
||||
actions: true,
|
||||
last_online: true,
|
||||
};
|
||||
|
||||
const DEFAULT_LOGPASS_COLUMNS: LogpassColumnSettings = {
|
||||
browser: true, profile: true, steam_id: true, login: true, password: true, login_pass: true,
|
||||
status: true, ban: true, prime: true, trophy: true, behavior: true,
|
||||
license: true, proxy: true, notes: true, actions: true, last_online: true,
|
||||
browser: true,
|
||||
profile: true,
|
||||
steam_id: true,
|
||||
login: true,
|
||||
password: true,
|
||||
login_pass: true,
|
||||
status: true,
|
||||
ban: true,
|
||||
prime: true,
|
||||
trophy: true,
|
||||
behavior: true,
|
||||
license: true,
|
||||
proxy: true,
|
||||
notes: true,
|
||||
actions: true,
|
||||
last_online: true,
|
||||
};
|
||||
|
||||
const DEFAULT_TOKEN_COLUMNS: TokenColumnSettings = {
|
||||
browser: true, profile: true, last_online: true, steam_id: true, login: true,
|
||||
token: true, status: true, proxy: true, notes: true, actions: true,
|
||||
browser: true,
|
||||
profile: true,
|
||||
last_online: true,
|
||||
steam_id: true,
|
||||
login: true,
|
||||
token: true,
|
||||
status: true,
|
||||
proxy: true,
|
||||
notes: true,
|
||||
actions: true,
|
||||
};
|
||||
|
||||
interface UiState {
|
||||
@@ -41,6 +82,15 @@ interface UiState {
|
||||
logpassColumnVisibility: LogpassColumnSettings;
|
||||
toggleLogpassColumn: (key: keyof LogpassColumnSettings) => void;
|
||||
loadLogpassColumnSettings: () => Promise<void>;
|
||||
autoProxyOnImport: boolean;
|
||||
autoValidateOnImport: boolean;
|
||||
autoProxyOnImportMafile: boolean;
|
||||
autoProxyOnImportLogpass: boolean;
|
||||
autoProxyOnImportToken: boolean;
|
||||
autoValidateOnImportMafile: boolean;
|
||||
autoValidateOnImportLogpass: boolean;
|
||||
autoValidateOnImportToken: boolean;
|
||||
loadImportSettings: () => Promise<void>;
|
||||
tokenColumnVisibility: TokenColumnSettings;
|
||||
toggleTokenColumn: (key: keyof TokenColumnSettings) => void;
|
||||
loadTokenColumnSettings: () => Promise<void>;
|
||||
@@ -59,7 +109,8 @@ export const useUiStore = create<UiState>((set, get) => ({
|
||||
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
|
||||
}, 4000);
|
||||
},
|
||||
removeToast: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
|
||||
removeToast: (id) =>
|
||||
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
|
||||
|
||||
hidePasswords: false,
|
||||
setHidePasswords: (v) => {
|
||||
@@ -70,13 +121,18 @@ export const useUiStore = create<UiState>((set, get) => ({
|
||||
try {
|
||||
const d = await api.getDisplaySettings();
|
||||
set({ hidePasswords: d.hide_passwords });
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
|
||||
columnVisibility: { ...DEFAULT_COLUMNS },
|
||||
setColumnVisibility: (v) => set({ columnVisibility: v }),
|
||||
toggleColumn: (key) => {
|
||||
const cols = { ...get().columnVisibility, [key]: !get().columnVisibility[key] };
|
||||
const cols = {
|
||||
...get().columnVisibility,
|
||||
[key]: !get().columnVisibility[key],
|
||||
};
|
||||
set({ columnVisibility: cols });
|
||||
api.updateColumnSettings(cols).catch(() => {});
|
||||
},
|
||||
@@ -84,12 +140,17 @@ export const useUiStore = create<UiState>((set, get) => ({
|
||||
try {
|
||||
const c = await api.getColumnSettings();
|
||||
set({ columnVisibility: { ...DEFAULT_COLUMNS, ...c } });
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
|
||||
logpassColumnVisibility: { ...DEFAULT_LOGPASS_COLUMNS },
|
||||
toggleLogpassColumn: (key) => {
|
||||
const cols = { ...get().logpassColumnVisibility, [key]: !get().logpassColumnVisibility[key] };
|
||||
const cols = {
|
||||
...get().logpassColumnVisibility,
|
||||
[key]: !get().logpassColumnVisibility[key],
|
||||
};
|
||||
set({ logpassColumnVisibility: cols });
|
||||
api.updateLogpassColumnSettings(cols).catch(() => {});
|
||||
},
|
||||
@@ -97,12 +158,43 @@ export const useUiStore = create<UiState>((set, get) => ({
|
||||
try {
|
||||
const c = await api.getLogpassColumnSettings();
|
||||
set({ logpassColumnVisibility: { ...DEFAULT_LOGPASS_COLUMNS, ...c } });
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
|
||||
autoProxyOnImport: false,
|
||||
autoValidateOnImport: false,
|
||||
autoProxyOnImportMafile: true,
|
||||
autoProxyOnImportLogpass: true,
|
||||
autoProxyOnImportToken: true,
|
||||
autoValidateOnImportMafile: true,
|
||||
autoValidateOnImportLogpass: true,
|
||||
autoValidateOnImportToken: true,
|
||||
loadImportSettings: async () => {
|
||||
try {
|
||||
const s = await api.getValidationSettings();
|
||||
set({
|
||||
autoProxyOnImport: s.auto_proxy_on_import,
|
||||
autoValidateOnImport: s.auto_validate_on_import,
|
||||
autoProxyOnImportMafile: s.auto_proxy_on_import_mafile,
|
||||
autoProxyOnImportLogpass: s.auto_proxy_on_import_logpass,
|
||||
autoProxyOnImportToken: s.auto_proxy_on_import_token,
|
||||
autoValidateOnImportMafile: s.auto_validate_on_import_mafile,
|
||||
autoValidateOnImportLogpass: s.auto_validate_on_import_logpass,
|
||||
autoValidateOnImportToken: s.auto_validate_on_import_token,
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
|
||||
tokenColumnVisibility: { ...DEFAULT_TOKEN_COLUMNS },
|
||||
toggleTokenColumn: (key) => {
|
||||
const cols = { ...get().tokenColumnVisibility, [key]: !get().tokenColumnVisibility[key] };
|
||||
const cols = {
|
||||
...get().tokenColumnVisibility,
|
||||
[key]: !get().tokenColumnVisibility[key],
|
||||
};
|
||||
set({ tokenColumnVisibility: cols });
|
||||
api.updateTokenColumnSettings(cols).catch(() => {});
|
||||
},
|
||||
@@ -110,6 +202,8 @@ export const useUiStore = create<UiState>((set, get) => ({
|
||||
try {
|
||||
const c = await api.getTokenColumnSettings();
|
||||
set({ tokenColumnVisibility: { ...DEFAULT_TOKEN_COLUMNS, ...c } });
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user