import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react"; 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 { flushSync } from "react-dom"; import { Link, Navigate, useLocation } from "react-router-dom"; import { getCurrentUser, getExpansionOverview, getOpportunityOverview, getProfileOverview, getWorkHistory, getWorkOverview, reverseWorkGeocode, saveWorkCheckIn, saveWorkDailyReport, uploadWorkCheckInPhoto, type ChannelExpansionItem, type CreateWorkCheckInPayload, type CreateWorkDailyReportPayload, type OpportunityItem, type ProfileOverview, type SalesExpansionItem, type UserProfile, type WorkHistoryItem, type WorkReportLineItem, type WorkTomorrowPlanItem, } from "@/lib/auth"; import { ProtectedImage } from "@/components/ProtectedImage"; import { resolveTencentMapLocation } from "@/lib/tencentMap"; import { loadTencentMapGlApi } from "@/lib/tencentMapGl"; import { cn } from "@/lib/utils"; const reportFieldLabels = { sales: ["沟通内容", "后续规划"], channel: ["沟通内容", "后续规划"], opportunity: ["项目最新进展", "后续规划"], } as const; const workSectionItems = [ { key: "checkin", label: "打卡", title: "外勤打卡", description: "定位、拍照并提交现场记录", path: "/work/checkin", accent: "bg-emerald-500", icon: MapPin, }, { key: "report", label: "日报", title: "销售日报", description: "记录跟进内容与明日计划", path: "/work/report", accent: "bg-violet-600", icon: NotebookPen, }, ] as const; const defaultCheckInForm: CreateWorkCheckInPayload = { locationText: "", remark: "", photoUrls: [], }; const defaultReportForm: CreateWorkDailyReportPayload = { workContent: "", lineItems: [], planItems: [], tomorrowPlan: "", sourceType: "manual", }; const LOCATION_ADJUST_RADIUS_METERS = 120; const CHECK_IN_ACCURACY_CONFIRM_THRESHOLD_METERS = 80; const locationDisplayCache = new Map(); type WorkRelationOption = { id: number; label: string; }; type BizType = WorkReportLineItem["bizType"]; type PickerMode = "report" | "checkin"; type WorkSection = (typeof workSectionItems)[number]["key"]; type LocationPoint = { latitude: number; longitude: number; }; type ObjectPickerState = { mode: PickerMode; lineIndex?: number; bizType: BizType; query: string; }; function createEmptyReportLine(): WorkReportLineItem { return { workDate: format(new Date(), "yyyy-MM-dd"), bizType: "sales", bizId: 0, bizName: "", editorText: "", content: "", visitStartTime: "", evaluationContent: "", nextPlan: "", latestProgress: "", communicationTime: "", communicationContent: "", }; } function createEmptyPlanItem(): WorkTomorrowPlanItem { return { content: "" }; } function getReportStatus(status?: string) { if (!status) { return "待提交"; } return status === "reviewed" || status === "已点评" ? "已点评" : "已提交"; } export default function Work() { const routerLocation = useLocation(); const currentWorkDate = format(new Date(), "yyyy-MM-dd"); const hasAutoRefreshedLocation = useRef(false); const photoInputRef = useRef(null); const historyLoadMoreRef = useRef(null); const [loading, setLoading] = useState(true); const [refreshingLocation, setRefreshingLocation] = useState(false); const [submittingCheckIn, setSubmittingCheckIn] = useState(false); const [submittingReport, setSubmittingReport] = useState(false); const [uploadingPhoto, setUploadingPhoto] = useState(false); const [mobilePanel, setMobilePanel] = useState<"entry" | "history">("entry"); const [locationHint, setLocationHint] = useState(""); const [locationAdjustOpen, setLocationAdjustOpen] = useState(false); const [locationAdjustOrigin, setLocationAdjustOrigin] = useState(null); const [locationAccuracyMeters, setLocationAccuracyMeters] = useState(); const [locationAdjustmentConfirmed, setLocationAdjustmentConfirmed] = useState(false); const [pageError, setPageError] = useState(""); const [checkInError, setCheckInError] = useState(""); const [reportError, setReportError] = useState(""); const [checkInSuccess, setCheckInSuccess] = useState(""); const [reportSuccess, setReportSuccess] = useState(""); const [historyData, setHistoryData] = useState([]); const [historyLoading, setHistoryLoading] = useState(true); const [historyLoadingMore, setHistoryLoadingMore] = useState(false); const [historyHasMore, setHistoryHasMore] = useState(false); const [historyPage, setHistoryPage] = useState(1); const [reportStatus, setReportStatus] = useState(); const [currentUser, setCurrentUser] = useState(null); const [profileOverview, setProfileOverview] = useState(null); const [checkInPhotoUrls, setCheckInPhotoUrls] = useState([]); const [salesOptions, setSalesOptions] = useState([]); const [channelOptions, setChannelOptions] = useState([]); const [opportunityOptions, setOpportunityOptions] = useState([]); const [reportTargetsLoaded, setReportTargetsLoaded] = useState(false); const [reportTargetsLoading, setReportTargetsLoading] = useState(false); const [checkInForm, setCheckInForm] = useState(defaultCheckInForm); const [reportForm, setReportForm] = useState(defaultReportForm); const [objectPicker, setObjectPicker] = useState(null); const [historyDetailItem, setHistoryDetailItem] = useState(null); const [previewPhoto, setPreviewPhoto] = useState<{ url: string; alt: string } | null>(null); const activeWorkSection = getWorkSection(routerLocation.pathname); const historySection = activeWorkSection ?? "checkin"; const activeWorkMeta = activeWorkSection ? workSectionItems.find((item) => item.key === activeWorkSection) : null; const pickerOptions = useMemo(() => { if (!objectPicker) { return []; } const options = getOptionsByBizType(objectPicker.bizType, salesOptions, channelOptions, opportunityOptions); const keyword = normalizeObjectPickerQuery(objectPicker.query).toLowerCase(); if (!keyword) { return options; } return options.filter((option) => option.label.toLowerCase().includes(keyword)); }, [objectPicker, salesOptions, channelOptions, opportunityOptions]); const loadReportTargets = useCallback(async () => { if (reportTargetsLoaded || reportTargetsLoading) { return; } setReportTargetsLoading(true); const [expansionResult, opportunityResult] = await Promise.allSettled([ getExpansionOverview(""), getOpportunityOverview(), ]); if (expansionResult.status === "fulfilled") { setSalesOptions(buildSalesOptions(expansionResult.value.salesItems ?? [])); setChannelOptions(buildChannelOptions(expansionResult.value.channelItems ?? [])); } if (opportunityResult.status === "fulfilled") { setOpportunityOptions(buildOpportunityOptions(opportunityResult.value.items ?? [])); } if (expansionResult.status === "fulfilled" && opportunityResult.status === "fulfilled") { setReportTargetsLoaded(true); } setReportTargetsLoading(false); }, [reportTargetsLoaded, reportTargetsLoading]); useEffect(() => { void loadOverview(); }, []); useEffect(() => { void loadHistory(historySection, 1, true); }, [historySection]); useEffect(() => { const sentinel = historyLoadMoreRef.current; if (!sentinel || !historyHasMore || historyLoading || historyLoadingMore) { return; } const observer = new IntersectionObserver( (entries) => { if (entries.some((entry) => entry.isIntersecting)) { void loadHistory(historySection, historyPage + 1, false); } }, { rootMargin: "120px 0px", }, ); observer.observe(sentinel); return () => observer.disconnect(); }, [historyHasMore, historyLoading, historyLoadingMore, historyPage, historySection]); useEffect(() => { if (!historyDetailItem) { return; } const nextItem = historyData.find((item) => item.id === historyDetailItem.id && item.type === historyDetailItem.type) ?? null; setHistoryDetailItem(nextItem); }, [historyData, historyDetailItem]); useEffect(() => { let cancelled = false; async function loadUserContext() { try { const [userData, overviewData] = await Promise.all([ getCurrentUser().catch(() => null), getProfileOverview().catch(() => null), ]); if (cancelled) { return; } setCurrentUser(userData); setProfileOverview(overviewData); } catch { if (!cancelled) { setCurrentUser(null); setProfileOverview(null); } } } void loadUserContext(); return () => { cancelled = true; }; }, []); useEffect(() => { if (loading || hasAutoRefreshedLocation.current) { return; } hasAutoRefreshedLocation.current = true; void handleRefreshLocation(); }, [loading]); async function loadOverview() { setLoading(true); setPageError(""); try { const data = await getWorkOverview(); setReportStatus(data.todayReport?.status); setCheckInForm({ ...defaultCheckInForm, locationText: "", remark: "", bizType: data.todayCheckIn?.bizType, bizId: data.todayCheckIn?.bizId, bizName: data.todayCheckIn?.bizName, userName: data.todayCheckIn?.userName, deptName: data.todayCheckIn?.deptName, }); setCheckInPhotoUrls([]); setLocationHint(""); setLocationAccuracyMeters(undefined); setLocationAdjustmentConfirmed(false); setReportForm({ workContent: data.todayReport?.workContent || "", lineItems: data.todayReport?.lineItems?.length ? data.todayReport.lineItems.map(normalizeLoadedLineItem) : [createEmptyReportLine()], planItems: data.todayReport?.planItems?.length ? data.todayReport.planItems.map((item) => ({ content: item.content || "" })) : [createEmptyPlanItem()], tomorrowPlan: data.todayReport?.tomorrowPlan ?? "", sourceType: data.todayReport?.sourceType ?? "manual", }); } catch (error) { setPageError(error instanceof Error ? error.message : "工作台数据加载失败"); setHistoryData([]); setReportStatus(undefined); } finally { setLoading(false); } } async function loadHistory(section: WorkSection, page = 1, replace = false) { if (replace) { setPageError(""); setHistoryData([]); setHistoryLoading(true); } else { setHistoryLoadingMore(true); } try { const data = await getWorkHistory(section, page, 8); const nextItems = data.items ?? []; setPageError(""); setHistoryData((current) => replace ? nextItems : [...current, ...nextItems]); setHistoryHasMore(Boolean(data.hasMore)); setHistoryPage(data.page ?? page); } catch (error) { if (replace) { setHistoryData([]); setPageError(error instanceof Error ? error.message : "历史记录加载失败"); } setHistoryHasMore(false); } finally { setHistoryLoading(false); setHistoryLoadingMore(false); } } const handleRefreshLocation = async () => { setCheckInError(""); setRefreshingLocation(true); if (!window.isSecureContext && location.hostname !== "localhost" && location.hostname !== "127.0.0.1") { setLocationHint("当前地址不是 HTTPS,手机浏览器会拦截定位。请使用 HTTPS 地址打开。"); setRefreshingLocation(false); return; } setLocationHint("正在获取当前位置..."); try { const position = await resolveTencentMapLocation(); const latitude = position.latitude; const longitude = position.longitude; const displayName = await resolveLocationDisplayName(latitude, longitude, position.address); setCheckInForm((prev) => ({ ...prev, locationText: displayName || formatLocationFallback(), latitude, longitude, })); setLocationAdjustOrigin({ latitude, longitude }); setLocationAccuracyMeters(position.accuracy); setLocationAdjustmentConfirmed(false); const accuracyText = position.accuracy ? `当前精度约 ${Math.round(position.accuracy)} 米` : "定位已刷新"; const sourceText = position.sourceType === "tencent" ? "腾讯定位" : "浏览器定位"; setLocationHint( position.accuracy && position.accuracy > CHECK_IN_ACCURACY_CONFIRM_THRESHOLD_METERS ? `${sourceText} ${accuracyText},当前精度超过 ${CHECK_IN_ACCURACY_CONFIRM_THRESHOLD_METERS} 米,不能直接打卡,请先重新定位或调整位置确认。` : `${sourceText} ${accuracyText},已通过腾讯地图解析当前位置;地图可在附近 ${LOCATION_ADJUST_RADIUS_METERS} 米内精调。`, ); } catch (error) { setCheckInForm((prev) => ({ ...prev, locationText: "", latitude: undefined, longitude: undefined, })); setLocationAdjustOrigin(null); setLocationAccuracyMeters(undefined); setLocationAdjustmentConfirmed(false); setLocationHint(error instanceof Error ? error.message : "定位获取失败,请开启定位权限后重试。"); } finally { setRefreshingLocation(false); } }; const handleOpenLocationAdjust = () => { if (!checkInForm.latitude || !checkInForm.longitude) { setCheckInError("请先刷新定位,再调整打卡位置。"); return; } setCheckInError(""); setLocationAdjustOpen(true); }; const handleApplyLocationAdjust = async (point: LocationPoint, resolvedAddress?: string) => { const displayName = isReadableLocationText(resolvedAddress) ? normalizeLocationText(resolvedAddress) : await resolveLocationDisplayName(point.latitude, point.longitude); const origin = locationAdjustOrigin ?? { latitude: checkInForm.latitude ?? point.latitude, longitude: checkInForm.longitude ?? point.longitude, }; const movedDistance = calculateDistanceMeters(origin, point); setCheckInForm((prev) => ({ ...prev, latitude: point.latitude, longitude: point.longitude, locationText: displayName || formatLocationFallback(), })); setLocationAdjustmentConfirmed(true); setLocationAdjustOpen(false); setLocationHint(`已调整约 ${Math.round(movedDistance)} 米,位置已确认,可继续打卡。`); }; const handlePickPhoto = () => { setCheckInError(""); if (!supportsMobileCameraCapture()) { setCheckInError("现场照片仅支持手机端直接拍照,请使用手机打开当前页面。"); return; } if (!checkInForm.locationText.trim()) { setCheckInError("请先完成定位,再进行现场拍照。"); return; } photoInputRef.current?.click(); }; const handlePhotoChange = async (event: ChangeEvent) => { const file = event.target.files?.[0]; if (!file) { return; } setCheckInError(""); setCheckInSuccess(""); setUploadingPhoto(true); try { if (!supportsMobileCameraCapture()) { throw new Error("现场照片仅支持手机端直接拍照,请使用手机打开当前页面。"); } if (!checkInForm.locationText.trim()) { throw new Error("请先完成定位,再进行现场拍照。"); } const stampedFile = await stampPhotoWithTimeAndLocation(file, checkInForm.locationText); const uploadedUrl = await uploadWorkCheckInPhoto(stampedFile); setCheckInPhotoUrls([uploadedUrl]); } catch (error) { setCheckInError(error instanceof Error ? error.message : "现场照片上传失败"); } finally { setUploadingPhoto(false); event.target.value = ""; } }; const handleRemovePhoto = () => { setCheckInPhotoUrls([]); }; const handleOpenObjectPicker = (mode: PickerMode, lineIndex?: number, bizType: BizType = "sales") => { const currentOptions = getOptionsByBizType(bizType, salesOptions, channelOptions, opportunityOptions); if (!currentOptions.length && !reportTargetsLoading) { void loadReportTargets(); } setObjectPicker({ mode, lineIndex, bizType, query: "" }); }; const handleSelectObject = (option: WorkRelationOption) => { if (!objectPicker) { return; } if (objectPicker.mode === "checkin") { setCheckInForm((current) => ({ ...current, bizType: objectPicker.bizType, bizId: option.id, bizName: option.label, userName: currentUser?.displayName || profileOverview?.realName || currentUser?.username || "", deptName: profileOverview?.deptName || "", })); setObjectPicker(null); return; } const lineIndex = objectPicker.lineIndex ?? 0; setReportForm((current) => ({ ...current, lineItems: current.lineItems.map((item, index) => { if (index !== lineIndex) { return item; } return { ...createEmptyReportLine(), bizType: objectPicker.bizType, bizId: option.id, bizName: option.label, workDate: currentWorkDate, editorText: buildEditorTemplate(objectPicker.bizType, option.label), content: "", }; }), })); setObjectPicker(null); }; const handleReportLineKeyDown = (index: number, event: KeyboardEvent) => { if (event.key !== "@") { return; } event.preventDefault(); const line = reportForm.lineItems[index]; handleOpenObjectPicker("report", index, line?.bizType || "sales"); }; const handleReportLineChange = (index: number, value: string) => { setReportForm((current) => ({ ...current, lineItems: current.lineItems.map((item, itemIndex) => { if (itemIndex !== index) { return item; } if (!item.bizId || !item.bizName) { return { ...item, editorText: "" }; } const firstLine = value.replace(/\r/g, "").split("\n")[0]?.trim() || ""; if (firstLine !== buildEditorMentionLine(item.bizType, item.bizName)) { return clearReportLineSelection(item); } return { ...item, editorText: sanitizeEditorText(item.bizType, item.bizName, value), }; }), })); }; const handleAddReportLine = () => { setReportForm((current) => ({ ...current, lineItems: [...current.lineItems, createEmptyReportLine()], })); }; const handleRemoveReportLine = (index: number) => { setReportForm((current) => ({ ...current, lineItems: current.lineItems.length === 1 ? [createEmptyReportLine()] : current.lineItems.filter((_, itemIndex) => itemIndex !== index), })); setObjectPicker((current) => ( current && current.mode === "report" && current.lineIndex === index ? null : current )); }; const handlePlanItemChange = (index: number, value: string) => { setReportForm((current) => ({ ...current, planItems: current.planItems.map((item, itemIndex) => itemIndex === index ? { ...item, content: value } : item), })); }; const handleAddPlanItem = () => { setReportForm((current) => ({ ...current, planItems: [...current.planItems, createEmptyPlanItem()], })); }; const handleRemovePlanItem = (index: number) => { setReportForm((current) => ({ ...current, planItems: current.planItems.length === 1 ? [createEmptyPlanItem()] : current.planItems.filter((_, itemIndex) => itemIndex !== index), })); }; const handleCheckInSubmit = async () => { if (submittingCheckIn) { return; } setCheckInError(""); setCheckInSuccess(""); setSubmittingCheckIn(true); try { if (!checkInForm.latitude || !checkInForm.longitude || !checkInForm.locationText.trim()) { throw new Error("请先完成定位后再提交打卡"); } if ( locationAccuracyMeters && locationAccuracyMeters > CHECK_IN_ACCURACY_CONFIRM_THRESHOLD_METERS && !locationAdjustmentConfirmed ) { throw new Error(`当前定位精度约 ${Math.round(locationAccuracyMeters)} 米,超过 ${CHECK_IN_ACCURACY_CONFIRM_THRESHOLD_METERS} 米;请先重新定位或完成位置确认后再打卡`); } if (!checkInForm.bizType || !checkInForm.bizId) { throw new Error("请先关联本次打卡对象"); } if (!checkInPhotoUrls.length) { throw new Error("请先上传现场照片"); } await saveWorkCheckIn({ locationText: checkInForm.locationText.trim(), remark: checkInForm.remark?.trim() || undefined, longitude: checkInForm.longitude, latitude: checkInForm.latitude, photoUrls: checkInPhotoUrls, bizType: checkInForm.bizType, bizId: checkInForm.bizId, bizName: checkInForm.bizName, userName: currentUser?.displayName || profileOverview?.realName || currentUser?.username || "", deptName: profileOverview?.deptName || "", }); await Promise.all([ loadOverview(), loadHistory(historySection, 1, true), ]); setCheckInPhotoUrls([]); setCheckInSuccess("打卡已记录。"); } catch (error) { setCheckInError(error instanceof Error ? error.message : "打卡提交失败"); } finally { setSubmittingCheckIn(false); } }; const handleReportSubmit = async () => { if (submittingReport) { return; } setReportError(""); setReportSuccess(""); setSubmittingReport(true); try { const normalizedLineItems = reportForm.lineItems.map((item) => normalizeReportLineItem(item, currentWorkDate)); const normalizedPlanItems = reportForm.planItems .map((item) => ({ content: item.content.trim() })) .filter((item) => item.content); if (!normalizedLineItems.length) { throw new Error("请至少填写一条今日工作内容"); } validateReportLineItems(normalizedLineItems); if (!normalizedPlanItems.length) { throw new Error("请至少填写一条明日工作计划"); } await saveWorkDailyReport({ workContent: buildReportSummary(normalizedLineItems), lineItems: normalizedLineItems, planItems: normalizedPlanItems, tomorrowPlan: buildPlanSummary(normalizedPlanItems), sourceType: reportForm.sourceType || "manual", }); await Promise.all([ loadOverview(), loadHistory(historySection, 1, true), ]); setReportSuccess("日报已保存,今日再次提交会覆盖当天内容。"); } catch (error) { setReportError(error instanceof Error ? error.message : "日报提交失败"); } finally { setSubmittingReport(false); } }; if (!activeWorkSection || !activeWorkMeta) { return ; } return (

销售工作台

今天是 {format(new Date(), "yyyy年MM月dd日 EEEE", { locale: zhCN })}

{loading ? ( ) : activeWorkSection === "checkin" ? ( handleOpenObjectPicker("checkin", undefined, checkInForm.bizType || "sales")} onRefreshLocation={() => void handleRefreshLocation()} onOpenLocationAdjust={handleOpenLocationAdjust} locationAccuracyMeters={locationAccuracyMeters} locationAdjustmentConfirmed={locationAdjustmentConfirmed} requiresLocationConfirmation={Boolean( locationAccuracyMeters && locationAccuracyMeters > CHECK_IN_ACCURACY_CONFIRM_THRESHOLD_METERS && !locationAdjustmentConfirmed, )} locationHint={locationHint} onRemarkChange={(remark) => setCheckInForm((prev) => ({ ...prev, remark }))} photoInputRef={photoInputRef} onPhotoChange={(event) => void handlePhotoChange(event)} onPickPhoto={handlePickPhoto} uploadingPhoto={uploadingPhoto} checkInPhotoUrls={checkInPhotoUrls} onRemovePhoto={handleRemovePhoto} checkInError={checkInError} checkInSuccess={checkInSuccess} pageError={pageError} submittingCheckIn={submittingCheckIn} onSubmit={() => void handleCheckInSubmit()} /> ) : ( void handleReportSubmit()} /> )}
{historyLoading && historyData.length === 0 ? : null} {historyData.map((item, index) => (
setHistoryDetailItem(item)} onPreviewPhoto={(url, alt) => setPreviewPhoto({ url, alt })} />
))} {!historyLoading && historyData.length === 0 ? (
当前没有{getHistoryLabelBySection(historySection)}记录
) : null} {historyHasMore ? (
{historyLoadingMore ? "加载更多记录中..." : "下拉到底自动加载更多"}
) : null}
{objectPicker ? (
{(["sales", "channel", "opportunity"] as BizType[]).map((bizType) => ( ))}
setObjectPicker((current) => current ? { ...current, query: normalizeObjectPickerQuery(event.target.value) } : current)} placeholder={`搜索${getBizTypeLabel(objectPicker.bizType)}名称`} className="crm-input-text min-h-11 w-full rounded-xl border border-slate-200 bg-white py-2.5 pl-10 pr-3 text-base text-slate-900 outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50 dark:text-white md:text-sm" />
{reportTargetsLoading && !pickerOptions.length ? (
正在加载可选对象...
) : pickerOptions.length ? (
{pickerOptions.map((option) => ( ))}
) : (
没有找到匹配对象,请换个关键词试试
)}
) : null} {locationAdjustOpen && checkInForm.latitude && checkInForm.longitude ? ( setLocationAdjustOpen(false)} onConfirm={(point, address) => void handleApplyLocationAdjust(point, address)} /> ) : null} {historyDetailItem ? ( setHistoryDetailItem(null)} onPreviewPhoto={(url, alt) => setPreviewPhoto({ url, alt })} /> ) : null} {previewPhoto ? ( setPreviewPhoto(null)} /> ) : null} ); } function LocationAdjustModal({ origin, currentPoint, radius, currentAddress, onClose, onConfirm, }: { origin: LocationPoint; currentPoint: LocationPoint; radius: number; currentAddress: string; onClose: () => void; onConfirm: (point: LocationPoint, address: string) => void; }) { const mapContainerRef = useRef(null); const mapRef = useRef<{ map?: { on: (event: string, handler: (event: { latLng?: { getLat?: () => number; getLng?: () => number; lat?: number; lng?: number } }) => void) => void; off?: (event: string, handler: (event: { latLng?: { getLat?: () => number; getLng?: () => number; lat?: number; lng?: number } }) => void) => void; setCenter?: (latLng: unknown) => void; panTo?: (latLng: unknown) => void; getCenter?: () => { getLat?: () => number; getLng?: () => number; lat?: number; lng?: number }; destroy?: () => void; }; originMarker?: { setGeometries: (geometries: unknown[]) => void }; createLatLng?: (latitude: number, longitude: number) => unknown; moveHandler?: () => void; }>({}); const [selectedPoint, setSelectedPoint] = useState(currentPoint); const [selectedAddress, setSelectedAddress] = useState(currentAddress); const [mapLoading, setMapLoading] = useState(true); const [mapError, setMapError] = useState(""); const [selectionHint, setSelectionHint] = useState(""); const [resolvingAddress, setResolvingAddress] = useState(false); const [confirming, setConfirming] = useState(false); useEffect(() => { let cancelled = false; async function initMap() { if (!mapContainerRef.current) { return; } setMapLoading(true); setMapError(""); try { const TMap = await loadTencentMapGlApi(); if (cancelled || !mapContainerRef.current) { return; } const createLatLng = (latitude: number, longitude: number) => new TMap.LatLng(latitude, longitude); const map = new TMap.Map(mapContainerRef.current, { center: createLatLng(currentPoint.latitude, currentPoint.longitude), zoom: 18, pitch: 0, rotation: 0, mapStyleId: "style1", }); const originMarker = new TMap.MultiMarker({ map, styles: { origin: new TMap.MarkerStyle({ width: 18, height: 18, anchor: { x: 9, y: 9 }, src: "data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 18 18'%3e%3ccircle cx='9' cy='9' r='7' fill='%23ffffff' fill-opacity='0.95' stroke='%237c3aed' stroke-width='2'/%3e%3ccircle cx='9' cy='9' r='2.5' fill='%237c3aed'/%3e%3c/svg%3e", }), }, geometries: [ { id: "origin", styleId: "origin", position: createLatLng(origin.latitude, origin.longitude), }, ], }); new TMap.MultiCircle({ map, styles: { allowed: new TMap.CircleStyle({ color: "rgba(124, 58, 237, 0.14)", showBorder: true, borderColor: "rgba(124, 58, 237, 0.55)", borderWidth: 2, }), }, geometries: [ { id: "range", styleId: "allowed", center: createLatLng(origin.latitude, origin.longitude), radius, }, ], }); const syncCenterSelection = () => { const center = map.getCenter?.(); const latitude = center?.getLat?.() ?? center?.lat; const longitude = center?.getLng?.() ?? center?.lng; if (typeof latitude !== "number" || typeof longitude !== "number") { return; } const nextPoint = { latitude: Number(latitude.toFixed(6)), longitude: Number(longitude.toFixed(6)), }; const distance = calculateDistanceMeters(origin, nextPoint); if (distance > radius) { setSelectionHint(`仅可在原始定位附近 ${radius} 米内微调,当前超出约 ${Math.round(distance - radius)} 米。`); } else { setSelectionHint(`当前准星距原始定位约 ${Math.round(distance)} 米。`); } setSelectedPoint(nextPoint); }; map.on("moveend", syncCenterSelection); map.on("dragend", syncCenterSelection); syncCenterSelection(); mapRef.current = { map, originMarker, createLatLng, moveHandler: syncCenterSelection, }; } catch (error) { if (!cancelled) { setMapError(error instanceof Error ? error.message : "地图加载失败,请稍后重试。"); } } finally { if (!cancelled) { setMapLoading(false); } } } void initMap(); return () => { cancelled = true; mapRef.current.map?.off?.("moveend", mapRef.current.moveHandler ?? (() => undefined)); mapRef.current.map?.off?.("dragend", mapRef.current.moveHandler ?? (() => undefined)); mapRef.current.map?.destroy?.(); mapRef.current = {}; }; }, [currentPoint.latitude, currentPoint.longitude, origin.latitude, origin.longitude]); useEffect(() => { let cancelled = false; const timer = window.setTimeout(async () => { setResolvingAddress(true); try { const address = await resolveLocationDisplayName(selectedPoint.latitude, selectedPoint.longitude); if (!cancelled) { setSelectedAddress(address || formatLocationFallback()); } } catch { if (!cancelled) { setSelectedAddress(formatLocationFallback()); } } finally { if (!cancelled) { setResolvingAddress(false); } } }, 350); return () => { cancelled = true; window.clearTimeout(timer); }; }, [selectedPoint.latitude, selectedPoint.longitude]); const movedDistance = Math.round(calculateDistanceMeters(origin, selectedPoint)); return (

原始定位:{currentAddress || formatLocationFallback()}

当前位置:距原点约 {movedDistance} 米

{!mapLoading ? (
) : null} {mapLoading ?
地图加载中...
: null}
{mapError ?

{mapError}

: null} {selectionHint ?

{selectionHint}

: null}

调整后地址

{resolvingAddress ? "正在解析地址..." : selectedAddress}

); } function WorkSectionNav({ activeWorkSection, }: { activeWorkSection: WorkSection; }) { return (
{workSectionItems.map((item) => { const isActive = item.key === activeWorkSection; return ( {item.label} ); })}
); } function MobilePanelToggle({ mobilePanel, onChange, }: { mobilePanel: "entry" | "history"; onChange: (panel: "entry" | "history") => void; }) { return (
); } function WorkEntrySkeleton({ section, }: { section: WorkSection; }) { return (
); } function WorkHistorySkeleton() { return (
{[0, 1, 2].map((item) => (
))}
); } function HistoryCard({ item, index, onOpen, onPreviewPhoto, }: { item: WorkHistoryItem; index: number; onOpen: () => void; onPreviewPhoto: (url: string, alt: string) => void; }) { return ( { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onOpen(); } }} className="crm-card crm-card-pad cursor-pointer rounded-2xl transition-all hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50" >
{item.type === "日报" ? : }

{item.type}

{[item.date, item.time].filter(Boolean).join(" ")}

{item.status} {item.score ? {item.score}分 : null}

{item.content}

{item.photoUrls?.length ? (
{item.photoUrls.map((photoUrl, photoIndex) => ( ))}
) : null} {item.comment ? (

主管点评:

{item.comment}

) : null}

点击查看详情

); } function CheckInPanel({ loading, checkInForm, refreshingLocation, onOpenObjectPicker, onRefreshLocation, onOpenLocationAdjust, locationAccuracyMeters, locationAdjustmentConfirmed, requiresLocationConfirmation, locationHint, onRemarkChange, photoInputRef, onPhotoChange, onPickPhoto, uploadingPhoto, checkInPhotoUrls, onRemovePhoto, checkInError, checkInSuccess, pageError, submittingCheckIn, onSubmit, }: { loading: boolean; checkInForm: CreateWorkCheckInPayload; refreshingLocation: boolean; onOpenObjectPicker: () => void; onRefreshLocation: () => void; onOpenLocationAdjust: () => void; locationAccuracyMeters?: number; locationAdjustmentConfirmed: boolean; requiresLocationConfirmation: boolean; locationHint: string; onRemarkChange: (remark: string) => void; photoInputRef: { current: HTMLInputElement | null }; onPhotoChange: (event: ChangeEvent) => void; onPickPhoto: () => void; uploadingPhoto: boolean; checkInPhotoUrls: string[]; onRemovePhoto: () => void; checkInError: string; checkInSuccess: string; pageError: string; submittingCheckIn: boolean; onSubmit: () => void; }) { const mobileCameraOnly = supportsMobileCameraCapture(); return (

外勤打卡

关联对象

当前位置