mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-06-23 06:33:49 +08:00
feat(account): record downloads and show reviews
This commit is contained in:
@@ -40,6 +40,23 @@ function getAppVersion(): string {
|
||||
}
|
||||
}
|
||||
|
||||
function getSystemInfo(): { distro: string } {
|
||||
try {
|
||||
const raw = fs.readFileSync("/etc/os-release", "utf8");
|
||||
const fields = Object.fromEntries(
|
||||
raw
|
||||
.split("\n")
|
||||
.map((line) => line.match(/^([A-Z_]+)=(.*)$/))
|
||||
.filter((match): match is RegExpMatchArray => match !== null)
|
||||
.map((match) => [match[1], match[2].replace(/^"|"$/g, "")]),
|
||||
);
|
||||
const distro = fields.PRETTY_NAME || fields.NAME || "unknown";
|
||||
return { distro };
|
||||
} catch {
|
||||
return { distro: "unknown" };
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 --version 参数(在单实例检查之前)
|
||||
if (process.argv.includes("--version") || process.argv.includes("-v")) {
|
||||
console.log(getAppVersion());
|
||||
@@ -118,6 +135,7 @@ ipcMain.handle("get-store-filter", (): "spark" | "apm" | "both" =>
|
||||
);
|
||||
|
||||
ipcMain.handle("get-app-version", (): string => getAppVersion());
|
||||
ipcMain.handle("get-system-info", (): { distro: string } => getSystemInfo());
|
||||
|
||||
ipcMain.handle("request-flarum-token", async (_event, payload: unknown) => {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||
|
||||
+28
-2
@@ -286,6 +286,7 @@ import {
|
||||
exchangeFlarumToken,
|
||||
listFavoriteFolders,
|
||||
listFavoriteItems,
|
||||
recordDownloadedApp,
|
||||
} from "./modules/backendApi";
|
||||
import { requestFlarumToken } from "./modules/flarumAuth";
|
||||
import {
|
||||
@@ -306,6 +307,7 @@ import {
|
||||
buildFavoriteAppKey,
|
||||
buildReviewTags,
|
||||
getDisplayApp,
|
||||
parsePackageArch,
|
||||
} from "./modules/appIdentity";
|
||||
import { resolveFavoriteItems } from "./modules/favoriteAvailability";
|
||||
import type {
|
||||
@@ -324,6 +326,7 @@ import type {
|
||||
FavoriteItem,
|
||||
InstalledAppInfo,
|
||||
ResolvedFavoriteItem,
|
||||
SystemInfo,
|
||||
} from "./global/typedefinition";
|
||||
import type { Ref } from "vue";
|
||||
import type { IpcRendererEvent } from "electron";
|
||||
@@ -403,6 +406,7 @@ const favoriteTargetApp = ref<App | null>(null);
|
||||
const favoriteLoading = ref(false);
|
||||
const favoriteError = ref("");
|
||||
const favoriteRequestGeneration = ref(0);
|
||||
const systemInfo = ref<SystemInfo>({ distro: "unknown" });
|
||||
|
||||
/** 启动参数 --no-apm => 仅 Spark;--no-spark => 仅 APM;由主进程 IPC 提供 */
|
||||
const storeFilter = ref<"spark" | "apm" | "both">("both");
|
||||
@@ -500,7 +504,7 @@ const currentReviewTags = computed<ReviewTags | null>(() => {
|
||||
if (!currentDisplayApp.value) return null;
|
||||
return buildReviewTags(currentDisplayApp.value, {
|
||||
clientArch: clientArch.value,
|
||||
distro: "unknown",
|
||||
distro: systemInfo.value.distro,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1236,7 +1240,22 @@ const onDetailRemove = (app: App) => {
|
||||
};
|
||||
|
||||
const onDetailInstall = async (app: App) => {
|
||||
await handleInstall(app);
|
||||
const download = await handleInstall(app);
|
||||
if (!download || !isLoggedIn.value) return;
|
||||
|
||||
try {
|
||||
await recordDownloadedApp({
|
||||
appKey: buildFavoriteAppKey(app),
|
||||
pkgname: app.pkgname,
|
||||
name: app.name,
|
||||
category: app.category,
|
||||
selectedOrigin: app.origin,
|
||||
version: app.version,
|
||||
packageArch: app.arch || parsePackageArch(app.filename),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
logger.warn({ err: error }, "记录下载应用失败");
|
||||
}
|
||||
};
|
||||
|
||||
const onDetailFavorite = async (app: App) => {
|
||||
@@ -1783,6 +1802,13 @@ onMounted(async () => {
|
||||
initTheme();
|
||||
updateCenterStore.bind();
|
||||
|
||||
try {
|
||||
systemInfo.value = await window.ipcRenderer.invoke("get-system-info");
|
||||
} catch (error: unknown) {
|
||||
logger.warn({ err: error }, "读取系统信息失败");
|
||||
systemInfo.value = { distro: "unknown" };
|
||||
}
|
||||
|
||||
// 从主进程获取启动参数(--no-apm / --no-spark),再加载数据
|
||||
storeFilter.value = await window.ipcRenderer.invoke("get-store-filter");
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { render, screen } from "@testing-library/vue";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import ReviewsPanel from "@/components/ReviewsPanel.vue";
|
||||
import type { ReviewTags } from "@/global/typedefinition";
|
||||
|
||||
const tags: ReviewTags = {
|
||||
origin: "apm",
|
||||
category: "office",
|
||||
pkgname: "wps",
|
||||
version: "1.0.0",
|
||||
packageArch: "amd64",
|
||||
clientArch: "amd64",
|
||||
distro: "deepin 25",
|
||||
};
|
||||
|
||||
describe("ReviewsPanel", () => {
|
||||
it("shows anonymous login prompt and read-only review tags", () => {
|
||||
render(ReviewsPanel, {
|
||||
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: false },
|
||||
});
|
||||
|
||||
expect(screen.getByText("登录后发表评论")).toBeTruthy();
|
||||
expect(screen.getByText("1.0.0")).toBeTruthy();
|
||||
expect(screen.getByText("deepin 25")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -107,4 +107,44 @@ describe("processInstall queue forwarding", () => {
|
||||
expect.stringContaining('"id":5'),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns queued download metadata for account records", async () => {
|
||||
vi.doMock("axios", () => ({
|
||||
default: {
|
||||
create: vi.fn(() => ({
|
||||
post: vi.fn(() => Promise.resolve({ data: { ok: true } })),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
Object.assign(window.ipcRenderer, {
|
||||
on: vi.fn(),
|
||||
send: vi.fn(),
|
||||
invoke: vi.fn(() => Promise.resolve(true)),
|
||||
});
|
||||
window.apm_store.arch = "amd64";
|
||||
const { handleInstall } = await import("@/modules/processInstall");
|
||||
|
||||
const result = await handleInstall({
|
||||
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",
|
||||
});
|
||||
|
||||
expect(result?.pkgname).toBe("wps");
|
||||
expect(result?.origin).toBe("apm");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -194,6 +194,14 @@
|
||||
</div>
|
||||
<p v-else class="text-sm text-slate-400">暂无应用截图</p>
|
||||
</div>
|
||||
|
||||
<ReviewsPanel
|
||||
v-if="reviewAppKey && reviewTags"
|
||||
:app-key="reviewAppKey"
|
||||
:tags="reviewTags"
|
||||
:logged-in="loggedIn"
|
||||
@request-login="$emit('request-login', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -201,6 +209,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import ReviewsPanel from "@/components/ReviewsPanel.vue";
|
||||
import {
|
||||
APM_STORE_BASE_URL,
|
||||
getHybridDefaultOrigin,
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<section
|
||||
class="rounded-2xl border border-slate-200/60 bg-slate-50/50 p-5 dark:border-slate-800/60 dark:bg-slate-800/30"
|
||||
>
|
||||
<div class="mb-4 flex items-center justify-between gap-3">
|
||||
<h2 class="flex items-center gap-2 text-base font-semibold">
|
||||
<i class="fas fa-comments text-slate-400"></i>
|
||||
应用评价
|
||||
</h2>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||
{{ ratingText }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl class="mb-4 grid gap-2 text-xs text-slate-500 sm:grid-cols-2">
|
||||
<div
|
||||
class="flex justify-between gap-3 rounded-xl bg-white px-3 py-2 dark:bg-slate-900/60"
|
||||
>
|
||||
<dt>版本</dt>
|
||||
<dd class="font-medium text-slate-700 dark:text-slate-300">
|
||||
{{ tags.version }}
|
||||
</dd>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-3 rounded-xl bg-white px-3 py-2 dark:bg-slate-900/60"
|
||||
>
|
||||
<dt>发行版</dt>
|
||||
<dd class="font-medium text-slate-700 dark:text-slate-300">
|
||||
{{ tags.distro }}
|
||||
</dd>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-3 rounded-xl bg-white px-3 py-2 dark:bg-slate-900/60"
|
||||
>
|
||||
<dt>架构</dt>
|
||||
<dd class="font-medium text-slate-700 dark:text-slate-300">
|
||||
{{ tags.packageArch }}
|
||||
</dd>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-3 rounded-xl bg-white px-3 py-2 dark:bg-slate-900/60"
|
||||
>
|
||||
<dt>来源</dt>
|
||||
<dd class="font-medium uppercase text-slate-700 dark:text-slate-300">
|
||||
{{ tags.origin }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<button
|
||||
v-if="!loggedIn"
|
||||
type="button"
|
||||
class="mb-4 inline-flex items-center rounded-xl bg-slate-800 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
|
||||
@click="emit('request-login', '登录后发表评论')"
|
||||
>
|
||||
登录后发表评论
|
||||
</button>
|
||||
|
||||
<form v-else class="mb-4 space-y-3" @submit.prevent="submit">
|
||||
<label
|
||||
class="block text-sm font-medium text-slate-600 dark:text-slate-300"
|
||||
>
|
||||
评分
|
||||
<select
|
||||
v-model.number="rating"
|
||||
class="mt-1 block w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
|
||||
>
|
||||
<option v-for="value in ratingOptions" :key="value" :value="value">
|
||||
{{ value }} 星
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label
|
||||
class="block text-sm font-medium text-slate-600 dark:text-slate-300"
|
||||
>
|
||||
评论
|
||||
<textarea
|
||||
v-model="content"
|
||||
rows="3"
|
||||
class="mt-1 block w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
|
||||
placeholder="分享你的使用体验"
|
||||
></textarea>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center rounded-xl bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-500 disabled:opacity-50"
|
||||
:disabled="submitting"
|
||||
>
|
||||
发表评论
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p v-if="loading" class="text-sm text-slate-400">正在加载评价...</p>
|
||||
<p v-else-if="error" class="text-sm text-rose-500">{{ error }}</p>
|
||||
<div v-else-if="reviews.length" class="space-y-3">
|
||||
<article
|
||||
v-for="review in reviews"
|
||||
:key="review.id"
|
||||
class="rounded-xl bg-white p-3 text-sm dark:bg-slate-900/60"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<strong class="text-slate-700 dark:text-slate-200">
|
||||
{{ review.userDisplayName || "星火用户" }}
|
||||
</strong>
|
||||
<span class="text-xs text-slate-400">{{ review.rating }} 星</span>
|
||||
</div>
|
||||
<p class="mt-2 whitespace-pre-wrap text-slate-600 dark:text-slate-300">
|
||||
{{ review.content || "暂无评论内容" }}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
<p v-else class="text-sm text-slate-400">暂无评价</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
|
||||
import {
|
||||
fetchRatingSummary,
|
||||
fetchReviews,
|
||||
submitReview,
|
||||
} from "@/modules/backendApi";
|
||||
import type {
|
||||
AppReview,
|
||||
RatingSummary,
|
||||
ReviewTags,
|
||||
} from "@/global/typedefinition";
|
||||
|
||||
const props = defineProps<{
|
||||
appKey: string;
|
||||
tags: ReviewTags;
|
||||
loggedIn: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"request-login": [message: string];
|
||||
}>();
|
||||
|
||||
const ratingOptions = [5, 4, 3, 2, 1];
|
||||
const rating = ref(5);
|
||||
const content = ref("");
|
||||
const reviews = ref<AppReview[]>([]);
|
||||
const summary = ref<RatingSummary | null>(null);
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
const ratingText = computed(() => {
|
||||
if (!summary.value || summary.value.reviewCount === 0) return "暂无评分";
|
||||
return `${summary.value.averageRating.toFixed(1)} / 5 (${summary.value.reviewCount})`;
|
||||
});
|
||||
|
||||
const loadReviews = async () => {
|
||||
if (!props.appKey) return;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const [nextSummary, nextReviews] = await Promise.all([
|
||||
fetchRatingSummary(props.appKey),
|
||||
fetchReviews(props.appKey),
|
||||
]);
|
||||
summary.value = nextSummary;
|
||||
reviews.value = nextReviews;
|
||||
} catch (caught: unknown) {
|
||||
error.value = (caught as Error)?.message || "加载评价失败";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
submitting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await submitReview(props.appKey, {
|
||||
rating: rating.value,
|
||||
content: content.value.trim(),
|
||||
tags: props.tags,
|
||||
});
|
||||
content.value = "";
|
||||
await loadReviews();
|
||||
} catch (caught: unknown) {
|
||||
error.value = (caught as Error)?.message || "发表评论失败";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadReviews);
|
||||
watch(() => props.appKey, loadReviews);
|
||||
</script>
|
||||
@@ -21,16 +21,18 @@ import axios from "axios";
|
||||
|
||||
const logger = pino({ name: "processInstall.ts" });
|
||||
|
||||
export const handleInstall = async (appObj?: App) => {
|
||||
export const handleInstall = async (
|
||||
appObj?: App,
|
||||
): Promise<DownloadItem | null> => {
|
||||
const targetApp = appObj || currentApp.value;
|
||||
if (!targetApp?.pkgname) return;
|
||||
if (!targetApp?.pkgname) return null;
|
||||
|
||||
// APM 应用:在创建下载任务前检查 APM 是否可用
|
||||
if (targetApp.origin === "apm") {
|
||||
const hasApm = await window.ipcRenderer.invoke("check-apm-available");
|
||||
if (!hasApm) {
|
||||
showApmInstallDialog.value = true;
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +44,7 @@ export const handleInstall = async (appObj?: App) => {
|
||||
logger.info(
|
||||
`任务已存在,忽略重复添加: ${targetApp.pkgname} (${targetApp.origin})`,
|
||||
);
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
// 创建下载任务
|
||||
@@ -98,6 +100,7 @@ export const handleInstall = async (appObj?: App) => {
|
||||
.then((response) => {
|
||||
logger.info("下载次数统计已发送,状态:", response.data);
|
||||
});
|
||||
return download;
|
||||
};
|
||||
|
||||
export const handleRetry = (download_: DownloadItem) => {
|
||||
|
||||
Vendored
+2
-1
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable */
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import type { UpdateCenterBridge } from "@/global/typedefinition";
|
||||
import type { SystemInfo, UpdateCenterBridge } from "@/global/typedefinition";
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
@@ -34,6 +34,7 @@ interface IpcRendererFacade {
|
||||
// IPC channel type definitions
|
||||
declare interface IpcChannels {
|
||||
"get-app-version": () => string;
|
||||
"get-system-info": () => Promise<SystemInfo>;
|
||||
"request-flarum-token": (payload: {
|
||||
identification: string;
|
||||
password: string;
|
||||
|
||||
Reference in New Issue
Block a user