feat: redesign Dashboard layout with search and recently used tools
- Add search input at top to filter tools by name/description - Add 'recently used' section showing last 3 used tools as compact chips - Add 'all tools' section header for the tool card grid - Track tool usage via new 'app/recentlyUsedTools' storage key - Update navigateTo to record recently used tools (max 3, LRU order) - Add i18n keys: dashboard_searchPlaceholder, dashboard_recentlyUsed, dashboard_allTools Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -75,6 +75,18 @@
|
|||||||
"message": "仪表盘",
|
"message": "仪表盘",
|
||||||
"description": "Translation key: dashboard_title"
|
"description": "Translation key: dashboard_title"
|
||||||
},
|
},
|
||||||
|
"dashboard_searchPlaceholder": {
|
||||||
|
"message": "搜索工具...",
|
||||||
|
"description": "Translation key: dashboard_searchPlaceholder"
|
||||||
|
},
|
||||||
|
"dashboard_recentlyUsed": {
|
||||||
|
"message": "最近使用",
|
||||||
|
"description": "Translation key: dashboard_recentlyUsed"
|
||||||
|
},
|
||||||
|
"dashboard_allTools": {
|
||||||
|
"message": "全部工具",
|
||||||
|
"description": "Translation key: dashboard_allTools"
|
||||||
|
},
|
||||||
"timestamp_title": {
|
"timestamp_title": {
|
||||||
"message": "时间戳",
|
"message": "时间戳",
|
||||||
"description": "Translation key: timestamp_title"
|
"description": "Translation key: timestamp_title"
|
||||||
|
|||||||
@@ -2,40 +2,115 @@ import { useRouter } from '@/providers/RouterProvider';
|
|||||||
import ToolCard from '@/pages/Dashboard/ToolCard';
|
import ToolCard from '@/pages/Dashboard/ToolCard';
|
||||||
import { getFeatureByKey } from '@/config/features';
|
import { getFeatureByKey } from '@/config/features';
|
||||||
import type { PageType } from '@/types/storage';
|
import type { PageType } from '@/types/storage';
|
||||||
import { useMemo } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Search } from 'lucide-react';
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { navigateTo, visiblePages, pageOrder } = useRouter();
|
const { navigateTo, visiblePages, pageOrder, recentlyUsedTools } = useRouter();
|
||||||
const { t } = useI18n(['features']);
|
const { t } = useI18n(['features']);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
const visibleSet = useMemo(() => new Set<string>(visiblePages), [visiblePages]);
|
const visibleSet = useMemo(() => new Set<string>(visiblePages), [visiblePages]);
|
||||||
|
|
||||||
|
const visibleFeatures = useMemo(() => {
|
||||||
|
return pageOrder
|
||||||
|
.filter((key) => visibleSet.has(key))
|
||||||
|
.map((key) => ({ key, feature: getFeatureByKey(key) }))
|
||||||
|
.filter((item) => item.feature?.themeColorKey && item.feature.icon != null);
|
||||||
|
}, [pageOrder, visibleSet]);
|
||||||
|
|
||||||
|
const filteredFeatures = useMemo(() => {
|
||||||
|
if (!searchQuery.trim()) return visibleFeatures;
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return visibleFeatures.filter(
|
||||||
|
(item) =>
|
||||||
|
t(item.feature!.labelKey).toLowerCase().includes(query) ||
|
||||||
|
t(item.feature!.descriptionKey).toLowerCase().includes(query),
|
||||||
|
);
|
||||||
|
}, [visibleFeatures, searchQuery, t]);
|
||||||
|
|
||||||
|
const recentFeatures = useMemo(() => {
|
||||||
|
return recentlyUsedTools
|
||||||
|
.filter((key) => visibleSet.has(key))
|
||||||
|
.map((key) => ({ key, feature: getFeatureByKey(key) }))
|
||||||
|
.filter((item) => item.feature?.themeColorKey && item.feature.icon != null);
|
||||||
|
}, [recentlyUsedTools, visibleSet]);
|
||||||
|
|
||||||
|
const showRecent = !searchQuery.trim() && recentFeatures.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cn('flex flex-col gap-4 p-3.5 w-full h-auto select-none')}>
|
||||||
|
{/* 搜索栏 */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground/60" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder={t('dashboard_searchPlaceholder')}
|
||||||
className={cn(
|
className={cn(
|
||||||
'grid grid-cols-1 sm:grid-cols-[repeat(auto-fill,minmax(290px,1fr))] auto-rows-auto gap-3.5 p-3.5 w-full h-auto',
|
'w-full h-9 pl-9 pr-3 rounded-lg border border-border/70 bg-background text-sm',
|
||||||
'select-none',
|
'placeholder:text-muted-foreground/50 outline-none',
|
||||||
|
'focus:border-primary/50 focus:ring-1 focus:ring-primary/20',
|
||||||
|
'transition-colors',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 最近使用 */}
|
||||||
|
{showRecent && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider">
|
||||||
|
{t('dashboard_recentlyUsed')}
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{recentFeatures.map(({ key, feature }) => {
|
||||||
|
const IconComponent = feature!.icon!;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigateTo(key as PageType)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium',
|
||||||
|
'border border-border/60 bg-card text-card-foreground',
|
||||||
|
'hover:bg-muted/40 hover:border-primary/30',
|
||||||
|
'transition-colors cursor-pointer',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{pageOrder.map((key) => {
|
<IconComponent className="h-3.5 w-3.5 text-muted-foreground/70" />
|
||||||
if (!visibleSet.has(key)) return null;
|
{t(feature!.labelKey)}
|
||||||
|
</button>
|
||||||
const feature = getFeatureByKey(key);
|
|
||||||
if (!feature?.themeColorKey || feature.icon == null) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ToolCard
|
|
||||||
key={key}
|
|
||||||
title={t(feature.labelKey)}
|
|
||||||
description={t(feature.descriptionKey)}
|
|
||||||
colorKey={feature.themeColorKey}
|
|
||||||
icon={feature.icon}
|
|
||||||
onNavigate={() => navigateTo(key as PageType)}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 全部工具 */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider">
|
||||||
|
{t('dashboard_allTools')}
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'grid grid-cols-1 sm:grid-cols-[repeat(auto-fill,minmax(290px,1fr))] auto-rows-auto gap-3.5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{filteredFeatures.map(({ key, feature }) => (
|
||||||
|
<ToolCard
|
||||||
|
key={key}
|
||||||
|
title={t(feature!.labelKey)}
|
||||||
|
description={t(feature!.descriptionKey)}
|
||||||
|
colorKey={feature!.themeColorKey!}
|
||||||
|
icon={feature!.icon!}
|
||||||
|
onNavigate={() => navigateTo(key as PageType)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
} from '@/config/features';
|
} from '@/config/features';
|
||||||
import { CONTEXT_MENU_DATA_EXPIRY_MS, saveContextMenuData } from '@/utils/useContextMenuData';
|
import { CONTEXT_MENU_DATA_EXPIRY_MS, saveContextMenuData } from '@/utils/useContextMenuData';
|
||||||
|
|
||||||
|
const MAX_RECENTLY_USED = 3;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验是否为合法的页面类型
|
* 校验是否为合法的页面类型
|
||||||
*/
|
*/
|
||||||
@@ -48,6 +50,7 @@ interface RouterContextType {
|
|||||||
currentPage: PageType;
|
currentPage: PageType;
|
||||||
visiblePages: PageType[];
|
visiblePages: PageType[];
|
||||||
pageOrder: PageType[];
|
pageOrder: PageType[];
|
||||||
|
recentlyUsedTools: PageType[];
|
||||||
isLoaded: boolean;
|
isLoaded: boolean;
|
||||||
navigateTo: (page: PageType) => void;
|
navigateTo: (page: PageType) => void;
|
||||||
goBack: () => void;
|
goBack: () => void;
|
||||||
@@ -118,6 +121,10 @@ export function RouterProvider({
|
|||||||
return mergeWithDefaults(snapshot, getDefaultPageOrder());
|
return mergeWithDefaults(snapshot, getDefaultPageOrder());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [recentlyUsedTools, setRecentlyUsedTools] = useState<PageType[]>(() =>
|
||||||
|
getSyncSnapshot('app/recentlyUsedTools', [], isValidPageList),
|
||||||
|
);
|
||||||
|
|
||||||
const [isLoaded, setIsLoaded] = useState(() => {
|
const [isLoaded, setIsLoaded] = useState(() => {
|
||||||
const snapshotKey = localStorage.getItem(`snapshot/${syncKey as string}`);
|
const snapshotKey = localStorage.getItem(`snapshot/${syncKey as string}`);
|
||||||
const snapshotVisible = localStorage.getItem(`snapshot/${visiblePagesKey as string}`);
|
const snapshotVisible = localStorage.getItem(`snapshot/${visiblePagesKey as string}`);
|
||||||
@@ -136,6 +143,7 @@ export function RouterProvider({
|
|||||||
getDefaultVisibleFeatureKeys(),
|
getDefaultVisibleFeatureKeys(),
|
||||||
);
|
);
|
||||||
const savedPageOrder = await storageUtil.get(pageOrderKey, getDefaultPageOrder());
|
const savedPageOrder = await storageUtil.get(pageOrderKey, getDefaultPageOrder());
|
||||||
|
const savedRecentTools = await storageUtil.get('app/recentlyUsedTools', []);
|
||||||
|
|
||||||
if (isValidPage(savedRoute) && syncRoute) {
|
if (isValidPage(savedRoute) && syncRoute) {
|
||||||
setCurrentPage(savedRoute);
|
setCurrentPage(savedRoute);
|
||||||
@@ -146,6 +154,9 @@ export function RouterProvider({
|
|||||||
if (isValidPageList(savedPageOrder) && savedPageOrder.length > 0) {
|
if (isValidPageList(savedPageOrder) && savedPageOrder.length > 0) {
|
||||||
setPageOrder(mergeWithDefaults(savedPageOrder, getDefaultPageOrder()));
|
setPageOrder(mergeWithDefaults(savedPageOrder, getDefaultPageOrder()));
|
||||||
}
|
}
|
||||||
|
if (isValidPageList(savedRecentTools)) {
|
||||||
|
setRecentlyUsedTools(savedRecentTools);
|
||||||
|
}
|
||||||
setIsLoaded(true);
|
setIsLoaded(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Router Init Error] Core data fetch failed:', error);
|
console.error('[Router Init Error] Core data fetch failed:', error);
|
||||||
@@ -234,6 +245,17 @@ export function RouterProvider({
|
|||||||
}
|
}
|
||||||
}, [pageOrder, isLoaded, pageOrderKey]);
|
}, [pageOrder, isLoaded, pageOrderKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoaded) {
|
||||||
|
void storageUtil.set('app/recentlyUsedTools', recentlyUsedTools).catch(console.error);
|
||||||
|
try {
|
||||||
|
localStorage.setItem('snapshot/app/recentlyUsedTools', JSON.stringify(recentlyUsedTools));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Router LocalStorage Error]', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [recentlyUsedTools, isLoaded]);
|
||||||
|
|
||||||
const currentPageRef = useRef(currentPage);
|
const currentPageRef = useRef(currentPage);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
currentPageRef.current = currentPage;
|
currentPageRef.current = currentPage;
|
||||||
@@ -261,6 +283,12 @@ export function RouterProvider({
|
|||||||
setPageOrder(newOrder);
|
setPageOrder(newOrder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (changes['app/recentlyUsedTools']) {
|
||||||
|
const newRecent = changes['app/recentlyUsedTools'].newValue;
|
||||||
|
if (isValidPageList(newRecent)) {
|
||||||
|
setRecentlyUsedTools(newRecent);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (changes['contextMenu/pendingData']) {
|
if (changes['contextMenu/pendingData']) {
|
||||||
const newData = changes['contextMenu/pendingData']
|
const newData = changes['contextMenu/pendingData']
|
||||||
.newValue as ContextMenuPendingData | null;
|
.newValue as ContextMenuPendingData | null;
|
||||||
@@ -282,6 +310,10 @@ export function RouterProvider({
|
|||||||
|
|
||||||
const navigateTo = (page: PageType) => {
|
const navigateTo = (page: PageType) => {
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
|
setRecentlyUsedTools((prev) => {
|
||||||
|
const filtered = prev.filter((p) => p !== page);
|
||||||
|
return [page, ...filtered].slice(0, MAX_RECENTLY_USED);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
@@ -294,6 +326,7 @@ export function RouterProvider({
|
|||||||
currentPage,
|
currentPage,
|
||||||
visiblePages,
|
visiblePages,
|
||||||
pageOrder,
|
pageOrder,
|
||||||
|
recentlyUsedTools,
|
||||||
isLoaded,
|
isLoaded,
|
||||||
navigateTo,
|
navigateTo,
|
||||||
goBack,
|
goBack,
|
||||||
|
|||||||
Vendored
+2
@@ -113,6 +113,8 @@ export interface StorageSchema {
|
|||||||
'app/language': string;
|
'app/language': string;
|
||||||
/** 右键菜单待处理数据 */
|
/** 右键菜单待处理数据 */
|
||||||
'contextMenu/pendingData': ContextMenuPendingData;
|
'contextMenu/pendingData': ContextMenuPendingData;
|
||||||
|
/** 最近使用的工具列表(最多保留 3 个) */
|
||||||
|
'app/recentlyUsedTools': PageType[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user