Compare commits

...

4 Commits

Author SHA1 Message Date
Julian Freeman
cdee6b47c7 selection states preserve 2026-03-14 19:04:06 -04:00
Julian Freeman
34ad167ad2 update selectable 2026-03-14 18:59:13 -04:00
Julian Freeman
8d700770aa sortable 2026-03-14 18:49:38 -04:00
Julian Freeman
2955e1eada essentials selectable 2026-03-14 18:40:34 -04:00
5 changed files with 425 additions and 90 deletions

View File

@@ -1,5 +1,25 @@
<template> <template>
<div class="software-card" :class="{ 'installed-mode': software.status === 'installed' }"> <div
class="software-card"
:class="{
'installed-mode': software.status === 'installed',
'is-selected': isSelected && software.status !== 'installed'
}"
@click="handleCardClick"
>
<!-- 左侧勾选框 -->
<div class="selection-area" v-if="selectable">
<div
class="checkbox"
:class="{
'checked': isSelected,
'disabled': software.status === 'installed'
}"
>
<span v-if="isSelected"></span>
</div>
</div>
<div class="card-left"> <div class="card-left">
<div class="icon-container"> <div class="icon-container">
<img v-if="software.icon_url" :src="software.icon_url" :alt="software.name" class="software-icon" /> <img v-if="software.icon_url" :src="software.icon_url" :alt="software.name" class="software-icon" />
@@ -26,7 +46,7 @@
<div class="action-wrapper"> <div class="action-wrapper">
<button <button
v-if="software.status === 'idle'" v-if="software.status === 'idle'"
@click="$emit('install', software.id)" @click.stop="$emit('install', software.id)"
class="action-btn install-btn" class="action-btn install-btn"
> >
{{ actionLabel }} {{ actionLabel }}
@@ -49,7 +69,6 @@
/> />
</svg> </svg>
</div> </div>
<span class="status-text">{{ software.status === 'pending' ? '等待中...' : '安装中...' }}</span>
</div> </div>
<div v-else-if="software.status === 'success'" class="status-success"> <div v-else-if="software.status === 'success'" class="status-success">
@@ -78,9 +97,13 @@ const props = defineProps<{
status: string; status: string;
progress: number; progress: number;
}, },
actionLabel?: string actionLabel?: string,
selectable?: boolean,
isSelected?: boolean
}>(); }>();
const emit = defineEmits(['install', 'toggleSelect']);
const placeholderColor = computed(() => { const placeholderColor = computed(() => {
const colors = ['#FF9500', '#FF3B30', '#34C759', '#007AFF', '#5856D6', '#AF52DE']; const colors = ['#FF9500', '#FF3B30', '#34C759', '#007AFF', '#5856D6', '#AF52DE'];
let hash = 0; let hash = 0;
@@ -89,6 +112,12 @@ const placeholderColor = computed(() => {
} }
return colors[Math.abs(hash) % colors.length]; return colors[Math.abs(hash) % colors.length];
}); });
const handleCardClick = () => {
if (props.selectable && props.software.status !== 'installed') {
emit('toggleSelect', props.software.id);
}
};
</script> </script>
<style scoped> <style scoped>
@@ -103,11 +132,49 @@ const placeholderColor = computed(() => {
justify-content: space-between; justify-content: space-between;
border: 1px solid rgba(0, 0, 0, 0.02); border: 1px solid rgba(0, 0, 0, 0.02);
margin-bottom: 12px; margin-bottom: 12px;
cursor: default;
} }
.software-card:hover { .software-card:not(.installed-mode):hover {
transform: scale(1.01); transform: scale(1.005);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.06); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.06);
border-color: rgba(0, 122, 255, 0.1);
}
.software-card.is-selected {
background-color: rgba(0, 122, 255, 0.02);
border-color: rgba(0, 122, 255, 0.2);
}
/* 勾选框样式 */
.selection-area {
margin-right: 16px;
flex-shrink: 0;
}
.checkbox {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid var(--border-color);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
color: white;
font-size: 12px;
font-weight: bold;
}
.checkbox.checked {
background-color: var(--primary-color);
border-color: var(--primary-color);
}
.checkbox.disabled {
background-color: #F2F2F7;
border-color: #E5E5E7;
cursor: not-allowed;
} }
.card-left { .card-left {
@@ -207,8 +274,8 @@ const placeholderColor = computed(() => {
} }
.action-btn { .action-btn {
width: 90px; /* 固定宽度,确保对齐 */ width: 90px;
height: 34px; /* 固定高度 */ height: 34px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -231,8 +298,8 @@ const placeholderColor = computed(() => {
} }
.installed-btn { .installed-btn {
background-color: #F2F2F7; /* 更浅的灰色 */ background-color: #F2F2F7;
color: #AEAEB2; /* 更淡的文字颜色 */ color: #AEAEB2;
cursor: not-allowed; cursor: not-allowed;
} }
@@ -263,13 +330,6 @@ const placeholderColor = computed(() => {
transition: stroke-dashoffset 0.3s ease; transition: stroke-dashoffset 0.3s ease;
} }
.status-text {
font-size: 12px;
font-weight: 500;
color: var(--primary-color);
display: none; /* 在固定宽度下隐藏文字,保持简洁 */
}
.status-success, .status-error { .status-success, .status-error {
width: 90px; width: 90px;
text-align: center; text-align: center;

View File

@@ -2,13 +2,17 @@ import { defineStore } from 'pinia'
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event' import { listen } from '@tauri-apps/api/event'
const sortByName = (a: any, b: any) => a.name.localeCompare(b.name, 'zh-CN', { sensitivity: 'accent' });
export const useSoftwareStore = defineStore('software', { export const useSoftwareStore = defineStore('software', {
state: () => ({ state: () => ({
essentials: [] as any[], essentials: [] as any[],
updates: [] as any[], updates: [] as any[],
allSoftware: [] as any[], allSoftware: [] as any[],
selectedEssentialIds: [] as string[],
selectedUpdateIds: [] as string[],
loading: false, loading: false,
lastFetched: 0 // 记录上次完整同步的时间戳 lastFetched: 0
}), }),
getters: { getters: {
mergedEssentials: (state) => { mergedEssentials: (state) => {
@@ -28,39 +32,75 @@ export const useSoftwareStore = defineStore('software', {
} }
} }
return { return { ...item, status: displayStatus, actionLabel };
...item,
status: displayStatus,
actionLabel
};
}); });
} },
sortedUpdates: (state) => [...state.updates].sort(sortByName),
sortedAllSoftware: (state) => [...state.allSoftware].sort(sortByName)
}, },
actions: { actions: {
// 通用的勾选切换逻辑
toggleSelection(id: string, type: 'essential' | 'update') {
const list = type === 'essential' ? this.selectedEssentialIds : this.selectedUpdateIds;
const index = list.indexOf(id);
if (index === -1) {
list.push(id);
} else {
list.splice(index, 1);
}
},
selectAll(type: 'essential' | 'update') {
if (type === 'essential') {
const selectable = this.mergedEssentials.filter(s => s.status !== 'installed');
this.selectedEssentialIds = selectable.map(s => s.id);
} else {
this.selectedUpdateIds = this.updates.map(s => s.id);
}
},
deselectAll(type: 'essential' | 'update') {
if (type === 'essential') this.selectedEssentialIds = [];
else this.selectedUpdateIds = [];
},
invertSelection(type: 'essential' | 'update') {
if (type === 'essential') {
const selectable = this.mergedEssentials.filter(s => s.status !== 'installed').map(s => s.id);
this.selectedEssentialIds = selectable.filter(id => !this.selectedEssentialIds.includes(id));
} else {
const selectable = this.updates.map(s => s.id);
this.selectedUpdateIds = selectable.filter(id => !this.selectedUpdateIds.includes(id));
}
},
async fetchEssentials() { async fetchEssentials() {
this.essentials = await invoke('get_essentials') this.essentials = await invoke('get_essentials')
}, },
async fetchUpdates() { async fetchUpdates() {
this.loading = true this.loading = true
this.updates = await invoke('get_updates') try {
const res = await invoke('get_updates')
this.updates = res as any[]
// 刷新数据后,如果没选过,默认全选
if (this.selectedUpdateIds.length === 0) this.selectAll('update');
} finally {
this.loading = false this.loading = false
}
}, },
async fetchAll() { async fetchAll() {
this.loading = true this.loading = true
this.allSoftware = await invoke('get_all_software') try {
const res = await invoke('get_all_software')
this.allSoftware = res as any[]
} finally {
this.loading = false this.loading = false
}
}, },
// 智能同步:如果数据较新,则直接返回
async syncDataIfNeeded(force = false) { async syncDataIfNeeded(force = false) {
const now = Date.now(); const now = Date.now();
const CACHE_TIMEOUT = 5 * 60 * 1000; // 5 分钟缓存 const CACHE_TIMEOUT = 5 * 60 * 1000;
if (!force && this.allSoftware.length > 0 && (now - this.lastFetched < CACHE_TIMEOUT)) { if (!force && this.allSoftware.length > 0 && (now - this.lastFetched < CACHE_TIMEOUT)) {
// 如果不是强制刷新,且数据在 5 分钟内,且已经有数据,则跳过
if (this.essentials.length === 0) await this.fetchEssentials(); if (this.essentials.length === 0) await this.fetchEssentials();
return; return;
} }
await this.fetchAllData(); await this.fetchAllData();
}, },
async fetchAllData() { async fetchAllData() {
@@ -75,6 +115,8 @@ export const useSoftwareStore = defineStore('software', {
this.allSoftware = all as any[]; this.allSoftware = all as any[];
this.updates = updates as any[]; this.updates = updates as any[];
this.lastFetched = Date.now(); this.lastFetched = Date.now();
// 如果没选过,默认全选
if (this.selectedEssentialIds.length === 0) this.selectAll('essential');
} finally { } finally {
this.loading = false; this.loading = false;
} }
@@ -85,13 +127,11 @@ export const useSoftwareStore = defineStore('software', {
await invoke('install_software', { id }) await invoke('install_software', { id })
}, },
findSoftware(id: string) { findSoftware(id: string) {
// 这里的逻辑会从多个列表中查找对象
return this.essentials.find(s => s.id === id) || return this.essentials.find(s => s.id === id) ||
this.updates.find(s => s.id === id) || this.updates.find(s => s.id === id) ||
this.allSoftware.find(s => s.id === id) this.allSoftware.find(s => s.id === id)
}, },
initListener() { initListener() {
// 避免重复监听
if ((window as any).__tauri_listener_init) return; if ((window as any).__tauri_listener_init) return;
(window as any).__tauri_listener_init = true; (window as any).__tauri_listener_init = true;
@@ -102,10 +142,11 @@ export const useSoftwareStore = defineStore('software', {
software.status = status software.status = status
software.progress = progress software.progress = progress
} }
// 如果安装成功,标记数据过期,以便下次切换或刷新时同步最新状态
if (status === 'success') { if (status === 'success') {
this.lastFetched = 0; this.lastFetched = 0;
// 安装成功后从选择列表中移除
this.selectedEssentialIds = this.selectedEssentialIds.filter(i => i !== id);
this.selectedUpdateIds = this.selectedUpdateIds.filter(i => i !== id);
} }
}) })
} }

View File

@@ -5,6 +5,22 @@
<h1>全部软件</h1> <h1>全部软件</h1>
<p class="count"> {{ filteredSoftware.length }} 个项目</p> <p class="count"> {{ filteredSoftware.length }} 个项目</p>
</div> </div>
<div class="header-actions">
<button
@click="store.fetchAll"
class="secondary-btn action-btn"
:disabled="store.loading"
>
<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>
<div class="search-box"> <div class="search-box">
<input <input
type="text" type="text"
@@ -13,9 +29,10 @@
class="search-input" class="search-input"
/> />
</div> </div>
</div>
</header> </header>
<div v-if="store.loading" class="loading-state"> <div v-if="store.loading && store.sortedAllSoftware.length === 0" class="loading-state">
<div class="spinner"></div> <div class="spinner"></div>
<p>正在读取已安装软件列表...</p> <p>正在读取已安装软件列表...</p>
</div> </div>
@@ -45,9 +62,10 @@ onMounted(() => {
}); });
const filteredSoftware = computed(() => { const filteredSoftware = computed(() => {
if (!searchQuery.value) return store.allSoftware; const data = store.sortedAllSoftware;
if (!searchQuery.value) return data;
const q = searchQuery.value.toLowerCase(); const q = searchQuery.value.toLowerCase();
return store.allSoftware.filter(s => return data.filter(s =>
s.name.toLowerCase().includes(q) || s.id.toLowerCase().includes(q) s.name.toLowerCase().includes(q) || s.id.toLowerCase().includes(q)
); );
}); });
@@ -78,20 +96,62 @@ const filteredSoftware = computed(() => {
margin-top: 4px; margin-top: 4px;
} }
.header-actions {
display: flex;
align-items: center;
gap: 16px;
}
.action-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 10px;
font-weight: 600;
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid var(--border-color);
background-color: var(--bg-light);
color: var(--text-main);
white-space: nowrap;
}
.action-btn:hover {
background-color: white;
border-color: var(--primary-color);
color: var(--primary-color);
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.icon {
display: flex;
align-items: center;
}
.icon.spinning {
animation: spin 1s linear infinite;
}
.search-input { .search-input {
width: 280px; width: 240px;
padding: 10px 16px; padding: 8px 16px;
border-radius: 12px; border-radius: 10px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
background-color: white; background-color: white;
font-size: 14px; font-size: 13px;
outline: none; outline: none;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.search-input:focus { .search-input:focus {
border-color: var(--primary-color); border-color: var(--primary-color);
box-shadow: 0 0 0 4px rgba(0, 122, 255, 0.1); box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.1);
} }
.software-list { .software-list {

View File

@@ -21,15 +21,29 @@
{{ store.loading ? '正在同步...' : '同步状态' }} {{ store.loading ? '正在同步...' : '同步状态' }}
</button> </button>
<button <button
@click="installAll" @click="installSelected"
class="primary-btn action-btn" class="primary-btn action-btn"
:disabled="store.loading" :disabled="store.loading || store.selectedEssentialIds.length === 0"
> >
一键安装全部 安装/更新所选 ({{ store.selectedEssentialIds.length }})
</button> </button>
</div> </div>
</header> </header>
<!-- 批量选择控制栏 -->
<div class="selection-toolbar" v-if="selectableItems.length > 0">
<div class="toolbar-left">
<span class="selection-count">已选 {{ store.selectedEssentialIds.length }} / {{ selectableItems.length }} </span>
</div>
<div class="toolbar-actions">
<button @click="store.selectAll('essential')" class="text-btn">全选</button>
<div class="divider"></div>
<button @click="store.deselectAll('essential')" class="text-btn">取消</button>
<div class="divider"></div>
<button @click="store.invertSelection('essential')" class="text-btn">反选</button>
</div>
</div>
<div v-if="store.loading && store.mergedEssentials.length === 0" class="loading-state"> <div v-if="store.loading && store.mergedEssentials.length === 0" class="loading-state">
<div class="spinner"></div> <div class="spinner"></div>
<p>正在读取必备软件列表...</p> <p>正在读取必备软件列表...</p>
@@ -41,7 +55,10 @@
:key="item.id" :key="item.id"
:software="item" :software="item"
:action-label="item.actionLabel" :action-label="item.actionLabel"
:selectable="true"
:is-selected="store.selectedEssentialIds.includes(item.id)"
@install="store.install" @install="store.install"
@toggle-select="id => store.toggleSelection(id, 'essential')"
/> />
</div> </div>
</main> </main>
@@ -50,22 +67,24 @@
<script setup lang="ts"> <script setup lang="ts">
import SoftwareCard from '../components/SoftwareCard.vue'; import SoftwareCard from '../components/SoftwareCard.vue';
import { useSoftwareStore } from '../store/software'; import { useSoftwareStore } from '../store/software';
import { onMounted } from 'vue'; import { onMounted, computed } from 'vue';
const store = useSoftwareStore(); const store = useSoftwareStore();
const selectableItems = computed(() => {
return store.mergedEssentials.filter(s => s.status !== 'installed');
});
const installSelected = () => {
store.selectedEssentialIds.forEach(id => {
store.install(id);
});
};
onMounted(() => { onMounted(() => {
store.syncDataIfNeeded(); store.syncDataIfNeeded();
store.initListener(); store.initListener();
}); });
const installAll = () => {
store.mergedEssentials.forEach(s => {
if (s.status === 'idle') {
store.install(s.id);
}
});
};
</script> </script>
<style scoped> <style scoped>
@@ -79,7 +98,7 @@ const installAll = () => {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 32px; margin-bottom: 24px;
} }
.content-header h1 { .content-header h1 {
@@ -95,6 +114,52 @@ const installAll = () => {
align-items: center; align-items: center;
} }
/* 批量选择工具栏 */
.selection-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 20px;
background-color: rgba(0, 0, 0, 0.02);
border-radius: 12px;
margin-bottom: 20px;
border: 1px solid rgba(0, 0, 0, 0.03);
}
.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 {
background-color: rgba(0, 122, 255, 0.08);
}
.divider {
width: 1px;
height: 12px;
background-color: var(--border-color);
}
.action-btn { .action-btn {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -133,9 +198,10 @@ const installAll = () => {
} }
.action-btn:disabled { .action-btn:disabled {
opacity: 0.6; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
transform: none !important; transform: none !important;
box-shadow: none;
} }
.icon { .icon {

View File

@@ -1,32 +1,69 @@
<template> <template>
<main class="content"> <main class="content">
<header class="content-header"> <header class="content-header">
<div class="header-left">
<h1>软件更新</h1> <h1>软件更新</h1>
<div class="actions"> </div>
<button @click="store.fetchUpdates" class="secondary-btn" :disabled="store.loading"> <div class="header-actions">
{{ store.loading ? '检查中...' : '检查更新' }} <button
@click="store.fetchUpdates"
class="secondary-btn action-btn"
:disabled="store.loading"
>
<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="updateSelected"
class="primary-btn action-btn"
:disabled="store.selectedUpdateIds.length === 0 || store.loading"
>
更新所选 ({{ store.selectedUpdateIds.length }})
</button> </button>
<button @click="updateAll" class="primary-btn" :disabled="!store.updates.length">全部更新</button>
</div> </div>
</header> </header>
<div v-if="store.loading" class="loading-state"> <!-- 批量选择控制栏 -->
<div class="selection-toolbar" v-if="store.sortedUpdates.length > 0">
<div class="toolbar-left">
<span class="selection-count">已选 {{ store.selectedUpdateIds.length }} / {{ store.sortedUpdates.length }} </span>
</div>
<div class="toolbar-actions">
<button @click="store.selectAll('update')" class="text-btn">全选</button>
<div class="divider"></div>
<button @click="store.deselectAll('update')" class="text-btn">取消</button>
<div class="divider"></div>
<button @click="store.invertSelection('update')" class="text-btn">反选</button>
</div>
</div>
<div v-if="store.loading && store.updates.length === 0" class="loading-state">
<div class="spinner"></div> <div class="spinner"></div>
<p>正在使用 Winget 扫描可用的更新...</p> <p>正在使用 Winget 扫描可用的更新...</p>
</div> </div>
<div v-else-if="store.updates.length === 0" class="empty-state"> <div v-else-if="store.updates.length === 0 && !store.loading" class="empty-state">
<span class="empty-icon"></span> <span class="empty-icon"></span>
<p>所有软件已是最新版本</p> <p>所有软件已是最新版本</p>
</div> </div>
<div v-else class="software-list"> <div v-else class="software-list">
<SoftwareCard <SoftwareCard
v-for="item in store.updates" v-for="item in store.sortedUpdates"
:key="item.id" :key="item.id"
:software="item" :software="item"
action-label="更新" action-label="更新"
:selectable="true"
:is-selected="store.selectedUpdateIds.includes(item.id)"
@install="store.install" @install="store.install"
@toggle-select="id => store.toggleSelection(id, 'update')"
/> />
</div> </div>
</main> </main>
@@ -39,19 +76,17 @@ import { onMounted } from 'vue';
const store = useSoftwareStore(); const store = useSoftwareStore();
const updateSelected = () => {
store.selectedUpdateIds.forEach(id => {
store.install(id);
});
};
onMounted(() => { onMounted(() => {
if (store.updates.length === 0) { if (store.updates.length === 0) {
store.fetchUpdates(); store.fetchUpdates();
} }
}); });
const updateAll = () => {
store.updates.forEach(s => {
if (s.status === 'idle') {
store.install(s.id);
}
});
};
</script> </script>
<style scoped> <style scoped>
@@ -65,27 +100,79 @@ const updateAll = () => {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 30px; margin-bottom: 24px;
} }
.content-header h1 { .content-header h1 {
font-size: 32px; font-size: 32px;
font-weight: 700; font-weight: 700;
letter-spacing: -0.5px;
} }
.actions { .header-actions {
display: flex; display: flex;
gap: 12px; gap: 12px;
align-items: center;
} }
.primary-btn, .secondary-btn { /* 批量选择工具栏 */
.selection-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 20px;
background-color: rgba(0, 0, 0, 0.02);
border-radius: 12px;
margin-bottom: 20px;
border: 1px solid rgba(0, 0, 0, 0.03);
}
.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; 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 {
background-color: rgba(0, 122, 255, 0.08);
}
.divider {
width: 1px;
height: 12px;
background-color: var(--border-color);
}
.action-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px; padding: 10px 20px;
border-radius: 12px; border-radius: 12px;
font-weight: 600; font-weight: 600;
font-size: 14px; font-size: 14px;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
border: none;
white-space: nowrap;
} }
.primary-btn { .primary-btn {
@@ -94,15 +181,36 @@ const updateAll = () => {
box-shadow: var(--btn-shadow); box-shadow: var(--btn-shadow);
} }
.primary-btn:disabled { .primary-btn:hover {
background-color: var(--border-color); background-color: var(--primary-hover);
box-shadow: none; transform: translateY(-1px);
cursor: not-allowed;
} }
.secondary-btn { .secondary-btn {
background-color: var(--border-color); background-color: var(--bg-light);
color: var(--text-main); color: var(--text-main);
border: 1px solid var(--border-color);
}
.secondary-btn:hover {
background-color: white;
border-color: var(--primary-color);
color: var(--primary-color);
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
}
.icon {
display: flex;
align-items: center;
}
.icon.spinning {
animation: spin 1s linear infinite;
} }
.software-list { .software-list {