mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-06-22 14:13:49 +08:00
feat: 新增动态侧边栏配置功能,优化主题色与侧边栏样式
新增SidebarEntry类型定义与侧边栏配置加载逻辑,支持从服务器拉取sidebar-config.json动态配置侧边栏入口 替换原分类侧边栏为可配置样式,新增CategoryBar分类选择组件,更新品牌色为苹果风格蓝色 重构侧边栏状态管理,拆分activeTab与选中分类逻辑,新增侧边栏入口计数统计 添加SIDEBAR_CONFIG.md文档说明配置格式与使用方法,更新测试用例与组件props
This commit is contained in:
@@ -0,0 +1,84 @@
|
|||||||
|
# 侧边栏入口配置 (Sidebar Config)
|
||||||
|
|
||||||
|
星火应用商店支持通过服务器上的 JSON 文件动态配置左侧侧边栏的入口项。
|
||||||
|
|
||||||
|
## 配置文件位置
|
||||||
|
|
||||||
|
将 `sidebar-config.json` 放置在服务器应用仓库的架构目录下:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Spark 仓库
|
||||||
|
{baseUrl}/{arch}-store/sidebar-config.json
|
||||||
|
|
||||||
|
# APM 仓库
|
||||||
|
{baseUrl}/{arch}-apm/sidebar-config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
例如:
|
||||||
|
- `https://example.com/amd64-store/sidebar-config.json`
|
||||||
|
- `https://example.com/arm64-store/sidebar-config.json`
|
||||||
|
|
||||||
|
## JSON 格式
|
||||||
|
|
||||||
|
每个入口项为一个对象,包含以下字段:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `id` | string | ✅ | 唯一标识符,对应分类名或自定义 ID |
|
||||||
|
| `name` | string | ✅ | 侧边栏显示的入口名称 |
|
||||||
|
| `icon` | string | ❌ | FontAwesome 图标类名,如 `fas fa-gamepad` |
|
||||||
|
| `type` | string | ❌ | 入口类型:`category`(分类筛选)、`search`(搜索关键词)、`link`(外部链接)。默认为 `category` |
|
||||||
|
| `value` | string | ❌ | 与 `type` 配合使用的值。`category` 类型为分类名,`search` 类型为搜索关键词。默认为 `id` 的值 |
|
||||||
|
|
||||||
|
## 示例配置
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "games",
|
||||||
|
"name": "游戏专区",
|
||||||
|
"icon": "fas fa-gamepad",
|
||||||
|
"type": "category",
|
||||||
|
"value": "games"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "devtools",
|
||||||
|
"name": "开发工具",
|
||||||
|
"icon": "fas fa-code",
|
||||||
|
"type": "category",
|
||||||
|
"value": "development"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "office",
|
||||||
|
"name": "办公学习",
|
||||||
|
"icon": "fas fa-book",
|
||||||
|
"type": "category",
|
||||||
|
"value": "office"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ai-search",
|
||||||
|
"name": "AI 应用",
|
||||||
|
"icon": "fas fa-robot",
|
||||||
|
"type": "search",
|
||||||
|
"value": "AI"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 入口类型说明
|
||||||
|
|
||||||
|
### `category` 类型
|
||||||
|
点击后在"全部应用"页面按指定分类筛选应用。`value` 字段对应 `categories.json` 中的分类键名。
|
||||||
|
|
||||||
|
### `search` 类型
|
||||||
|
点击后自动使用 `value` 字段的值进行搜索。适用于快速入口,如"AI 应用"、"微信"等热门关键词。
|
||||||
|
|
||||||
|
### `link` 类型(预留)
|
||||||
|
用于跳转到外部链接或内部页面。后续版本支持。
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 如果两个仓库(Spark 和 APM)都存在 `sidebar-config.json`,相同的 `id` 会自动去重合并
|
||||||
|
- 配置文件不存在时,侧边栏不会显示额外的入口项,不影响正常使用
|
||||||
|
- 入口项显示在"首页推荐"和"全部应用"之间,以分隔线区分
|
||||||
|
- 每个入口项会显示对应分类或搜索下的应用数量
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "spark-store",
|
"name": "spark-store",
|
||||||
"version": "5.0.0",
|
"version": "5.1.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "spark-store",
|
"name": "spark-store",
|
||||||
"version": "5.0.0",
|
"version": "5.1.1",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
|||||||
+113
-17
@@ -10,21 +10,22 @@
|
|||||||
></div>
|
></div>
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
class="fixed inset-y-0 left-0 z-50 w-72 transform border-r border-slate-200/70 bg-white/95 px-5 py-6 backdrop-blur transition-transform duration-300 ease-in-out dark:border-slate-800/70 dark:bg-slate-900 lg:sticky lg:top-0 lg:flex lg:h-screen lg:translate-x-0 lg:flex-col lg:border-b-0"
|
class="fixed inset-y-0 left-0 z-50 w-64 shrink-0 transform border-r border-slate-200/70 bg-white/95 px-4 py-6 backdrop-blur transition-transform duration-300 ease-in-out dark:border-slate-800/70 dark:bg-slate-900 lg:sticky lg:top-0 lg:flex lg:h-screen lg:translate-x-0 lg:flex-col lg:border-b-0"
|
||||||
:class="
|
:class="
|
||||||
isSidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
isSidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<AppSidebar
|
<AppSidebar
|
||||||
:categories="categories"
|
:active-tab="activeTab"
|
||||||
:active-category="activeCategory"
|
|
||||||
:category-counts="categoryCounts"
|
:category-counts="categoryCounts"
|
||||||
:theme-mode="themeMode"
|
:theme-mode="themeMode"
|
||||||
:spark-available="sparkAvailable"
|
:spark-available="sparkAvailable"
|
||||||
:apm-available="apmAvailable"
|
:apm-available="apmAvailable"
|
||||||
:store-filter="storeFilter"
|
:store-filter="storeFilter"
|
||||||
|
:sidebar-entries="sidebarEntries"
|
||||||
|
:entry-counts="entryCounts"
|
||||||
@toggle-theme="toggleTheme"
|
@toggle-theme="toggleTheme"
|
||||||
@select-category="selectCategory"
|
@select-tab="selectTab"
|
||||||
@close="isSidebarOpen = false"
|
@close="isSidebarOpen = false"
|
||||||
@list="handleList"
|
@list="handleList"
|
||||||
@update="handleUpdate"
|
@update="handleUpdate"
|
||||||
@@ -37,7 +38,7 @@
|
|||||||
>
|
>
|
||||||
<AppHeader
|
<AppHeader
|
||||||
:search-query="searchQuery"
|
:search-query="searchQuery"
|
||||||
:active-category="activeCategory"
|
:active-tab="activeTab"
|
||||||
:apps-count="filteredApps.length"
|
:apps-count="filteredApps.length"
|
||||||
@update-search="handleSearchInput"
|
@update-search="handleSearchInput"
|
||||||
@search-focus="handleSearchFocus"
|
@search-focus="handleSearchFocus"
|
||||||
@@ -46,8 +47,15 @@
|
|||||||
@toggle-sidebar="isSidebarOpen = !isSidebarOpen"
|
@toggle-sidebar="isSidebarOpen = !isSidebarOpen"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<CategoryBar
|
||||||
|
v-if="activeTab !== 'home' && Object.keys(categories).length > 0"
|
||||||
|
:categories="categories"
|
||||||
|
:selected-category="selectedCategory"
|
||||||
|
:category-counts="categoryCounts"
|
||||||
|
@select-category="selectSubCategory"
|
||||||
|
/>
|
||||||
<div class="px-4 py-6 lg:px-10">
|
<div class="px-4 py-6 lg:px-10">
|
||||||
<template v-if="activeCategory === 'home'">
|
<template v-if="activeTab === 'home'">
|
||||||
<HomeView
|
<HomeView
|
||||||
:links="homeLinks"
|
:links="homeLinks"
|
||||||
:lists="homeLists"
|
:lists="homeLists"
|
||||||
@@ -61,7 +69,7 @@
|
|||||||
<AppGrid
|
<AppGrid
|
||||||
:apps="filteredApps"
|
:apps="filteredApps"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:scroll-key="activeCategory"
|
:scroll-key="activeTab + '-' + selectedCategory"
|
||||||
:store-filter="storeFilter"
|
:store-filter="storeFilter"
|
||||||
@open-detail="openDetail"
|
@open-detail="openDetail"
|
||||||
/>
|
/>
|
||||||
@@ -172,6 +180,7 @@ import AppSidebar from "./components/AppSidebar.vue";
|
|||||||
import AppHeader from "./components/AppHeader.vue";
|
import AppHeader from "./components/AppHeader.vue";
|
||||||
import AppGrid from "./components/AppGrid.vue";
|
import AppGrid from "./components/AppGrid.vue";
|
||||||
import HomeView from "./components/HomeView.vue";
|
import HomeView from "./components/HomeView.vue";
|
||||||
|
import CategoryBar from "./components/CategoryBar.vue";
|
||||||
import AppDetailModal from "./components/AppDetailModal.vue";
|
import AppDetailModal from "./components/AppDetailModal.vue";
|
||||||
import ScreenPreview from "./components/ScreenPreview.vue";
|
import ScreenPreview from "./components/ScreenPreview.vue";
|
||||||
import DownloadQueue from "./components/DownloadQueue.vue";
|
import DownloadQueue from "./components/DownloadQueue.vue";
|
||||||
@@ -217,6 +226,7 @@ import type {
|
|||||||
CategoryInfo,
|
CategoryInfo,
|
||||||
HomeLink,
|
HomeLink,
|
||||||
HomeList,
|
HomeList,
|
||||||
|
SidebarEntry,
|
||||||
UpdateCenterItem,
|
UpdateCenterItem,
|
||||||
} from "./global/typedefinition";
|
} from "./global/typedefinition";
|
||||||
import type { Ref } from "vue";
|
import type { Ref } from "vue";
|
||||||
@@ -258,7 +268,8 @@ const isDarkTheme = computed(() => {
|
|||||||
|
|
||||||
const categories: Ref<Record<string, CategoryInfo>> = ref({});
|
const categories: Ref<Record<string, CategoryInfo>> = ref({});
|
||||||
const apps: Ref<App[]> = ref([]);
|
const apps: Ref<App[]> = ref([]);
|
||||||
const activeCategory = ref("home");
|
const activeTab = ref("home");
|
||||||
|
const selectedCategory = ref("all");
|
||||||
const searchQuery = ref("");
|
const searchQuery = ref("");
|
||||||
const isSidebarOpen = ref(false);
|
const isSidebarOpen = ref(false);
|
||||||
const showModal = ref(false);
|
const showModal = ref(false);
|
||||||
@@ -280,6 +291,7 @@ const showAboutModal = ref(false);
|
|||||||
const showSettingsModal = ref(false);
|
const showSettingsModal = ref(false);
|
||||||
const sparkAvailable = ref(false);
|
const sparkAvailable = ref(false);
|
||||||
const apmAvailable = ref(false);
|
const apmAvailable = ref(false);
|
||||||
|
const sidebarEntries: Ref<SidebarEntry[]> = ref([]);
|
||||||
|
|
||||||
/** 启动参数 --no-apm => 仅 Spark;--no-spark => 仅 APM;由主进程 IPC 提供 */
|
/** 启动参数 --no-apm => 仅 Spark;--no-spark => 仅 APM;由主进程 IPC 提供 */
|
||||||
const storeFilter = ref<"spark" | "apm" | "both">("both");
|
const storeFilter = ref<"spark" | "apm" | "both">("both");
|
||||||
@@ -322,9 +334,9 @@ const baseApps = computed(() => {
|
|||||||
const filteredApps = computed(() => {
|
const filteredApps = computed(() => {
|
||||||
let result = [...baseApps.value];
|
let result = [...baseApps.value];
|
||||||
|
|
||||||
// 按分类筛选
|
const effectiveCategory = getEffectiveCategory();
|
||||||
if (activeCategory.value !== "all") {
|
if (effectiveCategory && effectiveCategory !== "all") {
|
||||||
result = result.filter((app) => app.category === activeCategory.value);
|
result = result.filter((app) => app.category === effectiveCategory);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchQuery.value.trim()) {
|
if (searchQuery.value.trim()) {
|
||||||
@@ -339,7 +351,6 @@ const categoryCounts = computed(() => {
|
|||||||
return countSearchMatchesByCategory(baseApps.value, searchQuery.value);
|
return countSearchMatchesByCategory(baseApps.value, searchQuery.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 无搜索时显示总数量
|
|
||||||
const counts: Record<string, number> = { all: apps.value.length };
|
const counts: Record<string, number> = { all: apps.value.length };
|
||||||
apps.value.forEach((app) => {
|
apps.value.forEach((app) => {
|
||||||
if (!counts[app.category]) counts[app.category] = 0;
|
if (!counts[app.category]) counts[app.category] = 0;
|
||||||
@@ -348,6 +359,23 @@ const categoryCounts = computed(() => {
|
|||||||
return counts;
|
return counts;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const entryCounts = computed(() => {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
const allApps = baseApps.value;
|
||||||
|
|
||||||
|
sidebarEntries.value.forEach((entry) => {
|
||||||
|
if (entry.type === "category" && entry.value) {
|
||||||
|
counts[entry.id] = allApps.filter(
|
||||||
|
(app) => app.category === entry.value,
|
||||||
|
).length;
|
||||||
|
} else {
|
||||||
|
counts[entry.id] = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return counts;
|
||||||
|
});
|
||||||
|
|
||||||
// 方法
|
// 方法
|
||||||
const syncThemePreference = () => {
|
const syncThemePreference = () => {
|
||||||
document.documentElement.classList.toggle("dark", isDarkTheme.value);
|
document.documentElement.classList.toggle("dark", isDarkTheme.value);
|
||||||
@@ -384,12 +412,13 @@ const toggleTheme = () => {
|
|||||||
else themeMode.value = "auto";
|
else themeMode.value = "auto";
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectCategory = (category: string) => {
|
const selectTab = (tab: string) => {
|
||||||
activeCategory.value = category;
|
activeTab.value = tab;
|
||||||
|
selectedCategory.value = "all";
|
||||||
isSidebarOpen.value = false;
|
isSidebarOpen.value = false;
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
if (
|
if (
|
||||||
category === "home" &&
|
tab === "home" &&
|
||||||
homeLinks.value.length === 0 &&
|
homeLinks.value.length === 0 &&
|
||||||
homeLists.value.length === 0
|
homeLists.value.length === 0
|
||||||
) {
|
) {
|
||||||
@@ -397,6 +426,27 @@ const selectCategory = (category: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectSubCategory = (category: string) => {
|
||||||
|
selectedCategory.value = category;
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEffectiveCategory = (): string => {
|
||||||
|
if (activeTab.value === "home") return "";
|
||||||
|
if (activeTab.value === "all") return selectedCategory.value;
|
||||||
|
|
||||||
|
const entry = sidebarEntries.value.find((e) => e.id === activeTab.value);
|
||||||
|
if (entry) {
|
||||||
|
if (entry.type === "category" && entry.value) return entry.value;
|
||||||
|
if (entry.type === "search") {
|
||||||
|
searchQuery.value = entry.value || "";
|
||||||
|
return selectedCategory.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedCategory.value;
|
||||||
|
};
|
||||||
|
|
||||||
// 从仓库获取应用详细信息的辅助函数
|
// 从仓库获取应用详细信息的辅助函数
|
||||||
const fetchAppFromStore = async (
|
const fetchAppFromStore = async (
|
||||||
pkgname: string,
|
pkgname: string,
|
||||||
@@ -1130,6 +1180,50 @@ const loadCategories = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadSidebarConfig = async () => {
|
||||||
|
try {
|
||||||
|
const arch = window.apm_store.arch || "amd64";
|
||||||
|
const modes: Array<"spark" | "apm"> =
|
||||||
|
storeFilter.value === "both" ? ["spark", "apm"] : [storeFilter.value];
|
||||||
|
|
||||||
|
const entryMap = new Map<string, SidebarEntry>();
|
||||||
|
|
||||||
|
for (const mode of modes) {
|
||||||
|
const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`;
|
||||||
|
const path = `/${finalArch}/sidebar-config.json`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.get(path);
|
||||||
|
const data = response.data;
|
||||||
|
const entries = Array.isArray(data) ? data : (data.entries || []);
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.id && entry.name) {
|
||||||
|
if (!entryMap.has(entry.id)) {
|
||||||
|
entryMap.set(entry.id, {
|
||||||
|
id: entry.id,
|
||||||
|
name: entry.name,
|
||||||
|
icon: entry.icon || "",
|
||||||
|
type: entry.type || "category",
|
||||||
|
value: entry.value || entry.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn(`读取 ${mode} sidebar-config.json 失败: ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sidebarEntries.value = Array.from(entryMap.values());
|
||||||
|
if (sidebarEntries.value.length > 0) {
|
||||||
|
logger.info(`已加载 ${sidebarEntries.value.length} 个侧边栏配置入口`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`读取 sidebar-config 失败: ${error}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const loadApps = async (onFirstBatch?: () => void) => {
|
const loadApps = async (onFirstBatch?: () => void) => {
|
||||||
try {
|
try {
|
||||||
logger.info("开始加载应用数据(全并发带重试)...");
|
logger.info("开始加载应用数据(全并发带重试)...");
|
||||||
@@ -1214,7 +1308,7 @@ const handleSearchInput = (value: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSearchFocus = () => {
|
const handleSearchFocus = () => {
|
||||||
if (activeCategory.value === "home") activeCategory.value = "all";
|
if (activeTab.value === "home") activeTab.value = "all";
|
||||||
};
|
};
|
||||||
|
|
||||||
// 生命周期钩子
|
// 生命周期钩子
|
||||||
@@ -1242,6 +1336,8 @@ onMounted(async () => {
|
|||||||
|
|
||||||
await loadCategories();
|
await loadCategories();
|
||||||
|
|
||||||
|
await loadSidebarConfig();
|
||||||
|
|
||||||
// 分类目录加载后,并行加载主页数据和所有应用列表
|
// 分类目录加载后,并行加载主页数据和所有应用列表
|
||||||
// 使用非阻塞方式加载,让UI先展示出来
|
// 使用非阻塞方式加载,让UI先展示出来
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@@ -1335,7 +1431,7 @@ onMounted(async () => {
|
|||||||
// 根据包名直接打开应用详情
|
// 根据包名直接打开应用详情
|
||||||
const tryOpen = () => {
|
const tryOpen = () => {
|
||||||
// 先切换到"全部应用"分类
|
// 先切换到"全部应用"分类
|
||||||
activeCategory.value = "all";
|
activeTab.value = "all";
|
||||||
// 使用类似 HomeView 的方式打开应用,从两个仓库获取完整信息
|
// 使用类似 HomeView 的方式打开应用,从两个仓库获取完整信息
|
||||||
const target = apps.value.find((a) => a.pkgname === data.pkgname);
|
const target = apps.value.find((a) => a.pkgname === data.pkgname);
|
||||||
if (target) {
|
if (target) {
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ const renderSidebar = (
|
|||||||
) => {
|
) => {
|
||||||
return render(AppSidebar, {
|
return render(AppSidebar, {
|
||||||
props: {
|
props: {
|
||||||
categories: {},
|
activeTab: "all",
|
||||||
activeCategory: "all",
|
|
||||||
categoryCounts: { all: 0 },
|
categoryCounts: { all: 0 },
|
||||||
themeMode: "auto",
|
themeMode: "auto",
|
||||||
storeFilter: "both",
|
storeFilter: "both",
|
||||||
sparkAvailable: true,
|
sparkAvailable: true,
|
||||||
apmAvailable: true,
|
apmAvailable: true,
|
||||||
|
sidebarEntries: [],
|
||||||
|
entryCounts: {},
|
||||||
...overrides,
|
...overrides,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
@theme {
|
@theme {
|
||||||
--font-sans: "Inter", "system-ui", "-apple-system", "Segoe UI", "sans-serif";
|
--font-sans: "Inter", "system-ui", "-apple-system", "Segoe UI", "sans-serif";
|
||||||
|
|
||||||
--color-brand: #2563eb;
|
--color-brand: #0071e3;
|
||||||
--color-brand-dark: #1d4ed8;
|
--color-brand-dark: #0066cc;
|
||||||
--color-brand-soft: #60a5fa;
|
--color-brand-soft: #409cff;
|
||||||
|
|
||||||
--color-surface-light: #f5f7fb;
|
--color-surface-light: #f5f7fb;
|
||||||
--color-surface-dark: #0b1220;
|
--color-surface-dark: #0b1220;
|
||||||
@@ -76,4 +76,3 @@
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,16 +21,13 @@
|
|||||||
>
|
>
|
||||||
{{ app.name || "" }}
|
{{ app.name || "" }}
|
||||||
</div>
|
</div>
|
||||||
<!-- 来源标识 -->
|
|
||||||
<div class="flex shrink-0 gap-1">
|
<div class="flex shrink-0 gap-1">
|
||||||
<!-- 合并标识:两个来源都有时显示 -->
|
|
||||||
<span
|
<span
|
||||||
v-if="showMergedBadge"
|
v-if="showMergedBadge"
|
||||||
class="rounded-md px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider shadow-sm bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400"
|
class="rounded-md px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider shadow-sm bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400"
|
||||||
>
|
>
|
||||||
SPARK/APM
|
SPARK/APM
|
||||||
</span>
|
</span>
|
||||||
<!-- 单独标识 -->
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span
|
<span
|
||||||
v-if="showSparkBadge"
|
v-if="showSparkBadge"
|
||||||
|
|||||||
@@ -51,13 +51,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
v-if="activeCategory !== 'home'"
|
|
||||||
class="text-sm text-slate-500 dark:text-slate-400"
|
|
||||||
id="currentCount"
|
|
||||||
>
|
|
||||||
<!-- 共 {{ appsCount }} 个应用 -->
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -66,7 +59,7 @@ import { ref, watch } from "vue";
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
activeCategory: string;
|
activeTab: string;
|
||||||
appsCount: number;
|
appsCount: number;
|
||||||
}>();
|
}>();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|||||||
@@ -30,59 +30,53 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StoreModeSwitcher />
|
<div class="flex-1 space-y-1 overflow-y-auto scrollbar-muted px-1 py-1">
|
||||||
|
|
||||||
<div class="flex-1 space-y-2 overflow-y-auto scrollbar-muted px-2 py-1">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-full items-center gap-3 rounded-2xl border border-transparent px-4 py-3 text-left text-sm font-medium text-slate-600 transition hover:border-brand/30 hover:bg-brand/5 hover:text-brand focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 dark:text-slate-300 dark:hover:bg-slate-800"
|
class="sidebar-tab"
|
||||||
:class="
|
:class="{ 'sidebar-tab-active': activeTab === 'home' }"
|
||||||
activeCategory === 'home'
|
@click="selectTab('home')"
|
||||||
? 'border-brand/40 bg-brand/10 text-brand dark:bg-brand/15'
|
|
||||||
: ''
|
|
||||||
"
|
|
||||||
@click="selectCategory('home')"
|
|
||||||
>
|
>
|
||||||
<span>主页</span>
|
<span class="sidebar-tab-icon"><i class="fas fa-star"></i></span>
|
||||||
|
<span class="sidebar-tab-label">首页推荐</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-full items-center gap-3 rounded-2xl border border-transparent px-4 py-3 text-left text-sm font-medium text-slate-600 transition hover:border-brand/30 hover:bg-brand/5 hover:text-brand focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 dark:text-slate-300 dark:hover:bg-slate-800"
|
class="sidebar-tab"
|
||||||
:class="
|
:class="{ 'sidebar-tab-active': activeTab === 'all' }"
|
||||||
activeCategory === 'all'
|
@click="selectTab('all')"
|
||||||
? 'border-brand/40 bg-brand/10 text-brand dark:bg-brand/15'
|
|
||||||
: ''
|
|
||||||
"
|
|
||||||
@click="selectCategory('all')"
|
|
||||||
>
|
>
|
||||||
<span>全部应用</span>
|
<span class="sidebar-tab-icon"><i class="fas fa-th-large"></i></span>
|
||||||
|
<span class="sidebar-tab-label">全部应用</span>
|
||||||
<span
|
<span
|
||||||
class="ml-auto rounded-full bg-slate-100 px-2 py-0.5 text-xs font-semibold text-slate-500 dark:bg-slate-800/70 dark:text-slate-300"
|
class="ml-auto rounded-full bg-slate-100 px-2 py-0.5 text-xs font-semibold text-slate-500 dark:bg-slate-800/70 dark:text-slate-300"
|
||||||
>{{ categoryCounts.all || 0 }}</span
|
>{{ categoryCounts.all || 0 }}</span
|
||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="sidebarEntries.length > 0"
|
||||||
|
class="my-3 border-t border-slate-100 dark:border-slate-800"
|
||||||
|
></div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-for="(category, key) in categories"
|
v-for="entry in sidebarEntries"
|
||||||
:key="key"
|
:key="entry.id"
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-full items-center gap-3 rounded-2xl border border-transparent px-4 py-3 text-left text-sm font-medium text-slate-600 transition hover:border-brand/30 hover:bg-brand/5 hover:text-brand focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 dark:text-slate-300 dark:hover:bg-slate-800"
|
class="sidebar-tab"
|
||||||
:class="
|
:class="{ 'sidebar-tab-active': activeTab === entry.id }"
|
||||||
activeCategory === key
|
@click="selectTab(entry.id)"
|
||||||
? 'border-brand/40 bg-brand/10 text-brand dark:bg-brand/15'
|
|
||||||
: ''
|
|
||||||
"
|
|
||||||
@click="selectCategory(key)"
|
|
||||||
>
|
>
|
||||||
<span class="flex flex-col">
|
<span class="sidebar-tab-icon">
|
||||||
<span>
|
<i v-if="entry.icon" :class="entry.icon"></i>
|
||||||
<div class="text-left">{{ category.zh }}</div>
|
<i v-else class="fas fa-folder"></i>
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
|
<span class="sidebar-tab-label">{{ entry.name }}</span>
|
||||||
<span
|
<span
|
||||||
|
v-if="entryCounts[entry.id]"
|
||||||
class="ml-auto rounded-full bg-slate-100 px-2 py-0.5 text-xs font-semibold text-slate-500 dark:bg-slate-800/70 dark:text-slate-300"
|
class="ml-auto rounded-full bg-slate-100 px-2 py-0.5 text-xs font-semibold text-slate-500 dark:bg-slate-800/70 dark:text-slate-300"
|
||||||
>{{ categoryCounts[key] || 0 }}</span
|
>{{ entryCounts[entry.id] }}</span
|
||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,20 +85,20 @@
|
|||||||
<button
|
<button
|
||||||
v-if="canManageApps"
|
v-if="canManageApps"
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-full items-center gap-3 rounded-2xl border border-transparent px-4 py-3 text-left text-sm font-medium text-slate-600 transition hover:border-brand/30 hover:bg-brand/5 hover:text-brand focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 dark:text-slate-300 dark:hover:bg-slate-800"
|
class="sidebar-tab"
|
||||||
@click="$emit('list')"
|
@click="$emit('list')"
|
||||||
>
|
>
|
||||||
<i class="fas fa-download"></i>
|
<span class="sidebar-tab-icon"><i class="fas fa-download"></i></span>
|
||||||
<span>应用管理</span>
|
<span class="sidebar-tab-label">应用管理</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="canOpenUpdateCenter"
|
v-if="canOpenUpdateCenter"
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-full items-center gap-3 rounded-2xl border border-transparent px-4 py-3 text-left text-sm font-medium text-slate-600 transition hover:border-brand/30 hover:bg-brand/5 hover:text-brand focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 dark:text-slate-300 dark:hover:bg-slate-800"
|
class="sidebar-tab"
|
||||||
@click="$emit('update')"
|
@click="$emit('update')"
|
||||||
>
|
>
|
||||||
<i class="fas fa-sync-alt"></i>
|
<span class="sidebar-tab-icon"><i class="fas fa-sync-alt"></i></span>
|
||||||
<span>软件更新</span>
|
<span class="sidebar-tab-label">软件更新</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,21 +108,22 @@
|
|||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import ThemeToggle from "./ThemeToggle.vue";
|
import ThemeToggle from "./ThemeToggle.vue";
|
||||||
import amberLogo from "../assets/imgs/spark-store.svg";
|
import amberLogo from "../assets/imgs/spark-store.svg";
|
||||||
|
import type { SidebarEntry } from "../global/typedefinition";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
activeTab: string;
|
||||||
categories: Record<string, any>;
|
|
||||||
activeCategory: string;
|
|
||||||
categoryCounts: Record<string, number>;
|
categoryCounts: Record<string, number>;
|
||||||
themeMode: "light" | "dark" | "auto";
|
themeMode: "light" | "dark" | "auto";
|
||||||
sparkAvailable: boolean;
|
sparkAvailable: boolean;
|
||||||
apmAvailable: boolean;
|
apmAvailable: boolean;
|
||||||
storeFilter: "spark" | "apm" | "both";
|
storeFilter: "spark" | "apm" | "both";
|
||||||
|
sidebarEntries: SidebarEntry[];
|
||||||
|
entryCounts: Record<string, number>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "toggle-theme"): void;
|
(e: "toggle-theme"): void;
|
||||||
(e: "select-category", category: string): void;
|
(e: "select-tab", tab: string): void;
|
||||||
(e: "close"): void;
|
(e: "close"): void;
|
||||||
(e: "list"): void;
|
(e: "list"): void;
|
||||||
(e: "update"): void;
|
(e: "update"): void;
|
||||||
@@ -147,7 +142,62 @@ const canManageApps = computed(() => {
|
|||||||
|
|
||||||
const canOpenUpdateCenter = canManageApps;
|
const canOpenUpdateCenter = canManageApps;
|
||||||
|
|
||||||
const selectCategory = (category: string) => {
|
const selectTab = (tab: string) => {
|
||||||
emit("select-category", category);
|
emit("select-tab", tab);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sidebar-tab {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #64748b;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tab:hover {
|
||||||
|
background: rgba(0, 113, 227, 0.06);
|
||||||
|
color: #0071e3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .sidebar-tab:hover {
|
||||||
|
background: rgba(64, 156, 255, 0.1);
|
||||||
|
color: #409cff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tab-active {
|
||||||
|
background: rgba(0, 113, 227, 0.1);
|
||||||
|
color: #0066cc;
|
||||||
|
border-color: rgba(0, 113, 227, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .sidebar-tab-active {
|
||||||
|
background: rgba(64, 156, 255, 0.15);
|
||||||
|
color: #409cff;
|
||||||
|
border-color: rgba(64, 156, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tab-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tab-label {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<template>
|
||||||
|
<div class="category-bar-wrapper">
|
||||||
|
<div class="category-bar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="category-pill"
|
||||||
|
:class="{ 'category-pill-active': selectedCategory === 'all' }"
|
||||||
|
@click="selectCategory('all')"
|
||||||
|
>
|
||||||
|
<span>全部</span>
|
||||||
|
<span v-if="totalCount > 0" class="category-pill-count">{{ totalCount }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="(category, key) in categories"
|
||||||
|
:key="key"
|
||||||
|
type="button"
|
||||||
|
class="category-pill"
|
||||||
|
:class="{ 'category-pill-active': selectedCategory === key }"
|
||||||
|
@click="selectCategory(key)"
|
||||||
|
>
|
||||||
|
<span>{{ category.zh }}</span>
|
||||||
|
<span v-if="categoryCounts[key]" class="category-pill-count">{{ categoryCounts[key] }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
categories: Record<string, any>;
|
||||||
|
selectedCategory: string;
|
||||||
|
categoryCounts: Record<string, number>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "select-category", category: string): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const totalCount = computed(() => {
|
||||||
|
let total = 0;
|
||||||
|
Object.values(props.categoryCounts).forEach((v) => {
|
||||||
|
if (typeof v === "number") total += v;
|
||||||
|
});
|
||||||
|
return total;
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectCategory = (category: string) => {
|
||||||
|
emit("select-category", category);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.category-bar-wrapper {
|
||||||
|
border-bottom: 1px solid rgba(226, 232, 240, 0.6);
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .category-bar-wrapper {
|
||||||
|
border-color: rgba(30, 41, 59, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0.375rem 0.875rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #64748b;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-pill:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .category-pill {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .category-pill:hover {
|
||||||
|
background: #334155;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-pill-active {
|
||||||
|
background: #0071e3;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-pill-active:hover {
|
||||||
|
background: #0066cc;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .category-pill-active {
|
||||||
|
background: #409cff;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .category-pill-active:hover {
|
||||||
|
background: #0071e3;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-pill-count {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -72,7 +72,6 @@
|
|||||||
class="group block overflow-hidden rounded-xl transition-transform duration-300 hover:scale-[1.02]"
|
class="group block overflow-hidden rounded-xl transition-transform duration-300 hover:scale-[1.02]"
|
||||||
:title="link.more as string"
|
:title="link.more as string"
|
||||||
>
|
>
|
||||||
<!-- 图片区域 - 850:400 比例 -->
|
|
||||||
<div
|
<div
|
||||||
class="relative w-full aspect-[850/400] overflow-hidden rounded-xl bg-slate-100 dark:bg-slate-800"
|
class="relative w-full aspect-[850/400] overflow-hidden rounded-xl bg-slate-100 dark:bg-slate-800"
|
||||||
>
|
>
|
||||||
@@ -88,7 +87,6 @@
|
|||||||
imageLoaded[link.url + link.name],
|
imageLoaded[link.url + link.name],
|
||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
<!-- 图片加载占位符 -->
|
|
||||||
<div
|
<div
|
||||||
v-if="!imageLoaded[link.url + link.name]"
|
v-if="!imageLoaded[link.url + link.name]"
|
||||||
class="absolute inset-0 flex items-center justify-center"
|
class="absolute inset-0 flex items-center justify-center"
|
||||||
@@ -98,7 +96,6 @@
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 文字信息区域 -->
|
|
||||||
<div class="mt-3 px-1">
|
<div class="mt-3 px-1">
|
||||||
<div
|
<div
|
||||||
class="text-base font-semibold text-slate-900 dark:text-white group-hover:text-brand dark:group-hover:text-brand transition-colors"
|
class="text-base font-semibold text-slate-900 dark:text-white group-hover:text-brand dark:group-hover:text-brand transition-colors"
|
||||||
|
|||||||
@@ -241,3 +241,11 @@ export interface HomeList {
|
|||||||
title: string;
|
title: string;
|
||||||
apps: App[];
|
apps: App[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SidebarEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon?: string;
|
||||||
|
type?: "category" | "search" | "link";
|
||||||
|
value?: string;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user