unis_crm/frontend/src/pages/Work.tsx

474 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

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

import { useEffect, useMemo, useRef, useState } from "react";
import { MapPin, Camera, Mic, Send, CalendarDays, CheckCircle2, FileText, ListTodo, Filter, RefreshCw } from "lucide-react";
import { format } from "date-fns";
import { motion } from "motion/react";
import {
getWorkOverview,
reverseWorkGeocode,
saveWorkCheckIn,
saveWorkDailyReport,
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 === "已点评" ? "已点评" : "已提交";
}
export default function Work() {
const hasAutoRefreshedLocation = useRef(false);
const [loading, setLoading] = useState(true);
const [refreshingLocation, setRefreshingLocation] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [submittingCheckIn, setSubmittingCheckIn] = useState(false);
const [submittingReport, setSubmittingReport] = useState(false);
const [historyFilter, setHistoryFilter] = useState<(typeof historyFilters)[number]>("全部");
const [locationHint, setLocationHint] = useState("");
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>();
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]);
const handleRecord = () => {
if (isRecording) {
setIsRecording(false);
return;
}
setIsRecording(true);
window.setTimeout(() => {
setReportForm((prev) => ({
...prev,
workContent: prev.workContent + (prev.workContent ? "\n" : "") + "今天拜访了A市第一人民医院信息科主任沟通了云桌面扩容需求对方表示下个月会启动招标流程。",
sourceType: "voice_assist",
}));
setIsRecording(false);
}, 2000);
};
const handleRefreshLocation = async () => {
if (!navigator.geolocation) {
setLocationHint("当前浏览器不支持定位,请手动填写当前位置。");
return;
}
setCheckInError("");
setRefreshingLocation(true);
setLocationHint("正在获取当前位置...");
navigator.geolocation.getCurrentPosition(
async (position) => {
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,
}));
setLocationHint("定位已刷新,已为你填入具体地点名称。");
} catch {
setCheckInForm((prev) => ({
...prev,
locationText: `定位坐标:${latitude}, ${longitude}`,
latitude,
longitude,
}));
setLocationHint("已获取坐标,但地点名称解析失败,你也可以手动补充。");
} finally {
setRefreshingLocation(false);
}
},
() => {
setLocationHint("定位获取失败,请手动填写当前位置。");
setRefreshingLocation(false);
},
{
enableHighAccuracy: true,
timeout: 10000,
},
);
};
const handleCheckInSubmit = async () => {
if (submittingCheckIn) {
return;
}
setCheckInError("");
setCheckInSuccess("");
setSubmittingCheckIn(true);
try {
await saveWorkCheckIn({
locationText: checkInForm.locationText.trim(),
remark: checkInForm.remark?.trim() || undefined,
longitude: checkInForm.longitude,
latitude: checkInForm.latitude,
});
await loadOverview();
setCheckInForm(defaultCheckInForm);
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,
});
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);
}
}
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">
{loading ? "加载中..." : getCheckInStatus(checkInStatus)}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<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="请输入当前位置,手机端可点击“刷新定位”获取具体地点名称..."
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"
/>
{locationHint ? (
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">{locationHint}</p>
) : null}
</div>
<div>
<p className="text-sm font-medium text-slate-900 dark:text-white mb-1"> ()</p>
<textarea
rows={2}
value={checkInForm.remark ?? ""}
onChange={(e) => setCheckInForm((prev) => ({ ...prev, remark: e.target.value }))}
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>
<div
onClick={() => void handleRefreshLocation()}
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"
>
<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">
{refreshingLocation ? "正在刷新定位..." : "点击拍照"}
</span>
</div>
{checkInError ? <p className="text-xs text-rose-500">{checkInError}</p> : null}
{checkInSuccess ? <p className="text-xs text-emerald-500">{checkInSuccess}</p> : null}
</div>
</div>
<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"
>
<CheckCircle2 className="h-4 w-4" />
{submittingCheckIn ? "提交中..." : "确认打卡"}
</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">
{loading ? "加载中..." : getReportStatus(reportStatus)}
</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>
<button
onClick={handleRecord}
className={`flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium transition-all duration-300 ${
isRecording ? "bg-rose-100 dark:bg-rose-500/20 text-rose-600 dark:text-rose-400 animate-pulse" : "bg-violet-50 dark:bg-violet-500/10 text-violet-600 dark:text-violet-400 hover:bg-violet-100 dark:hover:bg-violet-500/20"
}`}
>
<Mic className="h-3.5 w-3.5" />
{isRecording ? "正在识别..." : "语音输入 (HubMind)"}
</button>
</div>
<textarea
rows={4}
value={reportForm.workContent}
onChange={(e) => setReportForm((prev) => ({ ...prev, workContent: e.target.value, sourceType: "manual" }))}
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}
value={reportForm.tomorrowPlan}
onChange={(e) => setReportForm((prev) => ({ ...prev, tomorrowPlan: e.target.value }))}
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>
{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"
>
<Send className="h-4 w-4" />
{submittingReport ? "提交中..." : "提交日报"}
</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>
<button
onClick={handleFilterToggle}
title={`当前筛选:${historyFilter}`}
className="p-2 text-slate-400 hover:text-violet-600 dark:hover:text-violet-400 transition-colors"
>
<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">
{filteredHistory.map((item, i) => (
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.1 }}
key={`${item.type}-${item.id}-${i}`}
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">
<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" />}
</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={`rounded-full px-2 py-0.5 text-[10px] font-medium ${
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"
}`}>
{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="text-xs text-slate-700 dark:text-slate-300 line-clamp-2 leading-relaxed whitespace-pre-line">
{item.content}
</p>
{item.comment ? (
<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>
) : null}
</div>
</motion.div>
))}
{!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}
</div>
</div>
</div>
</div>
);
}