Files
spark-store/src/components/AppCard.vue
T
shenmo7192 c877f0551e feat: 新增动态侧边栏配置功能,优化主题色与侧边栏样式
新增SidebarEntry类型定义与侧边栏配置加载逻辑,支持从服务器拉取sidebar-config.json动态配置侧边栏入口
替换原分类侧边栏为可配置样式,新增CategoryBar分类选择组件,更新品牌色为苹果风格蓝色
重构侧边栏状态管理,拆分activeTab与选中分类逻辑,新增侧边栏入口计数统计
添加SIDEBAR_CONFIG.md文档说明配置格式与使用方法,更新测试用例与组件props
2026-05-18 13:25:52 +08:00

174 lines
5.9 KiB
Vue

<template>
<div
@click="openDetail"
class="group flex cursor-pointer gap-3 rounded-xl border border-slate-200/70 bg-white/90 p-4 shadow-sm transition hover:-translate-y-0.5 hover:border-brand/50 hover:shadow-md dark:border-slate-800/60 dark:bg-slate-900/60"
>
<div
class="flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-gradient-to-b from-slate-100 to-slate-200 shadow-inner dark:from-slate-800 dark:to-slate-700"
>
<img
ref="iconImg"
:src="loadedIcon"
alt="icon"
class="h-full w-full object-cover transition-opacity duration-300"
:class="isLoaded ? 'opacity-100' : 'opacity-0'"
/>
</div>
<div class="flex flex-1 flex-col gap-1 overflow-hidden">
<div class="flex items-center gap-2">
<div
class="truncate text-base font-semibold text-slate-900 dark:text-white"
>
{{ app.name || "" }}
</div>
<div class="flex shrink-0 gap-1">
<span
v-if="showMergedBadge"
class="rounded-md px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider shadow-sm bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400"
>
SPARK/APM
</span>
<template v-else>
<span
v-if="showSparkBadge"
class="rounded-md px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider shadow-sm bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400"
>
Spark
</span>
<span
v-if="showApmBadge"
class="rounded-md px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider shadow-sm bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400"
>
APM
</span>
</template>
</div>
</div>
<div class="text-sm text-slate-500 dark:text-slate-400 leading-tight">
{{ app.pkgname || "" }} · {{ app.version || "" }}
</div>
<div
class="truncate text-xs text-slate-500 dark:text-slate-400 leading-tight"
>
{{ description || "\u00A0" }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, ref, watch } from "vue";
import { APM_STORE_BASE_URL } from "../global/storeConfig";
import type { App } from "../global/typedefinition";
const props = defineProps<{
app: App;
// 是否显示来源标识(仅在混合模式下显示)
showOrigin?: boolean;
}>();
const emit = defineEmits<{
(e: "open-detail", app: App): void;
}>();
const iconImg = ref<HTMLImageElement | null>(null);
const isLoaded = ref(false);
const loadedIcon = ref(
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3Crect fill="%23f0f0f0" width="100" height="100"/%3E%3C/svg%3E',
);
// 是否显示合并标识(两个来源都有)
const showMergedBadge = computed(() => {
// 只有在 showOrigin 为 true 且 isMerged 为 true 时才显示合并标识
return props.showOrigin === true && props.app.isMerged === true;
});
// 是否显示 Spark 标识
const showSparkBadge = computed(() => {
// 只有在 showOrigin 为 true 且不是合并状态时才显示单独标识
if (props.showOrigin !== true || props.app.isMerged === true) return false;
// 根据 app 的 origin 判断
return props.app.origin === "spark";
});
// 是否显示 APM 标识
const showApmBadge = computed(() => {
// 只有在 showOrigin 为 true 且不是合并状态时才显示单独标识
if (props.showOrigin !== true || props.app.isMerged === true) return false;
// 根据 app 的 origin 判断
return props.app.origin === "apm";
});
const iconPath = computed(() => {
const arch = window.apm_store.arch || "amd64";
const finalArch =
props.app.origin === "spark" ? `${arch}-store` : `${arch}-apm`;
return `${APM_STORE_BASE_URL}/${finalArch}/${props.app.category}/${props.app.pkgname}/icon.png`;
});
const description = computed(() => {
const more = props.app.more || "";
return more.substring(0, 80) + (more.length > 80 ? "..." : "");
});
const openDetail = () => {
emit("open-detail", props.app);
};
let observer: IntersectionObserver | null = null;
onMounted(() => {
// 创建 Intersection Observer
observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !isLoaded.value) {
// 图片进入视口,开始加载
const img = new Image();
img.onload = () => {
loadedIcon.value = iconPath.value;
isLoaded.value = true;
if (observer) observer.unobserve(entry.target);
};
img.onerror = () => {
// 加载失败时使用默认图标
loadedIcon.value =
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3Crect fill="%23e0e0e0" width="100" height="100"/%3E%3Ctext x="50" y="50" text-anchor="middle" dy=".3em" fill="%23999" font-size="14"%3ENo Icon%3C/text%3E%3C/svg%3E';
isLoaded.value = true;
if (observer) observer.unobserve(entry.target);
};
img.src = iconPath.value;
}
});
},
{
rootMargin: "50px", // 提前50px开始加载
threshold: 0.01,
},
);
// 观察图标元素
if (iconImg.value) {
observer.observe(iconImg.value);
}
});
// 当 app 变更时重置懒加载状态并重新观察
watch(iconPath, () => {
loadedIcon.value =
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3Crect fill="%23f0f0f0" width="100" height="100"/%3E%3C/svg%3E';
isLoaded.value = false;
if (observer && iconImg.value) {
observer.unobserve(iconImg.value);
observer.observe(iconImg.value);
}
});
onBeforeUnmount(() => {
// 清理 observer
if (observer && iconImg.value) {
observer.unobserve(iconImg.value);
}
});
</script>