mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-09-21 06:00:11 +08:00
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:
@@ -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' ||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user