纪念币预约助手

纪念币自动预约

// ==UserScript==
// @name         纪念币预约助手
// @namespace    http://tampermonkey.net/
// @version      1.4
// @description  纪念币自动预约
// @author       shenfangda
// @match        https://jnb.icbc.com.cn/*
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_notification
// @require      https://code.jquery.com/jquery-3.6.0.min.js
// ==/UserScript==

(function() {
  'use strict';

  // 配置参数
  const CONFIG = {
    VERSION: '1.4',
    FILL_DELAY: 50,    // 填充延迟(毫秒)
    RETRY_TIMES: 3,    // 重试次数
    AUTO_AMOUNT: 20    // 默认预约数量
  };

    // 样式注入
    GM_addStyle(`
        .booking-helper {
            position: fixed;
            top: 100px;
            right: 20px;
            width: 300px;
            background: white;
            border-radius: 8px;
            box-shadow: 0 2px 12px 0 rgba(0,0,0,.1);
            z-index: 9999;
            padding: 16px;
            font-family: -apple-system, system-ui, sans-serif;
        }
        .booking-helper .panel-header {
            border-bottom: 1px solid #eee;
            padding-bottom: 10px;
            margin-bottom: 15px;
            display: flex;
            justify-content: space-between;
            align-items: center;
        }
        .booking-helper .panel-title {
            font-size: 16px;
            font-weight: 600;
            color: #333;
        }
        .booking-helper .form-group {
            margin-bottom: 12px;
        }
        .booking-helper .form-group label {
            display: block;
            font-size: 13px;
            color: #606266;
            margin-bottom: 4px;
        }
        .booking-helper input {
            width: 100%;
            padding: 8px;
            border: 1px solid #dcdfe6;
            border-radius: 4px;
            font-size: 14px;
            box-sizing: border-box;
        }
        .booking-helper input:focus {
            border-color: #409eff;
            outline: none;
        }
        .booking-helper .btn {
            width: 100%;
            padding: 8px;
            margin-bottom: 8px;
            border: none;
            border-radius: 4px;
            font-size: 14px;
            cursor: pointer;
            transition: opacity 0.3s;
        }
        .booking-helper .btn-primary {
            background: #409eff;
            color: white;
        }
        .booking-helper .btn-primary:hover {
            opacity: 0.9;
        }
        .booking-helper .btn-primary:active {
            opacity: 0.8;
        }
        .booking-helper .status-bar {
            margin-top: 10px;
            padding-top: 10px;
            border-top: 1px solid #eee;
            font-size: 12px;
            color: #666;
        }
        .booking-helper .toast {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background: rgba(0, 0, 0, 0.7);
            color: white;
            padding: 10px 20px;
            border-radius: 4px;
            font-size: 14px;
            z-index: 10000;
        }
    `);

  // 默认配置
  const defaultConfig = {
    profiles: [{
      name: '',
      idCard: '',
      phone: '',
      province: '',
      city: '',
      district: '',
      targetBranch: '', // 目标网点
      autoSubmit: true,
      enabled: true,
      version: '1.2'
    }],
    activeProfile: 0,
    settings: {
      autoRetry: true,
      notifyOnSuccess: true,
      autoMode: false,
      multipleBooking: false
    }
  };

  // 验证身份证号
  function validateIdCard(idCard) {
    return /(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/.test(idCard);
  }

  // 验证手机号
  function validatePhone(phone) {
    return /^1[3456789]\d{9}$/.test(phone);
  }

  // 验证姓名
  function validateName(name) {
    return name.length >= 2 && /^[\u4e00-\u9fa5]{2,}$/.test(name);
  }

  // 延时函数
  function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }

  // 页面元素选择器定义
  const SELECTORS = {
    // 个人信息部分
    personalInfo: {
      name: 'input[placeholder="请输入客户姓名"]',
      idCard: 'input[placeholder="请输入证件号码"]',
      phone: 'input[placeholder="请输入正确的手机号码"]'
    },
    // 按钮和其他元素
    buttons: {
      agreement: '.el-checkbox__original',
      submit: '.mybutton'
    }
  };

  // 地区选择处理
  async function handleAreaSelect() {
    // 选择省份
    async function selectProvince() {
      const provinceSelect = document.querySelector('.el-select[placeholder="请选择省份"] input');
      if (!provinceSelect) return false;

      provinceSelect.click();
      await sleep(200);

      // 找到第一个非空的选项(假设是本省)并选中
      const options = Array.from(document.querySelectorAll('.el-select-dropdown__item'));
      const targetOption = options.find(opt => opt.textContent.trim() !== '');
      if (targetOption) {
        targetOption.click();
        await sleep(300);
        return true;
      }
      return false;
    }

    // 选择城市
    async function selectCity(value) {
      const citySelect = document.querySelector('.el-select[placeholder="请选择城市"] input');
      if (!citySelect) return false;

      citySelect.click();
      await sleep(200);

      const options = Array.from(document.querySelectorAll('.el-select-dropdown__item'));
      const targetOption = options.find(opt => opt.textContent.includes(value));
      if (targetOption) {
        targetOption.click();
        await sleep(300);
        return true;
      }
      return false;
    }

    // 选择区县
    async function selectDistrict(value) {
      const districtSelect = document.querySelector('.el-select[placeholder="请选择区县"] input');
      if (!districtSelect) return false;

      districtSelect.click();
      await sleep(200);

      const options = Array.from(document.querySelectorAll('.el-select-dropdown__item'));
      const targetOption = options.find(opt => opt.textContent.includes(value));
      if (targetOption) {
        targetOption.click();
        await sleep(300);
        return true;
      }
      return false;
    }

    // 选择网点
    async function selectBranch(value) {
      const branchSelect = document.querySelector('.el-select[placeholder="请选择网点"] input');
      if (!branchSelect) return false;

      branchSelect.click();
      await sleep(200);

      const options = Array.from(document.querySelectorAll('.el-select-dropdown__item'));
      const targetOption = options.find(opt => opt.textContent.includes(value));
      if (targetOption) {
        targetOption.click();
        await sleep(300);
        return true;
      }
      return false;
    }

    // 选择兑换日期 - 自动选第一个
    async function selectExchangeDate() {
      const dateSelect = document.querySelector('.el-select[placeholder="请选择兑换时间"] input');
      if (!dateSelect) return false;

      dateSelect.click();
      await sleep(200);

      const options = Array.from(document.querySelectorAll('.el-select-dropdown__item'));
      if (options.length > 0) {
        options[0].click();
        await sleep(300);
        return true;
      }
      return false;
    }

    return {
      selectProvince,
      selectCity,
      selectDistrict,
      selectBranch,
      selectExchangeDate
    };
  }

  // 创建控制面板
  function createPanel() {
    const panel = document.createElement('div');
    panel.className = 'booking-helper';
    panel.innerHTML = `
            <div class="panel-header">
                <span class="panel-title">预约助手</span>
                <span class="version">v${CONFIG.VERSION}</span>
            </div>
            <div class="panel-body">
                <!-- 简化控制面板元素 -->
                <div class="form-group">
                    <label>姓名</label>
                    <input type="text" class="form-input" id="nameInput" placeholder="请输入姓名">
                </div>
                <div class="form-group">
                    <label>身份证号</label>
                    <input type="text" class="form-input" id="idCardInput" placeholder="请输入身份证号">
                </div>
                <div class="form-group">
                    <label>手机号</label>
                    <input type="text" class="form-input" id="phoneInput" placeholder="请输入手机号">
                </div>

                <div class="form-group">
                    <label>目标时间</label>
                    <input type="text" class="form-input" id="targetTimeInput" placeholder="格式:22:00:00">
                </div>

                <div class="form-group switches">
                    <label class="switch" style="display: flex;">
                        <input type="checkbox" id="autoModeSwitch" style="width:30px">
                        <span class="label">自动模式</span>
                    </label>
                </div>
            </div>

            <div class="buttons">
                <button id="saveBtn" class="btn primary">保存配置</button>
                <button id="fillBtn" class="btn success">开始填充</button>
            </div>

            <div class="status-bar">
                <div class="status">状态: <span id="statusText">就绪</span></div>
                <div class="countdown">倒计时: <span id="countdownText">--:--:--</span></div>
            </div>
        `;
    document.body.appendChild(panel);
  }

  // 使面板可拖动
  function makeDraggable(element) {
    let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
    element.querySelector('.panel-header').onmousedown = dragMouseDown;

    function dragMouseDown(e) {
      e.preventDefault();
      pos3 = e.clientX;
      pos4 = e.clientY;
      document.onmouseup = closeDragElement;
      document.onmousemove = elementDrag;
    }

    function elementDrag(e) {
      e.preventDefault();
      pos1 = pos3 - e.clientX;
      pos2 = pos4 - e.clientY;
      pos3 = e.clientX;
      pos4 = e.clientY;
      element.style.top = (element.offsetTop - pos2) + "px";
      element.style.left = (element.offsetLeft - pos1) + "px";
    }

    function closeDragElement() {
      document.onmouseup = null;
      document.onmousemove = null;
    }
  }

  // 显示提示信息
  function showToast(message, type = 'info') {
    const toast = document.createElement('div');
    toast.className = 'booking-toast';
    if(type === 'error') {
      toast.style.backgroundColor = 'rgba(245, 108, 108, 0.9)';
    } else if(type === 'success') {
      toast.style.backgroundColor = 'rgba(103, 194, 58, 0.9)';
    }
    toast.textContent = message;
    document.body.appendChild(toast);
    setTimeout(() => {
      toast.remove();
    }, 2000);
  }

  // 更新状态显示
  function updateStatus(message, type = 'info') {
    const statusText = document.getElementById('statusText');
    if (statusText) {
      statusText.textContent = message;
      statusText.style.color =
          type === 'error' ? '#f56c6c' :
              type === 'success' ? '#67c23a' :
                  type === 'warning' ? '#e6a23c' : '#666';
    }
  }

  // 更新倒计时
  function updateCountdown(targetTime) {
    if (!targetTime) return;

    const countdownText = document.getElementById('countdownText');
    if (!countdownText) return;

    const now = new Date();
    const target = new Date();
    const [hours, minutes, seconds] = targetTime.split(':').map(Number);
    target.setHours(hours, minutes, seconds, 0);

    if (target <= now) {
      target.setDate(target.getDate() + 1);
    }

    const diff = target - now;
    const h = Math.floor(diff / 3600000);
    const m = Math.floor((diff % 3600000) / 60000);
    const s = Math.floor((diff % 60000) / 1000);

    countdownText.textContent =
        `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
  }

  // 自动填充核心函数
  async function fillForm(profile, options = {}) {
    try {
      updateStatus('开始填充...');

      // 填充个人信息
      await fillPersonalInfo(profile);

      // 选择地区
      const areaSelector = await handleAreaSelect();
      await areaSelector.selectProvince();
      await areaSelector.selectCity(profile.city);
      await areaSelector.selectDistrict(profile.district);
      await areaSelector.selectBranch(profile.targetBranch);

      // 选择兑换日期 - 自动选第一个
      await areaSelector.selectExchangeDate();

      // 勾选协议
      await handleAgreement();

      // 自动提交
      if (profile.autoSubmit && options.autoSubmit) {
        await submitForm();
      }

      showToast('填充完成');
      return true;

    } catch (error) {
      console.error('填充失败:', error);
      showToast('填充失败: ' + error.message);

      if (options.autoRetry) {
        return await retryFill(profile, options);
      }
      return false;
    }
  }

  // 填充个人信息
  async function fillPersonalInfo(profile) {
    const fields = {
      name: { selector: SELECTORS.personalInfo.name, value: profile.name },
      idCard: { selector: SELECTORS.personalInfo.idCard, value: profile.idCard },
      phone: { selector: SELECTORS.personalInfo.phone, value: profile.phone }
    };

    for (const [fieldName, field] of Object.entries(fields)) {
      const element = document.querySelector(field.selector);
      if (!element) {
        throw new Error(`未找到${fieldName}输入框`);
      }

      // 聚焦并清空
      element.focus();
      element.value = '';
      await sleep(50);

      // 模拟输入
      for (const char of field.value) {
        element.value += char;
        element.dispatchEvent(new Event('input', { bubbles: true }));
        await sleep(10);
      }

      // 触发事件
      ['change', 'blur'].forEach(event => {
        element.dispatchEvent(new Event(event, { bubbles: true }));
      });

      await sleep(50);
    }
  }

  // 处理协议勾选
  async function handleAgreement() {
    const checkbox = document.querySelector(SELECTORS.buttons.agreement);
    if (checkbox && !checkbox.checked) {
      checkbox.click();
      await sleep(50);
    }
  }

  // 提交表单
  async function submitForm() {
    const submitBtn = document.querySelector(SELECTORS.buttons.submit);
    if (!submitBtn) {
      throw new Error('未找到提交按钮');
    }
    submitBtn.click();
  }

  // 重试填充
  async function retryFill(profile, options) {
    for (let i = 0; i < CONFIG.RETRY_TIMES; i++) {
      updateStatus(`第${i + 1}次重试...`, 'warning');
      await sleep(1000);

      try {
        if (await fillForm(profile, { ...options, autoRetry: false })) {
          return true;
        }
      } catch (error) {
        console.error(`重试${i + 1}失败:`, error);
      }
    }

    updateStatus('重试次数已用完', 'error');
    return false;
  }

  // 验证配置
  function validateProfile(profile) {
    if (!profile) return false;

    if (!validateName(profile.name)) {
      showToast('姓名格式不正确', 'error');
      return false;
    }

    if (!validateIdCard(profile.idCard)) {
      showToast('身份证号格式不正确', 'error');
      return false;
    }

    if (!validatePhone(profile.phone)) {
      showToast('手机号格式不正确', 'error');
      return false;
    }

    return true;
  }

  // 绑定事件
  function bindEvents() {
    // 填充按钮点击事件
    document.getElementById('fillBtn')?.addEventListener('click', async () => {
      const btn = document.getElementById('fillBtn');
      try {
        btn.disabled = true;
        btn.textContent = '填充中...';

        const config = loadConfig();
        const profile = {
          name: document.getElementById('nameInput').value.trim(),
          idCard: document.getElementById('idCardInput').value.trim(),
          phone: document.getElementById('phoneInput').value.trim(),
          targetTime: document.getElementById('targetTimeInput').value.trim(),
          ...config.profiles[0]
        };

        if (!validateProfile(profile)) {
          return;
        }

        await fillForm(profile, {
          autoSubmit: profile.autoSubmit,
          autoRetry: config.settings.autoRetry
        });

      } catch (error) {
        console.error('填充出错:', error);
        showToast(error.message || '填充失败', 'error');
      } finally {
        btn.disabled = false;
        btn.textContent = '开始填充';
      }
    });

    // 保存配置按钮事件
    document.getElementById('saveBtn')?.addEventListener('click', () => {
      try {
        const profile = {
          name: document.getElementById('nameInput').value.trim(),
          idCard: document.getElementById('idCardInput').value.trim(),
          phone: document.getElementById('phoneInput').value.trim(),
          targetTime: document.getElementById('targetTimeInput').value.trim()
        };

        if (!validateProfile(profile)) {
          return;
        }

        const config = loadConfig();
        config.profiles[0] = { ...config.profiles[0], ...profile };
        saveConfig(config);

        showToast('配置已保存', 'success');
      } catch (error) {
        console.error('保存配置失败:', error);
        showToast('保存配置失败', 'error');
      }
    });

    // 自动模式开关事件
    document.getElementById('autoModeSwitch')?.addEventListener('change', (e) => {
      const config = loadConfig();
      config.settings.autoMode = e.target.checked;
      saveConfig(config);
      updateStatus(e.target.checked ? '自动模式已开启' : '自动模式已关闭');
    });

    // 定时检查
    setInterval(() => {
      const config = loadConfig();
      if (!config.settings.autoMode) return;

      const profile = config.profiles[0];
      if (!profile || !profile.targetTime) return;

      // 更新倒计时显示
      updateCountdown(profile.targetTime);

      // 检查是否到达目标时间
      const now = new Date();
      const [hours, minutes, seconds] = profile.targetTime.split(':').map(Number);
      const targetTime = new Date();
      targetTime.setHours(hours, minutes, seconds, 0);

      if (Math.abs(now - targetTime) < 1000) { // 1秒误差
        fillForm(profile, { autoSubmit: true, autoRetry: config.settings.autoRetry });
      }
    }, 500);
  }

  // 加载配置
  function loadConfig() {
    return GM_getValue('BOOKING_CONFIG', defaultConfig);
  }

  // 保存配置
  function saveConfig(config) {
    GM_setValue('BOOKING_CONFIG', config);
  }

  // 初始化函数
  async function init() {
    try {
      // 创建控制面板
      createPanel();
      // 使面板可拖动
      makeDraggable(document.querySelector('.booking-helper'));

      // 绑定事件
      bindEvents();

      // 加载已保存的配置
      const profile = loadConfig().profiles[0];
      if (profile) {
        document.getElementById('nameInput').value = profile.name || '';
        document.getElementById('idCardInput').value = profile.idCard || '';
        document.getElementById('phoneInput').value = profile.phone || '';
        document.getElementById('targetTimeInput').value = profile.targetTime || '';
      }

      // 加载配置
      const config = loadConfig();
      document.getElementById('autoModeSwitch').checked = config.settings.autoMode;

      showToast('预约助手已启动', 'success');
      updateStatus('就绪');

    } catch (error) {
      console.error('初始化失败:', error);
      showToast('初始化失败', 'error');
    }
  }

  // 检查是否在预约页面
  function isBookingPage() {
    return window.location.pathname.includes('/ICBCCOINWEBPC/');
  }

  // 启动脚本
  if (isBookingPage()) {
    // 等待页面加载完成
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', init);
    } else {
      init();
    }
  }

})();