@@ -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;