diff --git a/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java index d22547a1..5f1563d2 100644 --- a/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java +++ b/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java @@ -1124,9 +1124,9 @@ public class WorkServiceImpl implements WorkService { } 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) { @@ -1282,7 +1282,8 @@ public class WorkServiceImpl implements WorkService { address.path("road").asText(null), address.path("pedestrian").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), firstNonBlank( address.path("neighbourhood").asText(null), @@ -1290,6 +1291,7 @@ public class WorkServiceImpl implements WorkService { address.path("hamlet").asText(null)), firstNonBlank( address.path("building").asText(null), + address.path("commercial").asText(null), address.path("amenity").asText(null), address.path("office").asText(null), address.path("shop").asText(null))); diff --git a/frontend/src/lib/tencentMap.ts b/frontend/src/lib/tencentMap.ts index cf7302ad..a5e9dfd6 100644 --- a/frontend/src/lib/tencentMap.ts +++ b/frontend/src/lib/tencentMap.ts @@ -84,12 +84,32 @@ export async function resolveTencentMapLocation() { const bestResult = pickBetterLocationResult(sdkResult, browserResult); if (bestResult) { - return bestResult; + return mergeLocationAddress(bestResult, sdkResult, browserResult); } 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() { const Geolocation = await loadTencentGeolocationSdk(); diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index ad0607d4..e15af98e 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -107,17 +107,29 @@ export default function Dashboard() { {loading ? ( - + + + ) : ( - <> -
+ +
{stats.map((stat, i) => (
@@ -132,7 +144,7 @@ export default function Dashboard() { ))}
-
+
- + )}
); @@ -271,12 +283,12 @@ export default function Dashboard() { function DashboardSkeleton() { return ( - <> -
+
+
{[0, 1, 2, 3].map((item) => (
@@ -289,7 +301,7 @@ function DashboardSkeleton() { ))}
-
+
@@ -341,7 +353,7 @@ function DashboardSkeleton() {
- +
); } diff --git a/frontend/src/pages/Work.tsx b/frontend/src/pages/Work.tsx index 6d7d6e78..f25688a7 100644 --- a/frontend/src/pages/Work.tsx +++ b/frontend/src/pages/Work.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, ty import { format } from "date-fns"; import { zhCN } from "date-fns/locale"; 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 { Link, Navigate, useLocation } from "react-router-dom"; import { @@ -28,7 +28,7 @@ import { type WorkTomorrowPlanItem, } from "@/lib/auth"; import { ProtectedImage } from "@/components/ProtectedImage"; -import { resolveTencentMapLocation } from "@/lib/tencentMap"; +import { gcj02ToWgs84, resolveTencentMapLocation, wgs84ToGcj02 } from "@/lib/tencentMap"; import { loadTencentMapGlApi } from "@/lib/tencentMapGl"; import { cn } from "@/lib/utils"; @@ -75,6 +75,8 @@ const defaultReportForm: CreateWorkDailyReportPayload = { const LOCATION_ADJUST_RADIUS_METERS = 120; 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(); type WorkRelationOption = { @@ -131,6 +133,8 @@ export default function Work() { const hasAutoRefreshedLocation = useRef(false); const photoInputRef = useRef(null); const historyLoadMoreRef = useRef(null); + const historyScrollContainerRef = useRef(null); + const historyScrollIdleTimerRef = useRef(null); const [loading, setLoading] = useState(true); const [refreshingLocation, setRefreshingLocation] = useState(false); const [submittingCheckIn, setSubmittingCheckIn] = useState(false); @@ -152,6 +156,7 @@ export default function Work() { const [historyLoadingMore, setHistoryLoadingMore] = useState(false); const [historyHasMore, setHistoryHasMore] = useState(false); const [historyPage, setHistoryPage] = useState(1); + const [showHistoryBackToTop, setShowHistoryBackToTop] = useState(false); const [reportStatus, setReportStatus] = useState(); const [currentUser, setCurrentUser] = useState(null); const [profileOverview, setProfileOverview] = useState(null); @@ -237,6 +242,43 @@ export default function Work() { return () => observer.disconnect(); }, [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(() => { if (!historyDetailItem) { return; @@ -770,7 +812,10 @@ export default function Work() {
-
+
{historyLoading && historyData.length === 0 ? : null} {historyData.map((item, index) => ( @@ -798,6 +843,23 @@ export default function Work() { {historyLoadingMore ? "加载更多记录中..." : "下拉到底自动加载更多"}
) : null} + + 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="回到历史记录顶部" + > + +
@@ -950,6 +1012,8 @@ function LocationAdjustModal({ const [selectionHint, setSelectionHint] = useState(""); const [resolvingAddress, setResolvingAddress] = 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(() => { let cancelled = false; @@ -970,7 +1034,7 @@ function LocationAdjustModal({ const createLatLng = (latitude: number, longitude: number) => new TMap.LatLng(latitude, longitude); const map = new TMap.Map(mapContainerRef.current, { - center: createLatLng(currentPoint.latitude, currentPoint.longitude), + center: createLatLng(currentMapPoint.latitude, currentMapPoint.longitude), zoom: 18, pitch: 0, rotation: 0, @@ -991,7 +1055,7 @@ function LocationAdjustModal({ { id: "origin", styleId: "origin", - position: createLatLng(origin.latitude, origin.longitude), + position: createLatLng(originMapPoint.latitude, originMapPoint.longitude), }, ], }); @@ -1010,7 +1074,7 @@ function LocationAdjustModal({ { id: "range", styleId: "allowed", - center: createLatLng(origin.latitude, origin.longitude), + center: createLatLng(originMapPoint.latitude, originMapPoint.longitude), radius, }, ], @@ -1024,17 +1088,18 @@ function LocationAdjustModal({ return; } - const nextPoint = { - latitude: Number(latitude.toFixed(6)), - longitude: Number(longitude.toFixed(6)), + const nextPoint = gcj02ToWgs84(latitude, longitude); + const normalizedPoint = { + 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) { setSelectionHint(`仅可在原始定位附近 ${radius} 米内微调,当前超出约 ${Math.round(distance - radius)} 米。`); } else { setSelectionHint(`当前准星距原始定位约 ${Math.round(distance)} 米。`); } - setSelectedPoint(nextPoint); + setSelectedPoint(normalizedPoint); }; map.on("moveend", syncCenterSelection); @@ -1066,7 +1131,7 @@ function LocationAdjustModal({ mapRef.current.map?.destroy?.(); mapRef.current = {}; }; - }, [currentPoint.latitude, currentPoint.longitude, origin.latitude, origin.longitude]); + }, [currentMapPoint.latitude, currentMapPoint.longitude, originMapPoint.latitude, originMapPoint.longitude, origin.latitude, origin.longitude, radius]); useEffect(() => { let cancelled = false; @@ -1156,7 +1221,7 @@ function LocationAdjustModal({ setSelectedPoint(origin); setSelectionHint("已恢复到原始定位。"); 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" @@ -2146,29 +2211,93 @@ function isReadableLocationText(value?: string) { 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) { - 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; + 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 + : {}; + + 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) { + const cacheKey = buildLocationCacheKey(latitude, longitude); const normalizedPreferredText = normalizeLocationText(preferredText); - if (isReadableLocationText(normalizedPreferredText)) { - locationDisplayCache.set(buildLocationCacheKey(latitude, longitude), normalizedPreferredText); + if (isReadableLocationText(normalizedPreferredText) && hasDetailedLocationFeature(normalizedPreferredText)) { + locationDisplayCache.set(cacheKey, normalizedPreferredText); + persistLocationCacheValue(cacheKey, normalizedPreferredText); return normalizedPreferredText; } - const cacheKey = buildLocationCacheKey(latitude, longitude); const cachedLocation = locationDisplayCache.get(cacheKey); - if (isReadableLocationText(cachedLocation)) { + if (isReadableLocationText(cachedLocation) && hasDetailedLocationFeature(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) { try { const address = await reverseWorkGeocode(latitude, longitude); if (isReadableLocationText(address)) { const normalizedAddress = normalizeLocationText(address); locationDisplayCache.set(cacheKey, normalizedAddress); + persistLocationCacheValue(cacheKey, normalizedAddress); return normalizedAddress; } } 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 ""; } diff --git a/frontend/src/pages/login.css b/frontend/src/pages/login.css index 9f9c48ae..bd6c1f6f 100644 --- a/frontend/src/pages/login.css +++ b/frontend/src/pages/login.css @@ -309,88 +309,38 @@ @media (max-width: 1024px) { .login-page-grid { - grid-template-columns: 1fr; - gap: 28px; - align-items: start; - padding-top: max(24px, env(safe-area-inset-top)); - padding-bottom: max(24px, env(safe-area-inset-bottom)); - } - - .login-page-brand { - justify-content: flex-start; - gap: 32px; - padding: 0; - } - - .login-hero-copy h2 { - font-size: clamp(2.2rem, 10vw, 3.6rem); + grid-template-columns: minmax(0, 1fr); + gap: 0; + align-items: center; + justify-items: center; + padding: + max(20px, env(safe-area-inset-top)) + 20px + max(24px, calc(20px + env(safe-area-inset-bottom))); } .login-panel { - order: -1; - justify-content: flex-start; + width: 100%; + justify-content: center; + } + + .login-page-brand { + display: none; + } + + .login-panel-card { + margin: 0 auto; } } @media (max-width: 640px) { .login-page-grid { - gap: 20px; padding: max(16px, env(safe-area-inset-top)) 16px 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 { max-width: none; padding: 20px 16px;