unis_crm/frontend/src/pages/Work.tsx

2525 lines
99 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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