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:
2026-04-20 11:08:09 +08:00
parent ef868f81a1
commit 73dfbbe8b5
12 changed files with 579 additions and 53 deletions
+13 -1
View File
@@ -3,6 +3,8 @@ import { fileURLToPath } from "node:url";
import vue from "@vitejs/plugin-vue";
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));
@@ -48,7 +50,17 @@ export default defineConfig({
},
},
renderer: {
plugins: [vue()],
plugins: [
vue(),
Components({
dts: false,
resolvers: [
AntDesignVueResolver({
importStyle: false,
}),
],
}),
],
resolve: {
alias: {
"@renderer": resolve(rootDir, "src/renderer"),
+1
View File
@@ -33,6 +33,7 @@
"electron-builder": "^25.0.0",
"electron-vite": "^2.0.0",
"typescript": "^5.9.3",
"unplugin-vue-components": "^32.0.0",
"vite": "^5.4.19",
"vitest": "^2.0.0",
"vue-tsc": "^3.2.6"
+4 -4
View File
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { onMounted } from "vue";
import DesktopShell from "./components/DesktopShell.vue";
import { defineAsyncComponent, onMounted } from "vue";
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();
@@ -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">
import { computed } from "vue";
import { RouterLink, RouterView, useRoute } from "vue-router";
import {
AppstoreOutlined,
SendOutlined,
LinkOutlined,
RobotOutlined,
ToolOutlined,
} from "@ant-design/icons-vue";
import { useDesktopRuntime } from "../composables/useDesktopRuntime";
import { useDesktopSession } from "../composables/useDesktopSession";
@@ -30,30 +37,35 @@ const navItems = computed(() => {
title: "控制台",
description: "运行总览、活动流和总体健康度",
count: data?.summary.issuesOpen ?? 0,
icon: AppstoreOutlined,
},
{
to: "/publish-management",
title: "发布管理",
description: "查看待发布队列、历史发送结果,并对文章再次发送",
count: data?.summary.queuedTasks ?? 0,
icon: SendOutlined,
},
{
to: "/media-platforms",
title: "媒体平台",
description: "发布媒体授权、多个账号绑定与表格化管理",
count: accounts.filter((item) => publishIDs.has(item.platform)).length,
icon: LinkOutlined,
},
{
to: "/ai-platforms",
title: "AI 平台",
description: "AI 平台一平台一绑定,按卡片查看状态与归属",
count: accounts.filter((item) => monitoringIDs.has(item.platform)).length,
icon: RobotOutlined,
},
{
to: "/diagnostics",
title: "诊断",
description: "vault、scheduler、transport 与原始快照",
count: data?.clients.length ?? 0,
icon: ToolOutlined,
},
];
});
@@ -90,6 +102,7 @@ const clientLabel = computed(
@click="handleNavClick(item.to)"
>
<div class="nav-copy">
<component :is="item.icon" class="nav-icon" />
<strong>{{ item.title }}</strong>
</div>
<span class="nav-count">{{ item.count }}</span>
@@ -207,40 +220,56 @@ h1 {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px;
border-radius: 12px;
padding: 12px 16px;
border-radius: 8px;
text-decoration: none;
background: #ffffff;
border: 1px solid #e6edf5;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
background: transparent;
border: 1px solid transparent;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.nav-link:hover {
border-color: #91caff;
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.08);
transform: translateY(-1px);
background: #f8fafc;
}
.nav-link.active {
background: #f0f7ff;
border-color: #1677ff;
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.1);
background: #e6f4ff;
border-color: #e6f4ff;
}
.nav-copy {
display: grid;
gap: 4px;
display: flex;
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 {
font-size: 14px;
font-weight: 600;
color: #1a1a1a;
font-weight: 500;
color: #4b5563;
}
.nav-link:hover .nav-copy strong {
color: #1f2937;
}
.nav-link.active .nav-copy strong {
color: #1677ff;
font-weight: 600;
}
.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";
const RUNTIME_POLL_INTERVAL_MS = 15_000;
const snapshot = shallowRef<DesktopRuntimeSnapshot | null>(null);
const loading = shallowRef(false);
const error = shallowRef<string | null>(null);
let intervalHandle: ReturnType<typeof setInterval> | null = null;
let subscribers = 0;
let visibilityListenerBound = false;
function isPageVisible(): boolean {
return typeof document === "undefined" || document.visibilityState === "visible";
}
async function refreshRuntimeSnapshot() {
loading.value = true;
@@ -47,18 +54,8 @@ async function refreshAccounts() {
}
}
function startPolling() {
if (intervalHandle) {
return;
}
intervalHandle = setInterval(() => {
void refreshRuntimeSnapshot();
}, 15_000);
}
function stopPolling() {
if (subscribers > 0 || !intervalHandle) {
if (!intervalHandle) {
return;
}
@@ -66,18 +63,66 @@ function stopPolling() {
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() {
onMounted(() => {
subscribers += 1;
bindVisibilityListener();
if (!snapshot.value && !loading.value) {
void refreshRuntimeSnapshot();
}
startPolling();
syncPollingState();
});
onUnmounted(() => {
subscribers = Math.max(0, subscribers - 1);
stopPolling();
if (subscribers === 0) {
stopPolling();
unbindVisibilityListener();
return;
}
syncPollingState();
});
return {
@@ -1,5 +1,3 @@
import { Modal } from "ant-design-vue";
type ClientErrorTone = "error" | "warning";
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 { Modal } = await import("ant-design-vue");
const showModal = presentation.tone === "warning" ? Modal.warning : Modal.error;
showModal({
+1 -2
View File
@@ -1,5 +1,4 @@
import { createApp } from "vue";
import Antd from "ant-design-vue";
import App from "./App.vue";
import { router } from "./routes";
@@ -15,4 +14,4 @@ const themeQuery = window.matchMedia("(prefers-color-scheme: dark)");
applyTheme(themeQuery.matches);
themeQuery.addEventListener("change", (event) => applyTheme(event.matches));
createApp(App).use(Antd).use(router).mount("#app");
createApp(App).use(router).mount("#app");
+31 -15
View File
@@ -1,20 +1,36 @@
import { createRouter, createWebHashHistory } from "vue-router";
import { createRouter, createWebHashHistory, type RouteRecordRaw } from "vue-router";
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 PublishManagementView from "./views/PublishManagementView.vue";
const routes: RouteRecordRaw[] = [
{
path: "/",
name: "home",
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({
history: createWebHashHistory(),
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 },
],
routes,
});
+26
View File
@@ -184,6 +184,9 @@ importers:
typescript:
specifier: ^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:
specifier: ^5.4.19
version: 5.4.21(@types/node@24.12.0)(lightningcss@1.32.0)
@@ -4182,6 +4185,16 @@ packages:
resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==}
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:
resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -9235,6 +9248,19 @@ snapshots:
pathe: 2.0.3
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:
dependencies:
'@jridgewell/remapping': 2.3.5