feat(desktop): add monitor scheduler, Playwright CDP layer, and account health

Move monitor-task scheduling authority onto the client with a durable
file-backed queue that survives restart, drops stale cross-day tasks,
enforces per-platform serialism, and adapts global concurrency from
Electron process metrics. Publish tasks keep their existing FIFO.

Add a hidden Playwright CDP manager that attaches to Electron Chromium
on account session partitions, lets adapters opt into `executionMode:
"playwright"`, and leaves the existing hidden WebContentsView path in
place for current adapters.

Introduce an account-health subsystem with silent probes, projected
health/auth states, and IPC invalidation events so the renderer can
show accurate auth/probe status and verification timestamps.

Server-side, derive and forward title/business_date/scheduler_group_key/
question_text metadata on desktop task events so the local scheduler
can defer same-question fan-out before leasing.
This commit is contained in:
2026-04-20 17:16:15 +08:00
parent 07881a66d0
commit 55e1c2e74b
26 changed files with 3001 additions and 153 deletions
@@ -6,7 +6,7 @@ import StatusBadge from "../components/StatusBadge.vue";
import SurfaceCard from "../components/SurfaceCard.vue";
import { useDesktopRuntime } from "../composables/useDesktopRuntime";
import { showClientActionError } from "../lib/client-errors";
import { formatDateTime, titleCaseToken } from "../lib/formatters";
import { formatDateTime, formatRelativeTime, titleCaseToken } from "../lib/formatters";
import { desktopPublishMediaCatalog } from "../lib/media-catalog";
import type { RuntimeAccount } from "../types";
@@ -70,12 +70,13 @@ function platformMeta(platform: string) {
}
function authState(account: AccountRow): AccountAuthState {
switch (account.health) {
case "live":
switch (account.authState) {
case "active":
return "authorized";
case "expired":
case "revoked":
return "expired";
case "captcha":
case "challenge_required":
return "attention";
default:
return "risk";
@@ -89,9 +90,9 @@ function authStateLabel(account: AccountRow): string {
case "expired":
return "授权过期";
case "attention":
return "待补登";
return "需人工验证";
default:
return "风险观察";
return account.probeState === "network_error" ? "最近校验失败" : "待重新校验";
}
}
@@ -123,6 +124,16 @@ function sessionStateLabel(account: AccountRow): string {
}
}
function verificationTimeLabel(account: AccountRow): string {
if (account.lastVerifiedAt) {
return `${formatRelativeTime(account.lastVerifiedAt)}校验通过`;
}
if (account.probeState === "probing") {
return "正在校验";
}
return "尚未校验";
}
const filteredAccounts = computed(() => {
const keyword = searchQuery.value.trim().toLowerCase();
@@ -382,7 +393,7 @@ const tableColumns = [
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.health === 'live' ? 'success' : record.health === 'expired' ? 'error' : record.health === 'captcha' ? 'warning' : 'default'" style="border-radius: 999px; font-weight: 600; padding: 2px 10px;">
<a-tag :color="authState(record) === 'authorized' ? 'success' : authState(record) === 'expired' ? 'error' : authState(record) === 'attention' ? 'warning' : 'default'" style="border-radius: 999px; font-weight: 600; padding: 2px 10px;">
{{ authStateLabel(record) }}
</a-tag>
</template>
@@ -399,8 +410,8 @@ const tableColumns = [
<template v-else-if="column.key === 'syncTime'">
<div class="cell-primary-secondary">
<div class="info-stack">
<span class="title">{{ formatDateTime(record.lastSyncAt) }}</span>
<span class="subtitle">{{ record.partition }}</span>
<span class="title">{{ record.lastVerifiedAt ? formatDateTime(record.lastVerifiedAt) : formatDateTime(record.lastSyncAt) }}</span>
<span class="subtitle">{{ verificationTimeLabel(record) }} · {{ record.partition }}</span>
</div>
</div>
</template>
@@ -11,7 +11,7 @@ import { computed, ref } from "vue";
import { useDesktopRuntime } from "../composables/useDesktopRuntime";
import { showClientActionError } from "../lib/client-errors";
import { formatDateTime } from "../lib/formatters";
import { formatDateTime, formatRelativeTime } from "../lib/formatters";
import { desktopMonitoringMediaCatalog } from "../lib/media-catalog";
import type { RuntimeAccount } from "../types";
@@ -52,15 +52,19 @@ function authLabel(account: AccountRow | null) {
return "未绑定";
}
switch (account.health) {
case "live":
switch (account.authState) {
case "active":
return "授权正常";
case "captcha":
return "待处理";
case "challenge_required":
return "需人工验证";
case "expired":
return "授权过期";
case "expiring_soon":
return account.probeState === "network_error" ? "最近校验失败" : "待重新校验";
case "revoked":
return "已停用";
default:
return "风险观察";
return account.probeState === "probing" ? "校验中" : "待校验";
}
}
@@ -69,18 +73,38 @@ function authColor(account: AccountRow | null) {
return "default";
}
switch (account.health) {
case "live":
switch (account.authState) {
case "active":
return "success";
case "captcha":
case "challenge_required":
return "warning";
case "expired":
case "revoked":
return "error";
case "expiring_soon":
return account.probeState === "network_error" ? "warning" : "default";
default:
return "default";
}
}
function verificationLabel(account: AccountRow): string {
if (account.lastVerifiedAt) {
return `${formatRelativeTime(account.lastVerifiedAt)}校验通过`;
}
if (account.probeState === "probing") {
return "正在执行首轮校验";
}
return "尚未完成校验";
}
function nextProbeLabel(account: AccountRow): string {
if (!account.nextProbeAt) {
return "未排程";
}
return formatRelativeTime(account.nextProbeAt);
}
function sessionLabel(account: AccountRow): string {
switch (account.sessionState) {
case "hot":
@@ -243,6 +267,14 @@ async function unbindPlatform(account: AccountRow) {
<span class="info-label">最近同步</span>
<span class="info-value">{{ formatDateTime(platform.account.lastSyncAt) }}</span>
</div>
<div class="info-row">
<span class="info-label">校验状态</span>
<span class="info-value">{{ verificationLabel(platform.account) }}</span>
</div>
<div class="info-row">
<span class="info-label">下次巡检</span>
<span class="info-value">{{ nextProbeLabel(platform.account) }}</span>
</div>
</div>
<div class="card-footer">
@@ -236,7 +236,7 @@ const subsystemCards = computed(() => {
/* Metrics Top Grid */
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 24px;
}