feat(update-center): 实现集中式软件更新中心功能

新增更新中心模块,支持管理 APM 和传统 deb 软件更新任务
- 添加更新任务队列管理、状态跟踪和日志记录功能
- 实现更新项忽略配置持久化存储
- 新增更新确认对话框和迁移提示
- 优化主窗口关闭时的任务保护机制
- 添加单元测试覆盖核心逻辑
This commit is contained in:
2026-04-09 08:19:51 +08:00
parent 97bb8e5f59
commit 0b17ada45a
37 changed files with 6389 additions and 342 deletions

View File

@@ -442,7 +442,10 @@
import { computed, useAttrs, ref, watch } from "vue";
import axios from "axios";
import { useInstallFeedback, downloads } from "../global/downloadStatus";
import { APM_STORE_BASE_URL, getHybridDefaultOrigin } from "../global/storeConfig";
import {
APM_STORE_BASE_URL,
getHybridDefaultOrigin,
} from "../global/storeConfig";
import type { App } from "../global/typedefinition";
const attrs = useAttrs();

View File

@@ -1,145 +0,0 @@
<template>
<Transition
enter-active-class="duration-200 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="duration-150 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="show"
class="fixed inset-0 z-50 flex items-start justify-center bg-slate-900/70 px-4 py-10"
>
<div
class="w-full max-w-4xl max-h-[85vh] overflow-y-auto scrollbar-nowidth rounded-3xl border border-white/10 bg-white/95 p-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
>
<div class="flex flex-wrap items-center gap-3">
<div class="flex-1">
<p class="text-2xl font-semibold text-slate-900 dark:text-white">
软件更新
</p>
<p class="text-sm text-slate-500 dark:text-slate-400">
可更新的 APM 应用
</p>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 disabled:opacity-40 dark:border-slate-700 dark:text-slate-200"
:disabled="loading"
@click="$emit('refresh')"
>
<i class="fas fa-sync-alt"></i>
刷新
</button>
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 disabled:opacity-40 dark:border-slate-700 dark:text-slate-200"
:disabled="loading || apps.length === 0"
@click="$emit('toggle-all')"
>
<i class="fas fa-check-square"></i>
全选/全不选
</button>
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-r from-brand to-brand-dark px-4 py-2 text-sm font-semibold text-white shadow-lg disabled:opacity-40"
:disabled="loading || !hasSelected"
@click="$emit('upgrade-selected')"
>
<i class="fas fa-upload"></i>
更新选中
</button>
<button
type="button"
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200/70 text-slate-500 transition hover:text-slate-900 dark:border-slate-700"
@click="$emit('close')"
aria-label="关闭"
>
<i class="fas fa-xmark"></i>
</button>
</div>
</div>
<div class="mt-6 space-y-4">
<div
v-if="loading"
class="rounded-2xl border border-dashed border-slate-200/80 px-4 py-10 text-center text-slate-500 dark:border-slate-800/80 dark:text-slate-400"
>
正在检查可更新应用
</div>
<div
v-else-if="error"
class="rounded-2xl border border-rose-200/70 bg-rose-50/60 px-4 py-6 text-center text-sm text-rose-600 dark:border-rose-500/40 dark:bg-rose-500/10"
>
{{ error }}
</div>
<div
v-else-if="apps.length === 0"
class="rounded-2xl border border-slate-200/70 px-4 py-10 text-center text-slate-500 dark:border-slate-800/70 dark:text-slate-400"
>
暂无可更新应用
</div>
<div v-else class="space-y-3">
<label
v-for="app in apps"
:key="app.pkgname"
class="flex flex-col gap-3 rounded-2xl border border-slate-200/70 bg-white/90 p-4 shadow-sm dark:border-slate-800/70 dark:bg-slate-900/70 sm:flex-row sm:items-center sm:gap-4"
>
<div class="flex items-start gap-3">
<input
type="checkbox"
class="mt-1 h-4 w-4 rounded border-slate-300 accent-brand focus:ring-brand"
v-model="app.selected"
:disabled="app.upgrading"
/>
<div>
<p class="font-semibold text-slate-900 dark:text-white">
{{ app.pkgname }}
</p>
<p class="text-sm text-slate-500 dark:text-slate-400">
当前 {{ app.currentVersion || "-" }} · 更新至
{{ app.newVersion || "-" }}
</p>
</div>
</div>
<div class="flex items-center gap-2 sm:ml-auto">
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 disabled:opacity-40 dark:border-slate-700 dark:text-slate-200"
:disabled="app.upgrading"
@click.prevent="$emit('upgrade-one', app)"
>
<i class="fas fa-arrow-up"></i>
{{ app.upgrading ? "更新中…" : "更新" }}
</button>
</div>
</label>
</div>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import type { UpdateAppItem } from "../global/typedefinition";
defineProps<{
show: boolean;
apps: UpdateAppItem[];
loading: boolean;
error: string;
hasSelected: boolean;
}>();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const emit = defineEmits<{
(e: "close"): void;
(e: "refresh"): void;
(e: "toggle-all"): void;
(e: "upgrade-selected"): void;
(e: "upgrade-one", app: UpdateAppItem): void;
}>();
</script>

View File

@@ -0,0 +1,93 @@
<template>
<Transition
enter-active-class="duration-200 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="duration-150 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="show"
class="fixed inset-0 z-50 flex items-start justify-center bg-slate-900/70 px-4 py-6 lg:py-10"
>
<div
class="flex max-h-[90vh] w-full max-w-6xl flex-col overflow-hidden rounded-3xl border border-white/10 bg-white/95 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
>
<UpdateCenterToolbar
:search-query="store.searchQuery.value"
:selected-count="selectedCount"
@refresh="store.refresh"
@start-selected="emit('request-start-selected')"
@request-close="store.requestClose"
@update:search-query="emit('update:search-query', $event)"
/>
<div
v-if="store.snapshot.value.warnings.length > 0"
class="mx-6 mt-4 rounded-2xl border border-amber-200 bg-amber-50/80 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100"
>
<p
v-for="warning in store.snapshot.value.warnings"
:key="warning"
class="leading-6"
>
{{ warning }}
</p>
</div>
<div
class="grid min-h-0 flex-1 gap-0 lg:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]"
>
<UpdateCenterList
:items="store.filteredItems.value"
:tasks="store.snapshot.value.tasks"
:selected-task-keys="store.selectedTaskKeys.value"
@toggle-selection="emit('toggle-selection', $event)"
/>
<UpdateCenterLogPanel :tasks="store.snapshot.value.tasks" />
</div>
<UpdateCenterMigrationConfirm
:show="store.showMigrationConfirm.value"
@close="emit('dismiss-migration-confirm')"
@confirm="emit('confirm-migration-start')"
/>
<UpdateCenterCloseConfirm
:show="store.showCloseConfirm.value"
@close="emit('dismiss-close-confirm')"
@confirm="emit('confirm-close')"
/>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { UpdateCenterStore } from "@/modules/updateCenter";
import UpdateCenterCloseConfirm from "./update-center/UpdateCenterCloseConfirm.vue";
import UpdateCenterList from "./update-center/UpdateCenterList.vue";
import UpdateCenterLogPanel from "./update-center/UpdateCenterLogPanel.vue";
import UpdateCenterMigrationConfirm from "./update-center/UpdateCenterMigrationConfirm.vue";
import UpdateCenterToolbar from "./update-center/UpdateCenterToolbar.vue";
const emit = defineEmits<{
(e: "update:search-query", value: string): void;
(e: "toggle-selection", taskKey: string): void;
(e: "request-start-selected"): void;
(e: "confirm-migration-start"): void;
(e: "dismiss-migration-confirm"): void;
(e: "confirm-close"): void;
(e: "dismiss-close-confirm"): void;
}>();
const props = defineProps<{
show: boolean;
store: UpdateCenterStore;
}>();
const selectedCount = computed(() => props.store.getSelectedItems().length);
</script>

View File

@@ -0,0 +1,44 @@
<template>
<div
v-if="show"
class="absolute inset-0 flex items-center justify-center bg-slate-950/45 px-4"
>
<div
class="w-full max-w-md rounded-3xl border border-amber-200 bg-white p-6 shadow-2xl dark:border-amber-500/30 dark:bg-slate-900"
>
<p class="text-lg font-semibold text-slate-900 dark:text-white">
关闭确认
</p>
<p class="mt-2 text-sm text-slate-600 dark:text-slate-300">
更新仍在进行中确定关闭此窗口吗
</p>
<div class="mt-4 flex justify-end gap-3">
<button
type="button"
class="rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 dark:border-slate-700 dark:text-slate-200"
@click="$emit('close')"
>
继续查看
</button>
<button
type="button"
class="rounded-2xl bg-amber-500 px-4 py-2 text-sm font-semibold text-white"
@click="$emit('confirm')"
>
确认关闭
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
show: boolean;
}>();
defineEmits<{
(e: "close"): void;
(e: "confirm"): void;
}>();
</script>

View File

@@ -0,0 +1,116 @@
<template>
<label
class="flex flex-col gap-4 rounded-2xl border border-slate-200/70 bg-white/90 p-4 shadow-sm dark:border-slate-800/70 dark:bg-slate-900/70"
>
<div class="flex items-start gap-3">
<input
type="checkbox"
class="mt-1 h-4 w-4 rounded border-slate-300 accent-brand focus:ring-brand"
:checked="selected"
:disabled="item.ignored === true"
@change="$emit('toggle-selection')"
/>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<p class="font-semibold text-slate-900 dark:text-white">
{{ item.displayName }}
</p>
<span
class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-200"
>
{{ sourceLabel }}
</span>
<span
v-if="item.isMigration"
class="rounded-full bg-brand/10 px-2.5 py-1 text-xs font-semibold text-brand"
>
将迁移到 APM
</span>
<span
v-if="item.ignored === true"
class="rounded-full bg-slate-200 px-2.5 py-1 text-xs font-semibold text-slate-500 dark:bg-slate-800 dark:text-slate-300"
>
已忽略
</span>
</div>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
{{ item.packageName }} · 当前 {{ item.currentVersion }} · 更新至
{{ item.newVersion }}
</p>
<p
v-if="item.ignored === true"
class="mt-2 text-xs font-medium text-slate-500 dark:text-slate-400"
>
已忽略的更新不会加入本次任务
</p>
</div>
<div
v-if="task"
class="text-right text-sm font-semibold text-slate-600 dark:text-slate-300"
>
<p>{{ statusLabel }}</p>
<p v-if="showProgress" class="mt-1">{{ progressText }}</p>
</div>
</div>
<div v-if="showProgress" class="space-y-2">
<div
class="h-2 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800"
>
<div
class="h-full rounded-full bg-gradient-to-r from-brand to-brand-dark"
:style="progressStyle"
></div>
</div>
</div>
</label>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type {
UpdateCenterItem,
UpdateCenterTaskState,
} from "@/global/typedefinition";
const props = defineProps<{
item: UpdateCenterItem;
task?: UpdateCenterTaskState;
selected: boolean;
}>();
defineEmits<{
(e: "toggle-selection"): void;
}>();
const sourceLabel = computed(() => {
return props.item.source === "apm" ? "APM" : "传统deb";
});
const statusLabel = computed(() => {
switch (props.task?.status) {
case "downloading":
return "下载中";
case "installing":
return "安装中";
case "completed":
return "已完成";
case "failed":
return "失败";
case "cancelled":
return "已取消";
default:
return "待处理";
}
});
const showProgress = computed(() => {
return (
props.task?.status === "downloading" || props.task?.status === "installing"
);
});
const progressText = computed(() => `${props.task?.progress ?? 0}%`);
const progressStyle = computed(() => ({ width: progressText.value }));
</script>

View File

@@ -0,0 +1,47 @@
<template>
<div
class="min-h-0 overflow-y-auto border-r border-slate-200/70 p-6 dark:border-slate-800/70"
>
<div
v-if="items.length === 0"
class="rounded-2xl border border-dashed border-slate-200/80 px-4 py-10 text-center text-slate-500 dark:border-slate-800/80 dark:text-slate-400"
>
暂无可展示的更新任务
</div>
<div v-else class="space-y-3">
<UpdateCenterItem
v-for="item in items"
:key="item.taskKey"
:item="item"
:task="taskMap.get(item.taskKey)"
:selected="selectedTaskKeys.has(item.taskKey)"
@toggle-selection="$emit('toggle-selection', item.taskKey)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type {
UpdateCenterItem as UpdateCenterItemModel,
UpdateCenterTaskState,
} from "@/global/typedefinition";
import UpdateCenterItem from "./UpdateCenterItem.vue";
const props = defineProps<{
items: UpdateCenterItemModel[];
tasks: UpdateCenterTaskState[];
selectedTaskKeys: Set<string>;
}>();
defineEmits<{
(e: "toggle-selection", taskKey: string): void;
}>();
const taskMap = computed(() => {
return new Map(props.tasks.map((task) => [task.taskKey, task]));
});
</script>

View File

@@ -0,0 +1,45 @@
<template>
<aside
class="hidden min-h-0 flex-col border-t border-slate-200/70 bg-slate-50/70 p-6 lg:flex lg:border-t-0 dark:border-slate-800/70 dark:bg-slate-950/50"
>
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-semibold text-slate-900 dark:text-white">
任务日志
</p>
<span class="text-xs text-slate-500 dark:text-slate-400"
>{{ tasks.length }} </span
>
</div>
<div class="mt-4 min-h-0 flex-1 space-y-4 overflow-y-auto">
<div
v-if="tasks.length === 0"
class="rounded-2xl border border-dashed border-slate-200/80 px-4 py-8 text-center text-sm text-slate-500 dark:border-slate-800/80 dark:text-slate-400"
>
暂无运行日志
</div>
<div
v-for="task in tasks"
:key="task.taskKey"
class="rounded-2xl border border-slate-200/70 bg-white/80 p-4 dark:border-slate-800/70 dark:bg-slate-900/70"
>
<p class="text-sm font-semibold text-slate-900 dark:text-white">
{{ task.packageName }}
</p>
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
{{ task.status }}
</p>
<p class="mt-3 text-xs leading-5 text-slate-600 dark:text-slate-300">
{{ task.logs.at(-1)?.message || task.errorMessage || "等待日志输出" }}
</p>
</div>
</div>
</aside>
</template>
<script setup lang="ts">
import type { UpdateCenterTaskState } from "@/global/typedefinition";
defineProps<{
tasks: UpdateCenterTaskState[];
}>();
</script>

View File

@@ -0,0 +1,44 @@
<template>
<div
v-if="show"
class="absolute inset-0 flex items-center justify-center bg-slate-950/45 px-4"
>
<div
class="w-full max-w-md rounded-3xl border border-slate-200/70 bg-white p-6 shadow-2xl dark:border-slate-700 dark:bg-slate-900"
>
<p class="text-lg font-semibold text-slate-900 dark:text-white">
迁移确认
</p>
<p class="mt-2 text-sm text-slate-500 dark:text-slate-400">
部分传统 deb 更新将迁移到 APM 管理
</p>
<div class="mt-4 flex justify-end gap-3">
<button
type="button"
class="rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 dark:border-slate-700 dark:text-slate-200"
@click="$emit('close')"
>
取消
</button>
<button
type="button"
class="rounded-2xl bg-gradient-to-r from-brand to-brand-dark px-4 py-2 text-sm font-semibold text-white"
@click="$emit('confirm')"
>
继续更新
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
show: boolean;
}>();
defineEmits<{
(e: "close"): void;
(e: "confirm"): void;
}>();
</script>

View File

@@ -0,0 +1,73 @@
<template>
<div
class="flex flex-col gap-4 border-b border-slate-200/70 px-6 py-5 dark:border-slate-800/70"
>
<div class="flex items-start gap-4">
<div class="flex-1">
<p class="text-2xl font-semibold text-slate-900 dark:text-white">
软件更新
</p>
<p class="text-sm text-slate-500 dark:text-slate-400">
集中管理 APM 与传统 deb 更新任务
</p>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
@click="$emit('refresh')"
>
<i class="fas fa-sync-alt"></i>
刷新
</button>
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-r from-brand to-brand-dark px-4 py-2 text-sm font-semibold text-white shadow-lg disabled:opacity-40"
:disabled="selectedCount === 0"
@click="$emit('start-selected')"
>
<i class="fas fa-play"></i>
更新选中 ({{ selectedCount }})
</button>
<button
type="button"
aria-label="关闭"
class="inline-flex h-11 w-11 items-center justify-center rounded-full border border-slate-200/70 text-slate-500 transition hover:text-slate-900 dark:border-slate-700 dark:text-slate-300"
@click="$emit('request-close')"
>
<i class="fas fa-xmark"></i>
</button>
</div>
</div>
<label class="block">
<span class="sr-only">搜索更新</span>
<input
:value="searchQuery"
type="search"
placeholder="搜索应用或包名"
class="w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-brand/60 focus:bg-white dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 dark:focus:bg-slate-900"
@input="handleInput"
/>
</label>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
searchQuery: string;
selectedCount: number;
}>();
const emit = defineEmits<{
(e: "refresh"): void;
(e: "start-selected"): void;
(e: "request-close"): void;
(e: "update:search-query", value: string): void;
}>();
const handleInput = (event: Event): void => {
const target = event.target as HTMLInputElement | null;
emit("update:search-query", target?.value ?? props.searchQuery);
};
</script>