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;