unis_crm/frontend/src/pages/Work.tsx

2525 lines
99 KiB
TypeScript
Raw Normal View History

2026-03-26 09:29:55 +00:00
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react";
2026-03-19 06:23:03 +00:00
import { format } from "date-fns";
2026-03-26 09:29:55 +00:00
import { zhCN } from "date-fns/locale";
2026-03-19 06:23:03 +00:00
import { motion } from "motion/react";
2026-03-26 09:29:55 +00:00
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";
2026-03-20 08:39:07 +00:00
import {
2026-03-26 09:29:55 +00:00
getCurrentUser,
getExpansionOverview,
getOpportunityOverview,
getProfileOverview,
getWorkHistory,
2026-03-20 08:39:07 +00:00
getWorkOverview,
reverseWorkGeocode,
saveWorkCheckIn,
saveWorkDailyReport,
2026-03-23 01:03:27 +00:00
uploadWorkCheckInPhoto,
2026-03-26 09:29:55 +00:00
type ChannelExpansionItem,
2026-03-20 08:39:07 +00:00
type CreateWorkCheckInPayload,
type CreateWorkDailyReportPayload,
2026-03-26 09:29:55 +00:00
type OpportunityItem,
type ProfileOverview,
type SalesExpansionItem,
type UserProfile,
2026-03-20 08:39:07 +00:00
type WorkHistoryItem,
2026-03-26 09:29:55 +00:00
type WorkReportLineItem,
type WorkTomorrowPlanItem,
2026-03-20 08:39:07 +00:00
} from "@/lib/auth";
2026-03-26 09:29:55 +00:00
import { ProtectedImage } from "@/components/ProtectedImage";
import { resolveTencentMapLocation } from "@/lib/tencentMap";
import { loadTencentMapGlApi } from "@/lib/tencentMapGl";
import { cn } from "@/lib/utils";
2026-03-20 08:39:07 +00:00
2026-03-26 09:29:55 +00:00
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;
2026-03-20 08:39:07 +00:00
const defaultCheckInForm: CreateWorkCheckInPayload = {
locationText: "",
remark: "",
2026-03-26 09:29:55 +00:00
photoUrls: [],
2026-03-20 08:39:07 +00:00
};
const defaultReportForm: CreateWorkDailyReportPayload = {
workContent: "",
2026-03-26 09:29:55 +00:00
lineItems: [],
planItems: [],
2026-03-20 08:39:07 +00:00
tomorrowPlan: "",
sourceType: "manual",
};
2026-03-26 09:29:55 +00:00
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: "" };
2026-03-20 08:39:07 +00:00
}
function getReportStatus(status?: string) {
if (!status) {
return "待提交";
}
return status === "reviewed" || status === "已点评" ? "已点评" : "已提交";
}
2026-03-19 06:23:03 +00:00
export default function Work() {
2026-03-26 09:29:55 +00:00
const routerLocation = useLocation();
const currentWorkDate = format(new Date(), "yyyy-MM-dd");
2026-03-20 08:39:07 +00:00
const hasAutoRefreshedLocation = useRef(false);
2026-03-23 01:03:27 +00:00
const photoInputRef = useRef<HTMLInputElement | null>(null);
2026-03-26 09:29:55 +00:00
const historyLoadMoreRef = useRef<HTMLDivElement | null>(null);
2026-03-20 08:39:07 +00:00
const [loading, setLoading] = useState(true);
const [refreshingLocation, setRefreshingLocation] = useState(false);
const [submittingCheckIn, setSubmittingCheckIn] = useState(false);
const [submittingReport, setSubmittingReport] = useState(false);
2026-03-23 01:03:27 +00:00
const [uploadingPhoto, setUploadingPhoto] = useState(false);
2026-03-26 09:29:55 +00:00
const [mobilePanel, setMobilePanel] = useState<"entry" | "history">("entry");
2026-03-20 08:39:07 +00:00
const [locationHint, setLocationHint] = useState("");
2026-03-26 09:29:55 +00:00
const [locationAdjustOpen, setLocationAdjustOpen] = useState(false);
const [locationAdjustOrigin, setLocationAdjustOrigin] = useState<LocationPoint | null>(null);
const [locationAccuracyMeters, setLocationAccuracyMeters] = useState<number | undefined>();
const [locationAdjustmentConfirmed, setLocationAdjustmentConfirmed] = useState(false);
2026-03-20 08:39:07 +00:00
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[]>([]);
2026-03-26 09:29:55 +00:00
const [historyLoading, setHistoryLoading] = useState(true);
const [historyLoadingMore, setHistoryLoadingMore] = useState(false);
const [historyHasMore, setHistoryHasMore] = useState(false);
const [historyPage, setHistoryPage] = useState(1);
2026-03-20 08:39:07 +00:00
const [reportStatus, setReportStatus] = useState<string>();
2026-03-26 09:29:55 +00:00
const [currentUser, setCurrentUser] = useState<UserProfile | null>(null);
const [profileOverview, setProfileOverview] = useState<ProfileOverview | null>(null);
2026-03-23 01:03:27 +00:00
const [checkInPhotoUrls, setCheckInPhotoUrls] = useState<string[]>([]);
2026-03-26 09:29:55 +00:00
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);
2026-03-20 08:39:07 +00:00
const [checkInForm, setCheckInForm] = useState<CreateWorkCheckInPayload>(defaultCheckInForm);
const [reportForm, setReportForm] = useState<CreateWorkDailyReportPayload>(defaultReportForm);
2026-03-26 09:29:55 +00:00
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]);
2026-03-20 08:39:07 +00:00
2026-03-26 09:29:55 +00:00
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);
2026-03-20 08:39:07 +00:00
}
2026-03-26 09:29:55 +00:00
setReportTargetsLoading(false);
}, [reportTargetsLoaded, reportTargetsLoading]);
2026-03-20 08:39:07 +00:00
useEffect(() => {
void loadOverview();
}, []);
2026-03-26 09:29:55 +00:00
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;
};
}, []);
2026-03-20 08:39:07 +00:00
useEffect(() => {
if (loading || hasAutoRefreshedLocation.current) {
return;
}
hasAutoRefreshedLocation.current = true;
void handleRefreshLocation();
}, [loading]);
2026-03-19 06:23:03 +00:00
2026-03-26 09:29:55 +00:00
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);
}
}
2026-03-23 01:03:27 +00:00
const handleRefreshLocation = async () => {
setCheckInError("");
setRefreshingLocation(true);
if (!window.isSecureContext && location.hostname !== "localhost" && location.hostname !== "127.0.0.1") {
2026-03-26 09:29:55 +00:00
setLocationHint("当前地址不是 HTTPS手机浏览器会拦截定位。请使用 HTTPS 地址打开。");
2026-03-23 01:03:27 +00:00
setRefreshingLocation(false);
2026-03-20 08:39:07 +00:00
return;
}
2026-03-23 01:03:27 +00:00
setLocationHint("正在获取当前位置...");
try {
2026-03-26 09:29:55 +00:00
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} 米内精调。`,
);
2026-03-23 01:03:27 +00:00
} catch (error) {
2026-03-26 09:29:55 +00:00
setCheckInForm((prev) => ({
...prev,
locationText: "",
latitude: undefined,
longitude: undefined,
}));
setLocationAdjustOrigin(null);
setLocationAccuracyMeters(undefined);
setLocationAdjustmentConfirmed(false);
setLocationHint(error instanceof Error ? error.message : "定位获取失败,请开启定位权限后重试。");
2026-03-23 01:03:27 +00:00
} finally {
setRefreshingLocation(false);
}
2026-03-20 08:39:07 +00:00
};
2026-03-26 09:29:55 +00:00
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)} 米,位置已确认,可继续打卡。`);
};
2026-03-23 01:03:27 +00:00
const handlePickPhoto = () => {
2026-03-26 09:29:55 +00:00
setCheckInError("");
if (!supportsMobileCameraCapture()) {
setCheckInError("现场照片仅支持手机端直接拍照,请使用手机打开当前页面。");
return;
}
if (!checkInForm.locationText.trim()) {
setCheckInError("请先完成定位,再进行现场拍照。");
return;
}
2026-03-23 01:03:27 +00:00
photoInputRef.current?.click();
};
const handlePhotoChange = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
2026-03-20 08:39:07 +00:00
return;
2026-03-19 06:23:03 +00:00
}
2026-03-20 08:39:07 +00:00
setCheckInError("");
2026-03-23 01:03:27 +00:00
setCheckInSuccess("");
setUploadingPhoto(true);
try {
2026-03-26 09:29:55 +00:00
if (!supportsMobileCameraCapture()) {
throw new Error("现场照片仅支持手机端直接拍照,请使用手机打开当前页面。");
}
if (!checkInForm.locationText.trim()) {
throw new Error("请先完成定位,再进行现场拍照。");
}
const stampedFile = await stampPhotoWithTimeAndLocation(file, checkInForm.locationText);
const uploadedUrl = await uploadWorkCheckInPhoto(stampedFile);
2026-03-23 01:03:27 +00:00
setCheckInPhotoUrls([uploadedUrl]);
} catch (error) {
setCheckInError(error instanceof Error ? error.message : "现场照片上传失败");
} finally {
setUploadingPhoto(false);
event.target.value = "";
}
};
const handleRemovePhoto = () => {
setCheckInPhotoUrls([]);
2026-03-19 06:23:03 +00:00
};
2026-03-26 09:29:55 +00:00
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),
}));
};
2026-03-20 08:39:07 +00:00
const handleCheckInSubmit = async () => {
if (submittingCheckIn) {
return;
2026-03-19 06:23:03 +00:00
}
2026-03-20 08:39:07 +00:00
setCheckInError("");
setCheckInSuccess("");
setSubmittingCheckIn(true);
try {
2026-03-26 09:29:55 +00:00
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("请先关联本次打卡对象");
}
2026-03-23 01:03:27 +00:00
if (!checkInPhotoUrls.length) {
2026-03-26 09:29:55 +00:00
throw new Error("请先上传现场照片");
2026-03-23 01:03:27 +00:00
}
2026-03-20 08:39:07 +00:00
await saveWorkCheckIn({
locationText: checkInForm.locationText.trim(),
remark: checkInForm.remark?.trim() || undefined,
longitude: checkInForm.longitude,
latitude: checkInForm.latitude,
2026-03-23 01:03:27 +00:00
photoUrls: checkInPhotoUrls,
2026-03-26 09:29:55 +00:00
bizType: checkInForm.bizType,
bizId: checkInForm.bizId,
bizName: checkInForm.bizName,
userName: currentUser?.displayName || profileOverview?.realName || currentUser?.username || "",
deptName: profileOverview?.deptName || "",
2026-03-20 08:39:07 +00:00
});
2026-03-26 09:29:55 +00:00
await Promise.all([
loadOverview(),
loadHistory(historySection, 1, true),
]);
2026-03-23 01:03:27 +00:00
setCheckInPhotoUrls([]);
2026-03-26 09:29:55 +00:00
setCheckInSuccess("打卡已记录。");
2026-03-20 08:39:07 +00:00
} catch (error) {
setCheckInError(error instanceof Error ? error.message : "打卡提交失败");
} finally {
setSubmittingCheckIn(false);
}
};
const handleReportSubmit = async () => {
if (submittingReport) {
return;
}
setReportError("");
setReportSuccess("");
setSubmittingReport(true);
try {
2026-03-26 09:29:55 +00:00
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("请至少填写一条明日工作计划");
}
2026-03-20 08:39:07 +00:00
await saveWorkDailyReport({
2026-03-26 09:29:55 +00:00
workContent: buildReportSummary(normalizedLineItems),
lineItems: normalizedLineItems,
planItems: normalizedPlanItems,
tomorrowPlan: buildPlanSummary(normalizedPlanItems),
2026-03-20 08:39:07 +00:00
sourceType: reportForm.sourceType || "manual",
});
2026-03-26 09:29:55 +00:00
await Promise.all([
loadOverview(),
loadHistory(historySection, 1, true),
]);
2026-03-20 08:39:07 +00:00
setReportSuccess("日报已保存,今日再次提交会覆盖当天内容。");
} catch (error) {
setReportError(error instanceof Error ? error.message : "日报提交失败");
} finally {
setSubmittingReport(false);
}
};
2026-03-26 09:29:55 +00:00
if (!activeWorkSection || !activeWorkMeta) {
return <Navigate to="/work/checkin" replace />;
2026-03-20 08:39:07 +00:00
}
2026-03-19 06:23:03 +00:00
return (
2026-03-26 09:29:55 +00:00
<div className="crm-page-stack">
<header className="flex flex-wrap items-center justify-between gap-3">
2026-03-19 06:23:03 +00:00
<div>
2026-03-26 09:29:55 +00:00
<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>
2026-03-19 06:23:03 +00:00
</div>
</header>
2026-03-26 09:29:55 +00:00
<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} />
2026-03-19 06:23:03 +00:00
</div>
2026-03-26 09:29:55 +00:00
{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 })}
/>
2026-03-19 06:23:03 +00:00
</div>
2026-03-26 09:29:55 +00:00
))}
2026-03-19 06:23:03 +00:00
2026-03-26 09:29:55 +00:00
{!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">
2026-03-19 06:23:03 +00:00
<div>
2026-03-26 09:29:55 +00:00
<h3 className="text-base font-semibold text-slate-900 dark:text-white"></h3>
<p className="crm-field-note mt-1"></p>
2026-03-19 06:23:03 +00:00
</div>
2026-03-26 09:29:55 +00:00
<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>
2026-03-19 06:23:03 +00:00
</div>
2026-03-26 09:29:55 +00:00
<div className="mb-3 flex flex-wrap gap-2">
{(["sales", "channel", "opportunity"] as BizType[]).map((bizType) => (
2026-03-23 01:03:27 +00:00
<button
2026-03-26 09:29:55 +00:00
key={bizType}
2026-03-23 01:03:27 +00:00
type="button"
2026-03-26 09:29:55 +00:00
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"
}`}
2026-03-23 01:03:27 +00:00
>
2026-03-26 09:29:55 +00:00
{getBizTypeLabel(bizType)}
2026-03-23 01:03:27 +00:00
</button>
2026-03-26 09:29:55 +00:00
))}
2026-03-19 06:23:03 +00:00
</div>
2026-03-26 09:29:55 +00:00
<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"
2026-03-19 06:23:03 +00:00
/>
</div>
</div>
2026-03-26 09:29:55 +00:00
<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>
)}
2026-03-19 06:23:03 +00:00
</div>
</div>
2026-03-26 09:29:55 +00:00
</div>
) : null}
2026-03-19 06:23:03 +00:00
2026-03-26 09:29:55 +00:00
{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}
2026-03-23 01:03:27 +00:00
2026-03-26 09:29:55 +00:00
{historyDetailItem ? (
<HistoryDetailModal
item={historyDetailItem}
onClose={() => setHistoryDetailItem(null)}
onPreviewPhoto={(url, alt) => setPreviewPhoto({ url, alt })}
/>
) : null}
2026-03-20 08:39:07 +00:00
2026-03-26 09:29:55 +00:00
{previewPhoto ? (
<PhotoPreviewModal
url={previewPhoto.url}
alt={previewPhoto.alt}
onClose={() => setPreviewPhoto(null)}
/>
) : null}
2026-03-19 06:23:03 +00:00
</div>
);
}
2026-03-23 01:03:27 +00:00
2026-03-26 09:29:55 +00:00
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);
2026-03-23 07:21:09 +00:00
2026-03-26 09:29:55 +00:00
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>
);
2026-03-23 07:21:09 +00:00
}
2026-03-26 09:29:55 +00:00
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>
);
2026-03-23 01:03:27 +00:00
}
2026-03-26 09:29:55 +00:00
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) {
2026-03-23 01:03:27 +00:00
return;
}
2026-03-26 09:29:55 +00:00
if (element instanceof HTMLTextAreaElement) {
syncAutoHeightTextarea(element);
}
element.focus();
const textLength = element.value.length;
element.setSelectionRange(textLength, textLength);
}, []);
2026-03-23 01:03:27 +00:00
2026-03-26 09:29:55 +00:00
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]);
2026-03-23 01:03:27 +00:00
2026-03-26 09:29:55 +00:00
const activatePlanItemEditor = useCallback((index: number) => {
flushSync(() => {
setEditingReportLineIndex(null);
setEditingPlanItemIndex(index);
2026-03-23 01:03:27 +00:00
});
2026-03-26 09:29:55 +00:00
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;
2026-03-23 01:03:27 +00:00
}
2026-03-26 09:29:55 +00:00
requestAnimationFrame(() => focusTextControl(reportLineTextareaRefs.current[editingReportLineIndex]));
}, [editingReportLineIndex, focusTextControl]);
2026-03-23 01:03:27 +00:00
2026-03-26 09:29:55 +00:00
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) {
2026-03-23 01:03:27 +00:00
try {
2026-03-26 09:29:55 +00:00
const address = await reverseWorkGeocode(latitude, longitude);
if (isReadableLocationText(address)) {
const normalizedAddress = normalizeLocationText(address);
locationDisplayCache.set(cacheKey, normalizedAddress);
return normalizedAddress;
}
2026-03-23 01:03:27 +00:00
} catch {
2026-03-26 09:29:55 +00:00
// 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;
2026-03-23 01:03:27 +00:00
}
2026-03-26 09:29:55 +00:00
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: "" };
2026-03-23 01:03:27 +00:00
}
2026-03-26 09:29:55 +00:00
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;
});
2026-03-23 01:03:27 +00:00
}