perf(desktop): lazy-load renderer routes and pause polling when hidden
Drop the eager Antd global import in favour of unplugin-vue-components on-demand resolution, async route components, defineAsyncComponent for the shell/login split, and a dynamic Modal import inside showClientActionError. Pair that with a visibility-aware runtime poller that stops the 15s snapshot refresh when the window is hidden and kicks off an immediate refresh on re-show. Refresh DesktopShell nav with inline icons and a calmer active/hover treatment, and land ActionButton/DisclosurePanel/PaginationBar primitives for upcoming views. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,8 @@ import { fileURLToPath } from "node:url";
|
|||||||
|
|
||||||
import vue from "@vitejs/plugin-vue";
|
import vue from "@vitejs/plugin-vue";
|
||||||
import { defineConfig, externalizeDepsPlugin } from "electron-vite";
|
import { defineConfig, externalizeDepsPlugin } from "electron-vite";
|
||||||
|
import Components from "unplugin-vue-components/vite";
|
||||||
|
import { AntDesignVueResolver } from "unplugin-vue-components/resolvers";
|
||||||
|
|
||||||
const rootDir = fileURLToPath(new URL(".", import.meta.url));
|
const rootDir = fileURLToPath(new URL(".", import.meta.url));
|
||||||
|
|
||||||
@@ -48,7 +50,17 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
renderer: {
|
renderer: {
|
||||||
plugins: [vue()],
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
Components({
|
||||||
|
dts: false,
|
||||||
|
resolvers: [
|
||||||
|
AntDesignVueResolver({
|
||||||
|
importStyle: false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@renderer": resolve(rootDir, "src/renderer"),
|
"@renderer": resolve(rootDir, "src/renderer"),
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"electron-builder": "^25.0.0",
|
"electron-builder": "^25.0.0",
|
||||||
"electron-vite": "^2.0.0",
|
"electron-vite": "^2.0.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
"unplugin-vue-components": "^32.0.0",
|
||||||
"vite": "^5.4.19",
|
"vite": "^5.4.19",
|
||||||
"vitest": "^2.0.0",
|
"vitest": "^2.0.0",
|
||||||
"vue-tsc": "^3.2.6"
|
"vue-tsc": "^3.2.6"
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from "vue";
|
import { defineAsyncComponent, onMounted } from "vue";
|
||||||
|
|
||||||
import DesktopShell from "./components/DesktopShell.vue";
|
|
||||||
import { useDesktopSession } from "./composables/useDesktopSession";
|
import { useDesktopSession } from "./composables/useDesktopSession";
|
||||||
import LoginView from "./views/LoginView.vue";
|
|
||||||
|
const DesktopShell = defineAsyncComponent(() => import("./components/DesktopShell.vue"));
|
||||||
|
const LoginView = defineAsyncComponent(() => import("./views/LoginView.vue"));
|
||||||
|
|
||||||
const { isAuthenticated, syncRuntimeSession } = useDesktopSession();
|
const { isAuthenticated, syncRuntimeSession } = useDesktopSession();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
tone?: "primary" | "secondary" | "ghost" | "danger" | "text";
|
||||||
|
size?: "sm" | "md";
|
||||||
|
loading?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
type?: "button" | "submit" | "reset";
|
||||||
|
title?: string;
|
||||||
|
}>(), {
|
||||||
|
tone: "secondary",
|
||||||
|
size: "md",
|
||||||
|
loading: false,
|
||||||
|
disabled: false,
|
||||||
|
type: "button",
|
||||||
|
title: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "click", payload: MouseEvent): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function handleClick(event: MouseEvent) {
|
||||||
|
if (props.loading || props.disabled) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit("click", event);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
:type="props.type"
|
||||||
|
class="button"
|
||||||
|
:class="[props.tone, props.size, { loading: props.loading }]"
|
||||||
|
:disabled="props.loading || props.disabled"
|
||||||
|
:title="props.title"
|
||||||
|
@click="handleClick"
|
||||||
|
>
|
||||||
|
<span class="button__content">
|
||||||
|
<span v-if="props.loading || $slots.icon" class="button__icon" aria-hidden="true">
|
||||||
|
<span v-if="props.loading" class="button__spinner"></span>
|
||||||
|
<slot v-else name="icon" />
|
||||||
|
</span>
|
||||||
|
<span class="button__label">
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.button {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1f2937;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s ease, background-color 0.2s ease, color 0.2s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button__content {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button__icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button__spinner {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
border-right-color: transparent;
|
||||||
|
animation: spin 0.65s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button__label {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sm {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md {
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
background: #1677ff;
|
||||||
|
border-color: #1677ff;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary:hover:not(:disabled) {
|
||||||
|
background: #4096ff;
|
||||||
|
border-color: #4096ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-color: #dbe5f0;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary:hover:not(:disabled) {
|
||||||
|
background: #eef4fb;
|
||||||
|
border-color: #bfd0e4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost {
|
||||||
|
background: #eff6ff;
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost:hover:not(:disabled) {
|
||||||
|
background: #dbeafe;
|
||||||
|
border-color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
background: #fff1f2;
|
||||||
|
border-color: #fecdd3;
|
||||||
|
color: #b42318;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger:hover:not(:disabled) {
|
||||||
|
background: #ffe4e6;
|
||||||
|
border-color: #fda4af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
background: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text:hover:not(:disabled) {
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import { RouterLink, RouterView, useRoute } from "vue-router";
|
import { RouterLink, RouterView, useRoute } from "vue-router";
|
||||||
|
import {
|
||||||
|
AppstoreOutlined,
|
||||||
|
SendOutlined,
|
||||||
|
LinkOutlined,
|
||||||
|
RobotOutlined,
|
||||||
|
ToolOutlined,
|
||||||
|
} from "@ant-design/icons-vue";
|
||||||
|
|
||||||
import { useDesktopRuntime } from "../composables/useDesktopRuntime";
|
import { useDesktopRuntime } from "../composables/useDesktopRuntime";
|
||||||
import { useDesktopSession } from "../composables/useDesktopSession";
|
import { useDesktopSession } from "../composables/useDesktopSession";
|
||||||
@@ -30,30 +37,35 @@ const navItems = computed(() => {
|
|||||||
title: "控制台",
|
title: "控制台",
|
||||||
description: "运行总览、活动流和总体健康度",
|
description: "运行总览、活动流和总体健康度",
|
||||||
count: data?.summary.issuesOpen ?? 0,
|
count: data?.summary.issuesOpen ?? 0,
|
||||||
|
icon: AppstoreOutlined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: "/publish-management",
|
to: "/publish-management",
|
||||||
title: "发布管理",
|
title: "发布管理",
|
||||||
description: "查看待发布队列、历史发送结果,并对文章再次发送",
|
description: "查看待发布队列、历史发送结果,并对文章再次发送",
|
||||||
count: data?.summary.queuedTasks ?? 0,
|
count: data?.summary.queuedTasks ?? 0,
|
||||||
|
icon: SendOutlined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: "/media-platforms",
|
to: "/media-platforms",
|
||||||
title: "媒体平台",
|
title: "媒体平台",
|
||||||
description: "发布媒体授权、多个账号绑定与表格化管理",
|
description: "发布媒体授权、多个账号绑定与表格化管理",
|
||||||
count: accounts.filter((item) => publishIDs.has(item.platform)).length,
|
count: accounts.filter((item) => publishIDs.has(item.platform)).length,
|
||||||
|
icon: LinkOutlined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: "/ai-platforms",
|
to: "/ai-platforms",
|
||||||
title: "AI 平台",
|
title: "AI 平台",
|
||||||
description: "AI 平台一平台一绑定,按卡片查看状态与归属",
|
description: "AI 平台一平台一绑定,按卡片查看状态与归属",
|
||||||
count: accounts.filter((item) => monitoringIDs.has(item.platform)).length,
|
count: accounts.filter((item) => monitoringIDs.has(item.platform)).length,
|
||||||
|
icon: RobotOutlined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: "/diagnostics",
|
to: "/diagnostics",
|
||||||
title: "诊断",
|
title: "诊断",
|
||||||
description: "vault、scheduler、transport 与原始快照",
|
description: "vault、scheduler、transport 与原始快照",
|
||||||
count: data?.clients.length ?? 0,
|
count: data?.clients.length ?? 0,
|
||||||
|
icon: ToolOutlined,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
@@ -90,6 +102,7 @@ const clientLabel = computed(
|
|||||||
@click="handleNavClick(item.to)"
|
@click="handleNavClick(item.to)"
|
||||||
>
|
>
|
||||||
<div class="nav-copy">
|
<div class="nav-copy">
|
||||||
|
<component :is="item.icon" class="nav-icon" />
|
||||||
<strong>{{ item.title }}</strong>
|
<strong>{{ item.title }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<span class="nav-count">{{ item.count }}</span>
|
<span class="nav-count">{{ item.count }}</span>
|
||||||
@@ -207,40 +220,56 @@ h1 {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 14px 16px;
|
padding: 12px 16px;
|
||||||
border-radius: 12px;
|
border-radius: 8px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
background: #ffffff;
|
background: transparent;
|
||||||
border: 1px solid #e6edf5;
|
border: 1px solid transparent;
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
|
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link:hover {
|
.nav-link:hover {
|
||||||
border-color: #91caff;
|
background: #f8fafc;
|
||||||
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.08);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link.active {
|
.nav-link.active {
|
||||||
background: #f0f7ff;
|
background: #e6f4ff;
|
||||||
border-color: #1677ff;
|
border-color: #e6f4ff;
|
||||||
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-copy {
|
.nav-copy {
|
||||||
display: grid;
|
display: flex;
|
||||||
gap: 4px;
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover .nav-icon {
|
||||||
|
color: #595959;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active .nav-icon {
|
||||||
|
color: #1677ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-copy strong {
|
.nav-copy strong {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
color: #1a1a1a;
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover .nav-copy strong {
|
||||||
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link.active .nav-copy strong {
|
.nav-link.active .nav-copy strong {
|
||||||
color: #1677ff;
|
color: #1677ff;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-count {
|
.nav-count {
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
title: string;
|
||||||
|
open?: boolean;
|
||||||
|
}>(), {
|
||||||
|
open: false,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<details class="panel" :open="open">
|
||||||
|
<summary class="panel__summary">
|
||||||
|
<span class="panel__title">{{ title }}</span>
|
||||||
|
<span class="panel__indicator" aria-hidden="true"></span>
|
||||||
|
</summary>
|
||||||
|
<div class="panel__body">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.panel {
|
||||||
|
border: 1px solid #e6edf5;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fafafb;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel + .panel {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel__summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel__summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel__indicator {
|
||||||
|
position: relative;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel__indicator::before,
|
||||||
|
.panel__indicator::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 10px;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #64748b;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel__indicator::after {
|
||||||
|
transform: translate(-50%, -50%) rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel[open] .panel__indicator::after {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel__body {
|
||||||
|
border-top: 1px dashed #e6edf5;
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel__title {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
current: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
maxVisiblePages?: number;
|
||||||
|
}>(), {
|
||||||
|
maxVisiblePages: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "change", page: number): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
type PageToken = number | "ellipsis";
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize)));
|
||||||
|
|
||||||
|
const tokens = computed<PageToken[]>(() => {
|
||||||
|
if (totalPages.value <= props.maxVisiblePages) {
|
||||||
|
return Array.from({ length: totalPages.value }, (_value, index) => index + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages: PageToken[] = [1];
|
||||||
|
let start = Math.max(2, props.current - 1);
|
||||||
|
let end = Math.min(totalPages.value - 1, props.current + 1);
|
||||||
|
|
||||||
|
while (end - start < 2 && start > 2) {
|
||||||
|
start -= 1;
|
||||||
|
}
|
||||||
|
while (end - start < 2 && end < totalPages.value - 1) {
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start > 2) {
|
||||||
|
pages.push("ellipsis");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let page = start; page <= end; page += 1) {
|
||||||
|
pages.push(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end < totalPages.value - 1) {
|
||||||
|
pages.push("ellipsis");
|
||||||
|
}
|
||||||
|
|
||||||
|
pages.push(totalPages.value);
|
||||||
|
return pages;
|
||||||
|
});
|
||||||
|
|
||||||
|
function goTo(page: number) {
|
||||||
|
if (page === props.current || page < 1 || page > totalPages.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit("change", page);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<nav v-if="totalPages > 1" class="pagination" aria-label="Pagination">
|
||||||
|
<button class="pagination__nav" :disabled="props.current <= 1" @click="goTo(props.current - 1)">
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="pagination__pages">
|
||||||
|
<template v-for="token in tokens" :key="`${token}-${typeof token === 'number' ? token : Math.random()}`">
|
||||||
|
<span v-if="token === 'ellipsis'" class="pagination__ellipsis">…</span>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="pagination__page"
|
||||||
|
:class="{ active: token === props.current }"
|
||||||
|
@click="goTo(token)"
|
||||||
|
>
|
||||||
|
{{ token }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="pagination__nav" :disabled="props.current >= totalPages" @click="goTo(props.current + 1)">
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination__pages {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination__nav,
|
||||||
|
.pagination__page {
|
||||||
|
appearance: none;
|
||||||
|
min-width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #dbe5f0;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #334155;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination__nav:hover:not(:disabled),
|
||||||
|
.pagination__page:hover:not(:disabled) {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination__nav:disabled,
|
||||||
|
.pagination__page:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination__page.active {
|
||||||
|
background: #1677ff;
|
||||||
|
border-color: #1677ff;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination__ellipsis {
|
||||||
|
width: 18px;
|
||||||
|
text-align: center;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,12 +2,19 @@ import { computed, onMounted, onUnmounted, readonly, shallowRef } from "vue";
|
|||||||
|
|
||||||
import type { DesktopRuntimeSnapshot } from "../types";
|
import type { DesktopRuntimeSnapshot } from "../types";
|
||||||
|
|
||||||
|
const RUNTIME_POLL_INTERVAL_MS = 15_000;
|
||||||
|
|
||||||
const snapshot = shallowRef<DesktopRuntimeSnapshot | null>(null);
|
const snapshot = shallowRef<DesktopRuntimeSnapshot | null>(null);
|
||||||
const loading = shallowRef(false);
|
const loading = shallowRef(false);
|
||||||
const error = shallowRef<string | null>(null);
|
const error = shallowRef<string | null>(null);
|
||||||
|
|
||||||
let intervalHandle: ReturnType<typeof setInterval> | null = null;
|
let intervalHandle: ReturnType<typeof setInterval> | null = null;
|
||||||
let subscribers = 0;
|
let subscribers = 0;
|
||||||
|
let visibilityListenerBound = false;
|
||||||
|
|
||||||
|
function isPageVisible(): boolean {
|
||||||
|
return typeof document === "undefined" || document.visibilityState === "visible";
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshRuntimeSnapshot() {
|
async function refreshRuntimeSnapshot() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@@ -47,18 +54,8 @@ async function refreshAccounts() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startPolling() {
|
|
||||||
if (intervalHandle) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
intervalHandle = setInterval(() => {
|
|
||||||
void refreshRuntimeSnapshot();
|
|
||||||
}, 15_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopPolling() {
|
function stopPolling() {
|
||||||
if (subscribers > 0 || !intervalHandle) {
|
if (!intervalHandle) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,18 +63,66 @@ function stopPolling() {
|
|||||||
intervalHandle = null;
|
intervalHandle = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncPollingState() {
|
||||||
|
if (subscribers === 0 || !isPageVisible()) {
|
||||||
|
stopPolling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intervalHandle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
intervalHandle = setInterval(() => {
|
||||||
|
void refreshRuntimeSnapshot();
|
||||||
|
}, RUNTIME_POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVisibilityChange() {
|
||||||
|
if (isPageVisible()) {
|
||||||
|
void refreshRuntimeSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
syncPollingState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindVisibilityListener() {
|
||||||
|
if (visibilityListenerBound || typeof document === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
visibilityListenerBound = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unbindVisibilityListener() {
|
||||||
|
if (!visibilityListenerBound || typeof document === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
visibilityListenerBound = false;
|
||||||
|
}
|
||||||
|
|
||||||
export function useDesktopRuntime() {
|
export function useDesktopRuntime() {
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
subscribers += 1;
|
subscribers += 1;
|
||||||
|
bindVisibilityListener();
|
||||||
if (!snapshot.value && !loading.value) {
|
if (!snapshot.value && !loading.value) {
|
||||||
void refreshRuntimeSnapshot();
|
void refreshRuntimeSnapshot();
|
||||||
}
|
}
|
||||||
startPolling();
|
syncPollingState();
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
subscribers = Math.max(0, subscribers - 1);
|
subscribers = Math.max(0, subscribers - 1);
|
||||||
stopPolling();
|
if (subscribers === 0) {
|
||||||
|
stopPolling();
|
||||||
|
unbindVisibilityListener();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncPollingState();
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { Modal } from "ant-design-vue";
|
|
||||||
|
|
||||||
type ClientErrorTone = "error" | "warning";
|
type ClientErrorTone = "error" | "warning";
|
||||||
type ClientActionKind = "bind-account" | "open-console" | "unbind-account";
|
type ClientActionKind = "bind-account" | "open-console" | "unbind-account";
|
||||||
|
|
||||||
@@ -119,8 +117,9 @@ function presentClientError(kind: ClientActionKind, error: unknown): ClientError
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showClientActionError(kind: ClientActionKind, error: unknown): void {
|
export async function showClientActionError(kind: ClientActionKind, error: unknown): Promise<void> {
|
||||||
const presentation = presentClientError(kind, error);
|
const presentation = presentClientError(kind, error);
|
||||||
|
const { Modal } = await import("ant-design-vue");
|
||||||
const showModal = presentation.tone === "warning" ? Modal.warning : Modal.error;
|
const showModal = presentation.tone === "warning" ? Modal.warning : Modal.error;
|
||||||
|
|
||||||
showModal({
|
showModal({
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { createApp } from "vue";
|
import { createApp } from "vue";
|
||||||
import Antd from "ant-design-vue";
|
|
||||||
|
|
||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
import { router } from "./routes";
|
import { router } from "./routes";
|
||||||
@@ -15,4 +14,4 @@ const themeQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
|||||||
applyTheme(themeQuery.matches);
|
applyTheme(themeQuery.matches);
|
||||||
themeQuery.addEventListener("change", (event) => applyTheme(event.matches));
|
themeQuery.addEventListener("change", (event) => applyTheme(event.matches));
|
||||||
|
|
||||||
createApp(App).use(Antd).use(router).mount("#app");
|
createApp(App).use(router).mount("#app");
|
||||||
|
|||||||
@@ -1,20 +1,36 @@
|
|||||||
import { createRouter, createWebHashHistory } from "vue-router";
|
import { createRouter, createWebHashHistory, type RouteRecordRaw } from "vue-router";
|
||||||
|
|
||||||
import AccountsView from "./views/AccountsView.vue";
|
const routes: RouteRecordRaw[] = [
|
||||||
import AiPlatformsView from "./views/AiPlatformsView.vue";
|
{
|
||||||
import DiagnosticsView from "./views/DiagnosticsView.vue";
|
path: "/",
|
||||||
import HomeView from "./views/HomeView.vue";
|
name: "home",
|
||||||
import PublishManagementView from "./views/PublishManagementView.vue";
|
component: () => import("./views/HomeView.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/publish-management",
|
||||||
|
name: "publish-management",
|
||||||
|
component: () => import("./views/PublishManagementView.vue"),
|
||||||
|
},
|
||||||
|
{ path: "/tasks", redirect: "/publish-management" },
|
||||||
|
{
|
||||||
|
path: "/media-platforms",
|
||||||
|
name: "media-platforms",
|
||||||
|
component: () => import("./views/AccountsView.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/ai-platforms",
|
||||||
|
name: "ai-platforms",
|
||||||
|
component: () => import("./views/AiPlatformsView.vue"),
|
||||||
|
},
|
||||||
|
{ path: "/accounts", redirect: "/media-platforms" },
|
||||||
|
{
|
||||||
|
path: "/diagnostics",
|
||||||
|
name: "diagnostics",
|
||||||
|
component: () => import("./views/DiagnosticsView.vue"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
history: createWebHashHistory(),
|
history: createWebHashHistory(),
|
||||||
routes: [
|
routes,
|
||||||
{ path: "/", name: "home", component: HomeView },
|
|
||||||
{ 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" },
|
|
||||||
{ path: "/diagnostics", name: "diagnostics", component: DiagnosticsView },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+26
@@ -184,6 +184,9 @@ importers:
|
|||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.9.3
|
specifier: ^5.9.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
unplugin-vue-components:
|
||||||
|
specifier: ^32.0.0
|
||||||
|
version: 32.0.0(vue@3.5.31(typescript@5.9.3))
|
||||||
vite:
|
vite:
|
||||||
specifier: ^5.4.19
|
specifier: ^5.4.19
|
||||||
version: 5.4.21(@types/node@24.12.0)(lightningcss@1.32.0)
|
version: 5.4.21(@types/node@24.12.0)(lightningcss@1.32.0)
|
||||||
@@ -4182,6 +4185,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==}
|
resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==}
|
||||||
engines: {node: '>=20.19.0'}
|
engines: {node: '>=20.19.0'}
|
||||||
|
|
||||||
|
unplugin-vue-components@32.0.0:
|
||||||
|
resolution: {integrity: sha512-uLdccgS7mf3pv1bCCP20y/hm+u1eOjAmygVkh+Oa70MPkzgl1eQv1L0CwdHNM3gscO8/GDMGIET98Ja47CBbZg==}
|
||||||
|
engines: {node: '>=20.19.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@nuxt/kit': ^3.2.2 || ^4.0.0
|
||||||
|
vue: ^3.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@nuxt/kit':
|
||||||
|
optional: true
|
||||||
|
|
||||||
unplugin@3.0.0:
|
unplugin@3.0.0:
|
||||||
resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==}
|
resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -9235,6 +9248,19 @@ snapshots:
|
|||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
|
|
||||||
|
unplugin-vue-components@32.0.0(vue@3.5.31(typescript@5.9.3)):
|
||||||
|
dependencies:
|
||||||
|
chokidar: 5.0.0
|
||||||
|
local-pkg: 1.1.2
|
||||||
|
magic-string: 0.30.21
|
||||||
|
mlly: 1.8.2
|
||||||
|
obug: 2.1.1
|
||||||
|
picomatch: 4.0.4
|
||||||
|
tinyglobby: 0.2.15
|
||||||
|
unplugin: 3.0.0
|
||||||
|
unplugin-utils: 0.3.1
|
||||||
|
vue: 3.5.31(typescript@5.9.3)
|
||||||
|
|
||||||
unplugin@3.0.0:
|
unplugin@3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/remapping': 2.3.5
|
'@jridgewell/remapping': 2.3.5
|
||||||
|
|||||||
Reference in New Issue
Block a user