2026-03-25 05:16:54 +08:00
|
|
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
2026-03-18 12:11:49 +08:00
|
|
|
import {
|
|
|
|
|
View,
|
|
|
|
|
StyleSheet,
|
|
|
|
|
ScrollView,
|
|
|
|
|
TouchableOpacity,
|
|
|
|
|
Text,
|
|
|
|
|
Platform,
|
|
|
|
|
} from 'react-native';
|
|
|
|
|
import { useSafeAreaInsets, SafeAreaView } from 'react-native-safe-area-context';
|
|
|
|
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
2026-03-24 14:21:31 +08:00
|
|
|
import { usePathname, useRouter } from 'expo-router';
|
2026-03-18 12:11:49 +08:00
|
|
|
|
2026-03-25 05:16:54 +08:00
|
|
|
import { useAppColors, shadows, type AppColors } from '../theme';
|
2026-03-24 14:21:31 +08:00
|
|
|
import { useTotalUnreadCount } from '../stores';
|
|
|
|
|
import { AppRouteStack } from './AppRouteStack';
|
2026-03-18 12:11:49 +08:00
|
|
|
|
2026-03-25 01:29:41 +08:00
|
|
|
type TabName = 'HomeTab' | 'MessageTab' | 'AppsTab' | 'ProfileTab';
|
2026-03-18 12:11:49 +08:00
|
|
|
|
2026-03-24 14:21:31 +08:00
|
|
|
const SIDEBAR_WIDTH_DESKTOP = 240;
|
|
|
|
|
const SIDEBAR_WIDTH_TABLET = 200;
|
|
|
|
|
const SIDEBAR_COLLAPSED_WIDTH = 72;
|
2026-03-18 12:11:49 +08:00
|
|
|
|
2026-03-24 14:21:31 +08:00
|
|
|
const NAV_ITEMS: { name: TabName; label: string; href: string; icon: string; iconOutline: string }[] = [
|
|
|
|
|
{ name: 'HomeTab', label: '首页', href: '/home', icon: 'home', iconOutline: 'home-outline' },
|
2026-03-25 01:29:41 +08:00
|
|
|
{ name: 'AppsTab', label: '应用', href: '/apps', icon: 'view-grid', iconOutline: 'view-grid-outline' },
|
2026-03-25 01:30:00 +08:00
|
|
|
{ name: 'MessageTab', label: '消息', href: '/messages', icon: 'message-text', iconOutline: 'message-text-outline' },
|
2026-03-24 14:21:31 +08:00
|
|
|
{ name: 'ProfileTab', label: '我的', href: '/profile', icon: 'account', iconOutline: 'account-outline' },
|
2026-03-18 12:11:49 +08:00
|
|
|
];
|
|
|
|
|
|
2026-03-24 14:21:31 +08:00
|
|
|
function pathToTab(pathname: string): TabName {
|
|
|
|
|
if (pathname.startsWith('/messages')) return 'MessageTab';
|
2026-03-25 01:29:41 +08:00
|
|
|
if (pathname.startsWith('/apps')) return 'AppsTab';
|
2026-03-24 14:21:31 +08:00
|
|
|
if (pathname.startsWith('/profile')) return 'ProfileTab';
|
|
|
|
|
if (pathname.startsWith('/home')) return 'HomeTab';
|
|
|
|
|
return 'HomeTab';
|
2026-03-18 12:11:49 +08:00
|
|
|
}
|
|
|
|
|
|
2026-03-25 05:16:54 +08:00
|
|
|
function createDesktopShellStyles(colors: AppColors) {
|
|
|
|
|
return StyleSheet.create({
|
|
|
|
|
container: {
|
|
|
|
|
flex: 1,
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
backgroundColor: colors.background.default,
|
|
|
|
|
},
|
|
|
|
|
sidebar: {
|
|
|
|
|
backgroundColor: colors.background.paper,
|
|
|
|
|
borderRightWidth: 1,
|
|
|
|
|
borderRightColor: colors.divider,
|
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
...shadows.md,
|
|
|
|
|
},
|
|
|
|
|
sidebarHeader: {
|
|
|
|
|
paddingHorizontal: 16,
|
|
|
|
|
paddingVertical: 20,
|
|
|
|
|
borderBottomWidth: 1,
|
|
|
|
|
borderBottomColor: colors.divider,
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
},
|
|
|
|
|
logoText: {
|
|
|
|
|
fontSize: 18,
|
|
|
|
|
fontWeight: '700',
|
|
|
|
|
color: colors.primary.main,
|
|
|
|
|
marginLeft: 8,
|
|
|
|
|
},
|
|
|
|
|
sidebarContent: {
|
|
|
|
|
flex: 1,
|
|
|
|
|
paddingTop: 8,
|
|
|
|
|
},
|
|
|
|
|
sidebarItem: {
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
paddingHorizontal: 16,
|
|
|
|
|
paddingVertical: 12,
|
|
|
|
|
marginHorizontal: 8,
|
|
|
|
|
marginVertical: 4,
|
|
|
|
|
borderRadius: 12,
|
|
|
|
|
},
|
|
|
|
|
sidebarItemActive: {
|
|
|
|
|
backgroundColor: `${colors.primary.main}15`,
|
|
|
|
|
},
|
|
|
|
|
sidebarItemCollapsed: {
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
paddingHorizontal: 0,
|
|
|
|
|
},
|
|
|
|
|
sidebarIconContainer: {
|
|
|
|
|
position: 'relative',
|
|
|
|
|
width: 40,
|
|
|
|
|
height: 40,
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
borderRadius: 12,
|
|
|
|
|
},
|
|
|
|
|
sidebarLabel: {
|
|
|
|
|
fontSize: 15,
|
|
|
|
|
fontWeight: '500',
|
|
|
|
|
color: colors.text.secondary,
|
|
|
|
|
marginLeft: 12,
|
|
|
|
|
flex: 1,
|
|
|
|
|
},
|
|
|
|
|
sidebarLabelActive: {
|
|
|
|
|
color: colors.primary.main,
|
|
|
|
|
fontWeight: '600',
|
|
|
|
|
},
|
|
|
|
|
collapseButton: {
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
justifyContent: 'flex-end',
|
|
|
|
|
paddingHorizontal: 16,
|
|
|
|
|
paddingVertical: 12,
|
|
|
|
|
borderTopWidth: 1,
|
|
|
|
|
borderTopColor: colors.divider,
|
|
|
|
|
},
|
|
|
|
|
collapseButtonCollapsed: {
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
paddingHorizontal: 0,
|
|
|
|
|
},
|
|
|
|
|
badge: {
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
top: 2,
|
|
|
|
|
right: 2,
|
|
|
|
|
backgroundColor: colors.error.main,
|
|
|
|
|
borderRadius: 10,
|
|
|
|
|
minWidth: 18,
|
|
|
|
|
height: 18,
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
paddingHorizontal: 4,
|
|
|
|
|
},
|
|
|
|
|
badgeText: {
|
|
|
|
|
color: colors.primary.contrast,
|
|
|
|
|
fontSize: 10,
|
|
|
|
|
fontWeight: '600',
|
|
|
|
|
},
|
|
|
|
|
mainContent: {
|
|
|
|
|
flex: 1,
|
|
|
|
|
backgroundColor: colors.background.default,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 14:21:31 +08:00
|
|
|
export function AppDesktopShell() {
|
2026-03-25 05:16:54 +08:00
|
|
|
const colors = useAppColors();
|
|
|
|
|
const styles = useMemo(() => createDesktopShellStyles(colors), [colors]);
|
2026-03-18 12:11:49 +08:00
|
|
|
const insets = useSafeAreaInsets();
|
2026-03-24 14:21:31 +08:00
|
|
|
const router = useRouter();
|
|
|
|
|
const pathname = usePathname();
|
|
|
|
|
const unreadCount = useTotalUnreadCount();
|
|
|
|
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
2026-03-18 12:11:49 +08:00
|
|
|
const [isDesktop, setIsDesktop] = useState(false);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-03-24 14:21:31 +08:00
|
|
|
if (Platform.OS !== 'web') return;
|
|
|
|
|
const check = () => {
|
|
|
|
|
const w = window.innerWidth || document.documentElement.clientWidth;
|
|
|
|
|
setIsDesktop(w >= SIDEBAR_WIDTH_DESKTOP);
|
2026-03-18 12:11:49 +08:00
|
|
|
};
|
2026-03-24 14:21:31 +08:00
|
|
|
check();
|
|
|
|
|
window.addEventListener('resize', check);
|
|
|
|
|
return () => window.removeEventListener('resize', check);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const sidebarWidth = isCollapsed
|
|
|
|
|
? SIDEBAR_COLLAPSED_WIDTH
|
|
|
|
|
: isDesktop
|
|
|
|
|
? SIDEBAR_WIDTH_DESKTOP
|
|
|
|
|
: SIDEBAR_WIDTH_TABLET;
|
|
|
|
|
|
|
|
|
|
const currentTab = pathToTab(pathname || '/home');
|
|
|
|
|
|
|
|
|
|
const handleTabChange = useCallback(
|
|
|
|
|
(href: string) => {
|
|
|
|
|
router.replace(href);
|
|
|
|
|
},
|
|
|
|
|
[router]
|
|
|
|
|
);
|
2026-03-18 12:11:49 +08:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<View style={styles.container}>
|
2026-03-24 14:21:31 +08:00
|
|
|
<SafeAreaView
|
|
|
|
|
style={[
|
|
|
|
|
styles.sidebar,
|
|
|
|
|
{ width: sidebarWidth, paddingTop: insets.top, paddingBottom: insets.bottom },
|
|
|
|
|
]}
|
|
|
|
|
>
|
2026-03-18 12:11:49 +08:00
|
|
|
<View style={styles.sidebarHeader}>
|
|
|
|
|
<MaterialCommunityIcons name="carrot" size={32} color={colors.primary.main} />
|
2026-03-24 14:21:31 +08:00
|
|
|
{!isCollapsed && <Text style={styles.logoText}>胡萝卜BBS</Text>}
|
2026-03-18 12:11:49 +08:00
|
|
|
</View>
|
|
|
|
|
<ScrollView style={styles.sidebarContent} showsVerticalScrollIndicator={false}>
|
|
|
|
|
{NAV_ITEMS.map((item) => {
|
|
|
|
|
const isActive = currentTab === item.name;
|
|
|
|
|
const showBadge = item.name === 'MessageTab' && unreadCount > 0;
|
|
|
|
|
return (
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
key={item.name}
|
|
|
|
|
style={[
|
|
|
|
|
styles.sidebarItem,
|
|
|
|
|
isActive && styles.sidebarItemActive,
|
|
|
|
|
isCollapsed && styles.sidebarItemCollapsed,
|
|
|
|
|
]}
|
2026-03-24 14:21:31 +08:00
|
|
|
onPress={() => handleTabChange(item.href)}
|
2026-03-18 12:11:49 +08:00
|
|
|
activeOpacity={0.7}
|
|
|
|
|
>
|
|
|
|
|
<View style={styles.sidebarIconContainer}>
|
|
|
|
|
<MaterialCommunityIcons
|
2026-03-24 14:21:31 +08:00
|
|
|
name={(isActive ? item.icon : item.iconOutline) as any}
|
2026-03-18 12:11:49 +08:00
|
|
|
size={24}
|
|
|
|
|
color={isActive ? colors.primary.main : colors.text.secondary}
|
|
|
|
|
/>
|
2026-03-24 14:21:31 +08:00
|
|
|
{showBadge ? (
|
2026-03-18 12:11:49 +08:00
|
|
|
<View style={styles.badge}>
|
|
|
|
|
<Text style={styles.badgeText}>{unreadCount > 99 ? '99+' : unreadCount}</Text>
|
|
|
|
|
</View>
|
2026-03-24 14:21:31 +08:00
|
|
|
) : null}
|
2026-03-18 12:11:49 +08:00
|
|
|
</View>
|
2026-03-24 14:21:31 +08:00
|
|
|
{!isCollapsed ? (
|
|
|
|
|
<Text style={[styles.sidebarLabel, isActive && styles.sidebarLabelActive]}>{item.label}</Text>
|
|
|
|
|
) : null}
|
2026-03-18 12:11:49 +08:00
|
|
|
</TouchableOpacity>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</ScrollView>
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
style={[styles.collapseButton, isCollapsed && styles.collapseButtonCollapsed]}
|
2026-03-24 14:21:31 +08:00
|
|
|
onPress={() => setIsCollapsed((c) => !c)}
|
2026-03-18 12:11:49 +08:00
|
|
|
activeOpacity={0.7}
|
|
|
|
|
>
|
|
|
|
|
<MaterialCommunityIcons
|
|
|
|
|
name={isCollapsed ? 'chevron-right' : 'chevron-left'}
|
|
|
|
|
size={24}
|
|
|
|
|
color={colors.text.secondary}
|
|
|
|
|
/>
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
</SafeAreaView>
|
|
|
|
|
<View style={styles.mainContent}>
|
2026-03-24 14:21:31 +08:00
|
|
|
<AppRouteStack />
|
2026-03-18 12:11:49 +08:00
|
|
|
</View>
|
|
|
|
|
</View>
|
|
|
|
|
);
|
|
|
|
|
}
|