support categories

This commit is contained in:
Julian Freeman
2026-04-18 20:45:10 -04:00
parent 87ffe2e243
commit cfe93144e6
10 changed files with 368 additions and 15 deletions

View File

@@ -75,6 +75,7 @@ pub struct EssentialsStatusItem {
pub id: String,
pub name: String,
pub description: Option<String>,
pub category: Option<String>,
pub version: Option<String>,
pub recommended_version: Option<String>,
pub available_version: Option<String>,
@@ -92,6 +93,7 @@ pub struct UpdateCandidate {
pub id: String,
pub name: String,
pub description: Option<String>,
pub category: Option<String>,
pub version: Option<String>,
pub available_version: Option<String>,
pub icon_url: Option<String>,

View File

@@ -63,6 +63,7 @@ pub fn build_essentials_status(
id: definition.id.clone(),
name: definition.name.clone(),
description: definition.description.clone(),
category: definition.category.clone(),
version: current_version,
recommended_version,
available_version,
@@ -95,6 +96,7 @@ pub fn build_update_candidates(
id: update.id.clone(),
name: update.name.clone(),
description: definition.and_then(|item| item.description.clone()),
category: definition.and_then(|item| item.category.clone()),
version: update.version.clone(),
available_version: update.available_version.clone(),
icon_url: update.icon_url.clone().or_else(|| definition.and_then(|item| item.icon_url.clone())),

View File

@@ -47,6 +47,7 @@ pub struct Software {
pub id: String,
pub name: String,
pub description: Option<String>,
pub category: Option<String>,
pub version: Option<String>,
pub available_version: Option<String>,
pub icon_url: Option<String>,
@@ -387,6 +388,7 @@ fn map_package(p: WingetPackage) -> Software {
id: p.id,
name: p.name,
description: None,
category: None,
version: p.installed_version,
available_version: p.available_versions.and_then(|v| v.first().cloned()),
icon_url: p.icon_url,

View File

@@ -12,6 +12,17 @@
</span>
装机必备
</router-link>
<router-link to="/other-software" class="nav-item">
<span class="nav-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="6" rx="1.5"></rect>
<rect x="3" y="14" width="18" height="6" rx="1.5"></rect>
<path d="M7 7h.01"></path>
<path d="M7 17h.01"></path>
</svg>
</span>
其他软件
</router-link>
<router-link to="/updates" class="nav-item">
<span class="nav-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">

View File

@@ -11,6 +11,10 @@ const router = createRouter({
path: '/essentials',
component: () => import('../views/Essentials.vue')
},
{
path: '/other-software',
component: () => import('../views/OtherSoftware.vue')
},
{
path: '/updates',
component: () => import('../views/Updates.vue')

View File

@@ -22,12 +22,13 @@ export const useSoftwareStore = defineStore('software', () => {
const {
activeTasks,
selectedEssentialIds,
selectedSpecialIds,
selectedUpdateIds,
logs,
postInstallPrefs
} = storeToRefs(taskRuntime)
const mergedEssentials = computed(() => essentials.value.map(item => {
const mergeSoftwareItem = (item: typeof essentials.value[number]) => {
const task = activeTasks.value[item.id]
const enablePostInstall = postInstallPrefs.value[item.id] !== false
const baseStatus = item.action_label === '已安装' ? 'installed' : 'idle'
@@ -40,13 +41,27 @@ export const useSoftwareStore = defineStore('software', () => {
icon_url: item.icon_url ?? undefined,
manifest_url: item.manifest_url ?? undefined,
post_install_url: item.post_install_url ?? undefined,
category: item.category ?? 'general',
actionLabel: item.action_label,
targetVersion: item.target_version ?? undefined,
status: task ? task.status : baseStatus,
progress: task ? task.progress : 0,
enablePostInstall
}
}))
}
const mergedEssentials = computed(() => essentials.value.map(mergeSoftwareItem))
const categorizedEssentials = computed(() => {
return mergedEssentials.value.reduce((acc, item) => {
const category = item.category === 'special' ? 'special' : 'general'
acc[category].push(item)
return acc
}, { general: [] as typeof mergedEssentials.value, special: [] as typeof mergedEssentials.value })
})
const generalEssentials = computed(() => categorizedEssentials.value.general)
const specialEssentials = computed(() => categorizedEssentials.value.special)
const sortedUpdates = computed(() => [...updates.value].map(item => {
const task = activeTasks.value[item.id]
@@ -70,33 +85,44 @@ export const useSoftwareStore = defineStore('software', () => {
const isBusy = computed(() => loading.value || taskRuntime.isTaskBusy)
const toggleSelection = (id: string, type: 'essential' | 'update') => {
const toggleSelection = (id: string, type: 'essential' | 'special' | 'update') => {
if (isBusy.value) return
taskRuntime.toggleSelection(id, type)
}
const selectAll = (type: 'essential' | 'update') => {
const selectAll = (type: 'essential' | 'special' | 'update') => {
if (type === 'essential') {
const selectable = mergedEssentials.value.filter(item => item.actionLabel !== '已安装')
const selectable = generalEssentials.value.filter(item => item.actionLabel !== '已安装')
taskRuntime.setSelection('essential', selectable.map(item => item.id))
} else if (type === 'special') {
const selectable = specialEssentials.value.filter(item => item.actionLabel !== '已安装')
taskRuntime.setSelection('special', selectable.map(item => item.id))
} else {
taskRuntime.setSelection('update', updates.value.map(item => item.id))
}
}
const deselectAll = (type: 'essential' | 'update') => {
const deselectAll = (type: 'essential' | 'special' | 'update') => {
taskRuntime.setSelection(type, [])
}
const invertSelection = (type: 'essential' | 'update') => {
const invertSelection = (type: 'essential' | 'special' | 'update') => {
if (type === 'essential') {
const selectable = mergedEssentials.value
const selectable = generalEssentials.value
.filter(item => item.actionLabel !== '已安装')
.map(item => item.id)
taskRuntime.setSelection(
'essential',
selectable.filter(id => !selectedEssentialIds.value.includes(id))
)
} else if (type === 'special') {
const selectable = specialEssentials.value
.filter(item => item.actionLabel !== '已安装')
.map(item => item.id)
taskRuntime.setSelection(
'special',
selectable.filter(id => !selectedSpecialIds.value.includes(id))
)
} else {
const selectable = updates.value.map(item => item.id)
taskRuntime.setSelection(
@@ -116,11 +142,13 @@ export const useSoftwareStore = defineStore('software', () => {
if (isBusy.value) return
await catalog.syncDataIfNeeded(force)
if (selectedEssentialIds.value.length === 0) selectAll('essential')
if (selectedSpecialIds.value.length === 0) selectAll('special')
}
const fetchAllData = async () => {
await catalog.fetchAllData()
if (selectedEssentialIds.value.length === 0) selectAll('essential')
if (selectedSpecialIds.value.length === 0) selectAll('special')
}
return {
@@ -129,6 +157,7 @@ export const useSoftwareStore = defineStore('software', () => {
updates,
allSoftware,
selectedEssentialIds,
selectedSpecialIds,
selectedUpdateIds,
logs,
settings,
@@ -137,6 +166,8 @@ export const useSoftwareStore = defineStore('software', () => {
isInitialized,
initStatus,
mergedEssentials,
generalEssentials,
specialEssentials,
sortedUpdates,
isBusy,
initializeApp: catalog.initializeApp,

View File

@@ -9,6 +9,7 @@ export const useTaskRuntimeStore = defineStore('task-runtime', {
state: () => ({
taskRecords: {} as Record<string, TaskRecord>,
selectedEssentialIds: [] as string[],
selectedSpecialIds: [] as string[],
selectedUpdateIds: [] as string[],
logs: [] as LogEntry[],
refreshTimer: null as ReturnType<typeof setTimeout> | null,
@@ -33,15 +34,20 @@ export const useTaskRuntimeStore = defineStore('task-runtime', {
}
},
actions: {
toggleSelection(id: string, type: 'essential' | 'update') {
const list = type === 'essential' ? this.selectedEssentialIds : this.selectedUpdateIds
toggleSelection(id: string, type: 'essential' | 'special' | 'update') {
const list = type === 'essential'
? this.selectedEssentialIds
: type === 'special'
? this.selectedSpecialIds
: this.selectedUpdateIds
const index = list.indexOf(id)
if (index === -1) list.push(id)
else list.splice(index, 1)
},
setSelection(type: 'essential' | 'update', ids: string[]) {
setSelection(type: 'essential' | 'special' | 'update', ids: string[]) {
if (type === 'essential') this.selectedEssentialIds = ids
else if (type === 'special') this.selectedSpecialIds = ids
else this.selectedUpdateIds = ids
},
@@ -148,6 +154,7 @@ export const useTaskRuntimeStore = defineStore('task-runtime', {
}
this.selectedEssentialIds = this.selectedEssentialIds.filter(item => item !== payload.software_id)
this.selectedSpecialIds = this.selectedSpecialIds.filter(item => item !== payload.software_id)
this.selectedUpdateIds = this.selectedUpdateIds.filter(item => item !== payload.software_id)
setTimeout(() => {

View File

@@ -22,6 +22,7 @@ export interface SoftwareListItem {
id: string
name: string
description?: string
category?: string | null
version?: string | null
recommended_version?: string | null
available_version?: string | null

View File

@@ -50,14 +50,14 @@
<!-- 可滚动内容区域 -->
<div class="scroll-content">
<div v-if="store.loading && store.mergedEssentials.length === 0" class="loading-state">
<div v-if="store.loading && store.generalEssentials.length === 0" class="loading-state">
<div class="spinner"></div>
<p>正在读取必备软件列表...</p>
</div>
<div v-else class="software-list">
<SoftwareCard
v-for="item in store.mergedEssentials"
v-for="item in store.generalEssentials"
:key="item.id"
:software="item"
:action-label="item.actionLabel"
@@ -81,14 +81,14 @@ import { onMounted, computed } from 'vue';
const store = useSoftwareStore();
const selectableItems = computed(() => {
return store.mergedEssentials.filter(s => s.status !== 'installed');
return store.generalEssentials.filter(s => s.status !== 'installed');
});
const installSelected = () => {
const ids = [...store.selectedEssentialIds];
store.startBatch(ids);
ids.forEach(id => {
const item = store.mergedEssentials.find(s => s.id === id);
const item = store.generalEssentials.find(s => s.id === id);
if (item) {
store.install(id, item.targetVersion);
}

293
src/views/OtherSoftware.vue Normal file
View File

@@ -0,0 +1,293 @@
<template>
<main class="content">
<div class="sticky-header">
<header class="content-header">
<div class="header-left">
<h1>其他软件</h1>
<span v-if="store.essentialsVersion" class="version-badge">版本: {{ store.essentialsVersion }}</span>
</div>
<div class="header-actions">
<button
@click="store.syncDataIfNeeded(true)"
class="secondary-btn action-btn"
:disabled="store.loading || store.isBusy"
>
<span class="icon" :class="{ spinning: store.loading }">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 2v6h-6"></path>
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path>
<path d="M3 22v-6h6"></path>
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path>
</svg>
</span>
{{ store.loading ? '正在刷新...' : '刷新状态' }}
</button>
<button
@click="installSelected"
class="primary-btn action-btn"
:disabled="store.loading || store.isBusy || store.selectedSpecialIds.length === 0"
>
安装所选 ({{ store.selectedSpecialIds.length }})
</button>
</div>
</header>
<div class="selection-toolbar" v-if="selectableItems.length > 0">
<div class="toolbar-left">
<span class="selection-count">已选 {{ store.selectedSpecialIds.length }} / {{ selectableItems.length }} </span>
</div>
<div class="toolbar-actions">
<button @click="store.selectAll('special')" class="text-btn" :disabled="store.isBusy">全选</button>
<div class="divider"></div>
<button @click="store.deselectAll('special')" class="text-btn" :disabled="store.isBusy">取消</button>
<div class="divider"></div>
<button @click="store.invertSelection('special')" class="text-btn" :disabled="store.isBusy">反选</button>
</div>
</div>
</div>
<div class="scroll-content">
<div v-if="store.loading && store.specialEssentials.length === 0" class="loading-state">
<div class="spinner"></div>
<p>正在读取其他软件列表...</p>
</div>
<div v-else-if="store.specialEssentials.length === 0" class="empty-state">
<p>当前清单没有标记为其他软件的项目</p>
</div>
<div v-else class="software-list">
<SoftwareCard
v-for="item in store.specialEssentials"
:key="item.id"
:software="item"
:action-label="item.actionLabel"
:selectable="true"
:is-selected="store.selectedSpecialIds.includes(item.id)"
:disabled="store.isBusy"
@install="store.install"
@toggle-select="store.toggleSelection($event, 'special')"
@toggle-post-install="store.togglePostInstallPref"
/>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import SoftwareCard from '../components/SoftwareCard.vue'
import { useSoftwareStore } from '../store/software'
const store = useSoftwareStore()
const selectableItems = computed(() => {
return store.specialEssentials.filter(item => item.status !== 'installed')
})
const installSelected = () => {
const ids = [...store.selectedSpecialIds]
store.startBatch(ids)
ids.forEach(id => {
const item = store.specialEssentials.find(software => software.id === id)
if (item) {
store.install(id, item.targetVersion)
}
})
}
onMounted(() => {
store.syncDataIfNeeded()
store.initListener()
})
</script>
<style scoped>
.content {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.sticky-header {
padding: 24px 60px 8px 60px;
background-color: #F5F5F7;
z-index: 10;
flex-shrink: 0;
}
.scroll-content {
flex: 1;
overflow-y: auto;
padding: 8px 60px 32px 60px;
}
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.header-left {
display: flex;
align-items: baseline;
gap: 12px;
}
.version-badge {
font-size: 13px;
font-weight: 500;
color: var(--text-sec);
background-color: rgba(0, 0, 0, 0.05);
padding: 4px 10px;
border-radius: 20px;
}
.content-header h1 {
font-size: 32px;
font-weight: 700;
letter-spacing: -0.5px;
color: var(--text-main);
}
.header-actions {
display: flex;
gap: 12px;
align-items: center;
}
.selection-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 20px;
background-color: white;
border-radius: 12px;
margin-bottom: 10px;
border: 1px solid rgba(0, 0, 0, 0.03);
box-shadow: 0 2px 8px rgba(0,0,0,0.02);
}
.selection-count {
font-size: 13px;
color: var(--text-sec);
font-weight: 500;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.text-btn {
background: none;
border: none;
font-size: 13px;
font-weight: 600;
color: var(--primary-color);
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
transition: all 0.2s ease;
}
.text-btn:hover:not(:disabled) {
background-color: rgba(0, 122, 255, 0.08);
}
.text-btn:disabled {
color: #AEAEB2;
cursor: not-allowed;
}
.divider {
width: 1px;
height: 12px;
background-color: var(--border-color);
}
.action-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-radius: 12px;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
border: none;
white-space: nowrap;
}
.primary-btn {
background-color: var(--primary-color);
color: white;
box-shadow: var(--btn-shadow);
}
.primary-btn:hover:not(:disabled) {
background-color: var(--primary-hover);
}
.secondary-btn {
background-color: var(--bg-light);
color: var(--text-main);
border: 1px solid var(--border-color);
}
.secondary-btn:hover:not(:disabled) {
background-color: white;
border-color: var(--primary-color);
color: var(--primary-color);
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
box-shadow: none;
}
.icon {
display: flex;
align-items: center;
}
.icon.spinning {
animation: spin 1s linear infinite;
}
.software-list {
display: flex;
flex-direction: column;
}
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 300px;
color: var(--text-sec);
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(0, 122, 255, 0.1);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>