feat(update-center): 支持被系统锁定(hold)包的强制安装与更新中心体验优化

- 标记 apt-mark hold 的 spark(aptss) 源包,默认禁用勾选并提示开启强制安装
- 更新中心发起更新时透传 forceHeld,后端 Spark 本地 deb 走 shell-caller
  force-ssinstall 分支(单次免密 pkexec:unhold→ssinstall→hold),避免二次提权
- 无可选项时禁用全选并提示"无可更新项";更新选中后从列表中移除已启动项
- DownloadDetail 紧凑布局避免整页滚动;Toolbar 全选与更新选中按钮同行
- 继承 PKGNAME_PATTERN 与 metalinkUrl 白名单(https+*.spark-app.store+拒..)纵深防御
- apm 源无 hold 概念,forceHeld 在 apm 分支忽略(正确行为)
This commit is contained in:
xiyidaiwa
2026-08-13 16:42:21 +08:00
parent b5c5ab04b5
commit 0f62a7e36a
17 changed files with 893 additions and 105 deletions
+42 -33
View File
@@ -9,38 +9,41 @@
>
<div
v-if="show"
class="fixed inset-0 z-50 flex items-start justify-center bg-slate-900/70 px-4 py-10"
class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/70 px-4 py-6"
@click="handleOverlayClick"
@wheel="onOverlayWheel"
>
<div
class="scrollbar-nowidth scrollbar-thumb-slate-200 dark:scrollbar-thumb-slate-700 scrollbar-track-transparent w-full max-w-2xl max-h-[85vh] overflow-y-auto overscroll-contain rounded-3xl border border-white/10 bg-white/95 p-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
class="scrollbar-nowidth scrollbar-thumb-slate-200 dark:scrollbar-thumb-slate-700 scrollbar-track-transparent flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden rounded-3xl border border-white/10 bg-white/95 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
@click.stop
>
<div class="flex items-start justify-between">
<div class="flex items-start justify-between p-5 pb-3">
<div>
<p class="text-2xl font-semibold text-slate-900 dark:text-white">
<p class="text-xl font-semibold text-slate-900 dark:text-white">
下载详情
</p>
<p class="text-sm text-slate-500 dark:text-slate-400">
<p class="text-xs text-slate-500 dark:text-slate-400">
实时了解安装进度
</p>
</div>
<button
type="button"
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200/60 text-slate-500 transition hover:text-slate-900 dark:border-slate-700"
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-slate-200/60 text-slate-500 transition hover:text-slate-900 dark:border-slate-700"
@click="close"
>
<i class="fas fa-xmark"></i>
</button>
</div>
<div v-if="download" class="mt-6 space-y-6">
<div
v-if="download"
class="scrollbar-nowidth scrollbar-thumb-slate-200 dark:scrollbar-thumb-slate-700 scrollbar-track-transparent space-y-3 overflow-y-auto overscroll-contain px-5 pb-5"
>
<div
class="flex items-center gap-4 rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"
class="flex items-center gap-3 rounded-2xl border border-slate-200/60 p-3 dark:border-slate-800/60"
>
<div
class="h-16 w-16 overflow-hidden rounded-2xl bg-slate-100 dark:bg-slate-800"
class="h-12 w-12 shrink-0 overflow-hidden rounded-2xl bg-slate-100 dark:bg-slate-800"
>
<img
:src="download.icon"
@@ -48,18 +51,18 @@
class="h-full w-full object-cover"
/>
</div>
<div class="flex-1">
<p class="text-lg font-semibold text-slate-900 dark:text-white">
<div class="min-w-0 flex-1">
<p class="truncate text-base font-semibold text-slate-900 dark:text-white">
{{ download.name }}
</p>
<p class="text-sm text-slate-500 dark:text-slate-400">
<p class="truncate text-xs text-slate-500 dark:text-slate-400">
{{ download.pkgname }} · {{ download.version }}
</p>
</div>
</div>
<div
class="space-y-4 rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"
class="space-y-3 rounded-2xl border border-slate-200/60 p-3 dark:border-slate-800/60"
>
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-slate-500">状态</span>
@@ -80,7 +83,7 @@
</span>
</div>
<div v-if="download.status === 'downloading'" class="space-y-3">
<div v-if="download.status === 'downloading'" class="space-y-2">
<div class="h-2 rounded-full bg-slate-100 dark:bg-slate-800">
<div
class="h-full rounded-full bg-brand"
@@ -112,36 +115,42 @@
</div>
<div
class="rounded-2xl border border-slate-200/60 p-4 text-sm text-slate-600 dark:border-slate-800/60 dark:text-slate-300"
class="grid grid-cols-2 gap-x-4 gap-y-1 rounded-2xl border border-slate-200/60 p-3 text-sm text-slate-600 dark:border-slate-800/60 dark:text-slate-300"
>
<div class="flex justify-between py-1">
<span class="text-slate-400">下载源</span>
<span class="font-medium text-slate-900 dark:text-white">{{
<div class="flex justify-between gap-2">
<span class="shrink-0 text-slate-400">下载源</span>
<span class="truncate font-medium text-slate-900 dark:text-white">{{
download.origin === "spark" ? "Spark Store" : "APM Store"
}}</span>
</div>
<div v-if="download.startTime" class="flex justify-between py-1">
<span class="text-slate-400">开始时间</span>
<span>{{ formatDate(download.startTime) }}</span>
<div
v-if="download.startTime"
class="flex justify-between gap-2"
>
<span class="shrink-0 text-slate-400">开始时间</span>
<span class="truncate">{{ formatDate(download.startTime) }}</span>
</div>
<div v-if="download.endTime" class="flex justify-between py-1">
<span class="text-slate-400">完成时间</span>
<span>{{ formatDate(download.endTime) }}</span>
<div
v-if="download.endTime"
class="flex justify-between gap-2"
>
<span class="shrink-0 text-slate-400">完成时间</span>
<span class="truncate">{{ formatDate(download.endTime) }}</span>
</div>
<div
v-if="download.error"
class="flex justify-between py-1 text-rose-500"
class="col-span-2 flex justify-between gap-2 text-rose-500"
>
<span>错误信息</span>
<span class="text-right">{{ download.error }}</span>
<span class="shrink-0">错误信息</span>
<span class="truncate text-right">{{ download.error }}</span>
</div>
</div>
<div
v-if="download.logs && download.logs.length"
class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"
class="rounded-2xl border border-slate-200/60 p-3 dark:border-slate-800/60"
>
<div class="mb-3 flex items-center justify-between">
<div class="mb-2 flex items-center justify-between">
<span class="font-semibold text-slate-800 dark:text-slate-100"
>下载日志</span
>
@@ -155,22 +164,22 @@
</button>
</div>
<div
class="max-h-48 space-y-2 overflow-y-auto overscroll-contain rounded-2xl bg-slate-50/80 p-3 font-mono text-xs text-slate-600 dark:bg-slate-900/60 dark:text-slate-300"
class="max-h-32 space-y-1.5 overflow-y-auto overscroll-contain rounded-2xl bg-slate-50/80 p-3 font-mono text-xs text-slate-600 dark:bg-slate-900/60 dark:text-slate-300"
>
<div
v-for="(log, index) in download.logs"
:key="index"
class="flex gap-3"
>
<span class="text-slate-400">{{
<span class="shrink-0 text-slate-400">{{
formatLogTime(log.time)
}}</span>
<span>{{ log.message }}</span>
<span class="break-all">{{ log.message }}</span>
</div>
</div>
</div>
<div class="flex flex-wrap justify-end gap-3">
<div class="flex flex-wrap justify-end gap-3 pt-1">
<button
v-if="
download.status === 'downloading' ||
+21
View File
@@ -20,6 +20,7 @@
<UpdateCenterToolbar
:search-query="store.searchQuery.value"
:selected-count="selectedCount"
:selectable-count="store.selectableCount.value"
:all-selected="store.allSelected.value"
:some-selected="store.someSelected.value"
:loading="store.loading.value"
@@ -61,13 +62,24 @@
>
正在刷新更新列表
</div>
<div
v-if="heldLockedCount > 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 class="leading-6">
检测到 {{ heldLockedCount }} 个软件被系统锁定hold默认不会升级如需强制升级请在对应软件上打开强制安装开关
</p>
</div>
<div class="flex min-h-0 flex-1">
<UpdateCenterList
:items="store.filteredItems.value"
:tasks="store.snapshot.value.tasks"
:selected-task-keys="store.selectedTaskKeys.value"
:forced-task-keys="store.forcedTaskKeys.value"
:apps="apps"
@toggle-selection="emit('toggle-selection', $event)"
@toggle-force="store.toggleForce"
@ignore-item="store.ignoreItem"
@unignore-item="store.unignoreItem"
/>
@@ -110,6 +122,15 @@ const props = defineProps<{
const selectedCount = computed(() => props.store.getSelectedItems().length);
// 未开启强制安装的被锁定(held)软件数量,用于顶部提示
const heldLockedCount = computed(
() =>
props.store.snapshot.value.items.filter(
(i) =>
i.held === true && !props.store.forcedTaskKeys.value.has(i.taskKey),
).length,
);
const onOverlayWheel = (e: WheelEvent) => {
const target = e.target as HTMLElement;
if (target.closest(".overflow-y-auto, .overflow-auto")) return;
@@ -4,20 +4,22 @@
:class="
item.ignored === true
? 'border-slate-200/50 bg-slate-50/60 opacity-70 dark:border-slate-800/50 dark:bg-slate-900/40'
: 'border-slate-200/70 bg-white/90 dark:border-slate-800/70 dark:bg-slate-900/70'
: item.held === true
? 'border-amber-300/70 bg-amber-50/70 dark:border-amber-500/30 dark:bg-amber-500/10'
: 'border-slate-200/70 bg-white/90 dark:border-slate-800/70 dark:bg-slate-900/70'
"
>
<input
type="checkbox"
class="h-4 w-4 shrink-0 rounded border-slate-300 accent-brand focus:ring-brand"
:checked="selected"
:disabled="item.ignored === true"
:disabled="isSelectionLocked"
@change="$emit('toggle-selection')"
/>
<div
class="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-md bg-slate-100 text-slate-400 dark:bg-slate-800 dark:text-slate-500"
:class="item.ignored === true ? 'opacity-60' : ''"
:class="item.ignored === true || item.held === true ? 'opacity-60' : ''"
>
<img
:src="iconSrc"
@@ -50,6 +52,18 @@
>
已忽略
</span>
<span
v-else-if="item.held === true && !forced"
class="shrink-0 rounded-full bg-amber-200 px-2 py-0.5 text-[10px] font-semibold text-amber-700 dark:bg-amber-500/20 dark:text-amber-200"
>
已锁定
</span>
<span
v-else-if="item.held === true && forced"
class="shrink-0 rounded-full bg-amber-500 px-2 py-0.5 text-[10px] font-semibold text-white dark:bg-amber-500"
>
将强制
</span>
</div>
<p class="mt-0.5 truncate text-xs text-slate-500 dark:text-slate-400">
{{ item.packageName }} · {{ item.currentVersion }}
@@ -66,7 +80,7 @@
</div>
<div
v-if="item.ignored !== true"
v-if="item.ignored !== true && !task"
class="flex shrink-0 flex-col items-end gap-0.5"
>
<span class="text-[11px] font-semibold text-brand dark:text-amber-300">{{
@@ -74,13 +88,39 @@
}}</span>
<span class="text-[10px] text-slate-400">{{ sizeLabel }}</span>
</div>
<div v-else class="flex shrink-0 flex-col items-end gap-0.5">
<div
v-else-if="!task"
class="flex shrink-0 flex-col items-end gap-0.5"
>
<span class="text-[11px] font-semibold text-slate-400">{{
timeLabel
}}</span>
<span class="text-[10px] text-slate-400">{{ sizeLabel }}</span>
</div>
<!-- 强制安装开关仅对被系统锁定held的包显示 -->
<div
v-if="item.held === true && !task"
class="flex shrink-0 items-center gap-1"
:title="forced ? '已开启强制安装' : '强制安装被锁定的软件'"
>
<button
type="button"
class="relative inline-flex h-[18px] w-[34px] items-center rounded-full transition"
:class="forced ? 'bg-brand' : 'bg-slate-300 dark:bg-slate-600'"
role="switch"
:aria-checked="forced"
aria-label="强制安装"
@click.stop="$emit('toggle-force')"
>
<span
class="inline-block h-3.5 w-3.5 transform rounded-full bg-white transition"
:class="forced ? 'translate-x-[16px]' : 'translate-x-[2px]'"
></span>
</button>
<span class="text-[10px] text-slate-400">强制</span>
</div>
<button
v-if="item.ignored === true"
type="button"
@@ -124,6 +164,8 @@ const props = defineProps<{
item: UpdateCenterItem;
task?: UpdateCenterTaskState;
selected: boolean;
// 是否已开启强制安装(仅对 held 项有意义)
forced?: boolean;
}>();
const PLACEHOLDER_ICON =
@@ -134,8 +176,14 @@ defineEmits<{
(e: "toggle-selection"): void;
(e: "ignore-item"): void;
(e: "unignore-item"): void;
(e: "toggle-force"): void;
}>();
// 被锁定且未强制时,复选框禁用(与「已忽略」一样不可被选中)
const isSelectionLocked = computed(
() => props.item.ignored === true || (props.item.held === true && !props.forced),
);
const normalizeIconSrc = (icon: string): string => {
if (/^[a-z]+:\/\//i.test(icon)) {
return icon;
@@ -15,7 +15,9 @@
:item="item"
:task="taskMap.get(item.taskKey)"
:selected="selectedTaskKeys.has(item.taskKey)"
:forced="forcedTaskKeys.has(item.taskKey)"
@toggle-selection="$emit('toggle-selection', item.taskKey)"
@toggle-force="$emit('toggle-force', item.taskKey)"
@ignore-item="$emit('ignore-item', item.packageName, item.newVersion)"
@unignore-item="
$emit('unignore-item', item.packageName, item.newVersion)
@@ -40,11 +42,13 @@ const props = defineProps<{
items: UpdateCenterItemModel[];
tasks: UpdateCenterTaskState[];
selectedTaskKeys: Set<string>;
forcedTaskKeys: Set<string>;
apps: App[];
}>();
defineEmits<{
(e: "toggle-selection", taskKey: string): void;
(e: "toggle-force", taskKey: string): void;
(e: "ignore-item", packageName: string, newVersion: string): void;
(e: "unignore-item", packageName: string, newVersion: string): void;
}>();
@@ -11,7 +11,7 @@
集中管理 APM 与传统 deb 更新任务
</p>
</div>
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center justify-end 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 dark:hover:bg-slate-800"
@@ -21,6 +21,37 @@
<i class="fas fa-sync-alt" :class="{ 'animate-spin': loading }"></i>
{{ loading ? "刷新中" : "刷新" }}
</button>
<label
class="inline-flex cursor-pointer items-center gap-2 select-none rounded-2xl border border-slate-200/70 px-3 py-2 transition hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800"
:class="{
'cursor-not-allowed opacity-50 hover:bg-transparent dark:hover:bg-transparent':
selectableCount === 0,
}"
:title="
selectableCount === 0
? '当前没有可更新项(全部被锁定未强制或已忽略)'
: '全选所有可更新项'
"
>
<input
ref="selectAllRef"
type="checkbox"
class="h-4 w-4 rounded border-slate-300 accent-brand focus:ring-brand disabled:cursor-not-allowed"
:checked="allSelected"
:disabled="selectableCount === 0"
@change="$emit('toggle-select-all')"
/>
<span class="text-sm font-medium text-slate-700 dark:text-slate-200"
>全选</span
>
</label>
<span
class="text-sm text-slate-400 dark:text-slate-500"
:title="selectableCount === 0 ? '无可更新项' : '已选中的更新项数量'"
>
<template v-if="selectableCount === 0">无可更新项</template>
<template v-else>已选 {{ selectedCount }} </template>
</span>
<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"
@@ -41,24 +72,6 @@
</div>
</div>
<div class="flex items-center gap-3">
<label class="inline-flex cursor-pointer items-center gap-2 select-none">
<input
ref="selectAllRef"
type="checkbox"
class="h-4 w-4 rounded border-slate-300 accent-brand focus:ring-brand"
:checked="allSelected"
@change="$emit('toggle-select-all')"
/>
<span class="text-sm font-medium text-slate-700 dark:text-slate-200"
>全选</span
>
</label>
<span class="text-sm text-slate-400 dark:text-slate-500">
已选 {{ selectedCount }}
</span>
</div>
<label class="block relative">
<span class="sr-only">搜索更新</span>
<i
@@ -89,6 +102,7 @@ import { ref, watch } from "vue";
const props = defineProps<{
searchQuery: string;
selectedCount: number;
selectableCount: number;
allSelected: boolean;
someSelected: boolean;
loading?: boolean;