feat(desktop): drop parked review flow, add publish management

SaaS 侧人工审核后才创建 publish job,desktop 不再做二次审核:移除
manual/waiting_user/parked/from_parked 状态机与 LeaseFromParked 查询,
desktop client 只执行发布并新增"发布管理"页(待发布队列 / 历史 / 再次
发送)。同时抽离 @geo/publisher-platforms 共享适配器包、新增 Redis-based
desktop_presence 与 publish_record_support,刷新 admin-web 发布弹窗与
媒体库;plan A / spec 文档同步口径。
This commit is contained in:
2026-04-20 09:52:48 +08:00
parent b16e9f0bd1
commit a617d39a4a
93 changed files with 5519 additions and 3594 deletions
@@ -2,15 +2,21 @@
import { computed } from "vue";
import { RouterLink, RouterView, useRoute } from "vue-router";
import StatusBadge from "./StatusBadge.vue";
import { useDesktopRuntime } from "../composables/useDesktopRuntime";
import { useDesktopSession } from "../composables/useDesktopSession";
import { formatRelativeTime } from "../lib/formatters";
import { desktopMonitoringMediaCatalog, desktopPublishMediaCatalog } from "../lib/media-catalog";
const route = useRoute();
const { snapshot, error } = useDesktopRuntime();
const { session, logout, isPreviewMode } = useDesktopSession();
const { snapshot, error, refreshAccounts } = useDesktopRuntime();
const { session, logout } = useDesktopSession();
const accountAwareRoutes = new Set(["/media-platforms", "/ai-platforms"]);
function handleNavClick(target: string) {
if (accountAwareRoutes.has(target)) {
void refreshAccounts();
}
}
const navItems = computed(() => {
const data = snapshot.value;
@@ -26,10 +32,10 @@ const navItems = computed(() => {
count: data?.summary.issuesOpen ?? 0,
},
{
to: "/tasks",
title: "任务中心",
description: "MQ 投递、租约占用、人工审核与 fallback",
count: data?.tasks.length ?? 0,
to: "/publish-management",
title: "发布管理",
description: "查看待发布队列、历史发送结果,并对文章再次发送",
count: data?.summary.queuedTasks ?? 0,
},
{
to: "/media-platforms",
@@ -53,11 +59,8 @@ const navItems = computed(() => {
});
const updatedLabel = computed(() =>
snapshot.value ? formatRelativeTime(snapshot.value.generatedAt) : "等待首帧快照",
);
const operatorName = computed(() => session.value?.user?.name ?? "未命名操作员");
const operatorEmail = computed(() => session.value?.user?.email ?? "preview@geo-rankly.local");
const operatorEmail = computed(() => session.value?.user?.email ?? "");
const clientLabel = computed(
() => session.value?.desktopClient?.device_name ?? "当前设备尚未注册 client",
);
@@ -66,53 +69,25 @@ const clientLabel = computed(
<template>
<div class="app-shell">
<aside class="sidebar">
<div class="sidebar-top">
<div class="brand-block">
<div class="brand-mark">
<span class="brand-ring"></span>
<span class="brand-core"></span>
</div>
<div>
<p class="eyebrow">Workspace Cockpit</p>
<h1>GEO Rankly Desktop</h1>
</div>
<div class="brand-block">
<div class="brand-mark">
<span class="brand-ring"></span>
<span class="brand-core"></span>
</div>
<div>
<p class="eyebrow">Workspace Cockpit</p>
<h1>GEO Rankly Desktop</h1>
</div>
</div>
<section class="workspace-card">
<div class="workspace-topline">
<StatusBadge
:tone="snapshot?.previewMode || isPreviewMode ? 'brand' : 'success'"
:label="snapshot?.previewMode || isPreviewMode ? 'Preview Runtime' : 'Live Runtime'"
/>
<StatusBadge
v-if="snapshot"
tone="info"
:label="`Sync: ${updatedLabel}`"
/>
</div>
<div class="workspace-meta">
<div>
<span>在线客户端</span>
<strong>{{ snapshot?.summary.onlineClients ?? 0 }}</strong>
</div>
<div>
<span>绑定账号</span>
<strong>{{ snapshot?.summary.accountsBound ?? 0 }}</strong>
</div>
<div>
<span>待处理任务</span>
<strong>{{ snapshot?.summary.queuedTasks ?? 0 }}</strong>
</div>
</div>
</section>
<nav class="nav">
<RouterLink
v-for="item in navItems"
<nav class="nav">
<RouterLink
v-for="item in navItems"
:key="item.to"
:to="item.to"
class="nav-link"
:class="{ active: route.path === item.to }"
@click="handleNavClick(item.to)"
>
<div class="nav-copy">
<strong>{{ item.title }}</strong>
@@ -120,19 +95,20 @@ const clientLabel = computed(
<span class="nav-count">{{ item.count }}</span>
</RouterLink>
</nav>
</div>
<footer class="sidebar-footer">
<section class="operator-card">
<div class="operator-topline">
<div>
<span class="label">当前操作员</span>
<section class="user-profile-panel">
<div class="user-info-row">
<div class="user-avatar">{{ operatorName.charAt(0).toUpperCase() }}</div>
<div class="user-details">
<strong>{{ operatorName }}</strong>
<p>{{ operatorEmail }}</p>
</div>
<button type="button" class="logout-button" @click="logout">退出</button>
</div>
<p>{{ operatorEmail }}</p>
<small>{{ clientLabel }}</small>
<div class="user-actions-row">
<span class="device-tag">{{ clientLabel }}</span>
<button type="button" class="logout-link" @click="logout">退出</button>
</div>
</section>
@@ -165,20 +141,12 @@ const clientLabel = computed(
overflow-y: auto;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 24px;
padding: 24px;
border-right: 1px solid #e6edf5;
background: #ffffff;
flex-shrink: 0;
}
.sidebar-top {
display: flex;
flex-direction: column;
gap: 24px;
}
.brand-block {
display: flex;
gap: 16px;
@@ -228,59 +196,11 @@ h1 {
line-height: 1.2;
}
.workspace-card,
.operator-card {
padding: 16px;
border-radius: 12px;
border: 1px solid #e6edf5;
background: #fafafb;
}
.workspace-topline {
display: flex;
align-items: center;
gap: 8px;
}
.operator-topline {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.operator-topline strong {
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
}
.workspace-meta {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-top: 16px;
}
.workspace-meta span,
.footer-row span,
.label {
display: block;
color: #8c8c8c;
font-size: 12px;
}
.workspace-meta strong {
display: block;
margin-top: 4px;
font-size: 18px;
font-weight: 600;
color: #1a1a1a;
}
.nav {
display: grid;
display: flex;
flex-direction: column;
gap: 12px;
margin: auto 0;
}
.nav-link {
@@ -349,41 +269,96 @@ h1 {
padding-top: 16px;
}
.operator-card {
padding: 16px;
border-radius: 16px;
border: 1px solid #e6edf5;
background: #ffffff;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04);
.user-profile-panel {
padding: 14px;
border-radius: 10px;
background: #f8fafc;
border: 1px solid #e2e8f0;
}
.operator-card p,
.operator-card small {
.user-info-row {
display: flex;
align-items: center;
gap: 12px;
}
.user-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: #e0e7ff;
color: #4f46e5;
font-weight: 700;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.user-details {
min-width: 0;
}
.user-details strong {
display: block;
margin: 6px 0 0;
color: #8c8c8c;
font-size: 13px;
font-size: 14px;
font-weight: 600;
color: #0f172a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.operator-card small {
.user-details p {
margin: 2px 0 0;
font-size: 12px;
color: #64748b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.logout-button {
height: 28px;
padding: 0 12px;
border-radius: 14px;
border: 1px solid #d9d9d9;
background: #fff;
color: #595959;
.user-actions-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid #e2e8f0;
}
.device-tag {
font-size: 11px;
font-weight: 500;
color: #475569;
background: #f1f5f9;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid #e2e8f0;
}
.logout-link {
font-size: 12px;
font-weight: 500;
color: #ef4444;
background: transparent;
border: none;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.2s;
}
.logout-button:hover {
color: #ff4d4f;
border-color: #ff4d4f;
.logout-link:hover {
background: #fef2f2;
}
.footer-row span {
display: block;
color: #595959;
font-weight: 600;
font-size: 12px;
}
.footer-row {
@@ -1,16 +1,26 @@
<script setup lang="ts">
import type { Component } from 'vue';
defineProps<{
eyebrow: string;
value: string | number;
label: string;
tone?: "neutral" | "brand" | "success" | "warn" | "danger" | "info";
icon?: Component;
}>();
</script>
<template>
<article class="metric-card" :class="tone ?? 'neutral'">
<p class="eyebrow">{{ eyebrow }}</p>
<strong class="value">{{ value }}</strong>
<div class="metric-header">
<div v-if="icon" class="metric-icon-wrapper" :class="tone ?? 'neutral'">
<component :is="icon" />
</div>
<div class="metric-title-group">
<p class="eyebrow">{{ eyebrow }}</p>
<strong class="value">{{ value }}</strong>
</div>
</div>
<p class="label">{{ label }}</p>
</article>
</template>
@@ -19,30 +29,47 @@ defineProps<{
.metric-card {
padding: 20px;
border-radius: 12px;
border: 1px solid #e6edf5;
background: #ffffff;
border: 1px solid #f0f3fa;
display: flex;
flex-direction: column;
transition: box-shadow 0.2s, transform 0.2s;
}
.metric-card.brand {
border-top: 3px solid #1677ff;
.metric-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
transform: translateY(-2px);
}
.metric-card.success {
border-top: 3px solid #52c41a;
.metric-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 12px;
}
.metric-card.warn {
border-top: 3px solid #faad14;
.metric-icon-wrapper {
width: 44px;
height: 44px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
flex-shrink: 0;
}
.metric-card.danger {
border-top: 3px solid #ff4d4f;
}
.metric-icon-wrapper.neutral { background: #f0f2f5; color: #595959; }
.metric-icon-wrapper.brand { background: #e6f4ff; color: #1677ff; }
.metric-icon-wrapper.success { background: #f6ffed; color: #52c41a; }
.metric-icon-wrapper.warn { background: #fffbe6; color: #faad14; }
.metric-icon-wrapper.danger { background: #fff2f0; color: #ff4d4f; }
.metric-icon-wrapper.info { background: #e6f7ff; color: #1890ff; }
.metric-card.info {
border-top: 3px solid #13c2c2;
.metric-title-group {
display: flex;
flex-direction: column;
justify-content: center;
}
.eyebrow,
@@ -52,24 +79,40 @@ defineProps<{
.eyebrow {
color: #8c8c8c;
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
font-size: 13px;
font-weight: 500;
margin-bottom: 4px;
}
.value {
display: block;
margin-top: 12px;
font-size: 28px;
font-weight: 600;
color: #1a1a1a;
line-height: 1;
font-size: 24px;
font-weight: 800;
color: #141414;
line-height: 1.1;
}
.label {
margin-top: 8px;
color: #8c8c8c;
font-size: 12px;
line-height: 1.5;
border-top: 1px solid #f0f0f0;
padding-top: 12px;
margin-top: auto;
}
html[data-theme="dark"] .metric-card {
background: #141414;
border-color: #303030;
}
html[data-theme="dark"] .metric-icon-wrapper.neutral { background: #262626; color: rgba(255, 255, 255, 0.45); }
html[data-theme="dark"] .metric-icon-wrapper.brand { background: rgba(22, 119, 255, 0.2); color: #177ddc; }
html[data-theme="dark"] .metric-icon-wrapper.success { background: rgba(82, 196, 26, 0.2); color: #49aa19; }
html[data-theme="dark"] .metric-icon-wrapper.warn { background: rgba(250, 173, 20, 0.2); color: #d89614; }
html[data-theme="dark"] .metric-icon-wrapper.danger { background: rgba(255, 77, 79, 0.2); color: #a61d24; }
html[data-theme="dark"] .metric-icon-wrapper.info { background: rgba(24, 144, 255, 0.2); color: #177ddc; }
html[data-theme="dark"] .eyebrow,
html[data-theme="dark"] .label { color: rgba(255, 255, 255, 0.45); }
html[data-theme="dark"] .value { color: rgba(255, 255, 255, 0.85); }
html[data-theme="dark"] .label { border-top-color: #303030; }
</style>
@@ -17,15 +17,15 @@ const props = defineProps<{
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 26px;
padding: 0 10px;
border-radius: 999px;
min-height: 24px;
padding: 0 8px;
border-radius: 6px;
border: 1px solid var(--geo-color-border);
background: rgba(255, 255, 255, 0.42);
color: var(--geo-color-text-secondary);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
white-space: nowrap;
}
.subtle {
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { PlaySquareOutlined } from "@ant-design/icons-vue";
defineProps<{
eyebrow?: string;
title: string;
@@ -9,23 +11,31 @@ defineProps<{
<template>
<section class="surface-card">
<header class="header">
<div>
<p v-if="eyebrow" class="eyebrow">{{ eyebrow }}</p>
<h3>{{ title }}</h3>
<div class="header-content">
<h3 class="panel-title">{{ title }}</h3>
<p v-if="description" class="description">{{ description }}</p>
</div>
<slot name="action" />
</header>
<p v-if="description" class="description">{{ description }}</p>
<slot />
<div class="card-body">
<slot />
</div>
</section>
</template>
<style scoped>
.surface-card {
padding: 24px;
border-radius: 12px;
border: 1px solid #e6edf5;
background: #ffffff;
border-radius: 12px;
padding: 24px;
border: 1px solid #f0f3fa;
display: flex;
flex-direction: column;
transition: box-shadow 0.2s;
}
.surface-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02);
}
.header {
@@ -33,32 +43,44 @@ defineProps<{
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.eyebrow,
.description,
h3 {
.header-content {
display: flex;
flex-direction: column;
gap: 6px;
}
.panel-title {
margin: 0;
}
.eyebrow {
color: #8c8c8c;
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
}
h3 {
margin-top: 4px;
font-size: 16px;
font-weight: 600;
font-weight: 700;
color: #1a1a1a;
display: flex;
align-items: center;
}
.panel-title::before {
content: "";
display: inline-block;
width: 4px;
height: 14px;
background: #1677ff;
border-radius: 2px;
margin-right: 8px;
}
.description {
margin-top: 8px;
margin: 0;
color: #8c8c8c;
font-size: 13px;
line-height: 1.6;
line-height: 1.5;
}
.card-body {
flex: 1;
display: flex;
flex-direction: column;
}
</style>
@@ -1,6 +1,5 @@
import { computed, onMounted, onUnmounted, readonly, shallowRef } from "vue";
import { createPreviewRuntimeSnapshot } from "../mock/runtime-preview";
import type { DesktopRuntimeSnapshot } from "../types";
const snapshot = shallowRef<DesktopRuntimeSnapshot | null>(null);
@@ -15,12 +14,13 @@ async function refreshRuntimeSnapshot() {
error.value = null;
try {
if (window.desktopBridge?.app?.runtimeSnapshot) {
snapshot.value = await window.desktopBridge.app.runtimeSnapshot();
if (!window.desktopBridge?.app?.runtimeSnapshot) {
snapshot.value = null;
error.value = "desktop runtime bridge unavailable";
return;
}
snapshot.value = createPreviewRuntimeSnapshot();
snapshot.value = await window.desktopBridge.app.runtimeSnapshot();
} catch (err) {
error.value = err instanceof Error ? err.message : "runtime snapshot unavailable";
} finally {
@@ -28,6 +28,25 @@ async function refreshRuntimeSnapshot() {
}
}
async function refreshAccounts() {
if (!window.desktopBridge?.app?.refreshRuntimeAccounts) {
await refreshRuntimeSnapshot();
return;
}
loading.value = true;
error.value = null;
try {
await window.desktopBridge.app.refreshRuntimeAccounts();
snapshot.value = await window.desktopBridge.app.runtimeSnapshot();
} catch (err) {
error.value = err instanceof Error ? err.message : "refresh accounts failed";
} finally {
loading.value = false;
}
}
function startPolling() {
if (intervalHandle) {
return;
@@ -66,6 +85,7 @@ export function useDesktopRuntime() {
loading: readonly(loading),
error: readonly(error),
refresh: refreshRuntimeSnapshot,
refreshAccounts,
hasSnapshot: computed(() => snapshot.value !== null),
};
}
@@ -27,6 +27,7 @@ interface DesktopSession {
const sessionStorageKey = "geo.desktop.session.v1";
const apiBaseURLStorageKey = "geo.desktop.api-base-url";
const clientRegistrationStoragePrefix = "geo.desktop.client-registration.v1";
const session = shallowRef<DesktopSession | null>(readStoredSession());
const apiBaseURL = shallowRef(readStoredApiBaseURL());
@@ -125,8 +126,50 @@ function createAuthenticatedClient() {
});
}
function registerPayload(): DesktopClientRegisterRequest {
function clientRegistrationStorageKey(user: UserInfo): string {
return `${clientRegistrationStoragePrefix}:${user.primary_workspace_id}:${user.id}`;
}
function readStoredClientRegistrationID(user: UserInfo): string | null {
if (typeof window === "undefined") {
return null;
}
return window.localStorage.getItem(clientRegistrationStorageKey(user));
}
function persistClientRegistrationID(user: UserInfo, clientID: string): void {
if (typeof window === "undefined") {
return;
}
window.localStorage.setItem(clientRegistrationStorageKey(user), clientID);
}
function generateUUID(): string {
if (globalThis.crypto?.randomUUID) {
return globalThis.crypto.randomUUID();
}
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (char) => {
const random = Math.floor(Math.random() * 16);
const value = char === "x" ? random : (random & 0x3) | 0x8;
return value.toString(16);
});
}
function getOrCreateClientRegistrationID(user: UserInfo): string {
const existing = readStoredClientRegistrationID(user)?.trim();
if (existing) {
return existing;
}
const generated = generateUUID();
persistClientRegistrationID(user, generated);
return generated;
}
function registerPayload(user: UserInfo): DesktopClientRegisterRequest {
return {
client_id: getOrCreateClientRegistrationID(user),
device_name: `Desktop ${navigator.platform || "unknown"}`,
os: navigator.platform || "unknown",
cpu_arch: "renderer-preview",
@@ -217,7 +260,9 @@ async function login(payload: LoginRequest): Promise<void> {
const registered = await createAuthenticatedClient().post<
DesktopClientRegisterResponse,
DesktopClientRegisterRequest
>("/api/desktop/clients/register", registerPayload());
>("/api/desktop/clients/register", registerPayload(authData.user));
persistClientRegistrationID(authData.user, registered.client.id);
persistSession({
mode: "authenticated",
@@ -250,8 +295,24 @@ function enterPreview(): void {
});
}
function logout(): void {
async function logout(): Promise<void> {
error.value = null;
const current = session.value;
if (current?.mode === "authenticated") {
try {
await window.desktopBridge?.app?.releaseRuntimeSession?.(true);
} catch (err) {
console.warn("[desktop-session] releaseRuntimeSession failed", err);
}
try {
await createAuthenticatedClient().post<null, Record<string, never>>("/api/auth/logout", {});
} catch (err) {
console.warn("[desktop-session] auth logout failed", err);
}
}
persistSession(null);
}
+12 -3
View File
@@ -1,4 +1,10 @@
import type { DesktopAccountInfo, DesktopRuntimeSessionSyncRequest } from "@geo/shared-types";
import type {
CreatePublishJobResponse,
DesktopAccountInfo,
DesktopPublishTaskListResponse,
DesktopRuntimeSessionSyncRequest,
ListDesktopPublishTasksParams,
} from "@geo/shared-types";
import type { DesktopRuntimeSnapshot } from "./types";
export {};
@@ -10,15 +16,18 @@ declare global {
ping(): Promise<string>;
runtimeSnapshot(): Promise<DesktopRuntimeSnapshot>;
bindPublishAccount(platformId: string): Promise<DesktopAccountInfo>;
refreshRuntimeAccounts(): Promise<null>;
openPublishAccountConsole(account: {
id: string;
platform: string;
platformUid: string;
displayName: string;
}): Promise<null>;
openTaskReview(taskId: string): Promise<null>;
resolveParkedTask(taskId: string, status: "succeeded" | "failed" | "unknown"): Promise<null>;
unbindPublishAccount(accountId: string, syncVersion: number): Promise<null>;
listPublishTasks(params?: ListDesktopPublishTasksParams): Promise<DesktopPublishTaskListResponse>;
retryPublishTask(taskId: string): Promise<CreatePublishJobResponse>;
syncRuntimeSession(session: DesktopRuntimeSessionSyncRequest | null): Promise<null>;
releaseRuntimeSession(revoke?: boolean): Promise<null>;
};
};
}
@@ -1,7 +1,7 @@
import { Modal } from "ant-design-vue";
type ClientErrorTone = "error" | "warning";
type ClientActionKind = "bind-account" | "open-console";
type ClientActionKind = "bind-account" | "open-console" | "unbind-account";
interface ClientErrorPresentation {
tone: ClientErrorTone;
@@ -40,7 +40,11 @@ export function unwrapClientErrorMessage(error: unknown, fallback: string): stri
function presentClientError(kind: ClientActionKind, error: unknown): ClientErrorPresentation {
const message = unwrapClientErrorMessage(
error,
kind === "bind-account" ? "账号绑定失败" : "打开创作台失败",
kind === "bind-account"
? "账号绑定失败"
: kind === "unbind-account"
? "账号解绑失败"
: "打开创作台失败",
);
if (message === "desktop_account_bind_window_closed") {
@@ -76,6 +80,22 @@ function presentClientError(kind: ClientActionKind, error: unknown): ClientError
};
}
if (message === "desktop_account_sync_conflict") {
return {
tone: "warning",
title: kind === "unbind-account" ? "账号状态已变化" : "账号状态已更新",
content: "当前账号状态刚刚发生了变化。请先刷新列表,再重试这次操作。",
};
}
if (message === "desktop_account_delete_failed") {
return {
tone: "error",
title: "账号解绑失败",
content: "服务端解绑没有成功完成,请稍后重试。",
};
}
if (kind === "open-console") {
return {
tone: "error",
@@ -84,6 +104,14 @@ function presentClientError(kind: ClientActionKind, error: unknown): ClientError
};
}
if (kind === "unbind-account") {
return {
tone: "error",
title: "账号解绑失败",
content: message,
};
}
return {
tone: "error",
title: "账号绑定失败",
@@ -4,6 +4,8 @@ export function healthTone(health: RuntimeAccount["health"]): RuntimeTone {
switch (health) {
case "live":
return "success";
case "expired":
return "danger";
case "captcha":
return "warn";
case "risk":
@@ -17,8 +19,6 @@ export function taskTone(status: RuntimeTask["status"]): RuntimeTone {
switch (status) {
case "succeeded":
return "success";
case "waiting_user":
return "warn";
case "failed":
case "aborted":
case "unknown":
+3 -2
View File
@@ -4,13 +4,14 @@ import AccountsView from "./views/AccountsView.vue";
import AiPlatformsView from "./views/AiPlatformsView.vue";
import DiagnosticsView from "./views/DiagnosticsView.vue";
import HomeView from "./views/HomeView.vue";
import TasksView from "./views/TasksView.vue";
import PublishManagementView from "./views/PublishManagementView.vue";
export const router = createRouter({
history: createWebHashHistory(),
routes: [
{ path: "/", name: "home", component: HomeView },
{ path: "/tasks", name: "tasks", component: TasksView },
{ path: "/publish-management", name: "publish-management", component: PublishManagementView },
{ path: "/tasks", redirect: "/publish-management" },
{ path: "/media-platforms", name: "media-platforms", component: AccountsView },
{ path: "/ai-platforms", name: "ai-platforms", component: AiPlatformsView },
{ path: "/accounts", redirect: "/media-platforms" },
+2 -3
View File
@@ -20,6 +20,7 @@ export interface RuntimeAccount {
avatarUrl: string | null;
health: "live" | "expired" | "captcha" | "risk";
tags: string[];
syncVersion: number;
clientId: string;
partition: string;
sessionState: "hot" | "warm" | "cold";
@@ -37,8 +38,7 @@ export interface RuntimeTask {
accountId: string;
accountName: string;
clientId: string;
status: "queued" | "in_progress" | "waiting_user" | "succeeded" | "failed" | "unknown" | "aborted";
mode: "auto" | "manual";
status: "queued" | "in_progress" | "succeeded" | "failed" | "unknown" | "aborted";
routing: "rabbitmq-primary" | "db-recovery";
leaseExpiresAt: number | null;
updatedAt: number;
@@ -54,7 +54,6 @@ export interface RuntimeActivity {
}
export interface DesktopRuntimeSnapshot {
previewMode: boolean;
generatedAt: number;
app: {
name: string;
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { SearchOutlined, ReloadOutlined, ApiOutlined, ArrowRightOutlined, LinkOutlined, DeleteOutlined, EllipsisOutlined } from "@ant-design/icons-vue";
import { SearchOutlined, ReloadOutlined, ApiOutlined, ArrowRightOutlined, DeleteOutlined } from "@ant-design/icons-vue";
import StatusBadge from "../components/StatusBadge.vue";
import SurfaceCard from "../components/SurfaceCard.vue";
@@ -10,22 +10,19 @@ import { formatDateTime, titleCaseToken } from "../lib/formatters";
import { desktopPublishMediaCatalog } from "../lib/media-catalog";
import type { RuntimeAccount } from "../types";
const { snapshot, refresh, loading } = useDesktopRuntime();
const { snapshot, refresh, refreshAccounts, loading } = useDesktopRuntime();
const selectedPlatform = ref("all");
const selectedStatus = ref("all");
const searchQuery = ref("");
const bindPendingPlatformId = ref<string | null>(null);
const consolePendingAccountId = ref<string | null>(null);
const unbindPendingAccountId = ref<string | null>(null);
const actionSuccess = ref<string | null>(null);
type AccountAuthState = "authorized" | "expired" | "attention" | "risk";
type AccountRow = Readonly<Omit<RuntimeAccount, "tags">> & { tags: readonly string[] };
const clientsById = computed(
() => new Map((snapshot.value?.clients ?? []).map((item) => [item.id, item])),
);
const accounts = computed(() => snapshot.value?.accounts ?? []);
const overview = computed(() => ({
@@ -112,6 +109,17 @@ function accountInitial(account: AccountRow): string {
return account.displayName.slice(0, 1).toUpperCase();
}
function sessionStateLabel(account: AccountRow): string {
switch (account.sessionState) {
case "hot":
return "活跃会话";
case "warm":
return "本地缓存";
default:
return "冷缓存";
}
}
const filteredAccounts = computed(() => {
const keyword = searchQuery.value.trim().toLowerCase();
@@ -128,12 +136,11 @@ const filteredAccounts = computed(() => {
return true;
}
const clientName = clientsById.value.get(account.clientId)?.deviceName ?? account.clientId;
const haystack = [
account.displayName,
account.platformUid,
platformMeta(account.platform).label,
clientName,
account.partition,
]
.join(" ")
.toLowerCase();
@@ -144,6 +151,7 @@ const filteredAccounts = computed(() => {
function selectPlatform(platform: string) {
selectedPlatform.value = platform;
void refreshAccounts();
}
function resetFilters() {
@@ -184,11 +192,26 @@ async function openConsole(account: AccountRow) {
}
}
async function unbindAccount(account: AccountRow) {
unbindPendingAccountId.value = account.id;
actionSuccess.value = null;
try {
await window.desktopBridge.app.unbindPublishAccount(account.id, account.syncVersion);
actionSuccess.value = `${account.displayName} 已解绑`;
await refresh();
} catch (error) {
showClientActionError("unbind-account", error);
} finally {
unbindPendingAccountId.value = null;
}
}
const tableColumns = [
{ title: "昵称", key: "name", dataIndex: "name" },
{ title: "平台", key: "platform", dataIndex: "platform" },
{ title: "授权状态", key: "status", dataIndex: "status" },
{ title: "归属客户端", key: "client", dataIndex: "client" },
{ title: "本地会话", key: "session", dataIndex: "session" },
{ title: "授权时间", key: "syncTime", dataIndex: "syncTime" },
{ title: "操作", key: "actions", align: "right" as const }
];
@@ -203,7 +226,7 @@ const tableColumns = [
<p>先把平台授权跑通上面直接发起真实登录绑定下面看已授权账号状态和进入创作台入口</p>
</div>
<div class="media-view__header-actions">
<a-button :loading="loading" @click="refresh">
<a-button :loading="loading" @click="refreshAccounts">
<template #icon><ReloadOutlined /></template>
刷新状态
</a-button>
@@ -292,7 +315,7 @@ const tableColumns = [
<div class="panel list-panel">
<div class="table-header">
<h3 class="panel-title">授权账号列表</h3>
<p class="panel-desc">当前 {{ filteredAccounts.length }} 个媒体账号支持重新授权和直接进入创作台</p>
<p class="panel-desc">当前仅展示本机存在缓存分区或会话的媒体账号并实时校验授权是否过期</p>
<div class="media-list-toolbar">
<div class="toolbar-filters">
@@ -317,7 +340,7 @@ const tableColumns = [
<a-input
v-model:value="searchQuery"
placeholder="搜索昵称、UID、平台或客户端"
placeholder="搜索昵称、UID、平台或本地分区"
style="width: 280px"
allow-clear
>
@@ -372,11 +395,11 @@ const tableColumns = [
</a-tag>
</template>
<template v-else-if="column.key === 'client'">
<template v-else-if="column.key === 'session'">
<div class="cell-primary-secondary">
<div class="info-stack">
<span class="title">{{ clientsById.get(record.clientId)?.deviceName ?? record.clientId }}</span>
<span class="subtitle">{{ record.online ? "在线" : "离线" }}</span>
<span class="title">{{ sessionStateLabel(record) }}</span>
<span class="subtitle">{{ record.online ? "本机在线" : "本机离线" }}</span>
</div>
</div>
</template>
@@ -407,6 +430,22 @@ const tableColumns = [
>
重新授权
</a-button>
<a-popconfirm
title="解绑这个账号?"
description="会删除当前账号绑定,并清理本机的本地 session / partition 缓存。"
ok-text="确认解绑"
cancel-text="取消"
@confirm="unbindAccount(record)"
>
<a-button
danger
size="small"
:loading="unbindPendingAccountId === record.id"
>
<template #icon><DeleteOutlined /></template>
解绑
</a-button>
</a-popconfirm>
</div>
</template>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from "vue";
import { WarningOutlined } from "@ant-design/icons-vue";
import { ReloadOutlined, WarningOutlined } from "@ant-design/icons-vue";
import StatusBadge from "../components/StatusBadge.vue";
import { useDesktopRuntime } from "../composables/useDesktopRuntime";
@@ -10,11 +10,11 @@ import type { RuntimeAccount } from "../types";
type AccountRow = Readonly<Omit<RuntimeAccount, "tags">> & { tags: readonly string[] };
const { snapshot } = useDesktopRuntime();
const { snapshot, refreshAccounts, loading } = useDesktopRuntime();
const clientsById = computed(
() => new Map((snapshot.value?.clients ?? []).map((item) => [item.id, item])),
);
function handleCardClick() {
void refreshAccounts();
}
const aiAccounts = computed(() =>
(snapshot.value?.accounts ?? []).filter((account) =>
@@ -72,11 +72,19 @@ function sessionTone(account: AccountRow | null) {
}
}
function clientName(account: AccountRow | null): string {
function sessionLabel(account: AccountRow | null): string {
if (!account) {
return "未绑定客户端";
return "无本地缓存";
}
switch (account.sessionState) {
case "hot":
return "活跃会话";
case "warm":
return "本地缓存";
default:
return "冷缓存";
}
return clientsById.value.get(account.clientId)?.deviceName ?? account.clientId;
}
function shortName(value: string): string {
@@ -92,9 +100,15 @@ function shortName(value: string): string {
<p class="eyebrow" style="color: #8c8c8c; font-size: 11px; text-transform: uppercase; letter-spacing: 0.14em; margin-bottom: 6px;">AI PLATFORMS</p>
<h2>AI 平台管理</h2>
<p class="summary" style="margin-top: 10px; line-height: 1.6; color: #595959; font-size: 13px; max-width: 800px;">
AI 平台按每个平台只绑定一个账号建模所以这里不走媒体账号表格而是用一平台一卡片的结构直接看绑定状态会话热度和客户端归属
AI 平台按每个平台只绑定一个账号建模所以这里直接看本机缓存下的绑定状态会话热度和过期情况
</p>
</div>
<div class="media-view__header-actions">
<a-button :loading="loading" @click="refreshAccounts">
<template #icon><ReloadOutlined /></template>
刷新数据
</a-button>
</div>
</div>
<div class="overview-strip border-t">
@@ -129,6 +143,7 @@ function shortName(value: string): string {
:key="platform.id"
class="media-card"
:class="platform.account ? 'media-card--bound' : 'media-card--unbound'"
@click="handleCardClick"
>
<div class="media-card__head">
<div class="media-card__identity">
@@ -156,8 +171,8 @@ function shortName(value: string): string {
<strong>{{ platform.account.platformUid }}</strong>
</div>
<div class="account-row-info">
<span>归属客户端</span>
<strong>{{ clientName(platform.account) }}</strong>
<span>本地会话</span>
<strong>{{ sessionLabel(platform.account) }}</strong>
</div>
<div class="account-row-info">
<span>最近同步</span>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { computed } from "vue";
import { DesktopOutlined, LinkOutlined, ClockCircleOutlined, AlertOutlined } from "@ant-design/icons-vue";
import MetricCard from "../components/MetricCard.vue";
import StatusBadge from "../components/StatusBadge.vue";
@@ -8,23 +9,52 @@ import { useDesktopRuntime } from "../composables/useDesktopRuntime";
import { formatClock, formatRelativeTime, titleCaseToken } from "../lib/formatters";
import { healthTone, taskTone } from "../lib/runtime-ui";
function translatePlatform(platform: string) {
const map: Record<string, string> = {
toutiaohao: "头条号",
bilibili: "哔哩哔哩",
xiaohongshu: "小红书",
douyin: "抖音",
kuaishou: "快手",
wechat_oa: "微信公众号",
baijiahao: "百家号",
zhihu: "知乎",
};
return map[platform?.toLowerCase()] || titleCaseToken(platform);
}
function translateHealth(status: string) {
const map: Record<string, string> = {
live: "正常",
expired: "已过期",
disconnected: "已断开",
risk_control: "风控中",
needs_captcha: "需验证码",
};
return map[status?.toLowerCase()] || status;
}
function translateEventTitle(title: string) {
const map: Record<string, string> = {
"Accounts Synced": "本地账号同步",
"Heartbeat Healthy": "心跳连接正常",
"Desktop Stream Connected": "单点事件流已连接",
"Desktop Runtime Started": "调度节点已启动",
"Desktop Runtime Armed": "任务消费端已就绪",
};
return map[title] || title;
}
function formatUid(uid: string) {
return (uid || "").replace(/^(?:platform:)+/gi, "");
}
const { snapshot, loading, refresh } = useDesktopRuntime();
const spotlightActionPending = ref(false);
const spotlightActionError = ref<string | null>(null);
const tasks = computed(() => snapshot.value?.tasks ?? []);
const accounts = computed(() => snapshot.value?.accounts ?? []);
const activity = computed(() => snapshot.value?.activity ?? []);
const spotlightTask = computed(() => {
return (
tasks.value.find((item) => item.status === "waiting_user") ??
tasks.value.find((item) => item.status === "in_progress") ??
tasks.value[0] ??
null
);
});
const riskyAccounts = computed(() => accounts.value.filter((item) => item.health !== "live"));
const subsystemCards = computed(() => {
@@ -44,166 +74,56 @@ const subsystemCards = computed(() => {
},
];
});
async function openSpotlightReview() {
if (!spotlightTask.value) {
return;
}
spotlightActionPending.value = true;
spotlightActionError.value = null;
try {
await window.desktopBridge.app.openTaskReview(spotlightTask.value.id);
} catch (error) {
spotlightActionError.value = error instanceof Error ? error.message : "打开审核页失败";
} finally {
spotlightActionPending.value = false;
}
}
async function resolveSpotlightTask(status: "succeeded" | "failed") {
if (!spotlightTask.value) {
return;
}
spotlightActionPending.value = true;
spotlightActionError.value = null;
try {
await window.desktopBridge.app.resolveParkedTask(spotlightTask.value.id, status);
await refresh();
} catch (error) {
spotlightActionError.value = error instanceof Error ? error.message : "回写审核结果失败";
} finally {
spotlightActionPending.value = false;
}
}
</script>
<template>
<section class="page">
<header class="hero">
<div class="hero-copy">
<p class="eyebrow">RUNTIME COCKPIT</p>
<h2>运行控制台</h2>
<p class="hero-summary">
桌面客户端本地调度与运行时指标大盘全局观测实时在线客户端关联账号状态MQ 任务分发健康度及错误拦截告警
</p>
</div>
<SurfaceCard
class="spotlight"
eyebrow="Spotlight Task"
title="当前最需要人工关注的任务"
description="优先把会阻断队列流转的 waiting_user 或 in_progress 任务顶到最前。"
>
<template #action>
<button class="refresh-button" @click="refresh">刷新快照</button>
</template>
<div v-if="spotlightTask" class="spotlight-body">
<div class="spotlight-headline">
<div>
<strong>{{ spotlightTask.title }}</strong>
<p>{{ spotlightTask.summary }}</p>
</div>
<StatusBadge :tone="taskTone(spotlightTask.status)" :label="spotlightTask.status" />
</div>
<dl class="spotlight-grid">
<div>
<dt>Account</dt>
<dd>{{ spotlightTask.accountName }}</dd>
</div>
<div>
<dt>Routing</dt>
<dd>{{ spotlightTask.routing }}</dd>
</div>
<div>
<dt>Lease</dt>
<dd>
{{
spotlightTask.leaseExpiresAt
? `expires ${formatRelativeTime(spotlightTask.leaseExpiresAt)}`
: "released"
}}
</dd>
</div>
<div>
<dt>Updated</dt>
<dd>{{ formatClock(spotlightTask.updatedAt) }}</dd>
</div>
</dl>
<div v-if="spotlightTask.status === 'waiting_user'" class="spotlight-actions">
<button
type="button"
class="spotlight-button secondary"
:disabled="spotlightActionPending"
@click="openSpotlightReview"
>
打开审核页
</button>
<button
type="button"
class="spotlight-button success"
:disabled="spotlightActionPending"
@click="resolveSpotlightTask('succeeded')"
>
标记已发布
</button>
<button
type="button"
class="spotlight-button danger"
:disabled="spotlightActionPending"
@click="resolveSpotlightTask('failed')"
>
标记失败
</button>
</div>
<p v-if="spotlightActionError" class="spotlight-error">{{ spotlightActionError }}</p>
</div>
</SurfaceCard>
</header>
<div class="metric-grid">
<MetricCard
eyebrow="Clients"
eyebrow="在线节点"
:value="snapshot?.summary.onlineClients ?? 0"
label="当前在线 desktop clients"
label="当前处于活跃状态的桌面客户端"
tone="brand"
:icon="DesktopOutlined"
/>
<MetricCard
eyebrow="Accounts"
eyebrow="已绑账号"
:value="snapshot?.summary.accountsBound ?? 0"
label="已经绑定到 workspace 的账号"
label="已授权挂载至本空间的媒体账号"
tone="info"
:icon="LinkOutlined"
/>
<MetricCard
eyebrow="Queue"
eyebrow="排队任务"
:value="snapshot?.summary.queuedTasks ?? 0"
label="仍在等待租约领取的任务"
label="当前积压等待端侧领取的发布任务"
tone="warn"
:icon="ClockCircleOutlined"
/>
<MetricCard
eyebrow="Issues"
eyebrow="异常干预"
:value="snapshot?.summary.issuesOpen ?? 0"
label="待人工介入或 reconcile 的事项"
label="执行失败或需人工排查的阻断事项"
tone="danger"
:icon="AlertOutlined"
/>
</div>
<div class="panel-grid">
<SurfaceCard
eyebrow="Risk Watch"
eyebrow="风险巡检"
title="账号健康看板"
description="把需要修复登录态、验证码或风险确认的账号单独拉出来。"
>
<div v-if="riskyAccounts.length > 0" class="list-stack">
<article v-for="account in riskyAccounts" :key="account.id" class="list-row">
<div>
<div class="list-head-content">
<strong>{{ account.displayName }}</strong>
<p>{{ titleCaseToken(account.platform) }} · {{ account.platformUid }}</p>
<p>{{ translatePlatform(account.platform) }} · {{ formatUid(account.platformUid) }}</p>
</div>
<div class="list-side">
<StatusBadge :tone="healthTone(account.health)" :label="account.health" />
<StatusBadge :tone="healthTone(account.health)" :label="translateHealth(account.health)" />
<small>{{ formatRelativeTime(account.lastSyncAt) }}</small>
</div>
</article>
@@ -212,20 +132,20 @@ async function resolveSpotlightTask(status: "succeeded" | "failed") {
</SurfaceCard>
<SurfaceCard
eyebrow="Event Feed"
eyebrow="事件追踪"
title="最近事件"
description="这里会先承接 MQ 广播和主进程的关键状态变化,后面再接真实 SSE。"
description="系统底层状态流转的历史快照,主要用作诊断上下文。"
>
<div v-if="activity.length > 0" class="timeline">
<article v-for="entry in activity" :key="entry.id" class="timeline-item">
<div class="timeline-marker" :class="entry.severity"></div>
<div class="timeline-copy">
<div class="timeline-head">
<strong>{{ entry.title }}</strong>
<small>{{ formatRelativeTime(entry.at) }}</small>
<article v-for="entry in activity" :key="entry.id" class="feed-card">
<div class="feed-head">
<div class="feed-title">
<span class="feed-dot" :class="entry.severity"></span>
<strong>{{ translateEventTitle(entry.title) }}</strong>
</div>
<p>{{ entry.detail }}</p>
<small>{{ formatRelativeTime(entry.at) }}</small>
</div>
<p>{{ entry.detail }}</p>
</article>
</div>
<p v-else class="empty-text" style="margin-top: 16px;">当前没有最近事件记录</p>
@@ -233,8 +153,8 @@ async function resolveSpotlightTask(status: "succeeded" | "failed") {
</div>
<SurfaceCard
eyebrow="Subsystem Snapshot"
title="核心子系统快照"
eyebrow="核心子系统"
title="引擎状态快照"
description="用于快速确认 transport、lease manager 和 vault 这三段是否处在可工作状态。"
>
<div class="subsystem-grid">
@@ -255,147 +175,6 @@ async function resolveSpotlightTask(status: "succeeded" | "failed") {
gap: 22px;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(380px, 0.95fr);
gap: 20px;
align-items: stretch;
}
.hero-copy {
padding: 24px;
border-radius: 12px;
border: 1px solid #e6edf5;
background: #ffffff;
}
.eyebrow,
.hero-summary,
h2 {
margin: 0;
}
.eyebrow {
color: var(--geo-color-text-tertiary);
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
}
h2 {
margin-top: 8px;
font-size: 24px;
font-weight: 600;
color: #1a1a1a;
line-height: 1.4;
}
.hero-summary {
margin-top: 12px;
max-width: 720px;
color: #8c8c8c;
line-height: 1.6;
font-size: 13px;
}
.spotlight :deep(.description) {
max-width: 420px;
}
.refresh-button {
padding: 10px 14px;
border: 1px solid var(--geo-color-border);
border-radius: 999px;
background: rgba(255, 255, 255, 0.45);
color: var(--geo-color-text-primary);
cursor: pointer;
}
.spotlight-body {
margin-top: 18px;
display: grid;
gap: 18px;
}
.spotlight-headline {
display: flex;
justify-content: space-between;
gap: 16px;
}
.spotlight-headline strong {
display: block;
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
}
.spotlight-headline p {
margin: 4px 0 0;
color: #8c8c8c;
font-size: 12px;
line-height: 1.5;
}
.spotlight-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
margin: 0;
}
.spotlight-grid dt {
color: #8c8c8c;
font-size: 12px;
}
.spotlight-grid dd {
margin: 4px 0 0;
color: #1a1a1a;
font-weight: 500;
font-size: 13px;
}
.spotlight-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.spotlight-button {
border: 0;
border-radius: 999px;
padding: 10px 14px;
font: inherit;
cursor: pointer;
}
.spotlight-button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.spotlight-button.secondary {
background: rgba(15, 23, 42, 0.08);
color: var(--geo-color-text-primary);
}
.spotlight-button.success {
background: rgba(5, 150, 105, 0.14);
color: #047857;
}
.spotlight-button.danger {
background: rgba(220, 38, 38, 0.12);
color: #b91c1c;
}
.spotlight-error {
margin: 0;
color: #b91c1c;
}
.metric-grid,
.panel-grid,
.subsystem-grid {
@@ -441,8 +220,9 @@ h2 {
.list-row p {
margin: 6px 0 0;
color: var(--geo-color-text-secondary);
color: #8c8c8c;
line-height: 1.55;
font-size: 13px;
}
.list-side {
@@ -462,46 +242,63 @@ h2 {
color: var(--geo-color-text-secondary);
}
.timeline-item {
display: grid;
grid-template-columns: auto 1fr;
gap: 12px;
.feed-card {
padding: 12px 14px;
border-radius: 8px;
border: 1px solid #e6edf5;
background: #fafafb;
display: flex;
flex-direction: column;
gap: 6px;
}
.timeline-marker {
width: 10px;
height: 10px;
margin-top: 7px;
border-radius: 999px;
background: var(--geo-color-info);
box-shadow: 0 0 0 6px rgba(37, 99, 235, 0.1);
}
.timeline-marker.success {
background: var(--geo-color-success);
box-shadow: 0 0 0 6px rgba(21, 128, 61, 0.1);
}
.timeline-marker.warn {
background: var(--geo-color-warn);
box-shadow: 0 0 0 6px rgba(180, 83, 9, 0.1);
}
.timeline-marker.danger {
background: var(--geo-color-danger);
box-shadow: 0 0 0 6px rgba(185, 28, 28, 0.1);
}
.timeline-head {
.feed-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.timeline-copy p {
margin: 6px 0 0;
color: var(--geo-color-text-secondary);
line-height: 1.65;
.feed-title {
display: flex;
align-items: center;
gap: 8px;
}
.feed-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #1677ff;
}
.feed-dot.success {
background: #52c41a;
}
.feed-dot.warn {
background: #faad14;
}
.feed-dot.danger {
background: #ff4d4f;
}
.feed-title strong {
font-size: 13px;
font-weight: 600;
color: #1a1a1a;
}
.feed-head small {
font-size: 12px;
color: #8c8c8c;
}
.feed-card p {
margin: 0 0 0 14px;
color: #595959;
font-size: 13px;
line-height: 1.5;
}
.subsystem-grid {
@@ -530,46 +327,21 @@ h2 {
}
html[data-theme="dark"] .list-row,
html[data-theme="dark"] .subsystem-card,
html[data-theme="dark"] .refresh-button {
html[data-theme="dark"] .subsystem-card {
background: rgba(255, 255, 255, 0.03);
}
html[data-theme="dark"] .spotlight-button.secondary {
background: rgba(255, 255, 255, 0.08);
}
html[data-theme="dark"] .spotlight-button.success {
color: #6ee7b7;
}
html[data-theme="dark"] .spotlight-button.danger {
color: #fca5a5;
}
@media (max-width: 1240px) {
.hero,
.panel-grid,
.subsystem-grid,
.metric-grid {
grid-template-columns: 1fr;
}
.spotlight-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.hero-copy {
padding: 22px;
}
.spotlight-grid {
grid-template-columns: 1fr;
}
.spotlight-headline,
.timeline-head {
flex-direction: column;
}
@@ -2,7 +2,7 @@
import { computed, reactive, ref } from "vue";
import { useDesktopSession } from "../composables/useDesktopSession";
const { apiBaseURL, error, pending, setApiBaseURL, login, enterPreview } = useDesktopSession();
const { apiBaseURL, error, pending, setApiBaseURL, login } = useDesktopSession();
const showServerSettings = ref(false);
const rememberPassword = ref(!!localStorage.getItem("geo_rankly_saved_password"));
@@ -0,0 +1,617 @@
<script setup lang="ts">
import { ReloadOutlined, SearchOutlined } from "@ant-design/icons-vue";
import type { DesktopPublishTaskListResponse, DesktopTaskInfo, JsonValue } from "@geo/shared-types";
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import StatusBadge from "../components/StatusBadge.vue";
import SurfaceCard from "../components/SurfaceCard.vue";
import { useDesktopRuntime } from "../composables/useDesktopRuntime";
import { formatDateTime, formatRelativeTime, titleCaseToken } from "../lib/formatters";
const PAGE_SIZE = 10;
interface PublishTaskItem {
id: string;
title: string;
articleId: number | null;
platform: string;
accountId: string;
accountName: string;
status: DesktopTaskInfo["status"];
summary: string;
attempts: number;
leaseExpiresAt: number | null;
createdAt: number;
updatedAt: number;
externalArticleUrl: string | null;
externalManageUrl: string | null;
errorMessage: string | null;
}
let refreshTimer: ReturnType<typeof setInterval> | null = null;
function translatePlatform(platform: string) {
const map: Record<string, string> = {
toutiaohao: "头条号",
bilibili: "哔哩哔哩",
xiaohongshu: "小红书",
douyin: "抖音",
kuaishou: "快手",
wechat_oa: "微信公众号",
baijiahao: "百家号",
zhihu: "知乎",
};
return map[platform?.toLowerCase()] || titleCaseToken(platform);
}
function statusLabel(status: DesktopTaskInfo["status"]): string {
const map: Record<DesktopTaskInfo["status"], string> = {
queued: "等待发布",
in_progress: "正在发送",
succeeded: "发送成功",
failed: "发送失败",
unknown: "待确认",
aborted: "已取消",
};
return map[status];
}
function isPendingStatus(status: DesktopTaskInfo["status"]): boolean {
return status === "queued" || status === "in_progress";
}
function statusTone(status: DesktopTaskInfo["status"]): "info" | "warn" | "success" | "danger" {
switch (status) {
case "queued":
case "in_progress":
return "warn";
case "succeeded":
return "success";
case "failed":
case "unknown":
case "aborted":
default:
return "danger";
}
}
function extractNumber(value: JsonValue | null | undefined): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && /^\d+$/.test(value.trim())) {
return Number.parseInt(value.trim(), 10);
}
return null;
}
function extractString(value: JsonValue | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
function summaryForTask(task: DesktopTaskInfo): string {
switch (task.status) {
case "queued":
return "任务已进入队列,等前面的 PublishTasks 翻完后再继续展示历史记录。";
case "in_progress":
return "当前正在由桌面端执行发送,请避免重复触发。";
case "succeeded":
return "文章已成功发送到目标平台。";
case "failed":
return "本次发送失败,可以手动再次发送。";
case "aborted":
return "任务已被取消,如需继续可再次发送。";
case "unknown":
default:
return "平台返回结果仍待确认,建议人工检查后再次发送。";
}
}
function parseTimestamp(value: string | null): number {
if (!value) {
return Date.now();
}
const timestamp = Date.parse(value);
return Number.isNaN(timestamp) ? Date.now() : timestamp;
}
const { snapshot } = useDesktopRuntime();
const taskPage = ref<DesktopPublishTaskListResponse | null>(null);
const currentPage = ref(1);
const searchTitle = ref("");
const appliedTitle = ref("");
const loading = ref(false);
const error = ref<string | null>(null);
const actionError = ref<string | null>(null);
const actionPendingTaskId = ref<string | null>(null);
const actionMessage = ref<string | null>(null);
async function refreshTasks(page = currentPage.value) {
loading.value = true;
error.value = null;
try {
const response = await window.desktopBridge.app.listPublishTasks({
page,
page_size: PAGE_SIZE,
title: appliedTitle.value || undefined,
});
const lastPage = response.total > 0 ? Math.ceil(response.total / PAGE_SIZE) : 1;
if (response.total > 0 && page > lastPage) {
currentPage.value = lastPage;
await refreshTasks(lastPage);
return;
}
taskPage.value = response;
currentPage.value = response.page;
} catch (err) {
error.value = err instanceof Error ? err.message : "desktop publish tasks unavailable";
} finally {
loading.value = false;
}
}
async function retryTask(taskId: string) {
actionPendingTaskId.value = taskId;
actionError.value = null;
actionMessage.value = null;
try {
await window.desktopBridge.app.retryPublishTask(taskId);
actionMessage.value = "已重新加入发布队列。";
await refreshTasks();
} catch (err) {
actionError.value = err instanceof Error ? err.message : "重新发送失败";
} finally {
actionPendingTaskId.value = null;
}
}
function applySearch() {
currentPage.value = 1;
appliedTitle.value = searchTitle.value.trim();
void refreshTasks(1);
}
function handlePageChange(page: number) {
currentPage.value = page;
void refreshTasks(page);
}
function rowClassName(record: PublishTaskItem) {
return isPendingStatus(record.status) ? "publish-row--pending" : "";
}
watch(searchTitle, (value) => {
if (!value.trim() && appliedTitle.value) {
appliedTitle.value = "";
currentPage.value = 1;
void refreshTasks(1);
}
});
onMounted(() => {
void refreshTasks();
refreshTimer = setInterval(() => {
void refreshTasks();
}, 20_000);
});
onUnmounted(() => {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
});
const accountNameMap = computed(() => {
const entries = snapshot.value?.accounts ?? [];
return new Map(entries.map((item) => [item.id, item.displayName]));
});
const publishTasks = computed<PublishTaskItem[]>(() =>
(taskPage.value?.items ?? []).map((task) => {
const payload = task.payload ?? {};
const result = task.result ?? {};
const taskError = task.error ?? {};
const nestedContentRef =
payload.content_ref && typeof payload.content_ref === "object" && !Array.isArray(payload.content_ref)
? (payload.content_ref as Record<string, JsonValue>)
: null;
const articleId = extractNumber(payload.article_id) ?? extractNumber(nestedContentRef?.article_id ?? nestedContentRef?.id);
return {
id: task.id,
title: extractString(payload.title) ?? `发布任务 · ${translatePlatform(task.platform)}`,
articleId,
platform: task.platform,
accountId: task.target_account_id,
accountName: accountNameMap.value.get(task.target_account_id) ?? "待同步账号",
status: task.status,
summary: summaryForTask(task),
attempts: task.attempts,
leaseExpiresAt: task.lease_expires_at ? parseTimestamp(task.lease_expires_at) : null,
createdAt: parseTimestamp(task.created_at),
updatedAt: parseTimestamp(task.updated_at),
externalArticleUrl: extractString(result.external_article_url),
externalManageUrl: extractString(result.external_manage_url),
errorMessage:
extractString(taskError.message)
?? extractString(taskError.detail)
?? extractString(taskError.code),
};
}),
);
const pendingCount = computed(() => taskPage.value?.pending_count ?? 0);
const historyTotal = computed(() => taskPage.value?.history_total ?? 0);
const totalCount = computed(() => taskPage.value?.total ?? 0);
const tableColumns = [
{ title: "文章标题", key: "title", dataIndex: "title" },
{ title: "账号 / 平台", key: "account", dataIndex: "account" },
{ title: "状态", key: "status", dataIndex: "status", width: 130 },
{ title: "任务信息", key: "meta", dataIndex: "meta" },
{ title: "时间", key: "time", dataIndex: "time", width: 220 },
{ title: "操作", key: "actions", align: "right" as const, width: 120 },
];
</script>
<template>
<section class="page">
<header class="hero">
<div class="hero-copy">
<p class="eyebrow">PUBLISH EXECUTION DESK</p>
<h2>发布管理</h2>
<p class="hero-summary">
统一用一个 table 查看当前设备负责的待发布队列与已发送历史页面固定每页 10 待发布任务会始终排在最前面只有当前页的
PublishTasks 展示完后才会继续补已发送历史记录
</p>
</div>
</header>
<SurfaceCard
eyebrow="Publish Tasks"
title="待发布与已发送记录"
description="支持按文章标题搜索,分页与总数以服务端返回的 PublishTasks 结果为准。"
>
<template #action>
<a-button :loading="loading" @click="refreshTasks()">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</template>
<div class="table-toolbar">
<div class="toolbar-stats">
<span class="stat-chip">总数 {{ totalCount }}</span>
<span class="stat-chip">等待发布 {{ pendingCount }}</span>
<span class="stat-chip">已发送 {{ historyTotal }}</span>
</div>
<div class="toolbar-search">
<a-input
v-model:value="searchTitle"
allow-clear
placeholder="搜索文章标题"
class="search-input"
@pressEnter="applySearch"
>
<template #prefix>
<SearchOutlined style="color: #bfbfbf" />
</template>
</a-input>
<a-button type="primary" @click="applySearch">搜索</a-button>
</div>
</div>
<a-table
row-key="id"
class="modern-table publish-table"
:columns="tableColumns"
:data-source="publishTasks"
:pagination="false"
:loading="loading"
:row-class-name="rowClassName"
>
<template #emptyText>
<a-empty description="暂无匹配的发布任务" />
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'title'">
<div class="cell-primary-secondary">
<div class="info-stack">
<span class="title">{{ record.title }}</span>
<span class="subtitle">
<template v-if="record.articleId">文章 #{{ record.articleId }} · </template>
{{ isPendingStatus(record.status) ? "待发布队列" : "已发送历史" }}
</span>
</div>
</div>
</template>
<template v-else-if="column.key === 'account'">
<div class="cell-primary-secondary">
<div class="info-stack">
<span class="title">{{ record.accountName }}</span>
<span class="subtitle">{{ translatePlatform(record.platform) }}</span>
</div>
</div>
</template>
<template v-else-if="column.key === 'status'">
<div class="status-cell">
<StatusBadge :tone="statusTone(record.status)" :label="statusLabel(record.status)" />
<span v-if="isPendingStatus(record.status)" class="status-note">优先展示</span>
</div>
</template>
<template v-else-if="column.key === 'meta'">
<div class="cell-primary-secondary meta-cell">
<div class="info-stack">
<span class="subtitle strong-text">{{ record.summary }}</span>
<span v-if="record.errorMessage" class="error-text">{{ record.errorMessage }}</span>
<span class="subtitle">
尝试 {{ record.attempts }}
<template v-if="record.leaseExpiresAt"> · 租约 {{ formatRelativeTime(record.leaseExpiresAt) }} 到期</template>
<template v-else-if="record.externalArticleUrl"> · 已生成外链</template>
<template v-else-if="record.externalManageUrl"> · 已生成管理链接</template>
</span>
</div>
</div>
</template>
<template v-else-if="column.key === 'time'">
<div class="cell-primary-secondary">
<div class="info-stack">
<span class="title">{{ formatDateTime(record.updatedAt) }}</span>
<span class="subtitle">更新于 {{ formatRelativeTime(record.updatedAt) }}</span>
<span class="subtitle">创建于 {{ formatDateTime(record.createdAt) }}</span>
</div>
</div>
</template>
<template v-else-if="column.key === 'actions'">
<div class="action-buttons">
<a-button
v-if="!isPendingStatus(record.status)"
size="small"
:loading="actionPendingTaskId === record.id"
@click="retryTask(record.id)"
>
再次发送
</a-button>
<span v-else class="action-placeholder">排队中</span>
</div>
</template>
</template>
</a-table>
<div class="table-footer">
<p class="table-note">
当前页固定展示 10 待发布任务会先占满前面的页码 PublishTasks 展示完后才继续显示已发送历史记录
</p>
<a-pagination
:current="currentPage"
:page-size="PAGE_SIZE"
:total="totalCount"
:show-size-changer="false"
@change="handlePageChange"
/>
</div>
</SurfaceCard>
<p v-if="actionMessage" class="success-text">{{ actionMessage }}</p>
<p v-if="error" class="error-text page-feedback">{{ error }}</p>
<p v-if="actionError" class="error-text page-feedback">{{ actionError }}</p>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 18px;
}
.hero-copy {
padding: 24px;
border-radius: 12px;
border: 1px solid #e6edf5;
background: #ffffff;
}
.eyebrow,
.hero-summary,
h2 {
margin: 0;
}
.eyebrow {
color: #8c8c8c;
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
}
h2 {
margin-top: 8px;
font-size: 24px;
font-weight: 600;
color: #1a1a1a;
line-height: 1.4;
}
.hero-summary {
margin-top: 12px;
max-width: 860px;
color: #8c8c8c;
line-height: 1.6;
font-size: 13px;
}
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 18px;
margin-bottom: 16px;
}
.toolbar-stats,
.toolbar-search,
.status-cell,
.action-buttons {
display: flex;
align-items: center;
gap: 10px;
}
.toolbar-stats {
flex-wrap: wrap;
}
.toolbar-search {
justify-content: flex-end;
}
.search-input {
width: 280px;
}
.stat-chip {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0 12px;
border-radius: 999px;
background: #f5f7fa;
color: #4b5563;
font-size: 12px;
font-weight: 600;
}
.cell-primary-secondary {
display: flex;
align-items: center;
min-height: 52px;
}
.info-stack {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.title,
.subtitle,
.table-note,
.status-note,
.action-placeholder,
.success-text,
.error-text {
margin: 0;
}
.title {
color: #111827;
font-size: 14px;
font-weight: 600;
line-height: 1.5;
word-break: break-word;
}
.subtitle {
color: #8c8c8c;
font-size: 12px;
line-height: 1.6;
}
.strong-text {
color: #4b5563;
}
.meta-cell {
align-items: flex-start;
}
.status-note,
.action-placeholder {
color: #8c8c8c;
font-size: 12px;
}
.table-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 16px;
}
.table-note {
color: #8c8c8c;
font-size: 12px;
line-height: 1.6;
}
.success-text {
color: #15803d;
font-size: 13px;
}
.error-text {
color: #b42318;
font-size: 13px;
}
.page-feedback {
margin: 0;
}
:deep(.publish-table .ant-table-thead > tr > th) {
background: #f8fafc;
color: #4b5563;
font-size: 12px;
font-weight: 600;
border-bottom: 1px solid #e6edf5;
}
:deep(.publish-table .ant-table-tbody > tr > td) {
border-bottom: 1px solid #eef2f7;
vertical-align: top;
background: #ffffff;
}
:deep(.publish-table .publish-row--pending > td) {
background: #fffbeb;
}
:deep(.publish-table .ant-table-tbody > tr:hover > td) {
background: #f8fafc;
}
@media (max-width: 980px) {
.table-toolbar,
.table-footer {
flex-direction: column;
align-items: stretch;
}
.toolbar-search {
justify-content: stretch;
}
.search-input {
width: 100%;
}
}
</style>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { computed } from "vue";
import StatusBadge from "../components/StatusBadge.vue";
import SurfaceCard from "../components/SurfaceCard.vue";
@@ -7,61 +7,54 @@ import { useDesktopRuntime } from "../composables/useDesktopRuntime";
import { formatRelativeTime, titleCaseToken } from "../lib/formatters";
import { taskTone } from "../lib/runtime-ui";
const { snapshot, refresh } = useDesktopRuntime();
const actionPendingTaskId = ref<string | null>(null);
const actionError = ref<string | null>(null);
async function openReview(taskId: string) {
actionPendingTaskId.value = taskId;
actionError.value = null;
try {
await window.desktopBridge.app.openTaskReview(taskId);
} catch (error) {
actionError.value = error instanceof Error ? error.message : "打开审核页失败";
} finally {
actionPendingTaskId.value = null;
}
function translatePlatform(platform: string) {
const map: Record<string, string> = {
toutiaohao: "头条号",
bilibili: "哔哩哔哩",
xiaohongshu: "小红书",
douyin: "抖音",
kuaishou: "快手",
wechat_oa: "微信公众号",
baijiahao: "百家号",
zhihu: "知乎",
};
return map[platform?.toLowerCase()] || titleCaseToken(platform);
}
async function resolveParkedTask(taskId: string, status: "succeeded" | "failed") {
actionPendingTaskId.value = taskId;
actionError.value = null;
try {
await window.desktopBridge.app.resolveParkedTask(taskId, status);
await refresh();
} catch (error) {
actionError.value = error instanceof Error ? error.message : "回写审核结果失败";
} finally {
actionPendingTaskId.value = null;
}
function translateTaskStatus(status: string) {
const map: Record<string, string> = {
queued: "等待排队",
in_progress: "执行中",
succeeded: "成功",
failed: "失败",
unknown: "未知异常",
aborted: "已取消",
};
return map[status?.toLowerCase()] || status;
}
const { snapshot } = useDesktopRuntime();
const columns = computed(() => {
const tasks = snapshot.value?.tasks ?? [];
return [
{
key: "queued",
title: "Queued",
title: "等待派发 (Queued)",
description: "已经落库,等待 RabbitMQ 唤醒或客户端主动 lease。",
items: tasks.filter((item) => item.status === "queued"),
},
{
key: "in_progress",
title: "In Progress",
title: "正在执行 (In Progress)",
description: "当前被 client 持有活动租约,结果回写必须带 lease_token。",
items: tasks.filter((item) => item.status === "in_progress"),
},
{
key: "waiting_user",
title: "Waiting User",
description: "已释放 lease,不自动迁移,只允许原 target_client 恢复。",
items: tasks.filter((item) => item.status === "waiting_user"),
},
{
key: "exception",
title: "Fallback / Terminal",
title: "异常与终态 (Terminal)",
description: "unknown、failed、succeeded 等终态或补偿态。",
items: tasks.filter((item) => !["queued", "in_progress", "waiting_user"].includes(item.status)),
items: tasks.filter((item) => !["queued", "in_progress"].includes(item.status)),
},
];
});
@@ -74,7 +67,7 @@ const columns = computed(() => {
<p class="eyebrow">WORKFLOW CENTER</p>
<h2>任务调度中心</h2>
<p class="hero-summary">
基于 Lease-based 的可靠端云协议泳道全局监控本地客户端在 MQ 下发本地锁抢占及人工介入补偿各个流转阶段的阻塞情况
基于 lease 的可靠端云协议泳道这里更偏底层调度视角用来观察 MQ 下发租约占用与 fallback 消费的运行阶段
</p>
</div>
</header>
@@ -87,61 +80,29 @@ const columns = computed(() => {
:title="column.title"
:description="column.description"
>
<div class="lane-stack">
<div class="lane-stack" :class="{ 'scrollable-lane': column.items.length > 0 }">
<article v-for="task in column.items" :key="task.id" class="task-card">
<div class="task-head">
<div>
<div class="task-identity-copy">
<strong>{{ task.title }}</strong>
<p>{{ task.accountName }} · {{ titleCaseToken(task.platform) }}</p>
<p>{{ task.accountName }} · {{ translatePlatform(task.platform) }}</p>
</div>
<StatusBadge :tone="taskTone(task.status)" :label="task.status" />
<StatusBadge :tone="taskTone(task.status)" :label="translateTaskStatus(task.status)" class="shrink-badge" />
</div>
<div class="task-flags">
<StatusBadge tone="brand" :label="task.routing" subtle />
<StatusBadge tone="info" :label="task.kind" subtle />
<StatusBadge
:tone="task.mode === 'manual' ? 'warn' : 'success'"
:label="task.mode"
subtle
/>
</div>
<p class="task-summary">{{ task.summary }}</p>
<div v-if="task.status === 'waiting_user'" class="task-actions">
<button
type="button"
class="action-button secondary"
:disabled="actionPendingTaskId === task.id"
@click="openReview(task.id)"
>
打开审核页
</button>
<button
type="button"
class="action-button success"
:disabled="actionPendingTaskId === task.id"
@click="resolveParkedTask(task.id, 'succeeded')"
>
标记已发布
</button>
<button
type="button"
class="action-button danger"
:disabled="actionPendingTaskId === task.id"
@click="resolveParkedTask(task.id, 'failed')"
>
标记失败
</button>
</div>
<footer class="task-footer">
<span>
{{
task.leaseExpiresAt
? `lease ${formatRelativeTime(task.leaseExpiresAt)}`
: "lease released"
? `${formatRelativeTime(task.leaseExpiresAt)} 过期`
: "已释放"
}}
</span>
<small>{{ formatRelativeTime(task.updatedAt) }}</small>
@@ -149,7 +110,6 @@ const columns = computed(() => {
</article>
<p v-if="column.items.length === 0" class="empty-text">当前泳道为空</p>
</div>
<p v-if="actionError" class="error-text">{{ actionError }}</p>
</SurfaceCard>
</div>
</section>
@@ -157,12 +117,31 @@ const columns = computed(() => {
<style scoped>
.page,
.lane-grid,
.lane-stack {
.lane-grid {
display: grid;
gap: 18px;
}
.lane-stack {
display: flex;
flex-direction: column;
gap: 16px;
}
.scrollable-lane {
max-height: 480px;
overflow-y: auto;
padding-right: 6px;
}
.scrollable-lane::-webkit-scrollbar {
width: 6px;
}
.scrollable-lane::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 999px;
}
.hero {
display: block;
}
@@ -228,11 +207,22 @@ h2 {
gap: 16px;
}
.task-identity-copy {
flex: 1;
min-width: 0;
padding-right: 12px;
}
.task-head strong {
display: block;
font-size: 14px;
font-weight: 600;
color: #1a1a1a;
word-wrap: break-word;
}
.shrink-badge {
flex-shrink: 0;
}
.task-head p,