let visitMap = null;
let visitMarkers = [];
let currentFilter = "all";
let amapKey = null;
let amapSecurityJsCode = "";
let amapScriptLoaded = false;
let selectedCustomer = null;
let modalResolver = null;
let appSidebarOpen = false;
function setAuthView(loggedIn) {
const authWrap = document.getElementById("authWrap");
const appWrap = document.getElementById("appWrap");
if (authWrap) authWrap.classList.toggle("hidden", loggedIn);
if (appWrap) appWrap.classList.toggle("hidden", !loggedIn);
if (loggedIn && !visitMap) {
setTimeout(() => {
initVisitMap();
}, 300);
}
}
function setAppSidebarOpen(open) {
appSidebarOpen = !!open;
const sidebar = document.querySelector("#appWrap .sidebar");
const overlay = document.getElementById("appSidebarOverlay");
if (sidebar) sidebar.classList.toggle("open", appSidebarOpen);
if (overlay) overlay.classList.toggle("show", appSidebarOpen);
}
function bindAppSidebarDrawer() {
const menuBtn = document.getElementById("appMenuBtn");
const overlay = document.getElementById("appSidebarOverlay");
if (menuBtn) menuBtn.addEventListener("click", () => setAppSidebarOpen(!appSidebarOpen));
if (overlay) overlay.addEventListener("click", () => setAppSidebarOpen(false));
}
function showCenterModal({
title = "提示",
message = "",
confirmText = "确认",
cancelText = "取消",
showCancel = true
}) {
const overlay = document.getElementById("appModal");
const titleEl = document.getElementById("appModalTitle");
const bodyEl = document.getElementById("appModalBody");
const actions = document.getElementById("appModalActions");
const cancelBtn = document.getElementById("appModalCancelBtn");
const confirmBtn = document.getElementById("appModalConfirmBtn");
titleEl.textContent = title;
bodyEl.textContent = message;
cancelBtn.textContent = cancelText;
confirmBtn.textContent = confirmText;
cancelBtn.style.display = showCancel ? "block" : "none";
actions.classList.toggle("single", !showCancel);
overlay.classList.add("show");
return new Promise((resolve) => {
modalResolver = resolve;
});
}
function closeCenterModal(result) {
const overlay = document.getElementById("appModal");
overlay.classList.remove("show");
if (modalResolver) {
modalResolver(result);
modalResolver = null;
}
}
async function loadConfig() {
try {
const config = await api("/config");
amapKey = config.amapKey;
amapSecurityJsCode = config.amapSecurityJsCode || "";
window.AMAP_KEY = amapKey;
console.log("高德地图 Key 加载成功");
return true;
} catch (e) {
console.error("获取高德 Key 失败,使用默认值", e);
amapKey = "3e6daab967b17e5ccaba579de91c7845";
amapSecurityJsCode = "";
window.AMAP_KEY = amapKey;
return false;
}
}
function ensureAMapLoaded() {
return new Promise((resolve, reject) => {
if (window.AMap) {
amapScriptLoaded = true;
resolve();
return;
}
if (amapScriptLoaded) {
resolve();
return;
}
if (amapSecurityJsCode) {
window._AMapSecurityConfig = { securityJsCode: amapSecurityJsCode };
}
const script = document.createElement("script");
script.src = `https://webapi.amap.com/maps?v=2.0&key=${encodeURIComponent(amapKey || "3e6daab967b17e5ccaba579de91c7845")}`;
script.async = true;
script.onload = () => {
amapScriptLoaded = true;
resolve();
};
script.onerror = () => reject(new Error("高德地图 SDK 加载失败"));
document.head.appendChild(script);
});
}
async function initVisitMap() {
if (visitMap) return;
try {
await ensureAMapLoaded();
} catch (e) {
console.error(e);
return;
}
visitMap = new AMap.Map("visitMap", {
center: [121.4737, 31.2304],
zoom: 11,
mapStyle: "amap://styles/normal",
zoomControl: true,
scaleControl: true,
resizeEnable: true
});
console.log("高德地图初始化成功");
if (window.lastVisitData && window.lastVisitData.length > 0) {
renderVisitMap(window.lastVisitData);
}
}
function colorToAMap(statusColor) {
if (statusColor === "green") return "#188038";
if (statusColor === "yellow") return "#fbbc04";
if (statusColor === "half-yellow-half-green") return "#188038";
return "#d93025";
}
function markerFill(statusColor) {
if (statusColor === "half-yellow-half-green") {
return "linear-gradient(90deg, #fbbc04 50%, #188038 50%)";
}
return colorToAMap(statusColor);
}
function renderVisitMap(items) {
if (!visitMap) {
console.warn("地图尚未初始化");
return;
}
if (visitMarkers.length > 0) {
visitMap.remove(visitMarkers);
visitMarkers = [];
}
const filtered = items.filter(item => {
if (currentFilter === "all") return true;
if (currentFilter === "red" && item.status_color === "red") return true;
if (currentFilter === "green" && item.status_color === "green") return true;
if (currentFilter === "yellow" && item.status_color === "yellow") return true;
if (currentFilter === "half" && item.status_color === "half-yellow-half-green") return true;
return false;
});
console.log(`渲染 ${filtered.length} 个点 (筛选: ${currentFilter})`);
const newMarkers = [];
filtered.forEach((item) => {
if (!item.latitude || !item.longitude) return;
const marker = new AMap.Marker({
position: [parseFloat(item.longitude), parseFloat(item.latitude)],
title: item.customer_name || "未知客户",
content: `
`
});
if (item.marker_photo_url) {
marker.setLabel({
content: `
`,
direction: "top",
offset: new AMap.Pixel(0, -42)
});
}
marker.on('click', function() {
selectedCustomer = item;
resetVisitPhotoInputs();
updateSelectedCustomerInfo();
const infoWindow = new AMap.InfoWindow({
content: `
${item.customer_name || '未知客户'}
许可证: ${item.license_no}
状态: ${item.status_color}
坐标: ${parseFloat(item.latitude).toFixed(5)}, ${parseFloat(item.longitude).toFixed(5)}
`,
offset: new AMap.Pixel(0, -30)
});
infoWindow.open(visitMap, marker.getPosition());
});
visitMap.add(marker);
newMarkers.push(marker);
});
visitMarkers = newMarkers;
if (newMarkers.length > 0) {
visitMap.setFitView(newMarkers, false, [40, 40, 40, 40]);
} else {
visitMap.setZoomAndCenter(11, [121.4737, 31.2304]);
if (items.length === 0) {
new AMap.Marker({
position: [121.4737, 31.2304],
content: '暂无客户数据
请先在「客户采集」中添加客户
'
}).setMap(visitMap);
}
}
}
function resetVisitPhotoInputs() {
const startInput = document.getElementById("startPhotoInput");
const endInput = document.getElementById("endPhotoInput");
const startPreview = document.getElementById("startPhotoPreview");
const endPreview = document.getElementById("endPhotoPreview");
if (startInput) startInput.value = "";
if (endInput) endInput.value = "";
if (startPreview) startPreview.style.display = "none";
if (endPreview) endPreview.style.display = "none";
}
function updateVisitActionControls() {
const startInput = document.getElementById("startPhotoInput");
const endInput = document.getElementById("endPhotoInput");
const startBtn = document.getElementById("startVisitBtn");
const endBtn = document.getElementById("endVisitBtn");
const hasSelected = !!selectedCustomer;
const hasOpen = hasSelected && !!selectedCustomer.has_open_visit;
const hasStartFile = !!(startInput && startInput.files && startInput.files[0]);
const hasEndFile = !!(endInput && endInput.files && endInput.files[0]);
if (startInput) startInput.disabled = !hasSelected || hasOpen;
if (startBtn) startBtn.disabled = !hasSelected || hasOpen || !hasStartFile;
// 未上传第一张照片前,第二张上传入口不可用;进行中走访才允许上传第二张
if (endInput) endInput.disabled = !hasSelected || !hasOpen;
// 未上传第二张照片前,结束签到不可用
if (endBtn) endBtn.disabled = !hasSelected || !hasOpen || !hasEndFile;
}
function bindFilterButtons() {
const container = document.getElementById("statusFilter");
if (!container) return;
container.querySelectorAll("button").forEach(btn => {
btn.addEventListener("click", () => {
container.querySelectorAll("button").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
currentFilter = btn.getAttribute("data-status");
if (window.lastVisitData) {
renderVisitMap(window.lastVisitData);
}
});
});
}
function updateSelectedCustomerInfo() {
const el = document.getElementById("selectedCustomerInfo");
if (!el) return;
if (!selectedCustomer) {
el.textContent = "请点击地图上的客户标点后进行走访签到。";
updateVisitActionControls();
return;
}
const hasOpen = !!selectedCustomer.has_open_visit;
el.textContent = hasOpen
? `已选择客户:${selectedCustomer.customer_name}(${selectedCustomer.license_no}),存在进行中走访,请上传第二张照片后结束签到。`
: `已选择客户:${selectedCustomer.customer_name}(${selectedCustomer.license_no})`;
updateVisitActionControls();
}
function fileToDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
function loadImageFromDataUrl(dataUrl) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error("图片读取失败"));
img.src = dataUrl;
});
}
function canvasToDataUrl(canvas, quality) {
return canvas.toDataURL("image/jpeg", quality);
}
async function compressImageDataUrl(dataUrl, options = {}) {
const {
maxWidth = 1600,
maxHeight = 1600,
targetBytes = 900 * 1024,
minQuality = 0.5,
startQuality = 0.82
} = options;
const img = await loadImageFromDataUrl(dataUrl);
const scale = Math.min(1, maxWidth / img.width, maxHeight / img.height);
const width = Math.max(1, Math.round(img.width * scale));
const height = Math.max(1, Math.round(img.height * scale));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) return dataUrl;
ctx.drawImage(img, 0, 0, width, height);
let quality = startQuality;
let out = canvasToDataUrl(canvas, quality);
while (out.length * 0.75 > targetBytes && quality > minQuality) {
quality = Math.max(minQuality, quality - 0.08);
out = canvasToDataUrl(canvas, quality);
}
return out;
}
async function fileToCompressedDataUrl(file) {
const raw = await fileToDataUrl(file);
return compressImageDataUrl(raw);
}
function bindPhotoPreview(inputId, previewId) {
const input = document.getElementById(inputId);
const preview = document.getElementById(previewId);
if (!input || !preview) return;
input.addEventListener("change", () => {
const file = input.files && input.files[0];
if (!file) {
preview.style.display = "none";
updateVisitActionControls();
return;
}
const url = URL.createObjectURL(file);
preview.src = url;
preview.style.display = "block";
updateVisitActionControls();
});
}
async function startVisit() {
try {
if (!selectedCustomer) throw new Error("请先点击地图上的客户");
if (selectedCustomer.has_open_visit) throw new Error("该客户已有未完成走访,不能重复开始");
const file = document.getElementById("startPhotoInput").files?.[0];
if (!file) throw new Error("开始签到必须拍第一张照片");
const startPhotoUrl = await fileToCompressedDataUrl(file);
await api("/visits/start", {
method: "POST",
body: JSON.stringify({
customer_id: selectedCustomer.customer_id,
start_photo_url: startPhotoUrl,
latitude: selectedCustomer.latitude,
longitude: selectedCustomer.longitude
})
});
document.getElementById("visitActionMsg").innerText = "开始走访成功,请完成后拍第二张照片结束签到。";
await loadVisitManagement();
} catch (e) {
document.getElementById("visitActionMsg").innerText = e.message || "开始走访失败";
}
}
async function endVisit() {
try {
if (!selectedCustomer) throw new Error("请先点击地图上的客户");
if (!selectedCustomer.has_open_visit) throw new Error("该客户没有进行中的走访");
const file = document.getElementById("endPhotoInput").files?.[0];
if (!file) throw new Error("结束签到必须拍第二张照片");
const endPhotoUrl = await fileToCompressedDataUrl(file);
const result = await api("/visits/end", {
method: "POST",
body: JSON.stringify({
customer_id: selectedCustomer.customer_id,
end_photo_url: endPhotoUrl
})
});
document.getElementById("visitActionMsg").innerText = `结束走访成功,停留时长:${result.duration_text}`;
document.getElementById("startPhotoInput").value = "";
document.getElementById("endPhotoInput").value = "";
document.getElementById("startPhotoPreview").style.display = "none";
document.getElementById("endPhotoPreview").style.display = "none";
await loadVisitManagement();
updateVisitActionControls();
} catch (e) {
document.getElementById("visitActionMsg").innerText = e.message || "结束走访失败";
}
}
function bindTabs() {
document.querySelectorAll(".menu-btn[data-tab]").forEach((btn) => {
btn.addEventListener("click", () => {
document.querySelectorAll(".menu-btn[data-tab]").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
const tab = btn.getAttribute("data-tab");
document.getElementById("tabCollect").classList.toggle("hidden", tab !== "collect");
document.getElementById("tabVisit").classList.toggle("hidden", tab !== "visit");
setAppSidebarOpen(false);
if (tab === "visit") {
setTimeout(async () => {
if (!visitMap) {
await initVisitMap();
}
if (visitMap) {
visitMap.resize();
}
if (window.lastVisitData && window.lastVisitData.length > 0) {
renderVisitMap(window.lastVisitData);
} else {
await loadVisitManagement();
}
}, 120);
}
});
});
}
function switchAuthPane(mode) {
const isLogin = mode === "login";
document.getElementById("loginPane").classList.toggle("hidden", !isLogin);
document.getElementById("registerPane").classList.toggle("hidden", isLogin);
document.getElementById("showLoginBtn").classList.toggle("active", isLogin);
document.getElementById("showRegisterBtn").classList.toggle("active", !isLogin);
}
async function loginByPhone() {
try {
const phone = document.getElementById("loginPhone").value.trim();
const password = document.getElementById("loginPassword").value.trim();
if (!phone || !password) {
document.getElementById("loginMsg").innerText = "请输入手机号和密码";
return;
}
const data = await api("/auth/login", { method: "POST", body: JSON.stringify({ phone, password }) });
setToken(data.access_token);
const me = await getMe(true);
if (me.role !== "inspector") {
clearAuth();
throw new Error("请使用手机端巡检账号登录");
}
document.getElementById("loginPhone").value = "";
document.getElementById("loginPassword").value = "";
setAuthView(true);
await initAfterLogin();
document.getElementById("loginMsg").innerText = "登录成功";
} catch (e) {
document.getElementById("loginMsg").innerText = e.message;
}
}
async function registerByPhone() {
try {
const phone = document.getElementById("registerPhone").value.trim();
const password = document.getElementById("registerPassword").value.trim();
await api("/auth/register", { method: "POST", body: JSON.stringify({ phone, password }) });
document.getElementById("registerMsg").innerText = "注册成功,请使用新账号登录";
document.getElementById("registerPhone").value = "";
document.getElementById("registerPassword").value = "";
document.getElementById("loginPhone").value = phone;
document.getElementById("loginPassword").value = "";
switchAuthPane("login");
document.getElementById("loginMsg").innerText = "请登录";
} catch (e) {
document.getElementById("registerMsg").innerText = e.message;
}
}
async function collectCustomer() {
try {
const license_prefix = document.getElementById("licensePrefix").value;
const license_suffix = document.getElementById("licenseSuffix").value.trim();
const is_key_customer = !!document.getElementById("isKeyCustomer").checked;
if (!license_suffix) throw new Error("请先输入许可证号后缀");
const goCollect = await showCenterModal({
title: "确认采集",
message: "确认开始采集当前位置并保存该客户吗?",
confirmText: "确认保存",
cancelText: "取消",
showCancel: true
});
if (!goCollect) {
document.getElementById("collectMsg").innerText = "已取消采集";
return;
}
const { latitude, longitude } = await getAccurateLocation();
let row;
try {
row = await api("/customers/collect", {
method: "POST",
body: JSON.stringify({ license_prefix, license_suffix, latitude, longitude, is_key_customer })
});
} catch (e) {
if (e.status === 409) {
const yes = await showCenterModal({
title: "重复采集确认",
message: "该许可证号已采集过,是否重新采集并覆盖当前位置?",
confirmText: "覆盖保存",
cancelText: "取消",
showCancel: true
});
if (!yes) {
document.getElementById("collectMsg").innerText = "已取消重新采集";
return;
}
row = await api("/customers/collect", {
method: "POST",
body: JSON.stringify({ license_prefix, license_suffix, latitude, longitude, force: true, is_key_customer })
});
} else {
throw e;
}
}
document.getElementById("collectMsg").innerText = row.recollected
? `重新采集成功:${row.license_no},新坐标(${row.latitude}, ${row.longitude})`
: `采集成功:${row.license_no},坐标(${row.latitude}, ${row.longitude})`;
await showCenterModal({
title: "保存成功",
message: "客户位置已保存。",
confirmText: "知道了",
showCancel: false
});
document.getElementById("isKeyCustomer").checked = false;
await loadVisitManagement();
} catch (e) {
document.getElementById("collectMsg").innerText = e.message || "采集失败,请检查定位权限";
}
}
async function getAccurateLocation() {
await ensureAMapLoaded();
return new Promise((resolve, reject) => {
AMap.plugin("AMap.Geolocation", () => {
const geolocation = new AMap.Geolocation({
enableHighAccuracy: true,
timeout: 20000,
maximumAge: 0,
convert: true,
useNative: true,
GeoLocationFirst: true,
noIpLocate: 1,
noGeoLocation: 0
});
geolocation.getCurrentPosition((status, result) => {
if (status === "complete" && result && result.position) {
const locationType = String(result.location_type || "").toLowerCase();
const accuracy = Number(result.accuracy);
// 拒绝IP粗定位,避免偏到城市中心
if (locationType.includes("ip")) {
reject(new Error("当前仅获取到IP粗定位,坐标不准确。请开启GPS并重试"));
return;
}
// 精度阈值:超过150米视为不可靠
if (Number.isFinite(accuracy) && accuracy > 150) {
reject(new Error(`定位精度不足(约${Math.round(accuracy)}米),请到开阔区域重试`));
return;
}
const lat = typeof result.position.getLat === "function"
? result.position.getLat()
: result.position.lat;
const lng = typeof result.position.getLng === "function"
? result.position.getLng()
: result.position.lng;
if (Number.isFinite(lat) && Number.isFinite(lng)) {
console.log("定位成功", { lat, lng, accuracy: result.accuracy, locationType: result.location_type });
resolve({ latitude: lat, longitude: lng });
return;
}
}
reject(new Error("未获取到精准定位,请开启GPS并授予定位权限"));
});
});
});
}
async function loadVisitManagement() {
try {
const data = await api("/visit-management");
window.lastVisitData = data.items || [];
if (selectedCustomer) {
const refreshed = window.lastVisitData.find((x) => x.customer_id === selectedCustomer.customer_id);
selectedCustomer = refreshed || null;
updateSelectedCustomerInfo();
}
console.log("Loaded visit data:", window.lastVisitData.length, "customers");
renderVisitMap(window.lastVisitData);
} catch (e) {
console.error("Failed to load visit management:", e);
}
}
async function initAfterLogin() {
try {
await loadConfig();
await ensureAMapLoaded();
await loadVisitManagement();
} catch (e) {
console.error("Init after login failed:", e);
if (e.message.includes("登录") || e.message.includes("token")) {
clearAuth();
setAuthView(false);
}
}
}
document.getElementById("loginBtn").addEventListener("click", loginByPhone);
document.getElementById("registerBtn").addEventListener("click", registerByPhone);
document.getElementById("showLoginBtn").addEventListener("click", () => switchAuthPane("login"));
document.getElementById("showRegisterBtn").addEventListener("click", () => switchAuthPane("register"));
document.getElementById("collectBtn").addEventListener("click", collectCustomer);
document.getElementById("refreshVisitBtn").addEventListener("click", loadVisitManagement);
document.getElementById("startVisitBtn").addEventListener("click", startVisit);
document.getElementById("endVisitBtn").addEventListener("click", endVisit);
document.getElementById("logoutBtn").addEventListener("click", () => {
clearAuth();
setAuthView(false);
setAppSidebarOpen(false);
});
document.getElementById("appModalConfirmBtn").addEventListener("click", () => closeCenterModal(true));
document.getElementById("appModalCancelBtn").addEventListener("click", () => closeCenterModal(false));
document.getElementById("appModal").addEventListener("click", (e) => {
if (e.target === document.getElementById("appModal")) {
closeCenterModal(false);
}
});
bindTabs();
bindAppSidebarDrawer();
bindFilterButtons();
bindPhotoPreview("startPhotoInput", "startPhotoPreview");
bindPhotoPreview("endPhotoInput", "endPhotoPreview");
switchAuthPane("login");
updateSelectedCustomerInfo();
updateVisitActionControls();
if (getToken()) {
getMe(true)
.then(async (me) => {
if (!me || me.role !== "inspector") {
clearAuth();
setAuthView(false);
document.getElementById("loginMsg").innerText = "请使用手机端巡检账号登录";
return;
}
await initAfterLogin();
if (getToken()) setAuthView(true);
else setAuthView(false);
})
.catch(() => {
clearAuth();
setAuthView(false);
});
}