luoshi 6 年之前
当前提交
24e8e1e11a
共有 45 个文件被更改,包括 2656 次插入0 次删除
  1. 4 0
      .gitignore
  2. 46 0
      config.json
  3. 二进制
      font/simsun.ttf
  4. 85 0
      source/lib/api.js
  5. 30 0
      source/lib/config.js
  6. 106 0
      source/lib/db.js
  7. 14 0
      source/lib/env.js
  8. 229 0
      source/lib/image.js
  9. 22 0
      source/lib/logger.js
  10. 109 0
      source/lib/sync.js
  11. 346 0
      source/lib/upyun.js
  12. 63 0
      source/main.js
  13. 23 0
      source/package.json
  14. 14 0
      source/test/api-test.js
  15. 4 0
      source/test/config-test.js
  16. 12 0
      source/test/db-test.js
  17. 10 0
      source/test/readline-test.js
  18. 11 0
      source/test/upyun-test.js
  19. 204 0
      source/test/watermark-test.js
  20. 672 0
      source/view/css/style.css
  21. 96 0
      source/view/image-download.html
  22. 137 0
      source/view/image.html
  23. 二进制
      source/view/img/back.png
  24. 二进制
      source/view/img/bg.jpg
  25. 二进制
      source/view/img/close.png
  26. 二进制
      source/view/img/data_btn.png
  27. 二进制
      source/view/img/enter.png
  28. 二进制
      source/view/img/error.png
  29. 二进制
      source/view/img/error_small.png
  30. 二进制
      source/view/img/icon1.png
  31. 二进制
      source/view/img/icon2.png
  32. 二进制
      source/view/img/icon3.png
  33. 二进制
      source/view/img/id.png
  34. 二进制
      source/view/img/logo.png
  35. 二进制
      source/view/img/logo_blue.png
  36. 二进制
      source/view/img/pic_btn.png
  37. 二进制
      source/view/img/radio.png
  38. 二进制
      source/view/img/select.png
  39. 二进制
      source/view/img/success.png
  40. 二进制
      source/view/img/success_small.png
  41. 48 0
      source/view/index.html
  42. 132 0
      source/view/list.html
  43. 90 0
      source/view/login.html
  44. 97 0
      source/view/sync-run.html
  45. 52 0
      source/view/sync.html

+ 4 - 0
.gitignore

@@ -0,0 +1,4 @@
+*.asar
+logs/*
+source/node_modules/*
+**/.DS_Store

+ 46 - 0
config.json

@@ -0,0 +1,46 @@
+{
+    "db": {
+        "host": "192.168.10.30",
+        "post": 3306,
+        "user": "root",
+        "password": "root",
+        "database": "stmms_gx_init"
+    },
+    "imageUrl": {
+        "sheet": "/{{examId}}-{{campusCode}}/{{subjectCode}}/{{examNumber}}-{{index}}.jpg",
+        "package": "/{{examId}}/{{code}}/{{index}}.jpg"
+    },
+    "watermark": {
+        "fontFile": "/Users/luoshi/develop/project/mc-proxy/font/simsun.ttf",
+        "fontSize": 30,
+        "color": "#ff0000"
+    },
+    "logger": {
+        "level": "info"
+    },
+    "openDevTools": false,
+    "localStore": "",
+    "syncTime": {
+        "192.168.10.42:8080_1": "2018-10-19 18:40:47"
+    },
+    "servers": [
+        {
+            "name": "高校",
+            "host": "gx.markingcloud.com",
+            "bucketPrefix": "gx",
+            "upyunDomain": ""
+        },
+        {
+            "name": "奥鹏",
+            "host": "ap.markingcloud.com",
+            "bucketPrefix": "ap",
+            "upyunDomain": ""
+        },
+        {
+            "name": "局域网",
+            "host": "192.168.10.42:8080",
+            "bucketPrefix": "gx",
+            "upyunDomain": "192.168.10.42:8080/file"
+        }
+    ]
+}

二进制
font/simsun.ttf


+ 85 - 0
source/lib/api.js

@@ -0,0 +1,85 @@
+const request = require('request')
+const env = require('./env.js')
+const logger = require('./logger.js')('api.js')
+
+async function execute(uri, method, form) {
+    return new Promise((resolve, reject) => {
+        request({
+            url: 'http://' + env.server.host + uri,
+            method: method,
+            json: true,
+            form: form || {},
+            headers: {
+                'auth-info': 'loginname=' + env.loginName + ';password=' + env.password
+            }
+        }, function (error, response, body) {
+            if (response.statusCode == 200) {
+                resolve(body);
+            } else {
+                let message = response.statusCode + ' ' + (error || '') + (response.headers['error-info'] || '')
+                logger.error(message)
+                reject(message)
+            }
+        })
+    })
+}
+
+module.exports.login = function () {
+    return execute('/api/user/login', 'GET');
+}
+
+module.exports.getExams = function (pageNumber, pageSize) {
+    let uri = '/api/exams'
+    let param = [];
+    if (pageNumber != undefined) {
+        param.push('pageNumber=' + pageNumber)
+    }
+    if (pageSize != undefined) {
+        param.push('pageSize=' + pageSize)
+    }
+    if (param.length > 0) {
+        uri = uri + '?' + param.join('&')
+    }
+    return execute(uri, 'GET');
+}
+
+module.exports.getStudents = function (examId, pageNumber, pageSize, upload, absent, withScoreDetail) {
+    let form = {
+        examId: examId,
+        pageNumber: pageNumber,
+        pageSize: pageSize
+    }
+    if (upload != undefined) {
+        form.upload = upload
+    }
+    if (absent != undefined) {
+        form.absent = absent
+    }
+    if (withScoreDetail != undefined) {
+        form.withScoreDetail = withScoreDetail
+    }
+    return execute('/api/exam/students', 'POST', form);
+}
+
+module.exports.countStudents = function (examId, upload, absent) {
+    let uri = '/api/students/count/' + examId
+    let param = [];
+    if (upload != undefined) {
+        param.push('upload=' + (upload ? 'true' : 'false'))
+    }
+    if (absent != undefined) {
+        param.push('absent=' + (absent ? 'true' : 'false'))
+    }
+    if (param.length > 0) {
+        uri = uri + '?' + param.join('&')
+    }
+    return execute(uri, 'GET');
+}
+
+module.exports.getPackages = function (examId, upload) {
+    let uri = '/api/package/count/' + examId
+    if (upload != undefined) {
+        uri = uri + '?upload=' + (upload ? 'true' : false)
+    }
+    return execute(uri, 'GET');
+}

+ 30 - 0
source/lib/config.js

@@ -0,0 +1,30 @@
+const fs = require('fs')
+const path = require('path')
+const moment = require('moment')
+const store = require('../../config.json')
+const env = require('./env.js')
+
+let timeLog = path.join(__dirname, '../../logs/time.log')
+let syncTime = {}
+if (fs.existsSync(path.join(__dirname, '../../logs/time.log'))) {
+    try {
+        syncTime = JSON.parse(fs.readFileSync(timeLog))
+    } catch (err) {
+        syncTime = {}
+    }
+}
+store['syncTime'] = syncTime
+store['upyun'] = {
+    operator: 'qmth',
+    password: 'qmth123456'
+}
+
+module.exports = store;
+
+module.exports.updateSyncTime = function () {
+    if (env.server && env.exam) {
+        syncTime[env.server.host + '_' + env.exam.id] = moment().format('YYYY-MM-DD HH:mm:ss')
+        store['syncTime'] = syncTime
+        fs.writeFileSync(timeLog, JSON.stringify(syncTime))
+    }
+}

+ 106 - 0
source/lib/db.js

@@ -0,0 +1,106 @@
+const mysql = require('mysql')
+const config = require('./config.js')
+
+var pool = undefined;
+
+function initPool() {
+    pool = mysql.createPool({
+        host: config.db.host,
+        port: config.db.port,
+        user: config.db.user,
+        password: config.db.password,
+        database: config.db.database || '',
+        charset: 'utf8',
+        connectionLimit: 10
+    });
+}
+
+function getTransactionConnection() {
+    return new Promise((resolve, reject) => {
+        if (pool == undefined) {
+            reject('db not init');
+        }
+        pool.getConnection((err, connection) => {
+            if (err) {
+                reject(err);
+            } else {
+                connection.beginTransaction((err) => {
+                    if (err) {
+                        reject(err);
+                    } else {
+                        resolve(connection);
+                    }
+                });
+            }
+        });
+    });
+}
+
+function query(connection, sql, datas) {
+    return new Promise((resolve, reject) => {
+        connection.query(sql, datas, (err, results, fields) => {
+            if (err) {
+                reject(err);
+            } else {
+                resolve(results, fields);
+            }
+        });
+    });
+}
+
+module.exports.init = function () {
+    if (pool != undefined) {
+        pool.end();
+        pool = undefined;
+    }
+    initPool();
+}
+
+module.exports.close = function(){
+    if(pool!=undefined){
+        pool.end();
+        pool = undefined;
+    }
+}
+
+module.exports.query = function (sql, datas) {
+    return new Promise((resolve, reject) => {
+        pool.query(sql, datas, (err, results, fields) => {
+            if (err) {
+                reject(err);
+            } else {
+                resolve(results, fields);
+            }
+        });
+    });
+}
+
+module.exports.batchQuery = function (sql, datas) {
+    return new Promise((resolve, reject) => {
+        getTransactionConnection().then(connection => {
+            let results = [];
+            for (var i = 0; i < datas.length; i++) {
+                results.push(query(connection, sql, datas[i]));
+            }
+
+            Promise.all(results).then(() => {
+                connection.commit(err => {
+                    if (err) {
+                        connection.rollback(() => {
+                            reject(err);
+                        });
+                    } else {
+                        connection.release();
+                        resolve(datas.length);
+                    }
+                });
+            }).catch(err => {
+                connection.rollback(() => {
+                    reject(err);
+                })
+            });
+        }).catch(err => {
+            reject(err);
+        });
+    });
+}

+ 14 - 0
source/lib/env.js

@@ -0,0 +1,14 @@
+var store = {
+}
+
+module.exports = store;
+
+module.exports.merge = function (obj) {
+    if (obj == undefined) {
+        return
+    }
+    for (var field in obj) {
+        store[field] = obj[field]
+    }
+}
+

+ 229 - 0
source/lib/image.js

@@ -0,0 +1,229 @@
+//云端图片操作工具
+const EventEmitter = require('events')
+const api = require('./api.js')
+const env = require('./env.js')
+const config = require('./config.js')
+const logger = require('./logger.js')('image.js')
+const upyun = require('./upyun.js')
+const fs = require('fs')
+const path = require('path')
+const readline = require('readline')
+const sizeOf = require('image-size')
+const mustache = require('mustache')
+const mkdirp = require('mkdirp')
+const gm = config.imagemagick != undefined ? require('gm').subClass({
+    imageMagick: true,
+    appPath: config.imagemagick
+}) : require('gm')
+
+class executor extends EventEmitter {
+
+    async readFile(file) {
+        return new Promise((resolve, reject) => {
+            var data = []
+            if (fs.existsSync(file)) {
+                let reader = readline.createInterface({
+                    input: fs.createReadStream(file)
+                })
+                reader.on('line', line => {
+                    data.push(line)
+                })
+                reader.on('close', () => {
+                    resolve(data)
+                })
+            } else {
+                resolve(data)
+            }
+        })
+    }
+
+    async addWatermark(image, file, student) {
+        let fontSize = config.watermark.fontSize
+        let fontFile = config.watermark.fontFile
+        let color = config.watermark.color
+        let imgData = gm(image).font(fontFile, fontSize).fill(color)
+        let size = sizeOf(image)
+        //初始坐标
+        let x = 30
+        let y = 10
+        let height = fontSize + 10
+        //最大宽/高限制
+        let maxX = size.width - x * 2
+        //计算总分
+        let totalScore = (parseFloat(student.objectiveScore) || 0) + (parseFloat(student.subjectiveScore) || 0)
+        //显示总分明细
+        imgData.drawText(x, y += height, '成绩明细')
+        imgData.drawText(x, y += height, '总分=(客观+主观) | ' + totalScore + '=' + student.objectiveScore + '+' + student.subjectiveScore)
+        //显示客观题明细
+        if (student.objectiveScoreDetail && student.objectiveScoreDetail.length > 0) {
+            let lines = []
+            let array = []
+            //前置提示文字的字符数
+            let count = 10
+            lines.push(array)
+            for (let i = 0; i < student.objectiveScoreDetail.length; i++) {
+                let detail = student.objectiveScoreDetail[i]
+                let content = detail.answer + ':' + detail.score
+                //超长后另起一行显示客观题
+                if ((count + content.length) * fontSize * 0.7 > maxX) {
+                    array = []
+                    lines.push(array)
+                    count = 10
+                }
+                array.push(content)
+                count += content.length
+            }
+            //显示所有行的客观题明细
+            for (let l = 0; l < lines.length; l++) {
+                imgData.drawText(x, y += height, '客观题识别结果 | ' + lines[l].join(';'))
+            }
+        }
+        //显示主观题明细
+        if (student.subjectiveScoreDetail && student.subjectiveScoreDetail.length > 0) {
+            let startY = y
+            imgData.drawText(x, y += height, '题号 | 分数')
+            for (let i = 0; i < student.subjectiveScoreDetail.length; i++) {
+                let detail = student.subjectiveScoreDetail[i]
+                //超过最大高度了则另起一列
+                if ((y + height + 15) > size.height) {
+                    y = startY
+                    x += 200
+                    imgData.drawText(x, y += height, '题号 | 分数')
+                }
+                imgData.drawText(x, y += height, detail.mainNumber + '-' + detail.subNumber + ' : ' + detail.score)
+            }
+        }
+        return new Promise((resolve, reject) => {
+            imgData.write(file, error => {
+                if (error) {
+                    reject(error)
+                } else {
+                    resolve()
+                }
+            })
+        })
+    }
+
+    async downloadFile(remoteTemplate, localTemplate, data, dir, client, bucket, watermark) {
+        let remote = mustache.render(remoteTemplate, data)
+        let local = path.join(dir, mustache.render(localTemplate, data))
+        mkdirp.sync(path.dirname(local))
+
+        let imgData
+        if (config.localStore != undefined && config.localStore.length > 0) {
+            let cache = path.join(config.localStore, bucket, remote)
+            if (fs.existsSync(cache)) {
+                imgData = fs.readFileSync(cache)
+            } else {
+                imgData = await client.download(remote)
+            }
+        } else {
+            imgData = await client.download(remote)
+        }
+        //是否需要添加分数水印
+        if (watermark) {
+            await this.addWatermark(imgData, local, data)
+        } else {
+            await fs.writeFileSync(local, imgData)
+        }
+    }
+
+    async downloadSheet(dir, template, watermark) {
+        //从文件中读取已成功下载的准考证号列表
+        let doneFile = path.join(__dirname, '../../logs/sheet.log')
+        let finish = await this.readFile(doneFile)
+        logger.info(finish)
+        let success = {}
+        for (let i = 0; i < finish.length; i++) {
+            success[finish[i]] = true
+        }
+
+        let bucket = env.server.bucketPrefix + '-sheet'
+        let client = upyun(bucket, config.upyun.operator, config.upyun.password)
+        if (env.server.upyunDomain && env.server.upyunDomain != '') {
+            //局域网模式修改图片服务器地址
+            client.setDomain(env.server.upyunDomain)
+        }
+
+        try {
+            let totalCount = await api.countStudents(env.examId, true, undefined)
+            this.emit('total', totalCount)
+
+            let count = 0
+            let pageNumber = 0
+            this.emit('count', 0)
+            while (true) {
+                pageNumber++
+
+                let array = await api.getStudents(env.examId, pageNumber, 1000, true, undefined, watermark === true)
+                if (array == undefined || array.length == 0) {
+                    break
+                }
+                for (let i = 0; i < array.length; i++) {
+                    let promises = []
+                    let student = array[i]
+                    let examNumber = student['examNumber']
+                    if (success[examNumber]) {
+                        //在已完成列表中则跳过,不用重复下载
+                    } else {
+                        student.examId = env.examId
+                        for (let i = 1; i <= student.sheetCount; i++) {
+                            student.index = i
+                            promises.push(this.downloadFile(config.imageUrl.sheet, template, student, dir, client, bucket, (i == 1 && watermark)))
+                        }
+                        //等待所有图片下载完毕
+                        await Promise.all(promises)
+
+                        finish.push(examNumber)
+                        success[examNumber] = true
+                    }
+                    count++
+                    this.emit('count', count)
+
+                    //实时回写到记录文件中去
+                    fs.writeFileSync(doneFile, finish.join('\r\n'))
+                }
+            }
+            this.emit('finish')
+        } catch (error) {
+            logger.error(error)
+            this.emit('error', error)
+        } finally {
+            //回写到记录文件中去
+            fs.writeFileSync(doneFile, finish.join('\r\n'))
+        }
+    }
+
+    async downloadPackage(dir, template) {
+        let bucket = env.server.bucketPrefix + '-package'
+        let client = upyun(bucket, config.upyun.operator, config.upyun.password)
+        if (env.server.upyunDomain && env.server.upyunDomain != '') {
+            client.setDomain(env.server.upyunDomain)
+        }
+
+        try {
+            let array = await api.getPackages(env.examId, true)
+            this.emit('total', array.length)
+            let count = 0
+            this.emit('count', 0)
+            for (let i = 0; i < array.length; i++) {
+                let p = array[i]
+                p.examId = env.examId
+                for (let i = 1; i <= p.picCount; i++) {
+                    p.index = i
+                    await this.downloadFile(config.imageUrl.package, template, p, dir, client, bucket)
+                }
+                count++
+                this.emit('count', count)
+            }
+            this.emit('finish')
+        } catch (err) {
+            logger.error(err)
+            this.emit('error', err)
+        }
+    }
+}
+
+module.exports = function () {
+    return new executor()
+}

+ 22 - 0
source/lib/logger.js

@@ -0,0 +1,22 @@
+const log4js = require('log4js')
+const path = require('path')
+const config = require('./config.js')
+
+log4js.configure({
+    appenders: {
+        everything: {
+            type: 'dateFile',
+            filename: path.join(__dirname, '../../logs/app.log')
+        }
+    },
+    categories: {
+        default: {
+            appenders: ['everything'],
+            level: config.logger.level
+        }
+    }
+})
+
+module.exports = function (module) {
+    return log4js.getLogger(module)
+}

+ 109 - 0
source/lib/sync.js

@@ -0,0 +1,109 @@
+//云端数据同步导本地
+const EventEmitter = require('events')
+const db = require('./db.js')
+const env = require('./env.js')
+const api = require('./api.js')
+const logger = require('./logger.js')('sync.js')
+
+class executor extends EventEmitter {
+
+    async start() {
+        try {
+            db.init()
+
+            let totalCount = await api.countStudents(env.examId)
+            logger.info('student count: ' + totalCount)
+            this.emit('total', totalCount)
+
+            let promises = []
+            let campus = {}
+            let count = 0
+            let pageNumber = 0
+            this.emit('student', 0)
+            while (true) {
+                pageNumber++
+                logger.info('student page=' + pageNumber)
+                let array = await api.getStudents(env.examId, pageNumber, 5000)
+                if (array == undefined || array.length == 0) {
+                    break
+                }
+                let datas = []
+                for (var i = 0; i < array.length; i++) {
+                    var obj = array[i]
+                    datas.push([obj['id'], env.examId, obj['schoolId'], obj['examNumber'], obj['name'], obj['studentCode'], obj['subjectCode'],
+                        obj['subjectName'], obj['campusName'], obj['packageCode'], obj['batchCode'], obj['sheetCount'], obj['sliceCount'], obj['answers'],
+                        obj['upload'] ? 1 : 0, obj['absent'] ? 1 : 0, obj['breach'] ? 1 : 0, obj['manualAbsent'] ? 1 : 0, obj['objectiveScore'],
+                        obj['subjectiveScore'], obj['examSite'], obj['examRoom'], obj['remark']
+                    ])
+
+                    campus[obj['campusCode']] = {
+                        schoolId: obj['schoolId'],
+                        name: obj['campusName']
+                    }
+                }
+                //console.log('get:' + array.length)
+                promises.push(new Promise((resolve, reject) => {
+                    db.batchQuery('replace into eb_exam_student(id, exam_id, school_id\
+                        , exam_number, name, student_code, subject_code, subject_name, campus_name\
+                        , package_code, batch_code, sheet_count, slice_count, answers, is_upload\
+                        , is_absent, is_manual_absent, is_breach, is_exception\
+                        , objective_score, subjective_score, exam_site, exam_room, remark) \
+                        values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0,?,?,?,?,?)',
+                        datas).then(() => {
+                        count += datas.length
+                        this.emit('student', count)
+                        resolve()
+                    }).catch((err) => {
+                        reject(err)
+                    })
+                }))
+            }
+
+            //save campus
+            this.emit('campus', 0)
+            let campusData = []
+            for (let code in campus) {
+                campusData.push([code, campus[code].schoolId, campus[code].name])
+            }
+            logger.info('campus count:' + campusData.length)
+            promises.push(new Promise((resolve, reject) => {
+                db.batchQuery('replace into b_campus(id, school_id, name) values (?,?,?)', campusData).then(() => {
+                    this.emit('campus', campusData.length)
+                    resolve()
+                }).catch(err => {
+                    reject(err)
+                })
+            }))
+
+            //get and save package
+            this.emit('package', 0)
+            let packages = await api.getPackages(env.examId)
+            let packageData = []
+            for (let i = 0; i < packages.length; i++) {
+                let obj = packages[i]
+                packageData.push([env.examId, obj['code'], obj['picCount']])
+            }
+            logger.info('package count:' + packageData.length)
+            promises.push(new Promise((resolve, reject) => {
+                db.batchQuery('replace into eb_exam_package(exam_id, code, pic_count) values (?,?,?)', packageData).then(() => {
+                    this.emit('package', packageData.length)
+                    resolve()
+                }).catch((err) => {
+                    reject(err)
+                })
+            }))
+
+            await Promise.all(promises)
+            this.emit('finish')
+        } catch (err) {
+            this.emit('error', err)
+            logger.error(err)
+        } finally {
+            //console.log(moment().format('YYYY-MM-DD HH:mm:ss'))
+        }
+    }
+}
+
+module.exports = function () {
+    return new executor()
+}

+ 346 - 0
source/lib/upyun.js

@@ -0,0 +1,346 @@
+var util = require("thinkjs-util");
+var request = require("request");
+var fs = require("fs");
+var path = require("path");
+
+module.exports = util.Class(function () {
+    return {
+		/**
+		 * 接口的域名
+		 * @type {String}
+		 */
+        domain: "v0.api.upyun.com",
+		/**
+		 * 初始化
+		 * @param  {[type]} bucketname [description]
+		 * @param  {[type]} username   [description]
+		 * @param  {[type]} password   [description]
+		 * @return {[type]}            [description]
+		 */
+        init: function (bucketname, username, password) {
+            this.bucketname = bucketname;
+            this.username = username;
+            this.password = util.md5(password);
+        },
+		/**
+		 * 设置接口的domain
+		 * @param {[type]} domain [description]
+		 */
+        setDomain: function (domain) {
+            this.domain = domain;
+            return this;
+        },
+		/**
+		 * 签名
+		 * @param  {[type]} method [description]
+		 * @param  {[type]} uri    [description]
+		 * @param  {[type]} date   [description]
+		 * @param  {[type]} length [description]
+		 * @return {[type]}        [description]
+		 */
+        sign: function (method, uri, date, length) {
+            var sign = method + '&' + uri + '&' + date + '&' + length + '&' + this.password;
+            return 'UpYun ' + this.username + ':' + util.md5(sign);
+        },
+		/**
+		 * 获取文件或者文件夹的信息
+		 * @param  {[type]} file [description]
+		 * @return {[type]}      [description]
+		 */
+        getInfo: function (file) {
+            return this.request(file, 'HEAD').then(function (response) {
+                var headers = response.headers;
+                return {
+                    type: headers['x-upyun-file-type'],
+                    size: headers['x-upyun-file-size'],
+                    date: headers['x-upyun-file-date']
+                };
+            });
+        },
+		/**
+		 * 查看空间占用信息
+		 * @return {[type]} [description]
+		 */
+        getUsage: function (path) {
+            path = path || "/";
+            return this.request(path + "?usage").then(function (response) {
+                return parseInt(response.body, 10);
+            });
+        },
+		/**
+		 * 从返回的headers里获取图片的信息
+		 * @param  {[type]} response [description]
+		 * @return {[type]}          [description]
+		 */
+        getPicInfo: function (response) {
+            var headers = response.headers;
+            return {
+                width: headers['x-upyun-width'],
+                height: headers['x-upyun-height'],
+                frames: headers['x-upyun-frames'],
+                type: headers['x-upyun-file-type']
+            };
+        },
+		/**
+		 * 上传文件或者文件夹
+		 * @param  {[type]} savePath [description]
+		 * @param  {[type]} filePath [description]
+		 * @return {[type]}          [description]
+		 */
+        upload: function (savePath, filePath, headers) {
+            var defaultHeaders = {
+                mkdir: true
+            };
+            if (util.isObject(headers)) {
+                defaultHeaders = util.extend(defaultHeaders, headers);
+            } else if (headers) {
+                defaultHeaders["Content-Secret"] = headers;
+            }
+            var self = this;
+            //文件上传
+            if (util.isFile(filePath)) {
+                var stream = fs.readFileSync(filePath);
+                var filename;
+                if (!(/\.\w+$/.test(savePath))) {
+                    filename = filePath.split('/');
+                    filename = filename[filename.length - 1];
+                    savePath += '/' + filename;
+                }
+                return this.request(savePath, 'PUT', stream, defaultHeaders).then(function (response) {
+                    return self.getPicInfo(response);
+                }).then(function (data) {
+                    if (filename) {
+                        data.filename = filename;
+                    }
+                    return data;
+                });
+            } else if (util.isDir(filePath)) { //文件夹上传
+                if (savePath.slice(-1) !== '/') {
+                    savePath += '/';
+                }
+                if (filePath.slice(-1) !== '/') {
+                    filePath += '/';
+                }
+                var promises = [];
+                var files = fs.readdirSync(filePath);
+                files.forEach(function (item) {
+                    var nFilePath = filePath + item;
+                    var state = fs.statSync(nFilePath);
+                    if (state.isFile() || state.isDirectory()) {
+                        var promise = self.upload(savePath + item, nFilePath);
+                        promises.push(promise);
+                    }
+                });
+                if (promises.length) {
+                    return util.Promise.all(promises);
+                } else {
+                    return self.mkDir(savePath);
+                }
+            } else { //普通内容上传
+                return this.request(savePath, 'PUT', filePath, defaultHeaders).then(function (response) {
+                    return self.getPicInfo(response);
+                });
+            }
+        },
+		/**
+		 * 文件或者文件夹下载
+		 * @param  {[type]} path     [description]
+		 * @param  {[type]} savePath [description]
+		 * @return {[type]}          [description]
+		 */
+        download: function (sourcePath, savePath, typeData) {
+            sourcePath = sourcePath || "/";
+            //if (savePath && savePath.slice(-1) !== "/") {
+            //	savePath += "/";
+            //}
+            var self = this;
+            var promise = typeData ? util.getPromise(typeData) : this.getInfo(sourcePath);
+            return promise.then(function (data) {
+                if (data.type === 'folder') {
+                    if (sourcePath.slice(-1) !== "/") {
+                        sourcePath += "/";
+                    }
+                    return self.readDir(sourcePath).then(function (data) {
+                        var promises = [];
+                        data.forEach(function (item) {
+                            var nPath = sourcePath + item.name;
+                            var promise;
+                            //文件夹
+                            if (item.type === 'F') {
+                                promise = self.download(nPath + "/", savePath + item.name + "/", {
+                                    type: 'folder'
+                                });
+                            } else if (item.type) { //文件
+                                promise = self.download(nPath, savePath, {
+                                    type: 'file'
+                                });
+                            }
+                            promises.push(promise);
+                        });
+                        return util.Promise.all(promises);
+                    });
+                } else {
+                    //单个文件
+                    return self.request(sourcePath, 'GET', '', {}, {
+                        encoding: null
+                    }).then(function (response) {
+                        if (!savePath) {
+                            return response.body;
+                        }
+                        var sourceExt = path.extname(sourcePath);
+                        var saveExt = path.extname(savePath);
+                        var fileSavePath = savePath;
+                        if (sourceExt && sourceExt === saveExt) {
+                            util.mkdir(path.dirname(savePath));
+                        } else {
+                            util.mkdir(savePath);
+                            fileSavePath = savePath + path.basename(sourcePath);
+                        }
+                        fs.writeFileSync(fileSavePath, response.body);
+                    });
+                }
+            });
+        },
+		/**
+		 * 删除文件或者文件夹
+		 * @param  {[type]} path  [description]
+		 * @param  {[type]} force [description]
+		 * @return {[type]}       [description]
+		 */
+        rm: function (path, force) {
+            if (!path) {
+                return util.getPromise(new Error("path can't empty"), true);
+            }
+            if (path.slice(-1) !== '/') {
+                path += '/';
+            }
+            var self = this;
+            return this.getInfo(path).then(function (data) {
+                if (data.type === 'folder') {
+                    if (!force) {
+                        return self.request(path, 'DELETE').then(function (response) {
+                            return response.body;
+                        });
+                    }
+                    return self.readDir(path).then(function (data) {
+                        var promises = [];
+                        data.forEach(function (item) {
+                            var nPath = path + item.name;
+                            var promise;
+                            //文件夹
+                            if (item.type === 'F') {
+                                promise = self.rm(nPath + "/", true);
+                            } else if (item.type) { //文件
+                                promise = self.rm(nPath);
+                            }
+                            promises.push(promise);
+                        });
+                        if (promises.length) {
+                            return util.Promise.all(promises);
+                        }
+                    }).then(function () {
+                        return self.rm(path, false);
+                    });
+                } else {
+                    return self.request(path, 'DELETE').then(function (response) {
+                        return response.body;
+                    });
+                }
+            });
+        },
+		/**
+		 * 递归创建目录
+		 * @param  {[type]} path [description]
+		 * @return {[type]}      [description]
+		 */
+        mkDir: function (path) {
+            return this.request(path, 'PUT', '', {
+                mkdir: true,
+                folder: true
+            }).then(function (response) {
+                return response.body;
+            });
+        },
+		/**
+		 * 读取目录下的文件和子目录
+		 * @param  {[type]} dir [description]
+		 * @return {[type]}     [description]
+		 */
+        readDir: function (path, recursive) {
+            path = path || "/";
+            if (path.slice(-1) !== '/') {
+                path += '/';
+            }
+            var self = this;
+            return this.request(path, "GET").then(function (response) {
+                var dirs = response.body.split("\n");
+                var result = [];
+                var promises = [];
+                for (var i = 0; i < dirs.length; i++) {
+                    var dir = dirs[i];
+                    var attrs = dir.split("\t");
+                    dir = {
+                        name: attrs[0],
+                        type: attrs[1],
+                        size: attrs[2],
+                        time: attrs[3]
+                    };
+                    if (recursive && dir.type === 'F') {
+                        var promise = self.readDir(path + dir.name, true).then(function (data) {
+                            dir.children = data;
+                        });
+                        promises.push(promise);
+                    }
+                    result.push(dir);
+                }
+                if (promises.length) {
+                    return util.Promise.all(promises).then(function () {
+                        return result;
+                    });
+                } else {
+                    return result;
+                }
+            });
+        },
+		/**
+		 * 请求数据
+		 * @param  {[type]} uri     [description]
+		 * @param  {[type]} method  [description]
+		 * @param  {[type]} data    [description]
+		 * @param  {[type]} headers [description]
+		 * @param  {[type]} options [description]
+		 * @return {[type]}         [description]
+		 */
+        request: function (uri, method, data, headers, options) {
+            uri = "/" + this.bucketname + uri;
+            method = method || "GET";
+            headers = headers || {};
+            var length = 0;
+            if (data) {
+                length = !util.isBuffer(data) ? Buffer.byteLength(data) : data.length;
+            }
+            var date = (new Date()).toUTCString();
+            var Authorization = this.sign(method, uri, date, length);
+            headers = util.extend(headers, {
+                'Content-Length': length,
+                'Date': date,
+                'Authorization': Authorization
+            });
+            var deferred = util.getDefer();
+            var opts = util.extend({
+                url: "http://" + this.domain + uri,
+                method: method,
+                body: data || "",
+                headers: headers
+            }, options);
+            request(opts, function (error, response, body) {
+                if (error || response.statusCode !== 200) {
+                    deferred.reject(error || "statusCode: " + response.statusCode + "; body: " + body);
+                } else {
+                    deferred.resolve(response);
+                }
+            });
+            return deferred.promise;
+        }
+    };
+});

+ 63 - 0
source/main.js

@@ -0,0 +1,63 @@
+// Modules to control application life and create native browser window
+const { app, BrowserWindow } = require('electron')
+const config = require('./lib/config.js')
+
+// Keep a global reference of the window object, if you don't, the window will
+// be closed automatically when the JavaScript object is garbage collected.
+let mainWindow
+
+function createWindow() {
+    //console.log(app.getAppPath()
+
+    // Create the browser window.
+    mainWindow = new BrowserWindow({
+        width: 1100,
+        height: 720,
+        resizable: false,
+        webPreferences: {
+            nodeIntegration: true,
+            nodeIntegrationInWorker: true
+        }
+    })
+
+    // and load the index.html of the app.
+    mainWindow.loadFile('view/login.html')
+
+    // Open the DevTools.
+    if (config.openDevTools === true) {
+        mainWindow.webContents.openDevTools()
+    }
+
+    // Emitted when the window is closed.
+    mainWindow.on('closed', function () {
+        // Dereference the window object, usually you would store windows
+        // in an array if your app supports multi windows, this is the time
+        // when you should delete the corresponding element.
+        mainWindow = null
+    })
+}
+
+// This method will be called when Electron has finished
+// initialization and is ready to create browser windows.
+// Some APIs can only be used after this event occurs.
+app.on('ready', createWindow)
+
+// Quit when all windows are closed.
+app.on('window-all-closed', function () {
+    // On OS X it is common for applications and their menu bar
+    // to stay active until the user quits explicitly with Cmd + Q
+    //if (process.platform !== 'darwin') {
+    app.quit()
+    //}
+})
+
+app.on('activate', function () {
+    // On OS X it's common to re-create a window in the app when the
+    // dock icon is clicked and there are no other windows open.
+    if (mainWindow === null) {
+        createWindow()
+    }
+})
+
+// In this file you can include the rest of your app's specific main process
+// code. You can also put them in separate files and require them here.

+ 23 - 0
source/package.json

@@ -0,0 +1,23 @@
+{
+    "name": "mc-proxy",
+    "version": "1.0.0",
+    "description": "",
+    "main": "main.js",
+    "scripts": {
+        "test": "echo \"Error: no test specified\" && exit 1"
+    },
+    "author": "",
+    "license": "ISC",
+    "dependencies": {
+        "gm": "^1.23.1",
+        "image-size": "^0.6.3",
+        "jquery": "^3.3.1",
+        "log4js": "^3.0.6",
+        "mkdirp": "^0.5.1",
+        "moment": "^2.22.2",
+        "mustache": "^3.0.0",
+        "mysql": "^2.16.0",
+        "request": "^2.88.0",
+        "thinkjs-util": ">=0.0.1"
+    }
+}

+ 14 - 0
source/test/api-test.js

@@ -0,0 +1,14 @@
+var api = require('../lib/api.js')
+var config = require('../lib/config.js')
+var env = require('../lib/env.js')
+
+async function run() {
+    console.log(await api.countStudents(1, true, false));
+    let array = await api.getStudents(1, 1, 5000, true, false)
+    console.log(array.length);
+}
+
+env.server = config.servers[2]
+env.loginName = 'admin-hk'
+env.password = '123456'
+run();

+ 4 - 0
source/test/config-test.js

@@ -0,0 +1,4 @@
+const config = require('../lib/config')
+
+config.localStore = '/Users/luoshi'
+config.sync()

+ 12 - 0
source/test/db-test.js

@@ -0,0 +1,12 @@
+const db = require('../lib/db.js')
+const config = require('../lib/config.js')
+
+config.db.host = '192.168.10.30'
+db.init()
+db.query('select count(*) as count from campus').then(results => {
+    console.log(results[0]['count'])
+    db.close()
+}).catch(err => {
+    console.log(err || '')
+    db.close()
+})

+ 10 - 0
source/test/readline-test.js

@@ -0,0 +1,10 @@
+const image = require('../lib/image.js')()
+const path = require('path')
+
+let doneFile = path.join(__dirname, '../../logs/sheet.log')
+//console.log(doneFile)
+image.readFile(doneFile).then(data => {
+    console.log(data)
+}).catch(err => {
+    console.log(err)
+})

+ 11 - 0
source/test/upyun-test.js

@@ -0,0 +1,11 @@
+const upyun = require('node-upyun-sdk')
+const env = require('../lib/env.js')
+const config = require('../lib/config.js')
+
+env.server = config.servers[0]
+let client = upyun(env.server.bucket + '-sheet', config.upyun.operator, config.upyun.password)
+client.getInfo('/49-100/36431/11001490-1.jpg').then(result => {
+    console.log(result)
+}).catch(err => {
+    console.log(err)
+})

+ 204 - 0
source/test/watermark-test.js

@@ -0,0 +1,204 @@
+const image = require('../lib/image.js')()
+const fs = require('fs')
+
+image.addWatermark(fs.readFileSync('/Users/luoshi/test/1-1.jpg'), '/Users/luoshi/test/1-2.jpg', {
+    "objectiveScore": "48",
+    "subjectiveScore": "31",
+    "campusCode": "26",
+    "objectiveScoreDetail": [
+        {
+            "mainNumber": 1,
+            "subNumber": 1,
+            "score": 2,
+            "answer": "D"
+        },
+        {
+            "mainNumber": 1,
+            "subNumber": 2,
+            "score": 2,
+            "answer": "C"
+        },
+        {
+            "mainNumber": 1,
+            "subNumber": 3,
+            "score": 2,
+            "answer": "C"
+        },
+        {
+            "mainNumber": 1,
+            "subNumber": 4,
+            "score": 2,
+            "answer": "A"
+        },
+        {
+            "mainNumber": 1,
+            "subNumber": 5,
+            "score": 2,
+            "answer": "C"
+        },
+        {
+            "mainNumber": 1,
+            "subNumber": 6,
+            "score": 2,
+            "answer": "A"
+        },
+        {
+            "mainNumber": 1,
+            "subNumber": 7,
+            "score": 0,
+            "answer": "A"
+        },
+        {
+            "mainNumber": 1,
+            "subNumber": 8,
+            "score": 2,
+            "answer": "C"
+        },
+        {
+            "mainNumber": 1,
+            "subNumber": 9,
+            "score": 2,
+            "answer": "A"
+        },
+        {
+            "mainNumber": 1,
+            "subNumber": 10,
+            "score": 2,
+            "answer": "C"
+        },
+        {
+            "mainNumber": 1,
+            "subNumber": 11,
+            "score": 2,
+            "answer": "A"
+        },
+        {
+            "mainNumber": 1,
+            "subNumber": 12,
+            "score": 2,
+            "answer": "C"
+        },
+        {
+            "mainNumber": 1,
+            "subNumber": 13,
+            "score": 2,
+            "answer": "B"
+        },
+        {
+            "mainNumber": 1,
+            "subNumber": 14,
+            "score": 2,
+            "answer": "D"
+        },
+        {
+            "mainNumber": 1,
+            "subNumber": 15,
+            "score": 2,
+            "answer": "A"
+        },
+        {
+            "mainNumber": 1,
+            "subNumber": 16,
+            "score": 2,
+            "answer": "D"
+        },
+        {
+            "mainNumber": 1,
+            "subNumber": 17,
+            "score": 2,
+            "answer": "C"
+        },
+        {
+            "mainNumber": 1,
+            "subNumber": 18,
+            "score": 2,
+            "answer": "B"
+        },
+        {
+            "mainNumber": 1,
+            "subNumber": 19,
+            "score": 2,
+            "answer": "B"
+        },
+        {
+            "mainNumber": 1,
+            "subNumber": 20,
+            "score": 2,
+            "answer": "A"
+        },
+        {
+            "mainNumber": 2,
+            "subNumber": 1,
+            "score": 2,
+            "answer": "ABC"
+        },
+        {
+            "mainNumber": 2,
+            "subNumber": 2,
+            "score": 2,
+            "answer": "CD"
+        },
+        {
+            "mainNumber": 2,
+            "subNumber": 3,
+            "score": 2,
+            "answer": "BD"
+        },
+        {
+            "mainNumber": 2,
+            "subNumber": 4,
+            "score": 2,
+            "answer": "BD"
+        },
+        {
+            "mainNumber": 2,
+            "subNumber": 5,
+            "score": 2,
+            "answer": "BD"
+        }
+    ],
+    "subjectiveScoreDetail": [
+        {
+            "mainNumber": 3,
+            "subNumber": 1,
+            "score": 0
+        },
+        {
+            "mainNumber": 3,
+            "subNumber": 2,
+            "score": 5
+        },
+        {
+            "mainNumber": 3,
+            "subNumber": 3,
+            "score": 5
+        },
+        {
+            "mainNumber": 3,
+            "subNumber": 4,
+            "score": 5
+        },
+        {
+            "mainNumber": 3,
+            "subNumber": 5,
+            "score": 5
+        },
+        {
+            "mainNumber": 3,
+            "subNumber": 6,
+            "score": 5
+        },
+        {
+            "mainNumber": 4,
+            "subNumber": 1,
+            "score": 4
+        },
+        {
+            "mainNumber": 4,
+            "subNumber": 2,
+            "score": 2
+        }
+    ]
+}).then(console.log('success')).catch(error => {
+    console.log(error)
+})

+ 672 - 0
source/view/css/style.css

@@ -0,0 +1,672 @@
+* {
+	margin: 0;
+	padding: 0;
+	list-style: none;
+	outline: none;
+	-webkit-tap-highlight-color: transparent;
+	-webkit-box-sizing: border-box;
+	box-sizing: border-box;
+	font-family: "微软雅黑";
+}
+html, body {
+	height: 100%;
+	background: #F2F4F9 url(../img/bg.jpg) no-repeat 50% 0;
+	color: #647C92;
+	overflow-x: hidden;
+}
+a {
+	text-decoration: none;
+}
+input {
+	font-family: "微软雅黑";
+	outline: none;
+	vertical-align: top;
+}
+input::placeholder{
+	color: #99A9B7;
+}
+.ellipsis {
+	text-overflow: ellipsis;
+	overflow: hidden;
+	white-space: nowrap;
+}
+.cl:after {
+	content: ".";
+	display: block;
+	height: 0;
+	clear: both;
+	visibility: hidden;
+}
+.cl {
+	zoom: 1;
+}
+.wp {
+	width: 980px;
+	margin: 0 auto;
+}
+/*hd*/
+.hd {
+	height: 80px;
+	line-height: 40px;
+	padding: 20px 0;
+	font-size: 14px;
+	color: #FFF;
+}
+.hd a {
+	font-size: 14px;
+	color: #FFF;
+}
+.hd div.logo {
+	float: left;
+	height: 40px;
+}
+.hd span.y {
+	float: right;
+}
+.hd span.pipe {
+	margin: 0 12px;
+}
+/*cont*/
+.cont {
+	min-height: 500px;
+	background: #FFF;
+	-moz-border-radius: 10px;
+	-webkit-border-radius: 10px;
+	border-radius: 10px;
+}
+/*title*/
+.title {
+	height: 50px;
+	line-height: 50px;
+	overflow: hidden;
+	background: #FBFCFD;
+	border-bottom: 1px solid #F2F4F9;
+	border-radius: 10px 10px 0 0;
+	padding: 0 30px;
+}
+.title h2 {
+	float: left;
+	font-size: 20px;
+	color: #4078B6;
+}
+.title span.y {
+	float: right;
+	font-size: 14px;
+	color: #99A9B7;
+}
+.title span.y a {
+	font-size: 14px;
+	color: #99A9B7;
+	background: url(../img/back.png) no-repeat 0 50%;
+	padding-left: 15px;
+}
+.title b {
+	padding: 0 5px;
+	font-family: "Times New Roman", Times, serif;
+	font-size: 16px;
+	color: #FF8566;
+}
+.title em.id {
+	font-family: "Times New Roman", Times, serif;
+	font-size: 16px;
+	font-weight: 700;
+	font-style: inherit;
+	color: #FFF;
+	background: #FFB366 url(../img/id.png) no-repeat 100% 50%;
+	-moz-border-radius: 4px 0 0 4px;
+	-webkit-border-radius: 4px 0 0 4px;
+	border-radius: 4px 0 0 4px;
+	padding: 4px 20px 4px 10px;
+}
+.title span.name,.title span.pipe,.title span.time {
+	font-size: 14px;
+	color: #647992;
+	padding-left: 15px;
+}
+.title_grey h2 {
+	color: #99A9B7;
+}
+
+/*.tablelistlist*/
+.tablelist {
+	font-size: 14px;
+	text-align: center;
+}
+.tablelist th {
+	background: #FBFCFD;
+	border-bottom: 1px solid #F2F4F9;
+	padding: 6px 0;
+	color: #455363;
+}
+.tablelist th:nth-child(4) {
+	padding: 6px 15px;
+}
+.tablelist td {
+	border-bottom: 1px solid #F2F4F9;
+	padding: 5px 0;
+}
+
+.tablelist tr:nth-child(even) td {
+	background: #FBFCFD;
+}
+.tablelist td a {
+	display: inline-block;
+	width: 20px;
+	height: 20px;
+	overflow: hidden;
+	text-indent: -999em;
+	background: url(../img/enter.png) no-repeat 0 0;
+	margin-top: 4px;
+}
+.tablelist td a:hover {
+	background-position: 0 -51px;
+}
+/*page*/
+.page {
+	height: 60px;
+	padding: 15px 30px;
+    text-align: right;
+}
+.page div {
+	display: inline;
+}
+.page span,.page a {
+    display: inline-block;
+	width: 30px;
+	height: 30px;
+	line-height: 30px;
+	border: 1px solid #E8EBF4;
+	border-radius: 30px;
+	font-size: 12px;
+	color: #667D92;
+	text-align: center;
+}
+.page a {
+    margin: 0 1px;
+}
+.page span {
+    width: 60px;
+    cursor: pointer;
+}
+.page a.on {
+	background: #73CBFB;
+	border-color: #73CBFB;
+	color: #FFF;
+}
+.page a:hover {
+	color: #73CBFB;
+	border-color: #73CBFB;
+}
+/*ft*/
+.ft {
+	text-align: center;
+	font-size: 12px;
+	color: #99A9B7;
+	padding-top: 20px;
+}
+/*list*/
+.list {
+	padding: 70px 40px 70px 40px;
+	text-align: center;
+}
+.list h3 {
+	height: 70px;
+	line-height: 70px;
+	overflow: hidden;
+	font-size: 24px;
+	color: #3F78B6;
+}
+.list ul {
+	padding: 30px 0;
+}
+.list ul li {
+	display: inline;
+}
+.list li a {
+	display: inline-block;
+	width: 120px;
+	height: 120px;
+	overflow: hidden;
+	background: #5184E1;
+	-moz-border-radius: 10px;
+	-webkit-border-radius: 10px;
+	border-radius: 10px;
+	padding: 24px 0 0 0;
+	margin: 12px;
+	text-align: center;
+	font-size: 17px;
+	color: #FFF;
+}
+.list li a:hover {
+	background: #6C97E6;
+}
+.list li a span {
+	display: block;
+	height: 51px;
+	overflow: hidden;
+	background: url(../img/icon3.png) no-repeat 50% 0;
+} 
+.list li.l1 a {
+	background: #FF8566;
+}
+.list li.l1 a:hover {
+	background: #FF977D;
+}
+.list li.l1 a span {
+	background: url(../img/icon1.png) no-repeat 50% 0;
+}
+.list li.l2 a {
+	background: #73CBFB;
+}
+.list li.l2 a:hover {
+	background: #8BD5FC;
+}
+.list li.l2 a span {
+	background: url(../img/icon2.png) no-repeat 50% 0;
+}
+/*data*/
+.data {
+	padding: 80px 40px;
+	text-align: center;
+}
+.data p {
+	line-height: 50px;
+	font-size: 16px;
+	color: #647992;
+}
+.data p b {
+	font-family: "Times New Roman", Times, serif;
+	font-size: 18px;
+	color: #3F78B6;
+}
+.data .btn {
+	padding: 50px 0;
+}
+.data .btn a {
+	display: inline-block;
+	width: 220px;
+	height: 50px;
+	line-height: 50px;
+	background: #73CBFB;
+	-moz-border-radius: 30px;
+	-webkit-border-radius: 30px;
+	border-radius: 30px;
+	font-size: 17px;
+	color: #FFF;
+}
+.data .btn a:hover {
+	background: #8BD5FC;
+}
+.data .btn a span {
+	display: inline-block;
+	background: url(../img/data_btn.png) no-repeat 0 50%;
+	padding-left: 28px;
+}
+/*progress*/
+.progress-box {
+	padding: 80px 150px;
+	text-align: center;
+}
+.progress-box h3 {
+	height: 50px;
+	line-height: 50px;
+	overflow: hidden;
+	font-size: 24px;
+	font-weight: 400;
+	color: #3F78B6;
+}
+.progress-box p {
+	height: 40px;
+	line-height: 40px;
+	text-align: right;
+	font-size: 14px;
+	color: #99A9B7;
+}
+.progress-box p b {
+	font-family: "Times New Roman", Times, serif;
+	color: #647A92;
+}
+.progress {
+	padding-top: 80px;
+}
+.progress-outer {
+	position: relative;
+	display: inline-block;
+	width: 100%;
+	height: 16px;
+	background: #F2F4F9;
+	border-radius: 10px;
+}
+.progress-outer .progress-inner {
+	position: absolute;
+	height: 16px;
+	background: #73CBFB;
+	border-radius: 10px 0 0 10px;
+}
+.progress-outer .progress-inner .progress-text {
+	position: absolute;
+	top: -30px;
+	right: 0;
+	font-family: "Times New Roman", Times, serif;
+	font-size: 16px;
+	font-weight: 700;
+	color: #3F78B6;
+}
+/*picture*/
+.picture {
+	padding: 80px;
+}
+.picture th {
+	width: 120px;
+	font-size: 16px;
+	font-weight: 400;
+	color: #647A92;
+	text-align: right;
+}
+.picture td {
+	padding: 10px 40px;
+}
+.picture td input {
+	float: left;
+}
+/* checkbox // radio */
+input[type="radio"] {
+	appearance: none;
+	-webkit-appearance: none;
+	outline: none;
+	display: none
+}
+input[type="radio"] + span {
+	float: left;
+	width: 15px;
+	height: 15px;
+	display: inline-block;
+	background: url(../img/radio.png) no-repeat 0 0;
+}
+input[type="radio"]:checked + span {
+	background-position: -16px 0;
+}
+.input-radio label {
+	display: inline-block;
+	padding-right: 100px;
+	cursor: pointer;
+}
+.input-radio em {
+	float: left;
+	height: 15px;
+	line-height: 15px;
+	padding-left: 10px;
+	font-style: inherit;
+	font-size: 16px;
+	color: #455363;
+}
+.input-radio em.checked {
+	color: #3F78B6;
+}
+/* select // textarea */
+select, input[type="text"], input[type="password"] {
+	background-color: #fff;
+	border: 1px solid #D7DCEC;
+	-webkit-box-shadow: inset 0 3px 1px rgba(232,235,244,.2);
+	-moz-box-shadow: inset 0 3px 1px rgba(232,235,244,.2);
+	box-shadow: inset 0 3px 1px rgba(232,235,244,.2);
+	-webkit-transition: border linear .2s, box-shadow linear .2s;
+	-moz-transition: border linear .2s, box-shadow linear .2s;
+	-o-transition: border linear .2s, box-shadow linear .2s;
+	transition: border linear .2s, box-shadow linear .2s;
+}
+select, input[type="text"], input[type="password"]{
+	display: inline-block;
+	height: 36px;
+	padding: 0 10px;
+	font-size: 14px;
+	line-height: 36px;
+	color: #455363;
+	vertical-align: middle;
+	-webkit-border-radius: 6px;
+	-moz-border-radius: 6px;
+	border-radius: 6px;
+}
+select, input[type="text"]:focus, input[type="password"]:focus {
+	border-color: rgba(69,152,255,0.8);
+	outline: 0;
+	-webkit-box-shadow: inset 0 3px 1px rgba(232,235,244,.2), 0 0 8px rgba(69,152,255,.6);
+	-moz-box-shadow: inset 0 3px 1px rgba(232,235,244,.2), 0 0 8px rgba(69,152,255,.6);
+	box-shadow: inset 0 3px 1px rgba(232,235,244,.2), 0 0 8px rgba(69,152,255,.6);
+}
+/*filebtn*/
+.filebtn {
+	display: inline-block;
+	text-align: center;
+	width: 60px;
+	height: 36px;
+	line-height: 36px;
+	overflow: hidden;
+	background: #E8EBF4;
+	-webkit-border-radius: 6px;
+	-moz-border-radius: 6px;
+	border-radius: 6px;
+	border: 0;
+	font-size: 14px;
+	color: #99A9B7;
+	margin-left: 10px;
+}
+.filebtn:hover {
+	background: #E4E9F3;
+}
+/*error-tetx*/
+.error-tetx {
+	height: 20px;
+	line-height: 20px;
+	overflow: hidden;
+	font-size: 13px;
+	color: #FF7272;
+	background: url(../img/error_small.png) no-repeat 0 50%;
+	padding-left: 25px;
+}
+/*start-btn*/
+.start-btn {
+	display: inline-block;
+	width: 220px;
+	height: 50px;
+	line-height: 50px;
+	overflow: hidden;
+	background: #5184E1;
+	-moz-border-radius: 30px;
+	-webkit-border-radius: 30px;
+	border-radius: 30px;
+	text-align: center;
+	font-size: 17px;
+	color: #FFF;
+	margin-top: 30px;
+}
+.start-btn:hover {
+	background: #6C97E6;
+}
+.start-btn span {
+	display: inline-block;
+	height: 100%;
+	background: url(../img/pic_btn.png) no-repeat 0 50%;
+	padding-left: 25px;
+}
+/*login*/
+.login-flex {
+	width: 100%;
+	height: 100%;
+	display: -webkit-box;
+	display: -webkit-flex;
+	display: -ms-flexbox;
+	display: flex;
+	
+	-webkit-box-orient: vertical;
+	-webkit-box-direction: normal;
+	-webkit-flex-direction: column;
+	-ms-flex-direction: column;
+	flex-direction: column;
+		  
+	-webkit-box-pack: center;
+	-ms-flex-pack: center;
+	-webkit-justify-content: center;
+	justify-content: center;
+		  
+	-webkit-box-align: center;
+	-ms-flex-align: center;
+	-webkit-align-items: center;
+	align-items: center;
+}
+.login {
+	display: -webkit-box;
+	display: -webkit-flex;
+	display: -ms-flexbox;
+	display: flex;
+	
+	-webkit-box-orient: vertical;
+	-webkit-box-direction: normal;
+	-webkit-flex-direction: column;
+	-ms-flex-direction: column;
+	flex-direction: column;
+		  
+	-webkit-box-pack: center;
+	-ms-flex-pack: center;
+	-webkit-justify-content: center;
+	justify-content: center;
+		  
+	-webkit-box-align: center;
+	-ms-flex-align: center;
+	-webkit-align-items: center;
+	align-items: center;
+	
+	background: #FFF;
+	-moz-border-radius: 10px;
+	-webkit-border-radius: 10px;
+	border-radius: 10px;
+	padding: 50px 80px;
+}
+.login .logo {
+	padding-bottom: 20px;
+}
+.login div {
+	position: relative;
+	padding: 10px 0;
+}
+.login div span {
+	position: absolute;
+	top: 22px;
+	right: -35px;
+	display: block;
+	width: 16px;
+	height: 16px;
+	overflow: hidden;
+}
+.login div span.success {
+	background: url(../img/success_small.png) no-repeat 0 0;
+}
+.login div span.error {
+	background: url(../img/error_small.png) no-repeat 0 0;
+}
+.login input,.login select {
+	width: 320px;
+	height: 40px;
+}
+.login select {
+	background: url(../img/select.png) no-repeat scroll right center transparent;
+ 	-moz-appearance:none;
+ 	-webkit-appearance:none;
+	appearance:none;
+	padding-right: 25px;
+	color: #647992;
+}
+.login select option {
+	color: #647992!important;
+}
+.login select::-ms-expand { display: none; }
+.login a {
+	display: block;
+	height: 50px;
+	line-height: 50px;
+	overflow: hidden;
+	background: #5184E1;
+	border-radius: 30px;
+	font-size: 20px;
+	font-weight: 700;
+	text-align: center;
+	color: #FFF;
+	margin-top: 30px;
+}
+.login a:hover {
+	background: #6C97E6;
+}
+/*xcConfirm*/
+.xcConfirm .xc_layer {
+    position: fixed;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    background: rgba(69,84,99,.65);
+    z-index: 2147000000;
+}
+.xcConfirm .popbox {
+    position: fixed;
+    left: calc((100% - 440px) / 2);
+    top: calc((100% - 200px) / 2);
+    background-color: #FFF;
+    z-index: 2147000001;
+    width: 440px;
+    height: 200px;
+    -moz-border-radius: 10px;
+    -webkit-border-radius: 10px;
+    border-radius: 10px;
+	font-size: 16px;
+    color: #647C92;
+	padding: 50px;
+}
+.xcConfirm .popbox .close {
+	position: absolute;
+	right: 20px;
+	top: 20px;
+	display: block;
+	width: 12px;
+	height: 12px;
+	overflow: hidden;
+	background: url(../img/close.png) no-repeat 0 0;
+}
+.txtbox {
+	width: 100%;
+	height: 100%;
+	display: -webkit-box;
+	display: -webkit-flex;
+	display: -ms-flexbox;
+	display: flex;
+	
+	-webkit-box-orient: horizontal;
+	-webkit-box-direction: normal;
+	-webkit-flex-direction: row;
+	-ms-flex-direction: row;
+	flex-direction: row;
+		  
+	-webkit-box-pack: center;
+	-ms-flex-pack: center;
+	-webkit-justify-content: center;
+	justify-content: center;
+		  
+	-webkit-box-align: center;
+	-ms-flex-align: center;
+	-webkit-align-items: center;
+	align-items: center;
+}
+.txtbox .icon {
+	flex: 0 0 55px; 
+	max-width: 55px; 
+	min-width: 55px; 
+	width: 55px;
+	width: 55px;
+	height: 34px;
+	overflow: hidden;
+}
+.txtbox .error {
+	background: url(../img/error.png) no-repeat 0 0;
+}
+.txtbox .success {
+	background: url(../img/success.png) no-repeat 0 0;
+}

+ 96 - 0
source/view/image-download.html

@@ -0,0 +1,96 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+    <meta charset="UTF-8">
+    <title>云阅卷本地代理工具</title>
+    <meta name="viewport" content="width=device-width,initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no" />
+    <link rel="stylesheet" href="css/style.css">
+</head>
+
+<body>
+    <div class="wp">
+        <div class="hd">
+            <div class="logo"><a href="##"><img src="img/logo.png" /></a></div>
+            <span class="y"> 欢迎您,<span id="user-name"></span><span class="pipe">|</span><a href="##">退出</a></span>
+        </div>
+        <div class="cont">
+            <div class="title title_grey cl">
+                <h2>图片下载中 …</h2>
+            </div>
+            <div class="progress-box">
+                <h3>正在下载图片,请耐心等候 ~</h3>
+                <div class="progress">
+                    <div class="progress-outer">
+                        <div id="progress" class="progress-inner" style="width: 0%;"><span class="progress-text"></span></div>
+                    </div>
+                </div>
+                <p>已下载图片:<b id="finish-count"></b> / 全部图片:<b id="total-count"></b></p>
+            </div>
+        </div>
+        <div class="xcConfirm" id="popup" style="display: none">
+            <div class="xc_layer"></div>
+            <div class="popbox">
+                <a href="##" id="popup-close"><span class="close"></span></a>
+                <div class="txtbox">
+                    <div id="popup-error" class="icon error" style="display: none"></div>
+                    <div id="popup-success" class="icon success" style="display: none"></div>
+                    <div id="popup-text" class="text"></div>
+                </div>
+            </div>
+        </div>
+        <div class="ft">Copyright © 2011-2020 www.qmth.com.cn, All Rights Reserved</div>
+        <div style="display:none">
+            <canvas id="canvas"></canvas>
+        </div>
+    </div>
+
+    <script>
+        const $ = require('jquery')
+        const env = require('../lib/env.js')
+        const imageUtil = require('../lib/image.js')()
+
+        $(document).ready(() => {
+            env.merge(JSON.parse(window.localStorage.getItem('env')))
+            $('#user-name').html(env.user.userName)
+
+            let config = JSON.parse(window.localStorage.getItem('image-config'))
+            let totalCount;
+            imageUtil.on('total', (count) => {
+                totalCount = count
+                $('#total-count').html(count)
+            })
+            imageUtil.on('count', (count) => {
+                $('#finish-count').html(count)
+                let rate = parseInt(count * 100 / totalCount)
+                $('#progress').css('width', rate + '%')
+                $('.progress-text').html(rate + '%')
+            })
+            imageUtil.on('finish', () => {
+                $('#popup-success').show()
+                $('#popup-text').html('图片下载完成')
+                $('#popup-close').click(() => {
+                    $('#popup').hide()
+                    window.location.href = 'image.html'
+                })
+                $('#popup').show()
+            })
+            imageUtil.on('error', (err) => {
+                $('#popup-error').show()
+                $('#popup-text').html('图片下载出错\n' + (err || ''))
+                $('#popup-close').click(() => {
+                    $('#popup').hide()
+                    window.location.href = 'image.html'
+                })
+                $('#popup').show()
+            })
+            if (config.type == '1') {
+                imageUtil.downloadSheet(config.dir, config.template, config.watermark)
+            } else {
+                imageUtil.downloadPackage(config.dir, config.template)
+            }
+        })
+    </script>
+</body>
+
+</html>

+ 137 - 0
source/view/image.html

@@ -0,0 +1,137 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+    <meta charset="UTF-8">
+    <title>云阅卷本地代理工具</title>
+    <meta name="viewport" content="width=device-width,initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no" />
+    <link rel="stylesheet" href="css/style.css">
+</head>
+
+<body>
+    <div class="wp">
+        <div class="hd">
+            <div class="logo"><a href="index.html"><img src="img/logo.png" /></a></div>
+            <span class="y"> 欢迎您,<span id="user-name"></span><span class="pipe">|</span><a href="login.html">退出</a></span>
+        </div>
+        <div class="cont">
+            <div class="title cl">
+                <span class="y"><a href="index.html">返回考试主页</a></span>
+                <h2>图片下载</h2>
+            </div>
+            <div class="picture cl">
+                <table cellpadding="0" cellspacing="0" width="100%">
+                    <tr>
+                        <th>下载内容:</th>
+                        <td>
+                            <div class="input-radio">
+                                <label><input type="radio" name="type" value="1"><span></span><em>考生原图</em></label>
+                                <label><input type="radio" name="type" value="2"><span></span><em>签到表图片</em></label>
+                            </div>
+                        </td>
+                    </tr>
+                    <tr>
+                        <th>本地保存地址:</th>
+                        <td>
+                            <input id="path-text" type="text" style="width: 400px" class="filetext" />
+                            <a href="##" id="path-select" class="filebtn">选择</a>
+                        </td>
+                    </tr>
+                    <tr>
+                        <th>图片转存规则:</th>
+                        <td><input id="template-input" type="text" style="width: 600px" /></td>
+                    </tr>
+                    <tr id="watermark-select" style="display: none">
+                        <th>添加分数水印:</th>
+                        <td>
+                            <div class="input-radio">
+                                <label><input type="radio" name="watermark" value="1"><span></span><em>是</em></label>
+                                <label><input type="radio" name="watermark" value="2"><span></span><em>否</em></label>
+                            </div>
+                        </td>
+                    </tr>
+                    <tr id="message-tr" style="display: none">
+                        <th></th>
+                        <td>
+                            <p class="error-tetx" id="message-text"></p>
+                        </td>
+                    </tr>
+                    <tr>
+                        <th></th>
+                        <td><a id="run-button" href="##" class="start-btn"><span>开始图片下载</span></a></td>
+                    </tr>
+                </table>
+            </div>
+        </div>
+        <div class="ft">Copyright © 2011-2020 www.qmth.com.cn, All Rights Reserved</div>
+    </div>
+
+    <script>
+        const $ = require('jquery')
+        const env = require('../lib/env.js')
+        const config = require('../lib/config.js')
+        const { dialog } = require('electron').remote
+
+        $(document).ready(() => {
+            env.merge(JSON.parse(window.localStorage.getItem('env')))
+            $('#user-name').html(env.user.userName)
+
+            $('input:radio[name="type"]').change(() => {
+                let type = $('input:radio:checked').val()
+                if (type == '1') {
+                    $('#message-text').html('范例: ' + config.imageUrl.sheet)
+                    $('#message-tr').show()
+                    $('#watermark-select').show()
+                } else if (type == '2') {
+                    $('#message-text').html('范例: ' + config.imageUrl.package)
+                    $('#message-tr').show()
+                    $('#watermark-select').hide()
+                } else {
+                    $('#message-tr').hide()
+                    $('#watermark-select').hide()
+                }
+            })
+
+            $('#path-select').click(() => {
+                dialog.showOpenDialog({
+                    title: '请选择保存目录',
+                    properties: ['openDirectory']
+                }, filePaths => {
+                    $('#path-text').val(filePaths[0])
+                })
+            })
+
+            $('#run-button').click(() => {
+                let type = $('input:radio[name="type"]:checked').val()
+                let watermark = $('input:radio[name="watermark"]:checked').val()
+                let template = $('#template-input').val()
+                let dir = $('#path-text').val()
+                if (type == undefined || type == '') {
+                    alert('请选择图片类型')
+                    return false
+                }
+                if (template == undefined || template == '') {
+                    alert('请填写图片转存规则')
+                    return false
+                }
+                if (dir == undefined || dir == '') {
+                    alert('请选择图片转存目录')
+                    return false
+                }
+                if (type == '1' && (watermark == undefined || watermark == '')) {
+                    alert('请选择是否添加分数水印')
+                    return false
+                }
+                window.localStorage.setItem('image-config', JSON.stringify({
+                    type: type,
+                    template: template.trim(),
+                    dir: dir.trim(),
+                    watermark: watermark == '1'
+                }))
+                window.location.href = 'image-download.html'
+            })
+        })
+    </script>
+</body>
+
+</html>

二进制
source/view/img/back.png


二进制
source/view/img/bg.jpg


二进制
source/view/img/close.png


二进制
source/view/img/data_btn.png


二进制
source/view/img/enter.png


二进制
source/view/img/error.png


二进制
source/view/img/error_small.png


二进制
source/view/img/icon1.png


二进制
source/view/img/icon2.png


二进制
source/view/img/icon3.png


二进制
source/view/img/id.png


二进制
source/view/img/logo.png


二进制
source/view/img/logo_blue.png


二进制
source/view/img/pic_btn.png


二进制
source/view/img/radio.png


二进制
source/view/img/select.png


二进制
source/view/img/success.png


二进制
source/view/img/success_small.png


+ 48 - 0
source/view/index.html

@@ -0,0 +1,48 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+    <meta charset="UTF-8">
+    <title>云阅卷本地代理工具</title>
+    <meta name="viewport" content="width=device-width,initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no" />
+    <link rel="stylesheet" href="css/style.css">
+</head>
+
+<body>
+    <div class="wp">
+        <div class="hd">
+            <div class="logo"><a href="index.html"><img src="img/logo.png" /></a></div>
+            <span class="y"> 欢迎您,<span id="user-name"></span><span class="pipe">|</span><a href="login.html">退出</a></span>
+        </div>
+        <div class="cont">
+            <div class="title cl" id="exam-title">
+                <em class="id"></em><span class="name"></span><span class="pipe">|</span>
+                <span class="time"></span>
+            </div>
+            <div class="list">
+                <h3>功能选择</h3>
+                <ul class="cl">
+                    <li class="l1"><a href="list.html"><span></span>考试切换</a></li>
+                    <li class="l2"><a href="sync.html"><span></span>数据同步</a></li>
+                    <li class="l3"><a href="image.html"><span></span>图片下载</a></li>
+                </ul>
+            </div>
+        </div>
+        <div class="ft">Copyright © 2011-2020 www.qmth.com.cn, All Rights Reserved</div>
+    </div>
+
+    <script>
+        const $ = require('jquery')
+        const env = require('../lib/env.js')
+
+        $(document).ready(() => {
+            env.merge(JSON.parse(window.localStorage.getItem('env')))
+            $('#user-name').html(env.user.userName)
+            $('#exam-title').find('.id').html(env.exam.id)
+            $('#exam-title').find('.name').html(env.exam.name)
+            $('#exam-title').find('.time').html(env.exam.examTime)
+        })
+    </script>
+</body>
+
+</html>

+ 132 - 0
source/view/list.html

@@ -0,0 +1,132 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+    <meta charset="UTF-8">
+    <title>云阅卷本地代理工具</title>
+    <meta name="viewport" content="width=device-width,initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no" />
+    <link rel="stylesheet" href="css/style.css">
+</head>
+
+<body>
+    <div class="wp">
+        <div class="hd">
+            <div class="logo"><img src="img/logo.png" /></div>
+            <span class="y"> 欢迎您,<span id="user-name"></span><span class="pipe">|</span><a href="login.html">退出</a></span>
+        </div>
+        <div class="cont">
+            <div class="title cl" style="background:#FFF;">
+                <span class="y">共有<b id="total-count">0</b>场考试,请选择</span>
+                <h2>考试列表</h2>
+            </div>
+            <table cellpadding="0" cellspacing="0" width="100%" class="tablelist">
+                <thead>
+                    <th>ID</th>
+                    <th>考试名称</th>
+                    <th>创建时间</th>
+                    <th>操作</th>
+                </thead>
+                <tbody id="exam-list">
+                </tbody>
+            </table>
+            <div class="page">
+                <span class="back" id="previous-button">上页</span>
+                <div id="page-list">
+                </div>
+                <span class="next" id="next-button">下页</span>
+            </div>
+        </div>
+        <div class="ft">Copyright © 2011-2020 www.qmth.com.cn, All Rights Reserved</div>
+    </div>
+
+    <script>
+        const $ = require('jquery')
+        const env = require('../lib/env.js')
+        const api = require('../lib/api.js')
+        const mustache = require('mustache')
+        const examTemplate = '<tr><td>{{id}}</td>\
+            <td>{{name}}</td><td>{{examTime}}</td>\
+            <td><a href="##" data-index="{{index}}">进入考试</a></td></tr>'
+        const pageTemplate = '<a href="##" data-number="{{number}}">{{number}}</a>'
+        const pageSize = 10
+
+        let examList = []
+        let currentPage
+        let pageCount
+        $(document).ready(() => {
+            env.merge(JSON.parse(window.localStorage.getItem('env')))
+            $('#user-name').html(env.user.userName)
+
+            api.getExams(1, 1000).then(list => {
+                examList = list
+                initPage()
+            }).catch(err => {
+                alert('获取考试列表失败\n' + (err || ''))
+            })
+        })
+
+        function initPage() {
+            let totalCount = examList.length
+            if (totalCount > 0) {
+                pageCount = totalCount % pageSize == 0 ? parseInt(totalCount / pageSize) : parseInt(totalCount / pageSize) + 1
+            } else {
+                pageCount = 0
+            }
+            $('#total-count').html(totalCount)
+
+            $('#page-list').empty()
+            for (let i = 1; i <= pageCount; i++) {
+                let dom = $(mustache.render(pageTemplate, { number: i }))
+                $('#page-list').append(dom)
+
+                dom.click(function () {
+                    changePage(parseInt($(this).attr('data-number')))
+                })
+            }
+            $('#previous-button').click(() => {
+                if (currentPage > 1) {
+                    changePage(currentPage - 1)
+                }
+            })
+            $('#next-button').click(() => {
+                if (currentPage < pageCount) {
+                    changePage(currentPage + 1)
+                }
+            })
+            if (pageCount > 0) {
+                changePage(1)
+            }
+        }
+
+        function render() {
+            $('#page-list').find('a').removeClass('on')
+            $('#page-list').find('a[data-number="' + currentPage + '"]').addClass('on')
+
+            $('#exam-list').empty()
+            let start = (currentPage - 1) * pageSize
+            let end = start + pageSize
+            for (let i = start; i < end && i < examList.length; i++) {
+                examList[i].index = i
+                let dom = $(mustache.render(examTemplate, examList[i]))
+                $('#exam-list').append(dom)
+
+                dom.find('a').click(selectExam)
+            }
+        }
+
+        function selectExam() {
+            let exam = examList[parseInt($(this).attr('data-index'))]
+            env.exam = exam
+            env.examId = exam.id
+            window.localStorage.setItem('env', JSON.stringify(env))
+            window.location.href = 'index.html'
+        }
+
+        function changePage(pageNumber) {
+            currentPage = pageNumber
+            render()
+        }
+    </script>
+</body>
+
+</html>

+ 90 - 0
source/view/login.html

@@ -0,0 +1,90 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+    <meta charset="UTF-8">
+    <title>云阅卷本地代理工具</title>
+    <meta name="viewport" content="width=device-width,initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no" />
+    <link rel="stylesheet" href="css/style.css">
+    <style type="text/css">
+        body {
+            background: #F2F4F9;
+        }
+    </style>
+</head>
+
+<body>
+    <div class="login-flex">
+        <div class="login">
+            <div class="logo"><img src="img/logo_blue.png" /></div>
+            <form>
+                <div>
+                    <select id="server-select">
+                        <option value="">请选择云端环境</option>
+                    </select>
+                </div>
+                <div>
+                    <input id="loginName-input" type="text" placeholder="请输入账号">
+                </div>
+                <div>
+                    <input id="password-input" type="password" placeholder="请输入密码">
+                </div>
+                <div><a href="##" id="login-button">登录</a></div>
+            </form>
+        </div>
+        <div class="ft">Copyright © 2011-2020 www.qmth.com.cn, All Rights Reserved</div>
+    </div>
+    <script>
+        const config = require('../lib/config.js')
+        const env = require('../lib/env.js')
+        const api = require('../lib/api.js')
+        const $ = require('jquery')
+
+        $(document).ready(() => {
+            window.localStorage.clear()
+            for (let i = 0; i < config.servers.length; i++) {
+                let server = config.servers[i];
+                $('<option value="' + i + '">' + server.name + '</option>').appendTo($('#server-select'))
+            }
+        })
+
+        document.onkeydown = function (event) {
+            var e = event || window.event;
+            if (e && e.keyCode == 13) { //回车键的键值为13
+                $('#login-button').click() //调用登录按钮的登录事件
+            }
+        }
+
+        $('#login-button').click(() => {
+            let index = $('#server-select').val()
+            if (index != '') {
+                env.server = config.servers[parseInt(index)]
+            } else {
+                env.server = undefined
+            }
+            env.loginName = $('#loginName-input').val()
+            env.password = $('#password-input').val()
+            if (env.server == undefined) {
+                alert('请选择云端环境')
+                return
+            }
+            if (env.loginName == '') {
+                alert('请输入账号')
+                return
+            }
+            if (env.password == '') {
+                alert('请输入密码')
+                return
+            }
+            api.login().then(user => {
+                env.user = user
+                window.localStorage.setItem('env', JSON.stringify(env))
+                window.location.href = 'list.html'
+            }).catch(err => {
+                alert('登陆失败,用户名或密码错误')
+            })
+        })
+    </script>
+</body>
+
+</html>

+ 97 - 0
source/view/sync-run.html

@@ -0,0 +1,97 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+    <meta charset="UTF-8">
+    <title>云阅卷本地代理工具</title>
+    <meta name="viewport" content="width=device-width,initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no" />
+    <link rel="stylesheet" href="css/style.css">
+</head>
+
+<body>
+    <div class="wp">
+        <div class="hd">
+            <div class="logo"><a href="##"><img src="img/logo.png" /></a></div>
+            <span class="y"> 欢迎您,<span id="user-name"></span><span class="pipe">|</span><a href="##">退出</a></span>
+        </div>
+        <div class="cont">
+            <div class="title title_grey cl">
+                <h2>数据同步中 …</h2>
+            </div>
+            <div class="progress-box">
+                <h3 id="message">正在下载考生...</h3>
+                <div class="progress">
+                    <div class="progress-outer">
+                        <div id="progress" class="progress-inner" style="width: 0%;"><span class="progress-text"></span></div>
+                    </div>
+                </div>
+                <p>已数据同步考生:<b id="finish-count"></b> / 全部考生:<b id="total-count"></b></p>
+            </div>
+        </div>
+        <div class="ft">Copyright © 2011-2020 www.qmth.com.cn, All Rights Reserved</div>
+    </div>
+    <div class="xcConfirm" id="popup" style="display: none">
+        <div class="xc_layer"></div>
+        <div class="popbox">
+            <a href="##" id="popup-close"><span class="close"></span></a>
+            <div class="txtbox">
+                <div id="popup-error" class="icon error" style="display: none"></div>
+                <div id="popup-success" class="icon success" style="display: none"></div>
+                <div id="popup-text" class="text"></div>
+            </div>
+        </div>
+    </div>
+
+    <script>
+        const $ = require('jquery')
+        const env = require('../lib/env.js')
+        const config = require('../lib/config.js')
+        const sync = require('../lib/sync.js')()
+
+        $(document).ready(() => {
+            env.merge(JSON.parse(window.localStorage.getItem('env')))
+            $('#user-name').html(env.user.userName)
+
+            var totalCount = 0;
+            sync.on('total', (count) => {
+                totalCount = count
+                $('#total-count').html(count)
+            })
+            sync.on('student', (count) => {
+                $('#finish-count').html(count)
+                let rate = parseInt(count * 100 / totalCount)
+                $('#progress').css('width', rate + '%')
+                $('.progress-text').html(rate + '%')
+            })
+            sync.on('campus', (count) => {
+                $('#message').html('正在下载学习中心,已完成' + count)
+            })
+            sync.on('package', (count) => {
+                $('#message').html('正在下载签到表,已完成' + count)
+            })
+            sync.on('finish', () => {
+                $('#popup-success').show()
+                $('#popup-text').html('数据同步完成')
+                config.updateSyncTime()
+
+                $('#popup-close').click(() => {
+                    $('#popup').hide()
+                    window.location.href = 'sync.html'
+                })
+                $('#popup').show()
+            })
+            sync.on('error', (err) => {
+                $('#popup-error').show()
+                $('#popup-text').html('数据同步出错\n' + (err || ''))
+                $('#popup-close').click(() => {
+                    $('#popup').hide()
+                    window.location.href = 'sync.html'
+                })
+                $('#popup').show()
+            })
+            sync.start()
+        })
+    </script>
+</body>
+
+</html>

+ 52 - 0
source/view/sync.html

@@ -0,0 +1,52 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+    <meta charset="UTF-8">
+    <title>云阅卷本地代理工具</title>
+    <meta name="viewport" content="width=device-width,initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no" />
+    <link rel="stylesheet" href="css/style.css">
+</head>
+
+<body>
+    <div class="wp">
+        <div class="hd">
+            <div class="logo"><a href="index.html"><img src="img/logo.png" /></a></div>
+            <span class="y"> 欢迎您,<span id="user-name"></span><span class="pipe">|</span><a href="login.html">退出</a></span>
+        </div>
+        <div class="cont">
+            <div class="title cl">
+                <span class="y"><a href="index.html">返回考试主页</a></span>
+                <h2>数据同步</h2>
+            </div>
+            <div class="data">
+                <p>本地已下载考生数量:<b id="student-count"></b></p>
+                <p>上次下载完成时间:<b id="last-time"></b></p>
+                <div class="btn"><a href="sync-run.html"><span>开始同步数据</span></a></div>
+            </div>
+        </div>
+        <div class="ft">Copyright © 2011-2020 www.qmth.com.cn, All Rights Reserved</div>
+    </div>
+
+    <script>
+        const $ = require('jquery')
+        const env = require('../lib/env.js')
+        const config = require('../lib/config.js')
+        const db = require('../lib/db.js')
+
+        $(document).ready(() => {
+            env.merge(JSON.parse(window.localStorage.getItem('env')))
+            $('#user-name').html(env.user.userName)
+            $('#last-time').html(config['syncTime'][env.server.host + '_' + env.exam.id])
+
+            db.init()
+            db.query('select count(*) as count from eb_exam_student where exam_id=?', [env.exam.id]).then((results) => {
+                $('#student-count').html(results[0].count)
+            }).catch(err => {
+                alert('数据库查询失败\n' + (err || ''))
+            })
+        })
+    </script>
+</body>
+
+</html>