mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-04-26 01:10:16 +08:00
- 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.
430 lines
15 KiB
Vue
430 lines
15 KiB
Vue
<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>
|