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 { 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>
|
||||||
|
|||||||
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 { 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 /> : (<>
|
||||||
|
|||||||
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 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: '',
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user