0327
parent
0ca5070755
commit
6fee91ed80
|
|
@ -1124,9 +1124,9 @@ public class WorkServiceImpl implements WorkService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildLocationCacheKey(double latitude, double longitude) {
|
private String buildLocationCacheKey(double latitude, double longitude) {
|
||||||
return BigDecimal.valueOf(latitude).setScale(4, java.math.RoundingMode.HALF_UP).toPlainString()
|
return BigDecimal.valueOf(latitude).setScale(5, java.math.RoundingMode.HALF_UP).toPlainString()
|
||||||
+ ","
|
+ ","
|
||||||
+ BigDecimal.valueOf(longitude).setScale(4, java.math.RoundingMode.HALF_UP).toPlainString();
|
+ BigDecimal.valueOf(longitude).setScale(5, java.math.RoundingMode.HALF_UP).toPlainString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildTencentLocationName(JsonNode addressComponent) {
|
private String buildTencentLocationName(JsonNode addressComponent) {
|
||||||
|
|
@ -1282,7 +1282,8 @@ public class WorkServiceImpl implements WorkService {
|
||||||
address.path("road").asText(null),
|
address.path("road").asText(null),
|
||||||
address.path("pedestrian").asText(null),
|
address.path("pedestrian").asText(null),
|
||||||
address.path("residential").asText(null),
|
address.path("residential").asText(null),
|
||||||
address.path("city_block").asText(null)),
|
address.path("city_block").asText(null),
|
||||||
|
address.path("commercial").asText(null)),
|
||||||
address.path("house_number").asText(null),
|
address.path("house_number").asText(null),
|
||||||
firstNonBlank(
|
firstNonBlank(
|
||||||
address.path("neighbourhood").asText(null),
|
address.path("neighbourhood").asText(null),
|
||||||
|
|
@ -1290,6 +1291,7 @@ public class WorkServiceImpl implements WorkService {
|
||||||
address.path("hamlet").asText(null)),
|
address.path("hamlet").asText(null)),
|
||||||
firstNonBlank(
|
firstNonBlank(
|
||||||
address.path("building").asText(null),
|
address.path("building").asText(null),
|
||||||
|
address.path("commercial").asText(null),
|
||||||
address.path("amenity").asText(null),
|
address.path("amenity").asText(null),
|
||||||
address.path("office").asText(null),
|
address.path("office").asText(null),
|
||||||
address.path("shop").asText(null)));
|
address.path("shop").asText(null)));
|
||||||
|
|
|
||||||
|
|
@ -84,12 +84,32 @@ export async function resolveTencentMapLocation() {
|
||||||
|
|
||||||
const bestResult = pickBetterLocationResult(sdkResult, browserResult);
|
const bestResult = pickBetterLocationResult(sdkResult, browserResult);
|
||||||
if (bestResult) {
|
if (bestResult) {
|
||||||
return bestResult;
|
return mergeLocationAddress(bestResult, sdkResult, browserResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(buildLocationFailureMessage(browserError, sdkError));
|
throw new Error(buildLocationFailureMessage(browserError, sdkError));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeLocationAddress(
|
||||||
|
bestResult: TencentMapLocation,
|
||||||
|
sdkResult: TencentMapLocation | null,
|
||||||
|
browserResult: TencentMapLocation | null,
|
||||||
|
) {
|
||||||
|
if (bestResult.address) {
|
||||||
|
return bestResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackAddress = sdkResult?.address || browserResult?.address || "";
|
||||||
|
if (!fallbackAddress) {
|
||||||
|
return bestResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...bestResult,
|
||||||
|
address: fallbackAddress,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveWithTencentSdk() {
|
async function resolveWithTencentSdk() {
|
||||||
const Geolocation = await loadTencentGeolocationSdk();
|
const Geolocation = await loadTencentGeolocationSdk();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -107,17 +107,29 @@ export default function Dashboard() {
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
<motion.div
|
||||||
|
key="dashboard-skeleton"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.22, ease: "easeOut" }}
|
||||||
|
>
|
||||||
<DashboardSkeleton />
|
<DashboardSkeleton />
|
||||||
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<motion.div
|
||||||
<div className="grid grid-cols-2 gap-2.5 sm:gap-4 xl:grid-cols-4">
|
key="dashboard-content"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.24, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-x-3 gap-y-4 sm:gap-4 xl:grid-cols-4">
|
||||||
{stats.map((stat, i) => (
|
{stats.map((stat, i) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={stat.name}
|
key={stat.name}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: i * 0.1 }}
|
transition={{ delay: i * 0.1 }}
|
||||||
className="crm-card rounded-xl p-3 transition-all hover:shadow-md dark:hover:bg-slate-900 sm:rounded-2xl sm:p-5"
|
className="crm-card min-h-[88px] rounded-xl p-3 transition-all hover:shadow-md dark:hover:bg-slate-900 sm:min-h-[104px] sm:rounded-2xl sm:p-5"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2.5 sm:gap-4">
|
<div className="flex items-center gap-2.5 sm:gap-4">
|
||||||
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${stat.bg} sm:h-12 sm:w-12 sm:rounded-xl`}>
|
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${stat.bg} sm:h-12 sm:w-12 sm:rounded-xl`}>
|
||||||
|
|
@ -132,7 +144,7 @@ export default function Dashboard() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-5 md:grid-cols-2 md:gap-6">
|
<div className="mt-5 grid gap-5 md:mt-6 md:grid-cols-2 md:gap-6">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
|
@ -263,7 +275,7 @@ export default function Dashboard() {
|
||||||
) : null}
|
) : null}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -271,12 +283,12 @@ export default function Dashboard() {
|
||||||
|
|
||||||
function DashboardSkeleton() {
|
function DashboardSkeleton() {
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="animate-[fadeIn_220ms_ease-out]">
|
||||||
<div className="grid grid-cols-2 gap-2.5 sm:gap-4 xl:grid-cols-4">
|
<div className="grid grid-cols-2 gap-x-3 gap-y-4 sm:gap-4 xl:grid-cols-4">
|
||||||
{[0, 1, 2, 3].map((item) => (
|
{[0, 1, 2, 3].map((item) => (
|
||||||
<div
|
<div
|
||||||
key={`dashboard-stat-skeleton-${item}`}
|
key={`dashboard-stat-skeleton-${item}`}
|
||||||
className="crm-card rounded-xl p-3 sm:rounded-2xl sm:p-5"
|
className="crm-card min-h-[88px] rounded-xl p-3 sm:min-h-[104px] sm:rounded-2xl sm:p-5"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2.5 sm:gap-4">
|
<div className="flex items-center gap-2.5 sm:gap-4">
|
||||||
<div className="h-9 w-9 animate-pulse rounded-lg bg-slate-100 dark:bg-slate-800 sm:h-12 sm:w-12 sm:rounded-xl" />
|
<div className="h-9 w-9 animate-pulse rounded-lg bg-slate-100 dark:bg-slate-800 sm:h-12 sm:w-12 sm:rounded-xl" />
|
||||||
|
|
@ -289,7 +301,7 @@ function DashboardSkeleton() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-5 md:grid-cols-2 md:gap-6">
|
<div className="mt-5 grid gap-5 md:mt-6 md:grid-cols-2 md:gap-6">
|
||||||
<div className="crm-card crm-card-pad-lg rounded-2xl">
|
<div className="crm-card crm-card-pad-lg rounded-2xl">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<div className="h-5 w-20 animate-pulse rounded bg-slate-100 dark:bg-slate-800" />
|
<div className="h-5 w-20 animate-pulse rounded bg-slate-100 dark:bg-slate-800" />
|
||||||
|
|
@ -341,7 +353,7 @@ function DashboardSkeleton() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, ty
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { zhCN } from "date-fns/locale";
|
import { zhCN } from "date-fns/locale";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { AtSign, Camera, CheckCircle2, FileText, ListTodo, MapPin, NotebookPen, Plus, RefreshCw, Search, Send, Trash2, X } from "lucide-react";
|
import { ArrowUp, AtSign, Camera, CheckCircle2, FileText, ListTodo, MapPin, NotebookPen, Plus, RefreshCw, Search, Send, Trash2, X } from "lucide-react";
|
||||||
import { flushSync } from "react-dom";
|
import { flushSync } from "react-dom";
|
||||||
import { Link, Navigate, useLocation } from "react-router-dom";
|
import { Link, Navigate, useLocation } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
|
|
@ -28,7 +28,7 @@ import {
|
||||||
type WorkTomorrowPlanItem,
|
type WorkTomorrowPlanItem,
|
||||||
} from "@/lib/auth";
|
} from "@/lib/auth";
|
||||||
import { ProtectedImage } from "@/components/ProtectedImage";
|
import { ProtectedImage } from "@/components/ProtectedImage";
|
||||||
import { resolveTencentMapLocation } from "@/lib/tencentMap";
|
import { gcj02ToWgs84, resolveTencentMapLocation, wgs84ToGcj02 } from "@/lib/tencentMap";
|
||||||
import { loadTencentMapGlApi } from "@/lib/tencentMapGl";
|
import { loadTencentMapGlApi } from "@/lib/tencentMapGl";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -75,6 +75,8 @@ const defaultReportForm: CreateWorkDailyReportPayload = {
|
||||||
|
|
||||||
const LOCATION_ADJUST_RADIUS_METERS = 120;
|
const LOCATION_ADJUST_RADIUS_METERS = 120;
|
||||||
const CHECK_IN_ACCURACY_CONFIRM_THRESHOLD_METERS = 80;
|
const CHECK_IN_ACCURACY_CONFIRM_THRESHOLD_METERS = 80;
|
||||||
|
const LOCATION_DISPLAY_CACHE_STORAGE_KEY = "work:location-display-cache";
|
||||||
|
const LOCATION_DISPLAY_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000;
|
||||||
const locationDisplayCache = new Map<string, string>();
|
const locationDisplayCache = new Map<string, string>();
|
||||||
|
|
||||||
type WorkRelationOption = {
|
type WorkRelationOption = {
|
||||||
|
|
@ -131,6 +133,8 @@ export default function Work() {
|
||||||
const hasAutoRefreshedLocation = useRef(false);
|
const hasAutoRefreshedLocation = useRef(false);
|
||||||
const photoInputRef = useRef<HTMLInputElement | null>(null);
|
const photoInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const historyLoadMoreRef = useRef<HTMLDivElement | null>(null);
|
const historyLoadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const historyScrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const historyScrollIdleTimerRef = useRef<number | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshingLocation, setRefreshingLocation] = useState(false);
|
const [refreshingLocation, setRefreshingLocation] = useState(false);
|
||||||
const [submittingCheckIn, setSubmittingCheckIn] = useState(false);
|
const [submittingCheckIn, setSubmittingCheckIn] = useState(false);
|
||||||
|
|
@ -152,6 +156,7 @@ export default function Work() {
|
||||||
const [historyLoadingMore, setHistoryLoadingMore] = useState(false);
|
const [historyLoadingMore, setHistoryLoadingMore] = useState(false);
|
||||||
const [historyHasMore, setHistoryHasMore] = useState(false);
|
const [historyHasMore, setHistoryHasMore] = useState(false);
|
||||||
const [historyPage, setHistoryPage] = useState(1);
|
const [historyPage, setHistoryPage] = useState(1);
|
||||||
|
const [showHistoryBackToTop, setShowHistoryBackToTop] = useState(false);
|
||||||
const [reportStatus, setReportStatus] = useState<string>();
|
const [reportStatus, setReportStatus] = useState<string>();
|
||||||
const [currentUser, setCurrentUser] = useState<UserProfile | null>(null);
|
const [currentUser, setCurrentUser] = useState<UserProfile | null>(null);
|
||||||
const [profileOverview, setProfileOverview] = useState<ProfileOverview | null>(null);
|
const [profileOverview, setProfileOverview] = useState<ProfileOverview | null>(null);
|
||||||
|
|
@ -237,6 +242,43 @@ export default function Work() {
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [historyHasMore, historyLoading, historyLoadingMore, historyPage, historySection]);
|
}, [historyHasMore, historyLoading, historyLoadingMore, historyPage, historySection]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = historyScrollContainerRef.current;
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
const shouldShow = container.scrollTop > 280;
|
||||||
|
if (!shouldShow) {
|
||||||
|
setShowHistoryBackToTop(false);
|
||||||
|
if (historyScrollIdleTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(historyScrollIdleTimerRef.current);
|
||||||
|
historyScrollIdleTimerRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowHistoryBackToTop(false);
|
||||||
|
if (historyScrollIdleTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(historyScrollIdleTimerRef.current);
|
||||||
|
}
|
||||||
|
historyScrollIdleTimerRef.current = window.setTimeout(() => {
|
||||||
|
setShowHistoryBackToTop(true);
|
||||||
|
}, 180);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleScroll();
|
||||||
|
container.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
|
return () => {
|
||||||
|
container.removeEventListener("scroll", handleScroll);
|
||||||
|
if (historyScrollIdleTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(historyScrollIdleTimerRef.current);
|
||||||
|
historyScrollIdleTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [mobilePanel, historyData, historyLoading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!historyDetailItem) {
|
if (!historyDetailItem) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -770,7 +812,10 @@ export default function Work() {
|
||||||
<MobilePanelToggle mobilePanel={mobilePanel} onChange={setMobilePanel} />
|
<MobilePanelToggle mobilePanel={mobilePanel} onChange={setMobilePanel} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-[calc(100vh-12rem)] space-y-4 overflow-y-auto pr-2 scrollbar-hide">
|
<div
|
||||||
|
ref={historyScrollContainerRef}
|
||||||
|
className="relative max-h-[calc(100vh-12rem)] space-y-4 overflow-y-auto pr-2 scrollbar-hide"
|
||||||
|
>
|
||||||
{historyLoading && historyData.length === 0 ? <WorkHistorySkeleton /> : null}
|
{historyLoading && historyData.length === 0 ? <WorkHistorySkeleton /> : null}
|
||||||
|
|
||||||
{historyData.map((item, index) => (
|
{historyData.map((item, index) => (
|
||||||
|
|
@ -798,6 +843,23 @@ export default function Work() {
|
||||||
{historyLoadingMore ? "加载更多记录中..." : "下拉到底自动加载更多"}
|
{historyLoadingMore ? "加载更多记录中..." : "下拉到底自动加载更多"}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
type="button"
|
||||||
|
initial={false}
|
||||||
|
animate={{
|
||||||
|
opacity: showHistoryBackToTop ? 1 : 0,
|
||||||
|
scale: showHistoryBackToTop ? 1 : 0.92,
|
||||||
|
y: showHistoryBackToTop ? 0 : 8,
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.18, ease: "easeOut" }}
|
||||||
|
onClick={() => historyScrollContainerRef.current?.scrollTo({ top: 0, behavior: "smooth" })}
|
||||||
|
className="sticky bottom-4 ml-auto flex h-10 w-10 items-center justify-center rounded-full border border-violet-200 bg-white/92 text-violet-600 shadow-lg backdrop-blur transition-colors hover:bg-violet-50 dark:border-violet-500/30 dark:bg-slate-900/92 dark:text-violet-300 dark:hover:bg-slate-800"
|
||||||
|
style={{ pointerEvents: showHistoryBackToTop ? "auto" : "none" }}
|
||||||
|
aria-label="回到历史记录顶部"
|
||||||
|
>
|
||||||
|
<ArrowUp className="h-4 w-4" />
|
||||||
|
</motion.button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -950,6 +1012,8 @@ function LocationAdjustModal({
|
||||||
const [selectionHint, setSelectionHint] = useState("");
|
const [selectionHint, setSelectionHint] = useState("");
|
||||||
const [resolvingAddress, setResolvingAddress] = useState(false);
|
const [resolvingAddress, setResolvingAddress] = useState(false);
|
||||||
const [confirming, setConfirming] = useState(false);
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const originMapPoint = useMemo(() => wgs84ToGcj02(origin.latitude, origin.longitude), [origin.latitude, origin.longitude]);
|
||||||
|
const currentMapPoint = useMemo(() => wgs84ToGcj02(currentPoint.latitude, currentPoint.longitude), [currentPoint.latitude, currentPoint.longitude]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -970,7 +1034,7 @@ function LocationAdjustModal({
|
||||||
|
|
||||||
const createLatLng = (latitude: number, longitude: number) => new TMap.LatLng(latitude, longitude);
|
const createLatLng = (latitude: number, longitude: number) => new TMap.LatLng(latitude, longitude);
|
||||||
const map = new TMap.Map(mapContainerRef.current, {
|
const map = new TMap.Map(mapContainerRef.current, {
|
||||||
center: createLatLng(currentPoint.latitude, currentPoint.longitude),
|
center: createLatLng(currentMapPoint.latitude, currentMapPoint.longitude),
|
||||||
zoom: 18,
|
zoom: 18,
|
||||||
pitch: 0,
|
pitch: 0,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
|
|
@ -991,7 +1055,7 @@ function LocationAdjustModal({
|
||||||
{
|
{
|
||||||
id: "origin",
|
id: "origin",
|
||||||
styleId: "origin",
|
styleId: "origin",
|
||||||
position: createLatLng(origin.latitude, origin.longitude),
|
position: createLatLng(originMapPoint.latitude, originMapPoint.longitude),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
@ -1010,7 +1074,7 @@ function LocationAdjustModal({
|
||||||
{
|
{
|
||||||
id: "range",
|
id: "range",
|
||||||
styleId: "allowed",
|
styleId: "allowed",
|
||||||
center: createLatLng(origin.latitude, origin.longitude),
|
center: createLatLng(originMapPoint.latitude, originMapPoint.longitude),
|
||||||
radius,
|
radius,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -1024,17 +1088,18 @@ function LocationAdjustModal({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextPoint = {
|
const nextPoint = gcj02ToWgs84(latitude, longitude);
|
||||||
latitude: Number(latitude.toFixed(6)),
|
const normalizedPoint = {
|
||||||
longitude: Number(longitude.toFixed(6)),
|
latitude: Number(nextPoint.latitude.toFixed(6)),
|
||||||
|
longitude: Number(nextPoint.longitude.toFixed(6)),
|
||||||
};
|
};
|
||||||
const distance = calculateDistanceMeters(origin, nextPoint);
|
const distance = calculateDistanceMeters(origin, normalizedPoint);
|
||||||
if (distance > radius) {
|
if (distance > radius) {
|
||||||
setSelectionHint(`仅可在原始定位附近 ${radius} 米内微调,当前超出约 ${Math.round(distance - radius)} 米。`);
|
setSelectionHint(`仅可在原始定位附近 ${radius} 米内微调,当前超出约 ${Math.round(distance - radius)} 米。`);
|
||||||
} else {
|
} else {
|
||||||
setSelectionHint(`当前准星距原始定位约 ${Math.round(distance)} 米。`);
|
setSelectionHint(`当前准星距原始定位约 ${Math.round(distance)} 米。`);
|
||||||
}
|
}
|
||||||
setSelectedPoint(nextPoint);
|
setSelectedPoint(normalizedPoint);
|
||||||
};
|
};
|
||||||
|
|
||||||
map.on("moveend", syncCenterSelection);
|
map.on("moveend", syncCenterSelection);
|
||||||
|
|
@ -1066,7 +1131,7 @@ function LocationAdjustModal({
|
||||||
mapRef.current.map?.destroy?.();
|
mapRef.current.map?.destroy?.();
|
||||||
mapRef.current = {};
|
mapRef.current = {};
|
||||||
};
|
};
|
||||||
}, [currentPoint.latitude, currentPoint.longitude, origin.latitude, origin.longitude]);
|
}, [currentMapPoint.latitude, currentMapPoint.longitude, originMapPoint.latitude, originMapPoint.longitude, origin.latitude, origin.longitude, radius]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -1156,7 +1221,7 @@ function LocationAdjustModal({
|
||||||
setSelectedPoint(origin);
|
setSelectedPoint(origin);
|
||||||
setSelectionHint("已恢复到原始定位。");
|
setSelectionHint("已恢复到原始定位。");
|
||||||
if (mapRef.current.createLatLng) {
|
if (mapRef.current.createLatLng) {
|
||||||
mapRef.current.map?.panTo?.(mapRef.current.createLatLng(origin.latitude, origin.longitude));
|
mapRef.current.map?.panTo?.(mapRef.current.createLatLng(originMapPoint.latitude, originMapPoint.longitude));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="crm-btn min-w-0 flex-1 rounded-xl border border-slate-200 bg-white text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
|
className="crm-btn min-w-0 flex-1 rounded-xl border border-slate-200 bg-white text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||||
|
|
@ -2146,29 +2211,93 @@ function isReadableLocationText(value?: string) {
|
||||||
return normalizedValue.length >= 6;
|
return normalizedValue.length >= 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasDetailedLocationFeature(value?: string) {
|
||||||
|
const normalizedValue = normalizeLocationText(value);
|
||||||
|
if (!normalizedValue) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return /(街道|大道|路|街|巷|号|栋|座|室|园区|商务区|广场|大厦|中心|大楼|社区)/.test(normalizedValue);
|
||||||
|
}
|
||||||
|
|
||||||
function buildLocationCacheKey(latitude: number, longitude: number) {
|
function buildLocationCacheKey(latitude: number, longitude: number) {
|
||||||
return `${latitude.toFixed(4)},${longitude.toFixed(4)}`;
|
return `${latitude.toFixed(5)},${longitude.toFixed(5)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPersistedLocationCacheValue(cacheKey: string) {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawValue = window.localStorage.getItem(LOCATION_DISPLAY_CACHE_STORAGE_KEY);
|
||||||
|
if (!rawValue) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheRecord = JSON.parse(rawValue) as Record<string, { value?: string; expiresAt?: number }>;
|
||||||
|
const cacheEntry = cacheRecord?.[cacheKey];
|
||||||
|
if (!cacheEntry || typeof cacheEntry.expiresAt !== "number" || cacheEntry.expiresAt <= Date.now()) {
|
||||||
|
if (cacheEntry) {
|
||||||
|
delete cacheRecord[cacheKey];
|
||||||
|
window.localStorage.setItem(LOCATION_DISPLAY_CACHE_STORAGE_KEY, JSON.stringify(cacheRecord));
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeLocationText(cacheEntry.value);
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistLocationCacheValue(cacheKey: string, value: string) {
|
||||||
|
if (typeof window === "undefined" || !value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawValue = window.localStorage.getItem(LOCATION_DISPLAY_CACHE_STORAGE_KEY);
|
||||||
|
const cacheRecord = rawValue
|
||||||
|
? JSON.parse(rawValue) as Record<string, { value?: string; expiresAt?: number }>
|
||||||
|
: {};
|
||||||
|
|
||||||
|
cacheRecord[cacheKey] = {
|
||||||
|
value,
|
||||||
|
expiresAt: Date.now() + LOCATION_DISPLAY_CACHE_TTL_MS,
|
||||||
|
};
|
||||||
|
window.localStorage.setItem(LOCATION_DISPLAY_CACHE_STORAGE_KEY, JSON.stringify(cacheRecord));
|
||||||
|
} catch {
|
||||||
|
// ignore local persistence failures
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveLocationDisplayName(latitude: number, longitude: number, preferredText?: string) {
|
async function resolveLocationDisplayName(latitude: number, longitude: number, preferredText?: string) {
|
||||||
|
const cacheKey = buildLocationCacheKey(latitude, longitude);
|
||||||
const normalizedPreferredText = normalizeLocationText(preferredText);
|
const normalizedPreferredText = normalizeLocationText(preferredText);
|
||||||
if (isReadableLocationText(normalizedPreferredText)) {
|
if (isReadableLocationText(normalizedPreferredText) && hasDetailedLocationFeature(normalizedPreferredText)) {
|
||||||
locationDisplayCache.set(buildLocationCacheKey(latitude, longitude), normalizedPreferredText);
|
locationDisplayCache.set(cacheKey, normalizedPreferredText);
|
||||||
|
persistLocationCacheValue(cacheKey, normalizedPreferredText);
|
||||||
return normalizedPreferredText;
|
return normalizedPreferredText;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cacheKey = buildLocationCacheKey(latitude, longitude);
|
|
||||||
const cachedLocation = locationDisplayCache.get(cacheKey);
|
const cachedLocation = locationDisplayCache.get(cacheKey);
|
||||||
if (isReadableLocationText(cachedLocation)) {
|
if (isReadableLocationText(cachedLocation) && hasDetailedLocationFeature(cachedLocation)) {
|
||||||
return cachedLocation;
|
return cachedLocation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const persistedLocation = readPersistedLocationCacheValue(cacheKey);
|
||||||
|
if (isReadableLocationText(persistedLocation) && hasDetailedLocationFeature(persistedLocation)) {
|
||||||
|
locationDisplayCache.set(cacheKey, persistedLocation);
|
||||||
|
return persistedLocation;
|
||||||
|
}
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
const address = await reverseWorkGeocode(latitude, longitude);
|
const address = await reverseWorkGeocode(latitude, longitude);
|
||||||
if (isReadableLocationText(address)) {
|
if (isReadableLocationText(address)) {
|
||||||
const normalizedAddress = normalizeLocationText(address);
|
const normalizedAddress = normalizeLocationText(address);
|
||||||
locationDisplayCache.set(cacheKey, normalizedAddress);
|
locationDisplayCache.set(cacheKey, normalizedAddress);
|
||||||
|
persistLocationCacheValue(cacheKey, normalizedAddress);
|
||||||
return normalizedAddress;
|
return normalizedAddress;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -2176,6 +2305,16 @@ async function resolveLocationDisplayName(latitude: number, longitude: number, p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isReadableLocationText(persistedLocation)) {
|
||||||
|
return normalizeLocationText(persistedLocation);
|
||||||
|
}
|
||||||
|
if (isReadableLocationText(cachedLocation)) {
|
||||||
|
return normalizeLocationText(cachedLocation);
|
||||||
|
}
|
||||||
|
if (isReadableLocationText(normalizedPreferredText)) {
|
||||||
|
return normalizedPreferredText;
|
||||||
|
}
|
||||||
|
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -309,88 +309,38 @@
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.login-page-grid {
|
.login-page-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: minmax(0, 1fr);
|
||||||
gap: 28px;
|
gap: 0;
|
||||||
align-items: start;
|
align-items: center;
|
||||||
padding-top: max(24px, env(safe-area-inset-top));
|
justify-items: center;
|
||||||
padding-bottom: max(24px, env(safe-area-inset-bottom));
|
padding:
|
||||||
}
|
max(20px, env(safe-area-inset-top))
|
||||||
|
20px
|
||||||
.login-page-brand {
|
max(24px, calc(20px + env(safe-area-inset-bottom)));
|
||||||
justify-content: flex-start;
|
|
||||||
gap: 32px;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-hero-copy h2 {
|
|
||||||
font-size: clamp(2.2rem, 10vw, 3.6rem);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-panel {
|
.login-panel {
|
||||||
order: -1;
|
width: 100%;
|
||||||
justify-content: flex-start;
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page-brand {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-panel-card {
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.login-page-grid {
|
.login-page-grid {
|
||||||
gap: 20px;
|
|
||||||
padding:
|
padding:
|
||||||
max(16px, env(safe-area-inset-top))
|
max(16px, env(safe-area-inset-top))
|
||||||
16px
|
16px
|
||||||
max(20px, calc(16px + env(safe-area-inset-bottom)));
|
max(20px, calc(16px + env(safe-area-inset-bottom)));
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-brand-lockup {
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-brand-mark {
|
|
||||||
height: 56px;
|
|
||||||
width: 56px;
|
|
||||||
border-radius: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-brand-kicker {
|
|
||||||
margin-bottom: 4px;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
letter-spacing: 0.18em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-brand-lockup h1 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-page-brand {
|
|
||||||
gap: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-hero-tag {
|
|
||||||
margin-bottom: 14px;
|
|
||||||
padding: 7px 12px;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-hero-copy h2 {
|
|
||||||
font-size: clamp(1.9rem, 10vw, 2.6rem);
|
|
||||||
line-height: 1.04;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-hero-copy p:last-child {
|
|
||||||
margin-top: 14px;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
line-height: 1.65;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-brand-meta {
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-brand-meta span {
|
|
||||||
padding: 8px 12px;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-panel-card {
|
.login-panel-card {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
padding: 20px 16px;
|
padding: 20px 16px;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue