2525 lines
99 KiB
TypeScript
2525 lines
99 KiB
TypeScript
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<string, string>();
|
||
|
||
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<HTMLInputElement | null>(null);
|
||
const historyLoadMoreRef = useRef<HTMLDivElement | null>(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<LocationPoint | null>(null);
|
||
const [locationAccuracyMeters, setLocationAccuracyMeters] = useState<number | undefined>();
|
||
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<WorkHistoryItem[]>([]);
|
||
const [historyLoading, setHistoryLoading] = useState(true);
|
||
const [historyLoadingMore, setHistoryLoadingMore] = useState(false);
|
||
const [historyHasMore, setHistoryHasMore] = useState(false);
|
||
const [historyPage, setHistoryPage] = useState(1);
|
||
const [reportStatus, setReportStatus] = useState<string>();
|
||
const [currentUser, setCurrentUser] = useState<UserProfile | null>(null);
|
||
const [profileOverview, setProfileOverview] = useState<ProfileOverview | null>(null);
|
||
const [checkInPhotoUrls, setCheckInPhotoUrls] = useState<string[]>([]);
|
||
const [salesOptions, setSalesOptions] = useState<WorkRelationOption[]>([]);
|
||
const [channelOptions, setChannelOptions] = useState<WorkRelationOption[]>([]);
|
||
const [opportunityOptions, setOpportunityOptions] = useState<WorkRelationOption[]>([]);
|
||
const [reportTargetsLoaded, setReportTargetsLoaded] = useState(false);
|
||
const [reportTargetsLoading, setReportTargetsLoading] = useState(false);
|
||
const [checkInForm, setCheckInForm] = useState<CreateWorkCheckInPayload>(defaultCheckInForm);
|
||
const [reportForm, setReportForm] = useState<CreateWorkDailyReportPayload>(defaultReportForm);
|
||
const [objectPicker, setObjectPicker] = useState<ObjectPickerState | null>(null);
|
||
const [historyDetailItem, setHistoryDetailItem] = useState<WorkHistoryItem | null>(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<HTMLInputElement>) => {
|
||
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<HTMLTextAreaElement>) => {
|
||
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 <Navigate to="/work/checkin" replace />;
|
||
}
|
||
|
||
return (
|
||
<div className="crm-page-stack">
|
||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||
<div>
|
||
<h1 className="text-xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-2xl">销售工作台</h1>
|
||
<p className="mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">今天是 {format(new Date(), "yyyy年MM月dd日 EEEE", { locale: zhCN })}</p>
|
||
</div>
|
||
</header>
|
||
|
||
<WorkSectionNav activeWorkSection={activeWorkSection} />
|
||
|
||
<div className="grid grid-cols-1 items-start gap-5 lg:grid-cols-12 lg:gap-6">
|
||
<div className={`min-w-0 crm-page-stack lg:col-span-7 xl:col-span-8 ${mobilePanel === "entry" ? "block lg:flex" : "hidden lg:flex"}`}>
|
||
<div className="mb-2 flex items-center justify-between gap-3">
|
||
<SectionTitle title={activeWorkMeta.title} accent={activeWorkMeta.accent} compact />
|
||
<MobilePanelToggle mobilePanel={mobilePanel} onChange={setMobilePanel} />
|
||
</div>
|
||
|
||
{loading ? (
|
||
<WorkEntrySkeleton section={activeWorkSection} />
|
||
) : activeWorkSection === "checkin" ? (
|
||
<CheckInPanel
|
||
loading={loading}
|
||
checkInForm={checkInForm}
|
||
refreshingLocation={refreshingLocation}
|
||
onOpenObjectPicker={() => 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()}
|
||
/>
|
||
) : (
|
||
<ReportPanel
|
||
loading={loading}
|
||
reportStatus={reportStatus}
|
||
currentWorkDate={currentWorkDate}
|
||
reportForm={reportForm}
|
||
onAddReportLine={handleAddReportLine}
|
||
onRemoveReportLine={handleRemoveReportLine}
|
||
onOpenObjectPicker={handleOpenObjectPicker}
|
||
onReportLineKeyDown={handleReportLineKeyDown}
|
||
onReportLineChange={handleReportLineChange}
|
||
onAddPlanItem={handleAddPlanItem}
|
||
onPlanItemChange={handlePlanItemChange}
|
||
onRemovePlanItem={handleRemovePlanItem}
|
||
reportError={reportError}
|
||
reportSuccess={reportSuccess}
|
||
pageError={pageError}
|
||
submittingReport={submittingReport}
|
||
onSubmit={() => void handleReportSubmit()}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
<div className={`min-w-0 crm-page-stack lg:col-span-5 lg:sticky lg:top-6 xl:col-span-4 ${mobilePanel === "history" ? "block lg:flex" : "hidden lg:flex"}`}>
|
||
<div className="mb-2 flex items-center justify-between gap-3">
|
||
<SectionTitle title="历史记录" accent="bg-slate-300 dark:bg-slate-700" compact />
|
||
<MobilePanelToggle mobilePanel={mobilePanel} onChange={setMobilePanel} />
|
||
</div>
|
||
|
||
<div className="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) => (
|
||
<div key={`${item.type}-${item.id}-${index}`}>
|
||
<HistoryCard
|
||
item={item}
|
||
index={index}
|
||
onOpen={() => setHistoryDetailItem(item)}
|
||
onPreviewPhoto={(url, alt) => setPreviewPhoto({ url, alt })}
|
||
/>
|
||
</div>
|
||
))}
|
||
|
||
{!historyLoading && historyData.length === 0 ? (
|
||
<div className="crm-empty-state rounded-2xl border border-slate-100 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900/50">
|
||
当前没有{getHistoryLabelBySection(historySection)}记录
|
||
</div>
|
||
) : null}
|
||
|
||
{historyHasMore ? (
|
||
<div
|
||
ref={historyLoadMoreRef}
|
||
className="flex items-center justify-center rounded-2xl border border-dashed border-slate-200 bg-white px-3 py-3 text-sm text-slate-500 dark:border-slate-800 dark:bg-slate-900/50 dark:text-slate-400"
|
||
>
|
||
{historyLoadingMore ? "加载更多记录中..." : "下拉到底自动加载更多"}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{objectPicker ? (
|
||
<div className="fixed inset-0 z-[90]">
|
||
<button
|
||
type="button"
|
||
onClick={() => setObjectPicker(null)}
|
||
className="absolute inset-0 bg-slate-900/35 backdrop-blur-sm"
|
||
aria-label="关闭选择对象"
|
||
/>
|
||
<div className="absolute inset-x-0 bottom-0 max-h-[82dvh] overflow-hidden rounded-t-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900 md:inset-x-auto md:left-1/2 md:top-1/2 md:bottom-auto md:w-[min(720px,88vw)] md:max-h-[72vh] md:-translate-x-1/2 md:-translate-y-1/2 md:rounded-3xl">
|
||
<div className="border-b border-slate-100 px-5 py-4 dark:border-slate-800 md:px-6">
|
||
<div className="mb-3 flex items-center justify-between">
|
||
<div>
|
||
<h3 className="text-base font-semibold text-slate-900 dark:text-white">选择关联对象</h3>
|
||
<p className="crm-field-note mt-1">先选类型,再搜索具体对象</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setObjectPicker(null)}
|
||
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="mb-3 flex flex-wrap gap-2">
|
||
{(["sales", "channel", "opportunity"] as BizType[]).map((bizType) => (
|
||
<button
|
||
key={bizType}
|
||
type="button"
|
||
onClick={() => setObjectPicker((current) => current ? { ...current, bizType, query: "" } : current)}
|
||
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-colors ${
|
||
objectPicker.bizType === bizType
|
||
? "bg-violet-600 text-white"
|
||
: "bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
|
||
}`}
|
||
>
|
||
{getBizTypeLabel(bizType)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="relative">
|
||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||
<input
|
||
autoFocus
|
||
value={objectPicker.query}
|
||
onChange={(event) => 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"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="max-h-[calc(82dvh-180px)] overflow-y-auto px-3 py-3 pb-[calc(env(safe-area-inset-bottom)+16px)] md:max-h-[calc(72vh-180px)] md:px-4 md:py-4">
|
||
{reportTargetsLoading && !pickerOptions.length ? (
|
||
<div className="crm-empty-state rounded-2xl border border-dashed border-slate-200 px-4 py-8 dark:border-slate-800">
|
||
正在加载可选对象...
|
||
</div>
|
||
) : pickerOptions.length ? (
|
||
<div className="space-y-2">
|
||
{pickerOptions.map((option) => (
|
||
<button
|
||
key={`${objectPicker.bizType}-${option.id}`}
|
||
type="button"
|
||
onClick={() => handleSelectObject(option)}
|
||
className="flex w-full items-center justify-between rounded-2xl border border-slate-200 bg-white px-4 py-3 text-left transition-colors hover:border-violet-200 hover:bg-violet-50/70 dark:border-slate-800 dark:bg-slate-900/40 dark:hover:border-violet-500/30 dark:hover:bg-slate-800"
|
||
>
|
||
<span className="text-sm font-medium text-slate-900 dark:text-white">{option.label}</span>
|
||
<span className="text-xs text-slate-400">选择</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="crm-empty-state rounded-2xl border border-dashed border-slate-200 px-4 py-8 dark:border-slate-800">
|
||
没有找到匹配对象,请换个关键词试试
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{locationAdjustOpen && checkInForm.latitude && checkInForm.longitude ? (
|
||
<LocationAdjustModal
|
||
origin={locationAdjustOrigin ?? { latitude: checkInForm.latitude, longitude: checkInForm.longitude }}
|
||
currentPoint={{ latitude: checkInForm.latitude, longitude: checkInForm.longitude }}
|
||
radius={LOCATION_ADJUST_RADIUS_METERS}
|
||
currentAddress={checkInForm.locationText}
|
||
onClose={() => setLocationAdjustOpen(false)}
|
||
onConfirm={(point, address) => void handleApplyLocationAdjust(point, address)}
|
||
/>
|
||
) : null}
|
||
|
||
{historyDetailItem ? (
|
||
<HistoryDetailModal
|
||
item={historyDetailItem}
|
||
onClose={() => setHistoryDetailItem(null)}
|
||
onPreviewPhoto={(url, alt) => setPreviewPhoto({ url, alt })}
|
||
/>
|
||
) : null}
|
||
|
||
{previewPhoto ? (
|
||
<PhotoPreviewModal
|
||
url={previewPhoto.url}
|
||
alt={previewPhoto.alt}
|
||
onClose={() => setPreviewPhoto(null)}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<HTMLDivElement | null>(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<LocationPoint>(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 (
|
||
<div className="fixed inset-0 z-[95]">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||
aria-label="关闭调整位置"
|
||
/>
|
||
<div className="absolute inset-x-0 bottom-0 flex max-h-[92dvh] flex-col overflow-hidden rounded-t-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900 md:inset-x-auto md:left-1/2 md:top-1/2 md:bottom-auto md:w-[min(860px,92vw)] md:max-h-[84vh] md:-translate-x-1/2 md:-translate-y-1/2 md:rounded-3xl">
|
||
<div className="shrink-0 border-b border-slate-100 px-5 py-4 dark:border-slate-800 md:px-6">
|
||
<div className="mb-3 flex items-center justify-between">
|
||
<div>
|
||
<h3 className="text-base font-semibold text-slate-900 dark:text-white">调整打卡位置</h3>
|
||
<p className="crm-field-note mt-1">拖动地图,让中间准星对准目标位置;仅允许在当前定位附近 {radius} 米范围内精调。</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
<div className="rounded-2xl bg-slate-50 px-4 py-3 text-xs text-slate-500 dark:bg-slate-800/70 dark:text-slate-400">
|
||
<p>原始定位:{currentAddress || formatLocationFallback()}</p>
|
||
<p className="mt-1">当前位置:距原点约 {movedDistance} 米</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-4 md:px-6">
|
||
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-slate-100 dark:border-slate-800 dark:bg-slate-950">
|
||
<div className="relative h-[260px] w-full md:h-[420px]">
|
||
<div ref={mapContainerRef} className="h-full w-full" />
|
||
{!mapLoading ? (
|
||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||
<div className="relative h-12 w-12">
|
||
<div className="absolute inset-0 rounded-full border-2 border-violet-500/65 bg-violet-500/10 shadow-[0_0_0_6px_rgba(139,92,246,0.12)]" />
|
||
<div className="absolute left-1/2 top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white bg-violet-600 shadow-lg" />
|
||
<div className="absolute left-1/2 top-1/2 h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white" />
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
{mapLoading ? <div className="absolute inset-0 flex items-center justify-center text-sm text-slate-500">地图加载中...</div> : null}
|
||
</div>
|
||
</div>
|
||
{mapError ? <p className="text-sm text-rose-500">{mapError}</p> : null}
|
||
{selectionHint ? <p className="text-sm text-violet-600 dark:text-violet-300">{selectionHint}</p> : null}
|
||
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-800 dark:bg-slate-900/60">
|
||
<p className="crm-field-note">调整后地址</p>
|
||
<p className="mt-1 text-sm text-slate-900 dark:text-white">{resolvingAddress ? "正在解析地址..." : selectedAddress}</p>
|
||
</div>
|
||
</div>
|
||
<div className="shrink-0 border-t border-slate-100 bg-white px-4 py-4 pb-[calc(env(safe-area-inset-bottom)+16px)] dark:border-slate-800 dark:bg-slate-900 md:px-6">
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setSelectedPoint(origin);
|
||
setSelectionHint("已恢复到原始定位。");
|
||
if (mapRef.current.createLatLng) {
|
||
mapRef.current.map?.panTo?.(mapRef.current.createLatLng(origin.latitude, origin.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"
|
||
>
|
||
恢复原点
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={async () => {
|
||
setConfirming(true);
|
||
try {
|
||
await onConfirm(selectedPoint, selectedAddress);
|
||
} finally {
|
||
setConfirming(false);
|
||
}
|
||
}}
|
||
disabled={confirming || resolvingAddress || movedDistance > radius || Boolean(mapError)}
|
||
className="crm-btn min-w-0 flex-1 rounded-xl bg-violet-600 text-white transition-colors hover:bg-violet-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||
>
|
||
{confirming ? "保存中..." : resolvingAddress ? "解析中..." : "确认使用此位置"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function WorkSectionNav({
|
||
activeWorkSection,
|
||
}: {
|
||
activeWorkSection: WorkSection;
|
||
}) {
|
||
return (
|
||
<div className="flex rounded-xl border border-slate-200/50 bg-slate-100 p-1 backdrop-blur-sm dark:border-slate-800/50 dark:bg-slate-900/50">
|
||
{workSectionItems.map((item) => {
|
||
const isActive = item.key === activeWorkSection;
|
||
return (
|
||
<Link
|
||
key={item.path}
|
||
to={item.path}
|
||
className={cn(
|
||
"flex-1 rounded-lg px-4 py-2 text-center text-sm font-medium transition-all duration-200",
|
||
isActive
|
||
? "bg-white text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400"
|
||
: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
|
||
)}
|
||
>
|
||
{item.label}
|
||
</Link>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MobilePanelToggle({
|
||
mobilePanel,
|
||
onChange,
|
||
}: {
|
||
mobilePanel: "entry" | "history";
|
||
onChange: (panel: "entry" | "history") => void;
|
||
}) {
|
||
return (
|
||
<div className="flex rounded-xl border border-slate-200/70 bg-slate-100 p-1 dark:border-slate-800/70 dark:bg-slate-900/50 lg:hidden">
|
||
<button
|
||
type="button"
|
||
onClick={() => onChange("entry")}
|
||
className={cn(
|
||
"rounded-lg px-4 py-1.5 text-sm font-medium transition-all duration-200",
|
||
mobilePanel === "entry"
|
||
? "bg-white text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400"
|
||
: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
|
||
)}
|
||
>
|
||
填写
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => onChange("history")}
|
||
className={cn(
|
||
"rounded-lg px-4 py-1.5 text-sm font-medium transition-all duration-200",
|
||
mobilePanel === "history"
|
||
? "bg-white text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400"
|
||
: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
|
||
)}
|
||
>
|
||
历史
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function WorkEntrySkeleton({
|
||
section,
|
||
}: {
|
||
section: WorkSection;
|
||
}) {
|
||
return (
|
||
<div className="crm-card crm-card-pad rounded-2xl">
|
||
<div className="mb-4 flex items-center justify-between border-b border-slate-100 pb-4 dark:border-slate-800/60">
|
||
<div className="flex items-center gap-2">
|
||
<div className={cn("h-5 w-5 rounded-full", section === "checkin" ? "bg-emerald-100" : "bg-violet-100")} />
|
||
<div className="h-5 w-24 animate-pulse rounded bg-slate-100 dark:bg-slate-800" />
|
||
</div>
|
||
<div className="h-6 w-14 animate-pulse rounded-full bg-slate-100 dark:bg-slate-800" />
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<div className="rounded-2xl border border-slate-200 bg-slate-50/70 p-4 dark:border-slate-800 dark:bg-slate-900/40">
|
||
<div className="h-4 w-20 animate-pulse rounded bg-slate-100 dark:bg-slate-800" />
|
||
<div className="mt-3 h-14 animate-pulse rounded-xl bg-slate-100 dark:bg-slate-800" />
|
||
</div>
|
||
<div className="rounded-2xl border border-slate-200 bg-slate-50/70 p-4 dark:border-slate-800 dark:bg-slate-900/40">
|
||
<div className="mb-3 flex items-center justify-between gap-3">
|
||
<div className="h-4 w-16 animate-pulse rounded bg-slate-100 dark:bg-slate-800" />
|
||
<div className="flex gap-2">
|
||
<div className="h-8 w-20 animate-pulse rounded-full bg-slate-100 dark:bg-slate-800" />
|
||
<div className="h-8 w-16 animate-pulse rounded-full bg-slate-100 dark:bg-slate-800" />
|
||
</div>
|
||
</div>
|
||
<div className="h-24 animate-pulse rounded-2xl bg-slate-100 dark:bg-slate-800" />
|
||
</div>
|
||
<div className="h-11 animate-pulse rounded-xl bg-slate-100 dark:bg-slate-800" />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function WorkHistorySkeleton() {
|
||
return (
|
||
<div className="space-y-4">
|
||
{[0, 1, 2].map((item) => (
|
||
<div
|
||
key={`history-skeleton-${item}`}
|
||
className="crm-card crm-card-pad rounded-2xl"
|
||
>
|
||
<div className="mb-3 flex items-start justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<div className="h-8 w-8 animate-pulse rounded-full bg-slate-100 dark:bg-slate-800" />
|
||
<div className="space-y-2">
|
||
<div className="h-4 w-20 animate-pulse rounded bg-slate-100 dark:bg-slate-800" />
|
||
<div className="h-3 w-24 animate-pulse rounded bg-slate-100 dark:bg-slate-800" />
|
||
</div>
|
||
</div>
|
||
<div className="h-5 w-12 animate-pulse rounded-full bg-slate-100 dark:bg-slate-800" />
|
||
</div>
|
||
<div className="pl-11">
|
||
<div className="space-y-2">
|
||
<div className="h-3 animate-pulse rounded bg-slate-100 dark:bg-slate-800" />
|
||
<div className="h-3 w-11/12 animate-pulse rounded bg-slate-100 dark:bg-slate-800" />
|
||
<div className="h-3 w-3/4 animate-pulse rounded bg-slate-100 dark:bg-slate-800" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function HistoryCard({
|
||
item,
|
||
index,
|
||
onOpen,
|
||
onPreviewPhoto,
|
||
}: {
|
||
item: WorkHistoryItem;
|
||
index: number;
|
||
onOpen: () => void;
|
||
onPreviewPhoto: (url: string, alt: string) => void;
|
||
}) {
|
||
return (
|
||
<motion.div
|
||
initial={{ opacity: 0, x: 20 }}
|
||
animate={{ opacity: 1, x: 0 }}
|
||
transition={{ delay: index * 0.06 }}
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={onOpen}
|
||
onKeyDown={(event) => {
|
||
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"
|
||
>
|
||
<div className="mb-3 flex items-start justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<div className={`flex h-8 w-8 items-center justify-center rounded-full ${item.type === "日报" ? "bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400" : "bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400"}`}>
|
||
{item.type === "日报" ? <FileText className="h-4 w-4" /> : <MapPin className="h-4 w-4" />}
|
||
</div>
|
||
<div>
|
||
<h3 className="text-sm font-semibold text-slate-900 dark:text-white">{item.type}</h3>
|
||
<p className="text-[10px] text-slate-500 dark:text-slate-400">{[item.date, item.time].filter(Boolean).join(" ")}</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-col items-end gap-1">
|
||
<span className={`crm-pill px-2 py-0.5 text-[10px] ${
|
||
item.status === "已点评"
|
||
? "crm-pill-violet"
|
||
: item.status === "已阅" || item.status === "已提交"
|
||
? "crm-pill-neutral"
|
||
: "crm-pill-emerald"
|
||
}`}
|
||
>
|
||
{item.status}
|
||
</span>
|
||
{item.score ? <span className="text-[10px] font-bold text-rose-600 dark:text-rose-400">{item.score}分</span> : null}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="pl-11">
|
||
<p className="whitespace-pre-line text-xs leading-relaxed text-slate-700 dark:text-slate-300">{item.content}</p>
|
||
{item.photoUrls?.length ? (
|
||
<div className="mt-3 flex gap-2 overflow-x-auto pb-1">
|
||
{item.photoUrls.map((photoUrl, photoIndex) => (
|
||
<button
|
||
key={`${item.id}-photo-${photoIndex}`}
|
||
type="button"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
onPreviewPhoto(photoUrl, `打卡照片${photoIndex + 1}`);
|
||
}}
|
||
className="shrink-0"
|
||
>
|
||
<ProtectedImage
|
||
src={photoUrl}
|
||
alt={`打卡照片${photoIndex + 1}`}
|
||
className="h-16 w-16 rounded-lg border border-slate-200 object-cover dark:border-slate-700"
|
||
/>
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
{item.comment ? (
|
||
<div className="mt-2 rounded-lg border border-slate-100 bg-slate-50 p-2.5 dark:border-slate-800/50 dark:bg-slate-800/50">
|
||
<p className="mb-0.5 text-[10px] font-medium text-slate-900 dark:text-white">主管点评:</p>
|
||
<p className="text-[10px] text-slate-600 dark:text-slate-400">{item.comment}</p>
|
||
</div>
|
||
) : null}
|
||
<p className="mt-3 text-[11px] font-medium text-violet-600 dark:text-violet-400">点击查看详情</p>
|
||
</div>
|
||
</motion.div>
|
||
);
|
||
}
|
||
|
||
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<HTMLInputElement>) => void;
|
||
onPickPhoto: () => void;
|
||
uploadingPhoto: boolean;
|
||
checkInPhotoUrls: string[];
|
||
onRemovePhoto: () => void;
|
||
checkInError: string;
|
||
checkInSuccess: string;
|
||
pageError: string;
|
||
submittingCheckIn: boolean;
|
||
onSubmit: () => void;
|
||
}) {
|
||
const mobileCameraOnly = supportsMobileCameraCapture();
|
||
|
||
return (
|
||
<motion.div
|
||
key="checkin"
|
||
initial={{ opacity: 0, y: 10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
className="crm-card crm-card-pad rounded-2xl"
|
||
>
|
||
<div className="mb-4 flex items-center justify-between border-b border-slate-100 pb-4 dark:border-slate-800/60">
|
||
<div className="flex items-center gap-2">
|
||
<MapPin className="h-5 w-5 text-emerald-500 dark:text-emerald-400" />
|
||
<h3 className="text-base font-semibold text-slate-900 dark:text-white">外勤打卡</h3>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div>
|
||
<p className="mb-1 text-sm font-medium text-slate-900 dark:text-white">关联对象</p>
|
||
<button
|
||
type="button"
|
||
onClick={onOpenObjectPicker}
|
||
className="crm-btn crm-input-text flex w-full items-center justify-between rounded-xl border border-slate-200 bg-slate-50 text-left transition-colors hover:border-violet-300 hover:bg-violet-50 dark:border-slate-800 dark:bg-slate-900/60 dark:hover:border-violet-500/30"
|
||
>
|
||
<div>
|
||
<p className="crm-field-note">人员拓展 / 渠道拓展 / 商机</p>
|
||
<p className={`mt-1 text-sm font-medium ${checkInForm.bizName ? "text-slate-900 dark:text-white" : "text-slate-400 dark:text-slate-500"}`}>
|
||
{checkInForm.bizName || "点击选择本次打卡关联对象"}
|
||
</p>
|
||
</div>
|
||
<AtSign className="h-4 w-4 text-violet-500" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4">
|
||
<div className="mb-2 flex items-center justify-between gap-3">
|
||
<p className="text-sm font-medium text-slate-900 dark:text-white">当前位置</p>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={onOpenLocationAdjust}
|
||
disabled={!checkInForm.latitude || !checkInForm.longitude || refreshingLocation}
|
||
className="crm-pill crm-pill-neutral inline-flex h-8 shrink-0 items-center justify-center border border-slate-200 px-3 transition-colors hover:border-violet-300 hover:text-violet-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-200 dark:hover:border-violet-500/40 dark:hover:text-violet-300"
|
||
>
|
||
调整位置
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onRefreshLocation}
|
||
disabled={refreshingLocation}
|
||
className="crm-pill crm-pill-violet inline-flex h-8 shrink-0 items-center justify-center gap-1.5 px-3 transition-colors hover:bg-violet-100 disabled:cursor-not-allowed disabled:opacity-60 dark:hover:bg-violet-500/20"
|
||
>
|
||
<RefreshCw className={`h-3.5 w-3.5 ${refreshingLocation ? "animate-spin" : ""}`} />
|
||
{refreshingLocation ? "定位中" : "刷新"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<textarea
|
||
rows={3}
|
||
value={checkInForm.locationText}
|
||
readOnly
|
||
placeholder="系统会自动定位当前位置"
|
||
className="crm-input-box-readonly crm-input-text w-full border border-slate-200 bg-slate-50 text-slate-900 outline-none dark:border-slate-800 dark:bg-slate-900/60 dark:text-white"
|
||
/>
|
||
{locationHint ? <p className="crm-field-note mt-2">{locationHint}</p> : null}
|
||
{locationAccuracyMeters && locationAccuracyMeters > CHECK_IN_ACCURACY_CONFIRM_THRESHOLD_METERS ? (
|
||
<div className="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-300">
|
||
{locationAdjustmentConfirmed
|
||
? `当前原始定位精度约 ${Math.round(locationAccuracyMeters)} 米,已完成位置确认,可以提交打卡。`
|
||
: `当前原始定位精度约 ${Math.round(locationAccuracyMeters)} 米,超过 ${CHECK_IN_ACCURACY_CONFIRM_THRESHOLD_METERS} 米,暂不允许直接打卡。请先重新定位或完成位置确认。`}
|
||
</div>
|
||
) : null}
|
||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||
<span className="text-[11px] text-slate-400 dark:text-slate-500">仅支持在当前位置附近 {LOCATION_ADJUST_RADIUS_METERS} 米内精调</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 rounded-2xl border border-slate-200 bg-slate-50/70 p-4 dark:border-slate-800 dark:bg-slate-900/40">
|
||
<div className="mb-3">
|
||
<p className="text-sm font-medium text-slate-900 dark:text-white">现场照片</p>
|
||
</div>
|
||
{mobileCameraOnly ? (
|
||
<input
|
||
ref={photoInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
capture="environment"
|
||
className="hidden"
|
||
onChange={onPhotoChange}
|
||
/>
|
||
) : null}
|
||
|
||
{checkInPhotoUrls.length ? (
|
||
<div className="space-y-3">
|
||
<div className="relative overflow-hidden rounded-xl border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900/50">
|
||
<ProtectedImage src={checkInPhotoUrls[0]} alt="打卡现场照片" className="h-52 w-full object-cover" />
|
||
<button
|
||
type="button"
|
||
onClick={onRemovePhoto}
|
||
className="absolute right-3 top-3 inline-flex h-8 w-8 items-center justify-center rounded-full bg-slate-900/75 text-white transition-colors hover:bg-slate-900"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={onPickPhoto}
|
||
disabled={uploadingPhoto || !mobileCameraOnly}
|
||
className="crm-btn flex w-full items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white text-slate-700 transition-colors hover:border-violet-300 hover:text-violet-600 disabled:cursor-not-allowed disabled:opacity-60 dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-200 dark:hover:border-violet-500/40 dark:hover:text-violet-300"
|
||
>
|
||
<Camera className="h-4 w-4" />
|
||
{uploadingPhoto ? "处理中..." : "重新拍照"}
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={onPickPhoto}
|
||
disabled={uploadingPhoto || !mobileCameraOnly}
|
||
className="flex min-h-36 w-full flex-col items-center justify-center rounded-xl border border-dashed border-slate-300 bg-white px-4 py-6 text-center transition-colors hover:border-violet-300 hover:bg-violet-50/50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-slate-700 dark:bg-slate-900/50 dark:hover:border-violet-500/40 dark:hover:bg-slate-900"
|
||
>
|
||
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-violet-50 text-violet-600 dark:bg-violet-500/10 dark:text-violet-300">
|
||
<Camera className="h-5 w-5" />
|
||
</div>
|
||
<p className="text-sm font-medium text-slate-900 dark:text-white">
|
||
{uploadingPhoto ? "照片处理中..." : mobileCameraOnly ? "点击拍摄现场照片" : "请使用手机端拍照打卡"}
|
||
</p>
|
||
<p className="crm-field-note mt-1">
|
||
{mobileCameraOnly
|
||
? "仅支持手机端直接拍照,系统会自动叠加拍摄时间和当前位置水印。"
|
||
: "当前设备不支持拍照打卡,请使用手机浏览器打开后现场拍照。"}
|
||
</p>
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mt-4">
|
||
<p className="mb-1 text-sm font-medium text-slate-900 dark:text-white">打卡备注 (选填)</p>
|
||
<textarea
|
||
rows={2}
|
||
value={checkInForm.remark ?? ""}
|
||
onChange={(event) => onRemarkChange(event.target.value)}
|
||
placeholder="补充说明现场情况..."
|
||
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white text-slate-900 outline-none transition-all focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/60 dark:text-white"
|
||
/>
|
||
</div>
|
||
|
||
{checkInError ? <p className="mt-4 text-xs text-rose-500">{checkInError}</p> : null}
|
||
{checkInSuccess ? <p className="mt-4 text-xs text-emerald-500">{checkInSuccess}</p> : null}
|
||
{pageError ? <p className="mt-4 text-xs text-rose-500">{pageError}</p> : null}
|
||
|
||
<button
|
||
type="button"
|
||
onClick={onSubmit}
|
||
disabled={submittingCheckIn || loading || requiresLocationConfirmation}
|
||
className="crm-btn mt-6 flex w-full items-center justify-center gap-2 rounded-xl bg-emerald-600 text-white transition-all hover:bg-emerald-700 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60"
|
||
>
|
||
<CheckCircle2 className="h-4 w-4" />
|
||
{submittingCheckIn ? "提交中..." : requiresLocationConfirmation ? "请先确认位置" : "确认打卡"}
|
||
</button>
|
||
</motion.div>
|
||
);
|
||
}
|
||
|
||
function ReportPanel({
|
||
loading,
|
||
reportStatus,
|
||
currentWorkDate,
|
||
reportForm,
|
||
onAddReportLine,
|
||
onRemoveReportLine,
|
||
onOpenObjectPicker,
|
||
onReportLineKeyDown,
|
||
onReportLineChange,
|
||
onAddPlanItem,
|
||
onPlanItemChange,
|
||
onRemovePlanItem,
|
||
reportError,
|
||
reportSuccess,
|
||
pageError,
|
||
submittingReport,
|
||
onSubmit,
|
||
}: {
|
||
loading: boolean;
|
||
reportStatus?: string;
|
||
currentWorkDate: string;
|
||
reportForm: CreateWorkDailyReportPayload;
|
||
onAddReportLine: () => void;
|
||
onRemoveReportLine: (index: number) => void;
|
||
onOpenObjectPicker: (mode: PickerMode, lineIndex?: number, bizType?: BizType) => void;
|
||
onReportLineKeyDown: (index: number, event: KeyboardEvent<HTMLTextAreaElement>) => void;
|
||
onReportLineChange: (index: number, value: string) => void;
|
||
onAddPlanItem: () => void;
|
||
onPlanItemChange: (index: number, value: string) => void;
|
||
onRemovePlanItem: (index: number) => void;
|
||
reportError: string;
|
||
reportSuccess: string;
|
||
pageError: string;
|
||
submittingReport: boolean;
|
||
onSubmit: () => void;
|
||
}) {
|
||
const [editingReportLineIndex, setEditingReportLineIndex] = useState<number | null>(null);
|
||
const [editingPlanItemIndex, setEditingPlanItemIndex] = useState<number | null>(null);
|
||
const reportLineTextareaRefs = useRef<(HTMLTextAreaElement | null)[]>([]);
|
||
const planItemInputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||
const previousReportLineCountRef = useRef(reportForm.lineItems.length);
|
||
const previousPlanItemCountRef = useRef(reportForm.planItems.length);
|
||
const pendingNewReportLineFocusRef = useRef<number | null>(null);
|
||
const pendingNewPlanItemFocusRef = useRef<number | null>(null);
|
||
|
||
const focusTextControl = useCallback((element: HTMLTextAreaElement | HTMLInputElement | null) => {
|
||
if (!element) {
|
||
return;
|
||
}
|
||
if (element instanceof HTMLTextAreaElement) {
|
||
syncAutoHeightTextarea(element);
|
||
}
|
||
element.focus();
|
||
const textLength = element.value.length;
|
||
element.setSelectionRange(textLength, textLength);
|
||
}, []);
|
||
|
||
const activateReportLineEditor = useCallback((index: number) => {
|
||
flushSync(() => {
|
||
setEditingPlanItemIndex(null);
|
||
setEditingReportLineIndex(index);
|
||
});
|
||
const target = reportLineTextareaRefs.current[index];
|
||
if (target) {
|
||
focusTextControl(target);
|
||
return;
|
||
}
|
||
requestAnimationFrame(() => focusTextControl(reportLineTextareaRefs.current[index]));
|
||
}, [focusTextControl]);
|
||
|
||
const activatePlanItemEditor = useCallback((index: number) => {
|
||
flushSync(() => {
|
||
setEditingReportLineIndex(null);
|
||
setEditingPlanItemIndex(index);
|
||
});
|
||
const target = planItemInputRefs.current[index];
|
||
if (target) {
|
||
focusTextControl(target);
|
||
return;
|
||
}
|
||
requestAnimationFrame(() => focusTextControl(planItemInputRefs.current[index]));
|
||
}, [focusTextControl]);
|
||
|
||
const handleAddReportLineAndFocus = useCallback(() => {
|
||
pendingNewReportLineFocusRef.current = reportForm.lineItems.length;
|
||
onAddReportLine();
|
||
}, [onAddReportLine, reportForm.lineItems.length]);
|
||
|
||
const handleAddPlanItemAndFocus = useCallback(() => {
|
||
pendingNewPlanItemFocusRef.current = reportForm.planItems.length;
|
||
onAddPlanItem();
|
||
}, [onAddPlanItem, reportForm.planItems.length]);
|
||
|
||
useEffect(() => {
|
||
if (reportForm.lineItems.length > previousReportLineCountRef.current) {
|
||
if (pendingNewReportLineFocusRef.current !== null) {
|
||
const targetIndex = Math.min(pendingNewReportLineFocusRef.current, reportForm.lineItems.length - 1);
|
||
setEditingReportLineIndex(targetIndex);
|
||
setEditingPlanItemIndex(null);
|
||
pendingNewReportLineFocusRef.current = null;
|
||
}
|
||
} else if (editingReportLineIndex !== null && editingReportLineIndex >= reportForm.lineItems.length) {
|
||
setEditingReportLineIndex(reportForm.lineItems.length ? reportForm.lineItems.length - 1 : null);
|
||
}
|
||
previousReportLineCountRef.current = reportForm.lineItems.length;
|
||
}, [editingReportLineIndex, reportForm.lineItems.length]);
|
||
|
||
useEffect(() => {
|
||
if (reportForm.planItems.length > previousPlanItemCountRef.current) {
|
||
if (pendingNewPlanItemFocusRef.current !== null) {
|
||
const targetIndex = Math.min(pendingNewPlanItemFocusRef.current, reportForm.planItems.length - 1);
|
||
setEditingPlanItemIndex(targetIndex);
|
||
setEditingReportLineIndex(null);
|
||
pendingNewPlanItemFocusRef.current = null;
|
||
}
|
||
} else if (editingPlanItemIndex !== null && editingPlanItemIndex >= reportForm.planItems.length) {
|
||
setEditingPlanItemIndex(reportForm.planItems.length ? reportForm.planItems.length - 1 : null);
|
||
}
|
||
previousPlanItemCountRef.current = reportForm.planItems.length;
|
||
}, [editingPlanItemIndex, reportForm.planItems.length]);
|
||
|
||
useEffect(() => {
|
||
reportLineTextareaRefs.current.forEach(syncAutoHeightTextarea);
|
||
}, [reportForm.lineItems]);
|
||
|
||
useEffect(() => {
|
||
if (editingReportLineIndex === null) {
|
||
return;
|
||
}
|
||
requestAnimationFrame(() => focusTextControl(reportLineTextareaRefs.current[editingReportLineIndex]));
|
||
}, [editingReportLineIndex, focusTextControl]);
|
||
|
||
useEffect(() => {
|
||
if (editingPlanItemIndex === null) {
|
||
return;
|
||
}
|
||
requestAnimationFrame(() => focusTextControl(planItemInputRefs.current[editingPlanItemIndex]));
|
||
}, [editingPlanItemIndex, focusTextControl]);
|
||
|
||
return (
|
||
<motion.div
|
||
key="report"
|
||
initial={{ opacity: 0, y: 10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
className="crm-card crm-card-pad rounded-2xl"
|
||
>
|
||
<div className="mb-4 flex items-center justify-between border-b border-slate-100 pb-4 dark:border-slate-800/60">
|
||
<div className="flex items-center gap-2">
|
||
<NotebookPen className="h-5 w-5 text-violet-600 dark:text-violet-400" />
|
||
<h3 className="text-base font-semibold text-slate-900 dark:text-white">销售日报</h3>
|
||
</div>
|
||
<span className="crm-pill crm-pill-amber">
|
||
{loading ? "加载中..." : getReportStatus(reportStatus)}
|
||
</span>
|
||
</div>
|
||
|
||
<div>
|
||
<div className="mb-3 flex flex-col items-start justify-between gap-2 sm:flex-row sm:items-center">
|
||
<div>
|
||
<label className="flex items-center gap-2 text-sm font-medium text-slate-900 dark:text-white">
|
||
<FileText className="h-4 w-4 text-slate-400 dark:text-slate-500" />
|
||
今日工作内容
|
||
</label>
|
||
<p className="crm-field-note mt-1">
|
||
输入 <span className="font-semibold text-violet-600">@</span> 选择对象,系统会自动补齐 <span className="font-semibold text-violet-600">#字段</span>。
|
||
</p>
|
||
</div>
|
||
<div className="flex w-full items-center justify-between gap-2 lg:w-auto lg:justify-end">
|
||
<span className="crm-pill crm-pill-neutral text-[11px]">
|
||
日期 {currentWorkDate}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={handleAddReportLineAndFocus}
|
||
aria-label="添加一行今日工作内容"
|
||
className="crm-pill crm-pill-violet inline-flex h-8 w-8 items-center justify-center rounded-full p-0 transition-colors hover:bg-violet-100 dark:hover:bg-violet-500/20"
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
{reportForm.lineItems.map((item, index) => {
|
||
const isEditing = editingReportLineIndex === index;
|
||
const collapsedPreviewLines = buildCollapsedPreviewLines(item.editorText, "先输入 @ 选择对象,系统会自动生成固定字段。");
|
||
return (
|
||
<div key={`report-line-${index}`} className="flex items-start gap-3">
|
||
{isEditing ? (
|
||
<textarea
|
||
rows={3}
|
||
ref={(element) => {
|
||
reportLineTextareaRefs.current[index] = element;
|
||
syncAutoHeightTextarea(element);
|
||
}}
|
||
value={item.editorText || ""}
|
||
onKeyDown={(event) => onReportLineKeyDown(index, event)}
|
||
onChange={(event) => {
|
||
syncAutoHeightTextarea(event.currentTarget);
|
||
onReportLineChange(index, event.target.value);
|
||
}}
|
||
onBlur={() => setEditingReportLineIndex((current) => (current === index ? null : current))}
|
||
placeholder="先输入 @ 选择对象,系统会自动生成固定字段。"
|
||
className="crm-input-box crm-input-text min-h-[96px] flex-1 resize-none overflow-hidden rounded-2xl border border-slate-200 bg-slate-50 leading-7 text-slate-900 outline-none transition-all focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/60 dark:text-white"
|
||
/>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={() => activateReportLineEditor(index)}
|
||
className="min-h-[72px] flex-1 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-left transition-all hover:border-violet-300 dark:border-slate-800 dark:bg-slate-900/60 dark:hover:border-violet-500"
|
||
>
|
||
<div className="space-y-1">
|
||
{collapsedPreviewLines.map((line, lineIndex) => (
|
||
<p
|
||
key={`report-line-preview-${index}-${lineIndex}`}
|
||
className={cn(
|
||
"truncate text-sm leading-6",
|
||
item.editorText ? "text-slate-900 dark:text-white" : "text-slate-400 dark:text-slate-500",
|
||
)}
|
||
>
|
||
{line}
|
||
</p>
|
||
))}
|
||
</div>
|
||
</button>
|
||
)}
|
||
<div className="flex shrink-0 items-center self-center">
|
||
<button
|
||
type="button"
|
||
onClick={() => onRemoveReportLine(index)}
|
||
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-rose-200 bg-rose-50 text-rose-500 shadow-sm transition-colors hover:bg-rose-100 hover:text-rose-600 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-300 dark:hover:bg-rose-500/20"
|
||
title="删除本行"
|
||
>
|
||
<Trash2 className="h-4.5 w-4.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-6">
|
||
<div className="mb-3 flex items-center justify-between">
|
||
<label className="flex items-center gap-2 text-sm font-medium text-slate-900 dark:text-white">
|
||
<ListTodo className="h-4 w-4 text-slate-400 dark:text-slate-500" />
|
||
明日工作计划
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={handleAddPlanItemAndFocus}
|
||
aria-label="添加一行明日工作计划"
|
||
className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-violet-50 text-violet-600 transition-colors hover:bg-violet-100 dark:bg-violet-500/10 dark:text-violet-400 dark:hover:bg-violet-500/20"
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
<div className="space-y-3">
|
||
{reportForm.planItems.map((item, index) => {
|
||
const isEditing = editingPlanItemIndex === index;
|
||
const collapsedPreview = item.content.trim() || "输入明日工作计划";
|
||
return (
|
||
<div key={`plan-${index}`} className="flex items-center gap-3">
|
||
{isEditing ? (
|
||
<input
|
||
type="text"
|
||
ref={(element) => {
|
||
planItemInputRefs.current[index] = element;
|
||
}}
|
||
value={item.content}
|
||
onChange={(event) => onPlanItemChange(index, event.target.value)}
|
||
onBlur={() => setEditingPlanItemIndex((current) => (current === index ? null : current))}
|
||
placeholder="输入明日工作计划"
|
||
className="crm-input-box crm-input-text h-10 flex-1 rounded-xl border border-slate-200 bg-slate-50 text-slate-900 outline-none transition-all focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/60 dark:text-white"
|
||
/>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={() => activatePlanItemEditor(index)}
|
||
className="crm-btn-sm crm-input-text flex h-10 flex-1 items-center rounded-xl border border-slate-200 bg-slate-50 text-left transition-all hover:border-violet-300 dark:border-slate-800 dark:bg-slate-900/60 dark:hover:border-violet-500"
|
||
>
|
||
<p
|
||
className={cn(
|
||
"w-full truncate text-sm leading-6",
|
||
item.content ? "text-slate-900 dark:text-white" : "text-slate-400 dark:text-slate-500",
|
||
)}
|
||
>
|
||
{collapsedPreview}
|
||
</p>
|
||
</button>
|
||
)}
|
||
<div className="flex shrink-0 items-center">
|
||
<button
|
||
type="button"
|
||
onClick={() => onRemovePlanItem(index)}
|
||
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-rose-200 bg-rose-50 text-rose-500 shadow-sm transition-colors hover:bg-rose-100 hover:text-rose-600 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-300 dark:hover:bg-rose-500/20"
|
||
title="删除本行"
|
||
>
|
||
<Trash2 className="h-4.5 w-4.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{reportError ? <p className="mt-4 text-xs text-rose-500">{reportError}</p> : null}
|
||
{reportSuccess ? <p className="mt-4 text-xs text-emerald-500">{reportSuccess}</p> : null}
|
||
{pageError ? <p className="mt-4 text-xs text-rose-500">{pageError}</p> : null}
|
||
|
||
<button
|
||
type="button"
|
||
onClick={onSubmit}
|
||
disabled={submittingReport || loading}
|
||
className="crm-btn mt-6 flex w-full items-center justify-center gap-2 rounded-xl bg-violet-600 text-white shadow-sm transition-all hover:bg-violet-700 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60"
|
||
>
|
||
<Send className="h-4 w-4" />
|
||
{submittingReport ? "提交中..." : "提交日报"}
|
||
</button>
|
||
</motion.div>
|
||
);
|
||
}
|
||
|
||
function HistoryDetailModal({
|
||
item,
|
||
onClose,
|
||
onPreviewPhoto,
|
||
}: {
|
||
item: WorkHistoryItem;
|
||
onClose: () => void;
|
||
onPreviewPhoto: (url: string, alt: string) => void;
|
||
}) {
|
||
return (
|
||
<div className="fixed inset-0 z-[95]">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="absolute inset-0 bg-slate-900/45 backdrop-blur-sm"
|
||
aria-label="关闭历史详情"
|
||
/>
|
||
<div className="absolute inset-x-0 bottom-0 max-h-[86dvh] overflow-hidden rounded-t-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900 md:inset-x-auto md:left-1/2 md:top-1/2 md:bottom-auto md:w-[min(760px,88vw)] md:max-h-[80vh] md:-translate-x-1/2 md:-translate-y-1/2 md:rounded-3xl">
|
||
<div className="flex items-start justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800 md:px-6">
|
||
<div>
|
||
<h3 className="text-base font-semibold text-slate-900 dark:text-white">{item.type || "历史记录详情"}</h3>
|
||
<p className="crm-field-note mt-1">{[item.date, item.time].filter(Boolean).join(" ") || "无时间信息"}</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800"
|
||
aria-label="关闭"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="max-h-[calc(86dvh-84px)] overflow-y-auto px-5 py-4 pb-[calc(env(safe-area-inset-bottom)+20px)] md:max-h-[calc(80vh-84px)] md:px-6">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
{item.status ? (
|
||
<span className={cn(
|
||
"rounded-full px-2.5 py-1 text-xs font-medium",
|
||
item.status === "已点评"
|
||
? "bg-violet-50 text-violet-600 dark:bg-violet-500/10 dark:text-violet-400"
|
||
: item.status === "已阅" || item.status === "已提交"
|
||
? "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400"
|
||
: "bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400",
|
||
)}
|
||
>
|
||
{item.status}
|
||
</span>
|
||
) : null}
|
||
{item.score ? <span className="text-xs font-bold text-rose-600 dark:text-rose-400">{item.score}分</span> : null}
|
||
{item.photoUrls?.length ? (
|
||
<span className="rounded-full bg-slate-100 px-2.5 py-1 text-xs text-slate-500 dark:bg-slate-800 dark:text-slate-400">
|
||
{item.photoUrls.length} 张照片
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
|
||
<section className="mt-4 rounded-2xl border border-slate-100 bg-slate-50/70 p-4 dark:border-slate-800 dark:bg-slate-900/40">
|
||
<p className="text-xs font-medium text-slate-500 dark:text-slate-400">记录内容</p>
|
||
<p className="mt-2 whitespace-pre-line text-sm leading-7 text-slate-800 dark:text-slate-200">
|
||
{item.content || "无"}
|
||
</p>
|
||
</section>
|
||
|
||
{item.photoUrls?.length ? (
|
||
<section className="mt-4">
|
||
<p className="mb-3 text-sm font-medium text-slate-900 dark:text-white">现场照片</p>
|
||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||
{item.photoUrls.map((photoUrl, photoIndex) => (
|
||
<button
|
||
key={`${item.id}-detail-photo-${photoIndex}`}
|
||
type="button"
|
||
onClick={() => onPreviewPhoto(photoUrl, `${item.type || "历史"}照片${photoIndex + 1}`)}
|
||
className="overflow-hidden rounded-2xl border border-slate-200 bg-white text-left transition-transform hover:scale-[1.01] dark:border-slate-700 dark:bg-slate-900/50"
|
||
>
|
||
<ProtectedImage
|
||
src={photoUrl}
|
||
alt={`${item.type || "历史"}照片${photoIndex + 1}`}
|
||
className="h-28 w-full object-cover"
|
||
/>
|
||
<div className="crm-field-note px-3 py-2">点击查看大图</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{item.comment ? (
|
||
<section className="mt-4 rounded-2xl border border-slate-100 bg-white p-4 dark:border-slate-800 dark:bg-slate-900/40">
|
||
<p className="text-xs font-medium text-slate-500 dark:text-slate-400">主管点评</p>
|
||
<p className="mt-2 whitespace-pre-line text-sm leading-7 text-slate-800 dark:text-slate-200">{item.comment}</p>
|
||
</section>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PhotoPreviewModal({
|
||
url,
|
||
alt,
|
||
onClose,
|
||
}: {
|
||
url: string;
|
||
alt: string;
|
||
onClose: () => void;
|
||
}) {
|
||
return (
|
||
<div className="fixed inset-0 z-[100]">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="absolute inset-0 bg-slate-950/82 backdrop-blur-sm"
|
||
aria-label="关闭照片预览"
|
||
/>
|
||
<div className="absolute inset-0 flex items-center justify-center px-4 py-6">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="absolute right-4 top-4 inline-flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20"
|
||
aria-label="关闭"
|
||
>
|
||
<X className="h-5 w-5" />
|
||
</button>
|
||
<ProtectedImage
|
||
src={url}
|
||
alt={alt}
|
||
className="max-h-full max-w-full rounded-2xl object-contain shadow-2xl"
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function syncAutoHeightTextarea(textarea: HTMLTextAreaElement | null) {
|
||
if (!textarea) {
|
||
return;
|
||
}
|
||
textarea.style.height = "auto";
|
||
textarea.style.height = `${Math.max(textarea.scrollHeight, 96)}px`;
|
||
}
|
||
|
||
function buildCollapsedPreviewLines(content: string | undefined, placeholder: string) {
|
||
if (!content?.trim()) {
|
||
return [placeholder];
|
||
}
|
||
|
||
const lines = content
|
||
.replace(/\r/g, "")
|
||
.split("\n")
|
||
.map((line) => line.trim())
|
||
.filter(Boolean);
|
||
|
||
if (!lines.length) {
|
||
return [placeholder];
|
||
}
|
||
|
||
const previewLines = lines.slice(0, 3);
|
||
if (lines.length > previewLines.length) {
|
||
const lastIndex = previewLines.length - 1;
|
||
previewLines[lastIndex] = `${previewLines[lastIndex]} …`;
|
||
}
|
||
return previewLines;
|
||
}
|
||
|
||
function SummaryCell({ label, value }: { label: string; value: string }) {
|
||
return (
|
||
<div className="bg-white px-4 py-3 dark:bg-slate-900/50">
|
||
<p className="text-xs text-slate-500">{label}</p>
|
||
<p className="break-anywhere mt-1 text-sm font-semibold text-slate-900 dark:text-white">{value}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SectionTitle({ title, accent, compact = false }: { title: string; accent: string; compact?: boolean }) {
|
||
return (
|
||
<div className={`flex items-center gap-2 ${compact ? "mb-0" : "mb-2"}`}>
|
||
<div className={`h-6 w-1 rounded-full ${accent}`} />
|
||
<h2 className="text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{title}</h2>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function normalizeLocationText(value?: string) {
|
||
return typeof value === "string" ? value.trim() : "";
|
||
}
|
||
|
||
function isCoordinateLikeLocation(value: string) {
|
||
return /[-+]?\d+\.\d+\s*,\s*[-+]?\d+\.\d+/.test(value);
|
||
}
|
||
|
||
function isReadableLocationText(value?: string) {
|
||
const normalizedValue = normalizeLocationText(value);
|
||
if (!normalizedValue) {
|
||
return false;
|
||
}
|
||
if (normalizedValue.includes("坐标")) {
|
||
return false;
|
||
}
|
||
if (isCoordinateLikeLocation(normalizedValue)) {
|
||
return false;
|
||
}
|
||
return normalizedValue.length >= 6;
|
||
}
|
||
|
||
function buildLocationCacheKey(latitude: number, longitude: number) {
|
||
return `${latitude.toFixed(4)},${longitude.toFixed(4)}`;
|
||
}
|
||
|
||
async function resolveLocationDisplayName(latitude: number, longitude: number, preferredText?: string) {
|
||
const normalizedPreferredText = normalizeLocationText(preferredText);
|
||
if (isReadableLocationText(normalizedPreferredText)) {
|
||
locationDisplayCache.set(buildLocationCacheKey(latitude, longitude), normalizedPreferredText);
|
||
return normalizedPreferredText;
|
||
}
|
||
|
||
const cacheKey = buildLocationCacheKey(latitude, longitude);
|
||
const cachedLocation = locationDisplayCache.get(cacheKey);
|
||
if (isReadableLocationText(cachedLocation)) {
|
||
return cachedLocation;
|
||
}
|
||
|
||
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);
|
||
return normalizedAddress;
|
||
}
|
||
} catch {
|
||
// ignore and continue retrying
|
||
}
|
||
}
|
||
|
||
return "";
|
||
}
|
||
|
||
function formatLocationFallback() {
|
||
return "当前位置待补充,请点击“刷新定位”重新解析地点名称";
|
||
}
|
||
|
||
function calculateDistanceMeters(origin: LocationPoint, target: LocationPoint) {
|
||
const toRadians = (value: number) => (value * Math.PI) / 180;
|
||
const earthRadius = 6371000;
|
||
const deltaLatitude = toRadians(target.latitude - origin.latitude);
|
||
const deltaLongitude = toRadians(target.longitude - origin.longitude);
|
||
const originLatitude = toRadians(origin.latitude);
|
||
const targetLatitude = toRadians(target.latitude);
|
||
const haversine = Math.sin(deltaLatitude / 2) ** 2
|
||
+ Math.cos(originLatitude) * Math.cos(targetLatitude) * Math.sin(deltaLongitude / 2) ** 2;
|
||
return 2 * earthRadius * Math.asin(Math.sqrt(haversine));
|
||
}
|
||
|
||
function getWorkSection(pathname: string): WorkSection | null {
|
||
if (pathname === "/work" || pathname === "/work/") {
|
||
return null;
|
||
}
|
||
if (pathname.startsWith("/work/checkin")) {
|
||
return "checkin";
|
||
}
|
||
if (pathname.startsWith("/work/report")) {
|
||
return "report";
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function getHistoryLabelBySection(section: WorkSection) {
|
||
return section === "checkin" ? "打卡" : "日报";
|
||
}
|
||
|
||
function buildSalesOptions(items: SalesExpansionItem[]): WorkRelationOption[] {
|
||
return items.filter((item) => item.id).map((item) => ({ id: item.id, label: item.name || `人员拓展#${item.id}` }));
|
||
}
|
||
|
||
function buildChannelOptions(items: ChannelExpansionItem[]): WorkRelationOption[] {
|
||
return items.filter((item) => item.id).map((item) => ({ id: item.id, label: item.name || `拓展渠道#${item.id}` }));
|
||
}
|
||
|
||
function buildOpportunityOptions(items: OpportunityItem[]): WorkRelationOption[] {
|
||
return items.filter((item) => item.id).map((item) => ({ id: item.id, label: item.name || `商机#${item.id}` }));
|
||
}
|
||
|
||
function getBizTypeLabel(bizType: BizType) {
|
||
if (bizType === "sales") {
|
||
return "人员拓展";
|
||
}
|
||
if (bizType === "channel") {
|
||
return "渠道拓展";
|
||
}
|
||
return "商机";
|
||
}
|
||
|
||
function getOptionsByBizType(
|
||
bizType: BizType,
|
||
salesOptions: WorkRelationOption[],
|
||
channelOptions: WorkRelationOption[],
|
||
opportunityOptions: WorkRelationOption[],
|
||
) {
|
||
if (bizType === "sales") {
|
||
return salesOptions;
|
||
}
|
||
if (bizType === "channel") {
|
||
return channelOptions;
|
||
}
|
||
return opportunityOptions;
|
||
}
|
||
|
||
function normalizeObjectPickerQuery(query: string) {
|
||
return query.replace(/@+/g, "").trimStart();
|
||
}
|
||
|
||
function getTemplateFields(bizType: BizType) {
|
||
return [...reportFieldLabels[bizType]];
|
||
}
|
||
|
||
function buildEditorMentionLine(bizType: BizType, bizName: string) {
|
||
return `@${getBizTypeLabel(bizType)} ${bizName}`;
|
||
}
|
||
|
||
function buildEditorTemplate(bizType: BizType, bizName: string, fieldValues?: Record<string, string>) {
|
||
const lines = [buildEditorMentionLine(bizType, bizName)];
|
||
for (const field of getTemplateFields(bizType)) {
|
||
const value = fieldValues?.[field] || "";
|
||
lines.push(`# ${field}:${value}`);
|
||
}
|
||
return lines.join("\n");
|
||
}
|
||
|
||
function clearReportLineSelection(item: WorkReportLineItem): WorkReportLineItem {
|
||
return {
|
||
...createEmptyReportLine(),
|
||
bizType: item.bizType,
|
||
workDate: item.workDate,
|
||
};
|
||
}
|
||
|
||
function parseTemplateValues(bizType: BizType, editorText: string) {
|
||
const values: Record<string, string> = {};
|
||
const fieldSet = getTemplateFields(bizType);
|
||
let currentField: string | null = null;
|
||
|
||
for (const rawLine of editorText.replace(/\r/g, "").split("\n")) {
|
||
if (rawLine.startsWith("@")) {
|
||
currentField = null;
|
||
continue;
|
||
}
|
||
const parsedFieldLine = parseTemplateFieldLine(rawLine, fieldSet);
|
||
if (parsedFieldLine) {
|
||
currentField = parsedFieldLine.field;
|
||
if (parsedFieldLine.field) {
|
||
values[parsedFieldLine.field] = parsedFieldLine.value;
|
||
}
|
||
continue;
|
||
}
|
||
if (currentField) {
|
||
const line = rawLine.trim();
|
||
if (!line) {
|
||
continue;
|
||
}
|
||
values[currentField] = values[currentField] ? `${values[currentField]}\n${line}` : line;
|
||
}
|
||
}
|
||
|
||
return values;
|
||
}
|
||
|
||
function parseTemplateFieldLine(rawLine: string, fieldSet: string[]) {
|
||
const match = rawLine.match(/^#\s*(.*)$/);
|
||
if (!match) {
|
||
return null;
|
||
}
|
||
|
||
const content = match[1].trim();
|
||
for (const field of fieldSet) {
|
||
if (!content.startsWith(field)) {
|
||
continue;
|
||
}
|
||
|
||
const suffix = content.slice(field.length).trimStart();
|
||
if (!suffix) {
|
||
return { field, value: "" };
|
||
}
|
||
if (suffix.startsWith(":") || suffix.startsWith(":")) {
|
||
return { field, value: suffix.slice(1).trim() };
|
||
}
|
||
return { field: null, value: "" };
|
||
}
|
||
|
||
return { field: null, value: "" };
|
||
}
|
||
|
||
function sanitizeEditorText(bizType: BizType, bizName: string, input: string) {
|
||
return buildEditorTemplate(bizType, bizName, parseTemplateValues(bizType, input));
|
||
}
|
||
|
||
function normalizeLoadedLineItem(item: WorkReportLineItem): WorkReportLineItem {
|
||
const normalized: WorkReportLineItem = { ...createEmptyReportLine(), ...item };
|
||
const fieldValues: Record<string, string> = {};
|
||
if (normalized.bizType === "sales" || normalized.bizType === "channel") {
|
||
fieldValues["沟通内容"] = normalized.evaluationContent || extractContentByLabel(normalized.content, "沟通内容") || "";
|
||
fieldValues["后续规划"] = normalized.nextPlan || extractContentByLabel(normalized.content, "后续规划") || "";
|
||
} else {
|
||
fieldValues["项目最新进展"] = normalized.latestProgress || extractContentByLabel(normalized.content, "项目最新进展") || "";
|
||
fieldValues["后续规划"] = normalized.nextPlan || extractContentByLabel(normalized.content, "后续规划") || "";
|
||
}
|
||
const bizName = normalized.bizName || "未命名对象";
|
||
normalized.editorText = sanitizeEditorText(
|
||
normalized.bizType,
|
||
bizName,
|
||
normalized.editorText || buildEditorTemplate(normalized.bizType, bizName, fieldValues),
|
||
);
|
||
return normalized;
|
||
}
|
||
|
||
function normalizeReportLineItem(item: WorkReportLineItem, currentWorkDate: string): WorkReportLineItem {
|
||
const bizName = item.bizName?.trim() || "";
|
||
const editorText = bizName ? sanitizeEditorText(item.bizType, bizName, item.editorText || "") : "";
|
||
return {
|
||
...item,
|
||
workDate: currentWorkDate,
|
||
bizName,
|
||
editorText,
|
||
content: buildLinePreview(item.bizType, parseTemplateValues(item.bizType, editorText)),
|
||
};
|
||
}
|
||
|
||
function validateReportLineItems(lineItems: WorkReportLineItem[]) {
|
||
for (const item of lineItems) {
|
||
if (!item.bizId || !item.bizName || !item.editorText) {
|
||
throw new Error("每一条日报都需要先通过 @ 选择对象");
|
||
}
|
||
const values = parseTemplateValues(item.bizType, item.editorText);
|
||
if (item.bizType === "opportunity") {
|
||
if (!values["项目最新进展"]?.trim()) {
|
||
throw new Error(`商机“${item.bizName}”请填写项目最新进展`);
|
||
}
|
||
continue;
|
||
}
|
||
if (!values["沟通内容"]?.trim()) {
|
||
throw new Error(`${getBizTypeLabel(item.bizType)}“${item.bizName}”请填写沟通内容`);
|
||
}
|
||
}
|
||
}
|
||
|
||
function buildLinePreview(bizType: BizType, values: Record<string, string>) {
|
||
if (bizType === "opportunity") {
|
||
return [
|
||
values["项目最新进展"] ? `项目最新进展:${values["项目最新进展"]}` : "",
|
||
values["后续规划"] ? `后续规划:${values["后续规划"]}` : "",
|
||
].filter(Boolean).join("\n");
|
||
}
|
||
return [
|
||
values["沟通内容"] ? `沟通内容:${values["沟通内容"]}` : "",
|
||
values["后续规划"] ? `后续规划:${values["后续规划"]}` : "",
|
||
].filter(Boolean).join("\n");
|
||
}
|
||
|
||
function buildReportSummary(lineItems: WorkReportLineItem[]) {
|
||
return lineItems.map((item, index) => {
|
||
const values = parseTemplateValues(item.bizType, item.editorText || "");
|
||
const detail = buildLinePreview(item.bizType, values).replace(/\n/g, ";");
|
||
return `${index + 1}. ${item.workDate} 跟进${getBizTypeLabel(item.bizType)}“${item.bizName || ""}”:${detail}`;
|
||
}).join("\n");
|
||
}
|
||
|
||
function buildPlanSummary(planItems: WorkTomorrowPlanItem[]) {
|
||
return planItems.map((item, index) => `${index + 1}. ${item.content.trim()}`).join("\n");
|
||
}
|
||
|
||
function extractContentByLabel(content: string | undefined, label: string) {
|
||
if (!content) {
|
||
return "";
|
||
}
|
||
const match = content.match(new RegExp(`${label}:([^\n]+)`));
|
||
return match?.[1]?.trim() || "";
|
||
}
|
||
|
||
async function stampPhotoWithTimeAndLocation(file: File, locationText: string) {
|
||
const imageUrl = URL.createObjectURL(file);
|
||
try {
|
||
const image = await loadImageElement(imageUrl);
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = image.naturalWidth;
|
||
canvas.height = image.naturalHeight;
|
||
const context = canvas.getContext("2d");
|
||
if (!context) {
|
||
throw new Error("图片处理失败");
|
||
}
|
||
|
||
context.drawImage(image, 0, 0);
|
||
const timeText = format(new Date(), "yyyy-MM-dd HH:mm:ss");
|
||
const locationLine = compactLocationWatermark(locationText);
|
||
const stampLines = [timeText, locationLine];
|
||
const fontSize = Math.max(28, Math.round(canvas.width / 28));
|
||
context.font = `${fontSize}px sans-serif`;
|
||
context.textBaseline = "bottom";
|
||
const maxLineWidth = canvas.width * 0.58;
|
||
const wrappedLines = stampLines.flatMap((line) => wrapCanvasText(context, line, maxLineWidth));
|
||
const paddingX = Math.round(fontSize * 0.6);
|
||
const paddingY = Math.round(fontSize * 0.45);
|
||
const lineHeight = Math.round(fontSize * 1.35);
|
||
const maxTextWidth = wrappedLines.reduce((max, line) => Math.max(max, context.measureText(line).width), 0);
|
||
const boxWidth = Math.ceil(maxTextWidth + paddingX * 2);
|
||
const boxHeight = Math.ceil(lineHeight * wrappedLines.length + paddingY * 2);
|
||
const x = canvas.width - boxWidth - paddingX;
|
||
const y = canvas.height - paddingY;
|
||
|
||
context.fillStyle = "rgba(15, 23, 42, 0.68)";
|
||
context.fillRect(x - paddingX, y - boxHeight + paddingY, boxWidth, boxHeight);
|
||
context.fillStyle = "#ffffff";
|
||
wrappedLines.forEach((line, index) => {
|
||
const baselineY = y - lineHeight * (wrappedLines.length - 1 - index);
|
||
context.fillText(line, x, baselineY);
|
||
});
|
||
|
||
const blob = await new Promise<Blob>((resolve, reject) => {
|
||
canvas.toBlob((value) => {
|
||
if (!value) {
|
||
reject(new Error("图片处理失败"));
|
||
return;
|
||
}
|
||
resolve(value);
|
||
}, "image/jpeg", 0.92);
|
||
});
|
||
|
||
return new File([blob], file.name.replace(/\.[^.]+$/, "") + "-stamped.jpg", { type: "image/jpeg" });
|
||
} finally {
|
||
URL.revokeObjectURL(imageUrl);
|
||
}
|
||
}
|
||
|
||
function compactLocationWatermark(locationText: string) {
|
||
const normalized = locationText.replace(/\s+/g, " ").trim();
|
||
if (!normalized) {
|
||
return "位置待补充";
|
||
}
|
||
return normalized.length > 48 ? `${normalized.slice(0, 48)}...` : normalized;
|
||
}
|
||
|
||
function wrapCanvasText(context: CanvasRenderingContext2D, text: string, maxWidth: number) {
|
||
if (!text) {
|
||
return [""];
|
||
}
|
||
if (context.measureText(text).width <= maxWidth) {
|
||
return [text];
|
||
}
|
||
|
||
const lines: string[] = [];
|
||
let currentLine = "";
|
||
for (const char of text) {
|
||
const nextLine = `${currentLine}${char}`;
|
||
if (currentLine && context.measureText(nextLine).width > maxWidth) {
|
||
lines.push(currentLine);
|
||
currentLine = char;
|
||
} else {
|
||
currentLine = nextLine;
|
||
}
|
||
}
|
||
if (currentLine) {
|
||
lines.push(currentLine);
|
||
}
|
||
return lines;
|
||
}
|
||
|
||
function supportsMobileCameraCapture() {
|
||
if (typeof navigator === "undefined") {
|
||
return false;
|
||
}
|
||
const userAgent = navigator.userAgent || "";
|
||
return /Android|iPhone|iPod|Mobile|HarmonyOS/i.test(userAgent);
|
||
}
|
||
|
||
function loadImageElement(src: string) {
|
||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||
const image = new Image();
|
||
image.onload = () => resolve(image);
|
||
image.onerror = () => reject(new Error("图片读取失败"));
|
||
image.src = src;
|
||
});
|
||
}
|