Files
spark-store/src/components/FavoriteFolderManager.vue
T

195 lines
6.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<section
class="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-4 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<h1 class="text-2xl font-semibold text-slate-900 dark:text-white">
我的收藏
</h1>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
管理收藏夹中的应用已下架或不可用项目会保留显示
</p>
</div>
<button
type="button"
class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
@click="emit('create-folder')"
>
新建收藏夹
</button>
</div>
<div class="mt-5 flex flex-wrap gap-2">
<button
v-for="folder in folders"
:key="folder.id"
type="button"
class="rounded-full px-4 py-2 text-sm font-medium transition"
:class="
folder.id === activeFolderId
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700'
"
@click="emit('select-folder', folder.id)"
>
{{ folder.name }} ({{ folder.itemCount }})
</button>
</div>
<div v-if="loading" class="mt-6 text-sm text-slate-500">加载中...</div>
<div v-else-if="error" class="mt-6 text-sm text-rose-500">{{ error }}</div>
<div v-else class="mt-6 space-y-3">
<div
v-if="items.length === 0"
class="rounded-2xl border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400"
>
当前收藏夹暂无应用
</div>
<div
v-for="resolved in items"
:key="resolved.item.id"
class="flex items-center gap-3 rounded-2xl border border-slate-200 p-4 transition hover:bg-slate-50 dark:border-slate-800 dark:hover:bg-slate-800/60"
>
<input
v-model="selectedIds"
type="checkbox"
class="h-4 w-4 rounded border-slate-300"
:value="resolved.item.id"
:aria-label="`选择 ${resolved.item.name || resolved.item.pkgname}`"
/>
<button
type="button"
class="flex min-w-0 flex-1 items-center gap-3 text-left"
:aria-label="`打开 ${resolved.item.name || resolved.item.pkgname} 详情`"
:disabled="!resolved.selectedApp"
@click="openFavoriteDetail(resolved)"
>
<img
v-if="resolved.item.iconUrl"
:src="resolved.item.iconUrl"
alt=""
class="h-10 w-10 rounded-xl object-cover"
/>
<span
v-else
class="flex h-10 w-10 items-center justify-center rounded-xl bg-slate-100 text-sm font-semibold text-slate-500 dark:bg-slate-800"
>
{{ (resolved.item.name || resolved.item.pkgname).slice(0, 1) }}
</span>
<span class="min-w-0 flex-1">
<span
class="block truncate font-medium text-slate-900 dark:text-white"
>
{{ resolved.item.name || resolved.item.pkgname }}
</span>
<span
class="block truncate text-xs text-slate-500 dark:text-slate-400"
>
{{ resolved.item.pkgname }} · {{ resolved.item.category }}
</span>
</span>
</button>
<span
class="rounded-full px-3 py-1 text-xs font-medium"
:class="statusClass(resolved.status)"
>
{{ resolved.reason }}
</span>
</div>
</div>
<div class="mt-6 flex flex-wrap gap-3">
<button
type="button"
class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-50 disabled:opacity-40 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
@click="selectInstallable"
>
选择可安装
</button>
<button
type="button"
class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700 disabled:opacity-40 dark:bg-slate-100 dark:text-slate-900"
:disabled="selectedInstallableItems.length === 0"
@click="emit('install-selected', selectedInstallableItems)"
>
加入安装队列
</button>
<button
type="button"
class="rounded-xl border border-rose-300 px-4 py-2 text-sm font-medium text-rose-600 transition hover:bg-rose-50 disabled:opacity-40 dark:border-rose-900/70 dark:hover:bg-rose-950/30"
:disabled="selectedIds.length === 0"
@click="emit('remove-selected', [...selectedIds])"
>
移除选中
</button>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import type {
FavoriteAvailabilityStatus,
App,
FavoriteFolder,
ResolvedFavoriteItem,
} from "@/global/typedefinition";
const props = defineProps<{
folders: FavoriteFolder[];
activeFolderId: number | null;
items: ResolvedFavoriteItem[];
loading: boolean;
error: string;
}>();
const emit = defineEmits<{
"select-folder": [folderId: number];
"create-folder": [];
"remove-selected": [itemIds: number[]];
"install-selected": [items: ResolvedFavoriteItem[]];
"open-detail": [app: App];
}>();
const selectedIds = ref<number[]>([]);
const selectedInstallableItems = computed(() =>
props.items.filter(
(item) =>
selectedIds.value.includes(item.item.id) && item.status === "installable",
),
);
watch(
() => props.items,
() => {
const visibleIds = new Set(props.items.map((item) => item.item.id));
selectedIds.value = selectedIds.value.filter((id) => visibleIds.has(id));
},
);
const selectInstallable = () => {
selectedIds.value = props.items
.filter((item) => item.status === "installable")
.map((item) => item.item.id);
};
const openFavoriteDetail = (resolved: ResolvedFavoriteItem) => {
if (!resolved.selectedApp) return;
emit("open-detail", resolved.selectedApp);
};
const statusClass = (status: FavoriteAvailabilityStatus): string => {
if (status === "installable") {
return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300";
}
if (status === "installed") {
return "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300";
}
return "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300";
};
</script>