feat(club-summary): add map navigation and optimized article display

- Added a "Navigation" button in ClubSummary that opens a Dropdown menu.
- Supported multiple map types: Google, Apple, AMap, Tencent, and Baidu.
- Mobile-only features (AMap, Tencent, Baidu) are disabled on desktop devices.
- Added `geo` property to `ClubDetail` type to support location data.
- Implemented `openMapDirection` utility to launch specific map apps based on the selected type.
- Conditionally render the "View Announcement" button only if an article exists.
- Updated BaseLayout export name from `Layout` to `BaseLayout` for consistency.
This commit is contained in:
kyuuseiryuu 2026-03-15 10:44:48 +09:00
parent 6bcbff572e
commit 2f8ce1711f
8 changed files with 196 additions and 12 deletions

48
src/components/AppBar.tsx Normal file
View File

@ -0,0 +1,48 @@
import { CalendarOutlined, HeartOutlined, ScheduleOutlined, SearchOutlined, UserOutlined } from "@ant-design/icons";
import { Button, Flex } from "antd";
import { useNavigate } from "react-router";
import styled from "styled-components";
const StyledContainer = styled.div`
position: fixed;
bottom: 0;
width: 100vw;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.3);
z-index: 1;
background: #181818;
font-size: 16px;
padding: 8px;
box-sizing: border-box;
.ant-btn {
padding: 24px;
}
`;
export const AppBar = () => {
const navigate = useNavigate();
return (
<StyledContainer>
<Flex wrap align="center" justify="space-around">
<Button
type="text"
icon={<ScheduleOutlined size={64} />}
onClick={() => navigate('/')}
/>
<Button
type="text"
icon={<HeartOutlined />}
onClick={() => navigate('/fav-players')}
/>
<Button
type="text"
icon={<SearchOutlined />}
onClick={() => navigate('/find')}
/>
<Button
type="text"
icon={<UserOutlined />}
onClick={() => navigate('/user-center')}
/>
</Flex>
</StyledContainer>
);
}

View File

@ -1,19 +1,52 @@
import { Avatar, Button, Drawer, Flex, Skeleton, Typography } from "antd"; import { Avatar, Button, Drawer, Dropdown, Flex, Skeleton, Typography } from "antd";
import { ChangeBackground } from "./ChangeBackground"; import { ChangeBackground } from "./ChangeBackground";
import { useState } from "react"; import { useMemo, useState } from "react";
import { useRequest } from "ahooks"; import { useRequest } from "ahooks";
import type { ClubDetail } from "../types"; import type { ClubDetail } from "../types";
import { isMobile, MapType, openMapDirection } from "../utils/front";
import type { ItemType } from "antd/es/menu/interface";
import { CarOutlined, NotificationOutlined } from "@ant-design/icons";
interface Props { interface Props {
clubId: string; clubId: string;
} }
export const ClubSummary = (props: Props) => { export const ClubSummary = (props: Props) => {
const [isArticleOpen, setIsArticleOpen] = useState(false); const [isArticleOpen, setIsArticleOpen] = useState(false);
const requestClubSummary = useRequest<ClubDetail, []>(async () => { const requestClubSummary = useRequest<ClubDetail, []>(async () => {
return fetch(`/api/club/${props.clubId}`).then(r => r.json()); return fetch(`/api/club/${props.clubId}`).then(r => r.json());
}, { manual: false, refreshDeps: [props.clubId] }) }, { manual: false, refreshDeps: [props.clubId] })
const info = requestClubSummary.data; const info = useMemo(() => requestClubSummary.data, [requestClubSummary]);
const noArticle = !info?.article || info.article === '还没有公告';
const isMobileDevice = isMobile();
const mapMenu = useMemo<ItemType[]>(() => {
if (!info) return [];
return [
{
label: 'Google 地图',
key: MapType.GOOGLE,
},
{
label: 'Apple 地图',
key: MapType.APPLE,
},
{
label: '高德地图',
key: MapType.AMAP,
disabled: !isMobileDevice,
},
{
label: '腾讯地图',
key: MapType.TENCENT,
disabled: !isMobileDevice,
},
{
label: '百度地图',
key: MapType.BAIDU,
disabled: !isMobileDevice,
},
]
}, [info]);
return ( return (
<div style={{ width: '100%' }}> <div style={{ width: '100%' }}>
{requestClubSummary.loading ? ( {requestClubSummary.loading ? (
@ -22,11 +55,33 @@ export const ClubSummary = (props: Props) => {
<Flex vertical align="center" justify="center"> <Flex vertical align="center" justify="center">
<ChangeBackground url={info?.img} /> <ChangeBackground url={info?.img} />
<Avatar src={info?.img} size={80} /> <Avatar src={info?.img} size={80} />
<Typography.Title>{info?.name}</Typography.Title> <Typography.Title level={2}>{info?.name}</Typography.Title>
<Flex gap={12}> <Flex gap={12}>
<Button onClick={() => setIsArticleOpen(true)}></Button> {noArticle ? null : (
</Flex> <Button icon={<NotificationOutlined />} onClick={() => setIsArticleOpen(true)}></Button>
<Drawer size={'60vh'} open={isArticleOpen} onClose={() => setIsArticleOpen(false)} placement="bottom"> )}
{info?.geo && (
<Dropdown
trigger={['click']}
menu={{
items: mapMenu,
onClick: (e) => openMapDirection(e.key as MapType, info.geo!),
}}
>
<Button icon={<CarOutlined />}>
</Button>
</Dropdown>
)}
</Flex>
<Drawer
className="club-article"
size={'60vh'}
open={isArticleOpen}
onClose={() => setIsArticleOpen(false)}
placement="top"
title={<div style={{ textAlign: 'center' }}>{info?.name}</div>}
>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<Typography.Paragraph style={{ whiteSpace: 'pre', textWrap: 'auto' }}>{info?.article}</Typography.Paragraph> <Typography.Paragraph style={{ whiteSpace: 'pre', textWrap: 'auto' }}>{info?.article}</Typography.Paragraph>
</div> </div>

View File

@ -0,0 +1,17 @@
import { Outlet, useNavigation } from "react-router";
import { HydrateFallback } from "../HydrateFallback";
import { AppBar } from "../AppBar";
import styled from "styled-components";
const StyledContainer = styled.div`
padding-bottom: 54px;
box-sizing: border-box;
`;
export const AppBarLayout = () => {
const navigation = useNavigation();
const loading = navigation.state === 'loading';
return loading ? <HydrateFallback /> : (<StyledContainer>
<Outlet />
<AppBar />
</StyledContainer>);
}

View File

@ -2,7 +2,7 @@ import { Outlet, useNavigation } from "react-router";
import { HydrateFallback } from "../HydrateFallback"; import { HydrateFallback } from "../HydrateFallback";
import { MenuButtons } from "../MenuButtons"; import { MenuButtons } from "../MenuButtons";
export function Layout() { export function BaseLayout() {
const navigation = useNavigation(); const navigation = useNavigation();
const loading = navigation.state === 'loading'; const loading = navigation.state === 'loading';
return loading ? <HydrateFallback /> : (<> return loading ? <HydrateFallback /> : (<>

View File

@ -0,0 +1,2 @@
export { BaseLayout as ActionButtonLayout } from './BaseLayout';
export { AppBarLayout } from './AppBarLayout';

View File

@ -9,12 +9,12 @@ import { CallbackPage } from "./page/Logto/Callback";
import App from "./App"; import App from "./App";
import { ClubEventsPage } from "./page/ClubEvents"; import { ClubEventsPage } from "./page/ClubEvents";
import { HydrateFallback } from "./components/HydrateFallback"; import { HydrateFallback } from "./components/HydrateFallback";
import { Layout } from "./components/Layout/BaseLayout"; import { ActionButtonLayout, AppBarLayout } from './components/Layout';
export const route = createBrowserRouter([ export const route = createBrowserRouter([
{ {
path: '/', path: '/',
element: <Layout />, element: <AppBarLayout />,
children: [ children: [
{ {
path: '', path: '',

View File

@ -75,4 +75,5 @@ export interface ClubDetail {
name: string; name: string;
article: string; article: string;
img: string; img: string;
geo: { lng: number; lat: number; } | null;
} }

View File

@ -3,3 +3,64 @@ export enum SEX {
'男' = 1, '男' = 1,
'女' = 2, '女' = 2,
} }
export enum MapType {
AMAP = 'amap',
BAIDU = 'baidu',
TENCENT = 'qq',
APPLE = 'apple',
GOOGLE = 'google' // 新增 Google Maps
}
interface MapLocation {
lat: number;
lng: number;
name?: string; // 目的地名称
}
export const isMobile = (): boolean => {
const ua = navigator.userAgent;
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua);
};
/**
*
* @param type
* @param location ( GCJ-02 )
*/
export const openMapDirection = (type: MapType, location: MapLocation): void => {
const { lat, lng, name = '目的地' } = location;
const encodedName = encodeURIComponent(name);
let url = '';
switch (type) {
case MapType.GOOGLE:
/**
* Google Maps Scheme
* saddr: 起点 ()
* daddr: 终点经纬度
* directionsmode: 导航模式 (driving, walking, bicycling, transit)
*/
url = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`;
break;
case MapType.AMAP:
url = `iosamap://path?sourceApplication=appName&dlat=${lat}&dlon=${lng}&dname=${encodedName}&dev=0&t=0`;
break;
case MapType.BAIDU:
url = `baidumap://map/direction?destination=name:${encodedName}|latlng:${lat},${lng}&mode=driving&coord_type=gcj02`;
break;
case MapType.TENCENT:
url = `qqmap://map/routeplan?type=drive&to=${encodedName}&tocoord=${lat},${lng}&referer=myapp`;
break;
case MapType.APPLE:
url = `http://maps.apple.com/?daddr=${lat},${lng}&q=${encodedName}`;
break;
}
if (url) {
window.open(url);
}
};