Files
win-softmgr/src/components/SoftwareCard.vue
2026-03-30 20:21:44 -04:00

415 lines
9.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div
class="software-card"
:class="{
'installed-mode': software.status === 'installed',
'is-selected': isSelected && software.status !== 'installed',
'is-busy': software.status === 'pending' || software.status === 'installing'
}"
@click="handleCardClick"
>
<!-- 左侧勾选框 -->
<div class="selection-area" v-if="selectable">
<div
class="checkbox"
:class="{
'checked': isSelected,
'disabled': software.status === 'installed' || software.status === 'pending' || software.status === 'installing'
}"
>
<span v-if="isSelected"></span>
</div>
</div>
<div class="card-left">
<div class="icon-container">
<img v-if="software.icon_url" :src="software.icon_url" :alt="software.name" class="software-icon" />
<div v-else class="icon-placeholder" :style="{ backgroundColor: placeholderColor }">
{{ software.name.charAt(0).toUpperCase() }}
</div>
</div>
<div class="info">
<div class="title-row">
<h3 class="name">{{ software.name }}</h3>
<span class="id-badge">{{ software.id }}</span>
</div>
<p class="description" v-if="software.description">{{ software.description }}</p>
<div class="version-info">
<template v-if="software.status === 'installed'">
<span class="version-tag">当前: {{ software.version || '--' }}</span>
</template>
<template v-else>
<span class="version-tag recommended">
推荐: {{ software.version || '最新版' }}
</span>
</template>
<span class="version-tag available" v-if="software.status !== 'installed' && software.available_version">
最新: {{ software.available_version }}
</span>
</div>
</div>
</div>
<div class="card-right" v-if="actionLabel || software.status !== 'idle'">
<div class="action-wrapper">
<button
v-if="software.status === 'idle'"
@click.stop="$emit('install', software.id)"
class="action-btn install-btn"
>
{{ actionLabel }}
</button>
<button
v-else-if="software.status === 'installed'"
disabled
class="action-btn installed-btn"
>
已安装
</button>
<!-- 等待中状态 -->
<div v-else-if="software.status === 'pending'" class="status-pending">
<span class="wait-text">等待中</span>
</div>
<!-- 安装中状态显示进度环和百分比 -->
<div v-else-if="software.status === 'installing'" class="progress-status">
<div class="progress-ring-container">
<svg viewBox="0 0 32 32" class="ring-svg">
<circle class="bg" cx="16" cy="16" r="14" fill="none" stroke-width="3" />
<circle class="fg" cx="16" cy="16" r="14" fill="none" stroke-width="3"
:style="{ strokeDasharray: 88, strokeDashoffset: 88 - (88 * (software.progress || 0)) }"
/>
</svg>
<div v-if="!software.progress" class="inner-loader"></div>
</div>
<span class="loading-text">{{ displayProgress }}</span>
</div>
<div v-else-if="software.status === 'success'" class="status-success">
<span class="check-icon"></span> 已完成
</div>
<div v-else-if="software.status === 'error'" class="status-error">
失败
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps<{
software: {
id: string;
name: string;
description?: string;
version?: string;
available_version?: string;
icon_url?: string;
status: string;
progress: number;
},
actionLabel?: string,
selectable?: boolean,
isSelected?: boolean
}>();
const emit = defineEmits(['install', 'toggleSelect']);
const displayProgress = computed(() => {
if (!props.software.progress) return '准备中';
return Math.round(props.software.progress * 100) + '%';
});
const placeholderColor = computed(() => {
const colors = ['#FF9500', '#FF3B30', '#34C759', '#007AFF', '#5856D6', '#AF52DE'];
let hash = 0;
for (let i = 0; i < props.software.id.length; i++) {
hash = props.software.id.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
});
const handleCardClick = () => {
if (props.software.status === 'pending' || props.software.status === 'installing') return;
if (props.selectable && props.software.status !== 'installed') {
emit('toggleSelect', props.software.id);
}
};
</script>
<style scoped>
.software-card {
background: white;
border-radius: 20px;
padding: 16px 24px;
box-shadow: var(--card-shadow);
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
display: flex;
align-items: center;
justify-content: space-between;
border: 1px solid transparent;
margin-bottom: 12px;
cursor: default;
}
.software-card:not(.installed-mode):not(.is-busy):hover {
border-color: rgba(0, 122, 255, 0.3);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06);
}
.software-card.is-selected {
background-color: rgba(0, 122, 255, 0.02);
border-color: rgba(0, 122, 255, 0.4);
}
.software-card.is-busy {
opacity: 0.9;
}
.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 {
display: flex;
align-items: center;
gap: 20px;
flex: 1;
}
.icon-container {
width: 48px;
height: 48px;
border-radius: 12px;
overflow: hidden;
flex-shrink: 0;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.05);
}
.software-icon {
width: 100%;
height: 100%;
object-fit: cover;
}
.icon-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 20px;
font-weight: 700;
}
.info {
display: flex;
flex-direction: column;
gap: 4px;
}
.title-row {
display: flex;
align-items: center;
gap: 12px;
}
.name {
font-size: 16px;
font-weight: 600;
color: var(--text-main);
}
.id-badge {
font-size: 11px;
color: var(--text-sec);
background: var(--bg-light);
padding: 2px 8px;
border-radius: 6px;
font-family: monospace;
}
.description {
font-size: 13px;
color: var(--text-sec);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
max-width: 500px;
}
.version-info {
display: flex;
gap: 8px;
}
.version-tag {
font-size: 11px;
color: var(--text-sec);
font-weight: 500;
}
.version-tag.available, .version-tag.recommended {
color: var(--primary-color);
background: rgba(0, 122, 255, 0.08);
padding: 0 6px;
border-radius: 4px;
}
.card-right {
margin-left: 20px;
min-width: 100px;
display: flex;
justify-content: flex-end;
}
.action-btn {
width: 90px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 17px;
font-weight: 600;
font-size: 13px;
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease;
}
.install-btn {
background-color: rgba(0, 122, 255, 0.05);
color: var(--primary-color);
}
.install-btn:hover {
background-color: var(--primary-color);
color: white;
}
.installed-btn {
background-color: #F2F2F7;
color: #AEAEB2;
cursor: not-allowed;
}
.status-pending {
display: flex;
align-items: center;
gap: 6px;
width: 90px;
justify-content: center;
color: #AEAEB2;
}
.wait-text {
font-size: 12px;
font-weight: 600;
}
.progress-status {
display: flex;
align-items: center;
flex-direction: column;
gap: 6px;
width: 90px;
justify-content: center;
}
.progress-ring-container {
position: relative;
width: 24px;
height: 24px;
}
.ring-svg {
transform: rotate(-90deg);
width: 100%;
height: 100%;
}
.ring-svg .bg {
stroke: var(--border-color);
}
.ring-svg .fg {
stroke: var(--primary-color);
stroke-linecap: round;
transition: stroke-dashoffset 0.3s ease;
}
.inner-loader {
position: absolute;
top: 4px;
left: 4px;
width: 16px;
height: 16px;
border: 2px solid transparent;
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-text {
font-size: 10px;
font-weight: 700;
color: var(--primary-color);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-success, .status-error {
width: 90px;
text-align: center;
font-weight: 600;
font-size: 13px;
}
.status-success {
color: #34C759;
}
.status-error {
color: #FF3B30;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>