unis_crm/frontend/src/pages/Work.tsx

586 lines
26 KiB
TypeScript
Raw Normal View History

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