mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-06-22 14:13:49 +08:00
feat(detail): move app details into content view
This commit is contained in:
+69
-28
@@ -63,7 +63,29 @@
|
|||||||
/>
|
/>
|
||||||
<div class="px-4 py-6 lg:px-10">
|
<div class="px-4 py-6 lg:px-10">
|
||||||
<section
|
<section
|
||||||
v-if="currentView === 'account'"
|
v-if="currentView === 'detail' && currentApp"
|
||||||
|
data-app-detail-page="detail"
|
||||||
|
>
|
||||||
|
<AppDetailPage
|
||||||
|
:app="currentApp"
|
||||||
|
:screenshots="screenshots"
|
||||||
|
:spark-installed="currentAppSparkInstalled"
|
||||||
|
:apm-installed="currentAppApmInstalled"
|
||||||
|
:logged-in="isLoggedIn"
|
||||||
|
:review-app-key="currentReviewAppKey"
|
||||||
|
:review-tags="currentReviewTags"
|
||||||
|
@back="closeDetail"
|
||||||
|
@install="onDetailInstall"
|
||||||
|
@remove="onDetailRemove"
|
||||||
|
@favorite="onDetailFavorite"
|
||||||
|
@request-login="handleDetailRequestLogin"
|
||||||
|
@open-preview="openScreenPreview"
|
||||||
|
@open-app="openDownloadedApp"
|
||||||
|
@check-install="checkAppInstalled"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<section
|
||||||
|
v-else-if="currentView === 'account'"
|
||||||
class="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900"
|
class="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900"
|
||||||
>
|
>
|
||||||
<h1 class="text-2xl font-semibold text-slate-900 dark:text-white">
|
<h1 class="text-2xl font-semibold text-slate-900 dark:text-white">
|
||||||
@@ -106,21 +128,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<AppDetailModal
|
|
||||||
data-app-modal="detail"
|
|
||||||
:show="showModal"
|
|
||||||
:app="currentApp"
|
|
||||||
:screenshots="screenshots"
|
|
||||||
:spark-installed="currentAppSparkInstalled"
|
|
||||||
:apm-installed="currentAppApmInstalled"
|
|
||||||
@close="closeDetail"
|
|
||||||
@install="onDetailInstall"
|
|
||||||
@remove="onDetailRemove"
|
|
||||||
@open-preview="openScreenPreview"
|
|
||||||
@open-app="openDownloadedApp"
|
|
||||||
@check-install="checkAppInstalled"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ScreenPreview
|
<ScreenPreview
|
||||||
:show="showPreview"
|
:show="showPreview"
|
||||||
:screenshots="screenshots"
|
:screenshots="screenshots"
|
||||||
@@ -227,7 +234,7 @@ import AppHeader from "./components/AppHeader.vue";
|
|||||||
import AppGrid from "./components/AppGrid.vue";
|
import AppGrid from "./components/AppGrid.vue";
|
||||||
import HomeView from "./components/HomeView.vue";
|
import HomeView from "./components/HomeView.vue";
|
||||||
import CategoryBar from "./components/CategoryBar.vue";
|
import CategoryBar from "./components/CategoryBar.vue";
|
||||||
import AppDetailModal from "./components/AppDetailModal.vue";
|
import AppDetailPage from "./components/AppDetailPage.vue";
|
||||||
import ScreenPreview from "./components/ScreenPreview.vue";
|
import ScreenPreview from "./components/ScreenPreview.vue";
|
||||||
import DownloadQueue from "./components/DownloadQueue.vue";
|
import DownloadQueue from "./components/DownloadQueue.vue";
|
||||||
import DownloadDetail from "./components/DownloadDetail.vue";
|
import DownloadDetail from "./components/DownloadDetail.vue";
|
||||||
@@ -277,6 +284,11 @@ import {
|
|||||||
isOriginEnabled,
|
isOriginEnabled,
|
||||||
} from "./modules/storeFilter";
|
} from "./modules/storeFilter";
|
||||||
import { createUpdateCenterStore } from "./modules/updateCenter";
|
import { createUpdateCenterStore } from "./modules/updateCenter";
|
||||||
|
import {
|
||||||
|
buildReviewAppKey,
|
||||||
|
buildReviewTags,
|
||||||
|
getDisplayApp,
|
||||||
|
} from "./modules/appIdentity";
|
||||||
import type {
|
import type {
|
||||||
App,
|
App,
|
||||||
AppJson,
|
AppJson,
|
||||||
@@ -288,6 +300,7 @@ import type {
|
|||||||
FlarumLoginPayload,
|
FlarumLoginPayload,
|
||||||
SidebarEntry,
|
SidebarEntry,
|
||||||
UpdateCenterItem,
|
UpdateCenterItem,
|
||||||
|
ReviewTags,
|
||||||
} from "./global/typedefinition";
|
} from "./global/typedefinition";
|
||||||
import type { Ref } from "vue";
|
import type { Ref } from "vue";
|
||||||
import type { IpcRendererEvent } from "electron";
|
import type { IpcRendererEvent } from "electron";
|
||||||
@@ -329,11 +342,12 @@ const isDarkTheme = computed(() => {
|
|||||||
const categories: Ref<Record<string, CategoryInfo>> = ref({});
|
const categories: Ref<Record<string, CategoryInfo>> = ref({});
|
||||||
const apps: Ref<App[]> = ref([]);
|
const apps: Ref<App[]> = ref([]);
|
||||||
const activeTab = ref("home");
|
const activeTab = ref("home");
|
||||||
const currentView = ref<"default" | "account" | "favorites">("default");
|
type MainView = "default" | "account" | "favorites" | "detail";
|
||||||
|
const currentView = ref<MainView>("default");
|
||||||
|
const detailPreviousView = ref<MainView>("default");
|
||||||
const selectedCategory = ref("all");
|
const selectedCategory = ref("all");
|
||||||
const searchQuery = ref("");
|
const searchQuery = ref("");
|
||||||
const isSidebarOpen = ref(false);
|
const isSidebarOpen = ref(false);
|
||||||
const showModal = ref(false);
|
|
||||||
const showPreview = ref(false);
|
const showPreview = ref(false);
|
||||||
const currentScreenIndex = ref(0);
|
const currentScreenIndex = ref(0);
|
||||||
const screenshots = ref<string[]>([]);
|
const screenshots = ref<string[]>([]);
|
||||||
@@ -442,6 +456,23 @@ const entryCounts = computed(() => {
|
|||||||
return counts;
|
return counts;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const currentDisplayApp = computed(() => getDisplayApp(currentApp.value));
|
||||||
|
|
||||||
|
const clientArch = computed(() => window.apm_store.arch || "amd64");
|
||||||
|
|
||||||
|
const currentReviewAppKey = computed(() => {
|
||||||
|
if (!currentDisplayApp.value) return "";
|
||||||
|
return buildReviewAppKey(currentDisplayApp.value, clientArch.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentReviewTags = computed<ReviewTags | null>(() => {
|
||||||
|
if (!currentDisplayApp.value) return null;
|
||||||
|
return buildReviewTags(currentDisplayApp.value, {
|
||||||
|
clientArch: clientArch.value,
|
||||||
|
distro: "unknown",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// 方法
|
// 方法
|
||||||
const syncThemePreference = () => {
|
const syncThemePreference = () => {
|
||||||
document.documentElement.classList.toggle("dark", isDarkTheme.value);
|
document.documentElement.classList.toggle("dark", isDarkTheme.value);
|
||||||
@@ -557,7 +588,8 @@ const fetchAppFromStore = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openDetail = async (app: App | Record<string, unknown>) => {
|
const openDetail = async (app: App | Record<string, unknown>) => {
|
||||||
currentView.value = "default";
|
detailPreviousView.value =
|
||||||
|
currentView.value === "detail" ? "default" : currentView.value;
|
||||||
// 提取 pkgname 和 category(必须存在)
|
// 提取 pkgname 和 category(必须存在)
|
||||||
const pkgname = (app as Record<string, unknown>).pkgname as string;
|
const pkgname = (app as Record<string, unknown>).pkgname as string;
|
||||||
const category =
|
const category =
|
||||||
@@ -702,17 +734,14 @@ const openDetail = async (app: App | Record<string, unknown>) => {
|
|||||||
currentApp.value = finalApp;
|
currentApp.value = finalApp;
|
||||||
currentScreenIndex.value = 0;
|
currentScreenIndex.value = 0;
|
||||||
loadScreenshots(displayAppForScreenshots);
|
loadScreenshots(displayAppForScreenshots);
|
||||||
showModal.value = true;
|
currentView.value = "detail";
|
||||||
|
|
||||||
currentAppSparkInstalled.value = false;
|
currentAppSparkInstalled.value = false;
|
||||||
currentAppApmInstalled.value = false;
|
currentAppApmInstalled.value = false;
|
||||||
checkAppInstalled(finalApp);
|
checkAppInstalled(finalApp);
|
||||||
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const modal = document.querySelector(
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
'[data-app-modal="detail"] .modal-panel',
|
|
||||||
);
|
|
||||||
if (modal) modal.scrollTop = 0;
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -762,7 +791,11 @@ const loadScreenshots = (app: App) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const closeDetail = () => {
|
const closeDetail = () => {
|
||||||
showModal.value = false;
|
currentView.value =
|
||||||
|
detailPreviousView.value === "detail"
|
||||||
|
? "default"
|
||||||
|
: detailPreviousView.value;
|
||||||
|
detailPreviousView.value = "default";
|
||||||
currentApp.value = null;
|
currentApp.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1075,6 +1108,14 @@ const onDetailInstall = async (app: App) => {
|
|||||||
await handleInstall(app);
|
await handleInstall(app);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onDetailFavorite = (app: App) => {
|
||||||
|
logger.info(`Favorite requested for ${app.pkgname}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDetailRequestLogin = (message: string) => {
|
||||||
|
requireLogin(message);
|
||||||
|
};
|
||||||
|
|
||||||
const closeUninstallModal = () => {
|
const closeUninstallModal = () => {
|
||||||
showUninstallModal.value = false;
|
showUninstallModal.value = false;
|
||||||
uninstallTargetApp.value = null;
|
uninstallTargetApp.value = null;
|
||||||
@@ -1086,7 +1127,7 @@ const onUninstallSuccess = () => {
|
|||||||
refreshInstalledApps();
|
refreshInstalledApps();
|
||||||
}
|
}
|
||||||
// 更新当前详情页状态(如果在显示)
|
// 更新当前详情页状态(如果在显示)
|
||||||
if (showModal.value && currentApp.value) {
|
if (currentView.value === "detail" && currentApp.value) {
|
||||||
checkAppInstalled(currentApp.value);
|
checkAppInstalled(currentApp.value);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1484,7 +1525,7 @@ onMounted(async () => {
|
|||||||
if (e.key === "ArrowLeft") prevScreen();
|
if (e.key === "ArrowLeft") prevScreen();
|
||||||
if (e.key === "ArrowRight") nextScreen();
|
if (e.key === "ArrowRight") nextScreen();
|
||||||
}
|
}
|
||||||
if (showModal.value && e.key === "Escape") {
|
if (currentView.value === "detail" && e.key === "Escape") {
|
||||||
closeDetail();
|
closeDetail();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/vue";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import AppDetailPage from "@/components/AppDetailPage.vue";
|
||||||
|
import type { App } from "@/global/typedefinition";
|
||||||
|
|
||||||
|
const app: App = {
|
||||||
|
name: "WPS",
|
||||||
|
pkgname: "wps",
|
||||||
|
version: "1.0.0",
|
||||||
|
filename: "wps_1.0.0_amd64.deb",
|
||||||
|
torrent_address: "",
|
||||||
|
author: "",
|
||||||
|
contributor: "",
|
||||||
|
website: "",
|
||||||
|
update: "",
|
||||||
|
size: "110M",
|
||||||
|
more: "Office suite",
|
||||||
|
tags: "office",
|
||||||
|
img_urls: [],
|
||||||
|
icons: "",
|
||||||
|
category: "office",
|
||||||
|
origin: "apm",
|
||||||
|
currentStatus: "not-installed",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("AppDetailPage", () => {
|
||||||
|
it("renders as page, emits back, and gates favorite for anonymous users", async () => {
|
||||||
|
const rendered = render(AppDetailPage, {
|
||||||
|
props: {
|
||||||
|
app,
|
||||||
|
screenshots: [],
|
||||||
|
sparkInstalled: false,
|
||||||
|
apmInstalled: false,
|
||||||
|
loggedIn: false,
|
||||||
|
reviewAppKey: "apm:amd64-apm:office:wps",
|
||||||
|
reviewTags: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("Office suite")).toBeTruthy();
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "返回" }));
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "收藏" }));
|
||||||
|
|
||||||
|
expect(rendered.emitted("back")).toHaveLength(1);
|
||||||
|
expect(rendered.emitted("request-login")?.[0]?.[0]).toBe(
|
||||||
|
"收藏应用需要登录星火账号。",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildFavoriteAppKey,
|
||||||
|
buildReviewAppKey,
|
||||||
|
buildReviewTags,
|
||||||
|
getDisplayApp,
|
||||||
|
parsePackageArch,
|
||||||
|
} from "@/modules/appIdentity";
|
||||||
|
import type { App } from "@/global/typedefinition";
|
||||||
|
|
||||||
|
const app: App = {
|
||||||
|
name: "WPS",
|
||||||
|
pkgname: "wps",
|
||||||
|
version: "1.0.0",
|
||||||
|
filename: "wps_1.0.0_amd64.deb",
|
||||||
|
torrent_address: "",
|
||||||
|
author: "",
|
||||||
|
contributor: "",
|
||||||
|
website: "",
|
||||||
|
update: "",
|
||||||
|
size: "",
|
||||||
|
more: "",
|
||||||
|
tags: "",
|
||||||
|
img_urls: [],
|
||||||
|
icons: "",
|
||||||
|
category: "office",
|
||||||
|
origin: "apm",
|
||||||
|
currentStatus: "not-installed",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("appIdentity", () => {
|
||||||
|
it("builds favorite and review keys", () => {
|
||||||
|
expect(buildFavoriteAppKey(app)).toBe("app:office:wps");
|
||||||
|
expect(buildReviewAppKey(app, "amd64")).toBe("apm:amd64-apm:office:wps");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses package arch and review tags", () => {
|
||||||
|
expect(parsePackageArch(app.filename)).toBe("amd64");
|
||||||
|
expect(
|
||||||
|
buildReviewTags(app, { clientArch: "amd64", distro: "deepin 25" }),
|
||||||
|
).toMatchObject({
|
||||||
|
origin: "apm",
|
||||||
|
category: "office",
|
||||||
|
pkgname: "wps",
|
||||||
|
packageArch: "amd64",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns selected display app from merged apps", () => {
|
||||||
|
const merged: App = {
|
||||||
|
...app,
|
||||||
|
isMerged: true,
|
||||||
|
viewingOrigin: "spark",
|
||||||
|
sparkApp: { ...app, origin: "spark" },
|
||||||
|
apmApp: app,
|
||||||
|
};
|
||||||
|
expect(getDisplayApp(merged)?.origin).toBe("spark");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
<template>
|
||||||
|
<section
|
||||||
|
v-if="displayApp"
|
||||||
|
class="mx-auto max-w-6xl rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900 lg:p-6"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mb-5 inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 shadow-sm transition hover:bg-slate-50 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||||
|
aria-label="返回"
|
||||||
|
@click="emit('back')"
|
||||||
|
>
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
<span>返回</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="grid gap-6 lg:grid-cols-[18rem_minmax(0,1fr)]">
|
||||||
|
<aside class="space-y-5">
|
||||||
|
<div class="text-center">
|
||||||
|
<div
|
||||||
|
class="mx-auto flex h-28 w-28 items-center justify-center overflow-hidden rounded-3xl bg-gradient-to-b from-slate-100 to-slate-200 shadow-lg dark:from-slate-800 dark:to-slate-700"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="iconPath"
|
||||||
|
alt="icon"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1 class="mt-4 text-2xl font-bold text-slate-900 dark:text-white">
|
||||||
|
{{ displayApp.name }}
|
||||||
|
</h1>
|
||||||
|
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{{ displayApp.pkgname }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="displayApp.version"
|
||||||
|
class="mt-1 text-sm text-slate-500 dark:text-slate-400"
|
||||||
|
>
|
||||||
|
{{ displayApp.version }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between rounded-2xl border border-slate-200/60 bg-slate-50/70 px-4 py-3 dark:border-slate-800/60 dark:bg-slate-800/50"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-slate-500 dark:text-slate-400">来源</span>
|
||||||
|
<div
|
||||||
|
v-if="app.isMerged"
|
||||||
|
class="flex overflow-hidden rounded-lg border border-slate-200 shadow-sm dark:border-slate-700"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-if="app.sparkApp"
|
||||||
|
type="button"
|
||||||
|
class="px-3 py-1 text-xs font-medium uppercase tracking-wider transition-colors"
|
||||||
|
:class="
|
||||||
|
viewingOrigin === 'spark'
|
||||||
|
? 'bg-orange-500 text-white'
|
||||||
|
: 'bg-slate-100 text-slate-500 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-400 dark:hover:bg-slate-600'
|
||||||
|
"
|
||||||
|
@click="selectOrigin('spark')"
|
||||||
|
>
|
||||||
|
Spark
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="app.apmApp"
|
||||||
|
type="button"
|
||||||
|
class="px-3 py-1 text-xs font-medium uppercase tracking-wider transition-colors"
|
||||||
|
:class="
|
||||||
|
viewingOrigin === 'apm'
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'bg-slate-100 text-slate-500 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-400 dark:hover:bg-slate-600'
|
||||||
|
"
|
||||||
|
@click="selectOrigin('apm')"
|
||||||
|
>
|
||||||
|
APM
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="rounded-md px-2 py-1 text-xs font-bold uppercase tracking-wider"
|
||||||
|
:class="
|
||||||
|
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>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<button
|
||||||
|
v-if="!isInstalled"
|
||||||
|
type="button"
|
||||||
|
class="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-slate-800 px-4 py-2.5 text-sm font-medium text-white shadow-sm transition hover:bg-slate-700 disabled:opacity-40 dark:bg-slate-700 dark:hover:bg-slate-600"
|
||||||
|
:disabled="isOtherVersionInstalled"
|
||||||
|
@click="emit('install', displayApp)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-download text-xs"></i>
|
||||||
|
<span>{{ installButtonText }}</span>
|
||||||
|
</button>
|
||||||
|
<div v-else class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex flex-1 items-center justify-center gap-2 rounded-xl bg-slate-800 px-4 py-2.5 text-sm font-medium text-white shadow-sm transition hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
|
||||||
|
@click="emit('open-app', displayApp.pkgname, displayApp.origin)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-external-link-alt text-xs"></i>
|
||||||
|
<span>打开</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex flex-1 items-center justify-center gap-2 rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm font-medium text-slate-600 transition hover:border-rose-400 hover:bg-rose-50 hover:text-rose-500 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700 dark:hover:text-rose-400"
|
||||||
|
@click="emit('remove', displayApp)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-trash text-xs"></i>
|
||||||
|
<span>卸载</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex w-full items-center justify-center gap-2 rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm font-medium text-slate-600 transition hover:bg-slate-50 hover:text-slate-900 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||||
|
@click="handleFavorite"
|
||||||
|
>
|
||||||
|
<i class="fas fa-star text-xs"></i>
|
||||||
|
<span>收藏</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl
|
||||||
|
class="space-y-2 border-t border-slate-200/60 pt-3 text-xs dark:border-slate-800/60"
|
||||||
|
>
|
||||||
|
<div v-if="displayApp.category" class="flex justify-between gap-3">
|
||||||
|
<dt class="text-slate-400">分类</dt>
|
||||||
|
<dd class="truncate text-slate-700 dark:text-slate-300">
|
||||||
|
{{ displayApp.category }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="displayApp.author" class="flex justify-between gap-3">
|
||||||
|
<dt class="text-slate-400">作者</dt>
|
||||||
|
<dd class="truncate text-slate-700 dark:text-slate-300">
|
||||||
|
{{ displayApp.author }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="displayApp.size" class="flex justify-between gap-3">
|
||||||
|
<dt class="text-slate-400">大小</dt>
|
||||||
|
<dd class="text-slate-700 dark:text-slate-300">
|
||||||
|
{{ displayApp.size }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="displayApp.update" class="flex justify-between gap-3">
|
||||||
|
<dt class="text-slate-400">更新</dt>
|
||||||
|
<dd class="text-slate-700 dark:text-slate-300">
|
||||||
|
{{ displayApp.update }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="min-w-0 space-y-5">
|
||||||
|
<div
|
||||||
|
class="rounded-2xl border border-slate-200/60 bg-slate-50/50 p-5 dark:border-slate-800/60 dark:bg-slate-800/30"
|
||||||
|
>
|
||||||
|
<h2 class="mb-3 flex items-center gap-2 text-base font-semibold">
|
||||||
|
<i class="fas fa-info-circle text-slate-400"></i>
|
||||||
|
应用详情
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
v-if="displayApp.more.trim() !== ''"
|
||||||
|
class="space-y-2 text-sm leading-relaxed text-slate-600 dark:text-slate-300"
|
||||||
|
v-html="detailHtml"
|
||||||
|
></div>
|
||||||
|
<p v-else class="text-sm text-slate-400">暂无应用详情</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="rounded-2xl border border-slate-200/60 bg-slate-50/50 p-5 dark:border-slate-800/60 dark:bg-slate-800/30"
|
||||||
|
>
|
||||||
|
<h2 class="mb-3 flex items-center gap-2 text-base font-semibold">
|
||||||
|
<i class="fas fa-images text-slate-400"></i>
|
||||||
|
应用截图
|
||||||
|
</h2>
|
||||||
|
<div v-if="screenshots.length" class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<img
|
||||||
|
v-for="(screen, index) in screenshots"
|
||||||
|
:key="screen"
|
||||||
|
:src="screen"
|
||||||
|
alt="screenshot"
|
||||||
|
class="h-44 w-full cursor-pointer rounded-2xl border border-slate-200/60 object-cover shadow-sm transition hover:-translate-y-1 hover:shadow-lg dark:border-slate-800/60"
|
||||||
|
loading="lazy"
|
||||||
|
@click="emit('open-preview', index)"
|
||||||
|
@error="hideImage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-sm text-slate-400">暂无应用截图</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import {
|
||||||
|
APM_STORE_BASE_URL,
|
||||||
|
getHybridDefaultOrigin,
|
||||||
|
} from "@/global/storeConfig";
|
||||||
|
import { getDisplayApp } from "@/modules/appIdentity";
|
||||||
|
import type { App, ReviewTags } from "@/global/typedefinition";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
app: App;
|
||||||
|
screenshots: string[];
|
||||||
|
sparkInstalled: boolean;
|
||||||
|
apmInstalled: boolean;
|
||||||
|
loggedIn: boolean;
|
||||||
|
reviewAppKey: string;
|
||||||
|
reviewTags: ReviewTags | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
back: [];
|
||||||
|
install: [app: App];
|
||||||
|
remove: [app: App];
|
||||||
|
favorite: [app: App];
|
||||||
|
"request-login": [message: string];
|
||||||
|
"open-preview": [index: number];
|
||||||
|
"open-app": [pkgname: string, origin?: "spark" | "apm"];
|
||||||
|
"check-install": [app: App];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const viewingOrigin = ref<"spark" | "apm">(
|
||||||
|
props.app.viewingOrigin ?? props.app.origin,
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.app,
|
||||||
|
(app) => {
|
||||||
|
if (app.isMerged) {
|
||||||
|
viewingOrigin.value =
|
||||||
|
app.viewingOrigin ??
|
||||||
|
(app.sparkApp ? getHybridDefaultOrigin(app.sparkApp) : "apm");
|
||||||
|
} else {
|
||||||
|
viewingOrigin.value = app.origin;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const appWithSelectedOrigin = computed<App>(() => ({
|
||||||
|
...props.app,
|
||||||
|
viewingOrigin: viewingOrigin.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const displayApp = computed(() => getDisplayApp(appWithSelectedOrigin.value));
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => displayApp.value,
|
||||||
|
(app) => {
|
||||||
|
if (app) emit("check-install", app);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const isInstalled = computed(() =>
|
||||||
|
viewingOrigin.value === "spark" ? props.sparkInstalled : props.apmInstalled,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isOtherVersionInstalled = computed(() =>
|
||||||
|
viewingOrigin.value === "spark" ? props.apmInstalled : props.sparkInstalled,
|
||||||
|
);
|
||||||
|
|
||||||
|
const installButtonText = computed(() => {
|
||||||
|
if (isOtherVersionInstalled.value) {
|
||||||
|
return viewingOrigin.value === "spark"
|
||||||
|
? "已安装 APM 版"
|
||||||
|
: "已安装 Spark 版";
|
||||||
|
}
|
||||||
|
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 detailHtml = computed(
|
||||||
|
() => displayApp.value?.more.replace(/\n/g, "<br>") ?? "",
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectOrigin = (origin: "spark" | "apm") => {
|
||||||
|
viewingOrigin.value = origin;
|
||||||
|
if (displayApp.value) emit("check-install", displayApp.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFavorite = () => {
|
||||||
|
if (!displayApp.value) return;
|
||||||
|
if (!props.loggedIn) {
|
||||||
|
emit("request-login", "收藏应用需要登录星火账号。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit("favorite", displayApp.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideImage = (event: Event) => {
|
||||||
|
(event.target as HTMLElement).style.display = "none";
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { App, ReviewTags } from "@/global/typedefinition";
|
||||||
|
|
||||||
|
export const parsePackageArch = (filename: string): string => {
|
||||||
|
const match = filename.match(/_([^_]+)\.deb$/);
|
||||||
|
return match?.[1] || "unknown";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildStoreArch = (
|
||||||
|
origin: "spark" | "apm",
|
||||||
|
clientArch: string,
|
||||||
|
): string => {
|
||||||
|
return `${clientArch}-${origin === "spark" ? "store" : "apm"}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildFavoriteAppKey = (app: App): string => {
|
||||||
|
return `app:${app.category || "unknown"}:${app.pkgname}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildReviewAppKey = (app: App, clientArch: string): string => {
|
||||||
|
return `${app.origin}:${buildStoreArch(app.origin, clientArch)}:${app.category || "unknown"}:${app.pkgname}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDisplayApp = (app: App | null): App | null => {
|
||||||
|
if (!app) return null;
|
||||||
|
if (!app.isMerged) return app;
|
||||||
|
if (app.viewingOrigin === "spark") return app.sparkApp ?? app.apmApp ?? app;
|
||||||
|
if (app.viewingOrigin === "apm") return app.apmApp ?? app.sparkApp ?? app;
|
||||||
|
return app.sparkApp ?? app.apmApp ?? app;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildReviewTags = (
|
||||||
|
app: App,
|
||||||
|
options: { clientArch: string; distro: string },
|
||||||
|
): ReviewTags => {
|
||||||
|
return {
|
||||||
|
origin: app.origin,
|
||||||
|
category: app.category || "unknown",
|
||||||
|
pkgname: app.pkgname,
|
||||||
|
version: app.version,
|
||||||
|
packageArch: parsePackageArch(app.filename),
|
||||||
|
clientArch: options.clientArch,
|
||||||
|
distro: options.distro,
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user