main
kangwenjing 2026-03-27 09:16:04 +08:00
parent 0ca5070755
commit 6fee91ed80
5 changed files with 224 additions and 101 deletions

View File

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

View File

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

View File

@ -107,17 +107,29 @@ export default function Dashboard() {
</header>
{loading ? (
<DashboardSkeleton />
<motion.div
key="dashboard-skeleton"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.22, ease: "easeOut" }}
>
<DashboardSkeleton />
</motion.div>
) : (
<>
<div className="grid grid-cols-2 gap-2.5 sm:gap-4 xl:grid-cols-4">
<motion.div
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) => (
<motion.div
key={stat.name}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
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 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 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
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
@ -263,7 +275,7 @@ export default function Dashboard() {
) : null}
</motion.div>
</div>
</>
</motion.div>
)}
</div>
);
@ -271,12 +283,12 @@ export default function Dashboard() {
function DashboardSkeleton() {
return (
<>
<div className="grid grid-cols-2 gap-2.5 sm:gap-4 xl:grid-cols-4">
<div className="animate-[fadeIn_220ms_ease-out]">
<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) => (
<div
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="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 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="mb-4 flex items-center justify-between">
<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>
);
}

View File

@ -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<string, string>();
type WorkRelationOption = {
@ -131,6 +133,8 @@ export default function Work() {
const hasAutoRefreshedLocation = useRef(false);
const photoInputRef = useRef<HTMLInputElement | 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 [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<string>();
const [currentUser, setCurrentUser] = useState<UserProfile | null>(null);
const [profileOverview, setProfileOverview] = useState<ProfileOverview | null>(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() {
<MobilePanelToggle mobilePanel={mobilePanel} onChange={setMobilePanel} />
</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}
{historyData.map((item, index) => (
@ -798,6 +843,23 @@ export default function Work() {
{historyLoadingMore ? "加载更多记录中..." : "下拉到底自动加载更多"}
</div>
) : 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>
@ -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<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) {
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 "";
}

View File

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