📌 项目简介
这是一个基于 Cloudflare Workers 的挪车通知系统,支持通过网页一键通知车主挪车,集成了 Bark 和 WxPusher 双通道推送,支持位置共享和实时状态反馈。
核心功能:
🚗 一键通知车主挪车
📍 双方位置共享与地图导航
🔔 Bark + WxPusher 双通道推送
📱 响应式设计,完美适配移动端
⚙️ 车牌号、电话等通过环境变量灵活配置
🛠️ 准备工作
在开始部署前,请准备以下内容:
📦 第一步:创建 KV 命名空间
KV(Key-Value)用于存储通知状态和位置信息。
左侧菜单选择 Workers & Pages → KV
点击 创建命名空间
输入名称(如
move-car-status),点击 添加
📝 记下这个命名空间名称,后续绑定时会用到。
🚀 第二步:创建 Worker
2.1 新建 Worker
在 Workers & Pages 页面,点击 创建应用程序
选择 创建 Worker
输入 Worker 名称(如
move-car-notify)点击 部署(先部署默认代码)
2.2 绑定 KV 命名空间
进入刚创建的 Worker → 设置 → 绑定
点击 添加绑定
选择 KV 命名空间
填写绑定信息:
变量名称:
MOVE_CAR_STATUS(必须与此完全一致)KV 命名空间:选择刚才创建的
move-car-status
点击 保存
2.3 配置环境变量
在 Worker 设置页面 → 变量和密钥 中,添加以下配置:
🔒 密钥(Secrets)
📝 普通变量
⚠️ 注意:
BARK_KEY和WXPUSHER_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 更新代码
在 Worker 编辑器页面,点击 快速编辑
清空默认代码,粘贴上述完整代码
点击 保存并部署
🌐 第四步:绑定自定义域名(可选)
4.1 添加自定义域
进入 Worker → 设置 → 触发器
点击 添加自定义域
输入你的域名(如
car.yourdomain.com)系统会自动添加 DNS 记录,或手动在 DNS 中添加 CNAME 记录
4.2 使用 Workers 默认域名
如果不需要自定义域名,可以直接使用 Cloudflare 提供的默认域名:
text
https://move-car-notify.你的用户名.workers.dev🔧 第五步:获取推送服务配置(可选)
Bark 推送配置(iOS)
在 App Store 下载 Bark
打开 App,复制显示的 Device Token
在 Worker 中添加密钥:
变量名:
BARK_KEY值:你的 Device Token
https://via.placeholder.com/400x300?text=Bark+Token+%E6%88%AA%E5%9B%BE
WxPusher 推送配置(微信)
访问 WxPusher 管理台
使用微信扫码登录
创建应用,获取
APP_TOKEN在用户管理页面找到你的
UID在 Worker 中添加对应环境变量
✅ 第六步:测试验证
6.1 访问页面
打开浏览器访问你的 Worker 地址:
text
https://move-car-notify.你的用户名.workers.dev应该能看到完整的挪车通知页面,显示你配置的车牌号。
6.2 发送测试通知
点击 "我知道了" 授权位置
输入留言(可选)
点击 🔔 一键通知车主
检查是否收到 Bark / WxPusher 推送
6.3 测试车主确认
复制推送通知中的链接(或访问
/owner-confirm)点击 "我已知晓,正在前往"
回到请求者页面,查看是否显示车主确认状态
📱 使用说明
请求者页面(挪车人)
车主页面(被通知人)
🎨 自定义修改
修改预设留言标签
在代码中搜索 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_TOKEN和WXPUSHER_UID都已正确配置检查是否已关注 WxPusher 公众号
Q4: 位置获取失败?
确认使用 HTTPS 访问(HTTP 下浏览器会拒绝定位)
iOS 13+ 需要在设置中允许网站访问位置
Q5: 如何更换车牌号?
在 Worker 环境变量中修改
LICENSE_PLATE的值无需修改代码,修改后自动生效
📊 架构图
text
┌─────────────┐ ┌──────────────────┐ ┌─────────────┐
│ 请求者页面 │────▶│ Cloudflare Worker │────▶│ Bark 推送 │
│ (浏览器) │ │ │ └─────────────┘
└─────────────┘ │ ┌──────────────┐ │ ┌─────────────┐
│ │ KV 存储 │ │────▶│ WxPusher │
┌─────────────┐ │ └──────────────┘ │ └─────────────┘
│ 车主页面 │◀────│ │
│ (手机) │ └──────────────────┘
└─────────────┘🔗 相关链接
🖼️页面展示





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