Cloudflare Workers 挪车通知系统部署指南

Cloudflare Workers 挪车通知系统部署指南

 次点击
209 分钟阅读

📌 项目简介

这是一个基于 Cloudflare Workers 的挪车通知系统,支持通过网页一键通知车主挪车,集成了 Bark 和 WxPusher 双通道推送,支持位置共享和实时状态反馈。

核心功能:

  • 🚗 一键通知车主挪车

  • 📍 双方位置共享与地图导航

  • 🔔 Bark + WxPusher 双通道推送

  • 📱 响应式设计,完美适配移动端

  • ⚙️ 车牌号、电话等通过环境变量灵活配置

🛠️ 准备工作

在开始部署前,请准备以下内容:

  1. Cloudflare 账号 - 注册地址

  2. 一个域名(可选,用于自定义访问地址)

  3. Bark App(可选,用于 iOS 推送)- App Store

  4. WxPusher 账号(可选,用于微信推送)- 注册地址

📦 第一步:创建 KV 命名空间

KV(Key-Value)用于存储通知状态和位置信息。

  1. 登录 Cloudflare Dashboard

  2. 左侧菜单选择 Workers & PagesKV

  3. 点击 创建命名空间

  4. 输入名称(如 move-car-status),点击 添加

https://via.placeholder.com/800x400?text=%E5%88%9B%E5%BB%BAKV%E5%91%BD%E5%90%8D%E7%A9%BA%E9%97%B4%E6%88%AA%E5%9B%BE

📝 记下这个命名空间名称,后续绑定时会用到。

🚀 第二步:创建 Worker

2.1 新建 Worker

  1. Workers & Pages 页面,点击 创建应用程序

  2. 选择 创建 Worker

  3. 输入 Worker 名称(如 move-car-notify

  4. 点击 部署(先部署默认代码)

2.2 绑定 KV 命名空间

  1. 进入刚创建的 Worker → 设置绑定

  2. 点击 添加绑定

  3. 选择 KV 命名空间

  4. 填写绑定信息:

    • 变量名称MOVE_CAR_STATUS(必须与此完全一致)

    • KV 命名空间:选择刚才创建的 move-car-status

  5. 点击 保存

2.3 配置环境变量

在 Worker 设置页面 → 变量和密钥 中,添加以下配置:

🔒 密钥(Secrets)

变量名

类型

是否必填

说明

获取方式

BARK_KEY

密钥

Bark 推送密钥

打开 Bark App → 查看 Device Token

WXPUSHER_APP_TOKEN

密钥

WxPusher 应用 Token

WxPusher 管理台 创建应用获取

📝 普通变量

变量名

类型

是否必填

默认值

说明

示例

LICENSE_PLATE

文本

苏-U-88888

车牌号码

苏U88888

PHONE_NUMBER

文本

"" (空)

车主电话,留空隐藏电话按钮

13800138000

WXPUSHER_UID

文本

"" (空)

WxPusher 用户 UID

WxPusher 后台 查看

⚠️ 注意BARK_KEYWXPUSHER_APP_TOKEN 是敏感信息,请使用 "添加密钥" 方式添加,而非普通变量。

📝 第三步:部署代码

3.1 复制代码

将以下完整代码复制到 Worker 编辑器中:

<details> <summary>📄 点击展开完整代码</summary>

export default {
  async fetch(request, env, ctx) {
    // 配置(从 Cloudflare Workers 环境变量读取)
    const CONFIG = {
      KV_TTL: 3600,
      BARK_URL: env.BARK_KEY ? `https://api.day.app/${env.BARK_KEY}` : '',
      // WxPusher 配置
      WXPUSHER_APP_TOKEN: env.WXPUSHER_APP_TOKEN || '',
      WXPUSHER_UID: env.WXPUSHER_UID || '',
      PUSH_TITLE: '🚗 挪车通知',
      // 车牌号配置
      LICENSE_PLATE: env.LICENSE_PLATE || '苏-U-88888',
      PHONE_NUMBER: env.PHONE_NUMBER || '',
      // KV 命名空间
      MOVE_CAR_STATUS: env.MOVE_CAR_STATUS
    };

    return handleRequest(request, CONFIG);
  }
};

async function handleRequest(request, CONFIG) {
  const url = new URL(request.url)
  const path = url.pathname

  if (path === '/api/notify' && request.method === 'POST') {
    return handleNotify(request, url, CONFIG);
  }

  if (path === '/api/get-location') {
    return handleGetLocation(CONFIG);
  }

  if (path === '/api/owner-confirm' && request.method === 'POST') {
    return handleOwnerConfirmAction(request, CONFIG);
  }

  if (path === '/api/check-status') {
    const status = await CONFIG.MOVE_CAR_STATUS.get('notify_status');
    const ownerLocation = await CONFIG.MOVE_CAR_STATUS.get('owner_location');
    return new Response(JSON.stringify({
      status: status || 'waiting',
      ownerLocation: ownerLocation ? JSON.parse(ownerLocation) : null
    }), {
      headers: { 'Content-Type': 'application/json' }
    });
  }

  if (path === '/owner-confirm') {
    return renderOwnerPage(CONFIG);
  }

  return renderMainPage(url.origin, CONFIG);
}

// WGS-84 转 GCJ-02
function wgs84ToGcj02(lat, lng) {
  const a = 6378245.0;
  const ee = 0.00669342162296594323;

  if (outOfChina(lat, lng)) 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 outOfChina(lat, lng) {
  return lng < 72.004 || lng > 137.8347 || lat < 0.8293 || lat > 55.8271;
}

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=位置`
  };
}

// WxPusher 推送
async function sendWxPusher(content, confirmUrl, CONFIG) {
  if (!CONFIG.WXPUSHER_APP_TOKEN || !CONFIG.WXPUSHER_UID) return;
  try {
    const payload = {
      appToken: CONFIG.WXPUSHER_APP_TOKEN,
      content: content,
      summary: CONFIG.PUSH_TITLE,
      contentType: 1,
      uids: [CONFIG.WXPUSHER_UID],
      url: confirmUrl || "https://car.8852788.xyz/owner-confirm"
    };
    await fetch('https://wxpusher.zjiecode.com/api/send/message', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload)
    });
  } catch (err) {}
}

async function handleNotify(request, url, CONFIG) {
  try {
    const body = await request.json();
    const message = body.message || '车旁有人等待';
    const location = body.location || null;
    const delayed = body.delayed || false;

    const confirmUrl = encodeURIComponent(url.origin + '/owner-confirm');

    let notifyBody = '🚗 挪车请求';
    if (message) notifyBody += `\n💬 留言: ${message}`;

    let pushContent = `<strong>🚗 挪车请求</strong><br>`;
    pushContent += `💬 留言:${message}<br>`;

    if (location && location.lat && location.lng) {
      const urls = generateMapUrls(location.lat, location.lng);
      notifyBody += '\n📍 已附带位置信息';
      pushContent += `📍 位置:<a href="${urls.amapUrl}">点击查看</a>`;

      await CONFIG.MOVE_CAR_STATUS.put('requester_location', JSON.stringify({
        lat: location.lat,
        lng: location.lng,
        ...urls
      }), { expirationTtl: CONFIG.KV_TTL });
    } else {
      notifyBody += '\n⚠️ 未提供位置';
      pushContent += `⚠️ 未提供位置`;
    }

    await CONFIG.MOVE_CAR_STATUS.put('notify_status', 'waiting', { expirationTtl: 600 });

    if (delayed) {
      await new Promise(resolve => setTimeout(resolve, 30000));
    }

    // 推送 Bark
    if (CONFIG.BARK_URL) {
      const barkApiUrl = `${CONFIG.BARK_URL}/挪车请求/${encodeURIComponent(notifyBody)}?group=MoveCar&level=critical&call=1&sound=minuet&icon=https://cdn-icons-png.flaticon.com/512/741/741407.png&url=${confirmUrl}`;
      await fetch(barkApiUrl);
    }

    // 推送 WxPusher
    await sendWxPusher(pushContent, url.origin + '/owner-confirm', CONFIG);

    return new Response(JSON.stringify({ success: true }), {
      headers: { 'Content-Type': 'application/json' }
    });
  } catch (error) {
    return new Response(JSON.stringify({ success: false, error: error.message }), { status: 500 });
  }
}

async function handleGetLocation(CONFIG) {
  const data = await CONFIG.MOVE_CAR_STATUS.get('requester_location');
  if (data) {
    return new Response(data, { headers: { 'Content-Type': 'application/json' } });
  }
  return new Response(JSON.stringify({ error: 'No location' }), { status: 404 });
}

async function handleOwnerConfirmAction(request, CONFIG) {
  try {
    const body = await request.json();
    const ownerLocation = body.location || null;

    if (ownerLocation) {
      const urls = generateMapUrls(ownerLocation.lat, ownerLocation.lng);
      await CONFIG.MOVE_CAR_STATUS.put('owner_location', JSON.stringify({
        lat: ownerLocation.lat,
        lng: ownerLocation.lng,
        ...urls,
        timestamp: Date.now()
      }), { expirationTtl: CONFIG.KV_TTL });
    }

    await CONFIG.MOVE_CAR_STATUS.put('notify_status', 'confirmed', { expirationTtl: 600 });
    return new Response(JSON.stringify({ success: true }), {
      headers: { 'Content-Type': 'application/json' }
    });
  } catch (error) {
    await CONFIG.MOVE_CAR_STATUS.put('notify_status', 'confirmed', { expirationTtl: 600 });
    return new Response(JSON.stringify({ success: true }), {
      headers: { 'Content-Type': 'application/json' }
    });
  }
}

function renderMainPage(origin, CONFIG) {
  const phone = CONFIG.PHONE_NUMBER;
  const licensePlate = CONFIG.LICENSE_PLATE;

  const html = `
  <!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, user-scalable=yes, viewport-fit=cover">
    <meta name="apple-mobile-web-app-capable" content="yes">
    <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
    <meta name="theme-color" content="#0093E9">
    <title>通知车主挪车 - ${licensePlate}</title>
    <style>
      :root {
        --sat: env(safe-area-inset-top, 0px);
        --sar: env(safe-area-inset-right, 0px);
        --sab: env(safe-area-inset-bottom, 0px);
        --sal: env(safe-area-inset-left, 0px);
      }
      * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; margin: 0; padding: 0; }
      html {
        font-size: 16px;
        -webkit-text-size-adjust: 100%;
      }
      html, body { height: 100%; }
      body {
        font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, sans-serif;
        background: linear-gradient(160deg, #0093E9 0%, #80D0C7 100%);
        min-height: 100vh;
        min-height: -webkit-fill-available;
        padding: clamp(16px, 4vw, 24px);
        padding-top: calc(clamp(16px, 4vw, 24px) + var(--sat));
        padding-bottom: calc(clamp(16px, 4vw, 24px) + var(--sab));
        padding-left: calc(clamp(16px, 4vw, 24px) + var(--sal));
        padding-right: calc(clamp(16px, 4vw, 24px) + var(--sar));
        display: flex;
        justify-content: center;
        align-items: flex-start;
      }
      body::before {
        content: ''; position: fixed; inset: 0;
        background: url("data:image/svg+xml,%3Csvg width='52' height='26' viewBox='0 0 52 26' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.1'%3E%3Cpath d='M10 10c0-2.21-1.79-4-4-4-3.314 0-6-2.686-6-6h2c0 2.21 1.79 4 4 4 3.314 0 6 2.686 6 6 0 2.21 1.79 4 4 4 3.314 0 6 2.686 6 6 0 2.21 1.79 4 4 4v2c-3.314 0-6-2.686-6-6 0-2.21-1.79-4-4-4-3.314 0-6-2.686-6-6zm25.464-1.95l8.486 8.486-1.414 1.414-8.486-8.486 1.414-1.414z' /%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
        z-index: -1;
      }

      .container {
        width: 100%;
        max-width: 500px;
        display: flex;
        flex-direction: column;
        gap: clamp(12px, 3vw, 20px);
      }

      .card {
        background: rgba(255, 255, 255, 0.95);
        border-radius: clamp(20px, 5vw, 28px);
        padding: clamp(18px, 4vw, 28px);
        box-shadow: 0 10px 40px rgba(0, 147, 233, 0.2);
        transition: transform 0.2s ease;
      }
      @media (hover: hover) {
        .card:hover { transform: translateY(-2px); }
      }
      .card:active { transform: scale(0.98); }

      .header {
        text-align: center;
        padding: clamp(20px, 5vw, 32px) clamp(16px, 4vw, 28px);
        background: white;
      }
      .icon-wrap {
        width: clamp(72px, 18vw, 100px);
        height: clamp(72px, 18vw, 100px);
        background: linear-gradient(135deg, #0093E9 0%, #80D0C7 100%);
        border-radius: clamp(22px, 5vw, 32px);
        display: flex; align-items: center; justify-content: center;
        margin: 0 auto clamp(14px, 3vw, 24px);
        box-shadow: 0 12px 32px rgba(0, 147, 233, 0.35);
      }
      .icon-wrap span { font-size: clamp(36px, 9vw, 52px); }
      .header h1 {
        font-size: clamp(22px, 5.5vw, 30px);
        font-weight: 700;
        color: #1a202c;
        margin-bottom: 6px;
      }
      .header p {
        font-size: clamp(13px, 3.5vw, 16px);
        color: #718096;
        font-weight: 500;
      }

      .input-card { padding: 0; overflow: hidden; }
      .input-card textarea {
        width: 100%;
        min-height: clamp(90px, 20vw, 120px);
        border: none;
        padding: clamp(16px, 4vw, 24px);
        font-size: clamp(15px, 4vw, 18px);
        font-family: inherit;
        resize: none;
        outline: none;
        color: #2d3748;
        background: transparent;
        line-height: 1.5;
      }
      .input-card textarea::placeholder { color: #a0aec0; }
      .tags {
        display: flex;
        gap: clamp(6px, 2vw, 10px);
        padding: 0 clamp(12px, 3vw, 20px) clamp(14px, 3vw, 20px);
        overflow-x: auto;
        -webkit-overflow-scrolling: touch;
        scrollbar-width: none;
      }
      .tags::-webkit-scrollbar { display: none; }
      .tag {
        background: linear-gradient(135deg, #e0f7fa 0%, #b2ebf2 100%);
        color: #00796b;
        padding: clamp(8px, 2vw, 12px) clamp(12px, 3vw, 18px);
        border-radius: 20px;
        font-size: clamp(13px, 3.5vw, 15px);
        font-weight: 600;
        white-space: nowrap;
        cursor: pointer;
        transition: all 0.2s;
        border: 1px solid #80cbc4;
        min-height: 44px;
        display: flex;
        align-items: center;
      }
      .tag:active { transform: scale(0.95); background: #80cbc4; }

      .loc-card {
        display: flex;
        align-items: center;
        gap: clamp(10px, 3vw, 16px);
        padding: clamp(14px, 3.5vw, 22px) clamp(16px, 4vw, 24px);
        cursor: pointer;
        min-height: 64px;
      }
      .loc-icon {
        width: clamp(44px, 11vw, 56px);
        height: clamp(44px, 11vw, 56px);
        border-radius: clamp(14px, 3.5vw, 18px);
        display: flex; align-items: center; justify-content: center;
        font-size: clamp(22px, 5.5vw, 28px);
        transition: all 0.3s;
        flex-shrink: 0;
      }
      .loc-icon.loading { background: #fff3cd; }
      .loc-icon.success { background: #d4edda; }
      .loc-icon.error { background: #f8d7da; }
      .loc-content { flex: 1; min-width: 0; }
      .loc-title {
        font-size: clamp(15px, 4vw, 18px);
        font-weight: 600;
        color: #2d3748;
      }
      .loc-status {
        font-size: clamp(12px, 3.2vw, 14px);
        color: #718096;
        margin-top: 3px;
      }
      .loc-status.success { color: #28a745; }
      .loc-status.error { color: #dc3545; }
      .loc-retry-btn {
        color: #0093E9;
        text-decoration: underline;
        cursor: pointer;
        margin-left: 8px;
        font-weight: 600;
      }
      .loc-refresh {
        font-size: clamp(20px, 5vw, 26px);
        color: #a0aec0;
        flex-shrink: 0;
      }

      .btn-main {
        background: linear-gradient(135deg, #0093E9 0%, #80D0C7 100%);
        color: white;
        border: none;
        padding: clamp(16px, 4vw, 22px);
        border-radius: clamp(16px, 4vw, 22px);
        font-size: clamp(16px, 4.2vw, 20px);
        font-weight: 700;
        cursor: pointer;
        display: flex; align-items: center; justify-content: center; gap: 10px;
        box-shadow: 0 10px 30px rgba(0, 147, 233, 0.35);
        transition: all 0.2s;
        min-height: 56px;
      }
      .btn-main:active { transform: scale(0.98); }
      .btn-main:disabled {
        background: linear-gradient(135deg, #94a3b8 0%, #64748b 100%);
        box-shadow: none;
        cursor: not-allowed;
      }

      .toast {
        position: fixed;
        top: calc(20px + var(--sat));
        left: 50%;
        transform: translateX(-50%) translateY(-100px);
        background: white;
        padding: clamp(12px, 3vw, 16px) clamp(20px, 5vw, 32px);
        border-radius: 16px;
        font-size: clamp(14px, 3.5vw, 16px);
        font-weight: 600;
        color: #2d3748;
        box-shadow: 0 10px 40px rgba(0,0,0,0.15);
        opacity: 0;
        transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
        z-index: 100;
        max-width: calc(100vw - 40px);
      }
      .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }

      #successView { display: none; }
      .success-card {
        text-align: center;
        background: linear-gradient(135deg, #d4edda 0%, #c3e6cb 100%);
        border: 2px solid #28a745;
      }
      .success-icon {
        font-size: clamp(56px, 14vw, 80px);
        margin-bottom: clamp(12px, 3vw, 20px);
        display: block;
      }
      .success-card h2 {
        color: #155724;
        margin-bottom: 8px;
        font-size: clamp(20px, 5vw, 28px);
      }
      .success-card p {
        color: #1e7e34;
        font-size: clamp(14px, 3.5vw, 16px);
      }

      .owner-card {
        background: white;
        border: 2px solid #80D0C7;
        text-align: center;
      }
      .owner-card.hidden { display: none; }
      .owner-card h3 {
        color: #0093E9;
        margin-bottom: 8px;
        font-size: clamp(18px, 4.5vw, 22px);
      }
      .owner-card p {
        color: #718096;
        margin-bottom: 16px;
        font-size: clamp(14px, 3.5vw, 16px);
      }
      .owner-card .map-links {
        display: flex;
        gap: clamp(8px, 2vw, 14px);
        flex-wrap: wrap;
      }
      .owner-card .map-btn {
        flex: 1;
        min-width: 120px;
        padding: clamp(12px, 3vw, 16px);
        border-radius: clamp(12px, 3vw, 16px);
        text-decoration: none;
        font-weight: 600;
        font-size: clamp(13px, 3.5vw, 15px);
        text-align: center;
        min-height: 48px;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .map-btn.amap { background: #1890ff; color: white; }
      .map-btn.apple { background: #1d1d1f; color: white; }

      .action-card {
        display: flex;
        flex-direction: column;
        gap: clamp(10px, 2.5vw, 14px);
      }
      .action-hint {
        text-align: center;
        font-size: clamp(13px, 3.5vw, 15px);
        color: #718096;
        margin-bottom: 4px;
      }
      .btn-retry, .btn-phone {
        color: white;
        border: none;
        padding: clamp(14px, 3.5vw, 18px);
        border-radius: clamp(14px, 3.5vw, 18px);
        font-size: clamp(15px, 4vw, 17px);
        font-weight: 700;
        cursor: pointer;
        display: flex;
        align-items: center;
        justify-content: center;
        gap: 8px;
        transition: all 0.2s;
        min-height: 52px;
        text-decoration: none;
      }
      .btn-retry {
        background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
        box-shadow: 0 8px 24px rgba(245, 158, 11, 0.3);
      }
      .btn-retry:active { transform: scale(0.98); }
      .btn-retry:disabled {
        background: linear-gradient(135deg, #9ca3af 0%, #6b7280 100%);
        box-shadow: none;
        cursor: not-allowed;
      }
      .btn-phone {
        background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
        box-shadow: 0 8px 24px rgba(239, 68, 68, 0.3);
      }
      .btn-phone:active { transform: scale(0.98); }

      .btn-blog {
        display: flex;
        align-items: center;
        justify-content: center;
        gap: 8px;
        padding: clamp(14px, 3.5vw, 18px);
        border-radius: clamp(14px, 3.5vw, 18px);
        font-size: clamp(15px, 4vw, 17px);
        font-weight: 700;
        cursor: pointer;
        transition: all 0.2s;
        min-height: 52px;
        text-decoration: none;
        background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
        color: white;
        box-shadow: 0 8px 24px rgba(139, 92, 246, 0.3);
      }
      .btn-blog:active { transform: scale(0.98); }

      @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
      .loading-text { animation: pulse 1.5s ease-in-out infinite; }

      .modal-overlay {
        position: fixed;
        inset: 0;
        background: rgba(0, 0, 0, 0.5);
        display: flex;
        align-items: center;
        justify-content: center;
        z-index: 200;
        padding: 20px;
        opacity: 0;
        visibility: hidden;
        transition: all 0.3s;
      }
      .modal-overlay.show {
        opacity: 1;
        visibility: visible;
      }
      .modal-box {
        background: white;
        border-radius: 20px;
        padding: clamp(24px, 6vw, 32px);
        max-width: 340px;
        width: 100%;
        text-align: center;
        transform: scale(0.9);
        transition: transform 0.3s;
      }
      .modal-overlay.show .modal-box {
        transform: scale(1);
      }
      .modal-icon {
        font-size: 48px;
        margin-bottom: 16px;
      }
      .modal-title {
        font-size: 18px;
        font-weight: 700;
        color: #1a202c;
        margin-bottom: 8px;
      }
      .modal-desc {
        font-size: 14px;
        color: #718096;
        margin-bottom: 24px;
        line-height: 1.5;
      }
      .modal-buttons {
        display: flex;
        gap: 12px;
      }
      .modal-btn {
        flex: 1;
        padding: 14px 16px;
        border-radius: 12px;
        font-size: 15px;
        font-weight: 600;
        cursor: pointer;
        border: none;
        transition: all 0.2s;
      }
      .modal-btn:active { transform: scale(0.96); }
      .modal-btn-primary {
        background: linear-gradient(135deg, #0093E9 0%, #80D0C7 100%);
        color: white;
      }
      .modal-btn-secondary {
        background: #f1f5f9;
        color: #64748b;
      }

      @media (min-width: 768px) {
        body { align-items: center; }
        .container { max-width: 480px; }
      }

      @media (min-width: 1024px) {
        .container { max-width: 520px; }
        .card { padding: 32px; }
      }

      @media (min-width: 600px) and (max-width: 900px) {
        .container { max-width: 460px; }
      }

      @media (orientation: landscape) and (max-height: 500px) {
        body { align-items: flex-start; padding-top: calc(12px + var(--sat)); }
        .header { padding: 16px; }
        .icon-wrap { width: 60px; height: 60px; margin-bottom: 12px; }
        .icon-wrap span { font-size: 32px; }
        .input-card textarea { min-height: 70px; }
        .success-icon { font-size: 48px; margin-bottom: 10px; }
      }

      @media (max-width: 350px) {
        .container { gap: 10px; }
        .card { padding: 14px; border-radius: 18px; }
        .tags { gap: 6px; }
        .tag { padding: 8px 10px; font-size: 12px; }
      }
    </style>
  </head>
  <body>
    <div id="toast" class="toast"></div>

    <div id="locationTipModal" class="modal-overlay">
      <div class="modal-box">
        <div class="modal-icon">📍</div>
        <div class="modal-title">位置信息说明</div>
        <div class="modal-desc">分享位置可让车主确认您在车旁<br>不分享将延迟30秒发送通知</div>
        <div class="modal-buttons">
          <button class="modal-btn modal-btn-primary" onclick="hideModal('locationTipModal');requestLocation()">我知道了</button>
        </div>
      </div>
    </div>

    <div class="container" id="mainView">
      <div class="card header">
        <div class="icon-wrap"><span>🚗</span></div>
        <h1>呼叫车主挪车</h1>
        <p>${licensePlate}</p>
      </div>

      <div class="card input-card">
        <textarea id="msgInput" placeholder="输入留言给车主...(可选)"></textarea>
        <div class="tags">
          <div class="tag" onclick="addTag('您的车挡住我了')">🚧 挡路</div>
          <div class="tag" onclick="addTag('临时停靠一下')">⏱️ 临停</div>
          <div class="tag" onclick="addTag('电话打不通')">📞 没接</div>
          <div class="tag" onclick="addTag('麻烦尽快')">🙏 加急</div>
        </div>
      </div>

      <div class="card loc-card">
        <div id="locIcon" class="loc-icon loading">📍</div>
        <div class="loc-content">
          <div class="loc-title">我的位置</div>
          <div id="locStatus" class="loc-status">等待获取...</div>
        </div>
      </div>

      <button id="notifyBtn" class="card btn-main" onclick="sendNotify()">
        <span>🔔</span>
        <span>一键通知车主</span>
      </button>
    </div>

    <div class="container" id="successView">
      <div class="card success-card">
        <span class="success-icon">✅</span>
        <h2>通知已发送!</h2>
        <p id="waitingText" class="loading-text">正在等待车主回应...</p>
      </div>

      <div id="ownerFeedback" class="card owner-card hidden">
        <span style="font-size:56px; display:block; margin-bottom:16px">🎉</span>
        <h3>车主已收到通知</h3>
        <p>正在赶来,点击查看车主位置</p>
        <div id="ownerMapLinks" class="map-links" style="display:none">
          <a id="ownerAmapLink" href="#" class="map-btn amap">🗺️ 高德地图</a>
          <a id="ownerAppleLink" href="#" class="map-btn apple">🍎 Apple Maps</a>
        </div>
      </div>

      <div class="card action-card">
        <p class="action-hint">车主没反应?试试其他方式</p>
        <button id="retryBtn" class="btn-retry" onclick="retryNotify()">
          <span>🔔</span>
          <span>再次通知</span>
        </button>
        <a href="tel:${phone}" class="btn-phone" ${phone ? '' : 'style="display:none"'}>
          <span>📞</span>
          <span>直接打电话</span>
        </a>
        <a href="https://blog.8852788.xyz" target="_blank" class="btn-blog">
          <span>📝</span>
          <span>欢迎访问 Saisai'blog</span>
        </a>
      </div>
    </div>

    <script>
      let userLocation = null;
      let checkTimer = null;

      window.onload = () => {
        showModal('locationTipModal');
      };

      function showModal(id) {
        document.getElementById(id).classList.add('show');
      }

      function hideModal(id) {
        document.getElementById(id).classList.remove('show');
      }

      function requestLocation() {
        const icon = document.getElementById('locIcon');
        const txt = document.getElementById('locStatus');

        icon.className = 'loc-icon loading';
        txt.className = 'loc-status';
        txt.innerText = '正在获取定位...';

        if ('geolocation' in navigator) {
          navigator.geolocation.getCurrentPosition(
            (pos) => {
              userLocation = { lat: pos.coords.latitude, lng: pos.coords.longitude };
              icon.className = 'loc-icon success';
              txt.className = 'loc-status success';
              txt.innerText = '已获取位置 ✓';
            },
            (err) => {
              icon.className = 'loc-icon error';
              txt.className = 'loc-status error';
              txt.innerText = '位置获取失败,刷新页面可重试';
            },
            { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 }
          );
        } else {
          icon.className = 'loc-icon error';
          txt.className = 'loc-status error';
          txt.innerText = '浏览器不支持定位';
        }
      }

      function addTag(text) {
        document.getElementById('msgInput').value = text;
      }

      async function sendNotify() {
        const btn = document.getElementById('notifyBtn');
        const msg = document.getElementById('msgInput').value;
        const delayed = !userLocation;

        btn.disabled = true;
        btn.innerHTML = '<span>🚀</span><span>发送中...</span>';

        try {
          const res = await fetch('/api/notify', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ message: msg, location: userLocation, delayed: delayed })
          });

          if (res.ok) {
            if (delayed) {
              showToast('⏳ 通知将延迟30秒发送');
            } else {
              showToast('✅ 发送成功!');
            }
            document.getElementById('mainView').style.display = 'none';
            document.getElementById('successView').style.display = 'flex';
            startPolling();
          } else {
            throw new Error('API Error');
          }
        } catch (e) {
          showToast('❌ 发送失败,请重试');
          btn.disabled = false;
          btn.innerHTML = '<span>🔔</span><span>一键通知车主</span>';
        }
      }

      function startPolling() {
        let count = 0;
        checkTimer = setInterval(async () => {
          count++;
          if (count > 120) { clearInterval(checkTimer); return; }
          try {
            const res = await fetch('/api/check-status');
            const data = await res.json();
            if (data.status === 'confirmed') {
              const fb = document.getElementById('ownerFeedback');
              fb.classList.remove('hidden');

              if (data.ownerLocation && data.ownerLocation.amapUrl) {
                document.getElementById('ownerMapLinks').style.display = 'flex';
                document.getElementById('ownerAmapLink').href = data.ownerLocation.amapUrl;
                document.getElementById('ownerAppleLink').href = data.ownerLocation.appleUrl;
              }

              clearInterval(checkTimer);
              if(navigator.vibrate) navigator.vibrate([200, 100, 200]);
            }
          } catch(e) {}
        }, 3000);
      }

      function showToast(text) {
        const t = document.getElementById('toast');
        t.innerText = text;
        t.classList.add('show');
        setTimeout(() => t.classList.remove('show'), 3000);
      }

      async function retryNotify() {
        const btn = document.getElementById('retryBtn');
        btn.disabled = true;
        btn.innerHTML = '<span>🚀</span><span>发送中...</span>';

        try {
          const res = await fetch('/api/notify', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ message: '再次通知:请尽快挪车', location: userLocation })
          });

          if (res.ok) {
            showToast('✅ 再次通知已发送!');
            document.getElementById('waitingText').innerText = '已再次通知,等待车主回应...';
          } else {
            throw new Error('API Error');
          }
        } catch (e) {
          showToast('❌ 发送失败,请重试');
        }

        btn.disabled = false;
        btn.innerHTML = '<span>🔔</span><span>再次通知</span>';
      }
    </script>
  </body>
  </html>
  `;
  return new Response(html, { headers: { 'Content-Type': 'text/html;charset=UTF-8' } });
}

function renderOwnerPage(CONFIG) {
  const html = `
  <!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, user-scalable=yes, viewport-fit=cover">
    <meta name="apple-mobile-web-app-capable" content="yes">
    <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
    <meta name="theme-color" content="#667eea">
    <title>确认挪车</title>
    <style>
      :root {
        --sat: env(safe-area-inset-top, 0px);
        --sar: env(safe-area-inset-right, 0px);
        --sab: env(safe-area-inset-bottom, 0px);
        --sal: env(safe-area-inset-left, 0px);
      }
      * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
      html {
        font-size: 16px;
        -webkit-text-size-adjust: 100%;
      }
      body {
        font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, sans-serif;
        background: linear-gradient(160deg, #667eea 0%, #764ba2 100%);
        min-height: 100vh;
        min-height: -webkit-fill-available;
        padding: clamp(16px, 4vw, 24px);
        padding-top: calc(clamp(16px, 4vw, 24px) + var(--sat));
        padding-bottom: calc(clamp(16px, 4vw, 24px) + var(--sab));
        padding-left: calc(clamp(16px, 4vw, 24px) + var(--sal));
        padding-right: calc(clamp(16px, 4vw, 24px) + var(--sar));
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
      }
      .card {
        background: rgba(255,255,255,0.95);
        padding: clamp(24px, 6vw, 36px);
        border-radius: clamp(24px, 6vw, 32px);
        text-align: center;
        width: 100%;
        max-width: 420px;
        box-shadow: 0 20px 60px rgba(102, 126, 234, 0.3);
      }
      .emoji {
        font-size: clamp(52px, 13vw, 72px);
        margin-bottom: clamp(16px, 4vw, 24px);
        display: block;
      }
      h1 {
        font-size: clamp(22px, 5.5vw, 28px);
        color: #2d3748;
        margin-bottom: 8px;
      }
      .subtitle {
        color: #718096;
        font-size: clamp(14px, 3.5vw, 16px);
        margin-bottom: clamp(20px, 5vw, 28px);
      }

      .map-section {
        background: linear-gradient(135deg, #eef2ff 0%, #e0e7ff 100%);
        border-radius: clamp(14px, 3.5vw, 18px);
        padding: clamp(14px, 3.5vw, 20px);
        margin-bottom: clamp(16px, 4vw, 24px);
        display: none;
      }
      .map-section.show { display: block; }
      .map-section p {
        font-size: clamp(12px, 3.2vw, 14px);
        color: #6366f1;
        margin-bottom: 12px;
        font-weight: 600;
      }
      .map-links {
        display: flex;
        gap: clamp(8px, 2vw, 12px);
        flex-wrap: wrap;
      }
      .map-btn {
        flex: 1;
        min-width: 110px;
        padding: clamp(12px, 3vw, 16px);
        border-radius: clamp(10px, 2.5vw, 14px);
        text-decoration: none;
        font-weight: 600;
        font-size: clamp(13px, 3.5vw, 15px);
        text-align: center;
        transition: transform 0.2s;
        min-height: 48px;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .map-btn:active { transform: scale(0.96); }
      .map-btn.amap { background: #1890ff; color: white; }
      .map-btn.apple { background: #1d1d1f; color: white; }

      .loc-status {
        background: #fef3c7;
        border-radius: clamp(10px, 2.5vw, 14px);
        padding: clamp(10px, 2.5vw, 14px) clamp(14px, 3.5vw, 18px);
        margin-bottom: clamp(16px, 4vw, 24px);
        font-size: clamp(13px, 3.5vw, 15px);
        color: #92400e;
        display: none;
      }
      .loc-status.show { display: block; }
      .loc-status.success { background: #d1fae5; color: #065f46; }
      .loc-status.error { background: #fee2e2; color: #991b1b; }

      .btn {
        background: linear-gradient(135deg, #10b981 0%, #059669 100%);
        color: white;
        border: none;
        width: 100%;
        padding: clamp(16px, 4vw, 20px);
        border-radius: clamp(14px, 3.5vw, 18px);
        font-size: clamp(16px, 4.2vw, 19px);
        font-weight: 700;
        cursor: pointer;
        box-shadow: 0 8px 24px rgba(16, 185, 129, 0.35);
        display: flex;
        align-items: center;
        justify-content: center;
        gap: 10px;
        transition: all 0.2s;
        min-height: 56px;
      }
      .btn:active { transform: scale(0.98); }
      .btn:disabled {
        background: linear-gradient(135deg, #9ca3af 0%, #6b7280 100%);
        box-shadow: none;
        cursor: not-allowed;
      }

      .done-msg {
        background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
        border-radius: clamp(14px, 3.5vw, 18px);
        padding: clamp(16px, 4vw, 24px);
        margin-top: clamp(16px, 4vw, 24px);
        display: none;
      }
      .done-msg.show { display: block; }
      .done-msg p {
        color: #065f46;
        font-weight: 600;
        font-size: clamp(15px, 4vw, 17px);
      }

      @media (min-width: 768px) {
        .card { max-width: 440px; padding: 40px; }
      }

      @media (orientation: landscape) and (max-height: 500px) {
        body { justify-content: flex-start; padding-top: calc(12px + var(--sat)); }
        .card { padding: 20px 28px; }
        .emoji { font-size: 44px; margin-bottom: 12px; }
        .subtitle { margin-bottom: 16px; }
      }

      @media (max-width: 350px) {
        .card { padding: 20px; border-radius: 20px; }
        .map-btn { min-width: 100px; padding: 10px; }
      }
    </style>
  </head>
  <body>
    <div class="card">
      <span class="emoji">👋</span>
      <h1>收到挪车请求</h1>
      <p class="subtitle">对方正在等待,请尽快确认</p>

      <div id="mapArea" class="map-section">
        <p>📍 对方位置</p>
        <div class="map-links">
          <a id="amapLink" href="#" class="map-btn amap">🗺️ 高德地图</a>
          <a id="appleLink" href="#" class="map-btn apple">🍎 Apple Maps</a>
        </div>
      </div>

      <button id="confirmBtn" class="btn" onclick="confirmMove()">
        <span>🚀</span>
        <span>我已知晓,正在前往</span>
      </button>

      <div id="doneMsg" class="done-msg">
        <p>✅ 已通知对方您正在赶来!</p>
      </div>
    </div>

    <script>
      let ownerLocation = null;

      window.onload = async () => {
        try {
          const res = await fetch('/api/get-location');
          if(res.ok) {
            const data = await res.json();
            if(data.amapUrl) {
              document.getElementById('mapArea').classList.add('show');
              document.getElementById('amapLink').href = data.amapUrl;
              document.getElementById('appleLink').href = data.appleUrl;
            }
          }
        } catch(e) {}
      }

      async function confirmMove() {
        const btn = document.getElementById('confirmBtn');
        btn.disabled = true;
        btn.innerHTML = '<span>📍</span><span>获取位置中...</span>';

        if ('geolocation' in navigator) {
          navigator.geolocation.getCurrentPosition(
            async (pos) => {
              ownerLocation = { lat: pos.coords.latitude, lng: pos.coords.longitude };
              await doConfirm();
            },
            async (err) => {
              ownerLocation = null;
              await doConfirm();
            },
            { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 }
          );
        } else {
          ownerLocation = null;
          await doConfirm();
        }
      }

      async function doConfirm() {
        const btn = document.getElementById('confirmBtn');
        btn.innerHTML = '<span>⏳</span><span>确认中...</span>';

        try {
          await fetch('/api/owner-confirm', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ location: ownerLocation })
          });

          btn.innerHTML = '<span>✅</span><span>已确认</span>';
          btn.style.background = 'linear-gradient(135deg, #9ca3af 0%, #6b7280 100%)';
          document.getElementById('doneMsg').classList.add('show');
        } catch(e) {
          btn.disabled = false;
          btn.innerHTML = '<span>🚀</span><span>我已知晓,正在前往</span>';
        }
      }
    </script>
  </body>
  </html>
  `;
  return new Response(html, { headers: { 'Content-Type': 'text/html;charset=UTF-8' } });
}

3.2 更新代码

  1. 在 Worker 编辑器页面,点击 快速编辑

  2. 清空默认代码,粘贴上述完整代码

  3. 点击 保存并部署

🌐 第四步:绑定自定义域名(可选)

4.1 添加自定义域

  1. 进入 Worker → 设置触发器

  2. 点击 添加自定义域

  3. 输入你的域名(如 car.yourdomain.com

  4. 系统会自动添加 DNS 记录,或手动在 DNS 中添加 CNAME 记录

4.2 使用 Workers 默认域名

如果不需要自定义域名,可以直接使用 Cloudflare 提供的默认域名:

text

https://move-car-notify.你的用户名.workers.dev

🔧 第五步:获取推送服务配置(可选)

Bark 推送配置(iOS)

  1. 在 App Store 下载 Bark

  2. 打开 App,复制显示的 Device Token

  3. 在 Worker 中添加密钥:

    • 变量名BARK_KEY

    • :你的 Device Token

https://via.placeholder.com/400x300?text=Bark+Token+%E6%88%AA%E5%9B%BE

WxPusher 推送配置(微信)

  1. 访问 WxPusher 管理台

  2. 使用微信扫码登录

  3. 创建应用,获取 APP_TOKEN

  4. 在用户管理页面找到你的 UID

  5. 在 Worker 中添加对应环境变量

✅ 第六步:测试验证

6.1 访问页面

打开浏览器访问你的 Worker 地址:

text

https://move-car-notify.你的用户名.workers.dev

应该能看到完整的挪车通知页面,显示你配置的车牌号。

6.2 发送测试通知

  1. 点击 "我知道了" 授权位置

  2. 输入留言(可选)

  3. 点击 🔔 一键通知车主

  4. 检查是否收到 Bark / WxPusher 推送

6.3 测试车主确认

  1. 复制推送通知中的链接(或访问 /owner-confirm

  2. 点击 "我已知晓,正在前往"

  3. 回到请求者页面,查看是否显示车主确认状态

📱 使用说明

请求者页面(挪车人)

功能

操作

发送通知

输入留言 → 点击"一键通知车主"

共享位置

允许浏览器定位(未授权将延迟30秒发送)

快捷留言

点击预设标签快速填入

再次通知

发送成功后点击"再次通知"

查看车主位置

车主确认后显示地图链接

车主页面(被通知人)

功能

操作

查看对方位置

页面自动加载对方位置地图

确认前往

点击"我已知晓,正在前往"

共享位置

允许定位后对方可看到车主位置


🎨 自定义修改

修改预设留言标签

在代码中搜索 tags 部分,修改标签文字:

javascript

<div class="tag" onclick="addTag('您的车挡住我了')">🚧 挡路</div>
<div class="tag" onclick="addTag('临时停靠一下')">⏱️ 临停</div>
<div class="tag" onclick="addTag('电话打不通')">📞 没接</div>
<div class="tag" onclick="addTag('麻烦尽快')">🙏 加急</div>

修改博客按钮链接

搜索 blog.8852788.xyz,替换为你的博客地址。

修改主题色

修改 CSS 中的渐变色值:

css

background: linear-gradient(160deg, #0093E9 0%, #80D0C7 100%);

❓ 常见问题

Q1: 部署后页面无法访问?

  • 检查 Worker 是否正确部署

  • 确认 KV 命名空间绑定名称是否为 MOVE_CAR_STATUS

Q2: Bark 推送收不到?

  • 确认 BARK_KEY 密钥是否正确添加

  • 检查 iOS 设备通知权限是否开启

  • 确认 Bark App 在后台运行

Q3: WxPusher 推送收不到?

  • 确认 WXPUSHER_APP_TOKENWXPUSHER_UID 都已正确配置

  • 检查是否已关注 WxPusher 公众号

Q4: 位置获取失败?

  • 确认使用 HTTPS 访问(HTTP 下浏览器会拒绝定位)

  • iOS 13+ 需要在设置中允许网站访问位置

Q5: 如何更换车牌号?

  • 在 Worker 环境变量中修改 LICENSE_PLATE 的值

  • 无需修改代码,修改后自动生效

📊 架构图

text

┌─────────────┐     ┌──────────────────┐     ┌─────────────┐
│  请求者页面   │────▶│  Cloudflare Worker │────▶│  Bark 推送   │
│  (浏览器)    │     │                    │     └─────────────┘
└─────────────┘     │  ┌──────────────┐ │     ┌─────────────┐
                    │  │  KV 存储      │ │────▶│ WxPusher   │
┌─────────────┐     │  └──────────────┘ │     └─────────────┘
│  车主页面    │◀────│                    │
│  (手机)     │     └──────────────────┘
└─────────────┘

🔗 相关链接

🖼️页面展示


💡 提示:本文档中的所有代码和配置均基于 Cloudflare Workers 免费套餐,每日 10 万次请求额度,完全满足个人使用需求。


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