feat(account): add user management view

This commit is contained in:
2026-05-19 01:17:58 +08:00
parent f280039874
commit bbd9cbccb7
4 changed files with 309 additions and 12 deletions
+51 -12
View File
@@ -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();
});
});
+184
View File
@@ -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>
+26
View File
@@ -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));
});