From 4df81375b9ac5ec3b72dd90c954000e7144be8e8 Mon Sep 17 00:00:00 2001 From: HoshinoSuzumi Date: Sat, 13 Apr 2024 03:27:22 +0800 Subject: [PATCH] feat: get location and location with mobile --- app/(standalone)/layout.tsx | 13 ++ app/(standalone)/ob-location/page.tsx | 116 +++++++++++++++ app/actions.ts | 25 +++- app/satellites/Main.tsx | 207 ++++++++++++++++++++------ app/satellites/SatelliteTable.tsx | 2 +- package-lock.json | 30 ++++ package.json | 4 +- types/types.ts | 7 + 8 files changed, 356 insertions(+), 48 deletions(-) create mode 100644 app/(standalone)/layout.tsx create mode 100644 app/(standalone)/ob-location/page.tsx diff --git a/app/(standalone)/layout.tsx b/app/(standalone)/layout.tsx new file mode 100644 index 0000000..72dc28a --- /dev/null +++ b/app/(standalone)/layout.tsx @@ -0,0 +1,13 @@ +import { ReactNode } from 'react' + +export default function ObLocationLayout({ + children, +}: { + children: ReactNode +}) { + return ( +
+ { children } +
+ ) +} diff --git a/app/(standalone)/ob-location/page.tsx b/app/(standalone)/ob-location/page.tsx new file mode 100644 index 0000000..c0759e4 --- /dev/null +++ b/app/(standalone)/ob-location/page.tsx @@ -0,0 +1,116 @@ +'use client' + +import { useSearchParams } from 'next/navigation' +import { useEffect, useState } from 'react' +import { noto_sc, rubik } from '@/app/fonts' +import { Button } from '@douyinfe/semi-ui' +import { observerLocation } from '@/app/actions' + +export default function Page() { + const searchParams = useSearchParams() + const ref = searchParams.get('ref') + + const [loading, setLoading] = useState(false) + const [done, setDone] = useState(false) + + const [location, setLocation] = useState(null) + useEffect(() => { + if (!ref) return + (async () => { + await observerLocation(ref, 'pending') + })() + const watcher = navigator.geolocation.watchPosition(setLocation, null, { + enableHighAccuracy: true, + }) + return () => { + navigator.geolocation.clearWatch(watcher) + } + }, [ref]) + + const handleClick = () => { + if (!ref || !location) return + setLoading(true) + observerLocation(ref, { + longitude: location.coords.longitude, + latitude: location.coords.latitude, + altitude: location.coords.altitude, + accuracy: location.coords.accuracy, + altitudeAccuracy: location.coords.altitudeAccuracy, + }) + .then(() => { + setDone(true) + }) + .catch(err => { + console.error(err) + }) + .finally(() => { + setLoading(false) + }) + } + + return !ref ? ( +
+

No ref provided

+
+ ) : ( +
+ + + + + + +

上传地面站位置

+

您正在与电脑网页端同步位置信息

+
+
+

经度

+

+ { location?.coords.longitude.toFixed(6) || 'locating...' } +

+
+
+

纬度

+

+ { location?.coords.latitude.toFixed(6) || 'locating...' } +

+
+
+

海拔

+

+ { location?.coords.altitude?.toFixed(2) || '-' } +

+
+
+

位置精度

+

+ { location?.coords.accuracy.toFixed(2) || '-' } +

+
+
+

海拔精度

+

+ { location?.coords.altitudeAccuracy?.toFixed(2) || '-' } +

+
+
+
+ +
+

+ 您的位置会在服务器临时存储 5 分钟 +

+
+ ) +} diff --git a/app/actions.ts b/app/actions.ts index 44f6814..6f76cfe 100644 --- a/app/actions.ts +++ b/app/actions.ts @@ -1,7 +1,10 @@ 'use server' -import {sql} from '@vercel/postgres' -import {GeetestCaptchaSuccess, geetestValidate} from '@/app/geetest' +import { sql } from '@vercel/postgres' +import { GeetestCaptchaSuccess, geetestValidate } from '@/app/geetest' +import { UUID } from '@uniiem/uuid' +import { kv } from '@vercel/kv' +import { ObserverLocationStore } from '@/types/types' export interface Annotation { id: number @@ -31,12 +34,12 @@ export async function newAnnotation( } export async function getAnnotationsByLk(lk: string): Promise { - const {rows} = await sql`SELECT * FROM annotations WHERE lk = ${lk} order by upvote desc, id desc` + const { rows } = await sql`SELECT * FROM annotations WHERE lk = ${lk} order by upvote desc, id desc` return rows || [] } export async function getAnnotationsList(): Promise { - const {rows} = await sql`SELECT * FROM annotations order by upvote desc, id desc` + const { rows } = await sql`SELECT * FROM annotations order by upvote desc, id desc` return rows } @@ -103,3 +106,17 @@ export async function pastebin( .catch(reject) }) } + +export async function observerLocation(ref: UUID, payload?: ObserverLocationStore) { + if (!payload) { + return await kv.get(ref) + } else { + try { + return await kv.set(ref, payload, { + ex: 5 * 60, + }) + } catch (e) { + console.error(e) + } + } +} diff --git a/app/satellites/Main.tsx b/app/satellites/Main.tsx index 19482fa..10b68ca 100644 --- a/app/satellites/Main.tsx +++ b/app/satellites/Main.tsx @@ -1,16 +1,27 @@ 'use client' import './styles.scss' -import { useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import useSWR from 'swr' import { Icon } from '@iconify-icon/react' +import QRCode from 'react-qr-code' import { noto_sc, rubik } from '@/app/fonts' -import { Banner, Input } from '@douyinfe/semi-ui' +import { Banner, Button, Input, Modal, Toast, Tooltip } from '@douyinfe/semi-ui' import { IconSearch } from '@douyinfe/semi-icons' import { BaseResponse, LatestTleSet, Satellite } from '@/app/api/types' import { SatelliteTable } from '@/app/satellites/SatelliteTable' +import { UUID, uuidv4 } from '@uniiem/uuid' +import { observerLocation } from '@/app/actions' +import { ObserverLocationStore } from '@/types/types' export const Main = () => { + const [origin, setOrigin] = useState('https://ham-dev.c5r.app') + useEffect(() => { + if (window.location.origin !== origin) { + setOrigin(window.location.origin) + } + }, []) + const compositionRef = useRef({ isComposition: false }) const [filteredValue, setFilteredValue] = useState([]) const [pagination, setPagination] = useState({ @@ -18,6 +29,51 @@ export const Main = () => { pageSize: 10, }) + const [mobileLocationRefId, setMobileLocationRefId] = useState(null) + const [mobileScanned, setMobileScanned] = useState(false) + useEffect(() => { + let timer = setInterval(() => { + if (!mobileLocationRefId) return + observerLocation(mobileLocationRefId).then(loc => { + if (loc === 'pending') { + setMobileScanned(true) + } else if (typeof loc === 'object' && loc !== null) { + setLocationFromMobile(loc) + setMobileLocationRefId(null) + Toast.success('获取手机位置成功') + } + }) + }, 2000) + return () => { + clearTimeout(timer) + } + }, [mobileLocationRefId]) + + const [locationFromBrowser, setLocationFromBrowser] = useState | null>(null) + const [locationError, setLocationError] = useState(null) + const recommendMobileLocation = locationFromBrowser ? locationFromBrowser.accuracy > 1000 : false + useEffect(() => { + navigator.geolocation.getCurrentPosition( + (position) => { + setLocationFromBrowser({ + accuracy: position.coords.accuracy, + altitude: position.coords.altitude, + altitudeAccuracy: position.coords.altitudeAccuracy, + latitude: position.coords.latitude, + longitude: position.coords.longitude, + }) + }, + (error) => { + setLocationError(error.message) + }, + { + enableHighAccuracy: true, + }, + ) + }, []) + const [locationFromMobile, setLocationFromMobile] = useState | null>(null) + const location = locationFromMobile || locationFromBrowser + const { data: satellitesData, isLoading: isSatellitesLoading, @@ -62,48 +118,115 @@ export const Main = () => { setFilteredValue(newFilteredValue) } + // noinspection RequiredAttributes return ( -
-
-

- - 业余无线电卫星数据库 - Amateur Radio Satellites Database -

- } - onCompositionStart={ handleCompositionStart } - onCompositionEnd={ handleCompositionEnd } - onChange={ handleChange } - /> -
-
- { satellitesData && satellitesData.code !== 0 && ( - - ) } + <> +
+
+

+ + 业余无线电卫星数据库 + Amateur Radio Satellites Database +

+ } + onCompositionStart={ handleCompositionStart } + onCompositionEnd={ handleCompositionEnd } + onChange={ handleChange } + /> +
+

+ 台站位置: + + { location ? `${ location.latitude.toFixed(6) }, ${ location.longitude.toFixed(6) }` : 'unknown' || 'loading...' } + +

+
+ { !locationFromMobile && (locationError || recommendMobileLocation) && ( + +
+
+
+
+ { satellitesData && satellitesData.code !== 0 && ( + + ) } - { - return a.name.localeCompare(b.name) - } } - /> + { + return a.name.localeCompare(b.name) + } } + /> +
-
+ setMobileLocationRefId(null) } + closeOnEsc={ true } + > +
+ + { mobileScanned && ( +
+ +

+ 扫码成功,请在手机上确认 +

+
+ ) } +
+
+ ) } \ No newline at end of file diff --git a/app/satellites/SatelliteTable.tsx b/app/satellites/SatelliteTable.tsx index a3a94f9..8ca020d 100644 --- a/app/satellites/SatelliteTable.tsx +++ b/app/satellites/SatelliteTable.tsx @@ -243,7 +243,7 @@ export const SatelliteTable = ({ (pagination?.current || 1) * (pagination?.pageSize || 10), ).map((satellite, index) => ( i.norad_cat_id === satellite.norad_cat_id) || null } timestamp={ timestamp } diff --git a/package-lock.json b/package-lock.json index c4f0378..e662f46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@douyinfe/semi-ui": "^2.55.0", "@hamset/maidenhead-locator": "^0.2.1", "@uiw/react-amap": "^6.0.3", + "@uniiem/uuid": "^0.2.1", "@vercel/analytics": "^1.2.2", "@vercel/blob": "^0.22.1", "@vercel/kv": "^1.0.1", @@ -22,6 +23,7 @@ "react": "^18", "react-dom": "^18", "react-markdown": "^9.0.1", + "react-qr-code": "^2.0.12", "react-transition-group": "^4.4.5", "rehype-katex": "^7.0.0", "remark-gfm": "^4.0.0", @@ -1499,6 +1501,11 @@ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==" }, + "node_modules/@uniiem/uuid": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@uniiem/uuid/-/uuid-0.2.1.tgz", + "integrity": "sha512-p8DOA3BTkZgvgtOCtK5x7Y2l+GRTFhYrOua70YPiEEUomQFirwxpWrQBst+7oB/iPTeY1zHuF6MKl+mxi0R00A==" + }, "node_modules/@upstash/redis": { "version": "1.25.1", "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.25.1.tgz", @@ -6140,6 +6147,11 @@ "node": ">=6" } }, + "node_modules/qr.js": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/qr.js/-/qr.js-0.0.0.tgz", + "integrity": "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6226,6 +6238,24 @@ "react": ">=18" } }, + "node_modules/react-qr-code": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.0.12.tgz", + "integrity": "sha512-k+pzP5CKLEGBRwZsDPp98/CAJeXlsYRHM2iZn1Sd5Th/HnKhIZCSg27PXO58zk8z02RaEryg+60xa4vyywMJwg==", + "dependencies": { + "prop-types": "^15.8.1", + "qr.js": "0.0.0" + }, + "peerDependencies": { + "react": "^16.x || ^17.x || ^18.x", + "react-native-svg": "*" + }, + "peerDependenciesMeta": { + "react-native-svg": { + "optional": true + } + } + }, "node_modules/react-resizable": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.5.tgz", diff --git a/package.json b/package.json index f73d784..1081495 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.2.0", "private": true, "scripts": { - "dev": "next dev -p 4090", + "dev": "next dev -p 3000", "vercel-dev": "vercel dev --listen 3000", "build": "next build", "start": "next start", @@ -15,6 +15,7 @@ "@douyinfe/semi-ui": "^2.55.0", "@hamset/maidenhead-locator": "^0.2.1", "@uiw/react-amap": "^6.0.3", + "@uniiem/uuid": "^0.2.1", "@vercel/analytics": "^1.2.2", "@vercel/blob": "^0.22.1", "@vercel/kv": "^1.0.1", @@ -24,6 +25,7 @@ "react": "^18", "react-dom": "^18", "react-markdown": "^9.0.1", + "react-qr-code": "^2.0.12", "react-transition-group": "^4.4.5", "rehype-katex": "^7.0.0", "remark-gfm": "^4.0.0", diff --git a/types/types.ts b/types/types.ts index e69de29..e6ee4bb 100644 --- a/types/types.ts +++ b/types/types.ts @@ -0,0 +1,7 @@ +export type ObserverLocationStore = { + longitude: number, + latitude: number, + altitude?: number | null, + accuracy: number, + altitudeAccuracy?: number | null, +} | 'pending' \ No newline at end of file