2026-03-23 01:03:27 +00:00
|
|
|
|
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
|
|
|
|
|
import { MapPin, Camera, Send, CalendarDays, CheckCircle2, FileText, ListTodo, Filter, RefreshCw, X } from "lucide-react";
|
2026-03-19 06:23:03 +00:00
|
|
|
|
import { format } from "date-fns";
|
|
|
|
|
|
import { motion } from "motion/react";
|
2026-03-20 08:39:07 +00:00
|
|
|
|
import {
|
|
|
|
|
|
getWorkOverview,
|
|
|
|
|
|
reverseWorkGeocode,
|
|
|
|
|
|
saveWorkCheckIn,
|
|
|
|
|
|
saveWorkDailyReport,
|
2026-03-23 01:03:27 +00:00
|
|
|
|
uploadWorkCheckInPhoto,
|
2026-03-20 08:39:07 +00:00
|
|
|
|
type CreateWorkCheckInPayload,
|
|
|
|
|
|
type CreateWorkDailyReportPayload,
|
|
|
|
|
|
type WorkHistoryItem,
|
|
|
|
|
|
} from "@/lib/auth";
|
|
|
|
|
|
|
|
|
|
|
|
const historyFilters = ["全部", "日报", "外勤打卡"] as const;
|
|
|
|
|
|
|
|
|
|
|
|
const defaultCheckInForm: CreateWorkCheckInPayload = {
|
|
|
|
|
|
locationText: "",
|
|
|
|
|
|
remark: "",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const defaultReportForm: CreateWorkDailyReportPayload = {
|
|
|
|
|
|
workContent: "",
|
|
|
|
|
|
tomorrowPlan: "",
|
|
|
|
|
|
sourceType: "manual",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function getCheckInStatus(status?: string) {
|
|
|
|
|
|
if (!status) {
|
|
|
|
|
|
return "待打卡";
|
|
|
|
|
|
}
|
|
|
|
|
|
return status === "updated" ? "已更新" : "已打卡";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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-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-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-20 08:39:07 +00:00
|
|
|
|
const [historyFilter, setHistoryFilter] = useState<(typeof historyFilters)[number]>("全部");
|
|
|
|
|
|
const [locationHint, setLocationHint] = useState("");
|
2026-03-23 01:03:27 +00:00
|
|
|
|
const [locationLocked, setLocationLocked] = 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[]>([]);
|
|
|
|
|
|
const [checkInStatus, setCheckInStatus] = useState<string>();
|
|
|
|
|
|
const [reportStatus, setReportStatus] = useState<string>();
|
2026-03-23 01:03:27 +00:00
|
|
|
|
const [checkInPhotoUrls, setCheckInPhotoUrls] = useState<string[]>([]);
|
2026-03-20 08:39:07 +00:00
|
|
|
|
const [checkInForm, setCheckInForm] = useState<CreateWorkCheckInPayload>(defaultCheckInForm);
|
|
|
|
|
|
const [reportForm, setReportForm] = useState<CreateWorkDailyReportPayload>(defaultReportForm);
|
|
|
|
|
|
|
|
|
|
|
|
const filteredHistory = useMemo(() => {
|
|
|
|
|
|
if (historyFilter === "全部") {
|
|
|
|
|
|
return historyData;
|
|
|
|
|
|
}
|
|
|
|
|
|
return historyData.filter((item) => item.type === historyFilter);
|
|
|
|
|
|
}, [historyData, historyFilter]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
void loadOverview();
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (loading || hasAutoRefreshedLocation.current) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
hasAutoRefreshedLocation.current = true;
|
|
|
|
|
|
void handleRefreshLocation();
|
|
|
|
|
|
}, [loading]);
|
2026-03-19 06:23:03 +00:00
|
|
|
|
|
2026-03-23 01:03:27 +00:00
|
|
|
|
const handleRefreshLocation = async () => {
|
|
|
|
|
|
setCheckInError("");
|
|
|
|
|
|
setRefreshingLocation(true);
|
|
|
|
|
|
setLocationLocked(false);
|
|
|
|
|
|
if (!window.isSecureContext && location.hostname !== "localhost" && location.hostname !== "127.0.0.1") {
|
|
|
|
|
|
setLocationHint("当前是通过 HTTP 地址在手机端访问,浏览器会直接禁止定位。请改用 HTTPS 地址打开,或先手动填写当前位置。");
|
|
|
|
|
|
setRefreshingLocation(false);
|
2026-03-20 08:39:07 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-23 01:03:27 +00:00
|
|
|
|
setLocationHint("正在获取当前位置...");
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const position = await resolveCurrentPosition();
|
|
|
|
|
|
const latitude = Number(position.coords.latitude.toFixed(6));
|
|
|
|
|
|
const longitude = Number(position.coords.longitude.toFixed(6));
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const displayName = await reverseWorkGeocode(latitude, longitude);
|
|
|
|
|
|
setCheckInForm((prev) => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
locationText: displayName || `定位坐标:${latitude}, ${longitude}`,
|
|
|
|
|
|
latitude,
|
|
|
|
|
|
longitude,
|
|
|
|
|
|
}));
|
|
|
|
|
|
setLocationLocked(Boolean(displayName));
|
|
|
|
|
|
setLocationHint(displayName
|
|
|
|
|
|
? "定位已刷新并锁定当前位置,如需变更请点击“刷新定位”。"
|
|
|
|
|
|
: "已获取定位坐标,如需更精确地址可再次刷新定位。");
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
setCheckInForm((prev) => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
locationText: `定位坐标:${latitude}, ${longitude}`,
|
|
|
|
|
|
latitude,
|
|
|
|
|
|
longitude,
|
|
|
|
|
|
}));
|
|
|
|
|
|
setLocationLocked(false);
|
|
|
|
|
|
setLocationHint("已获取坐标,但地点名称解析失败,你也可以手动补充。");
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
setLocationLocked(false);
|
|
|
|
|
|
setLocationHint(error instanceof Error ? error.message : "定位获取失败,请手动填写当前位置。");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setRefreshingLocation(false);
|
|
|
|
|
|
}
|
2026-03-20 08:39:07 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-23 01:03:27 +00:00
|
|
|
|
const handlePickPhoto = () => {
|
|
|
|
|
|
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 {
|
|
|
|
|
|
const uploadedUrl = await uploadWorkCheckInPhoto(file);
|
|
|
|
|
|
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-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-23 01:03:27 +00:00
|
|
|
|
if (!checkInPhotoUrls.length) {
|
|
|
|
|
|
throw new Error("请先拍摄并上传现场照片");
|
|
|
|
|
|
}
|
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-20 08:39:07 +00:00
|
|
|
|
});
|
|
|
|
|
|
await loadOverview();
|
|
|
|
|
|
setCheckInForm(defaultCheckInForm);
|
2026-03-23 01:03:27 +00:00
|
|
|
|
setCheckInPhotoUrls([]);
|
|
|
|
|
|
setLocationLocked(false);
|
2026-03-20 08:39:07 +00:00
|
|
|
|
setCheckInSuccess("打卡已记录,本日可继续新增打卡。");
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
setCheckInError(error instanceof Error ? error.message : "打卡提交失败");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSubmittingCheckIn(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleReportSubmit = async () => {
|
|
|
|
|
|
if (submittingReport) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setReportError("");
|
|
|
|
|
|
setReportSuccess("");
|
|
|
|
|
|
setSubmittingReport(true);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await saveWorkDailyReport({
|
|
|
|
|
|
workContent: reportForm.workContent.trim(),
|
|
|
|
|
|
tomorrowPlan: reportForm.tomorrowPlan.trim(),
|
|
|
|
|
|
sourceType: reportForm.sourceType || "manual",
|
|
|
|
|
|
});
|
|
|
|
|
|
await loadOverview();
|
|
|
|
|
|
setReportSuccess("日报已保存,今日再次提交会覆盖当天内容。");
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
setReportError(error instanceof Error ? error.message : "日报提交失败");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSubmittingReport(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleFilterToggle = () => {
|
|
|
|
|
|
setHistoryFilter((current) => {
|
|
|
|
|
|
const index = historyFilters.indexOf(current);
|
|
|
|
|
|
return historyFilters[(index + 1) % historyFilters.length];
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
async function loadOverview() {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
setPageError("");
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await getWorkOverview();
|
|
|
|
|
|
setHistoryData(data.history ?? []);
|
|
|
|
|
|
setCheckInStatus(data.todayCheckIn?.status);
|
|
|
|
|
|
setReportStatus(data.todayReport?.status);
|
|
|
|
|
|
setCheckInForm({
|
|
|
|
|
|
locationText: "",
|
|
|
|
|
|
remark: "",
|
|
|
|
|
|
longitude: undefined,
|
|
|
|
|
|
latitude: undefined,
|
|
|
|
|
|
});
|
2026-03-23 01:03:27 +00:00
|
|
|
|
setCheckInPhotoUrls([]);
|
|
|
|
|
|
setLocationLocked(false);
|
2026-03-20 08:39:07 +00:00
|
|
|
|
setReportForm({
|
|
|
|
|
|
workContent: data.suggestedWorkContent || data.todayReport?.workContent || "",
|
|
|
|
|
|
tomorrowPlan: data.todayReport?.tomorrowPlan ?? "",
|
|
|
|
|
|
sourceType: data.todayReport?.sourceType ?? "manual",
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
setPageError(error instanceof Error ? error.message : "工作台数据加载失败");
|
|
|
|
|
|
setHistoryData([]);
|
|
|
|
|
|
setCheckInStatus(undefined);
|
|
|
|
|
|
setReportStatus(undefined);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
|
<header className="flex items-center justify-between">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h1 className="text-2xl font-bold tracking-tight text-slate-900 dark:text-white">工作台</h1>
|
|
|
|
|
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
|
|
|
|
|
今天是 {format(new Date(), "yyyy年MM月dd日 EEEE")}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
|
|
|
|
|
|
<div className="lg:col-span-7 xl:col-span-8 space-y-6">
|
|
|
|
|
|
<div className="flex items-center gap-2 mb-2">
|
|
|
|
|
|
<div className="h-6 w-1 bg-violet-600 rounded-full"></div>
|
|
|
|
|
|
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">今日工作</h2>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<motion.div
|
|
|
|
|
|
initial={{ opacity: 0, y: 10 }}
|
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
|
className="rounded-2xl border border-slate-100 dark:border-slate-800 bg-white dark:bg-slate-900/50 p-6 shadow-sm backdrop-blur-sm"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-center justify-between border-b border-slate-50 dark:border-slate-800/50 pb-4 mb-4">
|
|
|
|
|
|
<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>
|
|
|
|
|
|
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10 px-2.5 py-1 rounded-full">
|
2026-03-20 08:39:07 +00:00
|
|
|
|
{loading ? "加载中..." : getCheckInStatus(checkInStatus)}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
<div>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<div className="mb-1 flex items-center justify-between gap-2">
|
|
|
|
|
|
<p className="text-sm font-medium text-slate-900 dark:text-white">当前位置</p>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => void handleRefreshLocation()}
|
|
|
|
|
|
disabled={refreshingLocation}
|
|
|
|
|
|
className="inline-flex items-center gap-1 rounded-full bg-violet-50 px-2.5 py-1 text-[11px] font-medium text-violet-600 transition-colors hover:bg-violet-100 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-violet-500/10 dark:text-violet-400 dark:hover:bg-violet-500/20"
|
|
|
|
|
|
>
|
|
|
|
|
|
<RefreshCw className={`h-3.5 w-3.5 ${refreshingLocation ? "animate-spin" : ""}`} />
|
|
|
|
|
|
{refreshingLocation ? "刷新中" : "刷新定位"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
rows={3}
|
|
|
|
|
|
value={checkInForm.locationText}
|
|
|
|
|
|
onChange={(e) => setCheckInForm((prev) => ({ ...prev, locationText: e.target.value }))}
|
|
|
|
|
|
placeholder="请输入当前位置,手机端可点击“刷新定位”获取具体地点名称..."
|
2026-03-23 01:03:27 +00:00
|
|
|
|
readOnly={locationLocked}
|
|
|
|
|
|
className={`w-full rounded-xl border border-slate-200 dark:border-slate-800 p-3 text-sm text-slate-900 dark:text-white outline-none transition-all placeholder:text-slate-400 dark:placeholder:text-slate-500 ${locationLocked ? "bg-slate-50 dark:bg-slate-800/40 cursor-not-allowed" : "bg-white dark:bg-slate-900/50 focus:border-violet-500 focus:ring-1 focus:ring-violet-500"}`}
|
2026-03-20 08:39:07 +00:00
|
|
|
|
/>
|
|
|
|
|
|
{locationHint ? (
|
|
|
|
|
|
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">{locationHint}</p>
|
|
|
|
|
|
) : null}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="text-sm font-medium text-slate-900 dark:text-white mb-1">备注说明 (选填)</p>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
rows={2}
|
2026-03-20 08:39:07 +00:00
|
|
|
|
value={checkInForm.remark ?? ""}
|
|
|
|
|
|
onChange={(e) => setCheckInForm((prev) => ({ ...prev, remark: e.target.value }))}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
placeholder="请输入打卡备注..."
|
|
|
|
|
|
className="w-full rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900/50 p-3 text-sm text-slate-900 dark:text-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-500"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-4 flex flex-col">
|
|
|
|
|
|
<p className="text-sm font-medium text-slate-900 dark:text-white mb-1">现场照片 (必填)</p>
|
2026-03-23 01:03:27 +00:00
|
|
|
|
<input
|
|
|
|
|
|
ref={photoInputRef}
|
|
|
|
|
|
type="file"
|
|
|
|
|
|
accept="image/*"
|
|
|
|
|
|
capture="environment"
|
|
|
|
|
|
className="hidden"
|
|
|
|
|
|
onChange={(event) => void handlePhotoChange(event)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{checkInPhotoUrls.length ? (
|
|
|
|
|
|
<div className="relative overflow-hidden rounded-xl border border-slate-200 bg-slate-50 dark:border-slate-700 dark:bg-slate-800/50">
|
|
|
|
|
|
<img
|
|
|
|
|
|
src={checkInPhotoUrls[0]}
|
|
|
|
|
|
alt="现场照片"
|
|
|
|
|
|
className="h-48 w-full object-cover"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={handleRemovePhoto}
|
|
|
|
|
|
className="absolute right-3 top-3 inline-flex h-8 w-8 items-center justify-center rounded-full bg-slate-900/70 text-white transition-colors hover:bg-slate-900"
|
|
|
|
|
|
>
|
|
|
|
|
|
<X className="h-4 w-4" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={handlePickPhoto}
|
|
|
|
|
|
disabled={uploadingPhoto}
|
|
|
|
|
|
className="group flex flex-1 min-h-[120px] w-full cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50 transition-all hover:border-violet-400 dark:hover:border-violet-500 hover:bg-violet-50 dark:hover:bg-violet-500/10 disabled:cursor-not-allowed disabled:opacity-60"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Camera className="mb-2 h-6 w-6 text-slate-400 dark:text-slate-500 group-hover:text-violet-500 transition-colors" />
|
|
|
|
|
|
<span className="text-xs text-slate-500 dark:text-slate-400 group-hover:text-violet-600 dark:group-hover:text-violet-400 transition-colors">
|
|
|
|
|
|
{uploadingPhoto ? "上传中..." : "点击拍照"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
|
|
|
|
|
手机端会优先调用后置相机;如相机不可用,也可从相册选择现场照片。
|
|
|
|
|
|
</p>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
{checkInError ? <p className="text-xs text-rose-500">{checkInError}</p> : null}
|
|
|
|
|
|
{checkInSuccess ? <p className="text-xs text-emerald-500">{checkInSuccess}</p> : null}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => void handleCheckInSubmit()}
|
|
|
|
|
|
disabled={submittingCheckIn || loading}
|
|
|
|
|
|
className="mt-6 flex w-full items-center justify-center gap-2 rounded-xl bg-slate-900 dark:bg-white px-4 py-3 text-sm font-semibold text-white dark:text-slate-900 shadow-sm hover:bg-slate-800 dark:hover:bg-slate-100 active:scale-[0.98] transition-all disabled:cursor-not-allowed disabled:opacity-60"
|
|
|
|
|
|
>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
<CheckCircle2 className="h-4 w-4" />
|
2026-03-20 08:39:07 +00:00
|
|
|
|
{submittingCheckIn ? "提交中..." : "确认打卡"}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</button>
|
|
|
|
|
|
</motion.div>
|
|
|
|
|
|
|
|
|
|
|
|
<motion.div
|
|
|
|
|
|
initial={{ opacity: 0, y: 10 }}
|
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
|
transition={{ delay: 0.1 }}
|
|
|
|
|
|
className="rounded-2xl border border-slate-100 dark:border-slate-800 bg-white dark:bg-slate-900/50 p-6 shadow-sm backdrop-blur-sm"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-center justify-between border-b border-slate-50 dark:border-slate-800/50 pb-4 mb-4">
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<CalendarDays 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="text-xs font-medium text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10 px-2.5 py-1 rounded-full">
|
2026-03-20 08:39:07 +00:00
|
|
|
|
{loading ? "加载中..." : getReportStatus(reportStatus)}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-5">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="mb-2 flex items-center justify-between">
|
|
|
|
|
|
<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>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
rows={4}
|
2026-03-20 08:39:07 +00:00
|
|
|
|
value={reportForm.workContent}
|
|
|
|
|
|
onChange={(e) => setReportForm((prev) => ({ ...prev, workContent: e.target.value, sourceType: "manual" }))}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
placeholder="请输入今日拜访客户、沟通进展、遇到的问题等..."
|
|
|
|
|
|
className="w-full rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900/50 p-3 text-sm text-slate-900 dark:text-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-500"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="mb-2 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>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
rows={3}
|
2026-03-20 08:39:07 +00:00
|
|
|
|
value={reportForm.tomorrowPlan}
|
|
|
|
|
|
onChange={(e) => setReportForm((prev) => ({ ...prev, tomorrowPlan: e.target.value }))}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
placeholder="1. 上午拜访...\n2. 下午整理...\n3. 推进..."
|
|
|
|
|
|
className="w-full rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900/50 p-3 text-sm text-slate-900 dark:text-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-500"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-20 08:39:07 +00:00
|
|
|
|
{reportError ? <p className="text-xs text-rose-500">{reportError}</p> : null}
|
|
|
|
|
|
{reportSuccess ? <p className="text-xs text-emerald-500">{reportSuccess}</p> : null}
|
|
|
|
|
|
{pageError ? <p className="text-xs text-rose-500">{pageError}</p> : null}
|
|
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => void handleReportSubmit()}
|
|
|
|
|
|
disabled={submittingReport || loading}
|
|
|
|
|
|
className="flex w-full items-center justify-center gap-2 rounded-xl bg-violet-600 px-4 py-3 text-sm font-semibold text-white shadow-sm hover:bg-violet-700 active:scale-[0.98] transition-all disabled:cursor-not-allowed disabled:opacity-60"
|
|
|
|
|
|
>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
<Send className="h-4 w-4" />
|
2026-03-20 08:39:07 +00:00
|
|
|
|
{submittingReport ? "提交中..." : "提交日报"}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</motion.div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="lg:col-span-5 xl:col-span-4 space-y-6 lg:sticky lg:top-6">
|
|
|
|
|
|
<div className="flex items-center justify-between mb-2">
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<div className="h-6 w-1 bg-slate-300 dark:bg-slate-700 rounded-full"></div>
|
|
|
|
|
|
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">历史记录</h2>
|
|
|
|
|
|
</div>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<button
|
|
|
|
|
|
onClick={handleFilterToggle}
|
|
|
|
|
|
title={`当前筛选:${historyFilter}`}
|
|
|
|
|
|
className="p-2 text-slate-400 hover:text-violet-600 dark:hover:text-violet-400 transition-colors"
|
|
|
|
|
|
>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
<Filter className="h-4 w-4" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-4 max-h-[calc(100vh-12rem)] overflow-y-auto pr-2 scrollbar-hide">
|
2026-03-20 08:39:07 +00:00
|
|
|
|
{filteredHistory.map((item, i) => (
|
2026-03-19 06:23:03 +00:00
|
|
|
|
<motion.div
|
|
|
|
|
|
initial={{ opacity: 0, x: 20 }}
|
|
|
|
|
|
animate={{ opacity: 1, x: 0 }}
|
|
|
|
|
|
transition={{ delay: i * 0.1 }}
|
2026-03-20 08:39:07 +00:00
|
|
|
|
key={`${item.type}-${item.id}-${i}`}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
className="group cursor-pointer rounded-2xl border border-slate-100 dark:border-slate-800 bg-white dark:bg-slate-900/50 p-4 shadow-sm backdrop-blur-sm transition-all hover:shadow-md hover:border-violet-100 dark:hover:border-violet-900/50"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-start justify-between mb-3">
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<div className={`flex h-8 w-8 items-center justify-center rounded-full ${item.type === "日报" ? "bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400" : "bg-emerald-50 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"}`}>
|
|
|
|
|
|
{item.type === "日报" ? <FileText className="h-4 w-4" /> : <MapPin className="h-4 w-4" />}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h3 className="text-sm font-semibold text-slate-900 dark:text-white">{item.type}</h3>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<p className="text-[10px] text-slate-500 dark:text-slate-400">
|
|
|
|
|
|
{[item.date, item.time].filter(Boolean).join(" ")}
|
|
|
|
|
|
</p>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex flex-col items-end gap-1">
|
|
|
|
|
|
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
2026-03-20 08:39:07 +00:00
|
|
|
|
item.status === "已点评" ? "bg-violet-50 dark:bg-violet-500/10 text-violet-600 dark:text-violet-400" :
|
|
|
|
|
|
item.status === "已阅" || item.status === "已提交" ? "bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400" :
|
|
|
|
|
|
"bg-emerald-50 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
2026-03-19 06:23:03 +00:00
|
|
|
|
}`}>
|
|
|
|
|
|
{item.status}
|
|
|
|
|
|
</span>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
{item.score ? (
|
2026-03-19 06:23:03 +00:00
|
|
|
|
<span className="text-[10px] font-bold text-rose-600 dark:text-rose-400">{item.score}分</span>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
) : null}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
|
2026-03-19 06:23:03 +00:00
|
|
|
|
<div className="pl-11">
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<p className="text-xs text-slate-700 dark:text-slate-300 line-clamp-2 leading-relaxed whitespace-pre-line">
|
2026-03-19 06:23:03 +00:00
|
|
|
|
{item.content}
|
|
|
|
|
|
</p>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
|
2026-03-23 01:03:27 +00:00
|
|
|
|
{item.photoUrls?.length ? (
|
|
|
|
|
|
<div className="mt-3 flex gap-2 overflow-x-auto pb-1">
|
|
|
|
|
|
{item.photoUrls.map((photoUrl, photoIndex) => (
|
|
|
|
|
|
<img
|
|
|
|
|
|
key={`${item.id}-photo-${photoIndex}`}
|
|
|
|
|
|
src={photoUrl}
|
|
|
|
|
|
alt={`打卡照片${photoIndex + 1}`}
|
|
|
|
|
|
className="h-16 w-16 rounded-lg border border-slate-200 object-cover dark:border-slate-700"
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
2026-03-20 08:39:07 +00:00
|
|
|
|
{item.comment ? (
|
2026-03-19 06:23:03 +00:00
|
|
|
|
<div className="mt-2 rounded-lg bg-slate-50 dark:bg-slate-800/50 p-2.5 border border-slate-100 dark:border-slate-800/50">
|
|
|
|
|
|
<p className="text-[10px] font-medium text-slate-900 dark:text-white mb-0.5">主管点评:</p>
|
|
|
|
|
|
<p className="text-[10px] text-slate-600 dark:text-slate-400">{item.comment}</p>
|
|
|
|
|
|
</div>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
) : null}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</motion.div>
|
|
|
|
|
|
))}
|
2026-03-20 08:39:07 +00:00
|
|
|
|
|
|
|
|
|
|
{!loading && filteredHistory.length === 0 ? (
|
|
|
|
|
|
<div className="rounded-2xl border border-slate-100 dark:border-slate-800 bg-white dark:bg-slate-900/50 p-6 text-center text-sm text-slate-400 dark:text-slate-500 shadow-sm">
|
|
|
|
|
|
当前没有{historyFilter === "全部" ? "" : historyFilter}记录
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-03-23 01:03:27 +00:00
|
|
|
|
|
|
|
|
|
|
function getGeoErrorMessage(error: GeolocationPositionError) {
|
|
|
|
|
|
if (!window.isSecureContext) {
|
|
|
|
|
|
return "手机端定位需要通过安全地址访问。请使用 HTTPS,或继续手动填写当前位置。";
|
|
|
|
|
|
}
|
|
|
|
|
|
switch (error.code) {
|
|
|
|
|
|
case error.PERMISSION_DENIED:
|
|
|
|
|
|
return "定位权限被拒绝,请在手机浏览器里允许位置权限后再重试。";
|
|
|
|
|
|
case error.POSITION_UNAVAILABLE:
|
|
|
|
|
|
return "当前位置暂时不可用,请移动到开阔区域后重试。";
|
|
|
|
|
|
case error.TIMEOUT:
|
|
|
|
|
|
return "定位超时,已建议切换普通精度重试;你也可以手动填写当前位置。";
|
|
|
|
|
|
default:
|
|
|
|
|
|
return "定位获取失败,请手动填写当前位置。";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getCurrentPositionAsync(options: PositionOptions) {
|
|
|
|
|
|
return new Promise<GeolocationPosition>((resolve, reject) => {
|
|
|
|
|
|
if (!navigator.geolocation) {
|
|
|
|
|
|
reject(new Error("当前浏览器不支持定位,请手动填写当前位置。"));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
navigator.geolocation.getCurrentPosition(resolve, reject, options);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function resolveCurrentPosition() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
return await getCurrentPositionAsync({
|
|
|
|
|
|
enableHighAccuracy: true,
|
|
|
|
|
|
timeout: 12000,
|
|
|
|
|
|
maximumAge: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (!(error instanceof GeolocationPositionError)) {
|
|
|
|
|
|
throw error;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
return await getCurrentPositionAsync({
|
|
|
|
|
|
enableHighAccuracy: false,
|
|
|
|
|
|
timeout: 15000,
|
|
|
|
|
|
maximumAge: 300000,
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
throw new Error(getGeoErrorMessage(error));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|