mirror of
https://github.com/halfwaystudent/douyin-sparkflow.git
synced 2026-09-06 16:07:22 +08:00
feat: publish refreshed SparkFlow console and send safety
This commit is contained in:
+38
-33
@@ -15,12 +15,13 @@
|
||||
| 文件 | 说明 | 大小 |
|
||||
|------|------|------|
|
||||
| `browser.py` | 浏览器控制和页面操作 | 3.4 KB |
|
||||
| `friends.py` | 好友列表管理和刷新逻辑 | 5.2 KB |
|
||||
| `friends.py` | 好友列表管理和刷新逻辑 | 8.8 KB |
|
||||
| `login.py` | 登录流程控制 | 2.8 KB |
|
||||
| `msg_builder.py` | 消息内容构建(一言、祝福等) | 4.5 KB |
|
||||
| `protocol_dispatch.py` | 协议分发和路由 | 9.7 KB |
|
||||
| `protocol_sender.mjs` | 消息发送协议(Node.js 脚本) | 21 KB |
|
||||
| `tasks.py` | **任务调度核心**(定时任务、状态管理) | 83 KB |
|
||||
| `protocol_dispatch.py` | 协议分发和路由 | 14.5 KB |
|
||||
| `protocol_sender.mjs` | 消息发送协议(Node.js 脚本) | 22.8 KB |
|
||||
| `send_state.py` | 统一判定强确认、待核验和当日发送状态 | 1.6 KB |
|
||||
| `tasks.py` | **任务调度核心**(定时任务、状态管理) | 109.5 KB |
|
||||
|
||||
### `webui/` - Web 管理界面
|
||||
|
||||
@@ -42,17 +43,16 @@ webui/
|
||||
├── static/ # 静态资源
|
||||
│ ├── app.css # 主题样式(亮色/暗色)
|
||||
│ ├── app.js # 主题切换脚本
|
||||
│ ├── lucide.min.js # 本地化图标库
|
||||
│ ├── lucide-LICENSE.txt # 图标库许可证
|
||||
│ ├── styles.css # 基础样式
|
||||
│ └── multiPagePlugins/ # 浏览器扩展插件
|
||||
└── templates/ # HTML 模板
|
||||
├── base.html # 基础布局模板
|
||||
├── dashboard.html # 仪表盘(主界面)
|
||||
├── login.html # 登录页
|
||||
├── login_workspace.html # 登录工作区
|
||||
├── accounts.html # 账号管理
|
||||
├── send_console.html # 发送控制台
|
||||
├── logs.html # 日志查看
|
||||
└── settings.html # 系统设置
|
||||
└── logs.html # 日志查看
|
||||
```
|
||||
|
||||
### `utils/` - 工具模块
|
||||
@@ -140,37 +140,42 @@ docker run -d \
|
||||
|
||||
### `config.json` - 应用配置
|
||||
|
||||
主配置文件,控制任务行为和系统设置。
|
||||
主配置文件控制发送窗口、好友扫描、浏览器 Profile 和消息策略。仓库版本不包含真实账号标识:
|
||||
|
||||
```json
|
||||
{
|
||||
"send_window_start": "09:00",
|
||||
"send_window_end": "22:00",
|
||||
"send_interval_min": 300,
|
||||
"message_template": "hitokoto",
|
||||
"enable_send_confirm": true,
|
||||
"friend_refresh_interval": 3600,
|
||||
"browser_headless": false,
|
||||
"browser_timeout": 30000,
|
||||
"max_retry_count": 3,
|
||||
"cooldown_on_failure": 600
|
||||
"messageTemplate": "✨今日火花+1\n",
|
||||
"useProtocolSender": false,
|
||||
"browserSenderAccounts": [],
|
||||
"dailySendWindow": {
|
||||
"enabled": true,
|
||||
"startHour": 10,
|
||||
"endHour": 18,
|
||||
"scheduleIntervalMinutes": 10
|
||||
},
|
||||
"friendListScan": {
|
||||
"maxScanSeconds": 300,
|
||||
"idleScanSeconds": 120,
|
||||
"scrollStepPx": 400,
|
||||
"scrollDelaySeconds": 0.8
|
||||
},
|
||||
"persistentBrowserProfiles": {
|
||||
"enabled": true,
|
||||
"root": "/opt/douyin-sparkflow/state/browser-profiles",
|
||||
"seedCookiesWhenEmpty": true,
|
||||
"syncStoredCookiesBeforeRun": true,
|
||||
"refreshStoredCookiesAfterLogin": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**配置项说明**:
|
||||
|
||||
| 配置项 | 类型 | 默认值 | 说明 |
|
||||
|--------|------|--------|------|
|
||||
| `send_window_start` | string | "09:00" | 发送窗口开始时间 |
|
||||
| `send_window_end` | string | "22:00" | 发送窗口结束时间 |
|
||||
| `send_interval_min` | int | 300 | 最小发送间隔(秒) |
|
||||
| `message_template` | string | "hitokoto" | 消息模板类型 |
|
||||
| `enable_send_confirm` | bool | true | 是否启用发送确认 |
|
||||
| `friend_refresh_interval` | int | 3600 | 好友列表刷新间隔(秒) |
|
||||
| `browser_headless` | bool | false | 浏览器无头模式 |
|
||||
| `browser_timeout` | int | 30000 | 浏览器操作超时(毫秒) |
|
||||
| `max_retry_count` | int | 3 | 失败重试次数 |
|
||||
| `cooldown_on_failure` | int | 600 | 失败后冷却时间(秒) |
|
||||
| 配置项 | 说明 |
|
||||
|--------|------|
|
||||
| `dailySendWindow` | 每日发送窗口和调度间隔 |
|
||||
| `sendStrategy` | 账号启动延迟、消息间隔及消息变体 |
|
||||
| `friendListScan` | 好友列表扫描时限、空闲等待和滚动参数 |
|
||||
| `persistentBrowserProfiles` | 持久化 Playwright Profile 及 Cookie 同步策略 |
|
||||
| `browserSenderAccounts` | 强制使用浏览器发送的账号列表;公开模板默认为空 |
|
||||
|
||||
### `usersData.json` - 用户数据
|
||||
|
||||
|
||||
@@ -6,10 +6,7 @@
|
||||
"saveDebugArtifacts": false,
|
||||
"useProtocolSender": false,
|
||||
"protocolDryRun": false,
|
||||
"browserSenderAccounts": [
|
||||
"94262577168",
|
||||
"抖音号:softwomen"
|
||||
],
|
||||
"browserSenderAccounts": [],
|
||||
"sendStrategy": {
|
||||
"shuffleTargets": true,
|
||||
"accountStartDelaySecondsMin": 15,
|
||||
@@ -38,5 +35,18 @@
|
||||
"happyNewYear": {
|
||||
"enabled": true,
|
||||
"messageTemplate": "\r\n"
|
||||
},
|
||||
"friendListScan": {
|
||||
"maxScanSeconds": 300,
|
||||
"idleScanSeconds": 120,
|
||||
"scrollStepPx": 400,
|
||||
"scrollDelaySeconds": 0.8
|
||||
},
|
||||
"persistentBrowserProfiles": {
|
||||
"enabled": true,
|
||||
"root": "/opt/douyin-sparkflow/state/browser-profiles",
|
||||
"seedCookiesWhenEmpty": true,
|
||||
"syncStoredCookiesBeforeRun": true,
|
||||
"refreshStoredCookiesAfterLogin": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,10 @@ def configure_playwright_environment():
|
||||
|
||||
|
||||
def _headless_for(GUI=False):
|
||||
headful_env = str(os.getenv("SPARKFLOW_BROWSER_HEADFUL") or "").strip().lower()
|
||||
if headful_env in {"1", "true", "yes", "on"}:
|
||||
return False
|
||||
|
||||
headless = not GUI
|
||||
if get_environment() == Environment.LOCAL and DEBUG:
|
||||
headless = False
|
||||
|
||||
@@ -20,6 +20,24 @@ FIRST_FRIEND_SELECTOR = (
|
||||
)
|
||||
FRIEND_NAME_SELECTOR = """xpath=.//span[contains(@class, "item-header-name-")]"""
|
||||
LOGIN_MASK_SELECTORS = [".login-mask", ".login-guide-container", ".login-img-code-wrapper"]
|
||||
NON_LOGIN_DIALOG_DISMISS_TEXTS = (
|
||||
"我知道了",
|
||||
"知道了",
|
||||
"好的",
|
||||
"确定",
|
||||
"确认",
|
||||
"稍后再说",
|
||||
"关闭",
|
||||
)
|
||||
NON_LOGIN_DIALOG_CLOSE_SELECTORS = (
|
||||
".semi-modal-close",
|
||||
'button[aria-label="Close"]',
|
||||
'button[aria-label="关闭"]',
|
||||
'[aria-label="Close"]',
|
||||
'[aria-label="关闭"]',
|
||||
)
|
||||
FRIEND_LIST_EMPTY_ROUNDS = 6
|
||||
FRIEND_LIST_EMPTY_WAIT_SECONDS = 1.5
|
||||
|
||||
|
||||
def update_collection_progress(new_names_count, no_more_visible, scroll_moved, idle_rounds, stuck_rounds, idle_limit=5, stuck_limit=2):
|
||||
@@ -41,13 +59,103 @@ async def _ensure_logged_in(page):
|
||||
continue
|
||||
|
||||
|
||||
async def collect_friend_names(page):
|
||||
await page.wait_for_selector(FRIENDS_TAB_SELECTOR, timeout=30000)
|
||||
await page.locator(FRIENDS_TAB_SELECTOR).click()
|
||||
async def _dismiss_non_login_dialogs(page):
|
||||
for text in NON_LOGIN_DIALOG_DISMISS_TEXTS:
|
||||
try:
|
||||
locator = page.get_by_text(text, exact=False).first
|
||||
if await locator.count() > 0 and await locator.is_visible():
|
||||
await locator.click(timeout=3000)
|
||||
await asyncio.sleep(1)
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
await page.wait_for_selector(FIRST_FRIEND_SELECTOR, timeout=30000)
|
||||
await page.locator(FIRST_FRIEND_SELECTOR).click()
|
||||
await asyncio.sleep(2)
|
||||
for selector in NON_LOGIN_DIALOG_CLOSE_SELECTORS:
|
||||
try:
|
||||
locator = page.locator(selector).first
|
||||
if await locator.count() > 0 and await locator.is_visible():
|
||||
await locator.click(timeout=3000)
|
||||
await asyncio.sleep(1)
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
async def _click_friends_tab(page):
|
||||
await page.wait_for_selector("#sub-app", timeout=30000)
|
||||
try:
|
||||
await page.locator(FRIENDS_TAB_SELECTOR).click(timeout=10000)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
await page.get_by_text("朋友私信", exact=True).click(timeout=10000)
|
||||
return
|
||||
except Exception as exc:
|
||||
raise RuntimeError("未找到朋友私信标签") from exc
|
||||
|
||||
|
||||
async def _friend_list_dom_summary(page):
|
||||
return await page.evaluate(
|
||||
"""() => {
|
||||
const sub = document.querySelector('#sub-app');
|
||||
if (!sub) {
|
||||
return { hasSubApp: false, ulCount: 0, liCount: 0, listItemCount: 0, nameSpanCount: 0, text: '' };
|
||||
}
|
||||
return {
|
||||
hasSubApp: true,
|
||||
ulCount: sub.querySelectorAll('ul').length,
|
||||
liCount: sub.querySelectorAll('li').length,
|
||||
listItemCount: sub.querySelectorAll('[class*="list-item"]').length,
|
||||
nameSpanCount: sub.querySelectorAll('[class*="item-header-name"]').length,
|
||||
text: (sub.innerText || '').split(String.fromCharCode(10)).join(' ').slice(0, 500),
|
||||
};
|
||||
}"""
|
||||
)
|
||||
|
||||
|
||||
async def _wait_for_first_friend_or_empty(page):
|
||||
for _ in range(FRIEND_LIST_EMPTY_ROUNDS):
|
||||
await _dismiss_non_login_dialogs(page)
|
||||
first_friend = page.locator(FIRST_FRIEND_SELECTOR).first
|
||||
try:
|
||||
if await first_friend.count() > 0 and await first_friend.is_visible():
|
||||
await first_friend.click()
|
||||
await asyncio.sleep(2)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
summary = await _friend_list_dom_summary(page)
|
||||
has_any_list_content = any(
|
||||
int(summary.get(key) or 0) > 0
|
||||
for key in ("ulCount", "liCount", "listItemCount", "nameSpanCount")
|
||||
)
|
||||
if not has_any_list_content:
|
||||
await asyncio.sleep(FRIEND_LIST_EMPTY_WAIT_SECONDS)
|
||||
continue
|
||||
|
||||
# The current DOM has list content but not the historical first-friend XPath.
|
||||
# Let the collector below try the more general TARGET_SELECTOR path.
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
async def collect_friend_names(page):
|
||||
await _click_friends_tab(page)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
has_first_friend = await _wait_for_first_friend_or_empty(page)
|
||||
if not has_first_friend:
|
||||
summary = await _friend_list_dom_summary(page)
|
||||
has_any_list_content = any(
|
||||
int(summary.get(key) or 0) > 0
|
||||
for key in ("ulCount", "liCount", "listItemCount", "nameSpanCount")
|
||||
)
|
||||
if not has_any_list_content:
|
||||
return []
|
||||
|
||||
found_names = []
|
||||
seen_names = set()
|
||||
|
||||
@@ -64,6 +64,85 @@ def _account_identity_key(account):
|
||||
return ""
|
||||
|
||||
|
||||
def _coerce_attempt_count(entry):
|
||||
try:
|
||||
return max(0, int(dict(entry or {}).get("attemptCount") or 0))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _protocol_failure_category(entry):
|
||||
status_name = str(entry.get("statusName") or "").strip()
|
||||
status_code = entry.get("statusCode")
|
||||
if status_name == "CheckMessageNotPass" or status_code == 3:
|
||||
return "protocol_check_message_not_pass"
|
||||
if status_name == "CheckMessageNotPassButSelfVisible" or status_code == 4:
|
||||
return "protocol_check_message_self_visible"
|
||||
if status_name == "UserNotInConversation" or status_code == 1:
|
||||
return "protocol_user_not_in_conversation"
|
||||
if status_name == "CheckConversationNotPass" or status_code == 2:
|
||||
return "protocol_check_conversation_not_pass"
|
||||
if status_name == "UserHasBeenBlock" or status_code == 5:
|
||||
return "protocol_user_blocked"
|
||||
return "protocol_send_failed"
|
||||
|
||||
|
||||
def _protocol_failure_reason(entry):
|
||||
bits = [
|
||||
f"statusCode={entry.get('statusCode')}",
|
||||
f"statusName={entry.get('statusName') or ''}",
|
||||
f"statusMsg={entry.get('statusMsg') or ''}",
|
||||
]
|
||||
summary = entry.get("sendResultSummary") or {}
|
||||
raw_keys = summary.get("rawKeys") or []
|
||||
if raw_keys:
|
||||
bits.append(f"rawKeys={','.join(map(str, raw_keys))}")
|
||||
return " ".join(bits)
|
||||
|
||||
|
||||
def _persist_protocol_account_failure(account, category, reason, affected_targets=None):
|
||||
now_iso = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
all_accounts = get_userData(force_reload=True)
|
||||
accounts_by_identity = {
|
||||
identity: item
|
||||
for item in all_accounts
|
||||
for identity in [_account_identity_key(item)]
|
||||
if identity
|
||||
}
|
||||
target_account = accounts_by_identity.get(_account_identity_key(account))
|
||||
if not target_account:
|
||||
return
|
||||
|
||||
affected_targets = list(affected_targets or [])
|
||||
existing_entry = dict(target_account.get("account_failure") or {})
|
||||
target_account["account_failure"] = {
|
||||
"category": category,
|
||||
"reason": reason,
|
||||
"firstAttemptAt": existing_entry.get("firstAttemptAt") or now_iso,
|
||||
"lastAttemptAt": now_iso,
|
||||
"attemptCount": _coerce_attempt_count(existing_entry) + 1,
|
||||
"lastRunMode": "protocol",
|
||||
"affectedTargets": affected_targets,
|
||||
}
|
||||
save_userData(all_accounts)
|
||||
|
||||
|
||||
def _record_protocol_target_failure(target_account, target_name, message, category, reason):
|
||||
now_iso = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
queue = dict(target_account.get("failure_queue") or {})
|
||||
existing_entry = dict(queue.get(target_name) or {})
|
||||
queue[target_name] = {
|
||||
"category": category,
|
||||
"reason": reason,
|
||||
"message": message,
|
||||
"firstAttemptAt": existing_entry.get("firstAttemptAt") or now_iso,
|
||||
"lastAttemptAt": now_iso,
|
||||
"attemptCount": _coerce_attempt_count(existing_entry) + 1,
|
||||
"lastRunMode": "protocol",
|
||||
}
|
||||
target_account["failure_queue"] = queue
|
||||
|
||||
|
||||
def _merge_protocol_runtime_state(accounts, result_by_username):
|
||||
changed = False
|
||||
now_iso = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
@@ -109,6 +188,35 @@ def _merge_protocol_runtime_state(accounts, result_by_username):
|
||||
if history:
|
||||
target_account["message_history"] = history
|
||||
|
||||
for entry in result.get("sent", []):
|
||||
if entry.get("dryRun") or entry.get("success", True):
|
||||
continue
|
||||
target = str(entry.get("target", "")).strip()
|
||||
if not target:
|
||||
continue
|
||||
_record_protocol_target_failure(
|
||||
target_account,
|
||||
target,
|
||||
str(entry.get("message", "")).strip(),
|
||||
_protocol_failure_category(entry),
|
||||
_protocol_failure_reason(entry),
|
||||
)
|
||||
changed = True
|
||||
|
||||
unresolved = result.get("unresolved", []) or []
|
||||
for entry in unresolved:
|
||||
target = str(entry.get("target", "")).strip()
|
||||
if not target:
|
||||
continue
|
||||
_record_protocol_target_failure(
|
||||
target_account,
|
||||
target,
|
||||
"",
|
||||
str(entry.get("reason") or "protocol_unresolved"),
|
||||
str(entry.get("reason") or "protocol could not resolve target"),
|
||||
)
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
save_userData(all_accounts)
|
||||
|
||||
@@ -243,12 +351,24 @@ async def run_protocol_tasks(config, accounts, message_builder):
|
||||
dry_run,
|
||||
send_strategy,
|
||||
)
|
||||
sent_entries = result.get("sent", [])
|
||||
succeeded_count = len([
|
||||
entry for entry in sent_entries
|
||||
if not entry.get("dryRun") and entry.get("success", True)
|
||||
])
|
||||
failed_count = len([
|
||||
entry for entry in sent_entries
|
||||
if not entry.get("dryRun") and not entry.get("success", True)
|
||||
])
|
||||
logger.info(
|
||||
"Protocol sender finished for %s resolved=%s unresolved=%s sent=%s",
|
||||
"Protocol sender finished for %s resolved=%s unresolved=%s attempted=%s succeeded=%s failed=%s dryRun=%s",
|
||||
user.get("username", "unknown"),
|
||||
len(result.get("resolved", [])),
|
||||
len(result.get("unresolved", [])),
|
||||
len(result.get("sent", [])),
|
||||
len(sent_entries),
|
||||
succeeded_count,
|
||||
failed_count,
|
||||
bool(result.get("dryRun")),
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -258,8 +378,15 @@ async def run_protocol_tasks(config, accounts, message_builder):
|
||||
failures = []
|
||||
for user, item in zip(accounts, gathered):
|
||||
if isinstance(item, Exception):
|
||||
failures.append(str(item))
|
||||
reason = str(item)
|
||||
failures.append(reason)
|
||||
logger.error("Protocol sender failed for %s: %s", user.get("username", "unknown"), item)
|
||||
_persist_protocol_account_failure(
|
||||
user,
|
||||
"protocol_sender_failed",
|
||||
reason,
|
||||
user.get("targets", []),
|
||||
)
|
||||
continue
|
||||
result_by_username[user.get("username")] = item
|
||||
unresolved = item.get("unresolved", [])
|
||||
|
||||
@@ -18,12 +18,13 @@ const SDK_BUNDLES = [
|
||||
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/7771.d27d1891.js",
|
||||
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/6682.2a991dfb.js",
|
||||
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/361.4fc40815.js",
|
||||
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/pages-chat.6f823210.js",
|
||||
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/pages-chat.c817de31.js",
|
||||
];
|
||||
|
||||
const CREATOR_CHAT_URL = "https://creator.douyin.com/creator-micro/data/following/chat";
|
||||
const USER_AGENT =
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141 Safari/537.36";
|
||||
(process.env.SPARKFLOW_PROTOCOL_USER_AGENT ||
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36").trim();
|
||||
|
||||
function noop() {}
|
||||
|
||||
@@ -70,6 +71,36 @@ function randomBetweenInclusive(min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
const SEND_MESSAGE_STATUS_NAMES = {
|
||||
0: "Succeeded",
|
||||
1: "UserNotInConversation",
|
||||
2: "CheckConversationNotPass",
|
||||
3: "CheckMessageNotPass",
|
||||
4: "CheckMessageNotPassButSelfVisible",
|
||||
5: "UserHasBeenBlock",
|
||||
};
|
||||
|
||||
function sendMessageStatusName(statusCode) {
|
||||
if (statusCode === null || statusCode === undefined) {
|
||||
return "";
|
||||
}
|
||||
return SEND_MESSAGE_STATUS_NAMES[Number(statusCode)] || "Unknown";
|
||||
}
|
||||
|
||||
function publicSendResultSummary(sendResult) {
|
||||
if (!sendResult || typeof sendResult !== "object") {
|
||||
return {};
|
||||
}
|
||||
const summary = {};
|
||||
for (const key of ["success", "statusCode", "statusMsg", "checkCode", "checkMsg", "errorCode", "errorMsg"]) {
|
||||
if (sendResult[key] !== undefined) {
|
||||
summary[key] = sendResult[key];
|
||||
}
|
||||
}
|
||||
summary.rawKeys = Object.keys(sendResult).sort();
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function readStdinJson() {
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
@@ -88,6 +119,10 @@ async function ensureBundles(cacheDir) {
|
||||
const filename = url.split("/").at(-1);
|
||||
const filePath = path.join(cacheDir, filename);
|
||||
if (fs.existsSync(filePath)) {
|
||||
const response = await fetch(url, { method: "HEAD", headers: { "User-Agent": USER_AGENT } });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Cached SDK bundle is stale or unreachable ${url}: ${response.status}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const response = await fetch(url, { headers: { "User-Agent": USER_AGENT } });
|
||||
@@ -641,13 +676,16 @@ async function sendMessages({
|
||||
}
|
||||
|
||||
const sendResult = await client.sendMessage({ message: messageObject });
|
||||
const statusCode = sendResult?.statusCode ?? null;
|
||||
sent.push({
|
||||
target,
|
||||
dryRun: false,
|
||||
message,
|
||||
success: Boolean(sendResult?.success),
|
||||
statusCode: sendResult?.statusCode ?? null,
|
||||
statusCode,
|
||||
statusName: sendMessageStatusName(statusCode),
|
||||
statusMsg: sendResult?.statusMsg ?? "",
|
||||
sendResultSummary: publicSendResultSummary(sendResult),
|
||||
conversationId: mapping.conversationId,
|
||||
delayBeforeSendSeconds,
|
||||
sentAt: stableNow(),
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def parse_sent_at(raw_value, local_tz):
|
||||
if not raw_value:
|
||||
return None
|
||||
raw = str(raw_value).strip()
|
||||
if raw.endswith("Z"):
|
||||
raw = raw[:-1] + "+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=local_tz)
|
||||
return parsed.astimezone(local_tz)
|
||||
|
||||
|
||||
def _receipt_is_strong(receipt):
|
||||
receipt = dict(receipt or {})
|
||||
try:
|
||||
http_status = int(receipt.get("httpStatus") or 0)
|
||||
except (TypeError, ValueError):
|
||||
http_status = 0
|
||||
return (
|
||||
bool(receipt.get("ok"))
|
||||
and 200 <= http_status < 300
|
||||
and str(receipt.get("call") or "message_send") in ("", "message_send")
|
||||
)
|
||||
|
||||
|
||||
def history_entry_is_strong_confirmed_today(entry, now):
|
||||
entry = dict(entry or {})
|
||||
sent_at = parse_sent_at(entry.get("sentAt"), now.tzinfo)
|
||||
if not sent_at or sent_at.date() != now.date() or bool(entry.get("needsVerification")):
|
||||
return False
|
||||
if entry.get("status") == "confirmed" and entry.get("confirmationLevel") == "strong":
|
||||
return True
|
||||
return _receipt_is_strong(entry.get("serverReceipt"))
|
||||
|
||||
|
||||
def target_is_strong_confirmed_today(account, target_name, now):
|
||||
history = dict(account.get("message_history") or {})
|
||||
return history_entry_is_strong_confirmed_today(history.get(target_name), now)
|
||||
|
||||
|
||||
def history_entry_is_today(entry, now):
|
||||
sent_at = parse_sent_at(dict(entry or {}).get("sentAt"), now.tzinfo)
|
||||
return bool(sent_at and sent_at.date() == now.date())
|
||||
+474
-73
@@ -1,5 +1,8 @@
|
||||
import asyncio
|
||||
import asyncio
|
||||
import base64
|
||||
import errno
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
@@ -12,6 +15,7 @@ from zoneinfo import ZoneInfo
|
||||
from core.browser import get_browser, get_persistent_browser_context, sanitize_profile_name
|
||||
from core.msg_builder import build_message, build_message_candidates
|
||||
from core.protocol_dispatch import run_protocol_tasks
|
||||
from core.send_state import parse_sent_at, target_is_strong_confirmed_today
|
||||
from utils.config import get_config, get_userData, normalize_unique_id, save_userData
|
||||
from utils.logger import setup_logger
|
||||
|
||||
@@ -464,6 +468,239 @@ async def _detect_send_failure_indicator(page):
|
||||
return ""
|
||||
|
||||
|
||||
|
||||
async def start_im_send_observer(page, account_name, target_name):
|
||||
"""Observe creator IM service calls for one browser send attempt."""
|
||||
state = {
|
||||
"enabled": False,
|
||||
"send_request_seen": False,
|
||||
"send_response_seen": False,
|
||||
"send_receipt": {},
|
||||
"mark_read_calls": [],
|
||||
"identity_security_token_calls": [],
|
||||
"events": [],
|
||||
"error": "",
|
||||
}
|
||||
pending = {}
|
||||
session = None
|
||||
|
||||
try:
|
||||
session = await page.context.new_cdp_session(page)
|
||||
await session.send("Network.enable")
|
||||
state["enabled"] = True
|
||||
except Exception as exc:
|
||||
state["error"] = f"cdp_unavailable: {exc}"
|
||||
logger.warning("IM observer unavailable for %s/%s: %s", account_name, target_name, exc)
|
||||
|
||||
async def disabled_summary(extra_wait_seconds=0):
|
||||
if extra_wait_seconds:
|
||||
await asyncio.sleep(extra_wait_seconds)
|
||||
return dict(state)
|
||||
|
||||
return disabled_summary
|
||||
|
||||
def _trim(value, limit=220):
|
||||
return str(value or "")[:limit]
|
||||
|
||||
def _call_kind(url):
|
||||
value = str(url or "")
|
||||
if "/v1/message/send" in value:
|
||||
return "message_send"
|
||||
if "mark_read" in value:
|
||||
return "mark_read"
|
||||
if "identity_security_token" in value:
|
||||
return "identity_security_token"
|
||||
if "imapi.douyin.com" in value:
|
||||
return "imapi_other"
|
||||
return ""
|
||||
|
||||
def _safe_url(url):
|
||||
return str(url or "").split("?", 1)[0]
|
||||
|
||||
def _header_value(headers, name):
|
||||
target = name.lower()
|
||||
for key, value in (headers or {}).items():
|
||||
if str(key).lower() == target:
|
||||
return str(value)
|
||||
return ""
|
||||
|
||||
def _decode_body(body):
|
||||
raw = body.get("body") or ""
|
||||
if body.get("base64Encoded"):
|
||||
try:
|
||||
return base64.b64decode(raw).decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
return ""
|
||||
return raw
|
||||
|
||||
def _parse_json_body(text):
|
||||
try:
|
||||
return json.loads(text)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _extract_error_text(data, body_text):
|
||||
if isinstance(data, dict):
|
||||
for key in ("status_msg", "message", "msg", "err_msg", "error", "reason"):
|
||||
value = data.get(key)
|
||||
if value:
|
||||
return _trim(value, 300)
|
||||
nested = data.get("data")
|
||||
if isinstance(nested, dict):
|
||||
for key in ("status_msg", "message", "msg", "err_msg", "error", "reason"):
|
||||
value = nested.get(key)
|
||||
if value:
|
||||
return _trim(value, 300)
|
||||
return _trim(body_text, 300)
|
||||
|
||||
def _json_success(data):
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
success_values = []
|
||||
for key in ("status_code", "err_no", "errno", "error_code", "code"):
|
||||
if key in data:
|
||||
success_values.append(data.get(key) in (0, "0", None))
|
||||
message = str(data.get("message") or data.get("status_msg") or "").lower()
|
||||
if message:
|
||||
success_values.append(message in ("success", "ok"))
|
||||
if success_values:
|
||||
return all(success_values)
|
||||
return None
|
||||
|
||||
def _receipt_body_meta(data, body_text):
|
||||
meta = {"bodyLen": len(body_text or "")}
|
||||
if body_text:
|
||||
meta["bodySha256"] = hashlib.sha256(body_text.encode("utf-8", errors="replace")).hexdigest()
|
||||
if isinstance(data, dict):
|
||||
meta["jsonKeys"] = sorted(str(key) for key in data.keys())[:20]
|
||||
nested = data.get("data")
|
||||
if isinstance(nested, dict):
|
||||
meta["dataKeys"] = sorted(str(key) for key in nested.keys())[:20]
|
||||
for source_key, dest_key in (
|
||||
("server_message_id", "serverMessageId"),
|
||||
("message_id", "messageId"),
|
||||
("msg_id", "messageId"),
|
||||
("conversation_id", "conversationId"),
|
||||
("conversation_short_id", "conversationShortId"),
|
||||
):
|
||||
value = nested.get(source_key)
|
||||
if value:
|
||||
meta[dest_key] = _trim(value, 120)
|
||||
for source_key, dest_key in (
|
||||
("server_message_id", "serverMessageId"),
|
||||
("message_id", "messageId"),
|
||||
("msg_id", "messageId"),
|
||||
("conversation_id", "conversationId"),
|
||||
("conversation_short_id", "conversationShortId"),
|
||||
):
|
||||
value = data.get(source_key)
|
||||
if value:
|
||||
meta[dest_key] = _trim(value, 120)
|
||||
return meta
|
||||
|
||||
def _record(event):
|
||||
state["events"].append(event)
|
||||
if len(state["events"]) > 50:
|
||||
del state["events"][:-50]
|
||||
|
||||
def on_request(params):
|
||||
request = params.get("request") or {}
|
||||
url = request.get("url") or ""
|
||||
kind = _call_kind(url)
|
||||
if not kind:
|
||||
return
|
||||
request_id = params.get("requestId")
|
||||
post_data = request.get("postData") or ""
|
||||
method = request.get("method") or ""
|
||||
is_send_post = kind == "message_send" and method.upper() == "POST"
|
||||
pending[request_id] = {"url": url, "kind": kind, "request": request, "is_send_post": is_send_post}
|
||||
_record({"kind": "request", "call": kind, "url": _safe_url(url), "method": method, "postLen": len(post_data)})
|
||||
if is_send_post:
|
||||
state["send_request_seen"] = True
|
||||
logger.info("IM observer saw message send request for %s/%s url=%s postLen=%s", account_name, target_name, _trim(_safe_url(url), 160), len(post_data))
|
||||
|
||||
def on_response(params):
|
||||
request_id = params.get("requestId")
|
||||
item = pending.get(request_id)
|
||||
if not item:
|
||||
return
|
||||
response = params.get("response") or {}
|
||||
item["response"] = response
|
||||
kind = item.get("kind")
|
||||
headers = response.get("headers") or {}
|
||||
status = response.get("status")
|
||||
logid = _header_value(headers, "x-tt-logid") or _header_value(headers, "x-tt-trace-log") or _header_value(headers, "x-tt-trace-id")
|
||||
event = {"kind": "response", "call": kind, "url": _safe_url(item.get("url")), "status": status}
|
||||
if logid:
|
||||
event["logid"] = _trim(logid, 120)
|
||||
_record(event)
|
||||
if item.get("is_send_post"):
|
||||
state["send_response_seen"] = True
|
||||
state["send_receipt"] = {"call": kind, "httpStatus": status, "ok": False, "pendingBody": True, "logid": _trim(logid, 120), "url": _safe_url(item.get("url"))}
|
||||
logger.info("IM observer saw message send response for %s/%s status=%s logid=%s", account_name, target_name, status, _trim(logid, 80))
|
||||
|
||||
async def fetch_body(request_id):
|
||||
item = pending.get(request_id) or {}
|
||||
kind = item.get("kind") or ""
|
||||
url = item.get("url") or ""
|
||||
response = item.get("response") or {}
|
||||
status = response.get("status")
|
||||
headers = response.get("headers") or {}
|
||||
logid = _header_value(headers, "x-tt-logid") or _header_value(headers, "x-tt-trace-log") or _header_value(headers, "x-tt-trace-id")
|
||||
try:
|
||||
body = await session.send("Network.getResponseBody", {"requestId": request_id})
|
||||
body_text = _decode_body(body)
|
||||
data = _parse_json_body(body_text)
|
||||
http_ok = isinstance(status, (int, float)) and 200 <= status < 300
|
||||
json_ok = _json_success(data)
|
||||
receipt = {"call": kind, "httpStatus": status, "ok": bool(http_ok and (json_ok is not False)), "jsonParsed": isinstance(data, dict), "logid": _trim(logid, 120), "url": _safe_url(url)}
|
||||
receipt.update(_receipt_body_meta(data, body_text))
|
||||
if json_ok is not None:
|
||||
receipt["jsonOk"] = bool(json_ok)
|
||||
if not receipt["ok"]:
|
||||
receipt["reason"] = _extract_error_text(data, body_text)
|
||||
if item.get("is_send_post"):
|
||||
state["send_receipt"] = receipt
|
||||
_record({"kind": "body", "call": kind, "status": status, "ok": receipt["ok"], "jsonParsed": receipt["jsonParsed"], "logid": receipt.get("logid", ""), "reason": receipt.get("reason", "")})
|
||||
elif kind == "mark_read":
|
||||
state["mark_read_calls"].append(receipt)
|
||||
_record({"kind": "body", "call": kind, "status": status, "ok": receipt["ok"]})
|
||||
elif kind == "identity_security_token":
|
||||
has_token = isinstance(data, dict) and bool((data.get("data") or {}).get("identity_security_token"))
|
||||
receipt["hasToken"] = has_token
|
||||
receipt.pop("reason", None)
|
||||
state["identity_security_token_calls"].append(receipt)
|
||||
_record({"kind": "body", "call": kind, "status": status, "ok": receipt["ok"], "hasToken": has_token})
|
||||
else:
|
||||
_record({"kind": "body", "call": kind, "status": status, "ok": receipt["ok"]})
|
||||
except Exception as exc:
|
||||
error = _trim(exc, 160)
|
||||
_record({"kind": "body_error", "call": kind, "url": _safe_url(url), "error": error})
|
||||
if item.get("is_send_post"):
|
||||
state["send_receipt"] = {"call": kind, "httpStatus": status, "ok": False, "logid": _trim(logid, 120), "url": _safe_url(url), "reason": f"response_body_unavailable: {error}"}
|
||||
finally:
|
||||
pending.pop(request_id, None)
|
||||
|
||||
def on_loading_finished(params):
|
||||
request_id = params.get("requestId")
|
||||
if request_id in pending:
|
||||
asyncio.create_task(fetch_body(request_id))
|
||||
|
||||
session.on("Network.requestWillBeSent", on_request)
|
||||
session.on("Network.responseReceived", on_response)
|
||||
session.on("Network.loadingFinished", on_loading_finished)
|
||||
|
||||
async def summary(extra_wait_seconds=3):
|
||||
if extra_wait_seconds:
|
||||
await asyncio.sleep(extra_wait_seconds)
|
||||
try:
|
||||
await session.detach()
|
||||
except Exception:
|
||||
pass
|
||||
return dict(state)
|
||||
|
||||
return summary
|
||||
|
||||
async def snapshot_last_own_message(page, chat_input=None):
|
||||
try:
|
||||
return await page.evaluate(
|
||||
@@ -811,12 +1048,30 @@ def _build_normalized_target_map(targets):
|
||||
return normalized_targets
|
||||
|
||||
|
||||
async def _locator_count_with_timeout(locator, account_name, stage, label, timeout_seconds=3):
|
||||
try:
|
||||
return await asyncio.wait_for(locator.count(), timeout=timeout_seconds)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"Account %s locator count timed out at %s label=%s after %ss",
|
||||
account_name,
|
||||
stage,
|
||||
label,
|
||||
timeout_seconds,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def _first_non_empty_locator(page, selectors):
|
||||
for selector in selectors:
|
||||
locator = page.locator(selector)
|
||||
try:
|
||||
count = await locator.count()
|
||||
except Exception:
|
||||
count = await _locator_count_with_timeout(
|
||||
locator,
|
||||
"unknown",
|
||||
"first_non_empty_locator",
|
||||
selector,
|
||||
)
|
||||
if count is None:
|
||||
continue
|
||||
for index in range(min(count, 5)):
|
||||
item = locator.nth(index)
|
||||
@@ -832,7 +1087,13 @@ async def _selector_visible(page, selectors):
|
||||
for selector in selectors:
|
||||
locator = page.locator(selector).first
|
||||
try:
|
||||
if await locator.count() > 0 and await locator.is_visible(timeout=500):
|
||||
count = await _locator_count_with_timeout(
|
||||
locator,
|
||||
"unknown",
|
||||
"selector_visible",
|
||||
selector,
|
||||
)
|
||||
if count and await locator.is_visible(timeout=500):
|
||||
return selector
|
||||
except Exception:
|
||||
continue
|
||||
@@ -843,7 +1104,13 @@ async def _click_first_visible_locator(candidates, account_name, stage):
|
||||
last_error = None
|
||||
for label, locator in candidates:
|
||||
try:
|
||||
count = min(await locator.count(), 5)
|
||||
count = await _locator_count_with_timeout(
|
||||
locator,
|
||||
account_name,
|
||||
stage,
|
||||
label,
|
||||
)
|
||||
count = min(count or 0, 5)
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
continue
|
||||
@@ -878,17 +1145,26 @@ async def _open_friends_tab(page, account_name, fallback_selector, target_select
|
||||
'[contains(normalize-space(.), "朋友") or contains(normalize-space(.), "好友") '
|
||||
'or contains(normalize-space(.), "互关")]'
|
||||
)
|
||||
page_text_selector = (
|
||||
'xpath=//*[self::div or self::button or self::span or @role="tab" or @role="button"]'
|
||||
'[contains(normalize-space(.), "朋友私信") or contains(normalize-space(.), "好友私信") '
|
||||
'or contains(normalize-space(.), "朋友") or contains(normalize-space(.), "好友") '
|
||||
'or contains(normalize-space(.), "互关")]'
|
||||
)
|
||||
candidates = [
|
||||
("page tab text 朋友私信", page.get_by_text("朋友私信", exact=True)),
|
||||
("page tab text 好友私信", page.get_by_text("好友私信", exact=True)),
|
||||
("tab text 朋友", sub_app.get_by_text("朋友", exact=True)),
|
||||
("tab text 好友", sub_app.get_by_text("好友", exact=True)),
|
||||
("tab text 互关", sub_app.get_by_text("互关", exact=True)),
|
||||
("tab text 朋友私信", sub_app.get_by_text("朋友私信", exact=True)),
|
||||
("tab text 好友私信", sub_app.get_by_text("好友私信", exact=True)),
|
||||
("sub-app friend tab text", page.locator(text_selector)),
|
||||
("page friend tab text", page.locator(page_text_selector)),
|
||||
("fallback friends tab xpath", page.locator(fallback_selector)),
|
||||
]
|
||||
if await _click_first_visible_locator(candidates, account_name, "open_friends_tab"):
|
||||
await asyncio.sleep(1)
|
||||
await asyncio.sleep(2)
|
||||
return "clicked"
|
||||
|
||||
await page.wait_for_selector(fallback_selector, timeout=30000)
|
||||
@@ -1336,18 +1612,7 @@ def _account_identity(user):
|
||||
|
||||
|
||||
def _parse_sent_at(raw_value, local_tz):
|
||||
if not raw_value:
|
||||
return None
|
||||
raw = str(raw_value).strip()
|
||||
if raw.endswith("Z"):
|
||||
raw = raw[:-1] + "+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=local_tz)
|
||||
return parsed.astimezone(local_tz)
|
||||
return parse_sent_at(raw_value, local_tz)
|
||||
|
||||
|
||||
def _manual_run_failed_only():
|
||||
@@ -1368,10 +1633,18 @@ def _unsent_retry_max_attempts():
|
||||
|
||||
|
||||
def _target_sent_today(user, target_name, now):
|
||||
return target_is_strong_confirmed_today(user, target_name, now)
|
||||
|
||||
|
||||
def _target_unconfirmed_today(user, target_name, now):
|
||||
history = dict(user.get("message_history") or {})
|
||||
entry = history.get(target_name) or {}
|
||||
entry = dict(history.get(target_name) or {})
|
||||
sent_at = _parse_sent_at(entry.get("sentAt"), now.tzinfo)
|
||||
return bool(sent_at and sent_at.date() == now.date())
|
||||
return bool(
|
||||
sent_at
|
||||
and sent_at.date() == now.date()
|
||||
and not _target_sent_today(user, target_name, now)
|
||||
)
|
||||
|
||||
|
||||
def _target_failed_today(user, target_name, now):
|
||||
@@ -1462,6 +1735,25 @@ def _target_failure_attempts_today(user, target_name, now):
|
||||
return 0
|
||||
|
||||
|
||||
def _target_failure_category_today(user, target_name, now):
|
||||
queue = dict(user.get("failure_queue") or {})
|
||||
entry = queue.get(target_name) or {}
|
||||
last_attempt_at = _parse_sent_at(entry.get("lastAttemptAt"), now.tzinfo)
|
||||
if not last_attempt_at or last_attempt_at.date() != now.date():
|
||||
return ""
|
||||
return str(entry.get("category") or "").strip()
|
||||
|
||||
|
||||
def _target_has_non_retryable_failure_today(user, target_name, now):
|
||||
return _target_failure_category_today(user, target_name, now) in {
|
||||
"protocol_check_message_not_pass",
|
||||
"protocol_check_message_self_visible",
|
||||
"protocol_user_blocked",
|
||||
"protocol_user_not_in_conversation",
|
||||
"protocol_check_conversation_not_pass",
|
||||
}
|
||||
|
||||
|
||||
def _pending_failed_targets(user, now):
|
||||
queue = dict(user.get("failure_queue") or {})
|
||||
targets = []
|
||||
@@ -1477,6 +1769,9 @@ def _pending_failed_targets(user, now):
|
||||
for target_name in user.get("targets") or []:
|
||||
if _target_sent_today(user, target_name, now):
|
||||
continue
|
||||
if _target_unconfirmed_today(user, target_name, now):
|
||||
targets.append(target_name)
|
||||
continue
|
||||
if target_name in queue and _target_failed_today(user, target_name, now):
|
||||
targets.append(target_name)
|
||||
return targets
|
||||
@@ -1489,6 +1784,9 @@ def _pending_unsent_targets(user, now):
|
||||
for target_name in user.get("targets") or []:
|
||||
if _target_sent_today(user, target_name, now):
|
||||
continue
|
||||
if _target_has_non_retryable_failure_today(user, target_name, now):
|
||||
skipped_targets.append(f"{target_name}(non_retryable)")
|
||||
continue
|
||||
attempts_today = _target_failure_attempts_today(user, target_name, now)
|
||||
if attempts_today >= max_attempts:
|
||||
skipped_targets.append(f"{target_name}({attempts_today})")
|
||||
@@ -1583,10 +1881,10 @@ def _prepare_active_users_for_run(active_config, active_user_data):
|
||||
retry_targets = _pending_failed_targets(user, now)
|
||||
already_sent = [target for target in user.get("targets") or [] if _target_sent_today(user, target, now)]
|
||||
logger.info(
|
||||
"manual-retry user=%s retryTargets=%s alreadySentToday=%s",
|
||||
"manual-retry user=%s retryTargetCount=%s strongConfirmedToday=%s",
|
||||
user.get("username", "unknown"),
|
||||
retry_targets,
|
||||
already_sent,
|
||||
len(retry_targets),
|
||||
len(already_sent),
|
||||
)
|
||||
if retry_targets:
|
||||
runnable_user = dict(user)
|
||||
@@ -1605,11 +1903,11 @@ def _prepare_active_users_for_run(active_config, active_user_data):
|
||||
retry_targets, skipped_targets = _pending_unsent_targets(user, now)
|
||||
already_sent = [target for target in user.get("targets") or [] if _target_sent_today(user, target, now)]
|
||||
logger.info(
|
||||
"manual-unsent user=%s retryTargets=%s alreadySentToday=%s skippedMaxAttempts=%s",
|
||||
"manual-unsent user=%s retryTargetCount=%s strongConfirmedToday=%s skippedCount=%s",
|
||||
user.get("username", "unknown"),
|
||||
retry_targets,
|
||||
already_sent,
|
||||
skipped_targets,
|
||||
len(retry_targets),
|
||||
len(already_sent),
|
||||
len(skipped_targets),
|
||||
)
|
||||
if retry_targets:
|
||||
runnable_user = dict(user)
|
||||
@@ -1639,17 +1937,13 @@ def _prepare_active_users_for_run(active_config, active_user_data):
|
||||
runnable_users = []
|
||||
for user in active_user_data:
|
||||
due_targets, already_sent, pending_targets, queued_failures = _select_due_targets(user, send_window, now)
|
||||
pending_preview = [
|
||||
f"{target_name}@{scheduled_at.strftime('%H:%M')}"
|
||||
for target_name, scheduled_at in pending_targets[:5]
|
||||
]
|
||||
logger.info(
|
||||
"windowed user=%s dueTargets=%s alreadySentToday=%s pendingTargets=%s queuedFailures=%s",
|
||||
"windowed user=%s due=%s strongConfirmed=%s pending=%s queuedFailures=%s",
|
||||
user.get("username", "unknown"),
|
||||
due_targets,
|
||||
already_sent,
|
||||
pending_preview,
|
||||
queued_failures,
|
||||
len(due_targets),
|
||||
len(already_sent),
|
||||
len(pending_targets),
|
||||
len(queued_failures),
|
||||
)
|
||||
if due_targets:
|
||||
runnable_user = dict(user)
|
||||
@@ -1806,7 +2100,7 @@ def _persist_friend_index(user, friend_records, scanned_at, *, scan_complete, mi
|
||||
)
|
||||
|
||||
|
||||
def _persist_browser_send_failure(user, target_name, message, category, reason, attempted_at):
|
||||
def _persist_browser_send_failure(user, target_name, message, category, reason, attempted_at, server_receipt=None):
|
||||
accounts = get_userData(force_reload=True)
|
||||
matched_account = _find_matching_account(accounts, user)
|
||||
if matched_account is None:
|
||||
@@ -1828,6 +2122,8 @@ def _persist_browser_send_failure(user, target_name, message, category, reason,
|
||||
"attemptCount": _coerce_attempt_count(existing_entry) + 1,
|
||||
"lastRunMode": _current_run_mode(),
|
||||
}
|
||||
if server_receipt:
|
||||
queue[target_name]["serverReceipt"] = server_receipt
|
||||
matched_account["failure_queue"] = queue
|
||||
save_userData(accounts)
|
||||
|
||||
@@ -1844,7 +2140,7 @@ def _persist_browser_send_failure(user, target_name, message, category, reason,
|
||||
)
|
||||
|
||||
|
||||
def _persist_browser_send_success(user, target_name, message, sent_at):
|
||||
def _persist_browser_send_success(user, target_name, message, sent_at, server_receipt=None):
|
||||
accounts = get_userData(force_reload=True)
|
||||
matched_account = _find_matching_account(accounts, user)
|
||||
if matched_account is None:
|
||||
@@ -1855,11 +2151,25 @@ def _persist_browser_send_success(user, target_name, message, sent_at):
|
||||
)
|
||||
return
|
||||
|
||||
history = dict(matched_account.get("message_history") or {})
|
||||
history[target_name] = {
|
||||
receipt_summary = ""
|
||||
if isinstance(server_receipt, dict):
|
||||
receipt_summary = "message_send http={} logid={}".format(
|
||||
server_receipt.get("httpStatus"),
|
||||
server_receipt.get("logid") or "",
|
||||
)
|
||||
strong_entry = {
|
||||
"message": message,
|
||||
"sentAt": sent_at,
|
||||
"status": "confirmed",
|
||||
"confirmationLevel": "strong",
|
||||
"confirmationSource": "cdp_message_send_receipt" if server_receipt else "browser_visible_count_increased",
|
||||
"confirmationDetail": receipt_summary,
|
||||
"needsVerification": False,
|
||||
}
|
||||
if server_receipt:
|
||||
strong_entry["serverReceipt"] = server_receipt
|
||||
history = dict(matched_account.get("message_history") or {})
|
||||
history[target_name] = strong_entry
|
||||
matched_account["message_history"] = history
|
||||
queue = dict(matched_account.get("failure_queue") or {})
|
||||
queue.pop(target_name, None)
|
||||
@@ -1871,10 +2181,7 @@ def _persist_browser_send_success(user, target_name, message, sent_at):
|
||||
save_userData(accounts)
|
||||
|
||||
user_history = dict(user.get("message_history") or {})
|
||||
user_history[target_name] = {
|
||||
"message": message,
|
||||
"sentAt": sent_at,
|
||||
}
|
||||
user_history[target_name] = dict(strong_entry)
|
||||
user["message_history"] = user_history
|
||||
user_queue = dict(user.get("failure_queue") or {})
|
||||
user_queue.pop(target_name, None)
|
||||
@@ -1921,6 +2228,12 @@ def _pid_is_alive(pid):
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
except OSError as exc:
|
||||
if getattr(exc, "winerror", None) == 87 or exc.errno == errno.ESRCH:
|
||||
return False
|
||||
if exc.errno in (errno.EPERM, errno.EACCES):
|
||||
return True
|
||||
raise
|
||||
return True
|
||||
|
||||
|
||||
@@ -2009,6 +2322,22 @@ def _release_browser_account_lock(handle, lock_path, account_name):
|
||||
pass
|
||||
|
||||
|
||||
def _browser_account_timeout_seconds(friend_scan_config, target_count):
|
||||
raw_value = str(os.getenv("SPARKFLOW_BROWSER_ACCOUNT_TIMEOUT_SECONDS") or "").strip()
|
||||
if raw_value:
|
||||
try:
|
||||
return max(300, int(raw_value))
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Invalid SPARKFLOW_BROWSER_ACCOUNT_TIMEOUT_SECONDS=%r, using calculated timeout",
|
||||
raw_value,
|
||||
)
|
||||
|
||||
scan_seconds = int((friend_scan_config or {}).get("maxScanSeconds") or 300)
|
||||
# Bound one account run even if Playwright or the page wedges below our selector timeouts.
|
||||
return max(900, scan_seconds + 420 + max(1, target_count) * 240)
|
||||
|
||||
|
||||
async def run_browser_tasks(active_config, browser_user_data):
|
||||
if not browser_user_data:
|
||||
return
|
||||
@@ -2028,7 +2357,11 @@ async def run_browser_tasks(active_config, browser_user_data):
|
||||
profile_config["refreshStoredCookiesAfterLogin"],
|
||||
)
|
||||
for user in browser_user_data:
|
||||
logger.info("Using persistent browser sender for user=%s targets=%s", user.get("username", "unknown"), user["targets"])
|
||||
logger.info(
|
||||
"Using persistent browser sender for user=%s targetCount=%s",
|
||||
user.get("username", "unknown"),
|
||||
len(user["targets"]),
|
||||
)
|
||||
tasks.append(do_user_task(None, user, semaphore, send_strategy, profile_config, friend_scan_config))
|
||||
await asyncio.gather(*tasks)
|
||||
return
|
||||
@@ -2036,7 +2369,11 @@ async def run_browser_tasks(active_config, browser_user_data):
|
||||
playwright, browser = await get_browser()
|
||||
try:
|
||||
for user in browser_user_data:
|
||||
logger.info("Using browser sender for user=%s targets=%s", user.get("username", "unknown"), user["targets"])
|
||||
logger.info(
|
||||
"Using browser sender for user=%s targetCount=%s",
|
||||
user.get("username", "unknown"),
|
||||
len(user["targets"]),
|
||||
)
|
||||
tasks.append(do_user_task(browser, user, semaphore, send_strategy, profile_config, friend_scan_config))
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
@@ -2052,14 +2389,40 @@ async def do_user_task(browser, user, semaphore, send_strategy, profile_config,
|
||||
account_lock_path = None
|
||||
try:
|
||||
account_lock_handle, account_lock_path = await _acquire_browser_account_lock(user, account_name)
|
||||
await _do_user_task_locked(
|
||||
browser,
|
||||
user,
|
||||
send_strategy,
|
||||
profile_config,
|
||||
timeout_seconds = _browser_account_timeout_seconds(
|
||||
friend_scan_config,
|
||||
account_name,
|
||||
len(user.get("targets") or []),
|
||||
)
|
||||
logger.info(
|
||||
"Account %s browser sender timeout guard is %ss",
|
||||
account_name,
|
||||
timeout_seconds,
|
||||
)
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
_do_user_task_locked(
|
||||
browser,
|
||||
user,
|
||||
send_strategy,
|
||||
profile_config,
|
||||
friend_scan_config,
|
||||
account_name,
|
||||
),
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
attempted_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
reason = f"browser sender exceeded {timeout_seconds}s timeout guard"
|
||||
logger.exception("Account %s browser sender timed out", account_name)
|
||||
for target_name in user.get("targets") or []:
|
||||
_persist_browser_send_failure(
|
||||
user,
|
||||
target_name,
|
||||
"",
|
||||
"timeout",
|
||||
reason,
|
||||
attempted_at,
|
||||
)
|
||||
finally:
|
||||
if account_lock_handle is not None:
|
||||
_release_browser_account_lock(account_lock_handle, account_lock_path, account_name)
|
||||
@@ -2133,13 +2496,14 @@ async def _do_user_task_locked(browser, user, send_strategy, profile_config, fri
|
||||
logger.info("Account %s started the message flow", account_name)
|
||||
try:
|
||||
index_targets = targets
|
||||
last_message = ""
|
||||
schedule_now = datetime.now(_schedule_timezone())
|
||||
if not _friend_index_complete_today(user, schedule_now):
|
||||
index_targets = list(user.get("targets") or targets)
|
||||
logger.info(
|
||||
"Account %s will refresh today's friend index while delivering targets=%s",
|
||||
"Account %s will refresh today's friend index while delivering targetCount=%s",
|
||||
account_name,
|
||||
targets,
|
||||
len(targets),
|
||||
)
|
||||
async for target_name in scroll_and_select_user(
|
||||
page,
|
||||
@@ -2153,22 +2517,32 @@ async def _do_user_task_locked(browser, user, send_strategy, profile_config, fri
|
||||
message = ""
|
||||
chat_input = None
|
||||
last_own_message_before = None
|
||||
send_receipt = {}
|
||||
try:
|
||||
await save_debug_artifacts(page, account_name, target_name, "selected-friend")
|
||||
chat_input, selector_used = await locate_chat_input(page)
|
||||
logger.info("Using chat input selector %s for %s/%s", selector_used, account_name, target_name)
|
||||
|
||||
message = build_message()
|
||||
logger.info("Prepared message for %s/%s: %r", account_name, target_name, message)
|
||||
previous_entry = dict(user.get("message_history") or {}).get(target_name) or {}
|
||||
previous_message = str(previous_entry.get("message") or "")
|
||||
message = build_message(previous_message=previous_message, last_message=last_message)
|
||||
last_message = message
|
||||
logger.info(
|
||||
"Prepared message for %s/%s length=%s previousMatch=%s",
|
||||
account_name,
|
||||
target_name,
|
||||
len(message),
|
||||
bool(previous_message and previous_message == message),
|
||||
)
|
||||
last_own_message_before = await snapshot_last_own_message(
|
||||
page,
|
||||
chat_input=chat_input,
|
||||
)
|
||||
logger.info(
|
||||
"Last own message before send for %s/%s: %r",
|
||||
"Last own message before send for %s/%s length=%s",
|
||||
account_name,
|
||||
target_name,
|
||||
(last_own_message_before or {}).get("text", ""),
|
||||
len((last_own_message_before or {}).get("text", "")),
|
||||
)
|
||||
|
||||
lines = message.split("\n")
|
||||
@@ -2179,6 +2553,7 @@ async def _do_user_task_locked(browser, user, send_strategy, profile_config, fri
|
||||
|
||||
await save_debug_artifacts(page, account_name, target_name, "typed-message")
|
||||
|
||||
im_observer_summary = await start_im_send_observer(page, account_name, target_name)
|
||||
logger.info("Pressing Enter to send message for %s/%s", account_name, target_name)
|
||||
await chat_input.press("Enter")
|
||||
|
||||
@@ -2188,17 +2563,42 @@ async def _do_user_task_locked(browser, user, send_strategy, profile_config, fri
|
||||
message,
|
||||
before_snapshot=last_own_message_before,
|
||||
)
|
||||
im_summary = await im_observer_summary()
|
||||
logger.info(
|
||||
"IM send observer summary for %s/%s: request=%s response=%s events=%s error=%s",
|
||||
account_name,
|
||||
target_name,
|
||||
im_summary.get("send_request_seen"),
|
||||
im_summary.get("send_response_seen"),
|
||||
im_summary.get("events"),
|
||||
im_summary.get("error", ""),
|
||||
)
|
||||
send_receipt = im_summary.get("send_receipt") or {}
|
||||
server_ok = bool(send_receipt.get("ok"))
|
||||
detail = (
|
||||
f"{detail}; im_observer request={im_summary.get('send_request_seen')} "
|
||||
f"response={im_summary.get('send_response_seen')} serverOk={server_ok} "
|
||||
f"logid={send_receipt.get('logid', '')} reason={send_receipt.get('reason', '')}"
|
||||
)
|
||||
await save_debug_artifacts(page, account_name, target_name, "after-send")
|
||||
|
||||
if not sent_ok:
|
||||
if im_summary.get("enabled"):
|
||||
if not im_summary.get("send_request_seen"):
|
||||
raise RuntimeError(f"server send request was not observed; {detail}")
|
||||
if not im_summary.get("send_response_seen"):
|
||||
raise RuntimeError(f"server send response was not observed; {detail}")
|
||||
if not server_ok:
|
||||
raise RuntimeError(f"server send receipt rejected; {detail}")
|
||||
elif not sent_ok:
|
||||
raise RuntimeError(detail)
|
||||
|
||||
logger.info("Message send confirmed for %s/%s: %s", account_name, target_name, detail)
|
||||
logger.info("Message send confirmed for %s/%s by server receipt: %s", account_name, target_name, detail)
|
||||
_persist_browser_send_success(
|
||||
user,
|
||||
target_name,
|
||||
message,
|
||||
datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
server_receipt=send_receipt,
|
||||
)
|
||||
interval = _random_delay_seconds(
|
||||
send_strategy,
|
||||
@@ -2216,7 +2616,7 @@ async def _do_user_task_locked(browser, user, send_strategy, profile_config, fri
|
||||
message,
|
||||
before_snapshot=last_own_message_before,
|
||||
)
|
||||
if sent_ok:
|
||||
if sent_ok and not str(exc).startswith("server send"):
|
||||
logger.warning(
|
||||
"Recovered send outcome for %s/%s after failure: %s",
|
||||
account_name,
|
||||
@@ -2228,6 +2628,7 @@ async def _do_user_task_locked(browser, user, send_strategy, profile_config, fri
|
||||
target_name,
|
||||
message,
|
||||
datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
server_receipt=send_receipt,
|
||||
)
|
||||
continue
|
||||
|
||||
@@ -2265,6 +2666,7 @@ async def _do_user_task_locked(browser, user, send_strategy, profile_config, fri
|
||||
category,
|
||||
reason,
|
||||
datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
server_receipt=send_receipt,
|
||||
)
|
||||
interval = _random_delay_seconds(
|
||||
send_strategy,
|
||||
@@ -2320,12 +2722,17 @@ async def runTasks():
|
||||
|
||||
logger.info("Starting tasks with config")
|
||||
logger.info("multiTask=%s taskCount=%s", active_config["multiTask"], active_config["taskCount"])
|
||||
logger.info("messageTemplate=%s", active_config["messageTemplate"])
|
||||
logger.info("sendStrategy=%s", active_config.get("sendStrategy", {}))
|
||||
logger.info("hitokotoTypes=%s", active_config["hitokotoTypes"])
|
||||
send_strategy = active_config.get("sendStrategy", {}) or {}
|
||||
logger.info(
|
||||
"messageConfig templateConfigured=%s variantCount=%s shuffleTargets=%s",
|
||||
bool(str(active_config.get("messageTemplate") or "").strip()),
|
||||
len(send_strategy.get("messageVariants") or []),
|
||||
bool(send_strategy.get("shuffleTargets", True)),
|
||||
)
|
||||
logger.info("hitokotoTypeCount=%s", len(active_config.get("hitokotoTypes") or []))
|
||||
logger.info("enabledUsers=%s disabledUsers=%s", len(active_user_data), len(disabled_user_data))
|
||||
for user in active_user_data:
|
||||
logger.info("user=%s targets=%s", user.get("username", "unknown"), user["targets"])
|
||||
logger.info("user=%s targetCount=%s", user.get("username", "unknown"), len(user["targets"]))
|
||||
for user in disabled_user_data:
|
||||
logger.info("skipping disabled user=%s", user.get("username", "unknown"))
|
||||
|
||||
@@ -2350,13 +2757,7 @@ def task_run_lock():
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _lock_owner_is_alive(pid):
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
return True
|
||||
return _pid_is_alive(pid)
|
||||
|
||||
while True:
|
||||
try:
|
||||
|
||||
@@ -3,7 +3,7 @@ import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi import FastAPI, HTTPException, Request, Response
|
||||
import uvicorn
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
@@ -11,7 +11,29 @@ from core.login import collect_login_result
|
||||
|
||||
|
||||
REMOTE_LOGIN_URL = "https://creator.douyin.com/"
|
||||
WWW_SELF_URL = "https://www.douyin.com/user/self"
|
||||
PROFILE_DIR = Path("/data/login-profile")
|
||||
GENERIC_WWW_NAMES = {
|
||||
"",
|
||||
"我的",
|
||||
"我",
|
||||
"抖音官网账号",
|
||||
"精选",
|
||||
"推荐",
|
||||
"搜索",
|
||||
"关注",
|
||||
"朋友",
|
||||
"直播",
|
||||
"放映厅",
|
||||
"短剧",
|
||||
"小游戏",
|
||||
"客户端",
|
||||
"通知",
|
||||
"私信",
|
||||
"投稿",
|
||||
"海量优质视频内容",
|
||||
"抖音精选电脑版",
|
||||
}
|
||||
|
||||
|
||||
class LoginDesktopManager:
|
||||
@@ -41,10 +63,18 @@ class LoginDesktopManager:
|
||||
"--disable-dev-shm-usage",
|
||||
"--no-sandbox",
|
||||
"--start-maximized",
|
||||
"--disable-gpu",
|
||||
"--disable-gpu-compositing",
|
||||
"--disable-software-rasterizer",
|
||||
"--disable-accelerated-2d-canvas",
|
||||
"--disable-accelerated-video-decode",
|
||||
"--renderer-process-limit=2",
|
||||
"--disable-background-networking",
|
||||
"--disable-sync",
|
||||
"--disable-features=Translate,MediaRouter,OptimizationHints,AutofillServerCommunication",
|
||||
],
|
||||
)
|
||||
self.page = self.context.pages[0] if self.context.pages else await self.context.new_page()
|
||||
await self.page.goto(REMOTE_LOGIN_URL, wait_until="domcontentloaded", timeout=60000)
|
||||
|
||||
def _context_is_closed(self):
|
||||
return not self.context or getattr(self.context, "_impl_obj", None) is None
|
||||
@@ -89,19 +119,53 @@ class LoginDesktopManager:
|
||||
await self.start()
|
||||
|
||||
async def status(self):
|
||||
await self.ensure_running()
|
||||
logged_in = False
|
||||
username = ""
|
||||
unique_id = ""
|
||||
page = await self._get_active_page()
|
||||
current_url = page.url if page else ""
|
||||
current_url = ""
|
||||
|
||||
if not self.context or self._context_is_closed():
|
||||
return {
|
||||
"running": False,
|
||||
"logged_in": False,
|
||||
"username": "",
|
||||
"unique_id": "",
|
||||
"current_url": "",
|
||||
"profile_dir": str(PROFILE_DIR),
|
||||
}
|
||||
|
||||
page = None
|
||||
try:
|
||||
result = await collect_login_result(page, self.context, timeout_ms=1000)
|
||||
logged_in = True
|
||||
username = result["username"]
|
||||
unique_id = result["unique_id"]
|
||||
if self.page and not self.page.is_closed():
|
||||
page = self.page
|
||||
else:
|
||||
for candidate in self.context.pages:
|
||||
if not candidate.is_closed():
|
||||
self.page = candidate
|
||||
page = candidate
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
self.page = None
|
||||
self.context = None
|
||||
return {
|
||||
"running": False,
|
||||
"logged_in": False,
|
||||
"username": "",
|
||||
"unique_id": "",
|
||||
"current_url": "",
|
||||
"profile_dir": str(PROFILE_DIR),
|
||||
}
|
||||
|
||||
if page:
|
||||
current_url = page.url
|
||||
try:
|
||||
result = await collect_login_result(page, self.context, timeout_ms=1000)
|
||||
logged_in = True
|
||||
username = result["username"]
|
||||
unique_id = result["unique_id"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"running": True,
|
||||
"logged_in": logged_in,
|
||||
@@ -126,6 +190,99 @@ class LoginDesktopManager:
|
||||
return result
|
||||
|
||||
|
||||
def _clean_www_display_name(value):
|
||||
raw = str(value or "").replace("\u200b", "").replace("\ufeff", "")
|
||||
name = " ".join(raw.split()).strip(" -_||·•")
|
||||
if not name or name in GENERIC_WWW_NAMES:
|
||||
return ""
|
||||
if len(name) > 40:
|
||||
return ""
|
||||
if any(token in name for token in ("登录", "注册", "关注", "粉丝", "获赞", "作品", "喜欢", "收藏", "观看历史", "海量优质视频", "抖音旗下")):
|
||||
return ""
|
||||
return name
|
||||
|
||||
|
||||
async def collect_www_identity_from_page(page):
|
||||
return await page.evaluate(
|
||||
r"""() => {
|
||||
const normalize = (value) => String(value || "")
|
||||
.replace(/[\u200b\u200c\u200d\ufeff]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const bad = new Set(["", "我的", "我", "抖音官网账号", "精选", "推荐", "搜索", "关注", "朋友", "直播", "放映厅", "短剧", "小游戏", "客户端", "通知", "私信", "投稿"]);
|
||||
const candidates = [];
|
||||
const add = (value, source) => {
|
||||
const text = normalize(value).replace(/^@+/, "").trim();
|
||||
if (!text || bad.has(text) || text.length > 40) return;
|
||||
if (/登录|注册|关注|粉丝|获赞|作品|喜欢|收藏|观看历史/.test(text)) return;
|
||||
candidates.push({ text, source });
|
||||
};
|
||||
|
||||
add(document.querySelector('[data-e2e="user-title"]')?.innerText, "data-e2e=user-title");
|
||||
add(document.querySelector('[class*="userName"], [class*="UserName"], [class*="nickname"], [class*="Nickname"], h1')?.innerText, "profile-name-selector");
|
||||
add(document.title.split(/[||\-]/)[0], "document-title");
|
||||
add(document.querySelector('meta[property="og:title"]')?.content?.split(/[||\-]/)[0], "og:title");
|
||||
|
||||
const selfLink = document.querySelector('a[href*="/user/self"]');
|
||||
if (selfLink) {
|
||||
const root = selfLink.closest('div')?.parentElement?.parentElement || selfLink;
|
||||
const lines = normalize(root.innerText).split(/关注|粉丝|获赞|我的喜欢|我的收藏|观看历史|稍后再看|我的作品|我的预约|我的订单|退出登录/);
|
||||
add(lines[0], "self-link-root");
|
||||
}
|
||||
|
||||
if (location.pathname.includes('/user/')) {
|
||||
add(document.querySelector('meta[name="description"]')?.content?.split(/[,,。||-]/)[0], "description");
|
||||
}
|
||||
|
||||
return {
|
||||
url: location.href,
|
||||
title: document.title,
|
||||
profileHref: selfLink?.href || "",
|
||||
candidates,
|
||||
};
|
||||
}"""
|
||||
)
|
||||
|
||||
|
||||
async def collect_www_login_result(page, context):
|
||||
cookies = await context.cookies()
|
||||
try:
|
||||
await page.goto(WWW_SELF_URL, wait_until="domcontentloaded", timeout=60000)
|
||||
await page.wait_for_load_state("networkidle", timeout=15000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
identity = await collect_www_identity_from_page(page)
|
||||
username = ""
|
||||
for item in identity.get("candidates") or []:
|
||||
username = _clean_www_display_name(item.get("text"))
|
||||
if username:
|
||||
break
|
||||
|
||||
if not username:
|
||||
identity = await collect_www_identity_from_page(page)
|
||||
for item in identity.get("candidates") or []:
|
||||
username = _clean_www_display_name(item.get("text"))
|
||||
if username:
|
||||
break
|
||||
|
||||
if not username:
|
||||
username = "抖音官网账号"
|
||||
uid_cookie = ""
|
||||
for cookie in cookies:
|
||||
if cookie.get("name") in {"uid_tt", "uid_tt_ss", "sid_uid", "passport_csrf_token"}:
|
||||
uid_cookie = str(cookie.get("value") or "")
|
||||
if uid_cookie:
|
||||
break
|
||||
suffix = "".join(ch for ch in uid_cookie if ch.isalnum())[:24]
|
||||
unique_id = f"web-self-{suffix or username}"
|
||||
return {
|
||||
"unique_id": unique_id,
|
||||
"username": username,
|
||||
"cookies": cookies,
|
||||
}
|
||||
|
||||
|
||||
manager = LoginDesktopManager()
|
||||
app = FastAPI(title="Douyin Login Desktop")
|
||||
|
||||
@@ -164,12 +321,193 @@ async def reset():
|
||||
|
||||
@app.post("/export")
|
||||
async def export():
|
||||
page = await manager._get_active_page()
|
||||
try:
|
||||
result = await manager.export()
|
||||
return {"ok": True, "result": result}
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
except Exception as creator_exc:
|
||||
try:
|
||||
result = await collect_www_login_result(page, manager.context)
|
||||
except Exception as www_exc:
|
||||
raise HTTPException(status_code=400, detail=f"creator export failed: {creator_exc}; www export failed: {www_exc}")
|
||||
return {"ok": True, "result": result}
|
||||
|
||||
|
||||
@app.get("/debug/screenshot")
|
||||
async def debug_screenshot():
|
||||
page = await manager._get_active_page()
|
||||
data = await page.screenshot(full_page=False, type="png")
|
||||
return Response(content=data, media_type="image/png")
|
||||
|
||||
|
||||
@app.get("/debug/snapshot")
|
||||
async def debug_snapshot():
|
||||
page = await manager._get_active_page()
|
||||
items = await page.evaluate(
|
||||
r"""() => {
|
||||
const visible = (el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const s = getComputedStyle(el);
|
||||
return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none';
|
||||
};
|
||||
const textOf = (el) => String(el.innerText || el.textContent || el.getAttribute('aria-label') || el.title || '').replace(/\s+/g, ' ').trim();
|
||||
const nodes = [...document.querySelectorAll('button, a, [role="button"], [aria-label], input, textarea, [contenteditable="true"], [class*="message"], [class*="chat"], [class*="im"]')];
|
||||
return nodes.filter(visible).slice(0, 300).map((el, i) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return {
|
||||
i,
|
||||
tag: el.tagName.toLowerCase(),
|
||||
role: el.getAttribute('role') || '',
|
||||
aria: el.getAttribute('aria-label') || '',
|
||||
title: el.title || '',
|
||||
text: textOf(el).slice(0, 120),
|
||||
cls: String(el.className || '').slice(0, 120),
|
||||
contenteditable: el.getAttribute('contenteditable') || '',
|
||||
rect: {x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height)}
|
||||
};
|
||||
});
|
||||
}"""
|
||||
)
|
||||
return {"url": page.url, "title": await page.title(), "items": items}
|
||||
|
||||
|
||||
@app.post("/debug/action")
|
||||
async def debug_action(request: Request):
|
||||
page = await manager._get_active_page()
|
||||
payload = await request.json()
|
||||
action = payload.get("action")
|
||||
if action == "click_text":
|
||||
text = str(payload.get("text") or "")
|
||||
exact = bool(payload.get("exact", False))
|
||||
await page.get_by_text(text, exact=exact).first.click(timeout=int(payload.get("timeout", 5000)))
|
||||
elif action == "click_at":
|
||||
await page.mouse.click(float(payload["x"]), float(payload["y"]))
|
||||
elif action == "wheel":
|
||||
await page.mouse.wheel(float(payload.get("dx", 0)), float(payload.get("dy", 0)))
|
||||
elif action == "type":
|
||||
await page.keyboard.type(str(payload.get("text") or ""), delay=int(payload.get("delay", 20)))
|
||||
elif action == "press":
|
||||
await page.keyboard.press(str(payload.get("key") or "Enter"))
|
||||
elif action == "goto":
|
||||
await page.goto(str(payload.get("url") or REMOTE_LOGIN_URL), wait_until="commit", timeout=15000)
|
||||
elif action == "eval":
|
||||
result = await page.evaluate(str(payload.get("script") or "undefined"))
|
||||
return {"ok": True, "result": result, "url": page.url}
|
||||
elif action == "list_frames":
|
||||
frames = []
|
||||
for fr in page.frames:
|
||||
frames.append({"url": fr.url, "name": fr.name})
|
||||
return {"ok": True, "frames": frames, "url": page.url}
|
||||
elif action == "eval_in_frame":
|
||||
frame_url_match = str(payload.get("frame_url") or "")
|
||||
script = str(payload.get("script") or "undefined")
|
||||
target_frame = None
|
||||
for fr in page.frames:
|
||||
if frame_url_match and frame_url_match in fr.url:
|
||||
target_frame = fr
|
||||
break
|
||||
if not target_frame:
|
||||
return {"ok": False, "error": f"no frame matching '{frame_url_match}' found", "url": page.url}
|
||||
result = await target_frame.evaluate(script)
|
||||
return {"ok": True, "result": result, "url": page.url, "frame_url": target_frame.url}
|
||||
elif action == "click_in_frame":
|
||||
frame_url_match = str(payload.get("frame_url") or "")
|
||||
selector = str(payload.get("selector") or "")
|
||||
target_frame = None
|
||||
for fr in page.frames:
|
||||
if frame_url_match and frame_url_match in fr.url:
|
||||
target_frame = fr
|
||||
break
|
||||
if not target_frame:
|
||||
return {"ok": False, "error": f"no frame matching '{frame_url_match}' found"}
|
||||
await target_frame.click(selector, timeout=int(payload.get("timeout", 5000)))
|
||||
return {"ok": True, "url": page.url, "frame_url": target_frame.url}
|
||||
elif action == "type_in_frame":
|
||||
frame_url_match = str(payload.get("frame_url") or "")
|
||||
selector = str(payload.get("selector") or "")
|
||||
text = str(payload.get("text") or "")
|
||||
target_frame = None
|
||||
for fr in page.frames:
|
||||
if frame_url_match and frame_url_match in fr.url:
|
||||
target_frame = fr
|
||||
break
|
||||
if not target_frame:
|
||||
return {"ok": False, "error": "no frame found"}
|
||||
await target_frame.fill(selector, text, timeout=int(payload.get("timeout", 5000)))
|
||||
return {"ok": True, "url": page.url}
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"unknown action {action!r}")
|
||||
return {"ok": True, "url": page.url}
|
||||
|
||||
|
||||
# ---- Network capture (codex-added observability) ----
|
||||
_net_log = []
|
||||
_net_capturing = False
|
||||
_net_max = 500
|
||||
|
||||
@app.post("/debug/net_capture")
|
||||
async def net_capture(request: Request):
|
||||
global _net_capturing
|
||||
payload = await request.json()
|
||||
action_type = payload.get("type", "start")
|
||||
page = await manager._get_active_page()
|
||||
if action_type == "start":
|
||||
_net_log.clear()
|
||||
_net_capturing = True
|
||||
|
||||
async def on_request(req):
|
||||
if not _net_capturing:
|
||||
return
|
||||
if len(_net_log) >= _net_max:
|
||||
return
|
||||
try:
|
||||
_net_log.append({
|
||||
"ts": __import__("time").time(),
|
||||
"phase": "request",
|
||||
"method": req.method,
|
||||
"url": req.url,
|
||||
"headers": dict(req.headers),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def on_response(resp):
|
||||
if not _net_capturing:
|
||||
return
|
||||
if len(_net_log) >= _net_max:
|
||||
return
|
||||
try:
|
||||
body_preview = None
|
||||
try:
|
||||
body_preview = (await resp.text())[:300]
|
||||
except Exception:
|
||||
pass
|
||||
_net_log.append({
|
||||
"ts": __import__("time").time(),
|
||||
"phase": "response",
|
||||
"url": resp.url,
|
||||
"status": resp.status,
|
||||
"headers": dict(resp.headers),
|
||||
"body": body_preview,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
page.on("request", lambda req: __import__("asyncio").ensure_future(on_request(req)))
|
||||
page.on("response", lambda resp: __import__("asyncio").ensure_future(on_response(resp)))
|
||||
return {"ok": True, "msg": "capture started"}
|
||||
elif action_type == "stop":
|
||||
_net_capturing = False
|
||||
return {"ok": True, "count": len(_net_log)}
|
||||
elif action_type == "get":
|
||||
return {"ok": True, "count": len(_net_log), "log": _net_log.copy()}
|
||||
elif action_type == "clear":
|
||||
_net_log.clear()
|
||||
return {"ok": True}
|
||||
return {"ok": False, "error": "unknown type"}
|
||||
|
||||
@app.get("/debug/net_log")
|
||||
async def get_net_log():
|
||||
return {"count": len(_net_log), "capturing": _net_capturing, "log": _net_log.copy()}
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("LOGIN_DESKTOP_API_PORT", "18090")), reload=False)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import os
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
from core import msg_builder, tasks
|
||||
from core.send_state import history_entry_is_strong_confirmed_today
|
||||
from webui import ops
|
||||
|
||||
|
||||
class SendStateTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.now = datetime(2026, 7, 10, 14, 0, tzinfo=timezone.utc)
|
||||
self.window = {
|
||||
"enabled": True,
|
||||
"startHour": 10,
|
||||
"endHour": 18,
|
||||
"scheduleIntervalMinutes": 20,
|
||||
}
|
||||
|
||||
def test_strong_confirmation_is_the_only_sent_state(self):
|
||||
strong = {
|
||||
"sentAt": self.now.isoformat(),
|
||||
"status": "confirmed",
|
||||
"confirmationLevel": "strong",
|
||||
"needsVerification": False,
|
||||
}
|
||||
weak = {
|
||||
"sentAt": self.now.isoformat(),
|
||||
"status": "unconfirmed",
|
||||
"confirmationLevel": "weak",
|
||||
"needsVerification": True,
|
||||
}
|
||||
legacy = {"sentAt": self.now.isoformat()}
|
||||
|
||||
self.assertTrue(history_entry_is_strong_confirmed_today(strong, self.now))
|
||||
self.assertFalse(history_entry_is_strong_confirmed_today(weak, self.now))
|
||||
self.assertFalse(history_entry_is_strong_confirmed_today(legacy, self.now))
|
||||
|
||||
def test_unconfirmed_target_is_visible_and_retryable(self):
|
||||
user = {
|
||||
"username": "demo",
|
||||
"unique_id": "demo",
|
||||
"targets": ["target"],
|
||||
"message_history": {
|
||||
"target": {
|
||||
"sentAt": self.now.isoformat(),
|
||||
"status": "unconfirmed",
|
||||
"confirmationLevel": "weak",
|
||||
"needsVerification": True,
|
||||
}
|
||||
},
|
||||
"failure_queue": {
|
||||
"target": {
|
||||
"lastAttemptAt": self.now.isoformat(),
|
||||
"category": "send_unconfirmed",
|
||||
"attemptCount": 1,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
status = ops._build_target_status(user, "target", self.now, self.window)
|
||||
|
||||
self.assertEqual("unconfirmed", status["status"])
|
||||
self.assertFalse(tasks._target_sent_today(user, "target", self.now))
|
||||
self.assertEqual(["target"], tasks._pending_failed_targets(user, self.now))
|
||||
self.assertEqual(["target"], tasks._pending_unsent_targets(user, self.now)[0])
|
||||
|
||||
def test_legacy_sent_at_only_record_is_retryable(self):
|
||||
user = {
|
||||
"targets": ["target"],
|
||||
"message_history": {"target": {"sentAt": self.now.isoformat()}},
|
||||
}
|
||||
|
||||
status = ops._build_target_status(user, "target", self.now, self.window)
|
||||
|
||||
self.assertEqual("unconfirmed", status["status"])
|
||||
self.assertTrue(status["legacyUnverified"])
|
||||
self.assertEqual(["target"], tasks._pending_failed_targets(user, self.now))
|
||||
self.assertEqual(["target"], tasks._pending_unsent_targets(user, self.now)[0])
|
||||
|
||||
def test_unsent_retry_respects_non_retryable_and_attempt_limit(self):
|
||||
user = {
|
||||
"targets": ["blocked", "exhausted", "retryable"],
|
||||
"failure_queue": {
|
||||
"blocked": {
|
||||
"lastAttemptAt": self.now.isoformat(),
|
||||
"category": "protocol_user_blocked",
|
||||
"attemptCount": 1,
|
||||
},
|
||||
"exhausted": {
|
||||
"lastAttemptAt": self.now.isoformat(),
|
||||
"category": "timeout",
|
||||
"attemptCount": 3,
|
||||
},
|
||||
"retryable": {
|
||||
"lastAttemptAt": self.now.isoformat(),
|
||||
"category": "timeout",
|
||||
"attemptCount": 2,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
retryable, skipped = tasks._pending_unsent_targets(user, self.now)
|
||||
|
||||
self.assertEqual(["retryable"], retryable)
|
||||
self.assertEqual(2, len(skipped))
|
||||
|
||||
def test_manual_force_all_still_includes_strong_confirmed_targets(self):
|
||||
user = {
|
||||
"username": "demo",
|
||||
"targets": ["confirmed", "pending"],
|
||||
"message_history": {
|
||||
"confirmed": {
|
||||
"sentAt": self.now.isoformat(),
|
||||
"status": "confirmed",
|
||||
"confirmationLevel": "strong",
|
||||
"needsVerification": False,
|
||||
}
|
||||
},
|
||||
}
|
||||
config = {"dailySendWindow": self.window}
|
||||
|
||||
with patch.dict(os.environ, {"SPARKFLOW_MANUAL_RUN": "1"}, clear=False):
|
||||
prepared = tasks._prepare_active_users_for_run(config, [user])
|
||||
|
||||
self.assertEqual(["confirmed", "pending"], prepared[0]["targets"])
|
||||
|
||||
def test_message_choice_avoids_previous_and_last_when_possible(self):
|
||||
with patch.object(
|
||||
msg_builder,
|
||||
"build_message_candidates",
|
||||
return_value=["A", "B", "C"],
|
||||
):
|
||||
selected = msg_builder.build_message(previous_message="A", last_message="B")
|
||||
|
||||
self.assertEqual("C", selected)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,161 @@
|
||||
import errno
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from core import tasks
|
||||
from webui import app as app_module
|
||||
from webui import ops
|
||||
|
||||
|
||||
class WebUiSafetyTests(unittest.TestCase):
|
||||
def test_windows_invalid_pid_probe_is_treated_as_dead(self):
|
||||
error = OSError(errno.EINVAL, "invalid pid")
|
||||
error.winerror = 87
|
||||
with patch.object(ops.os, "kill", side_effect=error):
|
||||
self.assertFalse(ops._pid_is_alive(999999))
|
||||
with patch.object(tasks.os, "kill", side_effect=error):
|
||||
self.assertFalse(tasks._pid_is_alive(999999))
|
||||
|
||||
def test_stale_lock_inspection_does_not_delete_file(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
lock_path = root / "logs" / "task.run.lock"
|
||||
lock_path.parent.mkdir(parents=True)
|
||||
lock_path.write_text("99999999\n", encoding="utf-8")
|
||||
old = time.time() - 10800
|
||||
os.utime(lock_path, (old, old))
|
||||
|
||||
with patch.object(ops, "repo_root", return_value=root):
|
||||
status = ops.task_run_lock_status()
|
||||
|
||||
self.assertTrue(lock_path.exists())
|
||||
self.assertTrue(status["stale"])
|
||||
self.assertFalse(status["running"])
|
||||
self.assertEqual("owner_pid_missing", status["staleReason"])
|
||||
|
||||
def test_overview_snapshot_excludes_sensitive_payloads(self):
|
||||
send_console = {
|
||||
"now": "2026-07-10T22:00:00+08:00",
|
||||
"summary": {
|
||||
"enabled_accounts": 1,
|
||||
"total_targets": 2,
|
||||
"today_confirmed_targets": 1,
|
||||
"today_unconfirmed_targets": 1,
|
||||
"today_failed_targets": 0,
|
||||
"today_account_blocked_targets": 0,
|
||||
"today_attention_targets": 1,
|
||||
"today_pending_targets": 0,
|
||||
"today_unprocessed_targets": 0,
|
||||
"today_remaining_targets": 1,
|
||||
"today_warning_count": 0,
|
||||
"last_confirmed_at": "2026-07-10T21:00:00+08:00",
|
||||
"all_confirmed": False,
|
||||
},
|
||||
"accounts": [
|
||||
{
|
||||
"unique_id": "account-1",
|
||||
"username": "Account",
|
||||
"state": "attention",
|
||||
"total_targets": 2,
|
||||
"confirmed_targets": [{"message": "secret message"}],
|
||||
"attention_count": 1,
|
||||
"pending_count": 0,
|
||||
"last_confirmed_at": "2026-07-10T21:00:00+08:00",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
with (
|
||||
patch.object(ops, "get_send_console_snapshot", return_value=send_console),
|
||||
patch.object(
|
||||
ops,
|
||||
"get_schedule_snapshot",
|
||||
return_value={"label": "10:00-18:00/20m", "nextTriggerAt": ""},
|
||||
),
|
||||
patch.object(
|
||||
ops,
|
||||
"task_run_lock_status",
|
||||
return_value={"running": False, "stale": False, "ageSeconds": 0},
|
||||
),
|
||||
):
|
||||
payload = ops.get_overview_snapshot()
|
||||
|
||||
serialized = repr(payload)
|
||||
self.assertNotIn("secret message", serialized)
|
||||
self.assertNotIn("cookies", serialized)
|
||||
self.assertNotIn("serverReceipt", serialized)
|
||||
self.assertNotIn("reason", serialized)
|
||||
self.assertEqual(1, payload["summary"]["attention"])
|
||||
|
||||
def test_primary_pages_and_local_icons_render(self):
|
||||
client = TestClient(app_module.app, raise_server_exceptions=False)
|
||||
self.assertEqual(200, client.get("/login").status_code)
|
||||
self.assertEqual(200, client.get("/static/lucide.min.js").status_code)
|
||||
|
||||
with patch.object(app_module, "current_user", return_value="admin"):
|
||||
for path in ("/", "/ops/send-console", "/ops/logs"):
|
||||
response = client.get(path)
|
||||
self.assertEqual(200, response.status_code, path)
|
||||
self.assertEqual("no-store", response.headers["cache-control"])
|
||||
|
||||
def test_overview_api_requires_authentication_and_disables_cache(self):
|
||||
client = TestClient(app_module.app)
|
||||
response = client.get("/api/ops/overview")
|
||||
|
||||
self.assertEqual(401, response.status_code)
|
||||
self.assertEqual("no-store", response.headers["cache-control"])
|
||||
|
||||
with (
|
||||
patch.object(app_module, "current_user", return_value="admin"),
|
||||
patch.object(
|
||||
app_module,
|
||||
"get_overview_snapshot",
|
||||
return_value={
|
||||
"now": "2026-07-10T22:00:00+08:00",
|
||||
"schedule": {},
|
||||
"task": {},
|
||||
"summary": {},
|
||||
"accounts": [],
|
||||
},
|
||||
),
|
||||
):
|
||||
response = client.get("/api/ops/overview")
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual("no-store", response.headers["cache-control"])
|
||||
|
||||
def test_public_settings_and_template_do_not_expose_server_password(self):
|
||||
with patch.object(
|
||||
app_module,
|
||||
"get_app_settings",
|
||||
return_value={
|
||||
"server_host": "example",
|
||||
"server_username": "root",
|
||||
"server_password": "secret",
|
||||
"session_secret": "secret",
|
||||
"admin_password_hash": "hash",
|
||||
"compose_root": "/opt/app",
|
||||
"ui_port": 8787,
|
||||
"login_desktop_api_url": "http://127.0.0.1:18090",
|
||||
},
|
||||
):
|
||||
public = app_module.public_app_settings()
|
||||
|
||||
self.assertNotIn("server_password", public)
|
||||
self.assertNotIn("session_secret", public)
|
||||
dashboard = (
|
||||
Path(app_module.TEMPLATES_DIR) / "dashboard.html"
|
||||
).read_text(encoding="utf-8")
|
||||
self.assertNotIn("server_password", dashboard)
|
||||
self.assertNotIn("server_username", dashboard)
|
||||
self.assertNotIn("server_host", dashboard)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+189
-31
@@ -1,6 +1,5 @@
|
||||
import json
|
||||
import logging
|
||||
import traceback
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
import urllib.error
|
||||
@@ -16,6 +15,7 @@ from starlette.middleware.sessions import SessionMiddleware
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from core.friends import fetch_account_friends
|
||||
from core.send_state import history_entry_is_strong_confirmed_today, parse_sent_at
|
||||
from core.tasks import run_browser_tasks, task_run_lock
|
||||
from utils.config import (
|
||||
get_app_settings,
|
||||
@@ -41,6 +41,7 @@ from webui.auth import (
|
||||
)
|
||||
from webui.ops import (
|
||||
TASK_ALREADY_RUNNING,
|
||||
get_overview_snapshot,
|
||||
get_ops_snapshot,
|
||||
read_log_tail,
|
||||
refresh_proxy,
|
||||
@@ -113,24 +114,78 @@ def _schedule_timezone():
|
||||
|
||||
|
||||
def _parse_sent_at(raw_value):
|
||||
if not raw_value:
|
||||
return None
|
||||
raw = str(raw_value).strip()
|
||||
if raw.endswith("Z"):
|
||||
raw = raw[:-1] + "+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=_schedule_timezone())
|
||||
return parsed.astimezone(_schedule_timezone())
|
||||
return parse_sent_at(raw_value, _schedule_timezone())
|
||||
|
||||
|
||||
def _history_entry_strong_confirmed_today(entry):
|
||||
return history_entry_is_strong_confirmed_today(
|
||||
entry,
|
||||
datetime.now(_schedule_timezone()),
|
||||
)
|
||||
|
||||
|
||||
def _target_sent_today(account, target_name):
|
||||
entry = dict(account.get("message_history") or {}).get(target_name) or {}
|
||||
return _history_entry_strong_confirmed_today(entry)
|
||||
|
||||
|
||||
def _target_unconfirmed_today(account, target_name):
|
||||
entry = dict(account.get("message_history") or {}).get(target_name) or {}
|
||||
sent_at = _parse_sent_at(entry.get("sentAt"))
|
||||
return bool(sent_at and sent_at.date() == datetime.now(_schedule_timezone()).date())
|
||||
if sent_at and sent_at.date() == datetime.now(_schedule_timezone()).date() and not _history_entry_strong_confirmed_today(entry):
|
||||
return True
|
||||
failure_entry = dict(account.get("failure_queue") or {}).get(target_name) or {}
|
||||
last_attempt_at = _parse_sent_at(failure_entry.get("lastAttemptAt"))
|
||||
return bool(
|
||||
last_attempt_at
|
||||
and last_attempt_at.date() == datetime.now(_schedule_timezone()).date()
|
||||
and str(failure_entry.get("category") or "") == "send_unconfirmed"
|
||||
)
|
||||
|
||||
|
||||
def mark_target_unconfirmed(account, target_name, *, reason="manual_reset_possible_false_positive", force=False):
|
||||
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
history = dict(account.get("message_history") or {})
|
||||
existing = dict(history.get(target_name) or {})
|
||||
sent_at = _parse_sent_at(existing.get("sentAt"))
|
||||
today = datetime.now(_schedule_timezone()).date()
|
||||
if existing and sent_at and sent_at.date() != today and not force:
|
||||
return False
|
||||
if existing and _history_entry_strong_confirmed_today(existing) and not force:
|
||||
return False
|
||||
|
||||
previous_status = existing.get("status") or ("legacy_sentAt_only" if existing else "missing_history")
|
||||
message = str(existing.get("message") or "")
|
||||
history[target_name] = {
|
||||
**existing,
|
||||
"message": message,
|
||||
"sentAt": existing.get("sentAt") or now,
|
||||
"status": "unconfirmed",
|
||||
"confirmationLevel": existing.get("confirmationLevel") or "legacy",
|
||||
"confirmationSource": existing.get("confirmationSource") or "manual_reset",
|
||||
"confirmationDetail": existing.get("confirmationDetail") or "已手动标记为待核验/待补发。",
|
||||
"needsVerification": True,
|
||||
"resetAt": now,
|
||||
"resetReason": reason,
|
||||
"previousStatus": previous_status,
|
||||
}
|
||||
account["message_history"] = history
|
||||
|
||||
queue = dict(account.get("failure_queue") or {})
|
||||
existing_failure = dict(queue.get(target_name) or {})
|
||||
queue[target_name] = {
|
||||
"category": "send_unconfirmed",
|
||||
"reason": reason,
|
||||
"message": message,
|
||||
"firstAttemptAt": existing_failure.get("firstAttemptAt") or now,
|
||||
"lastAttemptAt": now,
|
||||
"attemptCount": int(existing_failure.get("attemptCount") or 0) + 1,
|
||||
"lastRunMode": "manual_reset",
|
||||
"confirmationLevel": history[target_name].get("confirmationLevel"),
|
||||
"confirmationSource": history[target_name].get("confirmationSource"),
|
||||
}
|
||||
account["failure_queue"] = queue
|
||||
return True
|
||||
|
||||
|
||||
def login_desktop_api_url():
|
||||
@@ -201,6 +256,22 @@ def save_exported_login_result(login_result: dict, *, relogin_unique_id: str = "
|
||||
return account, "created"
|
||||
|
||||
|
||||
def public_app_settings():
|
||||
settings = get_app_settings(force_reload=True)
|
||||
allowed_keys = (
|
||||
"compose_root",
|
||||
"ui_host",
|
||||
"ui_port",
|
||||
"ops_log_file",
|
||||
"proxy_refresh_script",
|
||||
"login_desktop_api_url",
|
||||
"login_desktop_public_url",
|
||||
"login_desktop_public_scheme",
|
||||
"login_desktop_public_port",
|
||||
)
|
||||
return {key: settings.get(key) for key in allowed_keys}
|
||||
|
||||
|
||||
def create_app():
|
||||
settings = get_app_settings()
|
||||
app = FastAPI(title="DouYin Spark Flow Admin")
|
||||
@@ -213,28 +284,35 @@ def create_app():
|
||||
)
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
DEBUG_ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
app.mount("/debug-artifacts", StaticFiles(directory=str(DEBUG_ARTIFACTS_DIR)), name="debug-artifacts")
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
tb = traceback.format_exception(type(exc), exc, exc.__traceback__)
|
||||
tb_text = "".join(tb)
|
||||
logger.error("Unhandled exception on %s %s:\n%s", request.method, request.url.path, tb_text)
|
||||
return PlainTextResponse(f"Internal Server Error\n\n{tb_text}", status_code=500)
|
||||
logger.exception("Unhandled exception on %s %s", request.method, request.url.path)
|
||||
return PlainTextResponse(
|
||||
"Internal Server Error",
|
||||
status_code=500,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
def render_template(request, template_name, context=None, status_code=200):
|
||||
base_context = context or {}
|
||||
base_context = dict(context or {})
|
||||
base_context.update(
|
||||
{
|
||||
"request": request,
|
||||
"current_user": current_user(request),
|
||||
"csrf_token": csrf_token(request) if current_user(request) else "",
|
||||
"is_https": is_https_request(request),
|
||||
"app_settings": get_app_settings(force_reload=True),
|
||||
"app_settings": public_app_settings(),
|
||||
"login_desktop_public_url": login_desktop_public_url(request),
|
||||
}
|
||||
)
|
||||
return templates.TemplateResponse(request, template_name, base_context, status_code=status_code)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
template_name,
|
||||
base_context,
|
||||
status_code=status_code,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
def redirect(path="/", status_code=303):
|
||||
return RedirectResponse(url=path, status_code=status_code)
|
||||
@@ -250,6 +328,17 @@ def create_app():
|
||||
def pop_flash(request):
|
||||
return request.session.pop("flash", None)
|
||||
|
||||
@app.get("/debug-artifacts/{artifact_path:path}")
|
||||
async def debug_artifact(request: Request, artifact_path: str):
|
||||
maybe_redirect = require_user(request)
|
||||
if maybe_redirect:
|
||||
return maybe_redirect
|
||||
root = DEBUG_ARTIFACTS_DIR.resolve()
|
||||
candidate = (root / artifact_path).resolve()
|
||||
if root not in candidate.parents or not candidate.is_file():
|
||||
return PlainTextResponse("Not found", status_code=404)
|
||||
return FileResponse(candidate, headers={"Cache-Control": "no-store"})
|
||||
|
||||
@app.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request):
|
||||
if current_user(request):
|
||||
@@ -304,6 +393,19 @@ def create_app():
|
||||
clear_session(request)
|
||||
return redirect("/login")
|
||||
|
||||
@app.get("/api/ops/overview")
|
||||
async def ops_overview(request: Request):
|
||||
if not current_user(request):
|
||||
return JSONResponse(
|
||||
{"error": "Unauthorized"},
|
||||
status_code=401,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
return JSONResponse(
|
||||
get_overview_snapshot(),
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def dashboard(request: Request):
|
||||
maybe_redirect = require_user(request)
|
||||
@@ -476,7 +578,11 @@ def create_app():
|
||||
|
||||
updated_account = find_account(get_userData(force_reload=True), unique_id) or {}
|
||||
if _target_sent_today(updated_account, target_name):
|
||||
flash(request, f"Retried {account.get('username', 'Account')} / {target_name} successfully.", "success")
|
||||
flash(request, f"已重试 {account.get('username', 'Account')} / {target_name},并获得强证据确认。", "success")
|
||||
elif _target_unconfirmed_today(updated_account, target_name):
|
||||
failure_entry = dict(updated_account.get("failure_queue") or {}).get(target_name) or {}
|
||||
reason = str(failure_entry.get("reason") or "Retry ran but did not get strong confirmation.")
|
||||
flash(request, f"已执行 {account.get('username', 'Account')} / {target_name},但未强确认,已进入待核验/待补发:{reason}", "warning")
|
||||
else:
|
||||
account_failure = dict(updated_account.get("account_failure") or {})
|
||||
affected_targets = list(account_failure.get("affectedTargets") or [])
|
||||
@@ -488,6 +594,64 @@ def create_app():
|
||||
flash(request, f"Retry did not succeed for {account.get('username', 'Account')} / {target_name}: {reason}", "error")
|
||||
return redirect("/ops/send-console")
|
||||
|
||||
@app.post("/accounts/{unique_id}/mark-target-unconfirmed")
|
||||
async def mark_account_target_unconfirmed(request: Request, unique_id: str):
|
||||
maybe_redirect = require_user(request)
|
||||
if maybe_redirect:
|
||||
return maybe_redirect
|
||||
|
||||
form = await request.form()
|
||||
if not validate_csrf(request, str(form.get("csrf_token", ""))):
|
||||
return Response("Invalid CSRF token", status_code=403)
|
||||
|
||||
target_name = str(form.get("target", "")).strip()
|
||||
if not target_name:
|
||||
flash(request, "Target is required.", "error")
|
||||
return redirect("/ops/send-console")
|
||||
|
||||
accounts = get_userData(force_reload=True)
|
||||
account = find_account(accounts, unique_id)
|
||||
if not account:
|
||||
flash(request, "Account not found.", "error")
|
||||
return redirect("/ops/send-console")
|
||||
|
||||
changed = mark_target_unconfirmed(account, target_name)
|
||||
if changed:
|
||||
save_userData(accounts)
|
||||
flash(request, f"已将 {account.get('username', 'Account')} / {target_name} 标记为待核验/待补发。", "warning")
|
||||
else:
|
||||
flash(request, f"{target_name} 已是强确认记录或不是今日记录,未自动重置。", "info")
|
||||
return redirect("/ops/send-console")
|
||||
|
||||
@app.post("/ops/reset-today-unconfirmed")
|
||||
async def reset_today_unconfirmed(request: Request):
|
||||
maybe_redirect = require_user(request)
|
||||
if maybe_redirect:
|
||||
return maybe_redirect
|
||||
|
||||
form = await request.form()
|
||||
if not validate_csrf(request, str(form.get("csrf_token", ""))):
|
||||
return Response("Invalid CSRF token", status_code=403)
|
||||
|
||||
accounts = get_userData(force_reload=True)
|
||||
changed_count = 0
|
||||
for account in accounts:
|
||||
for target_name in list(account.get("targets") or []):
|
||||
entry = dict(account.get("message_history") or {}).get(target_name) or {}
|
||||
sent_at = _parse_sent_at(entry.get("sentAt"))
|
||||
if not sent_at or sent_at.date() != datetime.now(_schedule_timezone()).date():
|
||||
continue
|
||||
if _history_entry_strong_confirmed_today(entry):
|
||||
continue
|
||||
if mark_target_unconfirmed(account, target_name, reason="batch_reset_today_suspicious_success"):
|
||||
changed_count += 1
|
||||
if changed_count:
|
||||
save_userData(accounts)
|
||||
flash(request, f"已将 {changed_count} 条今日可疑成功记录标记为待核验/待补发。", "warning")
|
||||
else:
|
||||
flash(request, "没有找到需要重置的今日可疑成功记录。", "info")
|
||||
return redirect("/ops/send-console")
|
||||
|
||||
@app.post("/config")
|
||||
async def save_runtime_config(request: Request):
|
||||
maybe_redirect = require_user(request)
|
||||
@@ -565,15 +729,9 @@ def create_app():
|
||||
return Response("Invalid CSRF token", status_code=403)
|
||||
|
||||
settings = get_app_settings(force_reload=True)
|
||||
settings["server_host"] = str(form.get("server_host", "")).strip()
|
||||
settings["server_username"] = str(form.get("server_username", "")).strip()
|
||||
settings["server_password"] = str(form.get("server_password", "")).strip()
|
||||
settings["compose_root"] = str(form.get("compose_root", settings.get("compose_root", ""))).strip()
|
||||
settings["ops_log_file"] = str(form.get("ops_log_file", settings.get("ops_log_file", ""))).strip()
|
||||
settings["proxy_refresh_script"] = str(form.get("proxy_refresh_script", settings.get("proxy_refresh_script", ""))).strip()
|
||||
settings["local_login_helper_url"] = str(
|
||||
form.get("local_login_helper_url", settings.get("local_login_helper_url", "http://127.0.0.1:18765"))
|
||||
).strip()
|
||||
settings["login_desktop_api_url"] = str(
|
||||
form.get("login_desktop_api_url", settings.get("login_desktop_api_url", "http://127.0.0.1:18090"))
|
||||
).strip()
|
||||
@@ -601,14 +759,14 @@ def create_app():
|
||||
if not validate_csrf(request, str(form.get("csrf_token", ""))):
|
||||
return Response("Invalid CSRF token", status_code=403)
|
||||
|
||||
pid = run_task_now()
|
||||
pid = run_task_now(force_all=True)
|
||||
if pid == TASK_ALREADY_RUNNING:
|
||||
flash(request, "已有发送任务正在运行,本次补发全部对象没有启动。请等当前任务结束后再试。", "warning")
|
||||
elif pid == -1:
|
||||
flash(request, "Failed to start the full resend run. Check server logs for details.", "error")
|
||||
else:
|
||||
flash(request, f"已启动补发全部对象后台任务(pid {pid})。这只表示任务已启动,实际成功数请刷新发送控制台查看。", "info")
|
||||
return redirect("/")
|
||||
return redirect("/ops/send-console")
|
||||
|
||||
@app.post("/ops/run-failed")
|
||||
async def run_failed_retry(request: Request):
|
||||
|
||||
+411
-99
@@ -1,3 +1,4 @@
|
||||
import errno
|
||||
import json
|
||||
import hashlib
|
||||
import logging
|
||||
@@ -11,6 +12,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from core.send_state import history_entry_is_strong_confirmed_today, parse_sent_at
|
||||
from utils.config import get_app_settings, get_config, get_userData, normalize_unique_id, repo_root, save_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -25,6 +27,26 @@ TASK_SCHEDULE_MARKERS = (
|
||||
HOST_CRONTAB_PATH = Path("/host-spool-cron/root")
|
||||
WINDOWED_SCHEDULE_RE = re.compile(r"^(\d{2}):(\d{2})-(\d{2}):(\d{2})/(\d+)m$", re.IGNORECASE)
|
||||
|
||||
CONFIRMATION_LABELS = {
|
||||
"cdp_message_send_receipt": "服务端回执",
|
||||
"browser_visible_count_increased": "页面回显",
|
||||
"legacy_sentAt_only": "旧记录待核验",
|
||||
"manual_reset": "人工标记待核验",
|
||||
}
|
||||
|
||||
FAILURE_CATEGORY_LABELS = {
|
||||
"send_unconfirmed": "待核验",
|
||||
"login_required": "登录失效",
|
||||
"friend_not_found": "未找到好友",
|
||||
"friend_list_unavailable": "好友列表不可用",
|
||||
"timeout": "执行超时",
|
||||
"navigation": "页面访问失败",
|
||||
"selector": "页面结构变化",
|
||||
"browser_crash": "浏览器异常",
|
||||
"protocol_user_blocked": "对方限制私信",
|
||||
"protocol_user_not_in_conversation": "不在会话中",
|
||||
}
|
||||
|
||||
|
||||
def running_in_container():
|
||||
return Path("/.dockerenv").exists()
|
||||
@@ -71,6 +93,12 @@ def _pid_is_alive(pid):
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
except OSError as exc:
|
||||
if getattr(exc, "winerror", None) == 87 or exc.errno == errno.ESRCH:
|
||||
return False
|
||||
if exc.errno in (errno.EPERM, errno.EACCES):
|
||||
return True
|
||||
raise
|
||||
return True
|
||||
|
||||
|
||||
@@ -84,32 +112,62 @@ def _parse_lock_pid(raw):
|
||||
def task_run_lock_status():
|
||||
lock_path = repo_root() / "logs" / "task.run.lock"
|
||||
if not lock_path.exists():
|
||||
return {"running": False, "path": str(lock_path), "pid": None, "ageSeconds": 0, "staleRemoved": False}
|
||||
return {
|
||||
"running": False,
|
||||
"path": str(lock_path),
|
||||
"pid": None,
|
||||
"ageSeconds": 0,
|
||||
"stale": False,
|
||||
"staleReason": "",
|
||||
"staleRemoved": False,
|
||||
}
|
||||
|
||||
raw = lock_path.read_text(encoding="utf-8", errors="ignore")
|
||||
pid = _parse_lock_pid(raw)
|
||||
try:
|
||||
age_seconds = max(0, int(datetime.now(timezone.utc).timestamp() - lock_path.stat().st_mtime))
|
||||
except OSError:
|
||||
return {"running": False, "path": str(lock_path), "pid": pid, "ageSeconds": 0, "staleRemoved": False}
|
||||
return {
|
||||
"running": False,
|
||||
"path": str(lock_path),
|
||||
"pid": pid,
|
||||
"ageSeconds": 0,
|
||||
"stale": True,
|
||||
"staleReason": "lock_stat_failed",
|
||||
"staleRemoved": False,
|
||||
}
|
||||
|
||||
if pid is not None and not _pid_is_alive(pid):
|
||||
try:
|
||||
lock_path.unlink()
|
||||
logger.warning("Removed stale task run lock owned by missing pid=%s", pid)
|
||||
return {"running": False, "path": str(lock_path), "pid": pid, "ageSeconds": age_seconds, "staleRemoved": True}
|
||||
except FileNotFoundError:
|
||||
return {"running": False, "path": str(lock_path), "pid": pid, "ageSeconds": age_seconds, "staleRemoved": True}
|
||||
return {
|
||||
"running": False,
|
||||
"path": str(lock_path),
|
||||
"pid": pid,
|
||||
"ageSeconds": age_seconds,
|
||||
"stale": True,
|
||||
"staleReason": "owner_pid_missing",
|
||||
"staleRemoved": False,
|
||||
}
|
||||
|
||||
if pid is None and age_seconds > 7200:
|
||||
try:
|
||||
lock_path.unlink()
|
||||
logger.warning("Removed stale unreadable task run lock contents=%r", raw[:80])
|
||||
return {"running": False, "path": str(lock_path), "pid": None, "ageSeconds": age_seconds, "staleRemoved": True}
|
||||
except FileNotFoundError:
|
||||
return {"running": False, "path": str(lock_path), "pid": None, "ageSeconds": age_seconds, "staleRemoved": True}
|
||||
return {
|
||||
"running": False,
|
||||
"path": str(lock_path),
|
||||
"pid": None,
|
||||
"ageSeconds": age_seconds,
|
||||
"stale": True,
|
||||
"staleReason": "unreadable_lock",
|
||||
"staleRemoved": False,
|
||||
}
|
||||
|
||||
return {"running": True, "path": str(lock_path), "pid": pid, "ageSeconds": age_seconds, "staleRemoved": False}
|
||||
return {
|
||||
"running": True,
|
||||
"path": str(lock_path),
|
||||
"pid": pid,
|
||||
"ageSeconds": age_seconds,
|
||||
"stale": False,
|
||||
"staleReason": "",
|
||||
"staleRemoved": False,
|
||||
}
|
||||
|
||||
|
||||
def build_task_run_spec():
|
||||
@@ -219,18 +277,30 @@ def _empty_result(stdout="", stderr=""):
|
||||
def run_background_command(args, log_path, cwd=None, env=None):
|
||||
log_path = Path(log_path)
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handle = log_path.open("ab")
|
||||
cwd_path = Path(cwd) if cwd else compose_root()
|
||||
child_env = os.environ.copy()
|
||||
if env:
|
||||
child_env.update(env)
|
||||
process = subprocess.Popen(
|
||||
args,
|
||||
cwd=str(Path(cwd) if cwd else compose_root()),
|
||||
stdout=handle,
|
||||
stderr=subprocess.STDOUT,
|
||||
env=child_env,
|
||||
)
|
||||
handle.close()
|
||||
|
||||
with log_path.open("ab") as handle:
|
||||
started_at = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
|
||||
env_keys = ",".join(sorted((env or {}).keys())) or "none"
|
||||
handle.write(
|
||||
(
|
||||
f"[WEB_TRIGGER] {started_at} start cwd={cwd_path} "
|
||||
f"env_keys={env_keys} command={shlex.join([str(part) for part in args])}\n"
|
||||
).encode("utf-8", errors="replace")
|
||||
)
|
||||
handle.flush()
|
||||
process = subprocess.Popen(
|
||||
args,
|
||||
cwd=str(cwd_path),
|
||||
stdout=handle,
|
||||
stderr=subprocess.STDOUT,
|
||||
env=child_env,
|
||||
)
|
||||
handle.write(f"[WEB_TRIGGER] {started_at} pid={process.pid}\n".encode("utf-8", errors="replace"))
|
||||
handle.flush()
|
||||
return process.pid
|
||||
|
||||
|
||||
@@ -289,7 +359,7 @@ def get_task_container_rows():
|
||||
return []
|
||||
|
||||
|
||||
def run_task_now(*, unsent_only=False, failed_only=False):
|
||||
def run_task_now(*, unsent_only=False, failed_only=False, force_all=False):
|
||||
try:
|
||||
lock_status = task_run_lock_status()
|
||||
if lock_status.get("running"):
|
||||
@@ -306,10 +376,19 @@ def run_task_now(*, unsent_only=False, failed_only=False):
|
||||
"SPARKFLOW_MANUAL_RUN": "1",
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
}
|
||||
if failed_only:
|
||||
if force_all:
|
||||
run_env["SPARKFLOW_MANUAL_FORCE_ALL"] = "1"
|
||||
elif failed_only:
|
||||
run_env["SPARKFLOW_MANUAL_FAILED_ONLY"] = "1"
|
||||
elif unsent_only:
|
||||
run_env["SPARKFLOW_MANUAL_UNSENT_ONLY"] = "1"
|
||||
logger.info(
|
||||
"Starting background task command=%s cwd=%s env=%s log=%s",
|
||||
command,
|
||||
cwd,
|
||||
{key: run_env[key] for key in sorted(run_env)},
|
||||
log_file,
|
||||
)
|
||||
return run_background_command(
|
||||
command,
|
||||
log_file,
|
||||
@@ -508,6 +587,49 @@ def current_daily_schedule():
|
||||
return ""
|
||||
|
||||
|
||||
def _next_window_trigger(now, window):
|
||||
interval = max(1, int(window["scheduleIntervalMinutes"]))
|
||||
candidates = []
|
||||
for hour in range(int(window["startHour"]), int(window["endHour"])):
|
||||
for minute in range(0, 60, interval):
|
||||
candidates.append(now.replace(hour=hour, minute=minute, second=0, microsecond=0))
|
||||
end_hour = int(window["endHour"])
|
||||
candidates.append(now.replace(hour=end_hour, minute=0, second=0, microsecond=0))
|
||||
if interval < 60:
|
||||
candidates.append(now.replace(hour=end_hour, minute=interval, second=0, microsecond=0))
|
||||
for candidate in sorted(set(candidates)):
|
||||
if candidate > now:
|
||||
return candidate
|
||||
tomorrow = now + timedelta(days=1)
|
||||
return tomorrow.replace(
|
||||
hour=int(window["startHour"]),
|
||||
minute=0,
|
||||
second=0,
|
||||
microsecond=0,
|
||||
)
|
||||
|
||||
|
||||
def get_schedule_snapshot(now=None):
|
||||
now = now or datetime.now(_schedule_timezone())
|
||||
window = _normalize_send_window()
|
||||
label = current_daily_schedule()
|
||||
if window.get("enabled"):
|
||||
next_trigger = _next_window_trigger(now, window)
|
||||
else:
|
||||
try:
|
||||
hour, minute = [int(part) for part in label.split(":", 1)]
|
||||
next_trigger = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
if next_trigger <= now:
|
||||
next_trigger += timedelta(days=1)
|
||||
except (TypeError, ValueError):
|
||||
next_trigger = None
|
||||
return {
|
||||
"label": label,
|
||||
"nextTriggerAt": next_trigger.isoformat(timespec="seconds") if next_trigger else "",
|
||||
"nextTriggerDisplay": next_trigger.strftime("%m-%d %H:%M") if next_trigger else "",
|
||||
}
|
||||
|
||||
|
||||
def _schedule_timezone():
|
||||
timezone_name = (
|
||||
str(os.getenv("SPARKFLOW_TIMEZONE") or "").strip()
|
||||
@@ -533,18 +655,7 @@ def _normalize_send_window():
|
||||
|
||||
|
||||
def _parse_sent_at(raw_value, local_tz):
|
||||
if not raw_value:
|
||||
return None
|
||||
raw = str(raw_value).strip()
|
||||
if raw.endswith("Z"):
|
||||
raw = raw[:-1] + "+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=local_tz)
|
||||
return parsed.astimezone(local_tz)
|
||||
return parse_sent_at(raw_value, local_tz)
|
||||
|
||||
|
||||
def _account_identity(user):
|
||||
@@ -629,74 +740,154 @@ def _scheduled_send_time(user, target_name, send_window, now):
|
||||
return start_of_window + timedelta(minutes=offset_minutes)
|
||||
|
||||
|
||||
def _build_target_status(account, target_name, now, send_window):
|
||||
history = dict(account.get("message_history") or {})
|
||||
failure_queue = dict(account.get("failure_queue") or {})
|
||||
friend_index = _friend_index_status(account, target_name)
|
||||
|
||||
history_entry = history.get(target_name) or {}
|
||||
sent_at = _parse_sent_at(history_entry.get("sentAt"), now.tzinfo)
|
||||
if sent_at and sent_at.date() == now.date():
|
||||
return {
|
||||
"target": target_name,
|
||||
"status": "sent",
|
||||
"message": str(history_entry.get("message") or ""),
|
||||
"sentAt": sent_at.isoformat(timespec="seconds"),
|
||||
"lastAttemptAt": "",
|
||||
"category": "",
|
||||
"reason": "",
|
||||
"attemptCount": 0,
|
||||
"scheduledAt": "",
|
||||
"friendIndex": friend_index,
|
||||
}
|
||||
|
||||
failure_entry = failure_queue.get(target_name) or {}
|
||||
last_attempt_at = _parse_sent_at(failure_entry.get("lastAttemptAt"), now.tzinfo)
|
||||
if last_attempt_at and last_attempt_at.date() == now.date():
|
||||
return {
|
||||
"target": target_name,
|
||||
"status": "failed",
|
||||
"message": str(failure_entry.get("message") or ""),
|
||||
"sentAt": "",
|
||||
"lastAttemptAt": last_attempt_at.isoformat(timespec="seconds"),
|
||||
"category": str(failure_entry.get("category") or ""),
|
||||
"reason": str(failure_entry.get("reason") or ""),
|
||||
"attemptCount": int(failure_entry.get("attemptCount") or 0),
|
||||
"scheduledAt": "",
|
||||
"friendIndex": friend_index,
|
||||
}
|
||||
|
||||
scheduled_at = None
|
||||
if send_window.get("enabled"):
|
||||
scheduled_at = _scheduled_send_time(account, target_name, send_window, now)
|
||||
if scheduled_at > now:
|
||||
return {
|
||||
"target": target_name,
|
||||
"status": "pending",
|
||||
"message": "",
|
||||
"sentAt": "",
|
||||
"lastAttemptAt": "",
|
||||
"category": "",
|
||||
"reason": "",
|
||||
"attemptCount": 0,
|
||||
"scheduledAt": scheduled_at.isoformat(timespec="seconds"),
|
||||
"friendIndex": friend_index,
|
||||
}
|
||||
|
||||
def _base_target_status(account, target_name, now):
|
||||
return {
|
||||
"target": target_name,
|
||||
"status": "unprocessed",
|
||||
"status": "",
|
||||
"message": "",
|
||||
"sentAt": "",
|
||||
"lastAttemptAt": "",
|
||||
"category": "",
|
||||
"reason": "",
|
||||
"attemptCount": 0,
|
||||
"scheduledAt": scheduled_at.isoformat(timespec="seconds") if scheduled_at else "",
|
||||
"friendIndex": friend_index,
|
||||
"scheduledAt": "",
|
||||
"friendIndex": _friend_index_status(account, target_name),
|
||||
"confirmationLevel": "",
|
||||
"confirmationSource": "",
|
||||
"confirmationDetail": "",
|
||||
"needsVerification": False,
|
||||
"legacyUnverified": False,
|
||||
"displaySentAt": "",
|
||||
"displayLastAttemptAt": "",
|
||||
"displayScheduledAt": "",
|
||||
"confirmationLabel": "",
|
||||
"categoryLabel": "",
|
||||
}
|
||||
|
||||
|
||||
def _history_entry_is_strong_confirmed(history_entry, sent_at, now):
|
||||
return history_entry_is_strong_confirmed_today(history_entry, now)
|
||||
|
||||
|
||||
def _format_short_time(raw_value, now):
|
||||
parsed = _parse_sent_at(raw_value, now.tzinfo)
|
||||
if not parsed:
|
||||
return ""
|
||||
if parsed.date() == now.date():
|
||||
return parsed.strftime("%H:%M:%S")
|
||||
return parsed.strftime("%m-%d %H:%M")
|
||||
|
||||
|
||||
def _finalize_target_status(item, now):
|
||||
item = dict(item)
|
||||
item["displaySentAt"] = _format_short_time(item.get("sentAt"), now)
|
||||
item["displayLastAttemptAt"] = _format_short_time(item.get("lastAttemptAt"), now)
|
||||
item["displayScheduledAt"] = _format_short_time(item.get("scheduledAt"), now)
|
||||
source = str(item.get("confirmationSource") or "")
|
||||
category = str(item.get("category") or "")
|
||||
item["confirmationLabel"] = CONFIRMATION_LABELS.get(source, source or "-")
|
||||
item["categoryLabel"] = FAILURE_CATEGORY_LABELS.get(category, category or "-")
|
||||
return item
|
||||
|
||||
|
||||
def _build_target_status(account, target_name, now, send_window):
|
||||
history = dict(account.get("message_history") or {})
|
||||
failure_queue = dict(account.get("failure_queue") or {})
|
||||
item = _base_target_status(account, target_name, now)
|
||||
|
||||
history_entry = dict(history.get(target_name) or {})
|
||||
sent_at = _parse_sent_at(history_entry.get("sentAt"), now.tzinfo)
|
||||
if _history_entry_is_strong_confirmed(history_entry, sent_at, now):
|
||||
item.update(
|
||||
{
|
||||
"status": "sent",
|
||||
"message": str(history_entry.get("message") or ""),
|
||||
"sentAt": sent_at.isoformat(timespec="seconds"),
|
||||
"confirmationLevel": str(history_entry.get("confirmationLevel") or "strong"),
|
||||
"confirmationSource": str(history_entry.get("confirmationSource") or "browser_visible_count_increased"),
|
||||
"confirmationDetail": str(history_entry.get("confirmationDetail") or ""),
|
||||
}
|
||||
)
|
||||
return _finalize_target_status(item, now)
|
||||
|
||||
failure_entry = dict(failure_queue.get(target_name) or {})
|
||||
last_attempt_at = _parse_sent_at(failure_entry.get("lastAttemptAt"), now.tzinfo)
|
||||
failure_is_today = bool(last_attempt_at and last_attempt_at.date() == now.date())
|
||||
|
||||
if sent_at and sent_at.date() == now.date():
|
||||
confirmation_level = str(history_entry.get("confirmationLevel") or "legacy")
|
||||
confirmation_source = str(history_entry.get("confirmationSource") or "legacy_sentAt_only")
|
||||
confirmation_detail = str(history_entry.get("confirmationDetail") or "")
|
||||
legacy_unverified = not history_entry.get("confirmationLevel")
|
||||
if legacy_unverified:
|
||||
confirmation_detail = confirmation_detail or "旧格式发送账本缺少强确认字段,已降级为待核验。"
|
||||
item.update(
|
||||
{
|
||||
"status": "unconfirmed",
|
||||
"message": str(history_entry.get("message") or failure_entry.get("message") or ""),
|
||||
"sentAt": sent_at.isoformat(timespec="seconds"),
|
||||
"lastAttemptAt": last_attempt_at.isoformat(timespec="seconds") if failure_is_today else "",
|
||||
"category": str(failure_entry.get("category") or "send_unconfirmed"),
|
||||
"reason": str(failure_entry.get("reason") or confirmation_detail or "发送记录缺少强确认,需要核验。"),
|
||||
"attemptCount": int(failure_entry.get("attemptCount") or 0),
|
||||
"confirmationLevel": confirmation_level,
|
||||
"confirmationSource": confirmation_source,
|
||||
"confirmationDetail": confirmation_detail,
|
||||
"needsVerification": True,
|
||||
"legacyUnverified": legacy_unverified,
|
||||
}
|
||||
)
|
||||
return _finalize_target_status(item, now)
|
||||
|
||||
if failure_is_today:
|
||||
category = str(failure_entry.get("category") or "")
|
||||
status = "unconfirmed" if category == "send_unconfirmed" else "failed"
|
||||
item.update(
|
||||
{
|
||||
"status": status,
|
||||
"message": str(failure_entry.get("message") or ""),
|
||||
"lastAttemptAt": last_attempt_at.isoformat(timespec="seconds"),
|
||||
"category": category,
|
||||
"reason": str(failure_entry.get("reason") or ""),
|
||||
"attemptCount": int(failure_entry.get("attemptCount") or 0),
|
||||
"confirmationLevel": str(failure_entry.get("confirmationLevel") or ("weak" if status == "unconfirmed" else "")),
|
||||
"confirmationSource": str(failure_entry.get("confirmationSource") or ""),
|
||||
"confirmationDetail": str(failure_entry.get("reason") or ""),
|
||||
"needsVerification": status == "unconfirmed",
|
||||
}
|
||||
)
|
||||
return _finalize_target_status(item, now)
|
||||
|
||||
scheduled_at = None
|
||||
if send_window.get("enabled"):
|
||||
scheduled_at = _scheduled_send_time(account, target_name, send_window, now)
|
||||
if scheduled_at > now:
|
||||
item.update(
|
||||
{
|
||||
"status": "pending",
|
||||
"scheduledAt": scheduled_at.isoformat(timespec="seconds"),
|
||||
}
|
||||
)
|
||||
return _finalize_target_status(item, now)
|
||||
|
||||
item.update(
|
||||
{
|
||||
"status": "unprocessed",
|
||||
"scheduledAt": scheduled_at.isoformat(timespec="seconds") if scheduled_at else "",
|
||||
}
|
||||
)
|
||||
return _finalize_target_status(item, now)
|
||||
|
||||
|
||||
def _orphan_records(account, configured_targets):
|
||||
configured_target_set = {str(target) for target in configured_targets}
|
||||
history = dict(account.get("message_history") or {})
|
||||
failure_queue = dict(account.get("failure_queue") or {})
|
||||
orphan_history = sorted(str(target) for target in history if str(target) not in configured_target_set)
|
||||
orphan_failure = sorted(str(target) for target in failure_queue if str(target) not in configured_target_set)
|
||||
return orphan_history, orphan_failure
|
||||
|
||||
|
||||
def get_send_console_snapshot():
|
||||
accounts = [account for account in get_userData(force_reload=True) if account.get("enabled", True)]
|
||||
send_window = _normalize_send_window()
|
||||
@@ -706,13 +897,23 @@ def get_send_console_snapshot():
|
||||
"enabled_accounts": len(accounts),
|
||||
"total_targets": 0,
|
||||
"today_sent_targets": 0,
|
||||
"today_confirmed_targets": 0,
|
||||
"today_unconfirmed_targets": 0,
|
||||
"today_legacy_unverified_targets": 0,
|
||||
"today_failed_targets": 0,
|
||||
"today_pending_targets": 0,
|
||||
"today_unprocessed_targets": 0,
|
||||
"today_account_blocked_targets": 0,
|
||||
"today_attention_targets": 0,
|
||||
"today_remaining_targets": 0,
|
||||
"today_account_failures": 0,
|
||||
"today_account_paused": 0,
|
||||
"today_warning_count": 0,
|
||||
"orphan_history_records": 0,
|
||||
"orphan_failure_records": 0,
|
||||
"last_confirmed_at": "",
|
||||
"last_confirmed_display": "",
|
||||
"all_confirmed": False,
|
||||
}
|
||||
account_rows = []
|
||||
account_failure_pause_after = _account_failure_pause_after_attempts()
|
||||
@@ -720,14 +921,16 @@ def get_send_console_snapshot():
|
||||
for account in accounts:
|
||||
configured_targets = list(account.get("targets") or [])
|
||||
statuses = [_build_target_status(account, target_name, now, send_window) for target_name in configured_targets]
|
||||
sent_targets = [item for item in statuses if item["status"] == "sent"]
|
||||
confirmed_targets = [item for item in statuses if item["status"] == "sent"]
|
||||
sent_targets = confirmed_targets
|
||||
unconfirmed_targets = [item for item in statuses if item["status"] == "unconfirmed"]
|
||||
failed_targets = [item for item in statuses if item["status"] == "failed"]
|
||||
account_failure = _account_failure_entry_today(account, now)
|
||||
account_paused = bool(account_failure and _coerce_attempt_count(account_failure) >= account_failure_pause_after)
|
||||
account_blocked_targets = []
|
||||
if account_paused:
|
||||
account_blocked_targets = [
|
||||
_account_blocked_target_status(item, account_failure)
|
||||
_finalize_target_status(_account_blocked_target_status(item, account_failure), now)
|
||||
for item in statuses
|
||||
if item["status"] in {"pending", "unprocessed"}
|
||||
]
|
||||
@@ -747,22 +950,63 @@ def get_send_console_snapshot():
|
||||
except (TypeError, ValueError):
|
||||
friend_index_meta["scannedCount"] = 0
|
||||
|
||||
orphan_history, orphan_failure = _orphan_records(account, configured_targets)
|
||||
warnings = []
|
||||
if not configured_targets:
|
||||
warnings.append({"category": "no_targets", "message": "该启用账号没有配置目标,不能代表全部续上。"})
|
||||
if orphan_history:
|
||||
warnings.append({"category": "orphan_history", "message": f"有 {len(orphan_history)} 条发送账本不在当前目标列表中。"})
|
||||
if orphan_failure:
|
||||
warnings.append({"category": "orphan_failure", "message": f"有 {len(orphan_failure)} 条失败队列记录不在当前目标列表中。"})
|
||||
legacy_unverified_targets = [item for item in unconfirmed_targets if item.get("legacyUnverified")]
|
||||
attention_count = len(unconfirmed_targets) + len(failed_targets) + len(account_blocked_targets)
|
||||
pending_count = len(pending_targets) + len(unprocessed_targets)
|
||||
confirmed_times = [
|
||||
_parse_sent_at(item.get("sentAt"), now.tzinfo)
|
||||
for item in confirmed_targets
|
||||
if item.get("sentAt")
|
||||
]
|
||||
confirmed_times = [item for item in confirmed_times if item]
|
||||
last_confirmed_at = max(confirmed_times).isoformat(timespec="seconds") if confirmed_times else ""
|
||||
if account_paused:
|
||||
account_state = "paused"
|
||||
elif attention_count:
|
||||
account_state = "attention"
|
||||
elif warnings:
|
||||
account_state = "warning"
|
||||
elif pending_count:
|
||||
account_state = "pending"
|
||||
else:
|
||||
account_state = "healthy"
|
||||
|
||||
summary["total_targets"] += len(configured_targets)
|
||||
summary["today_sent_targets"] += len(sent_targets)
|
||||
summary["today_confirmed_targets"] += len(confirmed_targets)
|
||||
summary["today_unconfirmed_targets"] += len(unconfirmed_targets)
|
||||
summary["today_legacy_unverified_targets"] += len(legacy_unverified_targets)
|
||||
summary["today_failed_targets"] += len(failed_targets)
|
||||
summary["today_pending_targets"] += len(pending_targets)
|
||||
summary["today_unprocessed_targets"] += len(unprocessed_targets)
|
||||
summary["today_account_blocked_targets"] += len(account_blocked_targets)
|
||||
summary["today_attention_targets"] += attention_count
|
||||
summary["today_remaining_targets"] += (
|
||||
len(failed_targets)
|
||||
len(unconfirmed_targets)
|
||||
+ len(failed_targets)
|
||||
+ len(pending_targets)
|
||||
+ len(unprocessed_targets)
|
||||
+ len(account_blocked_targets)
|
||||
)
|
||||
summary["today_warning_count"] += len(warnings)
|
||||
summary["orphan_history_records"] += len(orphan_history)
|
||||
summary["orphan_failure_records"] += len(orphan_failure)
|
||||
if account_failure:
|
||||
summary["today_account_failures"] += 1
|
||||
if account_paused:
|
||||
summary["today_account_paused"] += 1
|
||||
if last_confirmed_at and (
|
||||
not summary["last_confirmed_at"] or last_confirmed_at > summary["last_confirmed_at"]
|
||||
):
|
||||
summary["last_confirmed_at"] = last_confirmed_at
|
||||
|
||||
account_rows.append(
|
||||
{
|
||||
@@ -770,27 +1014,93 @@ def get_send_console_snapshot():
|
||||
"username": account.get("username") or "",
|
||||
"total_targets": len(configured_targets),
|
||||
"sent_targets": sent_targets,
|
||||
"confirmed_targets": confirmed_targets,
|
||||
"unconfirmed_targets": unconfirmed_targets,
|
||||
"legacy_unverified_targets": legacy_unverified_targets,
|
||||
"failed_targets": failed_targets,
|
||||
"pending_targets": pending_targets,
|
||||
"unprocessed_targets": unprocessed_targets,
|
||||
"account_blocked_targets": account_blocked_targets,
|
||||
"last_failure_reason": failed_targets[0]["reason"] if failed_targets else "",
|
||||
"last_unconfirmed_reason": unconfirmed_targets[0]["reason"] if unconfirmed_targets else "",
|
||||
"failure_queue": dict(account.get("failure_queue") or {}),
|
||||
"account_failure": account_failure,
|
||||
"account_paused": account_paused,
|
||||
"account_failure_pause_after": account_failure_pause_after,
|
||||
"state": account_state,
|
||||
"attention_count": attention_count,
|
||||
"pending_count": pending_count,
|
||||
"last_confirmed_at": last_confirmed_at,
|
||||
"last_confirmed_display": _format_short_time(last_confirmed_at, now),
|
||||
"friend_index_meta": friend_index_meta,
|
||||
"friend_index_count": len(dict(account.get("friend_index") or {})),
|
||||
"warnings": warnings,
|
||||
"orphan_history_records": orphan_history,
|
||||
"orphan_failure_records": orphan_failure,
|
||||
}
|
||||
)
|
||||
|
||||
state_rank = {"paused": 0, "attention": 1, "warning": 2, "pending": 3, "healthy": 4}
|
||||
account_rows.sort(key=lambda row: (state_rank.get(row.get("state"), 9), str(row.get("username") or "")))
|
||||
|
||||
summary["all_confirmed"] = bool(
|
||||
summary["total_targets"] > 0
|
||||
and summary["today_confirmed_targets"] == summary["total_targets"]
|
||||
and summary["today_remaining_targets"] == 0
|
||||
and summary["today_warning_count"] == 0
|
||||
and summary["orphan_history_records"] == 0
|
||||
and summary["orphan_failure_records"] == 0
|
||||
)
|
||||
summary["last_confirmed_display"] = _format_short_time(summary["last_confirmed_at"], now)
|
||||
|
||||
return {
|
||||
"now": now.isoformat(timespec="seconds"),
|
||||
"nowDisplay": now.strftime("%m-%d %H:%M"),
|
||||
"summary": summary,
|
||||
"accounts": account_rows,
|
||||
}
|
||||
|
||||
|
||||
def get_overview_snapshot():
|
||||
send_console = get_send_console_snapshot()
|
||||
summary = dict(send_console["summary"])
|
||||
accounts = []
|
||||
for row in send_console["accounts"]:
|
||||
accounts.append(
|
||||
{
|
||||
"uniqueId": row["unique_id"],
|
||||
"displayName": row["username"],
|
||||
"state": row["state"],
|
||||
"total": row["total_targets"],
|
||||
"confirmed": len(row["confirmed_targets"]),
|
||||
"attention": row["attention_count"],
|
||||
"pending": row["pending_count"],
|
||||
"lastConfirmedAt": row["last_confirmed_at"],
|
||||
}
|
||||
)
|
||||
return {
|
||||
"now": send_console["now"],
|
||||
"schedule": get_schedule_snapshot(),
|
||||
"task": task_run_lock_status(),
|
||||
"summary": {
|
||||
"enabledAccounts": summary["enabled_accounts"],
|
||||
"total": summary["total_targets"],
|
||||
"confirmed": summary["today_confirmed_targets"],
|
||||
"unconfirmed": summary["today_unconfirmed_targets"],
|
||||
"failed": summary["today_failed_targets"],
|
||||
"blocked": summary["today_account_blocked_targets"],
|
||||
"attention": summary["today_attention_targets"],
|
||||
"pending": summary["today_pending_targets"],
|
||||
"unprocessed": summary["today_unprocessed_targets"],
|
||||
"remaining": summary["today_remaining_targets"],
|
||||
"warnings": summary["today_warning_count"],
|
||||
"lastConfirmedAt": summary["last_confirmed_at"],
|
||||
"allConfirmed": summary["all_confirmed"],
|
||||
},
|
||||
"accounts": accounts,
|
||||
}
|
||||
|
||||
|
||||
def _check_image_present():
|
||||
"""Return True if the douyin-sparkflow:local image exists."""
|
||||
try:
|
||||
@@ -811,14 +1121,16 @@ def get_ops_snapshot():
|
||||
Every external call is individually guarded so the dashboard always
|
||||
renders, even when Docker or crontab are not available.
|
||||
"""
|
||||
send_console = get_send_console_snapshot()
|
||||
return {
|
||||
"compose_root": str(compose_root()),
|
||||
"compose_file": str(compose_file_path() or ""),
|
||||
"containers": get_container_status(),
|
||||
"task_containers": get_task_container_rows(),
|
||||
"send_console": get_send_console_snapshot(),
|
||||
"send_console": send_console,
|
||||
"task_lock": task_run_lock_status(),
|
||||
"daily_schedule": current_daily_schedule(),
|
||||
"schedule": get_schedule_snapshot(),
|
||||
"crontab": read_crontab(),
|
||||
"log_tail": read_log_tail(120),
|
||||
"image_present": _check_image_present(),
|
||||
|
||||
+1267
-880
File diff suppressed because it is too large
Load Diff
+435
-318
@@ -1,138 +1,360 @@
|
||||
(() => {
|
||||
const storageKey = "sparkflow-theme";
|
||||
const root = document.documentElement;
|
||||
const storageKey = "sparkflow-theme";
|
||||
|
||||
const readStoredTheme = () => {
|
||||
const storedTheme = () => {
|
||||
try {
|
||||
return window.localStorage.getItem(storageKey);
|
||||
return localStorage.getItem(storageKey);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const writeStoredTheme = (theme) => {
|
||||
const applyTheme = (theme) => {
|
||||
const value = theme === "light" ? "light" : "dark";
|
||||
root.dataset.theme = value;
|
||||
root.style.colorScheme = value;
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, theme);
|
||||
localStorage.setItem(storageKey, value);
|
||||
} catch {
|
||||
// Ignore storage restrictions; the current page can still switch theme.
|
||||
// The active page can still switch themes when storage is unavailable.
|
||||
}
|
||||
};
|
||||
|
||||
const preferredTheme = () => {
|
||||
const stored = readStoredTheme();
|
||||
if (stored === "dark" || stored === "light") return stored;
|
||||
return window.matchMedia?.("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
};
|
||||
|
||||
const applyTheme = (theme) => {
|
||||
const normalized = theme === "dark" ? "dark" : "light";
|
||||
root.dataset.theme = normalized;
|
||||
root.style.colorScheme = normalized;
|
||||
const nextLabel = normalized === "dark" ? "切换白天模式" : "切换黑夜模式";
|
||||
document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
|
||||
button.setAttribute("aria-label", nextLabel);
|
||||
button.setAttribute("title", nextLabel);
|
||||
button.setAttribute(
|
||||
"aria-pressed",
|
||||
normalized === "dark" ? "true" : "false",
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
applyTheme(preferredTheme());
|
||||
|
||||
applyTheme(storedTheme() || "dark");
|
||||
document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const next = root.dataset.theme === "dark" ? "light" : "dark";
|
||||
writeStoredTheme(next);
|
||||
applyTheme(next);
|
||||
applyTheme(root.dataset.theme === "light" ? "dark" : "light");
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
(() => {
|
||||
const body = document.body;
|
||||
const navToggle = document.querySelector("[data-nav-toggle]");
|
||||
const navClose = document.querySelector("[data-nav-close]");
|
||||
|
||||
navToggle?.addEventListener("click", () => body.classList.toggle("nav-open"));
|
||||
navClose?.addEventListener("click", () => body.classList.remove("nav-open"));
|
||||
document.querySelectorAll(".nav-item").forEach((item) => {
|
||||
item.addEventListener("click", () => body.classList.remove("nav-open"));
|
||||
document.querySelectorAll("[data-nav-toggle]").forEach((button) => {
|
||||
button.addEventListener("click", () => body.classList.add("nav-open"));
|
||||
});
|
||||
document.querySelectorAll("[data-nav-close]").forEach((button) => {
|
||||
button.addEventListener("click", () => body.classList.remove("nav-open"));
|
||||
});
|
||||
document.querySelectorAll(".nav-item").forEach((link) => {
|
||||
link.addEventListener("click", () => body.classList.remove("nav-open"));
|
||||
});
|
||||
})();
|
||||
|
||||
(() => {
|
||||
document.querySelectorAll("[data-confirm]").forEach((node) => {
|
||||
const message = node.getAttribute("data-confirm") || "确认执行此操作?";
|
||||
const handler = (event) => {
|
||||
if (!window.confirm(message)) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
};
|
||||
if (node.tagName === "FORM") {
|
||||
node.addEventListener("submit", handler);
|
||||
} else {
|
||||
node.addEventListener("click", handler);
|
||||
}
|
||||
});
|
||||
})();
|
||||
const dialog = document.getElementById("confirm-dialog");
|
||||
if (!dialog) return;
|
||||
const title = document.getElementById("confirm-title");
|
||||
const message = document.getElementById("confirm-message");
|
||||
const accept = dialog.querySelector("[data-confirm-accept]");
|
||||
const cancel = dialog.querySelector("[data-confirm-cancel]");
|
||||
let pendingForm = null;
|
||||
let pendingLink = "";
|
||||
let pendingButton = null;
|
||||
|
||||
(() => {
|
||||
document.querySelectorAll(".data-card__footer-button").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const targetId = button.dataset.expandTarget;
|
||||
if (!targetId) return;
|
||||
const panel = document.getElementById(targetId);
|
||||
if (!panel) return;
|
||||
const isOpen = panel.classList.toggle("is-open");
|
||||
button.textContent = isOpen
|
||||
? "收起"
|
||||
: button.dataset.totalLabel || "查看全部";
|
||||
const openDialog = (node) => {
|
||||
const source = node.closest("[data-confirm]") || node;
|
||||
title.textContent = source.dataset.confirmTitle || "确认操作";
|
||||
message.textContent =
|
||||
source.dataset.confirm ||
|
||||
"该操作会立即影响续火花任务,请确认是否继续。";
|
||||
accept.textContent = source.dataset.confirmAccept || "确认执行";
|
||||
accept.className =
|
||||
source.dataset.confirmTone === "primary"
|
||||
? "button button-primary"
|
||||
: "button button-danger";
|
||||
dialog.showModal();
|
||||
};
|
||||
|
||||
document.querySelectorAll("form[data-confirm]").forEach((form) => {
|
||||
form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
pendingForm = form;
|
||||
pendingLink = "";
|
||||
pendingButton = null;
|
||||
openDialog(form);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("a[data-confirm]").forEach((link) => {
|
||||
link.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
pendingForm = null;
|
||||
pendingLink = link.href;
|
||||
pendingButton = null;
|
||||
openDialog(link);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("button[data-confirm]").forEach((button) => {
|
||||
button.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
if (button.dataset.confirmApproved === "1") {
|
||||
delete button.dataset.confirmApproved;
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
pendingForm = null;
|
||||
pendingLink = "";
|
||||
pendingButton = button;
|
||||
openDialog(button);
|
||||
},
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
cancel.addEventListener("click", () => {
|
||||
pendingForm = null;
|
||||
pendingLink = "";
|
||||
pendingButton = null;
|
||||
dialog.close();
|
||||
});
|
||||
|
||||
accept.addEventListener("click", () => {
|
||||
const form = pendingForm;
|
||||
const href = pendingLink;
|
||||
const button = pendingButton;
|
||||
pendingForm = null;
|
||||
pendingLink = "";
|
||||
pendingButton = null;
|
||||
dialog.close();
|
||||
if (form) {
|
||||
HTMLFormElement.prototype.submit.call(form);
|
||||
} else if (href) {
|
||||
window.location.assign(href);
|
||||
} else if (button) {
|
||||
button.dataset.confirmApproved = "1";
|
||||
button.click();
|
||||
}
|
||||
});
|
||||
|
||||
dialog.addEventListener("cancel", () => {
|
||||
pendingForm = null;
|
||||
pendingLink = "";
|
||||
pendingButton = null;
|
||||
});
|
||||
})();
|
||||
|
||||
(() => {
|
||||
document.querySelectorAll("[data-segment-group]").forEach((group) => {
|
||||
const buttons = [...group.querySelectorAll("[data-segment-target]")];
|
||||
const owner = group.closest("[data-segment-owner]") || document;
|
||||
const panels = [...owner.querySelectorAll("[data-segment-panel]")];
|
||||
const activate = (name) => {
|
||||
buttons.forEach((button) => {
|
||||
const active = button.dataset.segmentTarget === name;
|
||||
button.classList.toggle("active", active);
|
||||
button.setAttribute("aria-selected", active ? "true" : "false");
|
||||
});
|
||||
panels.forEach((panel) => {
|
||||
panel.hidden = panel.dataset.segmentPanel !== name;
|
||||
});
|
||||
};
|
||||
buttons.forEach((button) => {
|
||||
button.addEventListener("click", () =>
|
||||
activate(button.dataset.segmentTarget),
|
||||
);
|
||||
});
|
||||
const initial =
|
||||
buttons.find((button) => button.classList.contains("active")) ||
|
||||
buttons[0];
|
||||
if (initial) activate(initial.dataset.segmentTarget);
|
||||
});
|
||||
})();
|
||||
|
||||
(() => {
|
||||
const overviewRoots = document.querySelectorAll("[data-overview-root]");
|
||||
if (!overviewRoots.length) return;
|
||||
let previousRunning = null;
|
||||
let timer = null;
|
||||
|
||||
const formatTime = (raw) => {
|
||||
if (!raw) return "-";
|
||||
const parsed = new Date(raw);
|
||||
if (Number.isNaN(parsed.getTime())) return raw;
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(parsed);
|
||||
};
|
||||
|
||||
const setText = (selector, value) => {
|
||||
document.querySelectorAll(selector).forEach((node) => {
|
||||
node.textContent = String(value ?? "");
|
||||
});
|
||||
};
|
||||
|
||||
const updateTaskBanner = (task) => {
|
||||
document.querySelectorAll("[data-task-banner]").forEach((banner) => {
|
||||
banner.className = "status-banner";
|
||||
if (task.running) {
|
||||
banner.classList.add("warning");
|
||||
banner.querySelector("[data-task-text]").textContent =
|
||||
`发送任务运行中,已运行约 ${task.ageSeconds || 0} 秒`;
|
||||
} else if (task.stale) {
|
||||
banner.classList.add("info");
|
||||
banner.querySelector("[data-task-text]").textContent =
|
||||
"检测到过期任务锁,下次启动任务时会自动清理";
|
||||
} else {
|
||||
banner.classList.add("success");
|
||||
banner.querySelector("[data-task-text]").textContent =
|
||||
"当前没有发送任务运行";
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const updateAccounts = (accounts) => {
|
||||
accounts.forEach((account) => {
|
||||
const selector = `[data-account-overview="${CSS.escape(account.uniqueId)}"]`;
|
||||
document.querySelectorAll(selector).forEach((row) => {
|
||||
row.dataset.accountState = account.state;
|
||||
row.querySelectorAll("[data-account-confirmed]").forEach((node) => {
|
||||
node.textContent = account.confirmed;
|
||||
});
|
||||
row.querySelectorAll("[data-account-attention]").forEach((node) => {
|
||||
node.textContent = account.attention;
|
||||
});
|
||||
row.querySelectorAll("[data-account-pending]").forEach((node) => {
|
||||
node.textContent = account.pending;
|
||||
});
|
||||
row.querySelectorAll("[data-account-progress]").forEach((node) => {
|
||||
const pct = account.total
|
||||
? Math.round((account.confirmed / account.total) * 100)
|
||||
: 0;
|
||||
node.style.width = `${pct}%`;
|
||||
});
|
||||
row.querySelectorAll("[data-account-progress-text]").forEach((node) => {
|
||||
node.textContent = `${account.confirmed}/${account.total}`;
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const updateActions = (summary, running) => {
|
||||
const counts = {
|
||||
attention: summary.attention,
|
||||
pending: summary.pending + summary.unprocessed,
|
||||
total: summary.total,
|
||||
};
|
||||
document.querySelectorAll("[data-action-count-source]").forEach((button) => {
|
||||
const count = counts[button.dataset.actionCountSource] || 0;
|
||||
button.disabled = running || count <= 0;
|
||||
const countNode = button.querySelector("[data-action-count]");
|
||||
if (countNode) countNode.textContent = count;
|
||||
});
|
||||
document.querySelectorAll("[data-disable-while-running]").forEach((button) => {
|
||||
if (!button.dataset.actionCountSource) {
|
||||
button.disabled = running;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const render = (data) => {
|
||||
const summary = data.summary || {};
|
||||
const task = data.task || {};
|
||||
updateTaskBanner(task);
|
||||
setText("[data-overview-value='total']", summary.total || 0);
|
||||
setText("[data-overview-value='confirmed']", summary.confirmed || 0);
|
||||
setText("[data-overview-value='attention']", summary.attention || 0);
|
||||
setText(
|
||||
"[data-overview-value='pending']",
|
||||
(summary.pending || 0) + (summary.unprocessed || 0),
|
||||
);
|
||||
setText("[data-overview-value='remaining']", summary.remaining || 0);
|
||||
setText(
|
||||
"[data-overview-value='progress']",
|
||||
`${summary.confirmed || 0}/${summary.total || 0}`,
|
||||
);
|
||||
setText(
|
||||
"[data-overview-value='progressPercent']",
|
||||
summary.total
|
||||
? `${Math.round((summary.confirmed / summary.total) * 100)}%`
|
||||
: "0%",
|
||||
);
|
||||
setText(
|
||||
"[data-overview-value='lastConfirmedAt']",
|
||||
formatTime(summary.lastConfirmedAt),
|
||||
);
|
||||
setText(
|
||||
"[data-overview-value='nextTriggerAt']",
|
||||
formatTime(data.schedule?.nextTriggerAt),
|
||||
);
|
||||
setText(
|
||||
"[data-overview-value='scheduleLabel']",
|
||||
data.schedule?.label || "-",
|
||||
);
|
||||
updateAccounts(data.accounts || []);
|
||||
updateActions(summary, Boolean(task.running));
|
||||
|
||||
if (previousRunning === true && !task.running) {
|
||||
document
|
||||
.querySelectorAll("[data-refresh-notice]")
|
||||
.forEach((node) => node.classList.add("visible"));
|
||||
}
|
||||
previousRunning = Boolean(task.running);
|
||||
document.querySelectorAll("[data-overview-live-state]").forEach((node) => {
|
||||
node.textContent = "实时";
|
||||
node.classList.remove("poll-stale");
|
||||
});
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
try {
|
||||
const response = await fetch("/api/ops/overview", {
|
||||
credentials: "same-origin",
|
||||
headers: { Accept: "application/json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
render(await response.json());
|
||||
} catch {
|
||||
document.querySelectorAll("[data-overview-live-state]").forEach((node) => {
|
||||
node.textContent = "更新延迟";
|
||||
node.classList.add("poll-stale");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
document.querySelectorAll("[data-refresh-page]").forEach((button) => {
|
||||
button.addEventListener("click", () => window.location.reload());
|
||||
});
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "visible") refresh();
|
||||
});
|
||||
refresh();
|
||||
timer = window.setInterval(refresh, 10000);
|
||||
window.addEventListener("pagehide", () => window.clearInterval(timer));
|
||||
})();
|
||||
|
||||
(() => {
|
||||
const root = document.getElementById("login-desktop-controls");
|
||||
if (!root) return;
|
||||
|
||||
const section = document.getElementById("interactive-login-section");
|
||||
const csrfToken = root.dataset.csrfToken || "";
|
||||
const publicUrl = root.dataset.publicUrl || "";
|
||||
const runtimeStateEl = document.getElementById("login-desktop-runtime-state");
|
||||
const statusTextEl = document.getElementById("login-desktop-status-text");
|
||||
const openButtons = document.querySelectorAll(".login-desktop-open");
|
||||
const saveButtons = document.querySelectorAll(".login-desktop-save");
|
||||
const resetButtons = document.querySelectorAll(".login-desktop-reset");
|
||||
const copyPublicUrlButton = document.getElementById("copy-public-url");
|
||||
const statusMap = {
|
||||
checking: document.getElementById("desktop-status-checking"),
|
||||
pending: document.getElementById("desktop-status-pending"),
|
||||
success: document.getElementById("desktop-status-success"),
|
||||
error: document.getElementById("desktop-status-error"),
|
||||
};
|
||||
const runtimeState = document.getElementById(
|
||||
"login-desktop-runtime-state",
|
||||
);
|
||||
const statusText = document.getElementById("login-desktop-status-text");
|
||||
const frame = document.querySelector("[data-login-frame]");
|
||||
let timer = null;
|
||||
|
||||
const setVisualStatus = (state) => {
|
||||
Object.values(statusMap).forEach((node) => {
|
||||
if (!node) return;
|
||||
node.style.opacity = "0.46";
|
||||
});
|
||||
if (statusMap[state]) {
|
||||
statusMap[state].style.opacity = "1";
|
||||
const setStatus = (text, tone = "") => {
|
||||
if (statusText) statusText.textContent = text;
|
||||
if (runtimeState) {
|
||||
runtimeState.className = `pill${tone ? ` ${tone}` : ""}`;
|
||||
runtimeState.textContent =
|
||||
tone === "success" ? "已登录" : tone === "danger" ? "异常" : "待登录";
|
||||
}
|
||||
};
|
||||
|
||||
const setStatus = (text, tone = "", state = "checking") => {
|
||||
if (statusTextEl) statusTextEl.textContent = text;
|
||||
if (runtimeStateEl) {
|
||||
runtimeStateEl.className = `pill${tone ? ` ${tone}` : ""}`;
|
||||
}
|
||||
setVisualStatus(state);
|
||||
};
|
||||
|
||||
const postForm = async (url, payload = {}) => {
|
||||
const formData = new FormData();
|
||||
formData.set("csrf_token", csrfToken);
|
||||
@@ -151,284 +373,185 @@
|
||||
return data;
|
||||
};
|
||||
|
||||
const openDesktopWindow = () => {
|
||||
const popup = window.open(publicUrl, "_blank");
|
||||
if (!popup) {
|
||||
setStatus(
|
||||
"浏览器阻止了登录工作区弹窗,请允许弹窗后再试。",
|
||||
"danger",
|
||||
"error",
|
||||
);
|
||||
return false;
|
||||
const loadFrame = () => {
|
||||
if (frame && frame.dataset.loaded !== "1" && frame.dataset.src) {
|
||||
frame.src = frame.dataset.src;
|
||||
frame.dataset.loaded = "1";
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const pollStatus = async () => {
|
||||
if (document.visibilityState !== "visible" || (section && !section.open)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch("/login-desktop/status", {
|
||||
credentials: "same-origin",
|
||||
cache: "no-store",
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || data.ok === false) {
|
||||
if (runtimeStateEl) runtimeStateEl.textContent = "不可用";
|
||||
setStatus(
|
||||
data.error || "登录工作区不可用,请检查 login-desktop 服务。",
|
||||
"warning",
|
||||
"error",
|
||||
"danger",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (runtimeStateEl)
|
||||
runtimeStateEl.textContent = data.logged_in ? "已登录" : "待登录";
|
||||
if (data.logged_in) {
|
||||
setStatus(
|
||||
`当前浏览器已登录:${data.username}(${data.unique_id})`,
|
||||
"success",
|
||||
"success",
|
||||
);
|
||||
setStatus(`当前浏览器已登录:${data.username}`, "success");
|
||||
} else {
|
||||
setStatus(
|
||||
"当前浏览器尚未登录,可打开登录工作区开始人工登录。",
|
||||
"",
|
||||
"pending",
|
||||
);
|
||||
setStatus("当前浏览器尚未登录,可打开工作区开始人工登录。");
|
||||
}
|
||||
} catch (error) {
|
||||
if (runtimeStateEl) runtimeStateEl.textContent = "异常";
|
||||
setStatus(`登录工作区状态检查失败:${error.message}`, "danger", "error");
|
||||
setStatus(`状态检查失败:${error.message}`, "danger");
|
||||
}
|
||||
};
|
||||
|
||||
copyPublicUrlButton?.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(publicUrl);
|
||||
setStatus("登录工作区地址已复制。", "success", "pending");
|
||||
} catch (error) {
|
||||
setStatus(`复制失败:${error.message}`, "warning", "error");
|
||||
}
|
||||
});
|
||||
|
||||
openButtons.forEach((button) => {
|
||||
document.querySelectorAll(".login-desktop-open").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const reloginUniqueId = String(
|
||||
button.dataset.reloginUniqueId || "",
|
||||
).trim();
|
||||
const accountName = String(button.dataset.accountName || "").trim();
|
||||
try {
|
||||
await postForm("/login-desktop/open");
|
||||
openDesktopWindow();
|
||||
setStatus(
|
||||
reloginUniqueId
|
||||
? `请使用账号 ${accountName || reloginUniqueId} 完成登录,然后保存登录态。`
|
||||
: "请在远端浏览器中完成抖音创作者中心登录,然后保存账号。",
|
||||
"",
|
||||
"pending",
|
||||
);
|
||||
loadFrame();
|
||||
window.open(publicUrl, "_blank", "noopener");
|
||||
setStatus("请在登录工作区完成登录,然后保存登录态。");
|
||||
} catch (error) {
|
||||
setStatus(`打开登录工作区失败:${error.message}`, "danger", "error");
|
||||
setStatus(`打开登录工作区失败:${error.message}`, "danger");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
saveButtons.forEach((button) => {
|
||||
document.querySelectorAll(".login-desktop-save").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const reloginUniqueId = String(
|
||||
button.dataset.reloginUniqueId || "",
|
||||
).trim();
|
||||
const accountName = String(button.dataset.accountName || "").trim();
|
||||
try {
|
||||
const data = await postForm("/login-desktop/save", {
|
||||
relogin_unique_id: reloginUniqueId,
|
||||
relogin_unique_id: button.dataset.reloginUniqueId || "",
|
||||
});
|
||||
setStatus(
|
||||
reloginUniqueId
|
||||
? `已把当前浏览器登录保存到账号:${accountName || reloginUniqueId}`
|
||||
: `已保存当前登录账号:${data.account?.username || ""}`,
|
||||
"success",
|
||||
"success",
|
||||
);
|
||||
window.setTimeout(() => window.location.reload(), 900);
|
||||
setStatus(`已保存登录账号:${data.account?.username || ""}`, "success");
|
||||
window.setTimeout(() => window.location.reload(), 800);
|
||||
} catch (error) {
|
||||
setStatus(`保存当前登录账号失败:${error.message}`, "danger", "error");
|
||||
setStatus(`保存登录账号失败:${error.message}`, "danger");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
resetButtons.forEach((button) => {
|
||||
document.querySelectorAll(".login-desktop-reset").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
try {
|
||||
await postForm("/login-desktop/reset");
|
||||
setStatus(
|
||||
"登录工作区已重置,正在重新初始化浏览器。",
|
||||
"warning",
|
||||
"checking",
|
||||
);
|
||||
setStatus("登录工作区已重置,正在重新初始化。");
|
||||
await pollStatus();
|
||||
} catch (error) {
|
||||
setStatus(`重置登录工作区失败:${error.message}`, "danger", "error");
|
||||
setStatus(`重置登录工作区失败:${error.message}`, "danger");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
setVisualStatus("checking");
|
||||
pollStatus();
|
||||
window.setInterval(pollStatus, 5000);
|
||||
document.querySelectorAll("[data-copy-login-url]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(publicUrl);
|
||||
setStatus("登录工作区地址已复制。", "success");
|
||||
} catch (error) {
|
||||
setStatus(`复制失败:${error.message}`, "danger");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (section) {
|
||||
section.addEventListener("toggle", () => {
|
||||
if (section.open) {
|
||||
loadFrame();
|
||||
pollStatus();
|
||||
}
|
||||
});
|
||||
}
|
||||
timer = window.setInterval(pollStatus, 5000);
|
||||
window.addEventListener("pagehide", () => window.clearInterval(timer));
|
||||
})();
|
||||
|
||||
(() => {
|
||||
const pickers = document.querySelectorAll(".friend-picker");
|
||||
if (!pickers.length) return;
|
||||
|
||||
const parseJsonScript = (id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return [];
|
||||
const parseJson = (id) => {
|
||||
const node = document.getElementById(id);
|
||||
if (!node) return [];
|
||||
try {
|
||||
return JSON.parse(el.textContent || "[]");
|
||||
} catch (error) {
|
||||
console.error("Failed to parse friend picker JSON", id, error);
|
||||
return JSON.parse(node.textContent || "[]");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const escapeHtml = (value) =>
|
||||
String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
|
||||
pickers.forEach((picker) => {
|
||||
document.querySelectorAll(".friend-picker").forEach((picker) => {
|
||||
const accountId = picker.dataset.accountId;
|
||||
const refreshUrl = picker.dataset.refreshUrl;
|
||||
const csrfToken = picker.dataset.csrfToken;
|
||||
const searchInput = picker.querySelector(".friend-search-input");
|
||||
const form = picker.closest("form");
|
||||
const textarea = form?.querySelector(".targets-textarea");
|
||||
const search = picker.querySelector(".friend-search-input");
|
||||
const refreshButton = picker.querySelector(".friend-refresh-button");
|
||||
const listEl = picker.querySelector(".friend-picker-list");
|
||||
const summaryEl = picker.querySelector(".friend-picker-summary");
|
||||
const statusEl = picker.querySelector(".friend-picker-status");
|
||||
const hiddenInputsEl = picker.querySelector(".friend-selected-inputs");
|
||||
const formEl = picker.closest("form");
|
||||
const targetsTextarea = formEl?.querySelector(".targets-textarea");
|
||||
const currentTargetsEl = picker.querySelector(
|
||||
".friend-picker-current-targets span",
|
||||
);
|
||||
const list = picker.querySelector(".friend-picker-list");
|
||||
const summary = picker.querySelector(".friend-picker-summary");
|
||||
const status = picker.querySelector(".friend-picker-status");
|
||||
let friends = parseJson(`friends-cache-${accountId}`);
|
||||
let selected = new Set(parseJson(`selected-targets-${accountId}`));
|
||||
|
||||
let friends = parseJsonScript(`friends-cache-${accountId}`);
|
||||
let selected = new Set(parseJsonScript(`selected-targets-${accountId}`));
|
||||
const parseTargets = (value) =>
|
||||
[...new Set(
|
||||
String(value || "")
|
||||
.replaceAll(",", "\n")
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
)];
|
||||
|
||||
const splitTargetText = (value) => {
|
||||
const seen = new Set();
|
||||
return String(value || "")
|
||||
.replaceAll(",", "\n")
|
||||
.split(/\r?\n/)
|
||||
.map((name) => name.trim())
|
||||
.filter((name) => {
|
||||
if (!name || seen.has(name)) return false;
|
||||
seen.add(name);
|
||||
return true;
|
||||
});
|
||||
const combined = () => [...new Set([...selected, ...friends])];
|
||||
|
||||
const syncTextarea = () => {
|
||||
if (textarea) textarea.value = [...selected].join("\n");
|
||||
};
|
||||
|
||||
const combinedFriends = () => {
|
||||
const merged = [];
|
||||
const seen = new Set();
|
||||
[...selected, ...friends].forEach((name) => {
|
||||
if (!name || seen.has(name)) return;
|
||||
seen.add(name);
|
||||
merged.push(name);
|
||||
});
|
||||
return merged;
|
||||
};
|
||||
|
||||
const syncTextareaFromSelected = () => {
|
||||
if (targetsTextarea) {
|
||||
targetsTextarea.value = [...selected].join("\n");
|
||||
}
|
||||
};
|
||||
|
||||
const syncSelectedFromTextarea = () => {
|
||||
if (targetsTextarea) {
|
||||
selected = new Set(splitTargetText(targetsTextarea.value));
|
||||
}
|
||||
};
|
||||
|
||||
const renderHiddenInputs = () => {
|
||||
hiddenInputsEl.innerHTML = "";
|
||||
[...selected].forEach((name) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = "targets";
|
||||
input.value = name;
|
||||
hiddenInputsEl.appendChild(input);
|
||||
});
|
||||
};
|
||||
|
||||
const updateSummary = () => {
|
||||
summaryEl.textContent = `已选 ${selected.size} 人`;
|
||||
if (currentTargetsEl) {
|
||||
currentTargetsEl.textContent = selected.size
|
||||
? [...selected].join("、")
|
||||
: "未选择";
|
||||
}
|
||||
};
|
||||
|
||||
const renderList = () => {
|
||||
const query = (searchInput.value || "").trim().toLowerCase();
|
||||
const allNames = combinedFriends();
|
||||
const displayNames = allNames.filter((name) =>
|
||||
const render = () => {
|
||||
const query = String(search?.value || "").trim().toLowerCase();
|
||||
const names = combined().filter((name) =>
|
||||
name.toLowerCase().includes(query),
|
||||
);
|
||||
renderHiddenInputs();
|
||||
updateSummary();
|
||||
|
||||
if (!allNames.length) {
|
||||
listEl.innerHTML =
|
||||
'<div class="friend-picker-empty">点击“刷新好友列表”后再勾选目标好友。</div>';
|
||||
if (summary) summary.textContent = `已选 ${selected.size} 人`;
|
||||
list.innerHTML = "";
|
||||
if (!names.length) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "friend-picker-empty";
|
||||
empty.textContent = combined().length
|
||||
? "没有匹配的好友。"
|
||||
: "点击“刷新好友列表”后再选择目标。";
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!displayNames.length) {
|
||||
listEl.innerHTML =
|
||||
'<div class="friend-picker-empty">没有匹配的好友。</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = displayNames
|
||||
.map(
|
||||
(name) => `
|
||||
<label class="friend-option ${selected.has(name) ? "selected" : ""}">
|
||||
<span>${escapeHtml(name)}</span>
|
||||
<input type="checkbox" value="${escapeHtml(name)}" ${selected.has(name) ? "checked" : ""}>
|
||||
</label>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
listEl.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => {
|
||||
names.forEach((name) => {
|
||||
const label = document.createElement("label");
|
||||
label.className = `friend-option${selected.has(name) ? " selected" : ""}`;
|
||||
const text = document.createElement("span");
|
||||
text.textContent = name;
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.checked = selected.has(name);
|
||||
checkbox.addEventListener("change", () => {
|
||||
const option = checkbox.closest(".friend-option");
|
||||
const value = checkbox.value;
|
||||
if (checkbox.checked) {
|
||||
selected.add(value);
|
||||
option?.classList.add("selected");
|
||||
} else {
|
||||
selected.delete(value);
|
||||
option?.classList.remove("selected");
|
||||
}
|
||||
syncTextareaFromSelected();
|
||||
renderHiddenInputs();
|
||||
updateSummary();
|
||||
if (checkbox.checked) selected.add(name);
|
||||
else selected.delete(name);
|
||||
syncTextarea();
|
||||
render();
|
||||
});
|
||||
label.append(text, checkbox);
|
||||
list.appendChild(label);
|
||||
});
|
||||
};
|
||||
|
||||
refreshButton.addEventListener("click", async () => {
|
||||
textarea?.addEventListener("input", () => {
|
||||
selected = new Set(parseTargets(textarea.value));
|
||||
render();
|
||||
});
|
||||
search?.addEventListener("input", render);
|
||||
refreshButton?.addEventListener("click", async () => {
|
||||
refreshButton.disabled = true;
|
||||
const originalText = refreshButton.textContent;
|
||||
refreshButton.textContent = "刷新中...";
|
||||
statusEl.textContent = "正在读取好友列表...";
|
||||
if (status) status.textContent = "正在读取好友列表...";
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.set("csrf_token", csrfToken);
|
||||
@@ -437,29 +560,23 @@
|
||||
body: formData,
|
||||
credentials: "same-origin",
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || "刷新好友列表失败");
|
||||
}
|
||||
friends = Array.isArray(payload.friends) ? payload.friends : [];
|
||||
statusEl.textContent =
|
||||
payload.message || `已刷新 ${friends.length} 个好友`;
|
||||
renderList();
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || "刷新失败");
|
||||
friends = data.friends || [];
|
||||
if (status) status.textContent = data.message || "好友列表已刷新";
|
||||
render();
|
||||
} catch (error) {
|
||||
statusEl.textContent = error.message || "刷新好友列表失败";
|
||||
if (status) status.textContent = `刷新失败:${error.message}`;
|
||||
} finally {
|
||||
refreshButton.disabled = false;
|
||||
refreshButton.textContent = originalText;
|
||||
}
|
||||
});
|
||||
|
||||
searchInput.addEventListener("input", renderList);
|
||||
targetsTextarea?.addEventListener("input", () => {
|
||||
syncSelectedFromTextarea();
|
||||
renderList();
|
||||
});
|
||||
|
||||
syncSelectedFromTextarea();
|
||||
renderList();
|
||||
render();
|
||||
});
|
||||
})();
|
||||
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons({ attrs: { "aria-hidden": "true" } });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
ISC License
|
||||
|
||||
Copyright (c) 2026 Lucide Icons and Contributors
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
The following Lucide icons are derived from the Feather project:
|
||||
|
||||
airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
|
||||
|
||||
The MIT License (MIT) (for the icons listed above)
|
||||
|
||||
Copyright (c) 2013-present Cole Bemis
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+12
File diff suppressed because one or more lines are too long
@@ -1,183 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block nav_key %}accounts{% endblock %}
|
||||
{% block title %}账号与目标 | 自动续火花{% endblock %}
|
||||
{% block page_title %}账号与目标{% endblock %}
|
||||
{% block page_subtitle %}集中维护账号开关、目标好友、好友缓存和登录态同步{% endblock %}
|
||||
|
||||
{% block topbar_actions %}
|
||||
<a class="ghost-button" href="/login-workspace">登录工作区</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div
|
||||
id="login-desktop-controls"
|
||||
data-public-url="{{ login_desktop_public_url }}"
|
||||
data-csrf-token="{{ csrf_token }}"
|
||||
hidden
|
||||
></div>
|
||||
|
||||
{% if accounts %}
|
||||
<section class="account-list">
|
||||
{% for account in accounts %}
|
||||
<article class="account-card">
|
||||
<div class="account-head">
|
||||
<div class="account-ident">
|
||||
<div class="avatar-photo">
|
||||
{{ account.username[:1] if account.username else "A" }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="account-name">{{ account.username }}</div>
|
||||
<div class="account-sub">unique_id: {{ account.unique_id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="status-chip {% if account.enabled|default(true) %}success{% else %}warning{% endif %}"
|
||||
>
|
||||
{% if account.enabled|default(true) %}已启用{% else %}已停用{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="account-metrics">
|
||||
<div class="metric-box">
|
||||
<span class="label">Cookies</span
|
||||
><strong>{{ account.cookies|length }}</strong>
|
||||
</div>
|
||||
<div class="metric-box">
|
||||
<span class="label">目标好友</span
|
||||
><strong>{{ account.targets|length }}</strong>
|
||||
</div>
|
||||
<div class="metric-box">
|
||||
<span class="label">好友缓存</span
|
||||
><strong>{{ account.friends_cache|default([], true)|length }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
method="post"
|
||||
action="/accounts/{{ account.unique_id }}/update"
|
||||
class="stack-form"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<label>
|
||||
<span>显示名</span>
|
||||
<input type="text" name="username" value="{{ account.username }}" />
|
||||
</label>
|
||||
<label class="check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="enabled"
|
||||
{% if account.enabled|default(true) %}checked{% endif %}
|
||||
/>
|
||||
<span>启用自动续火花</span>
|
||||
</label>
|
||||
<label>
|
||||
<span>目标好友(每行一个)</span>
|
||||
<textarea class="targets-textarea" name="targets" rows="5">
|
||||
{{ account.targets|default([], true)|join('\n') }}</textarea
|
||||
>
|
||||
</label>
|
||||
|
||||
<div
|
||||
class="friend-picker"
|
||||
data-account-id="{{ account.unique_id }}"
|
||||
data-refresh-url="/accounts/{{ account.unique_id }}/friends/refresh"
|
||||
data-csrf-token="{{ csrf_token }}"
|
||||
>
|
||||
<div class="friend-picker-toolbar">
|
||||
<div>
|
||||
<span class="friend-picker-title">好友选择器</span>
|
||||
<p class="muted compact friend-picker-status">
|
||||
{% if account.friends_cache_updated_at %} 上次刷新:{{
|
||||
account.friends_cache_updated_at }} {% else %} 还没有读取好友列表
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="ghost-button friend-refresh-button">
|
||||
刷新好友列表
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<span>搜索好友</span>
|
||||
<input
|
||||
type="search"
|
||||
class="friend-search-input"
|
||||
placeholder="输入昵称筛选"
|
||||
/>
|
||||
</label>
|
||||
<p class="friend-picker-current-targets muted compact">
|
||||
<strong>当前目标好友:</strong>
|
||||
<span
|
||||
>{{ account.targets|join('、') if account.targets else '未选择'
|
||||
}}</span
|
||||
>
|
||||
</p>
|
||||
<p class="friend-picker-summary muted compact">已选 0 人</p>
|
||||
<div class="friend-selected-inputs"></div>
|
||||
<div class="friend-picker-list"></div>
|
||||
|
||||
<script
|
||||
type="application/json"
|
||||
id="friends-cache-{{ account.unique_id }}"
|
||||
>
|
||||
{{ account.friends_cache|default([], true)|tojson }}
|
||||
</script>
|
||||
<script
|
||||
type="application/json"
|
||||
id="selected-targets-{{ account.unique_id }}"
|
||||
>
|
||||
{{ account.targets|default([], true)|tojson }}
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<button type="submit">保存账号</button>
|
||||
</form>
|
||||
|
||||
<div class="button-row">
|
||||
<button
|
||||
type="button"
|
||||
class="ghost-button login-desktop-open"
|
||||
data-relogin-unique-id="{{ account.unique_id }}"
|
||||
data-account-name="{{ account.username }}"
|
||||
>
|
||||
打开登录工作区
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ghost-button login-desktop-save"
|
||||
data-relogin-unique-id="{{ account.unique_id }}"
|
||||
data-account-name="{{ account.username }}"
|
||||
>
|
||||
保存当前登录
|
||||
</button>
|
||||
<form
|
||||
method="post"
|
||||
action="/accounts/{{ account.unique_id }}/toggle-enabled"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<button
|
||||
class="{% if account.enabled|default(true) %}soft-button{% else %}success-button{% endif %}"
|
||||
type="submit"
|
||||
>
|
||||
{% if account.enabled|default(true) %}停用自动续火花{% else
|
||||
%}启用自动续火花{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
<form
|
||||
method="post"
|
||||
action="/accounts/{{ account.unique_id }}/delete"
|
||||
data-confirm="确认删除账号 {{ account.username }}?此操作会移除该账号配置。"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<button class="danger-button" type="submit">删除账号</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% else %}
|
||||
<section class="empty-state">
|
||||
当前没有账号。请先前往登录工作区完成扫码登录并保存账号。
|
||||
</section>
|
||||
{% endif %} {% endblock %}
|
||||
@@ -4,518 +4,134 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>{% block title %}续火花{% endblock %}</title>
|
||||
<style>
|
||||
/* ============ DARK (default) ============ */
|
||||
:root {
|
||||
--bg: #0a0e1c;
|
||||
--bg-glow: rgba(255, 77, 109, 0.10);
|
||||
--surface: #161e34;
|
||||
--surface-subtle: #1a2440;
|
||||
--surface-tint: rgba(255, 255, 255, 0.06);
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--border-strong: rgba(255, 255, 255, 0.14);
|
||||
--text: #eaf0ff;
|
||||
--text-main: #eaf0ff;
|
||||
--text-soft: #9aa6c8;
|
||||
--text-faint: #64709a;
|
||||
--primary: #ff7a3d;
|
||||
--primary-strong: #ff4d6d;
|
||||
--primary-deep: #ff2da8;
|
||||
--primary-soft: rgba(255, 122, 61, 0.14);
|
||||
--success: #2bd47f;
|
||||
--success-soft: rgba(43, 212, 127, 0.14);
|
||||
--danger: #ff5470;
|
||||
--danger-soft: rgba(255, 84, 112, 0.14);
|
||||
--warning: #ffb547;
|
||||
--warning-soft: rgba(255, 181, 71, 0.14);
|
||||
--info: #5b8bff;
|
||||
--info-soft: rgba(91, 139, 255, 0.16);
|
||||
--shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
|
||||
--shadow-soft: 0 10px 28px rgba(0, 0, 0, 0.30);
|
||||
--ring: 0 0 0 4px rgba(255, 122, 61, 0.22);
|
||||
--fire-grad: linear-gradient(135deg, #ffb04a 0%, #ff7a3d 30%, #ff4d6d 65%, #ff2da8 100%);
|
||||
--glow-fire: 0 18px 50px rgba(255, 77, 109, 0.28);
|
||||
--sidebar-bg: linear-gradient(200deg, rgba(22, 30, 52, 0.96) 0%, rgba(14, 20, 38, 0.96) 100%);
|
||||
--sidebar-text: rgba(255, 255, 255, 0.72);
|
||||
--sidebar-text-strong: #ffffff;
|
||||
--sidebar-faint: rgba(255, 255, 255, 0.42);
|
||||
--sidebar-hover: rgba(255, 255, 255, 0.06);
|
||||
--sidebar-active-bg: linear-gradient(135deg, rgba(255, 122, 61, 0.22), rgba(255, 45, 168, 0.10));
|
||||
--sidebar-active-border: rgba(255, 122, 61, 0.28);
|
||||
--panel-glass: rgba(22, 30, 52, 0.72);
|
||||
--glass-blur: blur(10px);
|
||||
--body-bg:
|
||||
radial-gradient(900px circle at 88% -8%, rgba(255, 77, 109, 0.16), transparent 50%),
|
||||
radial-gradient(760px circle at -6% 12%, rgba(91, 139, 255, 0.14), transparent 48%),
|
||||
linear-gradient(180deg, #0a0e1c 0%, #0b1120 100%);
|
||||
--title-grad: linear-gradient(120deg, #ffffff 0%, #ffd9c8 60%, #ff7a9d 120%);
|
||||
--th-bg: rgba(255, 255, 255, 0.04);
|
||||
--row-hover: rgba(255, 255, 255, 0.03);
|
||||
--table-border: rgba(255, 255, 255, 0.06);
|
||||
--input-bg: rgba(255, 255, 255, 0.04);
|
||||
--code-bg: #0c1426;
|
||||
--code-light-bg: rgba(255, 255, 255, 0.03);
|
||||
--empty-bg: rgba(255, 255, 255, 0.02);
|
||||
--radius-xl: 24px;
|
||||
--radius-lg: 20px;
|
||||
--radius-md: 16px;
|
||||
--radius-sm: 12px;
|
||||
}
|
||||
|
||||
/* ============ LIGHT ============ */
|
||||
[data-theme="light"] {
|
||||
--bg: #eef2f9;
|
||||
--bg-glow: rgba(255, 122, 61, 0.08);
|
||||
--surface: #ffffff;
|
||||
--surface-subtle: #f7faff;
|
||||
--surface-tint: #f0f4fb;
|
||||
--border: #e3e9f3;
|
||||
--border-strong: #d0d9e8;
|
||||
--text: #14213d;
|
||||
--text-main: #14213d;
|
||||
--text-soft: #566079;
|
||||
--text-faint: #8a96b3;
|
||||
--primary: #ff7a3d;
|
||||
--primary-strong: #ff4d6d;
|
||||
--primary-deep: #ff2da8;
|
||||
--primary-soft: rgba(255, 122, 61, 0.10);
|
||||
--success: #18a558;
|
||||
--success-soft: rgba(24, 165, 88, 0.14);
|
||||
--danger: #e5484a;
|
||||
--danger-soft: rgba(229, 72, 74, 0.12);
|
||||
--warning: #e08a0c;
|
||||
--warning-soft: rgba(224, 138, 12, 0.14);
|
||||
--info: #3a66ff;
|
||||
--info-soft: rgba(58, 102, 255, 0.12);
|
||||
--shadow: 0 20px 45px rgba(20, 33, 61, 0.10);
|
||||
--shadow-soft: 0 8px 24px rgba(20, 33, 61, 0.06);
|
||||
--ring: 0 0 0 4px rgba(255, 122, 61, 0.18);
|
||||
--glow-fire: 0 16px 40px rgba(255, 77, 109, 0.20);
|
||||
--sidebar-bg: #ffffff;
|
||||
--sidebar-text: #566079;
|
||||
--sidebar-text-strong: #14213d;
|
||||
--sidebar-faint: #8a96b3;
|
||||
--sidebar-hover: rgba(20, 33, 61, 0.04);
|
||||
--sidebar-active-bg: linear-gradient(135deg, rgba(255, 122, 61, 0.16), rgba(255, 45, 168, 0.06));
|
||||
--sidebar-active-border: rgba(255, 122, 61, 0.30);
|
||||
--panel-glass: #ffffff;
|
||||
--glass-blur: none;
|
||||
--body-bg:
|
||||
radial-gradient(900px circle at 88% -8%, rgba(255, 77, 109, 0.10), transparent 50%),
|
||||
radial-gradient(760px circle at -6% 12%, rgba(91, 139, 255, 0.10), transparent 48%),
|
||||
linear-gradient(180deg, #f1f5fc 0%, #e9eef7 100%);
|
||||
--title-grad: linear-gradient(120deg, #14213d 0%, #c45a3a 60%, #e83e7c 120%);
|
||||
--th-bg: #f3f6fc;
|
||||
--row-hover: rgba(58, 102, 255, 0.04);
|
||||
--table-border: #eaeff7;
|
||||
--input-bg: #ffffff;
|
||||
--code-bg: #0f1728;
|
||||
--code-light-bg: #f7f9fd;
|
||||
--empty-bg: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; min-height: 100%; }
|
||||
|
||||
body {
|
||||
font-family: "Inter", "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
background: var(--body-bg);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button, input, textarea, select { font: inherit; }
|
||||
::selection { background: rgba(255, 77, 109, 0.30); }
|
||||
|
||||
.main-area ::-webkit-scrollbar,
|
||||
.table-shell ::-webkit-scrollbar,
|
||||
.code-block::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
.main-area ::-webkit-scrollbar-thumb,
|
||||
.table-shell ::-webkit-scrollbar-thumb,
|
||||
.code-block::-webkit-scrollbar-thumb {
|
||||
background: var(--text-faint);
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.main-area ::-webkit-scrollbar-thumb:hover { opacity: 0.8; background-clip: padding-box; }
|
||||
|
||||
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 236px minmax(0, 1fr); }
|
||||
|
||||
/* ---------- sidebar ---------- */
|
||||
.sidebar {
|
||||
position: sticky; top: 0; height: 100vh;
|
||||
padding: 22px 14px 18px;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--sidebar-bg);
|
||||
color: var(--sidebar-text);
|
||||
display: flex; flex-direction: column; gap: 22px;
|
||||
backdrop-filter: var(--glass-blur);
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 12px; padding: 4px 6px 14px; border-bottom: 1px solid var(--border); }
|
||||
.brand-mark {
|
||||
width: 40px; height: 40px; border-radius: 13px;
|
||||
background: var(--fire-grad); color: #fff;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 19px; font-weight: 800;
|
||||
box-shadow: var(--glow-fire);
|
||||
}
|
||||
.brand-title { font-size: 16px; font-weight: 800; letter-spacing: 0.01em; color: var(--sidebar-text-strong); }
|
||||
.brand-subtitle { margin-top: 2px; font-size: 11.5px; color: var(--sidebar-faint); }
|
||||
.sidebar-section-title {
|
||||
margin: 0 10px 8px; font-size: 10.5px; color: var(--sidebar-faint);
|
||||
letter-spacing: 0.16em; text-transform: uppercase;
|
||||
}
|
||||
.nav-list { display: grid; gap: 4px; }
|
||||
.nav-item {
|
||||
position: relative; display: flex; align-items: center; gap: 12px;
|
||||
padding: 11px 14px; border-radius: 11px;
|
||||
color: var(--sidebar-text); font-size: 14px; font-weight: 600;
|
||||
transition: background 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
.nav-item:hover { background: var(--sidebar-hover); color: var(--sidebar-text-strong); }
|
||||
.nav-item.active {
|
||||
background: var(--sidebar-active-bg);
|
||||
color: var(--sidebar-text-strong); font-weight: 700;
|
||||
box-shadow: inset 0 0 0 1px var(--sidebar-active-border);
|
||||
}
|
||||
.nav-item.active::before {
|
||||
content: ""; position: absolute; left: 4px; top: 50%; transform: translateY(-50%);
|
||||
width: 3px; height: 18px; border-radius: 3px; background: var(--fire-grad);
|
||||
}
|
||||
.nav-icon { width: 18px; text-align: center; font-size: 14px; flex: 0 0 18px; opacity: 0.9; }
|
||||
|
||||
.sidebar-footer {
|
||||
margin-top: auto; padding: 14px; border-radius: 16px;
|
||||
background: var(--surface-tint); border: 1px solid var(--border);
|
||||
}
|
||||
.sidebar-footer-head { display: flex; align-items: center; gap: 10px; }
|
||||
.sidebar-footer-mark {
|
||||
width: 34px; height: 34px; border-radius: 10px;
|
||||
background: var(--fire-grad); color: #fff;
|
||||
display: inline-flex; align-items: center; justify-content: center; font-weight: 800;
|
||||
}
|
||||
.sidebar-footer-title { font-size: 13.5px; font-weight: 700; color: var(--sidebar-text-strong); }
|
||||
.sidebar-footer-subtitle { margin-top: 2px; font-size: 11.5px; color: var(--sidebar-faint); }
|
||||
.sidebar-logout {
|
||||
width: 100%; justify-content: center; display: flex; align-items: center; gap: 8px;
|
||||
border: 1px solid var(--border); color: var(--sidebar-text);
|
||||
background: transparent; box-shadow: none; padding: 9px;
|
||||
}
|
||||
.sidebar-logout:hover {
|
||||
border-color: rgba(255, 84, 112, 0.4); color: var(--danger);
|
||||
background: var(--danger-soft); transform: none; box-shadow: none; filter: none;
|
||||
}
|
||||
|
||||
/* ---------- main ---------- */
|
||||
.main-area { min-width: 0; padding: 24px 28px 36px; }
|
||||
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 24px; }
|
||||
.page-header__title { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; }
|
||||
.page-title {
|
||||
font-size: 28px; line-height: 1.12; font-weight: 800; letter-spacing: -0.02em;
|
||||
background: var(--title-grad);
|
||||
-webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
|
||||
}
|
||||
.page-subtitle { font-size: 14px; color: var(--text-soft); font-weight: 600; margin-top: 4px; }
|
||||
|
||||
.page-header__right { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
|
||||
.theme-toggle {
|
||||
width: 38px; height: 38px; border-radius: 11px; flex: 0 0 38px;
|
||||
border: 1px solid var(--border); background: var(--surface); color: var(--text-soft);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 17px; cursor: pointer; transition: 0.18s; padding: 0;
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
.theme-toggle:hover { color: var(--primary); border-color: var(--primary); transform: none; box-shadow: var(--shadow-soft); filter: none; }
|
||||
.theme-toggle .ico-moon { display: none }
|
||||
.theme-toggle .ico-sun { display: inline }
|
||||
[data-theme="light"] .theme-toggle .ico-moon { display: inline }
|
||||
[data-theme="light"] .theme-toggle .ico-sun { display: none }
|
||||
|
||||
.page-header__actions {
|
||||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
padding: 6px; border-radius: 14px;
|
||||
background: var(--panel-glass); border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-soft); backdrop-filter: var(--glass-blur);
|
||||
}
|
||||
.page-header__actions > form { margin: 0; }
|
||||
.page-header__actions button,
|
||||
.page-header__actions .link-button,
|
||||
.page-header__actions .ghost-button,
|
||||
.page-header__actions .soft-button {
|
||||
margin: 0; border-radius: 10px; padding: 9px 16px; font-size: 13.5px; font-weight: 700;
|
||||
box-shadow: none; background: transparent; color: var(--text-soft);
|
||||
border: 1px solid transparent; transform: none; filter: none;
|
||||
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.page-header__actions button:hover,
|
||||
.page-header__actions .link-button:hover,
|
||||
.page-header__actions .ghost-button:hover,
|
||||
.page-header__actions .soft-button:hover {
|
||||
transform: none; filter: none; box-shadow: none;
|
||||
background: var(--primary-soft); color: var(--primary); border-color: rgba(255, 122, 61, 0.22);
|
||||
}
|
||||
.page-header__actions button:active { transform: none; box-shadow: none; }
|
||||
.page-header__actions button[type="submit"]:not(.soft-button):not(.ghost-button):not(.danger-button):not(.success-button) {
|
||||
background: var(--fire-grad); color: #fff; border-color: transparent;
|
||||
box-shadow: 0 8px 18px rgba(255, 77, 109, 0.32);
|
||||
}
|
||||
.page-header__actions button[type="submit"]:not(.soft-button):not(.ghost-button):not(.danger-button):not(.success-button):hover {
|
||||
filter: brightness(1.06); color: #fff; border-color: transparent;
|
||||
box-shadow: 0 10px 22px rgba(255, 77, 109, 0.40); transform: none;
|
||||
}
|
||||
.page-header__actions button:disabled {
|
||||
background: transparent; color: var(--text-faint);
|
||||
border: 1px dashed var(--border-strong); box-shadow: none; cursor: not-allowed; opacity: 1;
|
||||
}
|
||||
|
||||
.page-body { display: grid; gap: 22px; }
|
||||
.stack { display: grid; gap: 22px; }
|
||||
|
||||
/* ---------- panel ---------- */
|
||||
.panel {
|
||||
background: var(--panel-glass); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg); box-shadow: var(--shadow);
|
||||
backdrop-filter: var(--glass-blur); padding: 22px;
|
||||
}
|
||||
.section-title-row,
|
||||
.panel-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
|
||||
.section-title-row h2, .panel h2, .panel h3, .panel h4 { margin: 0; }
|
||||
.muted { color: var(--text-soft); }
|
||||
.muted.compact { margin: 6px 0 0; font-size: 13px; line-height: 1.6; }
|
||||
|
||||
/* ---------- stat cards (kept for send_console/logs compat) ---------- */
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px; }
|
||||
.stat-card {
|
||||
min-height: 124px; border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border); background: var(--panel-glass);
|
||||
box-shadow: var(--shadow-soft); backdrop-filter: var(--glass-blur);
|
||||
padding: 20px 22px; display: flex; align-items: center; justify-content: space-between; gap: 14px;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
.stat-card:hover { transform: translateY(-2px); box-shadow: var(--shadow); border-color: var(--sidebar-active-border); }
|
||||
.stat-meta { display: grid; gap: 10px; }
|
||||
.stat-label { font-size: 14px; color: var(--text-soft); }
|
||||
.stat-value { font-size: 28px; line-height: 1; font-weight: 800; letter-spacing: -0.03em; }
|
||||
.stat-help { font-size: 13px; color: var(--text-soft); }
|
||||
.stat-help.positive { color: var(--success); }
|
||||
.stat-help.negative { color: var(--danger); }
|
||||
.stat-icon {
|
||||
width: 58px; height: 58px; border-radius: 50%;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 24px; font-weight: 700;
|
||||
}
|
||||
.stat-icon.blue { background: var(--info-soft); color: var(--info); }
|
||||
.stat-icon.green { background: var(--success-soft); color: var(--success); }
|
||||
.stat-icon.red { background: var(--danger-soft); color: var(--danger); }
|
||||
.stat-icon.orange { background: var(--warning-soft); color: var(--warning); }
|
||||
|
||||
.layout-grid { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 22px; align-items: start; }
|
||||
.overview-time { display: inline-flex; align-items: center; gap: 8px; color: var(--text-soft); font-size: 14px; font-weight: 600; }
|
||||
.overview-time::before {
|
||||
content: ""; width: 10px; height: 10px; border-radius: 50%;
|
||||
background: var(--fire-grad); box-shadow: 0 0 0 4px rgba(255, 122, 61, 0.14);
|
||||
}
|
||||
|
||||
/* ---------- pills / chips ---------- */
|
||||
.status-pill, .pill, .status-chip {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
border-radius: 999px; padding: 6px 12px; font-size: 12px; font-weight: 700;
|
||||
color: var(--text-soft); background: var(--surface-tint); border: 1px solid var(--border);
|
||||
}
|
||||
.pill.soft, .status-pill.soft, .status-chip.success { color: var(--success); background: var(--success-soft); }
|
||||
.pill.warning, .status-pill.warning, .status-chip.warning { color: var(--warning); background: var(--warning-soft); }
|
||||
.pill.danger, .status-pill.danger, .status-chip.danger { color: var(--danger); background: var(--danger-soft); }
|
||||
.status-chip.info { color: var(--info); background: var(--info-soft); }
|
||||
.status-line { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
|
||||
/* ---------- buttons ---------- */
|
||||
button, .link-button {
|
||||
appearance: none; border: none; border-radius: 12px;
|
||||
background: var(--fire-grad); color: #fff; cursor: pointer;
|
||||
padding: 11px 18px; font-weight: 700; letter-spacing: 0.01em;
|
||||
box-shadow: 0 10px 22px rgba(255, 77, 109, 0.26);
|
||||
transition: transform 0.16s ease, box-shadow 0.16s ease, opacity 0.16s ease, filter 0.16s ease;
|
||||
}
|
||||
button:hover, .link-button:hover { transform: translateY(-1px); box-shadow: 0 16px 30px rgba(255, 77, 109, 0.36); filter: brightness(1.05); }
|
||||
button:active, .link-button:active { transform: translateY(0); box-shadow: 0 8px 16px rgba(255, 77, 109, 0.24); }
|
||||
button:focus-visible, .link-button:focus-visible, .nav-item:focus-visible { outline: none; box-shadow: var(--ring); }
|
||||
button:disabled { cursor: not-allowed; opacity: 0.5; transform: none; box-shadow: none; filter: grayscale(0.3); }
|
||||
|
||||
.ghost-button, .soft-button, .danger-button, .success-button { background: var(--surface); box-shadow: none; }
|
||||
.ghost-button { color: var(--text); border: 1px solid var(--border-strong); }
|
||||
.ghost-button:hover { border-color: var(--primary); color: var(--primary); background: var(--primary-soft); box-shadow: none; transform: none; filter: none; }
|
||||
.soft-button { color: var(--primary); background: var(--primary-soft); border: 1px solid rgba(255, 122, 61, 0.20); }
|
||||
.soft-button:hover { background: rgba(255, 122, 61, 0.20); box-shadow: none; transform: none; filter: none; }
|
||||
.danger-button { color: var(--danger); border: 1px solid rgba(255, 84, 112, 0.26); background: var(--danger-soft); }
|
||||
.danger-button:hover { background: rgba(255, 84, 112, 0.20); box-shadow: none; transform: none; filter: none; }
|
||||
.success-button { color: var(--success); border: 1px solid rgba(43, 212, 127, 0.28); background: var(--success-soft); }
|
||||
.success-button:hover { background: rgba(43, 212, 127, 0.20); box-shadow: none; transform: none; filter: none; }
|
||||
|
||||
.button-row, .button-grid { display: grid; gap: 12px; }
|
||||
.button-row.two, .button-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
|
||||
/* ---------- forms ---------- */
|
||||
input[type="text"], input[type="password"], input[type="search"], input[type="number"], textarea, select {
|
||||
width: 100%; border: 1px solid var(--border-strong); border-radius: 12px;
|
||||
background: var(--input-bg); color: var(--text); padding: 11px 13px; outline: none;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
input:focus, textarea:focus, select:focus { border-color: var(--primary); box-shadow: var(--ring); }
|
||||
input::placeholder, textarea::placeholder { color: var(--text-faint); }
|
||||
textarea { min-height: 96px; resize: vertical; }
|
||||
.stack-form { display: grid; gap: 14px; }
|
||||
.stack-form label { display: grid; gap: 8px; }
|
||||
.stack-form span { font-size: 13px; color: var(--text-soft); }
|
||||
.check-row { display: flex !important; align-items: center; gap: 10px; }
|
||||
.check-row span { color: var(--text); }
|
||||
|
||||
/* ---------- flash ---------- */
|
||||
.flash { border-radius: 14px; padding: 14px 16px; border: 1px solid var(--border); box-shadow: var(--shadow-soft); background: var(--surface); }
|
||||
.flash.success { background: var(--success-soft); color: var(--success); border-color: rgba(43, 212, 127, 0.24); }
|
||||
.flash.warning { background: var(--warning-soft); color: var(--warning); border-color: rgba(255, 181, 71, 0.24); }
|
||||
.flash.error { background: var(--danger-soft); color: var(--danger); border-color: rgba(255, 84, 112, 0.24); }
|
||||
|
||||
/* ---------- tables ---------- */
|
||||
.table-shell { overflow: hidden; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); }
|
||||
.table-shell.scrollable { overflow: auto; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 12px 14px; border-bottom: 1px solid var(--table-border); text-align: left; font-size: 13px; vertical-align: middle; }
|
||||
th { background: var(--th-bg); color: var(--text-soft); font-weight: 700; letter-spacing: 0.01em; }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
tbody tr:hover td { background: var(--row-hover); }
|
||||
|
||||
/* ---------- code ---------- */
|
||||
.code-block {
|
||||
margin: 0; padding: 16px; border-radius: 14px;
|
||||
background: var(--code-bg); color: #cdd8f5; overflow: auto;
|
||||
font-size: 12px; line-height: 1.7;
|
||||
}
|
||||
.code-block.light { color: var(--text); background: var(--code-light-bg); border: 1px solid var(--border); }
|
||||
|
||||
.empty-state {
|
||||
padding: 34px 20px; text-align: center; border-radius: 16px;
|
||||
border: 1px dashed var(--border-strong); background: var(--empty-bg); color: var(--text-soft);
|
||||
}
|
||||
|
||||
/* ---------- task banner ---------- */
|
||||
.task-state-banner {
|
||||
margin: 0 0 16px; padding: 12px 14px; border-radius: 12px;
|
||||
border: 1px solid var(--border); font-size: 13px; font-weight: 700;
|
||||
}
|
||||
.task-state-banner.success { background: var(--success-soft); color: var(--success); }
|
||||
.task-state-banner.warning { background: var(--warning-soft); color: var(--warning); }
|
||||
.task-state-banner.info { background: var(--info-soft); color: var(--info); }
|
||||
|
||||
@media (max-width: 1380px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.layout-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 1080px) {
|
||||
.app-shell { grid-template-columns: 1fr; }
|
||||
.sidebar { display: none; }
|
||||
.main-area { padding: 18px 16px 28px; }
|
||||
.page-header { flex-direction: column; }
|
||||
.stats-grid, .button-grid, .button-row.two { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/app.css?v=20260710">
|
||||
</head>
|
||||
<body>
|
||||
<body data-page="{% block page_key %}dashboard{% endblock %}">
|
||||
<header class="mobile-topbar">
|
||||
<button class="icon-button" type="button" data-nav-toggle aria-label="打开导航" title="打开导航">
|
||||
<i data-lucide="menu"></i>
|
||||
</button>
|
||||
<a class="mobile-brand" href="/">
|
||||
<span class="brand-mark"><i data-lucide="flame"></i></span>
|
||||
<span>续火花</span>
|
||||
</a>
|
||||
<button class="icon-button" type="button" data-theme-toggle aria-label="切换主题" title="切换主题">
|
||||
<i data-lucide="sun" class="theme-light-icon"></i>
|
||||
<i data-lucide="moon" class="theme-dark-icon"></i>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="sidebar-backdrop" data-nav-close></div>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<div>
|
||||
<div class="brand">
|
||||
<div class="brand-mark">🔥</div>
|
||||
<div>
|
||||
<div class="brand-title">续火花</div>
|
||||
<div class="brand-subtitle">多账号控制平台</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="sidebar-section-title">导航</p>
|
||||
<nav class="nav-list">
|
||||
<a class="nav-item {% if current_nav|trim == 'dashboard' %}active{% endif %}" href="/">
|
||||
<span class="nav-icon">⌂</span><span>首页总览</span>
|
||||
</a>
|
||||
<a class="nav-item {% if current_nav|trim == 'send_console' %}active{% endif %}" href="/ops/send-console">
|
||||
<span class="nav-icon">✦</span><span>发送控制台</span>
|
||||
</a>
|
||||
<a class="nav-item {% if current_nav|trim == 'logs' %}active{% endif %}" href="/ops/logs">
|
||||
<span class="nav-icon">☰</span><span>发送记录</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<p class="sidebar-section-title" style="margin-top:18px;">管理</p>
|
||||
<nav class="nav-list">
|
||||
<a class="nav-item" href="/#account-management"><span class="nav-icon">◉</span><span>账号管理</span></a>
|
||||
<a class="nav-item" href="/#config-panel"><span class="nav-icon">▣</span><span>运行配置</span></a>
|
||||
<a class="nav-item" href="/#interactive-login-section"><span class="nav-icon">⚡</span><span>登录抖音账号</span></a>
|
||||
<a class="nav-item" href="/#ops-panel"><span class="nav-icon">☷</span><span>运维操作</span></a>
|
||||
<a class="nav-item" href="/#settings-panel"><span class="nav-icon">⚙</span><span>系统设置</span></a>
|
||||
</nav>
|
||||
<aside class="sidebar" id="app-sidebar" aria-label="主导航">
|
||||
<div class="sidebar-head">
|
||||
<a class="brand" href="/">
|
||||
<span class="brand-mark"><i data-lucide="flame"></i></span>
|
||||
<span>
|
||||
<strong class="brand-title">续火花</strong>
|
||||
<small class="brand-subtitle">多账号控制平台</small>
|
||||
</span>
|
||||
</a>
|
||||
<button class="icon-button sidebar-close" type="button" data-nav-close aria-label="关闭导航" title="关闭导航">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="sidebar-footer-head">
|
||||
<div class="sidebar-footer-mark">{{ (current_user or "A")[:1] }}</div>
|
||||
<div style="min-width:0;">
|
||||
<div class="sidebar-footer-title" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ current_user or "未登录" }}</div>
|
||||
<div class="sidebar-footer-subtitle">已登录管理员</div>
|
||||
<nav class="nav-groups">
|
||||
<section>
|
||||
<p class="nav-group-title">工作台</p>
|
||||
<div class="nav-group">
|
||||
<a class="nav-item {% if current_nav|trim == 'dashboard' %}active{% endif %}" href="/">
|
||||
<i data-lucide="layout-dashboard"></i><span>首页总览</span>
|
||||
</a>
|
||||
<a class="nav-item {% if current_nav|trim == 'send_console' %}active{% endif %}" href="/ops/send-console">
|
||||
<i data-lucide="send"></i><span>发送控制台</span>
|
||||
</a>
|
||||
<a class="nav-item {% if current_nav|trim == 'logs' %}active{% endif %}" href="/ops/logs">
|
||||
<i data-lucide="scroll-text"></i><span>发送记录</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p class="nav-group-title">管理</p>
|
||||
<div class="nav-group">
|
||||
<a class="nav-item" href="/#account-management">
|
||||
<i data-lucide="users"></i><span>账号管理</span>
|
||||
</a>
|
||||
<a class="nav-item" href="/#interactive-login-section">
|
||||
<i data-lucide="scan-line"></i><span>登录抖音账号</span>
|
||||
</a>
|
||||
<a class="nav-item" href="/#config-panel">
|
||||
<i data-lucide="sliders-horizontal"></i><span>运行配置</span>
|
||||
</a>
|
||||
<a class="nav-item" href="/#ops-panel">
|
||||
<i data-lucide="wrench"></i><span>运维操作</span>
|
||||
</a>
|
||||
<a class="nav-item" href="/#settings-panel">
|
||||
<i data-lucide="settings"></i><span>系统设置</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="user-summary">
|
||||
<span class="user-avatar">{{ (current_user or "A")[:1] }}</span>
|
||||
<span class="user-copy">
|
||||
<strong>{{ current_user or "未登录" }}</strong>
|
||||
<small>管理员</small>
|
||||
</span>
|
||||
</div>
|
||||
<form method="post" action="/logout" style="margin-top:14px;">
|
||||
<button type="submit" class="sidebar-logout"><span>⏻</span><span>退出登录</span></button>
|
||||
<form method="post" action="/logout">
|
||||
<button class="button button-quiet button-block" type="submit">
|
||||
<i data-lucide="log-out"></i><span>退出登录</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-area">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<div class="page-header__title">
|
||||
<div class="page-title">{% block page_title %}续火花{% endblock %}</div>
|
||||
</div>
|
||||
<div class="page-subtitle">{% block page_subtitle %}多账号发送控制平台{% endblock %}</div>
|
||||
<div class="page-heading">
|
||||
<h1>{% block page_title %}续火花{% endblock %}</h1>
|
||||
<p>{% block page_subtitle %}多账号发送控制平台{% endblock %}</p>
|
||||
</div>
|
||||
<div class="page-header__right">
|
||||
<button class="theme-toggle" id="themeToggle" title="切换白天/夜间模式" aria-label="切换主题">
|
||||
<span class="ico-sun">☀</span><span class="ico-moon">☾</span>
|
||||
<div class="page-header-actions">
|
||||
<button class="icon-button desktop-theme-toggle" type="button" data-theme-toggle aria-label="切换主题" title="切换主题">
|
||||
<i data-lucide="sun" class="theme-light-icon"></i>
|
||||
<i data-lucide="moon" class="theme-dark-icon"></i>
|
||||
</button>
|
||||
<div class="page-header__actions">
|
||||
<div class="action-bar">
|
||||
{% block topbar_actions %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{% if flash %}
|
||||
<div class="flash flash-{{ flash.level }}" role="status">{{ flash.message }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="page-body">
|
||||
{% if flash %}
|
||||
<div class="flash {{ flash.level }}">{{ flash.message }}</div>
|
||||
{% endif %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<dialog class="confirm-dialog" id="confirm-dialog" aria-labelledby="confirm-title">
|
||||
<div class="confirm-icon"><i data-lucide="triangle-alert"></i></div>
|
||||
<h2 id="confirm-title">确认操作</h2>
|
||||
<p id="confirm-message">请确认是否继续。</p>
|
||||
<div class="confirm-actions">
|
||||
<button class="button button-quiet" type="button" data-confirm-cancel>取消</button>
|
||||
<button class="button button-danger" type="button" data-confirm-accept>确认执行</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script defer src="/static/lucide.min.js?v=20260710"></script>
|
||||
<script defer src="/static/app.js?v=20260710"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
<script>
|
||||
(function(){
|
||||
var root = document.documentElement;
|
||||
var btn = document.getElementById('themeToggle');
|
||||
var saved = 'dark';
|
||||
try { saved = localStorage.getItem('sparkflow-theme') || 'dark'; } catch(e){}
|
||||
function apply(t){ root.setAttribute('data-theme', t === 'light' ? 'light' : 'dark'); try{ localStorage.setItem('sparkflow-theme', t); }catch(e){} }
|
||||
apply(saved);
|
||||
if (btn) btn.addEventListener('click', function(){
|
||||
var cur = root.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
|
||||
apply(cur === 'light' ? 'dark' : 'light');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,168 +1,83 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="zh-CN" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>登录 | 续火花控制台</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #ff7a3d;
|
||||
--primary-strong: #ff4d6d;
|
||||
--text: #eaf0ff;
|
||||
--text-soft: #9aa6c8;
|
||||
--border: rgba(255,255,255,0.12);
|
||||
--fire-grad: linear-gradient(135deg,#ffb04a 0%,#ff7a3d 30%,#ff4d6d 65%,#ff2da8 100%);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; min-height: 100%; }
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr);
|
||||
font-family: "Inter", "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ---- left brand panel ---- */
|
||||
.brand-panel {
|
||||
position: relative; overflow: hidden;
|
||||
padding: 48px 52px;
|
||||
color: #fff;
|
||||
background:
|
||||
radial-gradient(680px circle at 12% 12%, rgba(255,122,61,0.45), transparent 55%),
|
||||
radial-gradient(620px circle at 95% 92%, rgba(255,45,168,0.32), transparent 50%),
|
||||
linear-gradient(205deg, #1a1230 0%, #140e26 56%, #0c0a1a 100%);
|
||||
display: flex; flex-direction: column; justify-content: space-between; gap: 32px;
|
||||
}
|
||||
.brand-panel::after {
|
||||
content: ""; position: absolute; right: -120px; bottom: -120px;
|
||||
width: 360px; height: 360px; border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(255,77,109,0.32), transparent 68%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.brand-top { display: flex; align-items: center; gap: 14px; position: relative; z-index: 1; }
|
||||
.brand-mark {
|
||||
width: 46px; height: 46px; border-radius: 14px;
|
||||
background: var(--fire-grad); color: #fff;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 22px; font-weight: 800;
|
||||
box-shadow: 0 16px 32px rgba(255,77,109,0.45);
|
||||
}
|
||||
.brand-title { font-size: 18px; font-weight: 800; letter-spacing: 0.01em; }
|
||||
.brand-sub { margin-top: 3px; font-size: 12.5px; color: rgba(255,255,255,0.55); }
|
||||
|
||||
.brand-hero { position: relative; z-index: 1; max-width: 440px; }
|
||||
.brand-hero h1 {
|
||||
margin: 0 0 16px; font-size: 36px; line-height: 1.18; font-weight: 800; letter-spacing: -0.02em;
|
||||
}
|
||||
.brand-hero h1 em {
|
||||
font-style: normal;
|
||||
background: linear-gradient(120deg,#ffd9a0,#ff7a3d 50%,#ff2da8);
|
||||
-webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
|
||||
}
|
||||
.brand-hero p { margin: 0; font-size: 15px; line-height: 1.8; color: rgba(255,255,255,0.72); }
|
||||
.brand-features { position: relative; z-index: 1; display: grid; gap: 14px; margin-top: 8px; }
|
||||
.brand-feature { display: flex; align-items: center; gap: 12px; font-size: 13.5px; color: rgba(255,255,255,0.82); }
|
||||
.brand-feature i {
|
||||
font-style: normal; width: 26px; height: 26px; border-radius: 8px;
|
||||
background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.14);
|
||||
display: inline-flex; align-items: center; justify-content: center; font-size: 14px;
|
||||
}
|
||||
.brand-foot { position: relative; z-index: 1; font-size: 12px; color: rgba(255,255,255,0.4); }
|
||||
|
||||
/* ---- right form panel ---- */
|
||||
.form-panel {
|
||||
display: flex; align-items: center; justify-content: center; padding: 40px 28px;
|
||||
background:
|
||||
radial-gradient(720px circle at 100% -10%, rgba(255,122,61,0.10), transparent 45%),
|
||||
linear-gradient(180deg, #0a0e1c 0%, #0b1120 100%);
|
||||
}
|
||||
.card { width: min(94vw, 420px); }
|
||||
.card-title { margin: 0 0 6px; font-size: 24px; font-weight: 800; letter-spacing: -0.01em; color: #fff; }
|
||||
.card-lead { margin: 0 0 24px; color: var(--text-soft); line-height: 1.6; font-size: 14px; }
|
||||
form { display: grid; gap: 15px; }
|
||||
label { display: grid; gap: 7px; }
|
||||
label > span { font-size: 13px; color: var(--text-soft); font-weight: 600; }
|
||||
input {
|
||||
width: 100%; border: 1px solid var(--border); border-radius: 12px;
|
||||
padding: 12px 14px; color: var(--text); outline: none;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
background: rgba(255,255,255,0.04);
|
||||
}
|
||||
input::placeholder { color: var(--text-soft); }
|
||||
input:focus { border-color: var(--primary); box-shadow: 0 0 0 4px rgba(255,122,61,0.22); }
|
||||
button {
|
||||
margin-top: 4px; border: none; border-radius: 12px; padding: 13px 14px;
|
||||
background: var(--fire-grad); color: #fff; font-weight: 700; font-size: 15px; cursor: pointer;
|
||||
box-shadow: 0 14px 28px rgba(255,77,109,0.36);
|
||||
transition: transform 0.16s ease, box-shadow 0.16s ease, filter 0.16s ease;
|
||||
}
|
||||
button:hover { transform: translateY(-1px); box-shadow: 0 18px 34px rgba(255,77,109,0.44); filter: brightness(1.05); }
|
||||
button:active { transform: translateY(0); }
|
||||
.flash { margin-bottom: 16px; padding: 12px 14px; border-radius: 12px; font-size: 13px; border: 1px solid transparent; }
|
||||
.flash.success { background: rgba(43,212,127,0.14); color: #2bd47f; border-color: rgba(43,212,127,0.28); }
|
||||
.flash.warning { background: rgba(255,181,71,0.14); color: #ffb547; border-color: rgba(255,181,71,0.28); }
|
||||
.flash.error { background: rgba(255,84,112,0.14); color: #ff5470; border-color: rgba(255,84,112,0.28); }
|
||||
.foot { margin-top: 20px; text-align: center; font-size: 12px; color: #64709a; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
body { grid-template-columns: 1fr; }
|
||||
.brand-panel { padding: 32px 28px; }
|
||||
.brand-hero h1 { font-size: 26px; }
|
||||
.brand-features { display: none; }
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/app.css?v=20260710">
|
||||
</head>
|
||||
<body>
|
||||
<aside class="brand-panel">
|
||||
<div class="brand-top">
|
||||
<div class="brand-mark">🔥</div>
|
||||
<div>
|
||||
<div class="brand-title">续火花</div>
|
||||
<div class="brand-sub">多账号控制平台</div>
|
||||
</div>
|
||||
<body class="login-page">
|
||||
<aside class="login-brand-panel">
|
||||
<a class="brand" href="/login">
|
||||
<span class="brand-mark"><i data-lucide="flame"></i></span>
|
||||
<span>
|
||||
<strong class="brand-title">续火花</strong>
|
||||
<small class="brand-subtitle">多账号控制平台</small>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<div class="login-brand-copy">
|
||||
<h1>让每个好友<br><span>都不再断火花</span></h1>
|
||||
<p>集中查看多账号发送进度、异常目标和登录状态,在一个控制台完成续火花运维。</p>
|
||||
<ul class="login-features">
|
||||
<li><i data-lucide="calendar-clock"></i><span>分时调度与当日未发送兜底</span></li>
|
||||
<li><i data-lucide="badge-check"></i><span>服务端回执与强确认账本</span></li>
|
||||
<li><i data-lucide="scan-line"></i><span>网页内扫码登录与登录态同步</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="brand-hero">
|
||||
<h1>让每个好友<br><em>都不再断火花</em></h1>
|
||||
<p>集中管理抖音多账号、目标好友与自动续火花任务,定时发送、失败补发、登录同步一站式运维。</p>
|
||||
<div class="brand-features">
|
||||
<div class="brand-feature"><i>✦</i><span>多账号批量发送与定时调度</span></div>
|
||||
<div class="brand-feature"><i>↻</i><span>失败目标自动补发与重试队列</span></div>
|
||||
<div class="brand-feature"><i>🔥</i><span>网页内交互式登录与登录态同步</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="brand-foot">DouYin Spark Flow · Admin Console</div>
|
||||
<small class="muted">DouYin Spark Flow · Admin Console</small>
|
||||
</aside>
|
||||
|
||||
<main class="form-panel">
|
||||
<div class="card">
|
||||
<h2 class="card-title">控制台登录</h2>
|
||||
<p class="card-lead">用于管理抖音多账号、目标好友、自动续火花任务与运维配置。</p>
|
||||
<main class="login-form-panel">
|
||||
<section class="login-card">
|
||||
<h2>{% if bootstrapped %}控制台登录{% else %}初始化管理员{% endif %}</h2>
|
||||
<p>登录后可管理账号、目标好友、发送任务与运维配置。</p>
|
||||
|
||||
{% if flash %}
|
||||
<div class="flash {{ flash.level }}">{{ flash.message }}</div>
|
||||
<div class="flash flash-{{ flash.level }}" role="status">{{ flash.message }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not bootstrapped %}
|
||||
<form method="post" action="/bootstrap">
|
||||
<label><span>管理员用户名</span><input type="text" name="username" value="admin"></label>
|
||||
<label><span>管理员密码</span><input type="password" name="password"></label>
|
||||
<label><span>确认密码</span><input type="password" name="confirm_password"></label>
|
||||
<button type="submit">初始化管理员账号</button>
|
||||
<form method="post" action="/bootstrap" class="stack-form">
|
||||
<label>
|
||||
<span>管理员用户名</span>
|
||||
<input type="text" name="username" value="admin" autocomplete="username">
|
||||
</label>
|
||||
<label>
|
||||
<span>管理员密码</span>
|
||||
<input type="password" name="password" autocomplete="new-password">
|
||||
</label>
|
||||
<label>
|
||||
<span>确认密码</span>
|
||||
<input type="password" name="confirm_password" autocomplete="new-password">
|
||||
</label>
|
||||
<button class="button button-primary button-block" type="submit">
|
||||
<i data-lucide="shield-check"></i><span>初始化管理员账号</span>
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="/login">
|
||||
<label><span>管理员用户名</span><input type="text" name="username" value="admin"></label>
|
||||
<label><span>管理员密码</span><input type="password" name="password"></label>
|
||||
<button type="submit">登录控制台</button>
|
||||
<form method="post" action="/login" class="stack-form">
|
||||
<label>
|
||||
<span>管理员用户名</span>
|
||||
<input type="text" name="username" value="admin" autocomplete="username">
|
||||
</label>
|
||||
<label>
|
||||
<span>管理员密码</span>
|
||||
<input type="password" name="password" autocomplete="current-password">
|
||||
</label>
|
||||
<button class="button button-primary button-block" type="submit">
|
||||
<i data-lucide="log-in"></i><span>登录控制台</span>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<div class="foot">登录后可访问首页总览、发送控制台与系统设置</div>
|
||||
</div>
|
||||
<div class="login-foot">认证页面不会展示服务器凭据或登录态数据。</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script defer src="/static/lucide.min.js?v=20260710"></script>
|
||||
<script defer src="/static/app.js?v=20260710"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block nav_key %}login_workspace{% endblock %}
|
||||
{% block title %}登录工作区 | 自动续火花{% endblock %}
|
||||
{% block page_title %}登录工作区{% endblock %}
|
||||
{% block page_subtitle %}通过远端浏览器完成扫码、验证和登录态保存{% endblock %}
|
||||
|
||||
{% block topbar_actions %}
|
||||
<a class="ghost-button" href="/accounts">账号管理</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="login-workspace-grid">
|
||||
<aside class="stack">
|
||||
<section class="panel">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h2>登录流程</h2>
|
||||
<p class="muted compact">
|
||||
适合新增账号、登录态失效或需要人工接管验证码的场景。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-list">
|
||||
<div class="summary-row">
|
||||
<strong>1. 打开浏览器</strong><span>启动远端交互式桌面</span>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<strong>2. 扫码与验证</strong><span>在 noVNC 中完成人工步骤</span>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<strong>3. 保存登录态</strong><span>写入新账号或覆盖已有账号</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="panel"
|
||||
id="login-desktop-controls"
|
||||
data-public-url="{{ login_desktop_public_url }}"
|
||||
data-csrf-token="{{ csrf_token }}"
|
||||
>
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h2>桌面状态</h2>
|
||||
<p class="muted compact">后台会持续轮询登录工作区状态。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-list">
|
||||
<div class="status-item" id="desktop-status-checking">
|
||||
<span class="status-dot"></span><span>检查中</span>
|
||||
</div>
|
||||
<div class="status-item pending" id="desktop-status-pending">
|
||||
<span class="status-dot"></span><span>待登录</span>
|
||||
</div>
|
||||
<div class="status-item success" id="desktop-status-success">
|
||||
<span class="status-dot"></span><span>已登录</span>
|
||||
</div>
|
||||
<div class="status-item error" id="desktop-status-error">
|
||||
<span class="status-dot"></span><span>异常</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<div class="button-row space-below-sm">
|
||||
<span class="pill" id="login-desktop-runtime-state">检查中</span>
|
||||
</div>
|
||||
<div id="login-desktop-status-text">正在检查登录工作区状态。</div>
|
||||
</div>
|
||||
|
||||
<div class="button-row space-above-md">
|
||||
<button
|
||||
type="button"
|
||||
class="success-button login-desktop-open"
|
||||
data-relogin-unique-id=""
|
||||
>
|
||||
打开新窗口
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ghost-button login-desktop-reset"
|
||||
data-confirm="确认重置登录工作区?当前远端浏览器状态会被重置。"
|
||||
>
|
||||
重置桌面
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="soft-button login-desktop-save"
|
||||
data-relogin-unique-id=""
|
||||
>
|
||||
保存当前账号
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h2>浏览器工作区</h2>
|
||||
<p class="muted compact">
|
||||
如果内嵌 noVNC 无法连接,请使用新窗口打开或检查 login-desktop 服务。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="url-field space-below-sm">
|
||||
<input
|
||||
id="desktop-public-url"
|
||||
type="text"
|
||||
value="{{ login_desktop_public_url }}"
|
||||
readonly
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="ghost-button copy-button"
|
||||
id="copy-public-url"
|
||||
aria-label="复制地址"
|
||||
>
|
||||
⧉
|
||||
</button>
|
||||
<a
|
||||
class="soft-button"
|
||||
href="{{ login_desktop_public_url }}"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>新窗口打开</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="browser-frame-shell">
|
||||
<div class="browser-frame-toolbar">
|
||||
<span>noVNC 登录桌面</span>
|
||||
<span class="pill info">端口 8788</span>
|
||||
</div>
|
||||
<iframe
|
||||
class="desktop-frame"
|
||||
src="{{ login_desktop_public_url }}"
|
||||
title="交互式登录浏览器工作区"
|
||||
loading="lazy"
|
||||
></iframe>
|
||||
<div class="frame-help">
|
||||
连接失败时,请先点击“打开新窗口”;仍失败则重置桌面或检查容器状态。
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,334 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block nav_key %}settings{% endblock %}
|
||||
{% block title %}运行与系统 | 自动续火花{% endblock %}
|
||||
{% block page_title %}运行与系统{% endblock %}
|
||||
{% block page_subtitle %}发送参数、手动任务、代理维护和面板服务设置{% endblock %}
|
||||
|
||||
{% block topbar_actions %}
|
||||
<a class="ghost-button" href="/ops/logs">运行日志</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="settings-layout">
|
||||
<div class="settings-stack">
|
||||
<section class="panel">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h2>运行配置</h2>
|
||||
<p class="muted compact">
|
||||
消息模板、随机策略和发送间隔等核心任务参数。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/config" class="stack-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<label>
|
||||
<span>固定消息模板</span>
|
||||
<input
|
||||
type="text"
|
||||
name="messageTemplate"
|
||||
value="{{ runtime_config.messageTemplate }}"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>消息变体(每行一个)</span>
|
||||
<textarea name="messageVariants" rows="5">
|
||||
{{ runtime_config.sendStrategy.messageVariants|join('\n') }}</textarea
|
||||
>
|
||||
</label>
|
||||
<label>
|
||||
<span>是否随机打乱目标顺序</span>
|
||||
<select name="shuffleTargets">
|
||||
<option
|
||||
value="on"
|
||||
{%
|
||||
if
|
||||
runtime_config.sendStrategy.shuffleTargets
|
||||
%}selected{%
|
||||
endif
|
||||
%}
|
||||
>
|
||||
是
|
||||
</option>
|
||||
<option
|
||||
value=""
|
||||
{%
|
||||
if
|
||||
not
|
||||
runtime_config.sendStrategy.shuffleTargets
|
||||
%}selected{%
|
||||
endif
|
||||
%}
|
||||
>
|
||||
否
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="settings-grid">
|
||||
<label>
|
||||
<span>消息最小间隔(秒)</span>
|
||||
<input
|
||||
type="number"
|
||||
name="messageIntervalSecondsMin"
|
||||
min="0"
|
||||
value="{{ runtime_config.sendStrategy.messageIntervalSecondsMin }}"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>消息最大间隔(秒)</span>
|
||||
<input
|
||||
type="number"
|
||||
name="messageIntervalSecondsMax"
|
||||
min="0"
|
||||
value="{{ runtime_config.sendStrategy.messageIntervalSecondsMax }}"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit">保存运行配置</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h2>面板与服务设置</h2>
|
||||
<p class="muted compact">
|
||||
服务连接、日志路径、交互式登录 API 与管理员配置。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/settings" class="stack-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<div class="settings-grid">
|
||||
<label>
|
||||
<span>服务器 Host</span>
|
||||
<input
|
||||
type="text"
|
||||
name="server_host"
|
||||
value="{{ app_settings.server_host }}"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>服务器用户名</span>
|
||||
<input
|
||||
type="text"
|
||||
name="server_username"
|
||||
value="{{ app_settings.server_username }}"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>服务器密码</span>
|
||||
<input
|
||||
type="password"
|
||||
name="server_password"
|
||||
value="{{ app_settings.server_password }}"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Compose 根目录</span>
|
||||
<input
|
||||
type="text"
|
||||
name="compose_root"
|
||||
value="{{ app_settings.compose_root }}"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>任务日志文件</span>
|
||||
<input
|
||||
type="text"
|
||||
name="ops_log_file"
|
||||
value="{{ app_settings.ops_log_file }}"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>代理刷新脚本</span>
|
||||
<input
|
||||
type="text"
|
||||
name="proxy_refresh_script"
|
||||
value="{{ app_settings.proxy_refresh_script }}"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>登录桌面 API 地址</span>
|
||||
<input
|
||||
type="text"
|
||||
name="login_desktop_api_url"
|
||||
value="{{ app_settings.login_desktop_api_url }}"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Web UI 端口</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
name="ui_port"
|
||||
value="{{ app_settings.ui_port }}"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>修改管理员密码</span>
|
||||
<input
|
||||
type="password"
|
||||
name="new_password"
|
||||
placeholder="留空表示不修改"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>确认新密码</span>
|
||||
<input
|
||||
type="password"
|
||||
name="confirm_password"
|
||||
placeholder="再次输入新密码"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit">保存系统设置</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h2>容器与调度</h2>
|
||||
<p class="muted compact">用于确认部署服务状态和当前调度任务。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-grid">
|
||||
<div class="table-shell">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<th>Image</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in ops.containers %}
|
||||
<tr>
|
||||
<td>{{ row.Names }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="pill {% if 'Up' in row.Status %}success{% else %}warning{% endif %}"
|
||||
>{{ row.Status }}</span
|
||||
>
|
||||
</td>
|
||||
<td>{{ row.Image }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3">当前没有可见容器状态。</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<pre class="code-block">
|
||||
{{ ops.crontab or "当前没有 crontab 任务。" }}</pre
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="settings-stack">
|
||||
<section class="panel">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h2>手动任务</h2>
|
||||
<p class="muted compact">
|
||||
批量发送动作会影响多个账号,执行前请确认目标范围。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="link-list">
|
||||
<form method="post" action="/ops/run-unsent">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<button class="soft-button" type="submit">补发未成功目标</button>
|
||||
</form>
|
||||
<form
|
||||
method="post"
|
||||
action="/ops/run-now"
|
||||
data-confirm="补发全部对象会对所有启用账号的全部目标重新发送一遍,确认继续?"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<button class="danger-button" type="submit">补发全部对象</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h2>代理维护</h2>
|
||||
<p class="muted compact">刷新订阅或重启代理容器。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="link-list">
|
||||
<form method="post" action="/ops/proxy/refresh">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<button class="ghost-button" type="submit">刷新代理订阅</button>
|
||||
</form>
|
||||
<form
|
||||
method="post"
|
||||
action="/ops/proxy/restart"
|
||||
data-confirm="确认重启代理容器?重启期间发送任务可能短暂受影响。"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<button class="danger-button" type="submit">重启代理容器</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h2>发送窗口</h2>
|
||||
<p class="muted compact">北京时间,例如 10:00-18:00/10m。</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/ops/schedule" class="stack-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<label>
|
||||
<span>当前发送窗口</span>
|
||||
<input
|
||||
type="text"
|
||||
name="daily_schedule"
|
||||
value="{{ ops.daily_schedule }}"
|
||||
placeholder="10:00-18:00/10m"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" class="ghost-button">更新发送窗口</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h2>部署路径</h2>
|
||||
<p class="muted compact">只读展示当前检测到的 Compose 信息。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stack-form">
|
||||
<label>
|
||||
<span>Compose 根目录</span>
|
||||
<input type="text" value="{{ ops.compose_root }}" readonly />
|
||||
</label>
|
||||
<label>
|
||||
<span>Compose 文件路径</span>
|
||||
<input
|
||||
type="text"
|
||||
value="{{ ops.compose_file or '未检测到' }}"
|
||||
readonly
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user