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
@@ -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>