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:
@@ -11,6 +11,7 @@ const error = shallowRef<string | null>(null);
|
||||
let intervalHandle: ReturnType<typeof setInterval> | null = null;
|
||||
let subscribers = 0;
|
||||
let visibilityListenerBound = false;
|
||||
let runtimeInvalidationUnsubscribe: (() => void) | null = null;
|
||||
|
||||
function isPageVisible(): boolean {
|
||||
return typeof document === "undefined" || document.visibilityState === "visible";
|
||||
@@ -95,6 +96,18 @@ function bindVisibilityListener() {
|
||||
visibilityListenerBound = true;
|
||||
}
|
||||
|
||||
function bindRuntimeInvalidationListener() {
|
||||
if (runtimeInvalidationUnsubscribe || !window.desktopBridge?.app?.onRuntimeInvalidated) {
|
||||
return;
|
||||
}
|
||||
|
||||
runtimeInvalidationUnsubscribe = window.desktopBridge.app.onRuntimeInvalidated(() => {
|
||||
if (isPageVisible()) {
|
||||
void refreshRuntimeSnapshot();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function unbindVisibilityListener() {
|
||||
if (!visibilityListenerBound || typeof document === "undefined") {
|
||||
return;
|
||||
@@ -104,10 +117,16 @@ function unbindVisibilityListener() {
|
||||
visibilityListenerBound = false;
|
||||
}
|
||||
|
||||
function unbindRuntimeInvalidationListener() {
|
||||
runtimeInvalidationUnsubscribe?.();
|
||||
runtimeInvalidationUnsubscribe = null;
|
||||
}
|
||||
|
||||
export function useDesktopRuntime() {
|
||||
onMounted(() => {
|
||||
subscribers += 1;
|
||||
bindVisibilityListener();
|
||||
bindRuntimeInvalidationListener();
|
||||
if (!snapshot.value && !loading.value) {
|
||||
void refreshRuntimeSnapshot();
|
||||
}
|
||||
@@ -119,6 +138,7 @@ export function useDesktopRuntime() {
|
||||
if (subscribers === 0) {
|
||||
stopPolling();
|
||||
unbindVisibilityListener();
|
||||
unbindRuntimeInvalidationListener();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -29,6 +29,9 @@ declare global {
|
||||
retryPublishTask(taskId: string): Promise<CreatePublishJobResponse>;
|
||||
syncRuntimeSession(session: DesktopRuntimeSessionSyncRequest | null): Promise<null>;
|
||||
releaseRuntimeSession(revoke?: boolean): Promise<null>;
|
||||
onRuntimeInvalidated(
|
||||
listener: (event: { reason: "account-health"; at: number }) => void,
|
||||
): () => void;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,12 +19,29 @@ export interface RuntimeAccount {
|
||||
platformUid: string;
|
||||
avatarUrl: string | null;
|
||||
health: "live" | "expired" | "captcha" | "risk";
|
||||
authState: "unknown" | "active" | "expiring_soon" | "challenge_required" | "expired" | "revoked";
|
||||
probeState: "idle" | "probing" | "network_error";
|
||||
authReason:
|
||||
| "bind_success"
|
||||
| "probe_success"
|
||||
| "login_redirect"
|
||||
| "http_401"
|
||||
| "captcha_gate"
|
||||
| "missing_partition"
|
||||
| "silent_refresh_failed"
|
||||
| "manual_unbind"
|
||||
| "too_many_failures"
|
||||
| "network_error"
|
||||
| null;
|
||||
tags: string[];
|
||||
syncVersion: number;
|
||||
clientId: string;
|
||||
partition: string;
|
||||
sessionState: "hot" | "warm" | "cold";
|
||||
lastSyncAt: number;
|
||||
lastVerifiedAt: number | null;
|
||||
lastProbeAt: number | null;
|
||||
nextProbeAt: number | null;
|
||||
queueDepth: number;
|
||||
online: boolean;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user