feat(account): record downloads and show reviews

This commit is contained in:
2026-05-19 00:25:57 +08:00
parent 8da044495a
commit 78a04fb51f
8 changed files with 323 additions and 7 deletions
+28 -2
View File
@@ -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");
+27
View File
@@ -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();
});
});
+40
View File
@@ -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");
});
});
+9
View File
@@ -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,
+192
View File
@@ -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>
+7 -4
View File
@@ -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) => {
+2 -1
View File
@@ -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;