Files
spark-store/src/components/AppDetailModal.vue
shenmo dbfe86aa64 feat: enhance UI and functionality across components
- Added Google Fonts preconnect and stylesheet link in `index.html` for improved typography.
- Updated version in `package.json` to `4.9.9alpha3`.
- Refined launch configuration by removing deprecated arguments.
- Improved app detail modal and card components for better accessibility and visual consistency.
- Enhanced download queue and sidebar components with updated styles and functionality.
- Implemented new utility classes for better styling control in CSS.
- Adjusted various components for improved responsiveness and user experience.
2026-03-15 15:21:29 +08:00

430 lines
15 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>
<Transition
enter-active-class="duration-200 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="duration-150 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="show"
v-bind="attrs"
class="fixed inset-0 z-50 flex items-center justify-center overflow-hidden bg-slate-900/60 backdrop-blur-sm p-4"
@click.self="closeModal"
>
<div
class="modal-panel relative w-full max-w-4xl max-h-[85vh] overflow-y-auto scrollbar-nowidth rounded-2xl border border-slate-200/60 bg-white/98 p-6 shadow-2xl dark:border-slate-700/50 dark:bg-slate-900/98"
>
<div class="flex flex-col gap-4 lg:flex-row lg:items-center">
<div class="flex flex-1 items-center gap-4">
<div
class="flex h-20 w-20 shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-gradient-to-br from-slate-100 to-slate-200/80 shadow-inner ring-1 ring-slate-200/50 dark:from-slate-700 dark:to-slate-800 dark:ring-slate-600/30"
>
<img
v-if="app"
:src="iconPath"
alt="icon"
class="h-full w-full object-cover transition-opacity duration-300"
:class="isIconLoaded ? 'opacity-100' : 'opacity-0'"
loading="lazy"
@load="isIconLoaded = true"
/>
</div>
<div class="space-y-1">
<div class="flex items-center gap-3">
<p class="text-2xl font-bold text-slate-900 dark:text-white">
{{ displayApp?.name || "" }}
</p>
<div
v-if="app?.isMerged"
class="flex gap-1 overflow-hidden rounded-md shadow-sm border border-slate-200 dark:border-slate-700 font-medium ml-1"
>
<button
v-if="app.sparkApp"
type="button"
class="px-2 py-0.5 text-[10px] uppercase tracking-wider transition-colors"
:class="
viewingOrigin === 'spark'
? 'bg-orange-500 text-white'
: 'bg-slate-100/50 text-slate-500 dark:bg-slate-800 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-700'
"
@click="viewingOrigin = 'spark'"
>
Spark
</button>
<button
v-if="app.apmApp"
type="button"
class="px-2 py-0.5 text-[10px] uppercase tracking-wider transition-colors"
:class="
viewingOrigin === 'apm'
? 'bg-blue-500 text-white'
: 'bg-slate-100/50 text-slate-500 dark:bg-slate-800 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-700'
"
@click="viewingOrigin = 'apm'"
>
APM
</button>
</div>
<span
v-else-if="displayApp"
:class="[
'rounded-md px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider shadow-sm',
displayApp.origin === 'spark'
? 'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400'
: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400',
]"
>
{{ displayApp.origin === "spark" ? "Spark" : "APM" }}
</span>
</div>
<p class="text-sm text-slate-500 dark:text-slate-400">
{{ displayApp?.pkgname || "" }} ·
{{ displayApp?.version || "" }}
<span v-if="downloadCount"> · 下载量{{ downloadCount }}</span>
</p>
</div>
</div>
<div class="flex flex-wrap gap-2 lg:ml-auto">
<button
v-if="!isinstalled"
type="button"
class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-r px-4 py-2 text-sm font-semibold text-white shadow-lg disabled:opacity-40 transition hover:-translate-y-0.5"
:class="
installFeedback
? 'from-emerald-500 to-emerald-600'
: 'from-brand to-brand-dark'
"
@click="handleInstall"
:disabled="
installFeedback || isCompleted || isOtherVersionInstalled
"
>
<i
class="fas"
:class="installFeedback ? 'fa-check' : 'fa-download'"
></i>
<span>{{ installBtnText }}</span>
</button>
<template v-else>
<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 transition hover:-translate-y-0.5"
@click="
emit(
'open-app',
displayApp?.pkgname || '',
displayApp?.origin,
)
"
>
<i class="fas fa-external-link-alt"></i>
<span>打开</span>
</button>
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-r from-rose-500 to-rose-600 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-rose-500/30 disabled:opacity-40 transition hover:-translate-y-0.5"
@click="handleRemove"
>
<i class="fas fa-trash"></i>
<span>卸载</span>
</button>
</template>
<button
type="button"
class="inline-flex h-10 w-10 items-center justify-center rounded-xl border border-slate-200/60 text-slate-500 transition hover:bg-slate-100 hover:text-slate-700 dark:border-slate-700 dark:hover:bg-slate-800 dark:hover:text-slate-200"
@click="closeModal"
aria-label="关闭"
>
<i class="fas fa-xmark text-sm"></i>
</button>
</div>
</div>
<!-- <div
class="mt-4 rounded-2xl border border-slate-200/60 bg-slate-50/70 px-4 py-3 text-sm text-slate-600 dark:border-slate-800/60 dark:bg-slate-900/60 dark:text-slate-300"
>
首次安装 APM 后需要重启系统以在启动器中看到应用入口可前往
<a
href="https://gitee.com/amber-ce/amber-pm/releases"
target="_blank"
class="font-semibold text-brand hover:underline"
>APM Releases</a
>
获取 APM
</div> -->
<div v-if="screenshots.length" class="mt-6 grid gap-3 sm:grid-cols-2">
<img
v-for="(screen, index) in screenshots"
:key="index"
:src="screen"
alt="screenshot"
class="h-40 w-full cursor-pointer rounded-2xl border border-slate-200/60 object-cover shadow-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-lg dark:border-slate-800/60"
loading="lazy"
@click="openPreview(index)"
@error="hideImage"
/>
</div>
<div class="mt-6 grid gap-4 sm:grid-cols-2">
<div
v-if="displayApp?.author"
class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"
>
<p class="text-xs uppercase tracking-wide text-slate-400">作者</p>
<p class="text-sm font-medium text-slate-800 dark:text-slate-200">
{{ displayApp.author }}
</p>
</div>
<div
v-if="displayApp?.contributor"
class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"
>
<p class="text-xs uppercase tracking-wide text-slate-400">贡献者</p>
<p class="text-sm font-medium text-slate-800 dark:text-slate-200">
{{ displayApp.contributor }}
</p>
</div>
<div
v-if="displayApp?.size"
class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"
>
<p class="text-xs uppercase tracking-wide text-slate-400">大小</p>
<p class="text-sm font-medium text-slate-800 dark:text-slate-200">
{{ displayApp.size }}
</p>
</div>
<div
v-if="displayApp?.update"
class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"
>
<p class="text-xs uppercase tracking-wide text-slate-400">
更新时间
</p>
<p class="text-sm font-medium text-slate-800 dark:text-slate-200">
{{ displayApp.update }}
</p>
</div>
<div
v-if="displayApp?.website"
class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"
>
<p class="text-xs uppercase tracking-wide text-slate-400">网站</p>
<a
:href="displayApp.website"
target="_blank"
class="text-sm font-medium text-brand hover:underline"
>{{ displayApp.website }}</a
>
</div>
<div
v-if="displayApp?.version"
class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"
>
<p class="text-xs uppercase tracking-wide text-slate-400">版本</p>
<p class="text-sm font-medium text-slate-800 dark:text-slate-200">
{{ displayApp.version }}
</p>
</div>
<div
v-if="displayApp?.tags"
class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"
>
<p class="text-xs uppercase tracking-wide text-slate-400">标签</p>
<p class="text-sm font-medium text-slate-800 dark:text-slate-200">
{{ displayApp.tags }}
</p>
</div>
</div>
<div
v-if="displayApp?.more && displayApp.more.trim() !== ''"
class="mt-6 space-y-3"
>
<h3 class="text-lg font-semibold text-slate-900 dark:text-white">
应用详情
</h3>
<div
class="max-h-60 space-y-2 overflow-y-auto rounded-2xl border border-slate-200/60 bg-slate-50/80 p-4 text-sm leading-relaxed text-slate-600 dark:border-slate-800/60 dark:bg-slate-900/60 dark:text-slate-300"
v-html="displayApp.more.replace(/\n/g, '<br>')"
></div>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { computed, useAttrs, ref, watch } from "vue";
import axios from "axios";
import {
useDownloadItemStatus,
useInstallFeedback,
downloads,
} from "../global/downloadStatus";
import { APM_STORE_BASE_URL } from "../global/storeConfig";
import type { App } from "../global/typedefinition";
const attrs = useAttrs();
const props = defineProps<{
show: boolean;
app: App | null;
screenshots: string[];
sparkInstalled: boolean;
apmInstalled: boolean;
}>();
const emit = defineEmits<{
(e: "close"): void;
(e: "install", app: App): void;
(e: "remove", app: App): void;
(e: "open-preview", index: number): void;
(e: "open-app", pkgname: string, origin?: "spark" | "apm"): void;
(e: "check-install", app: App): void;
}>();
const appPkgname = computed(() => props.app?.pkgname);
const isIconLoaded = ref(false);
const viewingOrigin = ref<"spark" | "apm">("spark");
watch(
() => props.app,
(newApp) => {
isIconLoaded.value = false;
if (newApp) {
if (newApp.isMerged) {
// 若父组件已根据安装状态设置了优先展示的版本,则使用;否则默认 Spark
viewingOrigin.value =
newApp.viewingOrigin ?? (newApp.sparkApp ? "spark" : "apm");
} else {
viewingOrigin.value = newApp.origin;
}
}
},
{ immediate: true },
);
const displayApp = computed(() => {
if (!props.app) return null;
if (!props.app.isMerged) return props.app;
return viewingOrigin.value === "spark"
? props.app.sparkApp || props.app
: props.app.apmApp || props.app;
});
watch(
() => displayApp.value,
(newApp) => {
if (newApp) {
emit("check-install", newApp);
}
},
{ immediate: false },
);
const activeDownload = computed(() => {
return downloads.value.find((d) => d.pkgname === displayApp.value?.pkgname);
});
const isinstalled = computed(() => {
return viewingOrigin.value === "spark"
? props.sparkInstalled
: props.apmInstalled;
});
const isOtherVersionInstalled = computed(() => {
return viewingOrigin.value === "spark"
? props.apmInstalled
: props.sparkInstalled;
});
const { installFeedback } = useInstallFeedback(appPkgname);
const { isCompleted } = useDownloadItemStatus(appPkgname);
const installBtnText = computed(() => {
if (isinstalled.value) {
return "已安装";
}
if (isCompleted.value) {
return "已安装";
}
if (isOtherVersionInstalled.value) {
return viewingOrigin.value === "spark" ? "已安装apm版" : "已安装spark版";
}
if (installFeedback.value) {
const status = activeDownload.value?.status;
if (status === "downloading") {
return `下载中 ${Math.floor((activeDownload.value?.progress || 0) * 100)}%`;
}
if (status === "installing") {
return "安装中...";
}
return "已加入队列";
}
return "安装";
});
const iconPath = computed(() => {
if (!displayApp.value) return "";
const arch = window.apm_store.arch || "amd64";
const finalArch =
displayApp.value.origin === "spark" ? `${arch}-store` : `${arch}-apm`;
return `${APM_STORE_BASE_URL}/${finalArch}/${displayApp.value.category}/${displayApp.value.pkgname}/icon.png`;
});
const downloadCount = ref<string>("");
// 监听 app 变化获取新app的下载量
watch(
() => displayApp.value,
async (newApp) => {
if (newApp) {
downloadCount.value = "";
try {
const arch = window.apm_store.arch || "amd64";
const finalArch =
newApp.origin === "spark" ? `${arch}-store` : `${arch}-apm`;
const url = `${APM_STORE_BASE_URL}/${finalArch}/${newApp.category}/${newApp.pkgname}/download-times.txt`;
const resp = await axios.get(url, { responseType: "text" });
if (resp.status === 200) {
downloadCount.value = String(resp.data).trim();
} else {
downloadCount.value = "N/A";
throw new Error(`Unexpected response status: ${resp.status}`);
}
} catch (e) {
console.error("Failed to fetch download count", e);
}
}
},
{ immediate: true },
);
const closeModal = () => {
emit("close");
};
const handleInstall = () => {
if (displayApp.value) {
emit("install", displayApp.value);
}
};
const handleRemove = () => {
if (displayApp.value) {
emit("remove", displayApp.value);
}
};
const openPreview = (index: number) => {
emit("open-preview", index);
};
const hideImage = (e: Event) => {
(e.target as HTMLElement).style.display = "none";
};
</script>