mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-06-22 06:03:49 +08:00
feat(account): add user management view
This commit is contained in:
+51
-12
@@ -84,17 +84,19 @@
|
|||||||
@check-install="checkAppInstalled"
|
@check-install="checkAppInstalled"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
<section
|
<UserManagementView
|
||||||
v-else-if="currentView === 'account'"
|
v-else-if="currentView === 'account' && currentUser"
|
||||||
class="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900"
|
:user="currentUser"
|
||||||
>
|
:downloaded-apps="downloadedApps"
|
||||||
<h1 class="text-2xl font-semibold text-slate-900 dark:text-white">
|
:sync-enabled="installedSyncEnabled ?? false"
|
||||||
用户管理
|
:loading="downloadedLoading"
|
||||||
</h1>
|
:error="downloadedError"
|
||||||
<p class="mt-3 text-sm text-slate-500 dark:text-slate-400">
|
@open-forum="openExternalUrl(FLARUM_BASE_URL)"
|
||||||
账号资料与安全设置功能即将开放。
|
@edit-profile="openExternalUrl(FLARUM_SETTINGS_URL)"
|
||||||
</p>
|
@toggle-sync="setInstalledSyncEnabled"
|
||||||
</section>
|
@sync-now="syncInstalledAppsNow"
|
||||||
|
@refresh-downloads="loadDownloadedHistory"
|
||||||
|
/>
|
||||||
<FavoriteFolderManager
|
<FavoriteFolderManager
|
||||||
v-else-if="currentView === 'favorites'"
|
v-else-if="currentView === 'favorites'"
|
||||||
:folders="favoriteFolders"
|
:folders="favoriteFolders"
|
||||||
@@ -256,6 +258,7 @@ import LoginModal from "./components/LoginModal.vue";
|
|||||||
import LoginPromptModal from "./components/LoginPromptModal.vue";
|
import LoginPromptModal from "./components/LoginPromptModal.vue";
|
||||||
import FavoriteFolderSelector from "./components/FavoriteFolderSelector.vue";
|
import FavoriteFolderSelector from "./components/FavoriteFolderSelector.vue";
|
||||||
import FavoriteFolderManager from "./components/FavoriteFolderManager.vue";
|
import FavoriteFolderManager from "./components/FavoriteFolderManager.vue";
|
||||||
|
import UserManagementView from "./components/UserManagementView.vue";
|
||||||
import {
|
import {
|
||||||
APM_STORE_BASE_URL,
|
APM_STORE_BASE_URL,
|
||||||
FLARUM_BASE_URL,
|
FLARUM_BASE_URL,
|
||||||
@@ -274,6 +277,10 @@ import {
|
|||||||
removeDownloadItem,
|
removeDownloadItem,
|
||||||
watchDownloadsChange,
|
watchDownloadsChange,
|
||||||
} from "./global/downloadStatus";
|
} from "./global/downloadStatus";
|
||||||
|
import {
|
||||||
|
installedSyncEnabled,
|
||||||
|
setInstalledSyncEnabled,
|
||||||
|
} from "./global/accountSyncState";
|
||||||
import {
|
import {
|
||||||
countSearchMatchesByCategory,
|
countSearchMatchesByCategory,
|
||||||
rankAppsBySearch,
|
rankAppsBySearch,
|
||||||
@@ -284,6 +291,7 @@ import {
|
|||||||
bulkDeleteFavoriteItems,
|
bulkDeleteFavoriteItems,
|
||||||
createFavoriteFolder,
|
createFavoriteFolder,
|
||||||
exchangeFlarumToken,
|
exchangeFlarumToken,
|
||||||
|
listDownloadedApps,
|
||||||
listFavoriteFolders,
|
listFavoriteFolders,
|
||||||
listFavoriteItems,
|
listFavoriteItems,
|
||||||
recordDownloadedApp,
|
recordDownloadedApp,
|
||||||
@@ -408,6 +416,9 @@ const favoriteTargetApp = ref<App | null>(null);
|
|||||||
const favoriteLoading = ref(false);
|
const favoriteLoading = ref(false);
|
||||||
const favoriteError = ref("");
|
const favoriteError = ref("");
|
||||||
const favoriteRequestGeneration = ref(0);
|
const favoriteRequestGeneration = ref(0);
|
||||||
|
const downloadedApps = ref<DownloadedAppRecord[]>([]);
|
||||||
|
const downloadedLoading = ref(false);
|
||||||
|
const downloadedError = ref("");
|
||||||
const systemInfo = ref<SystemInfo>({ distro: "unknown" });
|
const systemInfo = ref<SystemInfo>({ distro: "unknown" });
|
||||||
type PendingDownloadRecord = Omit<
|
type PendingDownloadRecord = Omit<
|
||||||
DownloadedAppRecord,
|
DownloadedAppRecord,
|
||||||
@@ -1399,6 +1410,12 @@ const clearFavoriteState = () => {
|
|||||||
favoriteError.value = "";
|
favoriteError.value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearDownloadedState = () => {
|
||||||
|
downloadedApps.value = [];
|
||||||
|
downloadedLoading.value = false;
|
||||||
|
downloadedError.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
const isCurrentFavoriteRequest = (generation: number): boolean =>
|
const isCurrentFavoriteRequest = (generation: number): boolean =>
|
||||||
favoriteRequestGeneration.value === generation && isLoggedIn.value;
|
favoriteRequestGeneration.value === generation && isLoggedIn.value;
|
||||||
|
|
||||||
@@ -1406,6 +1423,7 @@ const handleLogout = () => {
|
|||||||
logout();
|
logout();
|
||||||
pendingDownloadRecords.clear();
|
pendingDownloadRecords.clear();
|
||||||
clearFavoriteState();
|
clearFavoriteState();
|
||||||
|
clearDownloadedState();
|
||||||
showLoginModal.value = false;
|
showLoginModal.value = false;
|
||||||
showLoginPrompt.value = false;
|
showLoginPrompt.value = false;
|
||||||
isSidebarOpen.value = false;
|
isSidebarOpen.value = false;
|
||||||
@@ -1435,12 +1453,33 @@ const handleFlarumLogin = async (payload: FlarumLoginPayload) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openUserManagement = () => {
|
const loadDownloadedHistory = async (): Promise<void> => {
|
||||||
|
if (!requireLogin("请登录后查看和管理账号信息。")) return;
|
||||||
|
|
||||||
|
downloadedLoading.value = true;
|
||||||
|
downloadedError.value = "";
|
||||||
|
try {
|
||||||
|
const result = await listDownloadedApps(1, 50);
|
||||||
|
downloadedApps.value = result.items;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
downloadedApps.value = [];
|
||||||
|
downloadedError.value = (error as Error)?.message || "读取下载历史失败";
|
||||||
|
} finally {
|
||||||
|
downloadedLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncInstalledAppsNow = () => {
|
||||||
|
logger.warn("已安装应用同步将在后续任务中启用");
|
||||||
|
};
|
||||||
|
|
||||||
|
const openUserManagement = async () => {
|
||||||
if (!requireLogin("请登录后查看和管理账号信息。")) return;
|
if (!requireLogin("请登录后查看和管理账号信息。")) return;
|
||||||
currentView.value = "account";
|
currentView.value = "account";
|
||||||
activeTab.value = "account";
|
activeTab.value = "account";
|
||||||
isSidebarOpen.value = false;
|
isSidebarOpen.value = false;
|
||||||
showLoginPrompt.value = false;
|
showLoginPrompt.value = false;
|
||||||
|
await loadDownloadedHistory();
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadFavoriteFolders = async (
|
const loadFavoriteFolders = async (
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { render, screen } from "@testing-library/vue";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import UserManagementView from "@/components/UserManagementView.vue";
|
||||||
|
import type { DownloadedAppRecord, SparkUser } from "@/global/typedefinition";
|
||||||
|
|
||||||
|
const user: SparkUser = {
|
||||||
|
id: 1,
|
||||||
|
flarumUserId: "123",
|
||||||
|
username: "momen",
|
||||||
|
displayName: "Momen",
|
||||||
|
avatarUrl: "https://bbs.spark-app.store/avatar.png",
|
||||||
|
forumLevel: "管理员",
|
||||||
|
forumGroups: ["管理员"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const download: DownloadedAppRecord = {
|
||||||
|
id: 1,
|
||||||
|
appKey: "app:office:wps",
|
||||||
|
pkgname: "wps",
|
||||||
|
name: "WPS",
|
||||||
|
category: "office",
|
||||||
|
selectedOrigin: "apm",
|
||||||
|
version: "1.0.0",
|
||||||
|
packageArch: "amd64",
|
||||||
|
downloadedAt: "2026-05-18T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("UserManagementView", () => {
|
||||||
|
it("renders profile, forum level, links, downloads, and sync preference", () => {
|
||||||
|
render(UserManagementView, {
|
||||||
|
props: {
|
||||||
|
user,
|
||||||
|
downloadedApps: [download],
|
||||||
|
syncEnabled: true,
|
||||||
|
loading: false,
|
||||||
|
error: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("Momen")).toBeTruthy();
|
||||||
|
expect(screen.getByText("管理员")).toBeTruthy();
|
||||||
|
expect(screen.getByText("论坛首页")).toBeTruthy();
|
||||||
|
expect(screen.getByText("修改论坛资料")).toBeTruthy();
|
||||||
|
expect(screen.getByText("WPS")).toBeTruthy();
|
||||||
|
expect(screen.getByLabelText("自动同步已安装应用")).toBeChecked();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<template>
|
||||||
|
<section
|
||||||
|
class="space-y-6 rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<img
|
||||||
|
v-if="user.avatarUrl"
|
||||||
|
:src="user.avatarUrl"
|
||||||
|
:alt="user.displayName"
|
||||||
|
class="h-16 w-16 rounded-2xl border border-slate-200 object-cover dark:border-slate-700"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex h-16 w-16 items-center justify-center rounded-2xl bg-slate-100 text-2xl font-semibold text-slate-500 dark:bg-slate-800 dark:text-slate-300"
|
||||||
|
>
|
||||||
|
{{ userInitial }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold text-slate-900 dark:text-white">
|
||||||
|
用户管理
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
class="mt-1 text-lg font-medium text-slate-800 dark:text-slate-100"
|
||||||
|
>
|
||||||
|
{{ user.displayName }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
@{{ user.username }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{{ user.forumLevel }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-sky-300 hover:text-sky-600 dark:border-slate-700 dark:text-slate-200 dark:hover:border-sky-500 dark:hover:text-sky-300"
|
||||||
|
@click="emit('open-forum')"
|
||||||
|
>
|
||||||
|
论坛首页
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full bg-sky-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-sky-500"
|
||||||
|
@click="emit('edit-profile')"
|
||||||
|
>
|
||||||
|
修改论坛资料
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="visibleForumGroups.length > 0"
|
||||||
|
class="flex flex-wrap gap-2 text-sm text-slate-600 dark:text-slate-300"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-for="group in visibleForumGroups"
|
||||||
|
:key="group"
|
||||||
|
class="rounded-full bg-slate-100 px-3 py-1 dark:bg-slate-800"
|
||||||
|
>
|
||||||
|
{{ group }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-4 rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-950/40 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
class="flex items-center gap-3 text-sm font-medium text-slate-700 dark:text-slate-200"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
|
||||||
|
aria-label="自动同步已安装应用"
|
||||||
|
:checked="syncEnabled"
|
||||||
|
@change="handleSyncToggle"
|
||||||
|
/>
|
||||||
|
自动同步已安装应用
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-sky-300 hover:text-sky-600 dark:border-slate-700 dark:text-slate-200 dark:hover:border-sky-500 dark:hover:text-sky-300"
|
||||||
|
@click="emit('sync-now')"
|
||||||
|
>
|
||||||
|
立即同步
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-slate-900 dark:text-white">
|
||||||
|
下载历史
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
最近通过当前账号记录的应用安装历史。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-sky-300 hover:text-sky-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:text-slate-200 dark:hover:border-sky-500 dark:hover:text-sky-300"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="emit('refresh-downloads')"
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="loading" class="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
正在加载下载历史...
|
||||||
|
</p>
|
||||||
|
<p v-else-if="error" class="text-sm text-red-600 dark:text-red-400">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-else-if="downloadedApps.length === 0"
|
||||||
|
class="text-sm text-slate-500 dark:text-slate-400"
|
||||||
|
>
|
||||||
|
暂无下载记录。
|
||||||
|
</p>
|
||||||
|
<ul v-else class="divide-y divide-slate-200 dark:divide-slate-800">
|
||||||
|
<li
|
||||||
|
v-for="app in downloadedApps"
|
||||||
|
:key="app.id"
|
||||||
|
class="flex flex-col gap-1 py-4 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-slate-900 dark:text-white">
|
||||||
|
{{ app.name }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{{ app.pkgname }} · {{ app.category }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{{ app.selectedOrigin.toUpperCase() }} · {{ app.version }} ·
|
||||||
|
{{ app.packageArch }}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
import type { DownloadedAppRecord, SparkUser } from "@/global/typedefinition";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
user: SparkUser;
|
||||||
|
downloadedApps: DownloadedAppRecord[];
|
||||||
|
syncEnabled: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"open-forum": [];
|
||||||
|
"edit-profile": [];
|
||||||
|
"toggle-sync": [enabled: boolean];
|
||||||
|
"sync-now": [];
|
||||||
|
"refresh-downloads": [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const userInitial = computed(() =>
|
||||||
|
(props.user.displayName || props.user.username || "?").slice(0, 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
const visibleForumGroups = computed(() =>
|
||||||
|
props.user.forumGroups.filter((group) => group !== props.user.forumLevel),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSyncToggle = (event: Event): void => {
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof HTMLInputElement)) return;
|
||||||
|
emit("toggle-sync", target.checked);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { ref, watch } from "vue";
|
||||||
|
|
||||||
|
const INSTALLED_SYNC_STORAGE_KEY = "spark-store-installed-sync-enabled";
|
||||||
|
|
||||||
|
const readSyncEnabled = (): boolean | null => {
|
||||||
|
const savedValue = localStorage.getItem(INSTALLED_SYNC_STORAGE_KEY);
|
||||||
|
if (savedValue === "true") return true;
|
||||||
|
if (savedValue === "false") return false;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const installedSyncEnabled = ref<boolean | null>(readSyncEnabled());
|
||||||
|
|
||||||
|
export const setInstalledSyncEnabled = (enabled: boolean): void => {
|
||||||
|
installedSyncEnabled.value = enabled;
|
||||||
|
localStorage.setItem(INSTALLED_SYNC_STORAGE_KEY, String(enabled));
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(installedSyncEnabled, (enabled) => {
|
||||||
|
if (enabled === null) {
|
||||||
|
localStorage.removeItem(INSTALLED_SYNC_STORAGE_KEY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(INSTALLED_SYNC_STORAGE_KEY, String(enabled));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user