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:
parent
6bcbff572e
commit
2f8ce1711f
48
src/components/AppBar.tsx
Normal file
48
src/components/AppBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRequest } from "ahooks";
|
||||
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 {
|
||||
clubId: string;
|
||||
}
|
||||
export const ClubSummary = (props: Props) => {
|
||||
|
||||
export const ClubSummary = (props: Props) => {
|
||||
const [isArticleOpen, setIsArticleOpen] = useState(false);
|
||||
const requestClubSummary = useRequest<ClubDetail, []>(async () => {
|
||||
return fetch(`/api/club/${props.clubId}`).then(r => r.json());
|
||||
}, { 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 (
|
||||
<div style={{ width: '100%' }}>
|
||||
{requestClubSummary.loading ? (
|
||||
@ -22,11 +55,33 @@ export const ClubSummary = (props: Props) => {
|
||||
<Flex vertical align="center" justify="center">
|
||||
<ChangeBackground url={info?.img} />
|
||||
<Avatar src={info?.img} size={80} />
|
||||
<Typography.Title>{info?.name}</Typography.Title>
|
||||
<Flex gap={12}>
|
||||
<Button onClick={() => setIsArticleOpen(true)}>查看公告</Button>
|
||||
</Flex>
|
||||
<Drawer size={'60vh'} open={isArticleOpen} onClose={() => setIsArticleOpen(false)} placement="bottom">
|
||||
<Typography.Title level={2}>{info?.name}</Typography.Title>
|
||||
<Flex gap={12}>
|
||||
{noArticle ? null : (
|
||||
<Button icon={<NotificationOutlined />} onClick={() => setIsArticleOpen(true)}>查看公告</Button>
|
||||
)}
|
||||
{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' }}>
|
||||
<Typography.Paragraph style={{ whiteSpace: 'pre', textWrap: 'auto' }}>{info?.article}</Typography.Paragraph>
|
||||
</div>
|
||||
|
||||
17
src/components/Layout/AppBarLayout.tsx
Normal file
17
src/components/Layout/AppBarLayout.tsx
Normal 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>);
|
||||
}
|
||||
@ -2,7 +2,7 @@ import { Outlet, useNavigation } from "react-router";
|
||||
import { HydrateFallback } from "../HydrateFallback";
|
||||
import { MenuButtons } from "../MenuButtons";
|
||||
|
||||
export function Layout() {
|
||||
export function BaseLayout() {
|
||||
const navigation = useNavigation();
|
||||
const loading = navigation.state === 'loading';
|
||||
return loading ? <HydrateFallback /> : (<>
|
||||
|
||||
2
src/components/Layout/index.ts
Normal file
2
src/components/Layout/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { BaseLayout as ActionButtonLayout } from './BaseLayout';
|
||||
export { AppBarLayout } from './AppBarLayout';
|
||||
@ -9,12 +9,12 @@ import { CallbackPage } from "./page/Logto/Callback";
|
||||
import App from "./App";
|
||||
import { ClubEventsPage } from "./page/ClubEvents";
|
||||
import { HydrateFallback } from "./components/HydrateFallback";
|
||||
import { Layout } from "./components/Layout/BaseLayout";
|
||||
import { ActionButtonLayout, AppBarLayout } from './components/Layout';
|
||||
|
||||
export const route = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <Layout />,
|
||||
element: <AppBarLayout />,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
|
||||
@ -75,4 +75,5 @@ export interface ClubDetail {
|
||||
name: string;
|
||||
article: string;
|
||||
img: string;
|
||||
geo: { lng: number; lat: number; } | null;
|
||||
}
|
||||
@ -3,3 +3,64 @@ export enum SEX {
|
||||
'男' = 1,
|
||||
'女' = 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);
|
||||
}
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user