This commit is contained in:
Manchik
2026-05-08 22:46:47 +03:00
parent 9b7001407f
commit 13dc22f74f
30 changed files with 4107 additions and 843 deletions
+16
View File
@@ -16,6 +16,14 @@ DEFAULTS = {
"check_ban": True, "check_ban": True,
"max_threads": 5, "max_threads": 5,
"auto_revalidate_browser": True, "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,
}, },
"display": { "display": {
"hide_passwords": False, "hide_passwords": False,
@@ -84,6 +92,14 @@ class ValidationSettings(BaseModel):
check_ban: bool = True check_ban: bool = True
max_threads: int = 5 max_threads: int = 5
auto_revalidate_browser: bool = True auto_revalidate_browser: bool = True
auto_proxy_on_import: bool = False
auto_validate_on_import: bool = False
auto_proxy_on_import_mafile: bool = True
auto_proxy_on_import_logpass: bool = True
auto_proxy_on_import_token: bool = True
auto_validate_on_import_mafile: bool = True
auto_validate_on_import_logpass: bool = True
auto_validate_on_import_token: bool = True
class DisplaySettings(BaseModel): class DisplaySettings(BaseModel):
+104 -13
View File
@@ -3,10 +3,11 @@
import asyncio import asyncio
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from fastapi.responses import Response
from loguru import logger from loguru import logger
from app.database import get_db
from app.core.task_manager import task_manager from app.core.task_manager import task_manager
from app.database import get_db
from app.models import TokenAccountCreate, TokenAccountOut, TokenAccountUpdate from app.models import TokenAccountCreate, TokenAccountOut, TokenAccountUpdate
router = APIRouter(prefix="/api/token-accounts", tags=["token-accounts"]) router = APIRouter(prefix="/api/token-accounts", tags=["token-accounts"])
@@ -23,7 +24,9 @@ async def list_tokens():
@router.get("/{account_id}", response_model=TokenAccountOut) @router.get("/{account_id}", response_model=TokenAccountOut)
async def get_token(account_id: int): async def get_token(account_id: int):
db = await get_db() db = await get_db()
cursor = await db.execute("SELECT * FROM token_accounts WHERE id = ?", (account_id,)) cursor = await db.execute(
"SELECT * FROM token_accounts WHERE id = ?", (account_id,)
)
row = await cursor.fetchone() row = await cursor.fetchone()
if not row: if not row:
raise HTTPException(status_code=404, detail="Account not found") raise HTTPException(status_code=404, detail="Account not found")
@@ -52,14 +55,15 @@ async def update_token(account_id: int, account: TokenAccountUpdate):
raise HTTPException(status_code=400, detail="No fields to update") raise HTTPException(status_code=400, detail="No fields to update")
fields["updated_at"] = "datetime('now')" fields["updated_at"] = "datetime('now')"
set_clause = ", ".join( set_clause = ", ".join(
f"{k} = datetime('now')" if k == "updated_at" else f"{k} = ?" f"{k} = datetime('now')" if k == "updated_at" else f"{k} = ?" for k in fields
for k in fields
) )
values = [v for k, v in fields.items() if k != "updated_at"] values = [v for k, v in fields.items() if k != "updated_at"]
values.append(account_id) values.append(account_id)
await db.execute(f"UPDATE token_accounts SET {set_clause} WHERE id = ?", values) await db.execute(f"UPDATE token_accounts SET {set_clause} WHERE id = ?", values)
await db.commit() await db.commit()
cursor = await db.execute("SELECT * FROM token_accounts WHERE id = ?", (account_id,)) cursor = await db.execute(
"SELECT * FROM token_accounts WHERE id = ?", (account_id,)
)
row = await cursor.fetchone() row = await cursor.fetchone()
if not row: if not row:
raise HTTPException(status_code=404, detail="Account not found") raise HTTPException(status_code=404, detail="Account not found")
@@ -140,7 +144,9 @@ async def validate_tokens(data: dict):
accounts=accounts, accounts=accounts,
params={}, params={},
) )
logger.info(f"token_validate submitted for {len(accounts)} accounts → task {task_id}") logger.info(
f"token_validate submitted for {len(accounts)} accounts → task {task_id}"
)
return {"task_id": task_id, "accounts_count": len(accounts)} return {"task_id": task_id, "accounts_count": len(accounts)}
@@ -148,17 +154,21 @@ async def validate_tokens(data: dict):
async def open_token_browser(account_id: int): async def open_token_browser(account_id: int):
"""Open Chrome browser with saved session cookies for a token account.""" """Open Chrome browser with saved session cookies for a token account."""
db = await get_db() db = await get_db()
cursor = await db.execute("SELECT * FROM token_accounts WHERE id = ?", (account_id,)) cursor = await db.execute(
"SELECT * FROM token_accounts WHERE id = ?", (account_id,)
)
row = await cursor.fetchone() row = await cursor.fetchone()
if not row: if not row:
raise HTTPException(status_code=404, detail="Account not found") raise HTTPException(status_code=404, detail="Account not found")
account = dict(row) account = dict(row)
if not account.get("session_cookies"): if not account.get("session_cookies"):
raise HTTPException(status_code=400, detail="No session cookies. Validate the account first.") raise HTTPException(
status_code=400, detail="No session cookies. Validate the account first."
)
from app.services.steam_auth import check_cookies_alive, _resolve_proxy
from app.config import read_validation_settings from app.config import read_validation_settings
from app.services.steam_auth import _resolve_proxy, check_cookies_alive
proxy = await _resolve_proxy(account) proxy = await _resolve_proxy(account)
alive = await check_cookies_alive(account["session_cookies"], proxy) alive = await check_cookies_alive(account["session_cookies"], proxy)
@@ -170,9 +180,17 @@ async def open_token_browser(account_id: int):
accounts=[account], accounts=[account],
params={}, params={},
) )
logger.info(f"Cookies dead for token account {account_id}, auto-revalidating → task {task_id}") logger.info(
return {"status": "revalidating", "message": "Cookies expired. Re-validating...", "task_id": task_id} f"Cookies dead for token account {account_id}, auto-revalidating → task {task_id}"
raise HTTPException(status_code=400, detail="Session cookies expired. Re-validate the account.") )
return {
"status": "revalidating",
"message": "Cookies expired. Re-validating...",
"task_id": task_id,
}
raise HTTPException(
status_code=400, detail="Session cookies expired. Re-validate the account."
)
from app.services.browser_login import open_browser_with_cookies from app.services.browser_login import open_browser_with_cookies
@@ -180,12 +198,85 @@ async def open_token_browser(account_id: int):
try: try:
await open_browser_with_cookies(account) await open_browser_with_cookies(account)
except Exception as exc: except Exception as exc:
logger.error(f"Browser open failed for {account.get('login', account_id)}: {exc}") logger.error(
f"Browser open failed for {account.get('login', account_id)}: {exc}"
)
asyncio.create_task(_run()) asyncio.create_task(_run())
return {"status": "ok", "message": "Browser opening..."} return {"status": "ok", "message": "Browser opening..."}
@router.post("/full-check")
async def full_check_tokens(data: dict):
"""Submit a full-check task for selected token accounts."""
ids: list[int] = data.get("account_ids", [])
if not ids:
raise HTTPException(status_code=400, detail="No account IDs provided")
db = await get_db()
placeholders = ",".join("?" for _ in ids)
cursor = await db.execute(
f"SELECT * FROM token_accounts WHERE id IN ({placeholders})", ids
)
accounts = [dict(r) for r in await cursor.fetchall()]
if not accounts:
raise HTTPException(status_code=404, detail="No accounts found")
task_id = await task_manager.submit(
task_type="token_full_check",
accounts=accounts,
params={},
)
logger.info(
f"token_full_check submitted for {len(accounts)} accounts → task {task_id}"
)
return {"task_id": task_id, "accounts_count": len(accounts)}
@router.get("/{account_id}/check-data")
async def get_token_check_data(account_id: int):
"""Return stored check_data JSON for a token account."""
import json
db = await get_db()
cursor = await db.execute(
"SELECT check_data FROM token_accounts WHERE id = ?", (account_id,)
)
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Account not found")
check_data_str = row["check_data"]
if not check_data_str:
raise HTTPException(
status_code=404,
detail="No check data available. Run a full check first.",
)
return json.loads(check_data_str)
@router.get("/{account_id}/cookies")
async def download_token_cookies(account_id: int):
"""Download session_cookies as a .json file."""
db = await get_db()
cursor = await db.execute(
"SELECT login, steam_id, session_cookies FROM token_accounts WHERE id = ?",
(account_id,),
)
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Account not found")
cookies = row["session_cookies"]
if not cookies:
raise HTTPException(
status_code=404, detail="No cookies saved. Validate the account first."
)
login = row["login"] or row["steam_id"] or str(account_id)
filename = f"{login}_cookies.json"
return Response(
content=cookies,
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/assign-proxies") @router.post("/assign-proxies")
async def token_assign_proxies(): async def token_assign_proxies():
"""Round-robin assign proxies to token accounts that don't have one.""" """Round-robin assign proxies to token accounts that don't have one."""
+9
View File
@@ -1,4 +1,5 @@
from pathlib import Path from pathlib import Path
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
@@ -32,6 +33,14 @@ _VALIDATION_DEFAULTS: dict = {
"max_threads": 10, "max_threads": 10,
"account_timeout": 90, "account_timeout": 90,
"auto_revalidate_browser": True, "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,
} }
+21 -7
View File
@@ -1,10 +1,10 @@
import asyncio
import aiosqlite import aiosqlite
from loguru import logger from loguru import logger
from app.config import settings from app.config import settings
import asyncio
_db: aiosqlite.Connection | None = None _db: aiosqlite.Connection | None = None
_db_lock = asyncio.Lock() _db_lock = asyncio.Lock()
@@ -124,7 +124,10 @@ async def _migrate(db: aiosqlite.Connection) -> None:
("nickname", "ALTER TABLE accounts ADD COLUMN nickname TEXT"), ("nickname", "ALTER TABLE accounts ADD COLUMN nickname TEXT"),
("avatar_url", "ALTER TABLE accounts ADD COLUMN avatar_url TEXT"), ("avatar_url", "ALTER TABLE accounts ADD COLUMN avatar_url TEXT"),
("steam_level", "ALTER TABLE accounts ADD COLUMN steam_level INTEGER"), ("steam_level", "ALTER TABLE accounts ADD COLUMN steam_level INTEGER"),
("auto_accept", "ALTER TABLE accounts ADD COLUMN auto_accept INTEGER NOT NULL DEFAULT 0"), (
"auto_accept",
"ALTER TABLE accounts ADD COLUMN auto_accept INTEGER NOT NULL DEFAULT 0",
),
("ban_status", "ALTER TABLE accounts ADD COLUMN ban_status TEXT"), ("ban_status", "ALTER TABLE accounts ADD COLUMN ban_status TEXT"),
("session_cookies", "ALTER TABLE accounts ADD COLUMN session_cookies TEXT"), ("session_cookies", "ALTER TABLE accounts ADD COLUMN session_cookies TEXT"),
("last_online", "ALTER TABLE accounts ADD COLUMN last_online TEXT"), ("last_online", "ALTER TABLE accounts ADD COLUMN last_online TEXT"),
@@ -153,7 +156,10 @@ async def _migrate(db: aiosqlite.Connection) -> None:
("trophy", "ALTER TABLE logpass_accounts ADD COLUMN trophy TEXT"), ("trophy", "ALTER TABLE logpass_accounts ADD COLUMN trophy TEXT"),
("behavior", "ALTER TABLE logpass_accounts ADD COLUMN behavior TEXT"), ("behavior", "ALTER TABLE logpass_accounts ADD COLUMN behavior TEXT"),
("license", "ALTER TABLE logpass_accounts ADD COLUMN license TEXT"), ("license", "ALTER TABLE logpass_accounts ADD COLUMN license TEXT"),
("session_cookies", "ALTER TABLE logpass_accounts ADD COLUMN session_cookies TEXT"), (
"session_cookies",
"ALTER TABLE logpass_accounts ADD COLUMN session_cookies TEXT",
),
("steam_level", "ALTER TABLE logpass_accounts ADD COLUMN steam_level INTEGER"), ("steam_level", "ALTER TABLE logpass_accounts ADD COLUMN steam_level INTEGER"),
("avatar_url", "ALTER TABLE logpass_accounts ADD COLUMN avatar_url TEXT"), ("avatar_url", "ALTER TABLE logpass_accounts ADD COLUMN avatar_url TEXT"),
("last_online", "ALTER TABLE logpass_accounts ADD COLUMN last_online TEXT"), ("last_online", "ALTER TABLE logpass_accounts ADD COLUMN last_online TEXT"),
@@ -168,12 +174,16 @@ async def _migrate(db: aiosqlite.Connection) -> None:
tk_cols = {row[1] for row in await cursor.fetchall()} tk_cols = {row[1] for row in await cursor.fetchall()}
tk_migrations = [ tk_migrations = [
("proxy", "ALTER TABLE token_accounts ADD COLUMN proxy TEXT"), ("proxy", "ALTER TABLE token_accounts ADD COLUMN proxy TEXT"),
("session_cookies", "ALTER TABLE token_accounts ADD COLUMN session_cookies TEXT"), (
"session_cookies",
"ALTER TABLE token_accounts ADD COLUMN session_cookies TEXT",
),
("ban_status", "ALTER TABLE token_accounts ADD COLUMN ban_status TEXT"), ("ban_status", "ALTER TABLE token_accounts ADD COLUMN ban_status TEXT"),
("nickname", "ALTER TABLE token_accounts ADD COLUMN nickname TEXT"), ("nickname", "ALTER TABLE token_accounts ADD COLUMN nickname TEXT"),
("steam_level", "ALTER TABLE token_accounts ADD COLUMN steam_level INTEGER"), ("steam_level", "ALTER TABLE token_accounts ADD COLUMN steam_level INTEGER"),
("avatar_url", "ALTER TABLE token_accounts ADD COLUMN avatar_url TEXT"), ("avatar_url", "ALTER TABLE token_accounts ADD COLUMN avatar_url TEXT"),
("last_online", "ALTER TABLE token_accounts ADD COLUMN last_online TEXT"), ("last_online", "ALTER TABLE token_accounts ADD COLUMN last_online TEXT"),
("check_data", "ALTER TABLE token_accounts ADD COLUMN check_data TEXT"),
] ]
for col, sql in tk_migrations: for col, sql in tk_migrations:
if col not in tk_cols: if col not in tk_cols:
@@ -186,10 +196,14 @@ async def _migrate(db: aiosqlite.Connection) -> None:
idx_names = {row[1] for row in idx_rows} idx_names = {row[1] for row in idx_rows}
if "uq_logpass_login" not in idx_names: if "uq_logpass_login" not in idx_names:
try: try:
await db.execute("CREATE UNIQUE INDEX uq_logpass_login ON logpass_accounts(login)") await db.execute(
"CREATE UNIQUE INDEX uq_logpass_login ON logpass_accounts(login)"
)
logger.info("Migration: added unique index on logpass_accounts.login") logger.info("Migration: added unique index on logpass_accounts.login")
except Exception: except Exception:
logger.warning("Could not create unique index on logpass_accounts.login (duplicates may exist)") logger.warning(
"Could not create unique index on logpass_accounts.login (duplicates may exist)"
)
async def close_db() -> None: async def close_db() -> None:
+6 -4
View File
@@ -1,10 +1,11 @@
from app.core.task_manager import task_manager from app.core.task_manager import task_manager
from app.services.steam_guard import remove_guard, generate_2fa
from app.services.steam_password import change_password, random_password
from app.services.steam_email import change_email, validate_account
from app.services.steam_phone import change_phone
from app.services.steam_checker import check_logpass_account, full_parse_logpass_account from app.services.steam_checker import check_logpass_account, full_parse_logpass_account
from app.services.steam_email import change_email, validate_account
from app.services.steam_guard import generate_2fa, remove_guard
from app.services.steam_password import change_password, random_password
from app.services.steam_phone import change_phone
from app.services.steam_token_checker import check_token_account from app.services.steam_token_checker import check_token_account
from app.services.steam_token_full_checker import full_check_token_account
def register_all_handlers() -> None: def register_all_handlers() -> None:
@@ -18,3 +19,4 @@ def register_all_handlers() -> None:
task_manager.register_handler("logpass_validate", check_logpass_account) task_manager.register_handler("logpass_validate", check_logpass_account)
task_manager.register_handler("logpass_full_parse", full_parse_logpass_account) task_manager.register_handler("logpass_full_parse", full_parse_logpass_account)
task_manager.register_handler("token_validate", check_token_account) task_manager.register_handler("token_validate", check_token_account)
task_manager.register_handler("token_full_check", full_check_token_account)
+677
View File
@@ -0,0 +1,677 @@
"""Full-check service for token accounts.
Performs the finalizelogin flow to obtain authenticated cookies, then calls
multiple Steam Web APIs in parallel to collect detailed account information,
and stores the result as JSON in the token_accounts.check_data column.
"""
from __future__ import annotations
import asyncio
import json as _json
import re
import urllib.parse
from datetime import datetime
import aiohttp
from aiohttp import FormData
from loguru import logger
from app.core.proxy_manager import proxy_manager
from app.core.task_manager import task_manager
from app.services.steam_auth import _resolve_proxy
from app.services.steam_profile import (
_parse_avatar,
_parse_last_online,
_parse_level,
_parse_persona_name,
)
from pysteamauth.auth.schemas import FinalizeLoginStatus
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _extract_web_token(jar: aiohttp.CookieJar) -> str | None:
"""Extract the JWT access token from the steamLoginSecure cookie."""
for cookie in jar:
if cookie.key == "steamLoginSecure" and cookie.value:
raw = urllib.parse.unquote(cookie.value)
if "||" in raw:
return raw.split("||", 1)[1]
return None
def _build_cookie_jar_from_list(
cookies: list[dict],
) -> aiohttp.CookieJar:
"""Reconstruct an aiohttp CookieJar from a list of cookie dicts."""
jar = aiohttp.CookieJar(unsafe=True)
import yarl
for c in cookies:
domain = c.get("domain", ".steamcommunity.com").lstrip(".")
scheme = "https" if c.get("secure", True) else "http"
jar.update_cookies(
{c["name"]: c["value"]},
response_url=yarl.URL(f"{scheme}://{domain}/"),
)
return jar
# ---------------------------------------------------------------------------
# Individual API fetchers (each returns a partial dict or raises)
# ---------------------------------------------------------------------------
async def _fetch_wallet(session: aiohttp.ClientSession, web_token: str) -> dict:
"""POST wallet details → balance_raw, user_country."""
url = (
"https://api.steampowered.com/IUserAccountService/GetClientWalletDetails/v1/"
f"?access_token={web_token}"
)
try:
async with session.post(url, timeout=aiohttp.ClientTimeout(total=15)) as resp:
data = await resp.json(content_type=None)
inner = data.get("response", {})
balance_raw = inner.get("formatted_balance") or None
if not balance_raw:
# Construct from balance (cents) and currency if formatted_balance missing
cents = inner.get("balance", 0)
currency = inner.get("wallet_currency", "")
if cents:
balance_raw = f"{cents / 100:.2f} {currency}".strip()
return {
"balance_raw": balance_raw,
"user_country": inner.get("user_country") or None,
}
except Exception as exc:
logger.debug(f"[full_check] wallet API error: {exc}")
return {"balance_raw": None, "user_country": None}
async def _fetch_family_group(session: aiohttp.ClientSession, web_token: str) -> dict:
"""GET family group → family_group (bool)."""
url = (
"https://api.steampowered.com/IFamilyGroupsService/GetFamilyGroupForUser/v1/"
f"?access_token={web_token}"
)
try:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as resp:
data = await resp.json(content_type=None)
inner = data.get("response", {})
# If the account is in a family group there will be a family_groupid
has_group = bool(inner.get("family_groupid") or inner.get("family_group"))
return {"family_group": has_group}
except Exception as exc:
logger.debug(f"[full_check] family group API error: {exc}")
return {"family_group": False}
async def _fetch_recently_played(
session: aiohttp.ClientSession, web_token: str, steam_id: str
) -> dict:
"""GET recently played games → playtime_2weeks (total minutes)."""
url = (
"https://api.steampowered.com/IPlayerService/GetRecentlyPlayedGames/v1/"
f"?access_token={web_token}&steamid={steam_id}"
)
try:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as resp:
data = await resp.json(content_type=None)
games = data.get("response", {}).get("games", []) or []
total_minutes = sum(g.get("playtime_2weeks", 0) for g in games)
return {"playtime_2weeks": total_minutes}
except Exception as exc:
logger.debug(f"[full_check] recently played API error: {exc}")
return {"playtime_2weeks": 0}
async def _fetch_inventory(
session: aiohttp.ClientSession, steam_id: str, appid: int
) -> tuple[int, int]:
"""GET inventory → (total_count, marketable_count).
Uses count=1 to quickly fetch total_inventory_count.
Marketable count is set to 0 (requires full inventory scan).
"""
url = f"https://steamcommunity.com/inventory/{steam_id}/{appid}/2?l=english&count=1"
try:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=20)) as resp:
if resp.status == 403:
# Private inventory
return (0, 0)
if resp.status != 200:
return (0, 0)
data = await resp.json(content_type=None)
total = data.get("total_inventory_count", 0) if data else 0
return (total, 0)
except Exception as exc:
logger.debug(f"[full_check] inventory appid={appid} error: {exc}")
return (0, 0)
async def _fetch_profile_xml(session: aiohttp.ClientSession, steam_id: str) -> dict:
"""GET profile XML → trade_ban, created_date."""
url = f"https://steamcommunity.com/profiles/{steam_id}?xml=1"
try:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as resp:
xml = await resp.text()
# Trade ban
trade_ban: str | None = None
m = re.search(r"<tradeBanState>(.*?)</tradeBanState>", xml)
if m:
trade_ban = m.group(1).strip() or None
# Member since (creation date)
created_date: str | None = None
m = re.search(r"<memberSince>(.*?)</memberSince>", xml)
if m:
created_date = m.group(1).strip() or None
return {"trade_ban": trade_ban, "created_date": created_date}
except Exception as exc:
logger.debug(f"[full_check] profile XML error: {exc}")
return {"trade_ban": None, "created_date": None}
async def _fetch_profile_html(session: aiohttp.ClientSession, steam_id: str) -> dict:
"""GET profile HTML → display_name, avatar_url, steam_level, last_online."""
url = f"https://steamcommunity.com/profiles/{steam_id}/"
try:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=20)) as resp:
if resp.status != 200:
return {
"display_name": None,
"avatar_url": None,
"steam_level": None,
"last_online": None,
}
html = await resp.text()
return {
"display_name": _parse_persona_name(html),
"avatar_url": _parse_avatar(html),
"steam_level": _parse_level(html),
"last_online": _parse_last_online(html),
}
except Exception as exc:
logger.debug(f"[full_check] profile HTML error: {exc}")
return {
"display_name": None,
"avatar_url": None,
"steam_level": None,
"last_online": None,
}
async def _fetch_phone_digits(session: aiohttp.ClientSession) -> dict:
"""GET /account/ → phone last digits."""
url = "https://store.steampowered.com/account/"
try:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as resp:
html = await resp.text()
# "Ends in XX" or "ending in XX"
m = re.search(r"[Ee]nds?\s+in\s+(\d+)", html)
if not m:
m = re.search(r"ending\s+in\s+(\d+)", html)
return {"phone_digits": m.group(1) if m else None}
except Exception as exc:
logger.debug(f"[full_check] phone digits error: {exc}")
return {"phone_digits": None}
async def _fetch_alert_status(session: aiohttp.ClientSession) -> dict:
"""GET /supportmessages/ → alert_status string."""
url = "https://store.steampowered.com/supportmessages/"
try:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as resp:
html = await resp.text()
if re.search(
r"account[_\-\s]?locked|your account has been locked", html, re.IGNORECASE
):
return {"alert_status": "LOCKED"}
if re.search(r"restricted|limited account", html, re.IGNORECASE):
return {"alert_status": "RESTRICTED"}
if re.search(
r"support message|alert|warning|action required", html, re.IGNORECASE
):
return {"alert_status": "ALERT"}
return {"alert_status": "None"}
except Exception as exc:
logger.debug(f"[full_check] alert status error: {exc}")
return {"alert_status": "None"}
async def _fetch_market_limited(session: aiohttp.ClientSession) -> dict:
"""GET /market/ → market_limited (bool)."""
url = "https://steamcommunity.com/market/"
try:
async with session.get(
url,
timeout=aiohttp.ClientTimeout(total=15),
allow_redirects=True,
) as resp:
final_url = str(resp.url)
html = await resp.text()
# If redirected to login or the page body indicates we are not logged in
if "login.steampowered.com" in final_url:
return {"market_limited": True}
# Steam's market page includes this container only when logged in with market access
if "market_headertip_container_warning" in html:
return {"market_limited": True}
# If it threw us to a "you must log in" page
if re.search(
r"g_bInternalBrowser|please log in|you are not logged in",
html,
re.IGNORECASE,
):
return {"market_limited": True}
return {"market_limited": False}
except Exception as exc:
logger.debug(f"[full_check] market limited error: {exc}")
return {"market_limited": False}
# ---------------------------------------------------------------------------
# Finalizelogin flow (mirrors steam_token_checker.py)
# ---------------------------------------------------------------------------
async def _do_finalizelogin(
account: dict,
) -> tuple[str, list[dict], str | None]:
"""Run the finalizelogin flow.
Returns:
(steam_id, all_cookies_list, web_token)
Raises on failure.
"""
import base64
token = account["token"]
# Decode JWT to get steam_id as fallback
parts = token.split(".")
steam_id: str = ""
if len(parts) == 3:
payload_b64 = parts[1] + "=" * (-len(parts[1]) % 4)
try:
payload = _json.loads(base64.urlsafe_b64decode(payload_b64))
steam_id = payload.get("sub", "")
except Exception:
pass
proxy = await _resolve_proxy(account)
connector = proxy_manager.get_connector(proxy) if proxy else aiohttp.TCPConnector()
jar = aiohttp.CookieJar(unsafe=True)
headers = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
),
"Origin": "https://steamcommunity.com",
}
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(
connector=connector,
cookie_jar=jar,
headers=headers,
timeout=timeout,
) as session:
# Seed sessionid cookie
async with session.get("https://steamcommunity.com") as resp:
await resp.read()
sessionid: str | None = None
for cookie in jar:
if cookie.key == "sessionid":
sessionid = cookie.value
break
if not sessionid:
raise RuntimeError(
"Failed to acquire sessionid cookie from steamcommunity.com"
)
# POST finalizelogin
form = FormData(
fields=[
("nonce", token),
("sessionid", sessionid),
("redir", "https://steamcommunity.com/login/home/?goto="),
]
)
async with session.post(
"https://login.steampowered.com/jwt/finalizelogin",
data=form,
) as resp:
resp_text = await resp.text()
if resp.status != 200:
raise RuntimeError(
f"finalizelogin HTTP {resp.status}: {resp_text[:300]}"
)
resp_json = _json.loads(resp_text)
if not resp_json.get("steamID"):
error_msg = resp_json.get("message", resp_text[:200])
raise RuntimeError(f"finalizelogin rejected: {error_msg}")
finalize = FinalizeLoginStatus.model_validate(resp_json)
steam_id = finalize.steamID or steam_id
# Set domain cookies via transfer_info URLs
for ti in finalize.transfer_info:
form = FormData(
fields=[
("nonce", ti.params.nonce),
("auth", ti.params.auth),
("steamID", str(steam_id)),
]
)
try:
async with session.post(ti.url, data=form) as _resp:
await _resp.read()
except Exception:
pass
# Visit extra domains to collect all cookies
for url in (
"https://store.steampowered.com",
"https://help.steampowered.com",
):
try:
async with session.get(url) as _resp:
await _resp.read()
except Exception:
pass
# Extract web_token from steamLoginSecure cookie
web_token = _extract_web_token(jar)
# Serialise cookies
all_cookies: list[dict] = []
for c in jar:
all_cookies.append(
{
"name": c.key,
"value": c.value,
"domain": c.get("domain", ""),
"path": c.get("path", "/"),
"secure": "secure" in str(c).lower(),
"httpOnly": "httponly" in str(c).lower(),
}
)
return str(steam_id), all_cookies, web_token
# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------
TOTAL_STEPS = 6
async def full_check_token_account(
account: dict, params: dict, *, task_id: str
) -> None:
"""Full-check a token account and persist results to the DB.
Steps:
1. Decode JWT / prepare
2. finalizelogin flow
3. Parallel API calls Steam Web APIs
4. Parallel API calls Community pages
5. Parallel API calls Inventories
6. Save to DB
"""
acc_id = account["id"]
login_label = account.get("login") or str(acc_id)
await task_manager.set_step(task_id, 1, TOTAL_STEPS, "Подготовка токена", acc_id)
# ------------------------------------------------------------------ #
# Step 2: finalizelogin #
# ------------------------------------------------------------------ #
await task_manager.set_step(
task_id, 2, TOTAL_STEPS, "Авторизация (finalizelogin)", acc_id
)
try:
steam_id, all_cookies, web_token = await _do_finalizelogin(account)
except Exception as exc:
logger.error(f"[full_check] {login_label}: finalizelogin failed — {exc}")
await _mark_invalid(acc_id)
raise
session_cookies_json = _json.dumps(all_cookies)
logger.debug(
f"[full_check] {login_label}: finalizelogin OK, "
f"steam_id={steam_id}, web_token={'yes' if web_token else 'NO'}, "
f"cookies={len(all_cookies)}"
)
# ------------------------------------------------------------------ #
# Rebuild an authenticated aiohttp session from the saved cookies #
# ------------------------------------------------------------------ #
proxy = await _resolve_proxy(account)
connector = proxy_manager.get_connector(proxy) if proxy else aiohttp.TCPConnector()
auth_jar = _build_cookie_jar_from_list(all_cookies)
auth_headers = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
),
}
async with aiohttp.ClientSession(
connector=connector,
cookie_jar=auth_jar,
headers=auth_headers,
timeout=aiohttp.ClientTimeout(total=30),
) as session:
# -------------------------------------------------------------- #
# Step 3: Steam Web API calls (need web_token) #
# -------------------------------------------------------------- #
await task_manager.set_step(
task_id, 3, TOTAL_STEPS, "Steam Web API (кошелёк / игры / семья)", acc_id
)
if web_token:
api_results = await asyncio.gather(
_fetch_wallet(session, web_token),
_fetch_family_group(session, web_token),
_fetch_recently_played(session, web_token, steam_id),
return_exceptions=True,
)
else:
# No web_token — skip Steam Web API calls, use safe defaults
logger.warning(
f"[full_check] {login_label}: no web_token — skipping Steam Web API calls"
)
api_results = [
{"balance_raw": None, "user_country": None},
{"family_group": False},
{"playtime_2weeks": 0},
]
wallet_data = (
api_results[0]
if isinstance(api_results[0], dict)
else {"balance_raw": None, "user_country": None}
)
family_data = (
api_results[1]
if isinstance(api_results[1], dict)
else {"family_group": False}
)
played_data = (
api_results[2]
if isinstance(api_results[2], dict)
else {"playtime_2weeks": 0}
)
# -------------------------------------------------------------- #
# Step 4: Community/Store page calls #
# -------------------------------------------------------------- #
await task_manager.set_step(
task_id, 4, TOTAL_STEPS, "Профиль / телефон / маркет / алерты", acc_id
)
page_results = await asyncio.gather(
_fetch_profile_html(session, steam_id),
_fetch_profile_xml(session, steam_id),
_fetch_phone_digits(session),
_fetch_alert_status(session),
_fetch_market_limited(session),
return_exceptions=True,
)
profile_data = (
page_results[0]
if isinstance(page_results[0], dict)
else {
"display_name": None,
"avatar_url": None,
"steam_level": None,
"last_online": None,
}
)
xml_data = (
page_results[1]
if isinstance(page_results[1], dict)
else {"trade_ban": None, "created_date": None}
)
phone_data = (
page_results[2]
if isinstance(page_results[2], dict)
else {"phone_digits": None}
)
alert_data = (
page_results[3]
if isinstance(page_results[3], dict)
else {"alert_status": "None"}
)
market_data = (
page_results[4]
if isinstance(page_results[4], dict)
else {"market_limited": False}
)
# -------------------------------------------------------------- #
# Step 5: Inventory checks #
# -------------------------------------------------------------- #
await task_manager.set_step(
task_id, 5, TOTAL_STEPS, "Инвентари (CS2 / Dota2 / TF2 / Rust)", acc_id
)
inv_results = await asyncio.gather(
_fetch_inventory(session, steam_id, 730), # CS2
_fetch_inventory(session, steam_id, 570), # Dota 2
_fetch_inventory(session, steam_id, 440), # TF2
_fetch_inventory(session, steam_id, 252490), # Rust
return_exceptions=True,
)
def _inv(result: object) -> tuple[int, int]:
if isinstance(result, tuple):
return result
return (0, 0)
cs2_total, cs2_mkt = _inv(inv_results[0])
d2_total, d2_mkt = _inv(inv_results[1])
tf2_total, tf2_mkt = _inv(inv_results[2])
rust_total, rust_mkt = _inv(inv_results[3])
# ------------------------------------------------------------------ #
# Step 6: Assemble check_data & persist #
# ------------------------------------------------------------------ #
await task_manager.set_step(
task_id, 6, TOTAL_STEPS, "Сохранение результатов", acc_id
)
check_data: dict = {
"steam_id": steam_id,
# Profile
"display_name": profile_data.get("display_name"),
"avatar_url": profile_data.get("avatar_url"),
"steam_level": profile_data.get("steam_level"),
"last_online": profile_data.get("last_online"),
# Wallet
"balance_raw": wallet_data.get("balance_raw"),
"user_country": wallet_data.get("user_country"),
# Family
"family_group": family_data.get("family_group", False),
# Playtime
"playtime_2weeks": played_data.get("playtime_2weeks", 0),
# Inventories (total / marketable)
"inventory_cs2": cs2_total,
"inventory_dota2": d2_total,
"inventory_tf2": tf2_total,
"inventory_rust": rust_total,
"inventory_cs2_marketable": cs2_mkt,
"inventory_dota2_marketable": d2_mkt,
"inventory_tf2_marketable": tf2_mkt,
"inventory_rust_marketable": rust_mkt,
# Profile XML
"trade_ban": xml_data.get("trade_ban"),
"created_date": xml_data.get("created_date"),
# Store/community pages
"phone_digits": phone_data.get("phone_digits"),
"alert_status": alert_data.get("alert_status", "None"),
"market_limited": market_data.get("market_limited", False),
# Timestamp
"checked_at": datetime.utcnow().isoformat(),
}
from app.database import get_db
db = await get_db()
await db.execute(
"""UPDATE token_accounts
SET steam_id = ?,
nickname = ?,
avatar_url = ?,
steam_level = ?,
last_online = ?,
session_cookies = ?,
check_data = ?,
status = 'valid',
updated_at = datetime('now')
WHERE id = ?""",
(
steam_id,
check_data.get("display_name"),
check_data.get("avatar_url"),
check_data.get("steam_level"),
check_data.get("last_online"),
session_cookies_json,
_json.dumps(check_data),
acc_id,
),
)
await db.commit()
logger.success(
f"[full_check] {login_label} → valid | "
f"balance={check_data['balance_raw']} | "
f"country={check_data['user_country']} | "
f"cs2={cs2_total} | dota2={d2_total} | "
f"market_limited={check_data['market_limited']}"
)
async def _mark_invalid(acc_id: int) -> None:
from app.database import get_db
db = await get_db()
await db.execute(
"UPDATE token_accounts SET status = 'invalid', updated_at = datetime('now') WHERE id = ?",
(acc_id,),
)
await db.commit()
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+14 -14
View File
@@ -1,14 +1,14 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SteamPanel</title> <title>SteamPanel</title>
<script type="module" crossorigin src="/assets/index-D5Q9Ekc6.js"></script> <script type="module" crossorigin src="/assets/index-_9agmVKx.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-aSRiX3qP.css"> <link rel="stylesheet" crossorigin href="/assets/index-ztmqkldd.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
</body> </body>
</html> </html>
+5 -1
View File
@@ -12,12 +12,16 @@ export default function App() {
const activeTab = useUiStore((s) => s.activeTab); const activeTab = useUiStore((s) => s.activeTab);
const loadDisplaySettings = useUiStore((s) => s.loadDisplaySettings); const loadDisplaySettings = useUiStore((s) => s.loadDisplaySettings);
const loadColumnSettings = useUiStore((s) => s.loadColumnSettings); 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(() => { useEffect(() => {
loadDisplaySettings(); loadDisplaySettings();
loadColumnSettings(); loadColumnSettings();
loadLogpassColumnSettings(); loadLogpassColumnSettings();
loadImportSettings();
}, []); }, []);
return ( return (
+240 -62
View File
@@ -1,9 +1,24 @@
import type { import type {
Account, AccountCreate, AccountUpdate, BulkImportResult, Account,
Proxy, Task, MafileInfo, MafileExportRequest, AccountCreate,
ActionRequest, ValidationSettings, DisplaySettings, ColumnSettings, LogpassColumnSettings, LogEntry, AccountUpdate,
LogpassAccount, LogpassAccountCreate, LogpassAccountUpdate, BulkImportResult,
TokenAccount, TokenAccountCreate, TokenColumnSettings, Proxy,
Task,
MafileInfo,
MafileExportRequest,
ActionRequest,
ValidationSettings,
DisplaySettings,
ColumnSettings,
LogpassColumnSettings,
LogEntry,
LogpassAccount,
LogpassAccountCreate,
LogpassAccountUpdate,
TokenAccount,
TokenAccountCreate,
TokenColumnSettings,
} from "./types"; } from "./types";
const BASE = "/api"; const BASE = "/api";
@@ -29,76 +44,144 @@ export const api = {
getAccounts: () => request<Account[]>("/accounts"), getAccounts: () => request<Account[]>("/accounts"),
getAccount: (id: number) => request<Account>(`/accounts/${id}`), getAccount: (id: number) => request<Account>(`/accounts/${id}`),
createAccount: (data: AccountCreate) => 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) => 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) => deleteAccount: (id: number) =>
request<void>(`/accounts/${id}`, { method: "DELETE" }), request<void>(`/accounts/${id}`, { method: "DELETE" }),
deleteBulk: (ids: number[]) => 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: () => assignProxies: () =>
request<{ assigned: number; proxies_used: number }>("/accounts/assign-proxies", { method: "POST" }), request<{ assigned: number; proxies_used: number }>(
"/accounts/assign-proxies",
{ method: "POST" },
),
reassignProxies: () => reassignProxies: () =>
request<{ assigned: number; proxies_used: number }>("/accounts/reassign-proxies", { method: "POST" }), request<{ assigned: number; proxies_used: number }>(
"/accounts/reassign-proxies",
{ method: "POST" },
),
clearProxies: () => clearProxies: () =>
request<{ cleared: number }>("/accounts/clear-proxies", { method: "POST" }), request<{ cleared: number }>("/accounts/clear-proxies", { method: "POST" }),
importAccounts: (file: File) => { importAccounts: (file: File) => {
const form = new FormData(); const form = new FormData();
form.append("file", file); form.append("file", file);
return fetch(`${BASE}/accounts/import`, { method: "POST", body: form }).then(async (r) => { return fetch(`${BASE}/accounts/import`, {
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail ?? r.statusText); 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>; return r.json() as Promise<BulkImportResult>;
}); });
}, },
// Actions // Actions
executeAction: (data: ActionRequest) => executeAction: (data: ActionRequest) =>
request<{ task_id: string; accounts_count: number }>("/actions", { method: "POST", body: JSON.stringify(data) }), request<{ task_id: string; accounts_count: number }>("/actions", {
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", 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 // Proxies
getProxies: () => request<Proxy[]>("/proxies"), getProxies: () => request<Proxy[]>("/proxies"),
addProxy: (address: string, protocol = "http") => 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) => deleteProxy: (id: number) =>
request<void>(`/proxies/${id}`, { method: "DELETE" }), request<void>(`/proxies/${id}`, { method: "DELETE" }),
deleteAllProxies: () => deleteAllProxies: () =>
request<{ deleted: number }>("/proxies/all", { method: "DELETE" }), request<{ deleted: number }>("/proxies/all", { method: "DELETE" }),
bulkAddProxies: (proxies: { address: string; protocol: string }[]) => 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: () => checkProxies: () =>
request<{ total: number; alive: number; dead: number }>("/proxies/check", { method: "POST" }), request<{ total: number; alive: number; dead: number }>("/proxies/check", {
method: "POST",
}),
// Tasks // Tasks
getTasks: () => request<Task[]>("/tasks"), getTasks: () => request<Task[]>("/tasks"),
getTask: (id: string) => request<Task>(`/tasks/${id}`), 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 // Mafiles
getMafiles: () => request<MafileInfo[]>("/mafiles"), getMafiles: () => request<MafileInfo[]>("/mafiles"),
uploadMafiles: (files: File[]) => { uploadMafiles: (files: File[]) => {
const form = new FormData(); const form = new FormData();
files.forEach((f) => form.append("files", f)); files.forEach((f) => form.append("files", f));
return fetch(`${BASE}/mafiles/upload`, { method: "POST", body: form }).then(async (r) => { return fetch(`${BASE}/mafiles/upload`, { method: "POST", body: form }).then(
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail ?? r.statusText); async (r) => {
return r.json() as Promise<{ uploaded: number; errors: string[] }>; 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) => deleteMafile: (filename: string) =>
request<void>(`/mafiles/${encodeURIComponent(filename)}`, { method: "DELETE" }), request<void>(`/mafiles/${encodeURIComponent(filename)}`, {
exportSecrets: () => request<{ account_name: string; shared_secret: string; identity_secret: string; steam_id: string }[]>("/mafiles/export/all"), method: "DELETE",
}),
exportSecrets: () =>
request<
{
account_name: string;
shared_secret: string;
identity_secret: string;
steam_id: string;
}[]
>("/mafiles/export/all"),
exportZip: (body: MafileExportRequest) => exportZip: (body: MafileExportRequest) =>
fetch(`${BASE}/mafiles/export/zip`, { fetch(`${BASE}/mafiles/export/zip`, {
method: "POST", method: "POST",
@@ -110,80 +193,175 @@ export const api = {
}), }),
// Settings // Settings
getValidationSettings: () => request<ValidationSettings>("/settings/validation"), getValidationSettings: () =>
request<ValidationSettings>("/settings/validation"),
updateValidationSettings: (data: ValidationSettings) => 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"), getDisplaySettings: () => request<DisplaySettings>("/settings/display"),
updateDisplaySettings: (data: DisplaySettings) => 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"), getColumnSettings: () => request<ColumnSettings>("/settings/columns"),
updateColumnSettings: (data: ColumnSettings) => updateColumnSettings: (data: ColumnSettings) =>
request<ColumnSettings>("/settings/columns", { method: "PUT", body: JSON.stringify(data) }), request<ColumnSettings>("/settings/columns", {
getLogpassColumnSettings: () => request<LogpassColumnSettings>("/settings/logpass-columns"), method: "PUT",
body: JSON.stringify(data),
}),
getLogpassColumnSettings: () =>
request<LogpassColumnSettings>("/settings/logpass-columns"),
updateLogpassColumnSettings: (data: LogpassColumnSettings) => 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 // Logs
getLogs: () => request<LogEntry[]>("/logs"), getLogs: () => request<LogEntry[]>("/logs"),
// Auto-accept // Auto-accept
startAutoAccept: (ids: number[]) => 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[]) => 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: () => getAutoAcceptStatus: () =>
request<{ running: number[] }>("/auto-accept/status"), request<{ running: number[] }>("/auto-accept/status"),
// Browser // Browser
openBrowser: (id: number) => 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 // Logpass accounts
getLogpassAccounts: () => request<LogpassAccount[]>("/logpass"), getLogpassAccounts: () => request<LogpassAccount[]>("/logpass"),
createLogpassAccount: (data: LogpassAccountCreate) => 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) => 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) => deleteLogpassAccount: (id: number) =>
request<void>(`/logpass/${id}`, { method: "DELETE" }), request<void>(`/logpass/${id}`, { method: "DELETE" }),
deleteLogpassBulk: (ids: number[]) => 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[]) => 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[]) => 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[]) => 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: () => assignLogpassProxies: () =>
request<{ assigned: number; proxies_used: number }>("/logpass/assign-proxies", { method: "POST" }), request<{ assigned: number; proxies_used: number }>(
"/logpass/assign-proxies",
{ method: "POST" },
),
reassignLogpassProxies: () => reassignLogpassProxies: () =>
request<{ assigned: number; proxies_used: number }>("/logpass/reassign-proxies", { method: "POST" }), request<{ assigned: number; proxies_used: number }>(
"/logpass/reassign-proxies",
{ method: "POST" },
),
clearLogpassProxies: () => clearLogpassProxies: () =>
request<{ cleared: number }>("/logpass/clear-proxies", { method: "POST" }), request<{ cleared: number }>("/logpass/clear-proxies", { method: "POST" }),
openLogpassBrowser: (id: number) => 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 // Token accounts
getTokenAccounts: () => request<TokenAccount[]>("/token-accounts"), getTokenAccounts: () => request<TokenAccount[]>("/token-accounts"),
createTokenAccount: (data: TokenAccountCreate) => 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) => deleteTokenAccount: (id: number) =>
request<void>(`/token-accounts/${id}`, { method: "DELETE" }), request<void>(`/token-accounts/${id}`, { method: "DELETE" }),
deleteTokenBulk: (ids: number[]) => 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[]) => 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[]) => 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) => 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: () => 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: () => 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: () => clearTokenProxies: () =>
request<{ cleared: number }>("/token-accounts/clear-proxies", { method: "POST" }), request<{ cleared: number }>("/token-accounts/clear-proxies", {
getTokenColumnSettings: () => request<TokenColumnSettings>("/settings/token-columns"), method: "POST",
}),
getTokenColumnSettings: () =>
request<TokenColumnSettings>("/settings/token-columns"),
updateTokenColumnSettings: (data: TokenColumnSettings) => 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();
},
}; };
+38 -1
View File
@@ -71,7 +71,10 @@ export interface Task {
step_label?: string; step_label?: string;
active_count?: number; active_count?: number;
account_ids?: string | null; 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 }>; account_steps?: Record<string, { step: number; total: number }>;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -118,6 +121,14 @@ export interface ValidationSettings {
check_ban: boolean; check_ban: boolean;
max_threads: number; max_threads: number;
auto_revalidate_browser: boolean; 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 { export interface DisplaySettings {
@@ -259,6 +270,32 @@ export interface Toast {
message: string; 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 { export interface SteamConfirmation {
id: string; id: string;
nonce: string; nonce: string;
+125 -27
View File
@@ -6,17 +6,45 @@ import { AccountRow } from "./AccountRow";
import { useT } from "@/lib/i18n"; import { useT } from "@/lib/i18n";
const COLUMN_KEYS: (keyof ColumnSettings)[] = [ const COLUMN_KEYS: (keyof ColumnSettings)[] = [
"browser", "profile", "last_online", "steam_id", "login", "password", "login_pass", "browser",
"email", "email_pass", "email_login_pass", "phone", "status", "ban", "twofa", "mafile", "proxy", "notes", "actions", "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> = { const COL_LABEL_KEYS: Record<keyof ColumnSettings, string> = {
browser: "col.browser", profile: "col.profile", last_online: "col.lastOnline", browser: "col.browser",
steam_id: "col.steamId", login: "col.login", password: "col.password", profile: "col.profile",
login_pass: "col.loginPass", email: "col.email", email_pass: "col.emailPass", last_online: "col.lastOnline",
email_login_pass: "col.emailLoginPass", phone: "col.phone", status: "col.status", steam_id: "col.steamId",
ban: "col.ban", twofa: "col.twofa", mafile: "col.mafile", proxy: "col.proxy", login: "col.login",
notes: "col.notes", actions: "col.actions", 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 { interface Props {
@@ -26,19 +54,47 @@ interface Props {
accountSteps: Record<string, { step: number; total: number }>; accountSteps: Record<string, { step: number; total: number }>;
onEdit: (id: number) => void; onEdit: (id: number) => void;
onDelete: (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; onToggleAutoAccept: (id: number) => void;
onOpenBrowser: (id: number) => void; onOpenBrowser: (id: number) => void;
} }
export function AccountTable({ accounts, processingIds, accountResults, accountSteps, onEdit, onDelete, onAction, onToggleAutoAccept, onOpenBrowser }: Props) { export function AccountTable({
const selectAll = useAccountStore((s) => s.selectAll); accounts,
const clearSelection = useAccountStore((s) => s.clearSelection); processingIds,
accountResults,
accountSteps,
onEdit,
onDelete,
onAction,
onToggleAutoAccept,
onOpenBrowser,
}: Props) {
const selectedIds = useAccountStore((s) => s.selectedIds); const selectedIds = useAccountStore((s) => s.selectedIds);
const setSelectedIds = useAccountStore((s) => s.setSelectedIds);
const cols = useUiStore((s) => s.columnVisibility); const cols = useUiStore((s) => s.columnVisibility);
const t = useT(); 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 visibleKeys = COLUMN_KEYS.filter((k) => cols[k]);
const BATCH = 100; const BATCH = 100;
@@ -52,7 +108,10 @@ export function AccountTable({ accounts, processingIds, accountResults, accountS
const toggleSort = (field: SortField) => { const toggleSort = (field: SortField) => {
if (sortField === field) { if (sortField === field) {
if (sortDir === "asc") setSortDir("desc"); if (sortDir === "asc") setSortDir("desc");
else { setSortField(null); setSortDir("asc"); } else {
setSortField(null);
setSortDir("asc");
}
} else { } else {
setSortField(field); setSortField(field);
setSortDir("asc"); setSortDir("asc");
@@ -64,7 +123,9 @@ export function AccountTable({ accounts, processingIds, accountResults, accountS
const dir = sortDir === "asc" ? 1 : -1; const dir = sortDir === "asc" ? 1 : -1;
const EMPTY = Symbol(); const EMPTY = Symbol();
return [...accounts].sort((a, b) => { 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 || v === "\u2014") return EMPTY;
if (v === "online") return -1; if (v === "online") return -1;
const m = v.match(/^(\d+)([mhd])$/i); const m = v.match(/^(\d+)([mhd])$/i);
@@ -84,16 +145,25 @@ export function AccountTable({ accounts, processingIds, accountResults, accountS
}); });
}, [accounts, sortField, sortDir]); }, [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(() => { useEffect(() => {
const el = sentinelRef.current; const el = sentinelRef.current;
if (!el) return; if (!el) return;
const obs = new IntersectionObserver((entries) => { const obs = new IntersectionObserver(
if (entries[0].isIntersecting) setVisibleCount((c) => Math.min(c + BATCH, sorted.length)); (entries) => {
}, { rootMargin: "400px" }); if (entries[0].isIntersecting)
setVisibleCount((c) => Math.min(c + BATCH, sorted.length));
},
{ rootMargin: "400px" },
);
obs.observe(el); obs.observe(el);
return () => obs.disconnect(); return () => obs.disconnect();
}, [sorted.length, visibleCount]); }, [sorted.length, visibleCount]);
@@ -135,11 +205,13 @@ export function AccountTable({ accounts, processingIds, accountResults, accountS
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<input <input
type="checkbox" type="checkbox"
checked={allSelected} checked={allFilteredSelected}
onChange={() => allSelected ? clearSelection() : selectAll()} onChange={toggleSelectAllFiltered}
/> />
{selectedIds.size > 0 && ( {selectedIds.size > 0 && (
<span className="text-accent font-medium">{selectedIds.size}</span> <span className="text-accent font-medium">
{selectedIds.size}
</span>
)} )}
</div> </div>
</th> </th>
@@ -147,12 +219,28 @@ export function AccountTable({ accounts, processingIds, accountResults, accountS
{visibleKeys.map((k) => { {visibleKeys.map((k) => {
if (k === "last_online") { if (k === "last_online") {
return ( return (
<th key={k} className="px-3 py-2 cursor-pointer select-none hover:text-gray-300" onClick={() => toggleSort("last_online")}> <th
{t(COL_LABEL_KEYS[k])}{sortField === "last_online" ? (sortDir === "asc" ? " ▲" : " ▼") : ""} 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> </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> </tr>
</thead> </thead>
@@ -174,7 +262,17 @@ export function AccountTable({ accounts, processingIds, accountResults, accountS
/> />
))} ))}
{visibleCount < accounts.length && ( {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> </tbody>
</table> </table>
File diff suppressed because it is too large Load Diff
+55 -14
View File
@@ -2,7 +2,7 @@ import { useState, useRef } from "react";
import type { AccountCreate } from "@/api/types"; import type { AccountCreate } from "@/api/types";
import { api } from "@/api/client"; import { api } from "@/api/client";
import { useUiStore } from "@/stores/uiStore"; import { useUiStore } from "@/stores/uiStore";
import { useAccountStore } from "@/stores/accountStore";
import { IconUpload } from "@/components/shared/Icons"; import { IconUpload } from "@/components/shared/Icons";
import { useT } from "@/lib/i18n"; import { useT } from "@/lib/i18n";
@@ -14,7 +14,7 @@ interface Props {
export function AddModal({ onSave, onClose }: Props) { export function AddModal({ onSave, onClose }: Props) {
const t = useT(); const t = useT();
const addToast = useUiStore((s) => s.addToast); const addToast = useUiStore((s) => s.addToast);
const loadAccounts = useAccountStore((s) => s.loadAccounts);
const [login, setLogin] = useState(""); const [login, setLogin] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
@@ -63,7 +63,10 @@ export function AddModal({ onSave, onClose }: Props) {
return ( return (
<div className="confirm-overlay" onMouseDown={onClose}> <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> <h3 className="text-lg font-semibold mb-4">{t("modal.addAccount")}</h3>
<div className="space-y-3"> <div className="space-y-3">
<input <input
@@ -80,14 +83,39 @@ export function AddModal({ onSave, onClose }: Props) {
placeholder={t("ph.login") + " (login:pass)"} placeholder={t("ph.login") + " (login:pass)"}
className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" 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"> <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
<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" /> 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>
<div className="grid grid-cols-2 gap-2"> <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
<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" /> 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>
<div <div
@@ -100,19 +128,32 @@ export function AddModal({ onSave, onClose }: Props) {
</span> </span>
{mafile && ( {mafile && (
<button <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" className="text-xs text-red-400 hover:text-red-300 ml-1"
> >
</button> </button>
)} )}
</div> </div>
<input ref={fileRef} type="file" accept=".mafile" className="hidden" onChange={(e) => { <input
const f = e.target.files?.[0]; ref={fileRef}
if (f) setMafile(f); 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")} {saving ? t("import.uploading") : t("btn.add")}
</button> </button>
</div> </div>
@@ -7,9 +7,10 @@ import { useT } from "@/lib/i18n";
interface Props { interface Props {
onClose: () => void; onClose: () => void;
onImportDone?: (newIds: number[]) => void;
} }
export function ImportModal({ onClose }: Props) { export function ImportModal({ onClose, onImportDone }: Props) {
const t = useT(); const t = useT();
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [result, setResult] = useState<string>(""); const [result, setResult] = useState<string>("");
@@ -28,10 +29,23 @@ export function ImportModal({ onClose }: Props) {
if (!file) return; if (!file) return;
setUploading(true); setUploading(true);
try { try {
const idsBefore = new Set(
useAccountStore.getState().accounts.map((a) => a.id),
);
const res = await api.importAccounts(file); 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`); addToast("success", `${res.imported} ok, ${res.skipped} skip`);
await loadAccounts(); await loadAccounts();
const newIds = useAccountStore
.getState()
.accounts.map((a) => a.id)
.filter((id) => !idsBefore.has(id));
onImportDone?.(newIds);
} catch (e: unknown) { } catch (e: unknown) {
setResult(e instanceof Error ? e.message : t("misc.errorStr")); setResult(e instanceof Error ? e.message : t("misc.errorStr"));
} finally { } finally {
@@ -41,13 +55,30 @@ export function ImportModal({ onClose }: Props) {
return ( return (
<div className="confirm-overlay" onMouseDown={onClose}> <div className="confirm-overlay" onMouseDown={onClose}>
<div className="confirm-box max-w-lg w-full" onMouseDown={(e) => e.stopPropagation()}> <div
<h3 className="text-lg font-semibold mb-4">{t("modal.importAccounts")}</h3> 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"> <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-medium text-gray-300">
<p className="font-mono text-xs">login:pass:{"{"}mafile{"}"}</p> {t("import.formats")}{" "}
<p className="font-mono text-xs">login:pass:email:email_pass:{"{"}mafile{"}"}</p> <span className="text-gray-500 font-normal">
<p className="font-mono text-xs">login:pass:email:email_pass:{"{"}mafile{"}"}:notes</p> ({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</p>
<p className="font-mono text-xs">login:pass:email:email_pass:notes</p> <p className="font-mono text-xs">login:pass:email:email_pass:notes</p>
</div> </div>
@@ -59,14 +90,26 @@ export function ImportModal({ onClose }: Props) {
> >
<IconUpload size={32} className="mx-auto mb-2 text-gray-500" /> <IconUpload size={32} className="mx-auto mb-2 text-gray-500" />
<p className="text-sm text-gray-400"> <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> </p>
</div> </div>
<input ref={inputRef} type="file" accept=".txt" className="hidden" onChange={(e) => { <input
const f = e.target.files?.[0]; ref={inputRef}
if (f) setFile(f); type="file"
}} /> accept=".txt"
<button onClick={doImport} disabled={!file || uploading} className="btn-primary w-full"> 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")} {uploading ? t("import.uploading") : t("import.importBtn")}
</button> </button>
{result && <p className="mt-3 text-sm text-gray-300">{result}</p>} {result && <p className="mt-3 text-sm text-gray-300">{result}</p>}
@@ -7,9 +7,10 @@ import { useT } from "@/lib/i18n";
interface Props { interface Props {
onClose: () => void; onClose: () => void;
onImportDone?: (newIds: number[]) => void;
} }
export function LogpassImportModal({ onClose }: Props) { export function LogpassImportModal({ onClose, onImportDone }: Props) {
const t = useT(); const t = useT();
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [result, setResult] = useState(""); const [result, setResult] = useState("");
@@ -29,12 +30,33 @@ export function LogpassImportModal({ onClose }: Props) {
setUploading(true); setUploading(true);
try { try {
const text = await file.text(); const text = await file.text();
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean); const lines = text
if (!lines.length) { setResult(t("toast.fileEmpty")); setUploading(false); return; } .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); 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`); addToast("success", `${res.imported} ok, ${res.skipped} skip`);
await loadAccounts(); await loadAccounts();
const newIds = useLogpassStore
.getState()
.accounts.map((a) => a.id)
.filter((id) => !idsBefore.has(id));
onImportDone?.(newIds);
} catch (e: unknown) { } catch (e: unknown) {
setResult(e instanceof Error ? e.message : t("misc.errorStr")); setResult(e instanceof Error ? e.message : t("misc.errorStr"));
} finally { } finally {
@@ -44,14 +66,25 @@ export function LogpassImportModal({ onClose }: Props) {
return ( return (
<div className="confirm-overlay" onMouseDown={onClose}> <div className="confirm-overlay" onMouseDown={onClose}>
<div className="confirm-box max-w-lg w-full" onMouseDown={(e) => e.stopPropagation()}> <div
<h3 className="text-lg font-semibold mb-4">{t("modal.importAccounts")}</h3> 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"> <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">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="font-mono text-xs">
<p className="text-xs text-yellow-500/80 mt-1"> Mafile-файлы и JSON не принимаются. Только log:pass / log|pass.</p> 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>
<div <div
onDragOver={(e) => e.preventDefault()} onDragOver={(e) => e.preventDefault()}
@@ -61,14 +94,26 @@ export function LogpassImportModal({ onClose }: Props) {
> >
<IconUpload size={32} className="mx-auto mb-2 text-gray-500" /> <IconUpload size={32} className="mx-auto mb-2 text-gray-500" />
<p className="text-sm text-gray-400"> <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> </p>
</div> </div>
<input ref={inputRef} type="file" accept=".txt,.csv" className="hidden" onChange={(e) => { <input
const f = e.target.files?.[0]; ref={inputRef}
if (f) setFile(f); type="file"
}} /> accept=".txt,.csv"
<button onClick={doImport} disabled={!file || uploading} className="btn-primary w-full"> 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")} {uploading ? t("import.uploading") : t("import.importBtn")}
</button> </button>
{result && <p className="mt-3 text-sm text-gray-300">{result}</p>} {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 { interface Props {
onClose: () => void; onClose: () => void;
onImportDone?: (newIds: number[]) => void;
} }
export function TokenImportModal({ onClose }: Props) { export function TokenImportModal({ onClose, onImportDone }: Props) {
const t = useT(); const t = useT();
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [result, setResult] = useState(""); const [result, setResult] = useState("");
@@ -29,12 +30,32 @@ export function TokenImportModal({ onClose }: Props) {
setUploading(true); setUploading(true);
try { try {
const text = await file.text(); const text = await file.text();
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean); const lines = text
if (!lines.length) { setResult(t("toast.fileEmpty")); setUploading(false); return; } .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); 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`); addToast("success", `${res.imported} ok, ${res.skipped} skip`);
await loadAccounts(); await loadAccounts();
const newIds = useTokenStore
.getState()
.accounts.map((a) => a.id)
.filter((id) => !idsBefore.has(id));
onImportDone?.(newIds);
} catch (e: unknown) { } catch (e: unknown) {
setResult(e instanceof Error ? e.message : t("misc.errorStr")); setResult(e instanceof Error ? e.message : t("misc.errorStr"));
} finally { } finally {
@@ -44,10 +65,17 @@ export function TokenImportModal({ onClose }: Props) {
return ( return (
<div className="confirm-overlay" onMouseDown={onClose}> <div className="confirm-overlay" onMouseDown={onClose}>
<div className="confirm-box max-w-lg w-full" onMouseDown={(e) => e.stopPropagation()}> <div
<h3 className="text-lg font-semibold mb-4">{t("modal.importTokens")}</h3> 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"> <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> <p className="font-mono text-xs">{t("import.tokenFormat")}</p>
</div> </div>
<div <div
@@ -58,14 +86,26 @@ export function TokenImportModal({ onClose }: Props) {
> >
<IconUpload size={32} className="mx-auto mb-2 text-gray-500" /> <IconUpload size={32} className="mx-auto mb-2 text-gray-500" />
<p className="text-sm text-gray-400"> <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> </p>
</div> </div>
<input ref={inputRef} type="file" accept=".txt" className="hidden" onChange={(e) => { <input
const f = e.target.files?.[0]; ref={inputRef}
if (f) setFile(f); type="file"
}} /> accept=".txt"
<button onClick={doImport} disabled={!file || uploading} className="btn-primary w-full"> 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")} {uploading ? t("import.uploading") : t("import.importBtn")}
</button> </button>
{result && <p className="mt-3 text-sm text-gray-300">{result}</p>} {result && <p className="mt-3 text-sm text-gray-300">{result}</p>}
+549 -132
View File
@@ -8,9 +8,21 @@ import { SteamLevelBadge } from "./SteamLevelBadge";
import { TokenImportModal } from "./TokenImportModal"; import { TokenImportModal } from "./TokenImportModal";
import { TokenAddModal } from "./TokenAddModal"; import { TokenAddModal } from "./TokenAddModal";
import { TokenColumnSettingsDropdown } from "./TokenColumnSettingsDropdown"; import { TokenColumnSettingsDropdown } from "./TokenColumnSettingsDropdown";
import { TokenCheckModal } from "./TokenCheckModal";
import { import {
IconDownload, IconPlus, IconTrash, IconPlay, IconGlobe, IconRefresh, IconBan, IconDownload,
IconCheckCircle, IconXCircle, IconCheck, IconAlertTriangle, IconPlus,
IconTrash,
IconPlay,
IconGlobe,
IconRefresh,
IconBan,
IconCheckCircle,
IconXCircle,
IconCheck,
IconAlertTriangle,
IconScrollText,
IconFileText,
} from "@/components/shared/Icons"; } from "@/components/shared/Icons";
import type { Task } from "@/api/types"; import type { Task } from "@/api/types";
import { useT } from "@/lib/i18n"; 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 { function parseAccResults(raw: Task["account_results"]): AccResults | null {
if (!raw) return null; if (!raw) return null;
if (typeof raw === "string") { 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; return raw;
} }
@@ -31,47 +47,63 @@ export function TokenTab() {
const selectedIds = useTokenStore((s) => s.selectedIds); const selectedIds = useTokenStore((s) => s.selectedIds);
const loadAccounts = useTokenStore((s) => s.loadAccounts); const loadAccounts = useTokenStore((s) => s.loadAccounts);
const toggleSelect = useTokenStore((s) => s.toggleSelect); const toggleSelect = useTokenStore((s) => s.toggleSelect);
const selectAll = useTokenStore((s) => s.selectAll);
const clearSelection = useTokenStore((s) => s.clearSelection); const clearSelection = useTokenStore((s) => s.clearSelection);
const setSelectedIds = useTokenStore((s) => s.setSelectedIds);
const addToast = useUiStore((s) => s.addToast); 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 cols = useUiStore((s) => s.tokenColumnVisibility);
const loadTokenColumnSettings = useUiStore((s) => s.loadTokenColumnSettings); const loadTokenColumnSettings = useUiStore((s) => s.loadTokenColumnSettings);
const t = useT(); const t = useT();
const [disclaimerAccepted, setDisclaimerAccepted] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [showImport, setShowImport] = useState(false); const [showImport, setShowImport] = useState(false);
const [showAdd, setShowAdd] = 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 [processingIds, setProcessingIds] = useState<Set<number>>(new Set());
const [accountResults, setAccountResults] = useState<AccResults>({}); const [accountResults, setAccountResults] = useState<AccResults>({});
const [accountSteps, setAccountSteps] = useState<AccSteps>({}); const [accountSteps, setAccountSteps] = useState<AccSteps>({});
const [activeTask, setActiveTask] = useState<Task | null>(null); 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 watchTask = useCallback(
const es = new EventSource(`/api/tasks/${taskId}/stream`); (taskId: string) => {
es.onmessage = (ev) => { const es = new EventSource(`/api/tasks/${taskId}/stream`);
try { es.onmessage = (ev) => {
const data = JSON.parse(ev.data) as Task; try {
setActiveTask(data); const data = JSON.parse(ev.data) as Task;
if (data.account_results) { setActiveTask(data);
const r = parseAccResults(data.account_results); if (data.account_results) {
if (r) setAccountResults(r); 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.onerror = () => es.close();
es.close(); },
setProcessingIds(new Set()); [loadAccounts],
loadAccounts(); );
}
} catch { /* ignore */ }
};
es.onerror = () => es.close();
}, [loadAccounts]);
const handleValidate = useCallback(async () => { const handleValidate = useCallback(async () => {
const ids = [...selectedIds]; const ids = [...selectedIds];
@@ -82,7 +114,17 @@ export function TokenTab() {
setAccountSteps({}); setAccountSteps({});
try { try {
const { task_id } = await api.validateTokens(ids); 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); watchTask(task_id);
} catch (err) { } catch (err) {
setProcessingIds(new Set()); setProcessingIds(new Set());
@@ -90,45 +132,140 @@ export function TokenTab() {
} }
}, [selectedIds, clearSelection, watchTask, addToast]); }, [selectedIds, clearSelection, watchTask, addToast]);
const handleValidateSingle = useCallback(async (id: number) => { const handleValidateSingle = useCallback(
setProcessingIds(new Set([id])); async (id: number) => {
setAccountResults((r) => { const n = { ...r }; delete n[String(id)]; return n; }); setProcessingIds(new Set([id]));
setAccountSteps((s) => { const n = { ...s }; delete n[String(id)]; return n; }); 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 { try {
const { task_id } = await api.validateTokens([id]); const { task_id } = await api.fullCheckTokens(ids);
setActiveTask({ id: task_id, type: "token_validate", status: "running", progress: 0, total: 1, result: null, error: null, created_at: "", updated_at: "" }); 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); watchTask(task_id);
} catch (err) { } catch (err) {
setProcessingIds(new Set()); setProcessingIds(new Set());
addToast("error", String(err)); 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 handleDeleteSelected = () => {
const ids = [...selectedIds]; const ids = [...selectedIds];
if (!ids.length) return; if (!ids.length) return;
confirm(t("confirm.deleteTokenSelected", { count: ids.length }), async () => { confirm(
await api.deleteTokenBulk(ids); t("confirm.deleteTokenSelected", { count: ids.length }),
clearSelection(); async () => {
loadAccounts(); await api.deleteTokenBulk(ids);
}); clearSelection();
loadAccounts();
},
);
}; };
const handleDeleteAll = () => { const handleDeleteAll = () => {
confirm(t("confirm.deleteTokenAll", { count: accounts.length }), async () => { confirm(
await api.deleteTokenBulk(accounts.map((a) => a.id)); t("confirm.deleteTokenAll", { count: accounts.length }),
clearSelection(); async () => {
loadAccounts(); await api.deleteTokenBulk(accounts.map((a) => a.id));
}); clearSelection();
loadAccounts();
},
);
}; };
const handleAssignProxies = () => { const handleAssignProxies = () => {
confirm(t("confirm.assignProxies"), async () => { confirm(t("confirm.assignProxies"), async () => {
try { try {
const r = await api.assignTokenProxies(); const r = await api.assignTokenProxies();
addToast("success", t("toast.proxiesAssignedShort", { count: r.assigned })); addToast(
"success",
t("toast.proxiesAssignedShort", { count: r.assigned }),
);
loadAccounts(); 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 () => { confirm(t("confirm.reassignProxiesAll"), async () => {
try { try {
const r = await api.reassignTokenProxies(); const r = await api.reassignTokenProxies();
addToast("success", t("toast.proxiesReassignedShort", { count: r.assigned })); addToast(
"success",
t("toast.proxiesReassignedShort", { count: r.assigned }),
);
loadAccounts(); 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 () => { confirm(t("confirm.clearProxies"), async () => {
try { try {
const r = await api.clearTokenProxies(); const r = await api.clearTokenProxies();
addToast("success", t("toast.proxiesClearedShort", { count: r.cleared })); addToast(
"success",
t("toast.proxiesClearedShort", { count: r.cleared }),
);
loadAccounts(); 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")); addToast("warn", t("toast.cookiesExpiredRevalidating"));
if (result.task_id) { if (result.task_id) {
setProcessingIds(new Set([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); watchTask(result.task_id);
} }
} else { } 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(() => { const filtered = useMemo(() => {
if (!searchQuery.trim()) return accounts; if (!searchQuery.trim()) return accounts;
@@ -192,14 +403,29 @@ export function TokenTab() {
} }
const ql = q.toLowerCase(); const ql = q.toLowerCase();
return accounts.filter((a) => return accounts.filter(
(a.login ?? "").toLowerCase().includes(ql) || (a) =>
(a.steam_id ?? "").toLowerCase().includes(ql) || (a.login ?? "").toLowerCase().includes(ql) ||
(a.nickname ?? "").toLowerCase().includes(ql) || (a.steam_id ?? "").toLowerCase().includes(ql) ||
(a.notes ?? "").toLowerCase().includes(ql) (a.nickname ?? "").toLowerCase().includes(ql) ||
(a.notes ?? "").toLowerCase().includes(ql),
); );
}, [accounts, searchQuery]); }, [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 proxyLabels = useMemo(() => {
const map = new Map<number, string>(); const map = new Map<number, string>();
const unique: string[] = []; const unique: string[] = [];
@@ -209,71 +435,80 @@ export function TokenTab() {
const proxyToNum = new Map<string, number>(); const proxyToNum = new Map<string, number>();
unique.forEach((p, i) => proxyToNum.set(p, i + 1)); unique.forEach((p, i) => proxyToNum.set(p, i + 1));
for (const a of accounts) { 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; return map;
}, [accounts, t]); }, [accounts, t]);
return ( return (
<div className="relative flex flex-col gap-4 h-full min-h-0"> <div className="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>
)}
{/* Toolbar */} {/* 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"> <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={() => setShowImport(true)} className="btn-primary">
<button onClick={() => setShowAdd(true)} className="btn-secondary"><IconPlus size={14} className="inline mr-1" />{t("btn.add")}</button> <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 */} {/* Progress indicator */}
{activeTask && ( {activeTask && (
<> <>
<div className="h-5 border-l border-dark-500" /> <div className="h-5 border-l border-dark-500" />
<div className="flex items-center gap-2 min-w-0"> <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="w-28 bg-dark-600 rounded-full h-2.5 shrink-0">
<div <div
className={`h-2.5 rounded-full transition-all duration-300 ${ className={`h-2.5 rounded-full transition-all duration-300 ${
activeTask.status === "completed" ? "bg-green-500" : activeTask.status === "completed"
activeTask.status === "failed" ? "bg-red-500" : "bg-accent" ? "bg-green-500"
: activeTask.status === "failed"
? "bg-red-500"
: "bg-accent"
}`} }`}
style={{ width: `${activeTask.total > 1 style={{
? (activeTask.total > 0 ? Math.round((activeTask.progress / activeTask.total) * 100) : 0) width: `${
: (activeTask.total_steps ? Math.round(((activeTask.step ?? 0) / activeTask.total_steps) * 100) : 0)}%` }} 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> </div>
<span className="text-xs text-gray-500 whitespace-nowrap"> <span className="text-xs text-gray-500 whitespace-nowrap">
{activeTask.total > 1 {activeTask.total > 1
? `${activeTask.progress}/${activeTask.total}${activeTask.active_count ? ` (${activeTask.active_count})` : ""}` ? `${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 > 1
? ` (${activeTask.total > 0 ? Math.round((activeTask.progress / activeTask.total) * 100) : 0}%)` ? ` (${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> </span>
{activeTask.status === "completed" && <IconCheckCircle size={14} className="text-green-400" />} {activeTask.status === "completed" && (
{activeTask.status === "failed" && <span title={activeTask.error || ""}><IconXCircle size={14} className="text-red-400" /></span>} <IconCheckCircle size={14} className="text-green-400" />
)}
{activeTask.status === "failed" && (
<span title={activeTask.error || ""}>
<IconXCircle size={14} className="text-red-400" />
</span>
)}
</div> </div>
</> </>
)} )}
@@ -281,8 +516,15 @@ export function TokenTab() {
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
<TokenColumnSettingsDropdown /> <TokenColumnSettingsDropdown />
<div className="h-5 border-l border-dark-500" /> <div className="h-5 border-l border-dark-500" />
<button onClick={handleDeleteSelected} className="btn-danger-outline text-xs">{t("btn.deleteSelected")}</button> <button
<button onClick={handleDeleteAll} className="btn-danger text-xs">{t("btn.deleteAll")}</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>
</div> </div>
@@ -295,12 +537,39 @@ export function TokenTab() {
disabled={selectedIds.size === 0 || processingIds.size > 0} disabled={selectedIds.size === 0 || processingIds.size > 0}
className="btn-accent text-sm disabled:opacity-40" 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> </button>
<div className="h-5 border-l border-dark-500" /> <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
<button onClick={handleReassignProxies} className="btn-secondary text-sm"><IconRefresh size={14} className="inline mr-1" />{t("btn.reassign")}</button> onClick={handleAssignProxies}
<button onClick={handleClearProxies} className="btn-danger-outline text-sm"><IconBan size={14} className="inline mr-1" />{t("btn.clearProxies")}</button> 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"> <div className="ml-auto">
<input <input
type="text" type="text"
@@ -319,47 +588,85 @@ export function TokenTab() {
<tr className="text-left text-xs text-gray-500 border-b border-dark-600"> <tr className="text-left text-xs text-gray-500 border-b border-dark-600">
<th className="px-3 py-2"> <th className="px-3 py-2">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<input type="checkbox" checked={allSelected} onChange={() => allSelected ? clearSelection() : selectAll()} /> <input
{selectedIds.size > 0 && <span className="text-accent font-medium">{selectedIds.size}</span>} type="checkbox"
checked={allFilteredSelected}
onChange={toggleSelectAllFiltered}
/>
{selectedIds.size > 0 && (
<span className="text-accent font-medium">
{selectedIds.size}
</span>
)}
</div> </div>
</th> </th>
<th className="w-5" /> <th className="w-5" />
{cols.browser && <th className="px-3 py-2">{t("col.browser")}</th>} {cols.browser && (
{cols.profile && <th className="px-3 py-2">{t("col.profile")}</th>} <th className="px-3 py-2">{t("col.browser")}</th>
{cols.last_online && <th className="px-3 py-2">{t("col.lastOnline")}</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.steam_id && <th className="px-3 py-2">Steam ID</th>}
{cols.login && <th className="px-3 py-2">{t("col.login")}</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.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.proxy && <th className="px-3 py-2">{t("col.proxy")}</th>}
{cols.notes && <th className="px-3 py-2">{t("col.notes")}</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> </tr>
</thead> </thead>
<tbody> <tbody>
{filtered.length === 0 ? ( {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) => { filtered.map((a) => {
const isProcessing = processingIds.has(a.id); const isProcessing = processingIds.has(a.id);
const result = accountResults[String(a.id)]; const result = accountResults[String(a.id)];
const step = accountSteps[String(a.id)]; const step = accountSteps[String(a.id)];
return ( 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"> <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> </td>
{/* Status icon */} {/* Status icon */}
<td className="px-1 py-2 w-5"> <td className="px-1 py-2 w-5">
{isProcessing ? ( {isProcessing ? (
<div className="flex items-center gap-1"> <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" /> <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> </div>
) : result?.status === "ok" ? ( ) : result?.status === "ok" ? (
<IconCheck size={16} className="text-gray-400" /> <IconCheck size={16} className="text-gray-400" />
) : result?.status === "error" ? ( ) : 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} ) : null}
</td> </td>
{cols.browser && ( {cols.browser && (
@@ -369,7 +676,9 @@ export function TokenTab() {
onClick={() => handleOpenBrowser(a.id)} 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" 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")} title={t("tip.openBrowser")}
>{t("col.browser")}</button> >
{t("col.browser")}
</button>
) : ( ) : (
<span className="text-gray-600 text-xs"></span> <span className="text-gray-600 text-xs"></span>
)} )}
@@ -378,27 +687,69 @@ export function TokenTab() {
{cols.profile && ( {cols.profile && (
<td className="px-3 py-2 whitespace-nowrap"> <td className="px-3 py-2 whitespace-nowrap">
<div className="flex items-center gap-1.5"> <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> <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> </div>
</td> </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 && ( {cols.steam_id && (
<td className="px-3 py-2 text-xs"> <td className="px-3 py-2 text-xs">
{a.steam_id ? ( {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.steam_id}
</a> </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> </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 && ( {cols.actions && (
<td className="px-3 py-2 whitespace-nowrap"> <td className="px-3 py-2 whitespace-nowrap">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
@@ -407,12 +758,56 @@ export function TokenTab() {
disabled={isProcessing} disabled={isProcessing}
className="text-gray-400 hover:text-accent disabled:opacity-40 transition" className="text-gray-400 hover:text-accent disabled:opacity-40 transition"
title={t("tip.validate")} title={t("tip.validate")}
><IconPlay size={14} /></button> >
<IconPlay size={14} />
</button>
<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" className="text-gray-400 hover:text-red-400 transition"
title={t("tip.delete")} title={t("tip.delete")}
><IconTrash size={14} /></button> >
<IconTrash size={14} />
</button>
</div> </div>
</td> </td>
)} )}
@@ -425,12 +820,34 @@ export function TokenTab() {
</div> </div>
</div> </div>
{showImport && <TokenImportModal onClose={() => setShowImport(false)} />} {showImport && (
<TokenImportModal
onClose={() => setShowImport(false)}
onImportDone={handleAfterTokenImport}
/>
)}
{showAdd && <TokenAddModal onClose={() => setShowAdd(false)} />} {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.open && (
<ConfirmModal <ConfirmModal
message={confirmModal.message} 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 }))} onCancel={() => setConfirmModal((p) => ({ ...p, open: false }))}
/> />
)} )}
@@ -5,16 +5,31 @@ import { useUiStore } from "@/stores/uiStore";
import { useT } from "@/lib/i18n"; import { useT } from "@/lib/i18n";
import { IconSettings } from "@/components/shared/Icons"; 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 ( return (
<label className="toggle-switch"> <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" /> <span className="toggle-track" />
</label> </label>
); );
} }
function SettingRow({ label, desc, checked, onChange }: { function SettingRow({
label,
desc,
checked,
onChange,
}: {
label: string; label: string;
desc?: string; desc?: string;
checked: boolean; 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 [editing, setEditing] = useState(false);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -42,14 +63,32 @@ function ThreadStepper({ value, onChange }: { value: number; onChange: (v: numbe
setEditing(false); 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 ( return (
<div className="flex items-center gap-3 pt-3 mt-0.5"> <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"> <div className="flex items-center gap-1">
<button type="button" className={btnCls} onClick={() => onChange(clamp(value - 10))}>-10</button> <button
<button type="button" className={btnCls} onClick={() => onChange(clamp(value - 1))}>-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>
{editing ? ( {editing ? (
<input <input
ref={inputRef} ref={inputRef}
@@ -57,18 +96,38 @@ function ThreadStepper({ value, onChange }: { value: number; onChange: (v: numbe
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
onBlur={commit} 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" 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 <span
className="w-10 text-center text-sm font-mono text-gray-100 cursor-pointer select-none" 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="Двойной клик для ручного ввода" title="Двойной клик для ручного ввода"
>{value}</span> >
{value}
</span>
)} )}
<button type="button" className={btnCls} onClick={() => onChange(clamp(value + 1))}>+1</button> <button
<button type="button" className={btnCls} onClick={() => onChange(clamp(value + 10))}>+10</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>
</div> </div>
); );
@@ -81,21 +140,40 @@ export function ValidationSettings() {
check_ban: true, check_ban: true,
max_threads: 5, max_threads: 5,
auto_revalidate_browser: true, 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 hidePasswords = useUiStore((s) => s.hidePasswords);
const setHidePasswords = useUiStore((s) => s.setHidePasswords); const setHidePasswords = useUiStore((s) => s.setHidePasswords);
const loadImportSettings = useUiStore((s) => s.loadImportSettings);
const addToast = useUiStore((s) => s.addToast); const addToast = useUiStore((s) => s.addToast);
useEffect(() => { useEffect(() => {
api.getValidationSettings().then(setSettings).catch(() => {}); api
.getValidationSettings()
.then(setSettings)
.catch(() => {});
}, []); }, []);
const save = async () => { const save = async () => {
try { try {
await api.updateValidationSettings(settings); await api.updateValidationSettings(settings);
await loadImportSettings(); // sync stale uiStore values
addToast("success", t("toast.settingsSaved")); addToast("success", t("toast.settingsSaved"));
} catch (e: unknown) { } 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 <SettingRow
label={t("tools.autoRevalidateBrowser")} label={t("tools.autoRevalidateBrowser")}
checked={settings.auto_revalidate_browser} checked={settings.auto_revalidate_browser}
onChange={(v) => setSettings({ ...settings, auto_revalidate_browser: v })} onChange={(v) =>
setSettings({ ...settings, auto_revalidate_browser: v })
}
/> />
</div> </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>
<div className="px-5 pt-4 pb-3"> <div className="px-5 pt-4 pb-3">
@@ -142,7 +285,9 @@ export function ValidationSettings() {
</div> </div>
<div className="px-5 py-3 border-t border-dark-600 flex justify-end"> <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>
</div> </div>
); );
+62 -17
View File
@@ -7,9 +7,13 @@ const STORAGE_KEY = "steampanel_locale";
let _locale: Locale = (localStorage.getItem(STORAGE_KEY) as Locale) || "ru"; let _locale: Locale = (localStorage.getItem(STORAGE_KEY) as Locale) || "ru";
const _listeners = new Set<() => void>(); 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) { export function setLocale(l: Locale) {
_locale = l; _locale = l;
@@ -19,7 +23,10 @@ export function setLocale(l: Locale) {
export function useLocale(): [Locale, (l: Locale) => void] { export function useLocale(): [Locale, (l: Locale) => void] {
const locale = useSyncExternalStore( const locale = useSyncExternalStore(
(cb) => { _listeners.add(cb); return () => _listeners.delete(cb); }, (cb) => {
_listeners.add(cb);
return () => _listeners.delete(cb);
},
() => _locale, () => _locale,
); );
return [locale, setLocale]; return [locale, setLocale];
@@ -82,7 +89,8 @@ const translations: Translations = {
"prompt.newPassword": "Введите новый пароль", "prompt.newPassword": "Введите новый пароль",
"prompt.newEmail": "Введите новый email", "prompt.newEmail": "Введите новый email",
"prompt.newPhone": "Введите новый номер телефона", "prompt.newPhone": "Введите новый номер телефона",
"prompt.removePhone": "Введите номер телефона (на который переставится старый)", "prompt.removePhone":
"Введите номер телефона (на который переставится старый)",
"prompt.enterValue": "Введите значение...", "prompt.enterValue": "Введите значение...",
// -- Confirm dialogs -- // -- Confirm dialogs --
"confirm.removeGuard": "Удалить Steam Guard для этого аккаунта?", "confirm.removeGuard": "Удалить Steam Guard для этого аккаунта?",
@@ -123,6 +131,7 @@ const translations: Translations = {
"btn.clearProxies": "Очистить прокси", "btn.clearProxies": "Очистить прокси",
"btn.validate": "Валидировать", "btn.validate": "Валидировать",
"btn.fullParse": "Полный парс", "btn.fullParse": "Полный парс",
"btn.fullCheck": "Полная проверка",
"btn.generate": "Сгенерировать", "btn.generate": "Сгенерировать",
// -- Toast messages -- // -- Toast messages --
"toast.copied": "Скопировано", "toast.copied": "Скопировано",
@@ -142,7 +151,8 @@ const translations: Translations = {
"toast.autoAcceptOff": "Авто-принятие входа выключено", "toast.autoAcceptOff": "Авто-принятие входа выключено",
"toast.taskCancelled": "Задача отменена", "toast.taskCancelled": "Задача отменена",
"toast.browserOpening": "Браузер открывается...", "toast.browserOpening": "Браузер открывается...",
"toast.cookiesExpiredRevalidating": "Куки устарели. Запущена повторная валидация...", "toast.cookiesExpiredRevalidating":
"Куки устарели. Запущена повторная валидация...",
"toast.cookiesExpired": "Куки устарели. Провалидируйте аккаунт заново.", "toast.cookiesExpired": "Куки устарели. Провалидируйте аккаунт заново.",
"toast.proxyCleared": "Прокси убран у {name}", "toast.proxyCleared": "Прокси убран у {name}",
"toast.proxiesReassigned": "Переназначено {used} прокси на {count} акк.", "toast.proxiesReassigned": "Переназначено {used} прокси на {count} акк.",
@@ -249,7 +259,8 @@ const translations: Translations = {
"proxy.checking": "Проверка...", "proxy.checking": "Проверка...",
"proxy.checkAll": "Проверить все", "proxy.checkAll": "Проверить все",
"proxy.deleteAllBtn": "Удалить все прокси", "proxy.deleteAllBtn": "Удалить все прокси",
"proxy.confirmDeleteAll": "Удалить все прокси? Это действие нельзя отменить.", "proxy.confirmDeleteAll":
"Удалить все прокси? Это действие нельзя отменить.",
"proxy.checkResult": "Всего: {total} | Живых: {alive} | Мёртвых: {dead}", "proxy.checkResult": "Всего: {total} | Живых: {alive} | Мёртвых: {dead}",
"proxy.removeProxy": "Убрать прокси", "proxy.removeProxy": "Убрать прокси",
"proxy.reassignProxy": "Переназначить прокси", "proxy.reassignProxy": "Переназначить прокси",
@@ -262,6 +273,15 @@ const translations: Translations = {
"tools.checkBans": "Проверять баны", "tools.checkBans": "Проверять баны",
"tools.maxThreads": "Макс. потоков:", "tools.maxThreads": "Макс. потоков:",
"tools.autoRevalidateBrowser": "Автовалидация при мёртвых куках (браузер)", "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.clickCopy": "Клик для копирования",
"tools.genErrorStr": "Ошибка", "tools.genErrorStr": "Ошибка",
// -- Logs panel -- // -- Logs panel --
@@ -293,7 +313,8 @@ const translations: Translations = {
"mafile.clearBtn": "Очистить", "mafile.clearBtn": "Очистить",
"mafile.insert": "Вставить {value}", "mafile.insert": "Вставить {value}",
"mafile.emptyTitle": "Нет выбранных аккаунтов", "mafile.emptyTitle": "Нет выбранных аккаунтов",
"mafile.emptyHint": "Выберите аккаунты в таблице и отправьте сюда для экспорта mafile", "mafile.emptyHint":
"Выберите аккаунты в таблице и отправьте сюда для экспорта mafile",
"mafile.accountsCount": "акк.", "mafile.accountsCount": "акк.",
"mafile.withMafile": "с mafile", "mafile.withMafile": "с mafile",
"mafile.namingSection": "Шаблоны имён", "mafile.namingSection": "Шаблоны имён",
@@ -305,9 +326,12 @@ const translations: Translations = {
"settings.hidePasswords": "Скрывать пароли (спойлер)", "settings.hidePasswords": "Скрывать пароли (спойлер)",
// -- Token disclaimer -- // -- Token disclaimer --
"token.disclaimerTitle": "Вкладка в разработке", "token.disclaimerTitle": "Вкладка в разработке",
"token.disclaimerBody1": "Функциональность вкладки Token <strong>не завершена</strong> и находится в стадии разработки.", "token.disclaimerBody1":
"token.disclaimerBody2": "⚠ Валидация токенов может привести к их инвалидации (убить токены). Используйте на свой страх и риск.", "Функциональность вкладки Token <strong>не завершена</strong> и находится в стадии разработки.",
"token.disclaimerBody3": "Импорт, редактирование и любые действия с токенами могут работать некорректно.", "token.disclaimerBody2":
"⚠ Валидация токенов может привести к их инвалидации (убить токены). Используйте на свой страх и риск.",
"token.disclaimerBody3":
"Импорт, редактирование и любые действия с токенами могут работать некорректно.",
"token.acceptRisk": "Я понимаю риски и хочу продолжить", "token.acceptRisk": "Я понимаю риски и хочу продолжить",
// -- 2FA -- // -- 2FA --
"twofa.copied": "2FA: {code} (скопировано)", "twofa.copied": "2FA: {code} (скопировано)",
@@ -377,7 +401,8 @@ const translations: Translations = {
"prompt.newPassword": "Enter new password", "prompt.newPassword": "Enter new password",
"prompt.newEmail": "Enter new email", "prompt.newEmail": "Enter new email",
"prompt.newPhone": "Enter new phone number", "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...", "prompt.enterValue": "Enter value...",
// -- Confirm dialogs -- // -- Confirm dialogs --
"confirm.removeGuard": "Remove Steam Guard for this account?", "confirm.removeGuard": "Remove Steam Guard for this account?",
@@ -418,6 +443,7 @@ const translations: Translations = {
"btn.clearProxies": "Clear proxies", "btn.clearProxies": "Clear proxies",
"btn.validate": "Validate", "btn.validate": "Validate",
"btn.fullParse": "Full parse", "btn.fullParse": "Full parse",
"btn.fullCheck": "Full Check",
"btn.generate": "Generate", "btn.generate": "Generate",
// -- Toast messages -- // -- Toast messages --
"toast.copied": "Copied", "toast.copied": "Copied",
@@ -557,6 +583,15 @@ const translations: Translations = {
"tools.checkBans": "Check bans", "tools.checkBans": "Check bans",
"tools.maxThreads": "Max threads:", "tools.maxThreads": "Max threads:",
"tools.autoRevalidateBrowser": "Auto-revalidate on dead cookies (browser)", "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.clickCopy": "Click to copy",
"tools.genErrorStr": "Error", "tools.genErrorStr": "Error",
// -- Logs panel -- // -- Logs panel --
@@ -588,7 +623,8 @@ const translations: Translations = {
"mafile.clearBtn": "Clear", "mafile.clearBtn": "Clear",
"mafile.insert": "Insert {value}", "mafile.insert": "Insert {value}",
"mafile.emptyTitle": "No accounts selected", "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.accountsCount": "accs",
"mafile.withMafile": "with mafile", "mafile.withMafile": "with mafile",
"mafile.namingSection": "Naming templates", "mafile.namingSection": "Naming templates",
@@ -600,9 +636,12 @@ const translations: Translations = {
"settings.hidePasswords": "Hide passwords (spoiler)", "settings.hidePasswords": "Hide passwords (spoiler)",
// -- Token disclaimer -- // -- Token disclaimer --
"token.disclaimerTitle": "Tab under development", "token.disclaimerTitle": "Tab under development",
"token.disclaimerBody1": "Token tab functionality is <strong>not complete</strong> and is under development.", "token.disclaimerBody1":
"token.disclaimerBody2": "Token validation may invalidate (kill) tokens. Use at your own risk.", "Token tab functionality is <strong>not complete</strong> and is under development.",
"token.disclaimerBody3": "Import, editing and any actions with tokens may work incorrectly.", "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", "token.acceptRisk": "I understand the risks and want to continue",
// -- 2FA -- // -- 2FA --
"twofa.copied": "2FA: {code} (copied)", "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; const dict = translations[_locale] || translations.ru;
let text = dict[key] ?? translations.ru[key] ?? key; let text = dict[key] ?? translations.ru[key] ?? key;
if (params) { if (params) {
@@ -633,7 +675,10 @@ export function t(key: string, params?: Record<string, string | number>): string
export function useT() { export function useT() {
useSyncExternalStore( useSyncExternalStore(
(cb) => { _listeners.add(cb); return () => _listeners.delete(cb); }, (cb) => {
_listeners.add(cb);
return () => _listeners.delete(cb);
},
() => _locale, () => _locale,
); );
return t; return t;
+2
View File
@@ -11,6 +11,7 @@ interface LogpassState {
toggleSelect: (id: number) => void; toggleSelect: (id: number) => void;
selectAll: () => void; selectAll: () => void;
clearSelection: () => void; clearSelection: () => void;
setSelectedIds: (ids: Set<number>) => void;
} }
export const useLogpassStore = create<LogpassState>((set) => ({ 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)) })), set((s) => ({ selectedIds: new Set(s.accounts.map((a) => a.id)) })),
clearSelection: () => set({ selectedIds: new Set() }), clearSelection: () => set({ selectedIds: new Set() }),
setSelectedIds: (ids) => set({ selectedIds: ids }),
})); }));
+2
View File
@@ -11,6 +11,7 @@ interface TokenState {
toggleSelect: (id: number) => void; toggleSelect: (id: number) => void;
selectAll: () => void; selectAll: () => void;
clearSelection: () => void; clearSelection: () => void;
setSelectedIds: (ids: Set<number>) => void;
} }
export const useTokenStore = create<TokenState>((set) => ({ 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)) })), set((s) => ({ selectedIds: new Set(s.accounts.map((a) => a.id)) })),
clearSelection: () => set({ selectedIds: new Set() }), clearSelection: () => set({ selectedIds: new Set() }),
setSelectedIds: (ids) => set({ selectedIds: ids }),
})); }));
+112 -18
View File
@@ -1,26 +1,67 @@
import { create } from "zustand"; 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"; import { api } from "@/api/client";
let _toastId = 0; let _toastId = 0;
const DEFAULT_COLUMNS: ColumnSettings = { const DEFAULT_COLUMNS: ColumnSettings = {
browser: true, profile: true, steam_id: true, login: true, password: true, browser: true,
login_pass: true, email: true, email_pass: false, email_login_pass: false, profile: true,
phone: true, status: true, steam_id: true,
ban: true, twofa: true, mafile: true, proxy: true, notes: true, actions: 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, last_online: true,
}; };
const DEFAULT_LOGPASS_COLUMNS: LogpassColumnSettings = { const DEFAULT_LOGPASS_COLUMNS: LogpassColumnSettings = {
browser: true, profile: true, steam_id: true, login: true, password: true, login_pass: true, browser: true,
status: true, ban: true, prime: true, trophy: true, behavior: true, profile: true,
license: true, proxy: true, notes: true, actions: true, last_online: 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 = { const DEFAULT_TOKEN_COLUMNS: TokenColumnSettings = {
browser: true, profile: true, last_online: true, steam_id: true, login: true, browser: true,
token: true, status: true, proxy: true, notes: true, actions: true, profile: true,
last_online: true,
steam_id: true,
login: true,
token: true,
status: true,
proxy: true,
notes: true,
actions: true,
}; };
interface UiState { interface UiState {
@@ -41,6 +82,15 @@ interface UiState {
logpassColumnVisibility: LogpassColumnSettings; logpassColumnVisibility: LogpassColumnSettings;
toggleLogpassColumn: (key: keyof LogpassColumnSettings) => void; toggleLogpassColumn: (key: keyof LogpassColumnSettings) => void;
loadLogpassColumnSettings: () => Promise<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; tokenColumnVisibility: TokenColumnSettings;
toggleTokenColumn: (key: keyof TokenColumnSettings) => void; toggleTokenColumn: (key: keyof TokenColumnSettings) => void;
loadTokenColumnSettings: () => Promise<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) })); set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
}, 4000); }, 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, hidePasswords: false,
setHidePasswords: (v) => { setHidePasswords: (v) => {
@@ -70,13 +121,18 @@ export const useUiStore = create<UiState>((set, get) => ({
try { try {
const d = await api.getDisplaySettings(); const d = await api.getDisplaySettings();
set({ hidePasswords: d.hide_passwords }); set({ hidePasswords: d.hide_passwords });
} catch { /* ignore */ } } catch {
/* ignore */
}
}, },
columnVisibility: { ...DEFAULT_COLUMNS }, columnVisibility: { ...DEFAULT_COLUMNS },
setColumnVisibility: (v) => set({ columnVisibility: v }), setColumnVisibility: (v) => set({ columnVisibility: v }),
toggleColumn: (key) => { toggleColumn: (key) => {
const cols = { ...get().columnVisibility, [key]: !get().columnVisibility[key] }; const cols = {
...get().columnVisibility,
[key]: !get().columnVisibility[key],
};
set({ columnVisibility: cols }); set({ columnVisibility: cols });
api.updateColumnSettings(cols).catch(() => {}); api.updateColumnSettings(cols).catch(() => {});
}, },
@@ -84,12 +140,17 @@ export const useUiStore = create<UiState>((set, get) => ({
try { try {
const c = await api.getColumnSettings(); const c = await api.getColumnSettings();
set({ columnVisibility: { ...DEFAULT_COLUMNS, ...c } }); set({ columnVisibility: { ...DEFAULT_COLUMNS, ...c } });
} catch { /* ignore */ } } catch {
/* ignore */
}
}, },
logpassColumnVisibility: { ...DEFAULT_LOGPASS_COLUMNS }, logpassColumnVisibility: { ...DEFAULT_LOGPASS_COLUMNS },
toggleLogpassColumn: (key) => { toggleLogpassColumn: (key) => {
const cols = { ...get().logpassColumnVisibility, [key]: !get().logpassColumnVisibility[key] }; const cols = {
...get().logpassColumnVisibility,
[key]: !get().logpassColumnVisibility[key],
};
set({ logpassColumnVisibility: cols }); set({ logpassColumnVisibility: cols });
api.updateLogpassColumnSettings(cols).catch(() => {}); api.updateLogpassColumnSettings(cols).catch(() => {});
}, },
@@ -97,12 +158,43 @@ export const useUiStore = create<UiState>((set, get) => ({
try { try {
const c = await api.getLogpassColumnSettings(); const c = await api.getLogpassColumnSettings();
set({ logpassColumnVisibility: { ...DEFAULT_LOGPASS_COLUMNS, ...c } }); 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 }, tokenColumnVisibility: { ...DEFAULT_TOKEN_COLUMNS },
toggleTokenColumn: (key) => { toggleTokenColumn: (key) => {
const cols = { ...get().tokenColumnVisibility, [key]: !get().tokenColumnVisibility[key] }; const cols = {
...get().tokenColumnVisibility,
[key]: !get().tokenColumnVisibility[key],
};
set({ tokenColumnVisibility: cols }); set({ tokenColumnVisibility: cols });
api.updateTokenColumnSettings(cols).catch(() => {}); api.updateTokenColumnSettings(cols).catch(() => {});
}, },
@@ -110,6 +202,8 @@ export const useUiStore = create<UiState>((set, get) => ({
try { try {
const c = await api.getTokenColumnSettings(); const c = await api.getTokenColumnSettings();
set({ tokenColumnVisibility: { ...DEFAULT_TOKEN_COLUMNS, ...c } }); set({ tokenColumnVisibility: { ...DEFAULT_TOKEN_COLUMNS, ...c } });
} catch { /* ignore */ } } catch {
/* ignore */
}
}, },
})); }));
+35 -14
View File
@@ -3,31 +3,43 @@ import socket
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from loguru import logger
from app.api import (
accounts,
actions,
auto_accept,
browser,
logpass,
logs,
mafile_tools,
proxies,
tasks,
token_accounts,
)
from app.api import settings as settings_api
from app.config import settings
from app.core.auto_accept import auto_accept_manager
from app.core.logging import setup_logging
from app.core.proxy_manager import proxy_manager
from app.database import close_db, get_db
from app.services.registry import register_all_handlers
mimetypes.add_type("application/javascript", ".js") mimetypes.add_type("application/javascript", ".js")
mimetypes.add_type("text/css", ".css") mimetypes.add_type("text/css", ".css")
mimetypes.add_type("image/svg+xml", ".svg") mimetypes.add_type("image/svg+xml", ".svg")
from loguru import logger
VERSION = "0.3.4"
from app.config import settings VERSION = "0.3.5"
from app.core.logging import setup_logging
from app.database import get_db, close_db
from app.core.proxy_manager import proxy_manager
from app.core.auto_accept import auto_accept_manager
from app.services.registry import register_all_handlers
from app.api import accounts, actions, proxies, tasks, mafile_tools, logs, auto_accept, browser
from app.api import settings as settings_api
from app.api import logpass, token_accounts
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
setup_logging() setup_logging()
from app.api.logs import install_log_sink from app.api.logs import install_log_sink
install_log_sink() install_log_sink()
db = await get_db() db = await get_db()
@@ -73,6 +85,7 @@ app = FastAPI(
async def get_version(): async def get_version():
return {"version": VERSION} return {"version": VERSION}
app.include_router(accounts.router) app.include_router(accounts.router)
app.include_router(actions.router) app.include_router(actions.router)
app.include_router(proxies.router) app.include_router(proxies.router)
@@ -90,6 +103,7 @@ static_dir.mkdir(parents=True, exist_ok=True)
_MIME = {".js": "application/javascript", ".css": "text/css", ".svg": "image/svg+xml"} _MIME = {".js": "application/javascript", ".css": "text/css", ".svg": "image/svg+xml"}
@app.get("/", include_in_schema=False) @app.get("/", include_in_schema=False)
async def serve_root(): async def serve_root():
return FileResponse( return FileResponse(
@@ -97,16 +111,22 @@ async def serve_root():
headers={"Cache-Control": "no-store, no-cache, must-revalidate"}, headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
) )
@app.get("/assets/{filename:path}", include_in_schema=False) @app.get("/assets/{filename:path}", include_in_schema=False)
async def serve_asset(filename: str): async def serve_asset(filename: str):
from fastapi import HTTPException from fastapi import HTTPException
file_path = static_dir / "assets" / filename file_path = static_dir / "assets" / filename
if not file_path.exists(): if not file_path.exists():
raise HTTPException(status_code=404) raise HTTPException(status_code=404)
ext = "." + filename.rsplit(".", 1)[-1] if "." in filename else "" ext = "." + filename.rsplit(".", 1)[-1] if "." in filename else ""
media_type = _MIME.get(ext, "application/octet-stream") media_type = _MIME.get(ext, "application/octet-stream")
return FileResponse(str(file_path), media_type=media_type, return FileResponse(
headers={"Cache-Control": "public, max-age=31536000, immutable"}) str(file_path),
media_type=media_type,
headers={"Cache-Control": "public, max-age=31536000, immutable"},
)
app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static") app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")
@@ -128,6 +148,7 @@ if __name__ == "__main__":
import threading import threading
import time import time
import webbrowser import webbrowser
import uvicorn import uvicorn
# Install log intercept before uvicorn.run() so the first two startup # Install log intercept before uvicorn.run() so the first two startup
+7
View File
@@ -40,6 +40,13 @@ if errorlevel 1 (
:launch :launch
cls cls
rem === Patch nodriver/cdp/network.py encoding bug (non-UTF-8 byte \xb1 in comment) ===
venv\Scripts\python.exe -c ^
"import pathlib;^
p=pathlib.Path('venv/Lib/site-packages/nodriver/cdp/network.py');^
d=p.read_bytes() if p.exists() else b'';^
p.write_bytes(d.replace(b'\xb1',b'+-')) if p.exists() and b'\xb1' in d else None"
rem === Check built frontend === rem === Check built frontend ===
if not exist "app\static\assets\" ( if not exist "app\static\assets\" (
echo. echo.