更新:
This commit is contained in:
@@ -818,7 +818,22 @@ class DouyinImHttpClient:
|
|||||||
"""通过 IM API 发送 Protobuf 编码的私信(带接口签名)
|
"""通过 IM API 发送 Protobuf 编码的私信(带接口签名)
|
||||||
|
|
||||||
content 可为纯文本,或 JSON 格式的结构化回复(文本/网址/卡片)。
|
content 可为纯文本,或 JSON 格式的结构化回复(文本/网址/卡片)。
|
||||||
|
所有出站发送(自动回复/手动 API/脚本)都经过全局限流器,
|
||||||
|
防止大量托管账号同时发送占满出站带宽或触发平台级风控。
|
||||||
"""
|
"""
|
||||||
|
from .rate_limit import get_send_limiter
|
||||||
|
|
||||||
|
async with get_send_limiter():
|
||||||
|
return await self._send_text_message_unthrottled(
|
||||||
|
conversation_id, content, conversation_short_id
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _send_text_message_unthrottled(
|
||||||
|
self,
|
||||||
|
conversation_id: str,
|
||||||
|
content: str,
|
||||||
|
conversation_short_id: str = "",
|
||||||
|
) -> bool:
|
||||||
from .auth import DouyinAuth
|
from .auth import DouyinAuth
|
||||||
from .proto_builder import ProtoBuilder
|
from .proto_builder import ProtoBuilder
|
||||||
from .reply_payload import (
|
from .reply_payload import (
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""全局出站发送限流器。
|
||||||
|
|
||||||
|
目的:托管账号数量多(如 1000 个)时,避免自动回复/手动发送在同一时刻
|
||||||
|
大量并发打向抖音接口,占满服务器出站带宽或触发平台级风控。
|
||||||
|
|
||||||
|
机制(两层,先到先过):
|
||||||
|
1. 并发上限(Semaphore):同时在途的发送请求数不超过 KEFU_SEND_MAX_CONCURRENCY;
|
||||||
|
2. 令牌桶(QPS):平均发送速率不超过 KEFU_SEND_RATE_PER_SEC,允许 KEFU_SEND_BURST 的突发。
|
||||||
|
|
||||||
|
限流器按事件循环惰性创建(backend 主循环一个实例;独立脚本各自一个),
|
||||||
|
所有账号共享同一个实例,因此是跨账号的全局闸门。
|
||||||
|
|
||||||
|
环境变量:
|
||||||
|
KEFU_SEND_MAX_CONCURRENCY 同时在途发送数上限,默认 20,<=0 表示不限
|
||||||
|
KEFU_SEND_RATE_PER_SEC 平均每秒发送数上限,默认 10,<=0 表示不限
|
||||||
|
KEFU_SEND_BURST 令牌桶容量(突发上限),默认与并发上限相同
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
logger = logging.getLogger("douyin_im.rate_limit")
|
||||||
|
|
||||||
|
|
||||||
|
def _env_float(name: str, default: float) -> float:
|
||||||
|
try:
|
||||||
|
return float(os.getenv(name, "") or default)
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalSendLimiter:
|
||||||
|
"""并发上限 + 令牌桶。用法:async with limiter: await 发送。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
max_concurrency: int = 20,
|
||||||
|
rate_per_sec: float = 10.0,
|
||||||
|
burst: float | None = None,
|
||||||
|
):
|
||||||
|
self.max_concurrency = int(max_concurrency)
|
||||||
|
self.rate_per_sec = float(rate_per_sec)
|
||||||
|
self.burst = float(burst if burst is not None else max(1, max_concurrency))
|
||||||
|
self._sem = (
|
||||||
|
asyncio.Semaphore(self.max_concurrency) if self.max_concurrency > 0 else None
|
||||||
|
)
|
||||||
|
self._tokens = self.burst
|
||||||
|
self._last_refill = time.monotonic()
|
||||||
|
self._token_lock = asyncio.Lock()
|
||||||
|
# 统计:便于日志观察限流是否生效
|
||||||
|
self._waited_total = 0.0
|
||||||
|
self._acquired_count = 0
|
||||||
|
|
||||||
|
async def _take_token(self) -> None:
|
||||||
|
if self.rate_per_sec <= 0:
|
||||||
|
return
|
||||||
|
async with self._token_lock:
|
||||||
|
while True:
|
||||||
|
now = time.monotonic()
|
||||||
|
self._tokens = min(
|
||||||
|
self.burst, self._tokens + (now - self._last_refill) * self.rate_per_sec
|
||||||
|
)
|
||||||
|
self._last_refill = now
|
||||||
|
if self._tokens >= 1.0:
|
||||||
|
self._tokens -= 1.0
|
||||||
|
return
|
||||||
|
await asyncio.sleep((1.0 - self._tokens) / self.rate_per_sec)
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "GlobalSendLimiter":
|
||||||
|
start = time.monotonic()
|
||||||
|
if self._sem is not None:
|
||||||
|
await self._sem.acquire()
|
||||||
|
try:
|
||||||
|
await self._take_token()
|
||||||
|
except BaseException:
|
||||||
|
if self._sem is not None:
|
||||||
|
self._sem.release()
|
||||||
|
raise
|
||||||
|
waited = time.monotonic() - start
|
||||||
|
self._waited_total += waited
|
||||||
|
self._acquired_count += 1
|
||||||
|
if waited > 1.0:
|
||||||
|
logger.info(
|
||||||
|
"send throttled: waited %.1fs (in-flight cap=%s, rate=%s/s)",
|
||||||
|
waited, self.max_concurrency or "∞", self.rate_per_sec or "∞",
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc) -> None:
|
||||||
|
if self._sem is not None:
|
||||||
|
self._sem.release()
|
||||||
|
|
||||||
|
|
||||||
|
# 每个事件循环一个实例(backend 主循环即全局唯一;脚本自用循环互不影响)
|
||||||
|
_limiters: dict[int, GlobalSendLimiter] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_send_limiter() -> GlobalSendLimiter:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
key = id(loop)
|
||||||
|
limiter = _limiters.get(key)
|
||||||
|
if limiter is None:
|
||||||
|
max_conc = int(_env_float("KEFU_SEND_MAX_CONCURRENCY", 20))
|
||||||
|
rate = _env_float("KEFU_SEND_RATE_PER_SEC", 10.0)
|
||||||
|
burst = _env_float("KEFU_SEND_BURST", max(1, max_conc))
|
||||||
|
limiter = GlobalSendLimiter(max_conc, rate, burst)
|
||||||
|
_limiters[key] = limiter
|
||||||
|
logger.info(
|
||||||
|
"global send limiter ready: concurrency=%s, rate=%s/s, burst=%s",
|
||||||
|
max_conc if max_conc > 0 else "unlimited",
|
||||||
|
rate if rate > 0 else "unlimited",
|
||||||
|
burst,
|
||||||
|
)
|
||||||
|
return limiter
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import random
|
||||||
import time
|
import time
|
||||||
from typing import Awaitable, Callable, Optional
|
from typing import Awaitable, Callable, Optional
|
||||||
|
|
||||||
@@ -374,6 +376,22 @@ class DouyinImService:
|
|||||||
)
|
)
|
||||||
await self._ws_client.start()
|
await self._ws_client.start()
|
||||||
|
|
||||||
|
# 轮询错峰:多账号同时托管时,若所有账号按同一节奏轮询,请求会在同一
|
||||||
|
# 时刻叠峰。这里给每个账号随机相位偏移 + 每轮 ±20% 抖动,把请求摊平。
|
||||||
|
# WS 可用时轮询只是兜底,可以适当放缓(KEFU_IM_POLL_INTERVAL_SECONDS 可调)。
|
||||||
|
try:
|
||||||
|
poll_interval = float(os.getenv("KEFU_IM_POLL_INTERVAL_SECONDS", "") or 15)
|
||||||
|
except ValueError:
|
||||||
|
poll_interval = 15.0
|
||||||
|
poll_interval = max(5.0, poll_interval)
|
||||||
|
if has_ws:
|
||||||
|
poll_interval = max(poll_interval, 30.0)
|
||||||
|
|
||||||
|
# 首轮轮询前的随机延迟(相位偏移),批量启动时错开各账号的首波请求
|
||||||
|
await asyncio.sleep(random.uniform(0.5, min(10.0, poll_interval)))
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self._poll_conversations()
|
await self._poll_conversations()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -387,10 +405,12 @@ class DouyinImService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
loop_count = 0
|
loop_count = 0
|
||||||
|
next_poll = time.monotonic() + poll_interval * random.uniform(0.8, 1.2)
|
||||||
while self._running:
|
while self._running:
|
||||||
loop_count += 1
|
loop_count += 1
|
||||||
try:
|
try:
|
||||||
if loop_count % 3 == 1:
|
if time.monotonic() >= next_poll:
|
||||||
|
next_poll = time.monotonic() + poll_interval * random.uniform(0.8, 1.2)
|
||||||
await self._poll_conversations()
|
await self._poll_conversations()
|
||||||
if loop_count % 6 == 1:
|
if loop_count % 6 == 1:
|
||||||
logger.info(f"IM direct tick #{loop_count} account={self.account_id}")
|
logger.info(f"IM direct tick #{loop_count} account={self.account_id}")
|
||||||
|
|||||||
@@ -49,6 +49,36 @@ async def _launch_chromium(pw, args: list[str], headless: Optional[bool] = None)
|
|||||||
return await pw.chromium.launch(**launch_kwargs)
|
return await pw.chromium.launch(**launch_kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 启动错峰门:批量启动大量账号时,把各 worker 的启动时刻按固定间隔排开,
|
||||||
|
# 避免同一瞬间大量凭证校验/WS 建连/首轮拉取叠峰。
|
||||||
|
# 空闲时单个账号启动无需等待;只有短时间内大量启动才会排队。
|
||||||
|
# KEFU_WORKER_START_INTERVAL_SECONDS:相邻两个 worker 启动的最小间隔,默认 1.5s,<=0 关闭。
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_start_gate = {"lock": None, "next_at": 0.0}
|
||||||
|
|
||||||
|
|
||||||
|
async def _startup_stagger(account_id: int) -> None:
|
||||||
|
try:
|
||||||
|
interval = float(os.getenv("KEFU_WORKER_START_INTERVAL_SECONDS", "") or 1.5)
|
||||||
|
except ValueError:
|
||||||
|
interval = 1.5
|
||||||
|
if interval <= 0:
|
||||||
|
return
|
||||||
|
if _start_gate["lock"] is None:
|
||||||
|
_start_gate["lock"] = asyncio.Lock()
|
||||||
|
async with _start_gate["lock"]:
|
||||||
|
now = time.monotonic()
|
||||||
|
wait = max(0.0, _start_gate["next_at"] - now)
|
||||||
|
_start_gate["next_at"] = max(now, _start_gate["next_at"]) + interval
|
||||||
|
if wait > 0:
|
||||||
|
if wait > 5:
|
||||||
|
logger.info(
|
||||||
|
f"Account {account_id}: start queued, waiting {wait:.1f}s to smooth batch startup"
|
||||||
|
)
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
|
||||||
|
|
||||||
def format_error(exc: BaseException) -> str:
|
def format_error(exc: BaseException) -> str:
|
||||||
message = str(exc).strip()
|
message = str(exc).strip()
|
||||||
if "Target page, context or browser has been closed" in message:
|
if "Target page, context or browser has been closed" in message:
|
||||||
@@ -995,6 +1025,9 @@ class DouyinWorker:
|
|||||||
logger.info(f"Starting worker loop for account {self.account_id}")
|
logger.info(f"Starting worker loop for account {self.account_id}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
await _startup_stagger(self.account_id)
|
||||||
|
if self.stopping:
|
||||||
|
return
|
||||||
storage_state = await self._load_storage_state()
|
storage_state = await self._load_storage_state()
|
||||||
cookie_info = analyze_cookie(
|
cookie_info = analyze_cookie(
|
||||||
json.dumps(storage_state, ensure_ascii=False) if storage_state else None
|
json.dumps(storage_state, ensure_ascii=False) if storage_state else None
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"""抖音托管客服 · 桌面整合版
|
"""抖音托管客服 · 桌面整合版
|
||||||
|
|
||||||
一个软件搞定一切:
|
一个软件搞定一切:
|
||||||
1) 启动后默认打开云端网站(dev.zhenyangtang.com.cn);
|
1) 启动后先显示「站点选择」界面(内置 dev / dev1,可自行添加更多站点,
|
||||||
2) 页面右下角自动出现「一键本地登录」悬浮按钮;
|
可勾选“记住选择”下次直接进入);
|
||||||
3) 点它 → 弹出你的托管账号列表 → 选一个 → 本机直接打开一个
|
2) 进入站点后,页面右下角自动出现「一键本地登录」悬浮按钮,
|
||||||
已登录该托管账号的浏览器,进入抖音。
|
以及「切换站点」按钮可随时换环境;
|
||||||
|
3) 点「一键本地登录」→ 弹出你的托管账号列表 → 选一个 → 本机直接打开
|
||||||
|
一个已登录该托管账号的浏览器,进入抖音。
|
||||||
|
|
||||||
原理:桌面壳(pywebview)把云端网页装进原生窗口,并注入一段脚本。该脚本
|
原理:桌面壳(pywebview)把云端网页装进原生窗口,并注入一段脚本。该脚本
|
||||||
用网页里已有的登录令牌(localStorage.kefu_token)调用云端接口取账号与凭证,
|
用网页里已有的登录令牌(localStorage.kefu_token)调用云端接口取账号与凭证,
|
||||||
@@ -18,6 +20,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ctypes
|
import ctypes
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
@@ -29,10 +32,276 @@ from browser_open import normalize_storage_state, open_logged_in_browser
|
|||||||
from updater import check_update, download_installer
|
from updater import check_update, download_installer
|
||||||
from version import __version__
|
from version import __version__
|
||||||
|
|
||||||
CLOUD_URL = "https://dev.zhenyangtang.com.cn/"
|
|
||||||
CLOUD_HOST = urlparse(CLOUD_URL).hostname or ""
|
|
||||||
WINDOW_TITLE = f"抖音托管客服 · 桌面版 v{__version__}"
|
WINDOW_TITLE = f"抖音托管客服 · 桌面版 v{__version__}"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 多站点配置:内置站点 + 用户自定义站点(保存在用户目录,升级软件不丢失)。
|
||||||
|
# 以后要新增内置环境,往 BUILTIN_SITES 里加一行即可。
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
BUILTIN_SITES = [
|
||||||
|
{"name": "节点1", "url": "https://dev.zhenyangtang.com.cn/"},
|
||||||
|
{"name": "节点2", "url": "https://dev1.zhenyangtang.com.cn/"},
|
||||||
|
]
|
||||||
|
CONFIG_PATH = os.path.join(os.path.expanduser("~"), ".douyin_desktop_config.json")
|
||||||
|
|
||||||
|
|
||||||
|
def load_config() -> dict:
|
||||||
|
try:
|
||||||
|
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_config(cfg: dict) -> None:
|
||||||
|
try:
|
||||||
|
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(cfg, f, ensure_ascii=False, indent=2)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_site_url(url: str) -> str:
|
||||||
|
url = (url or "").strip()
|
||||||
|
if not url:
|
||||||
|
return ""
|
||||||
|
if not url.startswith(("http://", "https://")):
|
||||||
|
url = "https://" + url
|
||||||
|
if not url.endswith("/"):
|
||||||
|
url += "/"
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_sites() -> list[dict]:
|
||||||
|
"""内置站点 + 自定义站点(去重,按 URL)。"""
|
||||||
|
cfg = load_config()
|
||||||
|
sites: list[dict] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for s in BUILTIN_SITES:
|
||||||
|
u = normalize_site_url(s["url"])
|
||||||
|
sites.append({"name": s["name"], "url": u, "builtin": True})
|
||||||
|
seen.add(u)
|
||||||
|
for s in cfg.get("custom_sites", []):
|
||||||
|
u = normalize_site_url(s.get("url", ""))
|
||||||
|
if u and u not in seen:
|
||||||
|
sites.append({"name": s.get("name") or urlparse(u).hostname, "url": u, "builtin": False})
|
||||||
|
seen.add(u)
|
||||||
|
return sites
|
||||||
|
|
||||||
|
|
||||||
|
def site_hosts() -> set[str]:
|
||||||
|
return {urlparse(s["url"]).hostname or "" for s in get_all_sites()} - {""}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 站点选择界面(软件启动首页)。与注入浮层同一套视觉语言:#fe2c55 主色 + 玻璃拟态。
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
PICKER_HTML = r"""<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>选择站点</title>
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0;
|
||||||
|
font-family:'PingFang SC','Microsoft YaHei',system-ui,-apple-system,'Segoe UI',sans-serif;}
|
||||||
|
html,body{height:100%;}
|
||||||
|
body{display:flex;align-items:center;justify-content:center;padding:28px;
|
||||||
|
background:#0f1023;
|
||||||
|
background-image:radial-gradient(900px 500px at 15% -10%,rgba(254,44,85,.28),transparent 60%),
|
||||||
|
radial-gradient(800px 500px at 110% 110%,rgba(99,102,241,.25),transparent 60%);}
|
||||||
|
.card{width:520px;max-width:100%;max-height:92vh;display:flex;flex-direction:column;
|
||||||
|
background:rgba(255,255,255,.06);backdrop-filter:blur(24px);-webkit-backdrop-filter:blur(24px);
|
||||||
|
border:1px solid rgba(255,255,255,.14);border-radius:20px;overflow:hidden;
|
||||||
|
box-shadow:0 30px 80px rgba(0,0,0,.45);}
|
||||||
|
.head{padding:26px 28px 18px;}
|
||||||
|
.brand{display:flex;align-items:center;gap:12px;}
|
||||||
|
.logo{width:42px;height:42px;border-radius:12px;flex:none;display:flex;align-items:center;justify-content:center;
|
||||||
|
background:linear-gradient(135deg,#fe2c55,#ff7a59);box-shadow:0 8px 20px rgba(254,44,85,.4);}
|
||||||
|
.logo svg{width:22px;height:22px;color:#fff;}
|
||||||
|
h1{font-size:18px;font-weight:600;color:#fff;line-height:1.3;}
|
||||||
|
.sub{margin-top:3px;font-size:12.5px;color:rgba(255,255,255,.55);}
|
||||||
|
.body{padding:4px 20px 8px;overflow:auto;}
|
||||||
|
.site{display:flex;align-items:center;gap:12px;width:100%;text-align:left;
|
||||||
|
padding:13px 14px;margin-bottom:10px;border:1px solid rgba(255,255,255,.12);
|
||||||
|
border-radius:14px;background:rgba(255,255,255,.04);cursor:pointer;
|
||||||
|
transition:border-color .18s ease,background .18s ease,transform .18s ease;}
|
||||||
|
.site:hover{border-color:#fe2c55;background:rgba(254,44,85,.12);transform:translateY(-1px);}
|
||||||
|
.site:focus-visible{outline:2px solid #fe2c55;outline-offset:2px;}
|
||||||
|
.dot{width:36px;height:36px;border-radius:10px;flex:none;display:flex;align-items:center;justify-content:center;
|
||||||
|
background:rgba(255,255,255,.08);color:rgba(255,255,255,.85);font-weight:600;font-size:15px;}
|
||||||
|
.site:hover .dot{background:rgba(254,44,85,.9);color:#fff;}
|
||||||
|
.si{flex:1;min-width:0;}
|
||||||
|
.sn{font-size:14.5px;font-weight:600;color:#fff;display:flex;align-items:center;gap:8px;}
|
||||||
|
.tag{font-size:10.5px;font-weight:500;padding:1px 7px;border-radius:999px;
|
||||||
|
background:rgba(255,255,255,.1);color:rgba(255,255,255,.6);}
|
||||||
|
.su{margin-top:3px;font-size:12px;color:rgba(255,255,255,.5);
|
||||||
|
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||||
|
.ping{flex:none;display:inline-flex;align-items:center;gap:5px;padding:3px 9px;
|
||||||
|
border-radius:999px;font-size:11px;font-weight:500;
|
||||||
|
background:rgba(255,255,255,.08);color:rgba(255,255,255,.55);}
|
||||||
|
.ping i{width:7px;height:7px;border-radius:50%;background:rgba(255,255,255,.35);flex:none;}
|
||||||
|
.ping.fast{background:rgba(34,197,94,.15);color:#4ade80;}
|
||||||
|
.ping.fast i{background:#22c55e;}
|
||||||
|
.ping.mid{background:rgba(245,158,11,.15);color:#fbbf24;}
|
||||||
|
.ping.mid i{background:#f59e0b;}
|
||||||
|
.ping.slow{background:rgba(239,68,68,.15);color:#f87171;}
|
||||||
|
.ping.slow i{background:#ef4444;}
|
||||||
|
.ping.fail{background:rgba(239,68,68,.15);color:#f87171;}
|
||||||
|
.ping.fail i{background:#ef4444;}
|
||||||
|
@keyframes blink{50%{opacity:.35;}}
|
||||||
|
.ping.wait i{animation:blink 1s ease infinite;}
|
||||||
|
.arrow{flex:none;color:rgba(255,255,255,.3);transition:color .18s ease,transform .18s ease;}
|
||||||
|
.site:hover .arrow{color:#fff;transform:translateX(2px);}
|
||||||
|
.arrow svg{width:18px;height:18px;display:block;}
|
||||||
|
.del{flex:none;border:none;background:transparent;color:rgba(255,255,255,.35);cursor:pointer;
|
||||||
|
width:28px;height:28px;border-radius:8px;display:flex;align-items:center;justify-content:center;
|
||||||
|
transition:background .18s ease,color .18s ease;}
|
||||||
|
.del:hover{background:rgba(239,68,68,.2);color:#f87171;}
|
||||||
|
.del svg{width:15px;height:15px;}
|
||||||
|
.foot{padding:6px 20px 22px;}
|
||||||
|
.remember{display:flex;align-items:center;gap:8px;padding:6px 2px 14px;
|
||||||
|
font-size:12.5px;color:rgba(255,255,255,.65);cursor:pointer;user-select:none;}
|
||||||
|
.remember input{width:15px;height:15px;accent-color:#fe2c55;cursor:pointer;}
|
||||||
|
.addbar{display:flex;gap:8px;}
|
||||||
|
.addbar input{flex:1;min-width:0;padding:10px 12px;font-size:13px;color:#fff;
|
||||||
|
background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.14);border-radius:10px;outline:none;
|
||||||
|
transition:border-color .18s ease;}
|
||||||
|
.addbar input::placeholder{color:rgba(255,255,255,.35);}
|
||||||
|
.addbar input:focus{border-color:#fe2c55;}
|
||||||
|
.addbar input.name{flex:0 0 120px;}
|
||||||
|
.addbar button{flex:none;padding:10px 16px;font-size:13px;font-weight:600;color:#fff;
|
||||||
|
background:#fe2c55;border:none;border-radius:10px;cursor:pointer;
|
||||||
|
transition:background .18s ease,box-shadow .18s ease;}
|
||||||
|
.addbar button:hover{background:#e0214a;box-shadow:0 6px 18px rgba(254,44,85,.4);}
|
||||||
|
.err{margin-top:8px;font-size:12px;color:#f87171;min-height:16px;}
|
||||||
|
.empty{padding:20px;text-align:center;font-size:13px;color:rgba(255,255,255,.45);}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="head">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="logo"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/>
|
||||||
|
<line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></div>
|
||||||
|
<div>
|
||||||
|
<h1>抖音托管客服 · 桌面版</h1>
|
||||||
|
<div class="sub">请选择要进入的站点,也可以在下方添加新站点</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="body" id="list"><div class="empty">正在加载站点…</div></div>
|
||||||
|
<div class="foot">
|
||||||
|
<label class="remember"><input type="checkbox" id="remember">记住选择,下次启动直接进入(可在站点内切换)</label>
|
||||||
|
<div class="addbar">
|
||||||
|
<input class="name" id="addName" placeholder="名称(选填)" maxlength="20">
|
||||||
|
<input id="addUrl" placeholder="https://xxx.zhenyangtang.com.cn/" spellcheck="false">
|
||||||
|
<button id="addBtn">添加</button>
|
||||||
|
</div>
|
||||||
|
<div class="err" id="err"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var CHEV = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>';
|
||||||
|
var X = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
||||||
|
return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function api() { return window.pywebview && window.pywebview.api; }
|
||||||
|
function setErr(msg) { document.getElementById('err').textContent = msg || ''; }
|
||||||
|
|
||||||
|
function pingSite(url, badge) {
|
||||||
|
if (!badge) return;
|
||||||
|
api().ping_site(url).then(function (r) {
|
||||||
|
if (!r || !r.ok) {
|
||||||
|
badge.className = 'ping fail';
|
||||||
|
badge.innerHTML = '<i></i>不可达';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var ms = r.ms;
|
||||||
|
var cls = ms <= 300 ? 'fast' : (ms <= 800 ? 'mid' : 'slow');
|
||||||
|
badge.className = 'ping ' + cls;
|
||||||
|
badge.innerHTML = '<i></i>' + ms + ' ms';
|
||||||
|
}).catch(function () {
|
||||||
|
badge.className = 'ping fail';
|
||||||
|
badge.innerHTML = '<i></i>检测失败';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(sites) {
|
||||||
|
var list = document.getElementById('list');
|
||||||
|
if (!sites || !sites.length) {
|
||||||
|
list.innerHTML = '<div class="empty">暂无站点,请在下方添加</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = '';
|
||||||
|
sites.forEach(function (s) {
|
||||||
|
var host = '';
|
||||||
|
try { host = new URL(s.url).hostname; } catch (e) { host = s.url; }
|
||||||
|
var initial = esc((s.name || host || '?').trim().charAt(0).toUpperCase());
|
||||||
|
var el = document.createElement('button');
|
||||||
|
el.className = 'site';
|
||||||
|
el.type = 'button';
|
||||||
|
el.innerHTML = '<span class="dot">' + initial + '</span>'
|
||||||
|
+ '<span class="si"><span class="sn">' + esc(s.name || host)
|
||||||
|
+ (s.builtin ? '<span class="tag">内置</span>' : '') + '</span>'
|
||||||
|
+ '<span class="su">' + esc(s.url) + '</span></span>'
|
||||||
|
+ '<span class="ping wait"><i></i>检测中</span>'
|
||||||
|
+ (s.builtin ? '' : '<span class="del" title="删除该站点" role="button">' + X + '</span>')
|
||||||
|
+ '<span class="arrow">' + CHEV + '</span>';
|
||||||
|
pingSite(s.url, el.querySelector('.ping'));
|
||||||
|
el.addEventListener('click', function (e) {
|
||||||
|
var del = e.target.closest && e.target.closest('.del');
|
||||||
|
if (del) {
|
||||||
|
e.stopPropagation();
|
||||||
|
api().remove_site(s.url).then(function (r) { render(r.sites); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var remember = document.getElementById('remember').checked;
|
||||||
|
el.style.opacity = '.6';
|
||||||
|
api().open_site(s.url, remember);
|
||||||
|
});
|
||||||
|
list.appendChild(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSite() {
|
||||||
|
var name = document.getElementById('addName').value.trim();
|
||||||
|
var url = document.getElementById('addUrl').value.trim();
|
||||||
|
if (!url) { setErr('请填写站点网址'); return; }
|
||||||
|
setErr('');
|
||||||
|
api().add_site(name, url).then(function (r) {
|
||||||
|
if (r.error) { setErr(r.error); return; }
|
||||||
|
document.getElementById('addName').value = '';
|
||||||
|
document.getElementById('addUrl').value = '';
|
||||||
|
render(r.sites);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
api().get_sites().then(function (r) {
|
||||||
|
render(r.sites);
|
||||||
|
document.getElementById('remember').checked = !!r.remember;
|
||||||
|
});
|
||||||
|
document.getElementById('addBtn').addEventListener('click', addSite);
|
||||||
|
document.getElementById('addUrl').addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Enter') addSite();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (api()) init();
|
||||||
|
else window.addEventListener('pywebviewready', init);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
# 注入到云端页面的脚本:加悬浮按钮 + 账号选择浮层,调用本机桥打开浏览器。
|
# 注入到云端页面的脚本:加悬浮按钮 + 账号选择浮层,调用本机桥打开浏览器。
|
||||||
# 设计:Glassmorphism 浮层 + SVG 图标(无 emoji)+ 悬浮过渡 + 可访问性(焦点/Esc/减少动效)。
|
# 设计:Glassmorphism 浮层 + SVG 图标(无 emoji)+ 悬浮过渡 + 可访问性(焦点/Esc/减少动效)。
|
||||||
@@ -47,7 +316,8 @@ INJECT_JS = r"""
|
|||||||
close: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
|
close: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
|
||||||
chevron: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>',
|
chevron: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>',
|
||||||
monitor: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>',
|
monitor: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>',
|
||||||
alert: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>'
|
alert: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
|
||||||
|
swap: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>'
|
||||||
};
|
};
|
||||||
|
|
||||||
var ROOT = null; // Shadow root,隔离宿主站点 CSS,避免错位/层级冲突
|
var ROOT = null; // Shadow root,隔离宿主站点 CSS,避免错位/层级冲突
|
||||||
@@ -91,6 +361,16 @@ INJECT_JS = r"""
|
|||||||
.${NS}-btn:focus-visible{outline:3px solid rgba(254,44,85,.4);outline-offset:2px;}
|
.${NS}-btn:focus-visible{outline:3px solid rgba(254,44,85,.4);outline-offset:2px;}
|
||||||
.${NS}-btn svg{width:18px;height:18px;}
|
.${NS}-btn svg{width:18px;height:18px;}
|
||||||
|
|
||||||
|
.${NS}-switch{position:fixed;right:24px;bottom:76px;z-index:2147483646;display:inline-flex;
|
||||||
|
align-items:center;gap:6px;background:rgba(15,23,42,.72);color:#fff;
|
||||||
|
border:1px solid rgba(255,255,255,.18);border-radius:999px;padding:8px 14px 8px 12px;
|
||||||
|
font-size:12.5px;font-weight:500;cursor:pointer;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);
|
||||||
|
box-shadow:0 6px 18px rgba(15,23,42,.3);transition:background .2s ease,transform .2s ease;}
|
||||||
|
.${NS}-switch:hover{background:rgba(15,23,42,.88);}
|
||||||
|
.${NS}-switch:active{transform:translateY(1px);}
|
||||||
|
.${NS}-switch:focus-visible{outline:2px solid #fe2c55;outline-offset:2px;}
|
||||||
|
.${NS}-switch svg{width:14px;height:14px;}
|
||||||
|
|
||||||
.${NS}-mask{position:fixed;inset:0;z-index:2147483647;background:rgba(15,23,42,.45);
|
.${NS}-mask{position:fixed;inset:0;z-index:2147483647;background:rgba(15,23,42,.45);
|
||||||
display:flex;align-items:center;justify-content:center;padding:20px;
|
display:flex;align-items:center;justify-content:center;padding:20px;
|
||||||
animation:${NS}-fade .18s ease;}
|
animation:${NS}-fade .18s ease;}
|
||||||
@@ -316,13 +596,25 @@ INJECT_JS = r"""
|
|||||||
|
|
||||||
function ensureButton() {
|
function ensureButton() {
|
||||||
var root = ensureRoot();
|
var root = ensureRoot();
|
||||||
if (root.querySelector('.' + NS + '-btn')) return;
|
if (!root.querySelector('.' + NS + '-btn')) {
|
||||||
var btn = document.createElement('button');
|
var btn = document.createElement('button');
|
||||||
btn.className = NS + '-btn';
|
btn.className = NS + '-btn';
|
||||||
btn.innerHTML = ICON.login + '<span>一键本地登录</span>';
|
btn.innerHTML = ICON.login + '<span>一键本地登录</span>';
|
||||||
btn.title = '在本机打开已登录托管账号的抖音浏览器';
|
btn.title = '在本机打开已登录托管账号的抖音浏览器';
|
||||||
btn.addEventListener('click', showPanel);
|
btn.addEventListener('click', showPanel);
|
||||||
root.appendChild(btn);
|
root.appendChild(btn);
|
||||||
|
}
|
||||||
|
if (!root.querySelector('.' + NS + '-switch')) {
|
||||||
|
var sw = document.createElement('button');
|
||||||
|
sw.className = NS + '-switch';
|
||||||
|
sw.innerHTML = ICON.swap + '<span>切换站点</span>';
|
||||||
|
sw.title = '返回站点选择界面';
|
||||||
|
sw.addEventListener('click', function () {
|
||||||
|
var a = api();
|
||||||
|
if (a && a.go_picker) a.go_picker();
|
||||||
|
});
|
||||||
|
root.appendChild(sw);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 供 Python 端回报本地登录错误:右下角弹出可关闭的提示条
|
// 供 Python 端回报本地登录错误:右下角弹出可关闭的提示条
|
||||||
@@ -415,6 +707,76 @@ class Api:
|
|||||||
self._window = None
|
self._window = None
|
||||||
self._update_info: dict | None = None
|
self._update_info: dict | None = None
|
||||||
|
|
||||||
|
# ---- 站点选择 ----
|
||||||
|
def get_sites(self) -> dict:
|
||||||
|
cfg = load_config()
|
||||||
|
return {"sites": get_all_sites(), "remember": bool(cfg.get("default_url"))}
|
||||||
|
|
||||||
|
def add_site(self, name: str, url: str) -> dict:
|
||||||
|
u = normalize_site_url(url)
|
||||||
|
host = urlparse(u).hostname if u else None
|
||||||
|
if not u or not host or "." not in host:
|
||||||
|
return {"error": "网址格式不对,示例:https://dev1.zhenyangtang.com.cn/", "sites": get_all_sites()}
|
||||||
|
if any(s["url"] == u for s in get_all_sites()):
|
||||||
|
return {"error": "该站点已存在", "sites": get_all_sites()}
|
||||||
|
cfg = load_config()
|
||||||
|
custom = cfg.get("custom_sites", [])
|
||||||
|
custom.append({"name": (name or "").strip() or host, "url": u})
|
||||||
|
cfg["custom_sites"] = custom
|
||||||
|
save_config(cfg)
|
||||||
|
return {"sites": get_all_sites()}
|
||||||
|
|
||||||
|
def remove_site(self, url: str) -> dict:
|
||||||
|
u = normalize_site_url(url)
|
||||||
|
cfg = load_config()
|
||||||
|
cfg["custom_sites"] = [
|
||||||
|
s for s in cfg.get("custom_sites", []) if normalize_site_url(s.get("url", "")) != u
|
||||||
|
]
|
||||||
|
if normalize_site_url(cfg.get("default_url", "")) == u:
|
||||||
|
cfg.pop("default_url", None)
|
||||||
|
save_config(cfg)
|
||||||
|
return {"sites": get_all_sites()}
|
||||||
|
|
||||||
|
def ping_site(self, url: str) -> dict:
|
||||||
|
"""测量站点响应时间(毫秒)。服务器有响应即算可达,包括 4xx/5xx。"""
|
||||||
|
import time
|
||||||
|
from urllib import error as _err, request as _req
|
||||||
|
|
||||||
|
u = normalize_site_url(url)
|
||||||
|
if not u:
|
||||||
|
return {"ok": False}
|
||||||
|
req = _req.Request(u, method="HEAD", headers={"User-Agent": "Mozilla/5.0"})
|
||||||
|
start = time.perf_counter()
|
||||||
|
try:
|
||||||
|
with _req.urlopen(req, timeout=8):
|
||||||
|
pass
|
||||||
|
except _err.HTTPError:
|
||||||
|
pass # 服务器已响应(如 403/405),延迟有效
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return {"ok": False}
|
||||||
|
return {"ok": True, "ms": int((time.perf_counter() - start) * 1000)}
|
||||||
|
|
||||||
|
def open_site(self, url: str, remember: bool = False) -> dict:
|
||||||
|
u = normalize_site_url(url)
|
||||||
|
cfg = load_config()
|
||||||
|
if remember:
|
||||||
|
cfg["default_url"] = u
|
||||||
|
else:
|
||||||
|
cfg.pop("default_url", None)
|
||||||
|
save_config(cfg)
|
||||||
|
if self._window:
|
||||||
|
self._window.load_url(u)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
def go_picker(self) -> dict:
|
||||||
|
"""从站点页面返回选择界面,并取消“记住选择”。"""
|
||||||
|
cfg = load_config()
|
||||||
|
cfg.pop("default_url", None)
|
||||||
|
save_config(cfg)
|
||||||
|
if self._window:
|
||||||
|
self._window.load_html(PICKER_HTML)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
# ---- 本地登录 ----
|
# ---- 本地登录 ----
|
||||||
def _notify(self, msg: str, ok: bool = False) -> None:
|
def _notify(self, msg: str, ok: bool = False) -> None:
|
||||||
if not self._window:
|
if not self._window:
|
||||||
@@ -547,25 +909,39 @@ def _check_update_async(window, api) -> None:
|
|||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
api = Api()
|
api = Api()
|
||||||
# 直接打开云端主界面,更新检测放到后台线程做,避免启动时同步联网卡住窗口。
|
# 上次勾选了“记住选择”则直接进该站点,否则先显示站点选择界面。
|
||||||
window = webview.create_window(
|
# 更新检测放到后台线程做,避免启动时同步联网卡住窗口。
|
||||||
WINDOW_TITLE,
|
cfg = load_config()
|
||||||
CLOUD_URL,
|
default_url = normalize_site_url(cfg.get("default_url", ""))
|
||||||
js_api=api,
|
if default_url and any(s["url"] == default_url for s in get_all_sites()):
|
||||||
width=1280,
|
window = webview.create_window(
|
||||||
height=860,
|
WINDOW_TITLE,
|
||||||
text_select=True,
|
default_url,
|
||||||
)
|
js_api=api,
|
||||||
|
width=1280,
|
||||||
|
height=860,
|
||||||
|
text_select=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
window = webview.create_window(
|
||||||
|
WINDOW_TITLE,
|
||||||
|
html=PICKER_HTML,
|
||||||
|
js_api=api,
|
||||||
|
width=1280,
|
||||||
|
height=860,
|
||||||
|
text_select=True,
|
||||||
|
)
|
||||||
api._window = window
|
api._window = window
|
||||||
_update_checked = {"done": False}
|
_update_checked = {"done": False}
|
||||||
|
|
||||||
def on_loaded():
|
def on_loaded():
|
||||||
# 仅在云端站点页面注入悬浮按钮脚本;about:blank 等不注入。
|
# 仅在已配置站点的页面注入悬浮按钮脚本;选择页/about:blank 等不注入。
|
||||||
try:
|
try:
|
||||||
current = window.get_current_url() or ""
|
current = window.get_current_url() or ""
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
current = ""
|
current = ""
|
||||||
if CLOUD_HOST and CLOUD_HOST in current:
|
host = urlparse(current).hostname or ""
|
||||||
|
if host and host in site_hosts():
|
||||||
try:
|
try:
|
||||||
window.evaluate_js(INJECT_JS)
|
window.evaluate_js(INJECT_JS)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
|
|||||||
@@ -11,10 +11,15 @@
|
|||||||
【推荐:桌面整合版 desktop_app.py】——一个软件搞定
|
【推荐:桌面整合版 desktop_app.py】——一个软件搞定
|
||||||
====================================================================
|
====================================================================
|
||||||
效果:
|
效果:
|
||||||
打开软件 → 默认加载云端网站 https://dev.zhenyangtang.com.cn/
|
打开软件 → 先显示「站点选择」界面,内置两个站点:
|
||||||
→ 你在里面正常登录后台
|
- 节点1 https://dev.zhenyangtang.com.cn/
|
||||||
→ 页面右下角出现「🚀 一键本地登录」按钮
|
- 节点2 https://dev1.zhenyangtang.com.cn/
|
||||||
→ 点它 → 弹出托管账号列表 → 选一个
|
以后有新环境,可直接在界面下方填名称+网址「添加」(保存在本机,升级不丢)。
|
||||||
|
勾选「记住选择」后,下次启动直接进入该站点,跳过选择页。
|
||||||
|
→ 选一个站点进入 → 你在里面正常登录后台
|
||||||
|
→ 页面右下角出现「一键本地登录」按钮(上方还有「切换站点」按钮,
|
||||||
|
点击可随时返回站点选择界面,并自动取消“记住选择”)
|
||||||
|
→ 点「一键本地登录」→ 弹出托管账号列表 → 选一个
|
||||||
→ 本机直接打开一个“已登录该托管账号”的浏览器进入抖音。
|
→ 本机直接打开一个“已登录该托管账号”的浏览器进入抖音。
|
||||||
|
|
||||||
运行方式一(推荐,无黑窗):
|
运行方式一(推荐,无黑窗):
|
||||||
|
|||||||
Reference in New Issue
Block a user