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