CF Worker 挪车最终版本

CF Worker 挪车最终版本

MoveCar 是一个基于 Cloudflare Workers 的智能挪车解决方案,通过二维码扫码即可实现匿名通知车主挪车,保护双方隐私。

 次点击
270 分钟阅读

感谢原作者大佬Github

https://github.com/zhuoershang/movecar

先展示

核心功能

  1. 多用户隔离系统:通过URL参数?u=用户名区分不同车主,每个用户独立配置

  2. 30分钟断点续传:用户刷新页面后自动恢复会话,无需重新发送通知

  3. 域名优先级二维码:支持EXTERNAL_URL自定义域名生成二维码

  4. 仅限国内访问:非中国IP返回Access Denied

车牌验证(T9虚拟键盘)

  1. 后4位验证:通过环境变量CAR_TITLE设置完整车牌,自动提取后4位作为验证码

  2. T9虚拟键盘

    • 短按数字键(0-9):直接输入数字

    • 长按2-9键:弹出字母选择浮层,手指滑动选择字母,松手确认

    • 删除键逐位删除

    • 极速响应,直接操作DOM元素

  3. 验证状态持久化:通过sessionStorage缓存验证结果,刷新页面无需重新验证

定位功能

  1. 自动获取定位:页面加载后自动请求GPS定位

  2. 30秒倒计时:定位失败后显示倒计时,30秒后才能发送通知

  3. 重新获取按钮:定位失败后可手动重新获取

  4. WGS84转GCJ02:自动转换坐标系,适配高德和苹果地图

通知发送

  1. 一键通知车主:留言+位置信息发送给车主

  2. 快捷留言标签:预设常用留言模板

  3. 通知发送频率限制:同一用户1分钟内只能发送1次

多渠道通知

  1. PushPlus:微信公众号推送

  2. WxPusher:微信推送(支持HTML格式+地图链接)

  3. Bark:iOS推送

  4. Email:通过Resend API发送邮件

  5. FlareMsg:自建消息推送

车主回复

  1. 车主确认页面:点击通知链接进入,可查看扫码者位置

  2. 一键回复:预设回复模板(马上到、暂时无法离开等)

  3. 自定义回复:支持输入文字回复

  4. 回复带定位:车主可选择分享自己的位置

  5. 实时轮询:扫码者页面每5秒轮询检查车主回复状态

展示功能(车主回复后显示)

  1. BLOG按钮

    • 文字通过BLOG_LABEL变量配置

    • 链接通过BLOG_URL变量配置

    • 可通过SHOW_BLOG=always始终显示

  2. 图片展示

    • 图片链接通过IMAGE_URL变量配置

    • 可通过SHOW_IMAGE=always始终显示

    • 支持自定义样式(宽高、圆角、阴影等)

    • 加载失败自动隐藏

其他功能

  1. 拨打车主电话:配置PHONE_NUMBER后显示拨号按钮

  2. 二维码打印页:访问/qr?u=用户名生成打印版二维码

  3. 防误触空按钮:键盘左下角空位不可点击


环境变量配置汇总

基础配置

变量名

说明

示例值

CAR_TITLE

车牌全称(后4位为验证码)

京A88888

CAR_TITLE_DEFAULT

特定用户default的车牌

沪B66666

PHONE_NUMBER

车主电话号码

13800138000

PHONE_NUMBER_DEFAULT

特定用户的电话

13900139000

EXTERNAL_URL

自定义域名

https://move.example.com

通知渠道配置

变量名

说明

示例值

PUSHPLUS_TOKEN

PushPlus推送Token

xxxxx

WXPUSHER_APP_TOKEN

WxPusher应用Token

AT_xxxxx

WXPUSHER_UID

WxPusher用户UID

UID_xxxxx

BARK_URL

Bark推送地址

https://api.day.app/xxxxx

EMAIL

接收通知的邮箱

owner@example.com

RESEND_API_KEY

Resend邮件API Key

re_xxxxx

RESEND_FROM

发件人地址

noreply@example.com

FlareMsgToken

FlareMsg推送Token

xxxxx

FlareMsgTempID

FlareMsg模板ID

xxxxx

以上变量均支持按用户配置,如 PUSHPLUS_TOKEN_DEFAULT

BLOG按钮配置

变量名

说明

示例值

BLOG_LABEL

按钮显示文字

🌐 访问车主主页

BLOG_LABEL_DEFAULT

特定用户的按钮文字

📝 我的博客

BLOG_URL

按钮跳转链接

https://www.15176688.xyz

BLOG_URL_DEFAULT

特定用户的跳转链接

https://blog.example.com

SHOW_BLOG

设为always始终显示

always

图片展示配置

变量名

说明

示例值

SHOW_IMAGE

任意值启用图片

always

SHOW_IMAGE_DEFAULT

特定用户的图片开关

yes

IMAGE_URL

图片链接

https://example.com/ad.jpg

IMAGE_URL_DEFAULT

特定用户的图片链接

https://cdn.example.com/banner.png

IMAGE_ALT

图片alt描述

车主展示图片

IMAGE_WIDTH

图片宽度

100%

IMAGE_HEIGHT

图片高度

auto

IMAGE_STYLE

自定义CSS样式

border-radius:16px;box-shadow:0 5px 15px rgba(0,0,0,0.1)

BLOG按钮和图片的显示逻辑

SHOW_BLOG值

SHOW_IMAGE值

显示时机

未设置

未设置

仅车主回复后显示

always

未设置

BLOG始终显示,图片仅回复后显示

未设置

always

BLOG仅回复后显示,图片始终显示

always

always

两者始终显示


页面路由汇总

路径

说明

/?u=用户名

扫码者主页面(通知发送)

/qr?u=用户名

二维码打印页面

/owner-confirm?u=用户名

车主确认回复页面

/api/notify

发送通知API

/api/get-location

获取扫码者位置API

/api/owner-confirm

车主确认回复API

/api/check-status

轮询检查状态API


用户页面布局顺序

扫码者页面(成功通知后):

  1. 📧 通知已送达卡片

  2. 👨‍✈️ 车主回复卡片(含地图链接)

  3. 🔄 刷新状态按钮

  4. 📞 拨打车主电话按钮(可选)

  5. 🌐 访问车主BLOG按钮(可选,默认车主回复后显示)

  6. 🖼️ 图片展示区域(可选,默认车主回复后显示)

最终代码,仅在原作者大佬代码内做了美化修改,功能基本一致

/**
 * MoveCar 多用户智能挪车系统 - v3.8
 * 新增:BLOG按钮下方预留图片展示区域,链接通过变量配置
 * 修复:验证车牌虚拟键盘支持桌面端鼠标输入字母
 */

addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request))
})

const CONFIG = {
  KV_TTL: 3600,
  SESSION_TTL: 1800,
  RATE_LIMIT_TTL: 60
}

function escapeHtml(unsafe) {
  return unsafe
    .replace(/&/g, "&")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#039;");
}

async function handleRequest(request) {
  const country = request.cf?.country;
  if (country && country !== 'CN' && country !== 'XX') {
    return new Response('Access Denied', { status: 208 });
  }
  
  const url = new URL(request.url)
  const path = url.pathname
  const userParam = url.searchParams.get('u') || 'default';
  const userKey = userParam.toLowerCase();

  if (path === '/qr') return renderQRPage(url.origin, userKey);
  if (path === '/api/notify' && request.method === 'POST') return handleNotify(request, url, userKey);
  if (path === '/api/get-location') return handleGetLocation(userKey);
  if (path === '/api/owner-confirm' && request.method === 'POST') return handleOwnerConfirmAction(request, userKey);
  
  if (path === '/api/check-status') {
    const s = url.searchParams.get('s');
    return handleCheckStatus(userKey, s);
  }

  if (path === '/owner-confirm') return renderOwnerPage(userKey);
  return renderMainPage(url.origin, userKey);
}

function getUserConfig(userKey, envPrefix) {
  const specificKey = envPrefix + "_" + userKey.toUpperCase();
  if (typeof globalThis[specificKey] !== 'undefined') return globalThis[specificKey];
  if (typeof globalThis[envPrefix] !== 'undefined') return globalThis[envPrefix];
  return null;
}

function wgs84ToGcj02(lat, lng) {
  const a = 6378245.0; const ee = 0.00669342162296594323;
  if (lng < 72.004 || lng > 137.8347 || lat < 0.8293 || lat > 55.8271) return { lat, lng };
  let dLat = transformLat(lng - 105.0, lat - 35.0);
  let dLng = transformLng(lng - 105.0, lat - 35.0);
  const radLat = lat / 180.0 * Math.PI;
  let magic = Math.sin(radLat); magic = 1 - ee * magic * magic;
  const sqrtMagic = Math.sqrt(magic);
  dLat = (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtMagic) * Math.PI);
  dLng = (dLng * 180.0) / (a / sqrtMagic * Math.cos(radLat) * Math.PI);
  return { lat: lat + dLat, lng: lng + dLng };
}

function transformLat(x, y) {
  let ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
  ret += (20.0 * Math.sin(6.0 * x * Math.PI) + 20.0 * Math.sin(2.0 * x * Math.PI)) * 2.0 / 3.0;
  ret += (20.0 * Math.sin(y * Math.PI) + 40.0 * Math.sin(y / 3.0 * Math.PI)) * 2.0 / 3.0;
  ret += (160.0 * Math.sin(y / 12.0 * Math.PI) + 320 * Math.sin(y * Math.PI / 30.0)) * 2.0 / 3.0;
  return ret;
}

function transformLng(x, y) {
  let ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
  ret += (20.0 * Math.sin(6.0 * x * Math.PI) + 20.0 * Math.sin(2.0 * x * Math.PI)) * 2.0 / 3.0;
  ret += (20.0 * Math.sin(x * Math.PI) + 40.0 * Math.sin(x / 3.0 * Math.PI)) * 2.0 / 3.0;
  ret += (150.0 * Math.sin(x / 12.0 * Math.PI) + 300.0 * Math.sin(x / 30.0 * Math.PI)) * 2.0 / 3.0;
  return ret;
}

function generateMapUrls(lat, lng) {
  const gcj = wgs84ToGcj02(lat, lng);
  return {
    amapUrl: "https://uri.amap.com/marker?position=" + gcj.lng + "," + gcj.lat + "&name=扫码者位置",
    appleUrl: "https://maps.apple.com/?ll=" + gcj.lat + "," + gcj.lng + "&q=扫码者位置"
  };
}

async function handleNotify(request, url, userKey) {
  try {
    if (typeof MOVE_CAR_STATUS === 'undefined') throw new Error('KV 未绑定');
    const lockKey = "lock_" + userKey;
    const isLocked = await MOVE_CAR_STATUS.get(lockKey);
    if (isLocked) throw new Error('发送频率过快,请一分钟后再试');

    const body = await request.json();
    const sessionId = body.sessionId; 
    const clientTimestamp = body.clientTimestamp || Date.now();

    const ppToken = getUserConfig(userKey, 'PUSHPLUS_TOKEN');
    const barkUrl = getUserConfig(userKey, 'BARK_URL');
    const email   = getUserConfig(userKey, 'EMAIL');
    const resendApiKey = getUserConfig("", 'RESEND_API_KEY');
    const resendFrom = getUserConfig("", 'RESEND_FROM') || 'noreply_huang@xian5.de5.net';
    const wxPusherAppToken = getUserConfig(userKey, 'WXPUSHER_APP_TOKEN');
    const wxPusherUid = getUserConfig(userKey, 'WXPUSHER_UID');
    const FlareMsgToken = getUserConfig(userKey, 'FlareMsgToken') || getUserConfig("", 'FlareMsgToken');
    const FlareMsgTempID = getUserConfig("", 'FlareMsgTempID') || '';
    
    const carTitle = getUserConfig(userKey, 'CAR_TITLE') || '车主';
    const baseDomain = (typeof globalThis.EXTERNAL_URL !== 'undefined' && globalThis.EXTERNAL_URL) ? globalThis.EXTERNAL_URL.replace(/\/$/, "") : url.origin;
    const confirmUrl = baseDomain + "/owner-confirm?u=" + userKey;

    let notifyText = "🚗 挪车请求【" + carTitle + "】\n💬 留言: " + (body.message || '车旁有人等待');
    const statusData = { status: 'waiting', sessionId: sessionId, sentAt: clientTimestamp };
    
    let maps = null;
    if (body.location && body.location.lat) {
      maps = generateMapUrls(body.location.lat, body.location.lng);
      await MOVE_CAR_STATUS.put("loc_" + userKey, JSON.stringify({ 
        ...body.location, 
        ...maps,
        message: body.message || '车旁有人等待'
      }), { expirationTtl: CONFIG.KV_TTL });
    } else {
      await MOVE_CAR_STATUS.put("loc_" + userKey, JSON.stringify({ 
        message: body.message || '车旁有人等待' 
      }), { expirationTtl: CONFIG.KV_TTL });
    }

    await MOVE_CAR_STATUS.put("status_" + userKey, JSON.stringify(statusData), { expirationTtl: CONFIG.SESSION_TTL });
    await MOVE_CAR_STATUS.put(lockKey, '1', { expirationTtl: CONFIG.RATE_LIMIT_TTL });

    const tasks = [];

    if (ppToken) {
      tasks.push(
        fetch('http://www.pushplus.plus/send', { 
          method: 'POST', 
          headers: { 'Content-Type': 'application/json' }, 
          body: JSON.stringify({ 
            token: ppToken, 
            title: "🚗 挪车请求:" + carTitle, 
            content: notifyText.replace(/\\n/g, '<br>') + '<br><br><a href="' + confirmUrl + '" style="font-size:18px;color:#0093E9">【点击处理】</a>', 
            template: 'html' 
          })
        }).catch(e => console.error('PushPlus error:', e))
      );
    }

    if (wxPusherAppToken && wxPusherUid) {
      let wxPusherContent = "🚗 <b>挪车请求【" + carTitle + "】</b><br><br>";
      wxPusherContent += "💬 留言:" + (body.message || '车旁有人等待') + "<br>";
      wxPusherContent += "⏰ 时间:" + new Date(clientTimestamp).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }) + "<br>";
      if (maps) {
        wxPusherContent += "<br>📍 点击查看对方位置:<br>";
        wxPusherContent += '<a href="' + maps.amapUrl + '">高德地图</a> | <a href="' + maps.appleUrl + '">苹果地图</a><br>';
      }
      wxPusherContent += '<br><a href="' + confirmUrl + '">📱 点击处理挪车</a>';
      
      tasks.push(
        fetch('https://wxpusher.zjiecode.com/api/send/message', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            appToken: wxPusherAppToken,
            content: wxPusherContent,
            contentType: 3,
            uids: [wxPusherUid],
            summary: "🚗 挪车请求:" + carTitle,
            verifyPay: false
          })
        }).catch(e => console.error('WxPusher error:', e))
      );
    }

    if (FlareMsgToken) tasks.push(fetch('https://flaremsg.xian5.de5.net/send', { 
      method: 'POST', 
      headers: { 'Content-Type': 'application/json' }, 
      body: JSON.stringify({ 
        token: FlareMsgToken, 
        temp: FlareMsgTempID,
        from: carTitle, 
        desc: (body.message || '车旁有人等待'), 
        remark: new Date(statusData.sentAt).toLocaleString('zh-CN', { 
          timeZone: 'Asia/Shanghai', 
          year: 'numeric', 
          month: '2-digit', 
          day: '2-digit', 
          hour: '2-digit', 
          minute: '2-digit', 
          second: '2-digit', 
          hour12: false 
        }).replace(/\//g, '-'), 
        url: confirmUrl 
      }) 
    }));

    if (barkUrl) tasks.push(fetch(barkUrl + "/" + encodeURIComponent('挪车请求') + "/" + encodeURIComponent(notifyText) + "?url=" + encodeURIComponent(confirmUrl)));

    if (email && resendApiKey) {
      const escapedMessage = escapeHtml(body.message || '车旁有人等待');
      let locationHtml = '';
      if (maps) {
        locationHtml = '<p><strong>扫码者位置:</strong><br><a href="' + maps.amapUrl + '">高德地图</a> | <a href="' + maps.appleUrl + '">苹果地图</a></p>';
      }
      const mailHtml = '<h2>🚗 挪车请求【' + carTitle + '】</h2><p><strong>留言:</strong>' + escapedMessage + '</p>' + locationHtml + '<p><a href="' + confirmUrl + '" style="display:inline-block;padding:10px 20px;background:#0093E9;color:#fff;text-decoration:none;border-radius:5px;">点击处理挪车</a></p>';
      tasks.push(
        fetch('https://api.resend.com/emails', {
          method: 'POST',
          headers: {
            'Authorization': 'Bearer ' + resendApiKey,
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({
            from: resendFrom,
            to: [email],
            subject: '挪车请求:' + carTitle,
            html: mailHtml
          })
        }).catch(e => console.error('Resend error:', e))
      );
    }

    await Promise.all(tasks);
    return new Response(JSON.stringify({ success: true }));
  } catch (e) {
    return new Response(JSON.stringify({ success: false, error: e.message }), { status: 500 });
  }
}

async function handleCheckStatus(userKey, clientSessionId) {
  const data = await MOVE_CAR_STATUS.get("status_" + userKey);
  if (!data) return new Response(JSON.stringify({ status: 'none' }));

  const statusObj = JSON.parse(data);
  if (statusObj.sessionId !== clientSessionId) {
    return new Response(JSON.stringify({ status: 'none' }));
  }

  const ownerLoc = await MOVE_CAR_STATUS.get("owner_loc_" + userKey);
  return new Response(JSON.stringify({ 
    status: statusObj.status, 
    ownerLocation: ownerLoc ? JSON.parse(ownerLoc) : null,
    sentAt: statusObj.sentAt || null
  }));
}

async function handleGetLocation(userKey) {
  const data = await MOVE_CAR_STATUS.get("loc_" + userKey);
  return new Response(data || '{}');
}

async function handleOwnerConfirmAction(request, userKey) {
  const body = await request.json();
  const data = await MOVE_CAR_STATUS.get("status_" + userKey);
  if (data) {
    const statusObj = JSON.parse(data);
    statusObj.status = 'confirmed';
    let ownerInfo = {};
    if (body.location) {
      const urls = generateMapUrls(body.location.lat, body.location.lng);
      ownerInfo = { ...body.location, ...urls };
    }
    if (body.replyMessage) {
      ownerInfo.replyMessage = body.replyMessage;
    }
    ownerInfo.replyAt = Date.now();
    await MOVE_CAR_STATUS.put("owner_loc_" + userKey, JSON.stringify(ownerInfo), { expirationTtl: 600 });
    await MOVE_CAR_STATUS.put("status_" + userKey, JSON.stringify(statusObj), { expirationTtl: 600 });
  }
  return new Response(JSON.stringify({ success: true }));
}

function renderQRPage(origin, userKey) {
  const carTitle = getUserConfig(userKey, 'CAR_TITLE') || '车主';
  let baseDomain = (typeof globalThis.EXTERNAL_URL !== 'undefined' && globalThis.EXTERNAL_URL) ? globalThis.EXTERNAL_URL.replace(/\/$/, "") : origin;
  const targetUrl = baseDomain + "/?u=" + userKey;
  return new Response('<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>制作挪车码</title><style>body{font-family:sans-serif;background:#f8fafc;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}.qr-card{background:white;padding:40px 20px;border-radius:30px;box-shadow:0 10px 40px rgba(0,0,0,0.05);text-align:center;width:90%;max-width:380px}.qr-img{width:250px;height:250px;margin:25px auto;border:1px solid #f1f5f9;padding:8px;border-radius:12px}.btn{display:block;background:#0093E9;color:white;text-decoration:none;padding:16px;border-radius:16px;font-weight:bold;margin-top:20px}.url-info{font-size:11px;color:#cbd5e1;margin-top:15px;word-break:break-all}</style></head><body><div class="qr-card"><h2 style="color:#1e293b">' + carTitle + ' 的专属挪车码</h2><p style="color:#64748b;font-size:14px;margin-top:8px">扫码通知,保护隐私</p><img class="qr-img" src="https://api.qrserver.com/v1/create-qr-code/?size=450x450&data=' + encodeURIComponent(targetUrl) + '"><a href="javascript:window.print()" class="btn">🖨️ 立即打印挪车牌</a><div class="url-info">' + targetUrl + '</div></div></body></html>', { headers: { 'Content-Type': 'text/html;charset=UTF-8' } });
}

/** 界面渲染:扫码者页(BLOG按钮 + 图片展示) **/
function renderMainPage(origin, userKey) {
  const phone = getUserConfig(userKey, 'PHONE_NUMBER') || '';
  const carTitle = getUserConfig(userKey, 'CAR_TITLE') || '车主A888';
  const phoneHtml = phone ? '<a href="tel:' + phone + '" class="btn-phone">📞 拨打车主电话</a>' : '';
  
  const lastFour = carTitle.length >= 4 ? carTitle.slice(-4) : carTitle;
  const needVerify = (carTitle !== '车主' && carTitle.length >= 4);

  // BLOG按钮配置
  const blogLabel = getUserConfig(userKey, 'BLOG_LABEL') || '🌐 访问车主主页';
  const blogUrl = getUserConfig(userKey, 'BLOG_URL') || 'https://www.15176688.xyz';
  const showBlog = getUserConfig(userKey, 'SHOW_BLOG'); // "always" 始终显示

  // 图片配置
  const showImage = getUserConfig(userKey, 'SHOW_IMAGE'); // 任意值即启用图片
  const imageUrl = getUserConfig(userKey, 'IMAGE_URL') || '';
  const imageAlt = getUserConfig(userKey, 'IMAGE_ALT') || '车主展示图片';
  const imageWidth = getUserConfig(userKey, 'IMAGE_WIDTH') || '100%';
  const imageHeight = getUserConfig(userKey, 'IMAGE_HEIGHT') || 'auto';
  const imageStyle = getUserConfig(userKey, 'IMAGE_STYLE') || 'max-width:100%;border-radius:16px;margin-top:10px;box-shadow:0 5px 15px rgba(0,0,0,0.1)';

  // 图片HTML - 只有配置了IMAGE_URL且SHOW_IMAGE有值时才生成
  let imageHtml = '';
  if (showImage && imageUrl) {
    imageHtml = '<div id="displayImage" class="image-container hidden"><img id="displayImageImg" src="' + imageUrl + '" alt="' + imageAlt + '" style="' + imageStyle + ';width:' + imageWidth + ';height:' + imageHeight + '" onload="this.parentElement.classList.remove(\'hidden\')" onerror="this.parentElement.classList.add(\'hidden\')"></div>';
  }

  return new Response('<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, viewport-fit=cover, user-scalable=no"><title>挪车通知</title><style>*{box-sizing:border-box;-webkit-tap-highlight-color:transparent;margin:0;padding:0}body{font-family:-apple-system,sans-serif;background:linear-gradient(160deg,#0093E9 0%,#80D0C7 100%);min-height:100vh;padding:20px;display:flex;justify-content:center}.container{width:100%;max-width:500px;display:flex;flex-direction:column;gap:15px}.card{background:white;border-radius:24px;padding:20px;box-shadow:0 10px 30px rgba(0,0,0,0.1)}.header{text-align:center}.icon-wrap{width:64px;height:64px;background:#0093E9;border-radius:20px;display:flex;align-items:center;justify-content:center;margin:0 auto 10px;font-size:32px;color:white}textarea{width:100%;min-height:90px;border:1px solid #eee;border-radius:14px;padding:15px;font-size:16px;outline:none;margin-top:10px;background:#fcfcfc;resize:none}.tag{display:inline-block;background:#f1f5f9;padding:10px 16px;border-radius:20px;font-size:14px;margin:5px 3px;cursor:pointer;color:#475569}.btn-main{background:#0093E9;color:white;border:none;padding:18px;border-radius:18px;font-size:18px;font-weight:bold;cursor:pointer;width:100%}.btn-main:disabled{background:#94a3b8;cursor:not-allowed}.btn-phone{background:#ef4444;color:white;border:none;padding:15px;border-radius:15px;text-decoration:none;text-align:center;font-weight:bold;display:block;margin-top:10px}.btn-blog{background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);color:white;border:none;padding:16px;border-radius:18px;font-size:17px;font-weight:bold;cursor:pointer;width:100%;text-decoration:none;text-align:center;display:none;margin-top:10px;box-shadow:0 5px 15px rgba(102,126,234,0.4);transition:transform 0.15s,box-shadow 0.15s}.btn-blog:active{transform:scale(0.96);box-shadow:0 3px 10px rgba(102,126,234,0.3)}.image-container{text-align:center;margin-top:10px}.image-container img{display:block;max-width:100%;height:auto;border-radius:16px;box-shadow:0 5px 15px rgba(0,0,0,0.1)}.btn-retry{background:#f59e0b;color:white;border:none;padding:8px 16px;border-radius:20px;font-size:13px;cursor:pointer;margin-left:10px}.hidden{display:none!important}.map-links{display:flex;gap:10px;margin-top:15px}.map-btn{flex:1;padding:14px;border-radius:14px;text-align:center;text-decoration:none;color:white;font-weight:bold}.amap{background:#1890ff}.apple{background:#000}.code-inputs{display:flex;justify-content:center;gap:10px;margin:15px 0}.code-inputs input{width:55px;height:65px;text-align:center;font-size:28px;font-weight:bold;border:2px solid #e2e8f0;border-radius:12px;outline:none;transition:all 0.15s;background:#f8fafc;text-transform:uppercase;pointer-events:none;caret-color:transparent}.code-inputs input.active{border-color:#0093E9;box-shadow:0 0 0 3px rgba(0,147,233,0.3);background:#eff6ff}.code-inputs input.filled{border-color:#10b981;background:#f0fdf4}.error-msg{color:#ef4444;font-size:14px;min-height:20px;text-align:center;margin-top:5px}.verify-btn{background:#10b981;color:white;border:none;padding:16px;border-radius:18px;font-size:18px;font-weight:bold;cursor:pointer;width:100%;margin-top:15px;transition:background 0.2s}.verify-btn:active{background:#059669}.loc-row{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap}.loc-text{font-size:13px;color:#94a3b8}.countdown-msg{font-size:14px;color:#f97316;text-align:center;margin:8px 0;min-height:24px;background-color:#fff3e0;padding:6px 12px;border-radius:20px;font-weight:500;display:none}.numpad{background:#f1f5f9;border-radius:20px;padding:12px;margin-top:15px;user-select:none;-webkit-user-select:none;touch-action:manipulation}.numpad-row{display:flex;gap:8px;margin-bottom:8px}.numpad-row:last-child{margin-bottom:0}.numpad-key{flex:1;height:58px;border:none;border-radius:12px;font-size:24px;font-weight:700;cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;color:#1e293b;background:white;box-shadow:0 2px 6px rgba(0,0,0,0.06);-webkit-tap-highlight-color:transparent;touch-action:manipulation;transition:transform 0.05s,background 0.05s;will-change:transform}.numpad-key:active{transform:scale(0.93);background:#e2e8f0;box-shadow:0 1px 3px rgba(0,0,0,0.08)}.numpad-key-num{background:#0093E9;color:white;font-size:26px}.numpad-key-num:active{background:#0077cc}.numpad-key-delete{background:#ef4444;color:white;font-size:18px}.numpad-key-delete:active{background:#dc2626}.numpad-key-empty{background:transparent;box-shadow:none;cursor:default;pointer-events:none}.key-sub{font-size:10px;color:#94a3b8;display:block;line-height:1;margin-top:-2px}.numpad-key-num .key-sub{color:rgba(255,255,255,0.7)}.letter-popup{position:fixed;background:rgba(0,0,0,0.92);border-radius:20px;padding:8px 10px;display:flex;gap:6px;z-index:9999;box-shadow:0 10px 30px rgba(0,0,0,0.5);flex-wrap:wrap;justify-content:center;pointer-events:auto}.popup-letter{color:white;font-size:24px;font-weight:700;padding:10px 14px;border-radius:14px;background:rgba(255,255,255,0.15);min-width:44px;text-align:center;transition:background 0.08s,transform 0.08s}.popup-letter.highlight{background:#0093E9;transform:scale(1.12)}@media(max-width:400px){.numpad-key{height:50px;font-size:22px;border-radius:10px}.numpad-key-num{font-size:24px}.numpad{padding:10px}.numpad-row{gap:6px;margin-bottom:6px}.code-inputs input{width:48px;height:58px;font-size:26px}}</style></head><body><div class="container" id="verifyView"' + (needVerify ? '' : ' style="display:none"') + '><div class="card"><div class="icon-wrap">🔐</div><h2 style="color:#1e293b">验证车牌</h2><p style="color:#64748b;margin-top:5px">请输入车牌号后四位,长按键盘可滑动切换英文</p><div class="code-inputs" id="codeInputs"><input type="text" maxlength="1" class="code-digit" id="digit0" readonly><input type="text" maxlength="1" class="code-digit" id="digit1" readonly><input type="text" maxlength="1" class="code-digit" id="digit2" readonly><input type="text" maxlength="1" class="code-digit" id="digit3" readonly></div><div class="error-msg" id="verifyError"></div><div class="numpad" id="numpad"><div class="numpad-row"><button class="numpad-key numpad-key-num" data-key="1">1</button><button class="numpad-key numpad-key-num" data-key="2">2<span class="key-sub">ABC</span></button><button class="numpad-key numpad-key-num" data-key="3">3<span class="key-sub">DEF</span></button></div><div class="numpad-row"><button class="numpad-key numpad-key-num" data-key="4">4<span class="key-sub">GHI</span></button><button class="numpad-key numpad-key-num" data-key="5">5<span class="key-sub">JKL</span></button><button class="numpad-key numpad-key-num" data-key="6">6<span class="key-sub">MNO</span></button></div><div class="numpad-row"><button class="numpad-key numpad-key-num" data-key="7">7<span class="key-sub">PQRS</span></button><button class="numpad-key numpad-key-num" data-key="8">8<span class="key-sub">TUV</span></button><button class="numpad-key numpad-key-num" data-key="9">9<span class="key-sub">WXYZ</span></button></div><div class="numpad-row"><button class="numpad-key numpad-key-empty"></button><button class="numpad-key numpad-key-num" data-key="0">0</button><button class="numpad-key numpad-key-delete" data-key="delete">⌫</button></div></div><button class="verify-btn" id="verifyBtn">验证</button></div></div><div class="container ' + (needVerify ? 'hidden' : '') + '" id="mainView"><div class="card header"><div class="icon-wrap">🚗</div><h2 style="color:#1e293b">呼叫 ' + carTitle + '</h2><p style="color:#64748b;font-size:14px;margin-top:5px">提示:车主将收到即时提醒</p></div><div class="card"><textarea id="msgInput" placeholder="请输入留言...\n(获取定位后通知,车主回复更快哦!)"></textarea><div style="margin-top:5px"><div class="tag" onclick="setTag(\'麻烦挪下车,谢谢\')">🚧 挡路了</div><div class="tag" onclick="setTag(\'有急事外出,速来\')">🏃 急事</div><div class="tag" onclick="setTag(\'有叔叔贴条,速度来挪车!\')">⏱️ 温馨提醒</div><div class="tag" onclick="setTag(\'请挪车,我在你车旁,请查看位置,尽快前来!\')">🏃 发送我的位置</div><div class="tag" onclick="setTag(\'这是我的车位,我要用了,谢谢\')">🚧 占我车位</div></div></div><div class="card" id="locStatusCard"><div class="loc-row"><span class="loc-text" id="locStatus">定位请求中...</span><button id="retryLocationBtn" class="btn-retry" style="display:none" onclick="retryLocation()">重新获取</button></div></div><div class="countdown-msg" id="countdownMsg"></div><button id="notifyBtn" class="btn-main" onclick="sendNotify()">🔔 一键通知车主</button></div><div class="container hidden" id="successView"><div class="card" style="text-align:center"><div style="font-size:64px;margin-bottom:15px">📧</div><h2 style="color:#1e293b">通知已送达</h2><p style="color:#64748b">车主已收到挪车请求,请在车旁稍候</p><p id="sentTimeDisplay" style="font-size:12px;color:#888;margin-top:5px"></p></div><div id="ownerFeedback" class="card hidden" style="text-align:center;border:2.5px solid #10b981"><div style="font-size:40px">👨‍✈️</div><h3 id="ownerReplyMsg" style="color:#059669">车主回复:马上到</h3><div class="map-links"><a id="ownerAmap" href="#" class="map-btn amap">高德地图</a><a id="ownerApple" href="#" class="map-btn apple">苹果地图</a></div></div><div><button class="btn-main" style="background:#f59e0b;margin-top:10px" onclick="location.reload()">🔄 刷新状态</button>' + phoneHtml + '<a id="blogBtn" class="btn-blog" href="' + blogUrl + '" target="_blank" rel="noopener">' + blogLabel + '</a>' + imageHtml + '</div></div><div id="letterPopup" class="letter-popup hidden"></div><script>!function(){var userLoc=null;var userKey="' + userKey + '";var correctLastFour="' + lastFour + '";var needVerify=' + (needVerify ? 'true' : 'false') + ';var showBlogAlways=' + (showBlog === 'always' ? 'true' : 'false') + ';var showImageAlways=' + (showImage ? 'true' : 'false') + ';var locationReady=false;var countdown=30;var countdownInterval=null;var sessionId=localStorage.getItem("movecar_session_"+userKey);if(!sessionId){sessionId=Date.now()+"_"+Math.random().toString(36).substr(2,9);localStorage.setItem("movecar_session_"+userKey,sessionId)}var verifiedFlag="verified_"+userKey;var isVerified=sessionStorage.getItem(verifiedFlag)==="true";if(!needVerify){initMain()}else{if(isVerified){document.getElementById("verifyView").style.display="none";document.getElementById("mainView").classList.remove("hidden");initMain()}else{initVerify()}}function initVerify(){var inputs=[document.getElementById("digit0"),document.getElementById("digit1"),document.getElementById("digit2"),document.getElementById("digit3")];var verifyBtn=document.getElementById("verifyBtn");var errorDiv=document.getElementById("verifyError");var popup=document.getElementById("letterPopup");var keyLetterMap={"2":["A","B","C"],"3":["D","E","F"],"4":["G","H","I"],"5":["J","K","L"],"6":["M","N","O"],"7":["P","Q","R","S"],"8":["T","U","V"],"9":["W","X","Y","Z"]};var longPressTimer=null;var longPressKey=null;var popupVisible=false;var popupLetters=[];var highlightedIndex=-1;function getFilledCount(){var n=0;for(var i=0;i<4;i++){if(inputs[i].value!="")n++}return n}function inputChar(ch){if(getFilledCount()>=4)return;errorDiv.textContent="";var c=ch.toUpperCase();for(var i=0;i<4;i++){if(inputs[i].value===""){inputs[i].value=c;inputs[i].classList.add("filled");break}}updateCursor();if(getFilledCount()>=4){for(var j=0;j<4;j++)inputs[j].classList.remove("active")}}function deleteChar(){errorDiv.textContent="";for(var i=3;i>=0;i--){if(inputs[i].value!==""){inputs[i].value="";inputs[i].classList.remove("filled");break}}updateCursor()}function updateCursor(){for(var i=0;i<4;i++){inputs[i].classList.remove("active")}for(var k=0;k<4;k++){if(inputs[k].value===""){inputs[k].classList.add("active");return}}for(var j=0;j<4;j++)inputs[j].classList.remove("active")}function showPopup(keyChar,x,y){var letters=keyLetterMap[keyChar];if(!letters)return;popupLetters=letters;highlightedIndex=0;popup.innerHTML="";for(var i=0;i<letters.length;i++){var s=document.createElement("span");s.className="popup-letter";s.textContent=letters[i];s.setAttribute("data-idx",i);popup.appendChild(s)}var left=x-60,top=y-80;if(left<10)left=10;if(left+180>window.innerWidth)left=window.innerWidth-190;if(top<20)top=20;popup.style.left=left+"px";popup.style.top=top+"px";popup.classList.remove("hidden");popupVisible=true;updateHighlight()}function updateHighlight(){var items=popup.querySelectorAll(".popup-letter");for(var i=0;i<items.length;i++){items[i].classList.toggle("highlight",i===highlightedIndex)}}function hidePopup(){popup.classList.add("hidden");popupVisible=false;popupLetters=[];highlightedIndex=-1;if(longPressTimer){clearTimeout(longPressTimer);longPressTimer=null}longPressKey=null}function selectLetter(l){if(l&&getFilledCount()<4)inputChar(l);hidePopup()}function handlePopupMove(cx,cy){if(!popupVisible)return;var items=popup.querySelectorAll(".popup-letter");var closest=0,minDist=1e9;for(var i=0;i<items.length;i++){var r=items[i].getBoundingClientRect();var d=Math.hypot(cx-(r.left+r.width/2),cy-(r.top+r.height/2));if(d<minDist){minDist=d;closest=i}}if(closest!==highlightedIndex){highlightedIndex=closest;updateHighlight()}}var numpad=document.getElementById("numpad");var keys=numpad.querySelectorAll(".numpad-key:not(.numpad-key-empty)");function handleKeyStart(kv,clientX,clientY){if(kv==="delete"){deleteChar();return}if(kv==="0"||kv==="1"){if(getFilledCount()<4)inputChar(kv);return}longPressKey=kv;longPressTimer=setTimeout(function(){if(longPressKey===kv&&getFilledCount()<4){showPopup(kv,clientX||100,clientY||250)}longPressTimer=null},("ontouchstart"in window?350:500))}function handleKeyEnd(kv){if(popupVisible&&longPressKey===kv){if(highlightedIndex>=0&&highlightedIndex<popupLetters.length){selectLetter(popupLetters[highlightedIndex])}else{hidePopup()}}else{if(longPressTimer){clearTimeout(longPressTimer);longPressTimer=null;if(getFilledCount()<4)inputChar(kv)}}longPressKey=null}for(var k=0;k<keys.length;k++){(function(btn){var kv=btn.getAttribute("data-key");btn.addEventListener("mousedown",function(e){e.preventDefault();handleKeyStart(kv,e.clientX,e.clientY)});btn.addEventListener("touchstart",function(e){e.preventDefault();var t=e.touches[0];handleKeyStart(kv,t?t.clientX:null,t?t.clientY:null)});btn.addEventListener("touchmove",function(e){e.preventDefault();if(popupVisible&&longPressKey===kv){var t=e.touches[0];if(t)handlePopupMove(t.clientX,t.clientY)}else{if(longPressTimer){clearTimeout(longPressTimer);longPressTimer=null}}});btn.addEventListener("touchend",function(e){e.preventDefault();handleKeyEnd(kv)});btn.addEventListener("touchcancel",function(){hidePopup()})})(keys[k])}document.addEventListener("mousemove",function(e){if(popupVisible&&longPressKey){handlePopupMove(e.clientX,e.clientY)}});document.addEventListener("mouseup",function(e){if(popupVisible&&longPressKey){if(highlightedIndex>=0&&highlightedIndex<popupLetters.length){selectLetter(popupLetters[highlightedIndex])}else{hidePopup()}}else{if(longPressTimer){clearTimeout(longPressTimer);longPressTimer=null;if(longPressKey&&getFilledCount()<4)inputChar(longPressKey)}}longPressKey=null});document.addEventListener("click",function(e){if(popupVisible&&!e.target.closest("#numpad")&&!e.target.closest("#letterPopup")){hidePopup()}});verifyBtn.addEventListener("click",function(){var code="";for(var i=0;i<4;i++)code+=inputs[i].value;code=code.toUpperCase();if(code.length!==4){errorDiv.textContent="请输入完整的四位验证码";return}if(code===correctLastFour){sessionStorage.setItem(verifiedFlag,"true");document.getElementById("verifyView").style.display="none";document.getElementById("mainView").classList.remove("hidden");initMain()}else{errorDiv.textContent="验证码错误,请重新输入";for(var j=0;j<4;j++){inputs[j].value="";inputs[j].classList.remove("filled","active")}updateCursor()}});updateCursor()}function initMain(){if(window._mainInitDone)return;window._mainInitDone=true;initMainAsync().catch(function(e){console.error(e)})}async function initMainAsync(){var hasActive=await checkActiveSession();if(hasActive){console.log("active session");return}resetLocation()}function resetLocation(){if(countdownInterval){clearInterval(countdownInterval);countdownInterval=null}countdown=30;locationReady=false;var md=document.getElementById("countdownMsg");if(md)md.style.display="none";var rb=document.getElementById("retryLocationBtn");if(rb)rb.style.display="none";if(navigator.geolocation){navigator.geolocation.getCurrentPosition(function(p){userLoc={lat:p.coords.latitude,lng:p.coords.longitude};locationReady=true;document.getElementById("locStatus").innerText="已获取位置";document.getElementById("locStatus").style.color="#10b981";if(countdownInterval){clearInterval(countdownInterval);countdownInterval=null}var md2=document.getElementById("countdownMsg");if(md2)md2.style.display="none";var rb2=document.getElementById("retryLocationBtn");if(rb2)rb2.style.display="none";document.getElementById("notifyBtn").disabled=false},function(err){document.getElementById("locStatus").innerText="无法获取精确位置";document.getElementById("locStatus").style.color="#ef4444";var rb3=document.getElementById("retryLocationBtn");if(rb3)rb3.style.display="inline-block";if(!locationReady&&!countdownInterval)startCountdown()},{timeout:10000})}else{document.getElementById("locStatus").innerText="浏览器不支持定位";document.getElementById("locStatus").style.color="#ef4444";var rb4=document.getElementById("retryLocationBtn");if(rb4)rb4.style.display="inline-block";if(!locationReady&&!countdownInterval)startCountdown()}}function startCountdown(){countdown=30;var btn=document.getElementById("notifyBtn");var md=document.getElementById("countdownMsg");if(!md)return;md.style.display="block";btn.disabled=true;md.innerText="定位获取失败,等待 "+countdown+" 秒后可发送";countdownInterval=setInterval(function(){countdown--;if(countdown<=0){clearInterval(countdownInterval);countdownInterval=null;btn.disabled=false;md.innerText="现在可以发送通知(位置未获取)"}else{md.innerText="定位获取失败,等待 "+countdown+" 秒后可发送"}},1000)}window.retryLocation=function(){if(!navigator.geolocation){alert("浏览器不支持定位功能");return}var btn=document.getElementById("retryLocationBtn");btn.disabled=true;btn.innerText="获取中...";navigator.geolocation.getCurrentPosition(function(p){userLoc={lat:p.coords.latitude,lng:p.coords.longitude};locationReady=true;document.getElementById("locStatus").innerText="已获取位置";document.getElementById("locStatus").style.color="#10b981";if(countdownInterval){clearInterval(countdownInterval);countdownInterval=null}var md2=document.getElementById("countdownMsg");if(md2)md2.style.display="none";btn.style.display="none";document.getElementById("notifyBtn").disabled=false;btn.disabled=false;btn.innerText="重新获取"},function(err){alert("再次获取位置失败,请允许定位权限");btn.disabled=false;btn.innerText="重新获取";if(!locationReady&&!countdownInterval)startCountdown()},{timeout:10000})};async function checkActiveSession(){try{var res=await fetch("/api/check-status?u="+userKey+"&s="+sessionId);var data=await res.json();if(data.status&&data.status!=="none"){showSuccess(data);pollStatus();return true}}catch(e){}return false}window.setTag=function(t){document.getElementById("msgInput").value=t};window.sendNotify=async function(){var btn=document.getElementById("notifyBtn");if(!locationReady&&countdown>0){alert("尚未获取到您的位置,请给定位权限,或等待 "+countdown+" 秒后再试");return}btn.disabled=true;btn.innerText="正在联络车主...";try{var res=await fetch("/api/notify?u="+userKey,{method:"POST",body:JSON.stringify({message:document.getElementById("msgInput").value,location:userLoc,sessionId:sessionId,clientTimestamp:Date.now()})});var data=await res.json();if(data.success){showSuccess({status:"waiting",sentAt:Date.now()});pollStatus()}else{alert(data.error);btn.disabled=false;btn.innerText="一键通知车主"}}catch(e){alert("服务暂时不可用");btn.disabled=false}};function showSuccess(data){document.getElementById("mainView").classList.add("hidden");document.getElementById("successView").classList.remove("hidden");updateUI(data)}function updateUI(data){var td=document.getElementById("sentTimeDisplay");if(data.sentAt){var s=Math.floor((Date.now()-data.sentAt)/1000);td.innerText="(发送于 "+s+" 秒前)"}else{td.innerText=""}var blogBtn=document.getElementById("blogBtn");var imgContainer=document.getElementById("displayImage");if(showBlogAlways){if(blogBtn)blogBtn.style.display="block"}else if(data.status==="confirmed"){if(blogBtn)blogBtn.style.display="block"}else{if(blogBtn)blogBtn.style.display="none"}if(showImageAlways){if(imgContainer)imgContainer.classList.remove("hidden")}else if(data.status==="confirmed"){if(imgContainer)imgContainer.classList.remove("hidden")}else{if(imgContainer)imgContainer.classList.add("hidden")}if(data.status==="confirmed"){document.getElementById("ownerFeedback").classList.remove("hidden");var rm=data.ownerLocation?.replyMessage||"车主已确认,马上到";var rs=data.ownerLocation?.replyAt?Math.floor((Date.now()-data.ownerLocation.replyAt)/1000):0;document.getElementById("ownerReplyMsg").innerText="车主回复 ("+rs+" 秒前):"+rm;if(data.ownerLocation){document.getElementById("ownerAmap").href=data.ownerLocation.amapUrl||"#";document.getElementById("ownerApple").href=data.ownerLocation.appleUrl||"#"}}}function pollStatus(){if(window.pollInterval)clearInterval(window.pollInterval);window.pollInterval=setInterval(async function(){try{var res=await fetch("/api/check-status?u="+userKey+"&s="+sessionId);updateUI(await res.json())}catch(e){}},5000)}}();</script></body></html>', { headers: { 'Content-Type': 'text/html;charset=UTF-8' } });
}

/** 界面渲染:车主页 **/
function renderOwnerPage(userKey) {
  const carTitle = getUserConfig(userKey, 'CAR_TITLE') || '车主';
  return new Response('<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>挪车处理</title><style>body{font-family:sans-serif;background:#4f46e5;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;padding:20px}.card{background:white;padding:35px 25px;border-radius:30px;text-align:center;width:100%;max-width:400px;box-shadow:0 20px 40px rgba(0,0,0,0.2)}.btn{background:#10b981;color:white;border:none;width:100%;padding:20px;border-radius:18px;font-size:18px;font-weight:bold;cursor:pointer;margin-top:20px;box-shadow:0 5px 15px rgba(16,185,129,0.3)}.btn-secondary{background:#6b7280}.map-box{background:#f8fafc;padding:20px;border-radius:20px;margin-top:15px;border:1px solid #e2e8f0;display:none}.map-btn{display:inline-block;padding:12px 18px;background:#2563eb;color:white;text-decoration:none;border-radius:12px;margin:5px;font-size:14px}.reply-section{margin-top:20px;border-top:1px solid #e2e8f0;padding-top:15px}.fold-btn{background:#e2e8f0;color:#1e293b;border:none;padding:10px;border-radius:30px;font-size:14px;cursor:pointer;width:100%;margin-bottom:10px}.fold-content{display:none}.textarea-reply{width:100%;min-height:80px;border:1px solid #ccc;border-radius:14px;padding:12px;font-size:16px;margin-top:10px;resize:vertical}.tag{display:inline-block;background:#f1f5f9;padding:8px 12px;border-radius:20px;font-size:14px;margin:5px 3px;cursor:pointer;color:#475569}.tag:hover{background:#e2e8f0}.btn-send{background:#2563eb;color:white;border:none;padding:16px;border-radius:18px;font-size:16px;font-weight:bold;cursor:pointer;width:100%;margin-top:15px}.time-text{font-size:12px;color:#94a3b8;margin:5px 0}.msg-box{background:#f1f5f9;padding:15px;border-radius:16px;margin:10px 0;text-align:left}.msg-content{font-size:16px;color:#1e293b}</style></head><body><div class="card"><div style="font-size:50px">📣</div><h2 style="margin:15px 0;color:#1e293b">' + carTitle + '</h2><div id="visitorMsgBox" class="msg-box" style="display:none"><div class="msg-content" id="visitorMsg"></div><div class="time-text" id="visitorMsgTime"></div></div><p style="color:#64748b">有人正在车旁等您,请确认:</p><div id="mapArea" class="map-box"><p style="font-size:14px;color:#2563eb;margin-bottom:12px;font-weight:bold">对方实时位置 📍</p><a id="amapLink" href="#" class="map-btn">高德地图</a><a id="appleLink" href="#" class="map-btn" style="background:#000">苹果地图</a></div><button id="confirmBtn" class="btn" onclick="confirmMove()">🚀 我已知晓,马上过去</button><div class="reply-section"><button id="foldBtn" class="fold-btn" onclick="toggleFold()">✏️ 发送其他回复</button><div id="foldContent" class="fold-content"><textarea id="customReply" class="textarea-reply" placeholder="请输入您的回复..."></textarea><div style="margin-top:5px"><span class="tag" onclick="setReplyTag(\'🚫 定位错误,请确认在车旁?\')">🚫 定位错误</span><span class="tag" onclick="setReplyTag(\'⏳ 我暂时没法离开,等一会才能来···\')">⏳ 暂时无法离开</span><span class="tag" onclick="setReplyTag(\'🏃 请稍等,🐎上到\')">🏃 马上到</span><span class="tag" onclick="setReplyTag(\'📞 请拨打联系电话\')">📞 拨打电话</span></div><button id="sendCustomBtn" class="btn-send" onclick="sendCustomReply()">📨 发送回复</button><div class="time-text" id="myReplyTime" style="display:none;margin-top:10px"></div></div></div></div><script>const userKey="' + userKey + '";let foldOpen=false;let visitorSentAt=0;let myReplyAt=0;function formatTimeAgo(timestamp){if(!timestamp)return "";const seconds=Math.floor((Date.now()-timestamp)/1000);return seconds+" 秒前"}function updateTimeDisplays(){if(visitorSentAt){document.getElementById("visitorMsgTime").innerText="发送于 "+formatTimeAgo(visitorSentAt)}if(myReplyAt){document.getElementById("myReplyTime").innerText="您的回复发送于 "+formatTimeAgo(myReplyAt)}}window.onload=async()=>{const locRes=await fetch("/api/get-location?u="+userKey);const locData=await locRes.json();if(locData.amapUrl){document.getElementById("mapArea").style.display="block";document.getElementById("amapLink").href=locData.amapUrl;document.getElementById("appleLink").href=locData.appleUrl}const statusRes=await fetch("/api/check-status?u="+userKey);const statusData=await statusRes.json();if(statusData.sentAt){visitorSentAt=statusData.sentAt;const visitorMsg=locData.message||"车旁有人等待";document.getElementById("visitorMsg").innerText=visitorMsg;document.getElementById("visitorMsgBox").style.display="block"}updateTimeDisplays();setInterval(updateTimeDisplays,1000)};function toggleFold(){foldOpen=!foldOpen;document.getElementById("foldContent").style.display=foldOpen?"block":"none";document.getElementById("foldBtn").innerText=foldOpen?"🔽 收起":"✏️ 发送其他回复"}function setReplyTag(text){document.getElementById("customReply").value=text}async function sendConfirmWithReply(replyMessage){const btn=event?.target||document.getElementById("confirmBtn");const originalText=btn.innerText;btn.disabled=true;btn.innerText="发送中...";myReplyAt=Date.now();if(navigator.geolocation){navigator.geolocation.getCurrentPosition(async p=>{const location={lat:p.coords.latitude,lng:p.coords.longitude};await sendConfirmRequest(location,replyMessage);btn.disabled=false;btn.innerText=originalText;document.getElementById("myReplyTime").style.display="block";updateTimeDisplays()},async()=>{await sendConfirmRequest(null,replyMessage);btn.disabled=false;btn.innerText=originalText;document.getElementById("myReplyTime").style.display="block";updateTimeDisplays()})}else{await sendConfirmRequest(null,replyMessage);btn.disabled=false;btn.innerText=originalText;document.getElementById("myReplyTime").style.display="block";updateTimeDisplays()}}async function sendConfirmRequest(location,replyMessage){try{const res=await fetch("/api/owner-confirm?u="+userKey,{method:"POST",body:JSON.stringify({location,replyMessage})});const data=await res.json();if(data.success){alert("回复已发送");if(foldOpen)toggleFold()}else{alert("发送失败,请重试")}}catch(e){alert("网络错误")}}function confirmMove(){sendConfirmWithReply("🚀 马上到")}function sendCustomReply(){const reply=document.getElementById("customReply").value.trim();if(!reply){alert("请输入回复内容");return}sendConfirmWithReply(reply)}</script></body></html>', { headers: { 'Content-Type': 'text/html;charset=UTF-8' } });
}

© 本文著作权归作者所有,未经许可不得转载使用。