最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 如何编写Quantumult-X脚本

    正文概述 掘金(沧沧凉凉)   2021-02-25   2247

    最近对脚本开发比较感兴趣,通过JavaScript编写脚本,不仅可以加深你对JavaScript使用的熟练程度,更甚可以锻炼你的逻辑能力。

    IOS上面有一些可以定时运行js脚本的工具,这些工具可以实现通过js定时京东签到,漫画签到等一系列的功能。Quantumult-X就是其中的一个软件,我知道的还有LoonSurge不过后两者我是完全没有使用过。

    奇怪的是Quantumult-X这种软件,我目前没有找到官方的API文档,翻来覆去折腾了好久,还是决定直接看一下别人写的源码,其中我参考的是京东签到这个脚本的源代码。

    读这种比较长的源码一定不要在网页上面阅读,因为没有代码定位,你可以将它拷贝到本地,通过vscode这一类的编辑器打开阅读,其中它有一个比较重要的方法就是。

    注:本篇文章不适合对JavaScript没有了解的小白阅读,同时阅读本篇文章之前,相信你已经知道Quantumult-X的基础用法,并且已经可以正常使用别人的开源脚本。

    1. 工具函数

    下面是脚本作者封装的工具函数,里面实现了http请求、消息提醒、警告、数据持久化储存

    可以大致过一遍这些源代码,在后面的文章会单独的将上面提到的功能进行讲解。

    /**
     * 工具类
     * @return {{read: ((function(*=): (*|null|undefined))|*), isRequest: boolean, isLoon: boolean, isQuanX: boolean, isNode: boolean, done: ((function(*=): (*|undefined))|*), notify: notify, isSurge: boolean, post: post, AnError: (function(*, *=, *=, *=, *): void), get: get, time: (function(): void), isJSBox: boolean, write: ((function(*=, *=): (*|undefined))|*)}}
     */
    export function nobyda() {
      const start = Date.now();
      // 判断是否是重写
      const isRequest = typeof $request != "undefined";
      // 判断是否是Surge
      const isSurge = typeof $httpClient != "undefined";
      // 判断是否是QuanX
      const isQuanX = typeof $task != "undefined";
      // 判断是否是Loon
      const isLoon = typeof $loon != "undefined";
      // 判断是否是JSBox
      const isJSBox = typeof $app != "undefined" && typeof $http != "undefined";
      // 判断是否是Node环境
      const isNode = typeof require == "function" && !isJSBox;
      const NodeSet = "CookieSet.json";
      /**
       * 引入Nodejs中的request模块和fs模块
       * @type {{request: *, fs: module:fs}|null}
       */
      const node = (() => {
        if (isNode) {
          const request = require("request");
          const fs = require("fs");
          return ({
            request,
            fs
          });
        } else {
          return null;
        }
      })();
      /**
       * 提示信息
       * @param {string} title 标题
       * @param {string} subtitle 副标题
       * @param {string} message 提示信息
       * @param {*} rawopts 设置
       */
      const notify = (title, subtitle, message, rawopts) => {
        const Opts = (rawopts) => {
          //Modified from https://github.com/chavyleung/scripts/blob/master/Env.js
          if (!rawopts) return rawopts;
          switch (typeof rawopts) {
            case "string":
              return isLoon
                ? rawopts
                : isQuanX
                ? {
                    "open-url": rawopts,
                  }
                : isSurge
                ? {
                    url: rawopts,
                  }
                : undefined;
            case "object":
              if (isLoon) {
                let openUrl = rawopts.openUrl || rawopts.url || rawopts["open-url"];
                let mediaUrl = rawopts.mediaUrl || rawopts["media-url"];
                return {
                  openUrl,
                  mediaUrl,
                };
              } else if (isQuanX) {
                let openUrl = rawopts["open-url"] || rawopts.url || rawopts.openUrl;
                let mediaUrl = rawopts["media-url"] || rawopts.mediaUrl;
                return {
                  "open-url": openUrl,
                  "media-url": mediaUrl,
                };
              } else if (isSurge) {
                let openUrl = rawopts.url || rawopts.openUrl || rawopts["open-url"];
                return {
                  url: openUrl,
                };
              }
              break;
            default:
              return undefined;
          }
        };
        console.log(`${ title }\n${ subtitle }\n${ message }`);
        if (isQuanX) $notify(title, subtitle, message, Opts(rawopts));
        if (isSurge) $notification.post(title, subtitle, message, Opts(rawopts));
        if (isJSBox) $push.schedule({
          title: title,
          body: subtitle ? subtitle + "\n" + message : message
        });
      };
      // 将获得的cookies信息储存起来
      const write = (value, key) => {
        if (isQuanX) return $prefs.setValueForKey(value, key);
        if (isSurge) return $persistentStore.write(value, key);
        if (isNode) {
          try {
            if (!node.fs.existsSync(NodeSet)) node.fs.writeFileSync(NodeSet, JSON.stringify({}));
            const dataValue = JSON.parse(node.fs.readFileSync(NodeSet));
            if (value) dataValue[key] = value;
            if (!value) delete dataValue[key];
            return node.fs.writeFileSync(NodeSet, JSON.stringify(dataValue));
          } catch (er) {
            return AnError("Node.js持久化写入", null, er);
          }
        }
        if (isJSBox) {
          if (!value) return $file.delete(`shared://${ key }.txt`);
          return $file.write({
            data: $data({
              string: value
            }),
            path: `shared://${ key }.txt`
          });
        }
      };
      // 将获取的cookies信息读出来
      const read = (key) => {
        if (isQuanX) return $prefs.valueForKey(key);
        if (isSurge) return $persistentStore.read(key);
        if (isNode) {
          try {
            if (!node.fs.existsSync(NodeSet)) return null;
            const dataValue = JSON.parse(node.fs.readFileSync(NodeSet));
            return dataValue[key];
          } catch (er) {
            return AnError("Node.js持久化读取", null, er);
          }
        }
        if (isJSBox) {
          if (!$file.exists(`shared://${ key }.txt`)) return null;
          return $file.read(`shared://${ key }.txt`).string;
        }
      };
      const adapterStatus = (response) => {
        if (response) {
          if (response.status) {
            response["statusCode"] = response.status;
          } else if (response.statusCode) {
            response["status"] = response.statusCode;
          }
        }
        return response;
      };
      // get请求
      const get = (options, callback) => {
        options.headers["User-Agent"] = "JD4iPhone/167169 (iPhone; iOS 13.4.1; Scale/3.00)";
        if (isQuanX) {
          if (typeof options == "string") options = {
            url: options
          };
          options["method"] = "GET";
          //options["opts"] = {
          //  "hints": false
          //}
          $task.fetch(options).then(response => {
            callback(null, adapterStatus(response), response.body);
          }, reason => callback(reason.error, null, null));
        }
        if (isSurge) {
          options.headers["X-Surge-Skip-Scripting"] = false;
          $httpClient.get(options, (error, response, body) => {
            callback(error, adapterStatus(response), body);
          });
        }
        if (isNode) {
          node.request(options, (error, response, body) => {
            callback(error, adapterStatus(response), body);
          });
        }
        if (isJSBox) {
          if (typeof options == "string") options = {
            url: options
          };
          options["header"] = options["headers"];
          options["handler"] = function (resp) {
            let error = resp.error;
            if (error) error = JSON.stringify(resp.error);
            let body = resp.data;
            if (typeof body == "object") body = JSON.stringify(resp.data);
            callback(error, adapterStatus(resp.response), body);
          };
          $http.get(options);
        }
      };
      // post请求
      const post = (options, callback) => {
        options.headers["User-Agent"] = "JD4iPhone/167169 (iPhone; iOS 13.4.1; Scale/3.00)";
        if (options.body) options.headers["Content-Type"] = "application/x-www-form-urlencoded";
        if (isQuanX) {
          if (typeof options == "string") options = {
            url: options
          };
          options["method"] = "POST";
          $task.fetch(options).then(response => {
            callback(null, adapterStatus(response), response.body);
          }, reason => callback(reason.error, null, null));
        }
        if (isSurge) {
          options.headers["X-Surge-Skip-Scripting"] = false;
          $httpClient.post(options, (error, response, body) => {
            callback(error, adapterStatus(response), body);
          });
        }
        if (isNode) {
          node.request.post(options, (error, response, body) => {
            callback(error, adapterStatus(response), body);
          });
        }
        if (isJSBox) {
          if (typeof options == "string") options = {
            url: options
          };
          options["header"] = options["headers"];
          options["handler"] = function (resp) {
            let error = resp.error;
            if (error) error = JSON.stringify(resp.error);
            let body = resp.data;
            if (typeof body == "object") body = JSON.stringify(resp.data);
            callback(error, adapterStatus(resp.response), body);
          };
          $http.post(options);
        }
      };
      // 异常信息
      const AnError = (name, keyname, er, resp, body) => {
        if (typeof (merge) != "undefined" && keyname) {
          if (!merge[keyname].notify) {
            merge[keyname].notify = `${ name }: 异常, 已输出日志 ‼️`;
          } else {
            merge[keyname].notify += `\n${ name }: 异常, 已输出日志 ‼️ (2)`;
          }
          merge[keyname].error = 1;
        }
        return console.log(`\n‼️${ name }发生错误\n‼️名称: ${ er.name }\n‼️描述: ${ er.message }${ JSON.stringify(er).match(/"line"/) ? `\n‼️行列: ${ JSON.stringify(er) }` : `` }${ resp && resp.status ? `\n‼️状态: ${ resp.status }` : `` }${ body ? `\n‼️响应: ${ resp && resp.status != 503 ? body : `Omit.` }` : `` }`);
      };
      // 总共用时
      const time = () => {
        const end = ((Date.now() - start) / 1000).toFixed(2);
        return console.log("\n签到用时: " + end + " 秒");
      };
      // 关闭请求
      const done = (value = {}) => {
        if (isQuanX) return $done(value);
        if (isSurge) isRequest ? $done(value) : $done();
      };
      return {
        AnError,
        isRequest,
        isJSBox,
        isSurge,
        isQuanX,
        isLoon,
        isNode,
        notify,
        write,
        read,
        get,
        post,
        time,
        done
      };
    }
    

    2. 分析

    2.1 判断环境

    其实大部分代码我都已经在阅读源码的时候标上了注释,源码不难理解,首先进入代码一开始就是判断各种环境:

    // 判断是否是重写
    const isRequest = typeof $request != "undefined";
    // 判断是否是Surge
    const isSurge = typeof $httpClient != "undefined";
    // 判断是否是QuanX
    const isQuanX = typeof $task != "undefined";
    // 判断是否是Loon
    const isLoon = typeof $loon != "undefined";
    // 判断是否是JSBox
    const isJSBox = typeof $app != "undefined" && typeof $http != "undefined";
    // 判断是否是Node环境
    const isNode = typeof require == "function" && !isJSBox;
    

    该作者的这个脚本不仅仅可以用在QuanX上面,还可以在Surge、Loon、JSBox甚至是Node环境上运行。

    2.2 提示信息

    该方法封装了消息提示功能,就跟微信来消息的弹框一样,如果你没有给该软件提示权限则收不到相关的消息提示。

    /**
     * 提示信息
     * @param {string} title 标题
     * @param {string} subtitle 副标题
     * @param {string} message 提示信息
     * @param {*} rawopts 设置
     */
    const notify = (title, subtitle, message, rawopts) => {
      const Opts = (rawopts) => {
        //Modified from https://github.com/chavyleung/scripts/blob/master/Env.js
        if (!rawopts) return rawopts;
        switch (typeof rawopts) {
          case "string":
            return isLoon
              ? rawopts
              : isQuanX
              ? {
                  "open-url": rawopts,
                }
              : isSurge
              ? {
                  url: rawopts,
                }
              : undefined;
          case "object":
            if (isLoon) {
              let openUrl = rawopts.openUrl || rawopts.url || rawopts["open-url"];
              let mediaUrl = rawopts.mediaUrl || rawopts["media-url"];
              return {
                openUrl,
                mediaUrl,
              };
            } else if (isQuanX) {
              let openUrl = rawopts["open-url"] || rawopts.url || rawopts.openUrl;
              let mediaUrl = rawopts["media-url"] || rawopts.mediaUrl;
              return {
                "open-url": openUrl,
                "media-url": mediaUrl,
              };
            } else if (isSurge) {
              let openUrl = rawopts.url || rawopts.openUrl || rawopts["open-url"];
              return {
                url: openUrl,
              };
            }
            break;
          default:
            return undefined;
        }
      };
      console.log(`${ title }\n${ subtitle }\n${ message }`);
      if (isQuanX) $notify(title, subtitle, message, Opts(rawopts));
      if (isSurge) $notification.post(title, subtitle, message, Opts(rawopts));
      if (isJSBox) $push.schedule({
        title: title,
        body: subtitle ? subtitle + "\n" + message : message
      });
    };
    

    2.3 异常信息

    脚本中封装的异常信息提示方法。

    // 异常信息
    const AnError = (name, keyname, er, resp, body) => {
      if (typeof (merge) != "undefined" && keyname) {
        if (!merge[keyname].notify) {
          merge[keyname].notify = `${ name }: 异常, 已输出日志 ‼️`;
        } else {
          merge[keyname].notify += `\n${ name }: 异常, 已输出日志 ‼️ (2)`;
        }
        merge[keyname].error = 1;
      }
      return console.log(`\n‼️${ name }发生错误\n‼️名称: ${ er.name }\n‼️描述: ${ er.message }${ JSON.stringify(er).match(/"line"/) ? `\n‼️行列: ${ JSON.stringify(er) }` : `` }${ resp && resp.status ? `\n‼️状态: ${ resp.status }` : `` }${ body ? `\n‼️响应: ${ resp && resp.status != 503 ? body : `Omit.` }` : `` }`);
    };
    

    2.4 请求

    在脚本的编写中因为要获取第三方网站的信息,或者模拟请求,所以需要使用到http请求,由于各个软件的请求方式有些许不同,所以该脚本中封装了一个get以及post请求,进行了差异化处理,直接调用这两个方法就可以进行http请求。

    // get请求
    const get = (options, callback) => {
      options.headers["User-Agent"] = "JD4iPhone/167169 (iPhone; iOS 13.4.1; Scale/3.00)";
      if (isQuanX) {
        if (typeof options == "string") options = {
          url: options
        };
        options["method"] = "GET";
        //options["opts"] = {
        //  "hints": false
        //}
        $task.fetch(options).then(response => {
          callback(null, adapterStatus(response), response.body);
        }, reason => callback(reason.error, null, null));
      }
      if (isSurge) {
        options.headers["X-Surge-Skip-Scripting"] = false;
        $httpClient.get(options, (error, response, body) => {
          callback(error, adapterStatus(response), body);
        });
      }
      if (isNode) {
        node.request(options, (error, response, body) => {
          callback(error, adapterStatus(response), body);
        });
      }
      if (isJSBox) {
        if (typeof options == "string") options = {
          url: options
        };
        options["header"] = options["headers"];
        options["handler"] = function (resp) {
          let error = resp.error;
          if (error) error = JSON.stringify(resp.error);
          let body = resp.data;
          if (typeof body == "object") body = JSON.stringify(resp.data);
          callback(error, adapterStatus(resp.response), body);
        };
        $http.get(options);
      }
    };
    // post请求
    const post = (options, callback) => {
      options.headers["User-Agent"] = "JD4iPhone/167169 (iPhone; iOS 13.4.1; Scale/3.00)";
      if (options.body) options.headers["Content-Type"] = "application/x-www-form-urlencoded";
      if (isQuanX) {
        if (typeof options == "string") options = {
          url: options
        };
        options["method"] = "POST";
        $task.fetch(options).then(response => {
          callback(null, adapterStatus(response), response.body);
        }, reason => callback(reason.error, null, null));
      }
      if (isSurge) {
        options.headers["X-Surge-Skip-Scripting"] = false;
        $httpClient.post(options, (error, response, body) => {
          callback(error, adapterStatus(response), body);
        });
      }
      if (isNode) {
        node.request.post(options, (error, response, body) => {
          callback(error, adapterStatus(response), body);
        });
      }
      if (isJSBox) {
        if (typeof options == "string") options = {
          url: options
        };
        options["header"] = options["headers"];
        options["handler"] = function (resp) {
          let error = resp.error;
          if (error) error = JSON.stringify(resp.error);
          let body = resp.data;
          if (typeof body == "object") body = JSON.stringify(resp.data);
          callback(error, adapterStatus(resp.response), body);
        };
        $http.post(options);
      }
    };
    
    // 关闭请求
    const done = (value = {}) => {
      if (isQuanX) return $done(value);
      if (isSurge) isRequest ? $done(value) : $done();
    };
    

    2.5 数据读取

    该脚本针对数据持久化储存进行了封装,可以将你获取到的Cookie信息存储下来,就不用每次运行脚本时都需要填写Cookie信息。

    // 将获得的cookies信息储存起来
    const write = (value, key) => {
      if (isQuanX) return $prefs.setValueForKey(value, key);
      if (isSurge) return $persistentStore.write(value, key);
      if (isNode) {
        try {
          if (!node.fs.existsSync(NodeSet)) node.fs.writeFileSync(NodeSet, JSON.stringify({}));
          const dataValue = JSON.parse(node.fs.readFileSync(NodeSet));
          if (value) dataValue[key] = value;
          if (!value) delete dataValue[key];
          return node.fs.writeFileSync(NodeSet, JSON.stringify(dataValue));
        } catch (er) {
          return AnError("Node.js持久化写入", null, er);
        }
      }
      if (isJSBox) {
        if (!value) return $file.delete(`shared://${ key }.txt`);
        return $file.write({
          data: $data({
            string: value
          }),
          path: `shared://${ key }.txt`
        });
      }
    };
    
    // 将获取的cookies信息读出来
    const read = (key) => {
      if (isQuanX) return $prefs.valueForKey(key);
      if (isSurge) return $persistentStore.read(key);
      if (isNode) {
        try {
          if (!node.fs.existsSync(NodeSet)) return null;
          const dataValue = JSON.parse(node.fs.readFileSync(NodeSet));
          return dataValue[key];
        } catch (er) {
          return AnError("Node.js持久化读取", null, er);
        }
      }
      if (isJSBox) {
        if (!$file.exists(`shared://${ key }.txt`)) return null;
        return $file.read(`shared://${ key }.txt`).string;
      }
    };
    

    3. 重写

    写过脚本或者爬虫的人应该都比较清楚,http为无状态请求,也就是后端并不知道请求之前用户进行了什么操作,如果服务器要识别某一请求为哪个用户发出来的,现在最主流的有两种办法,一种是token,一种是Cookie。

    而从该脚本中可以得知,京东明显是使用了Cookie判断用户信息,那么如何使用脚本来获取cookie信息呢?

    根据源代码,如果const isRequest = typeof $request != "undefined";那么该脚本即为重写。

    那么我们只需要着重观察哪儿有调用isRequest

    if (DeleteCookie) {
      if ($nobyda.read(EnvInfo) || $nobyda.read(EnvInfo2)) {
        $nobyda.write("", EnvInfo);
        $nobyda.write("", EnvInfo2);
        $nobyda.notify("京东Cookie清除成功 !", "", "请手动关闭脚本内\"DeleteCookie\"选项");
        $nobyda.done();
        return;
      }
      $nobyda.notify("脚本终止", "", "未关闭脚本内\"DeleteCookie\"选项 ‼️");
      $nobyda.done();
      return;
    } else if ($nobyda.isRequest) {
      // 如果为重写,那么就执行GetCookie函数
      GetCookie();
      return;
    }
    

    可以看到在$nobyda.isRequesttrue时调用了GetCookie()函数,于是我们就着重分析GetCookie()函数。

    // 自动获取cookie方法
    function GetCookie() {
      try {
        if ($request.headers && $request.url.match(/api\.m\.jd\.com.*=signBean/)) {
          var CV = $request.headers["Cookie"];
          if (CV.match(/pt_key=.+?;/) && CV.match(/pt_pin=.+?;/)) {
            var CookieValue = CV.match(/pt_key=.+?;/)[0] + CV.match(/pt_pin=.+?;/)[0];
            var CK1 = $nobyda.read("CookieJD");
            var CK2 = $nobyda.read("CookieJD2");
            var AccountOne = CK1 ? CK1.match(/pt_pin=.+?;/) ? CK1.match(/pt_pin=(.+?);/)[1] : null : null;
            var AccountTwo = CK2 ? CK2.match(/pt_pin=.+?;/) ? CK2.match(/pt_pin=(.+?);/)[1] : null : null;
            var UserName = CookieValue.match(/pt_pin=(.+?);/)[1];
            var DecodeName = decodeURIComponent(UserName);
            if (!AccountOne || UserName == AccountOne) {
              var CookieName = " [账号一] ";
              var CookieKey = "CookieJD";
            } else if (!AccountTwo || UserName == AccountTwo) {
              var CookieName = " [账号二] ";
              var CookieKey = "CookieJD2";
            } else {
              $nobyda.notify("更新京东Cookie失败", "非历史写入账号 ‼️", "请开启脚本内\"DeleteCookie\"以清空Cookie ‼️");
              return;
            }
          } else {
            $nobyda.notify("写入京东Cookie失败", "", "请查看脚本内说明, 登录网页获取 ‼️");
            return;
          }
          const RA = $nobyda.read(CookieKey);
          if (RA == CookieValue) {
            console.log(`\n用户名: ${ DecodeName }\n与历史京东${ CookieName }Cookie相同, 跳过写入 ⚠️`);
          } else {
            const WT = $nobyda.write(CookieValue, CookieKey);
            $nobyda.notify(`用户名: ${ DecodeName }`, ``, `${ RA ? `更新` : `写入` }京东${ CookieName }Cookie${ WT ? `成功 ?` : `失败 ‼️` }`);
          }
        } else if ($request.url === "http://www.apple.com/") {
          $nobyda.notify("京东签到", "", "类型错误, 手动运行请选择上下文环境为Cron ⚠️");
        } else {
          $nobyda.notify("京东签到", "写入Cookie失败", "请检查匹配URL或配置内脚本类型 ⚠️");
        }
      } catch (eor) {
        $nobyda.write("", "CookieJD");
        $nobyda.write("", "CookieJD2");
        $nobyda.notify("写入京东Cookie失败", "", "已尝试清空历史Cookie, 请重试 ⚠️");
        console.log(`\n写入京东Cookie出现错误 ‼️\n${ JSON.stringify(eor) }\n\n${ eor }\n\n${ JSON.stringify($request.headers) }\n`);
      } finally {
        $nobyda.done();
      }
    }
    

    因为Quantumult-X的重写功能为访问到指定的url就可以触发脚本,根据该脚本来看,几个软件的重写方法没有什么差异化,都是使用的$request对象,而Node环境下无法自动获取Cookie,必须进行手动填写。

    4. 最后

    我找了好久Quantumult-X都没有提供官方文档,所以我并不清楚它的API,不过从上面的脚本来看,大致分为下面几个API:

    • $prefs:持久化数据存储(读取和写入)。
    • $task:网络请求。
    • $done:请求完毕时需要调用。
    • $request:重写网络请求,用来获取请求中的Cookie等,甚至可以用来篡改响应体
    • $notify:弹框提示信息。

    看了一下该脚本封装的还是比较全的,几乎可能用到的方法都封装进去了,同时我尝试将脚本通过webpack进行压缩,事实证明即使经过webpack压缩,该脚本依然是可以正常使用。也就是说,在编写脚本的时候可以通过webpack引入一些第三方工具类。

    目前来说通过webpack压缩脚本仅仅只有一点缺陷,就是在分享脚本时,别人无法直接阅读到你的源代码,而无法进行修改(修改难度高),不过如果你不想暴露自己的脚本源代码,通过webpack压缩是一个非常不错的选择。


    起源地下载网 » 如何编写Quantumult-X脚本

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元