Browse Source

局域网前端页面

XW 4 months ago
parent
commit
cb0fc08fa9
34 changed files with 4759 additions and 57 deletions
  1. 4 0
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/package.json
  2. 3 3
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/api/http.js
  3. 35 1
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/api/index.js
  4. BIN
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/icon/icon_play.png
  5. 49 3
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/iconfont/demo_index.html
  6. 11 3
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/iconfont/iconfont.css
  7. 1 1
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/iconfont/iconfont.js
  8. 14 0
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/iconfont/iconfont.json
  9. BIN
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/iconfont/iconfont.ttf
  10. BIN
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/iconfont/iconfont.woff
  11. BIN
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/iconfont/iconfont.woff2
  12. BIN
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/image/tmd_logo.png
  13. BIN
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/source/audio.png
  14. BIN
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/source/excel.png
  15. BIN
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/source/folder.png
  16. BIN
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/source/image.png
  17. BIN
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/source/item.png
  18. BIN
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/source/link.png
  19. BIN
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/source/pdf.png
  20. BIN
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/source/ppt.png
  21. BIN
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/source/unknow.png
  22. BIN
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/source/video.png
  23. BIN
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/source/word.png
  24. BIN
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/source/zip.png
  25. 2 0
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/main.js
  26. 734 0
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/utils/blobTool.js
  27. 490 0
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/utils/js-fn.js
  28. 2249 0
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/utils/public.js
  29. 113 6
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/view/admin/ActivityManage.less
  30. 299 16
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/view/admin/ActivityManage.vue
  31. 247 20
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/view/login/Admin.vue
  32. 306 3
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/view/student/ActivityAnswer.vue
  33. 197 0
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/view/student/AudioRecorder.vue
  34. 5 1
      TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/vue.config.js

+ 4 - 0
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/package.json

@@ -8,11 +8,15 @@
     "lint": "vue-cli-service lint"
   },
   "dependencies": {
+    "@azure/storage-blob": "^12.26.0",
+    "@fingerprintjs/fingerprintjs": "^4.5.1",
     "axios": "^1.7.9",
     "clean-webpack-plugin": "^4.0.0",
     "compression-webpack-plugin": "^11.1.0",
     "core-js": "^3.8.3",
     "element-ui": "^2.15.14",
+    "js-audio-recorder": "^1.0.7",
+    "qrcodejs2": "^0.0.2",
     "vue": "^2.6.14",
     "vue-router": "^3.6.5"
   },

+ 3 - 3
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/api/http.js

@@ -4,9 +4,9 @@ import { Loading } from 'element-ui';
 
 // 不需携带access_token
 const NO_ACCESS_API = [
-    '/activity/get-website',
-    '/activity/login-portal',
-    '/activity/list-portal',
+    '/index/device',
+    '/index/login-init',
+    '/index/login-check',
 ]
 // 需要携带access_token 不需要auth-token
 const NO_AUTH_API = []

+ 35 - 1
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/api/index.js

@@ -1,3 +1,37 @@
 import { fetch, post } from '@/api/http'
 
-export default {}
+export default {
+    /**
+     * 获取当前浏览器信息
+     * @param {String} fp - 浏览器指纹
+     */
+    getDevice: function(data) {
+        return post('/index/device', data)
+    },
+
+    // 登录页面
+    /**
+     * 获取二维码 / 短信验证码
+     * @param {String} type - qrcode(二维码)  smspin(短信验证码)
+     * @param {String} area - 短信验证码:区号
+     * @param {String} to - 短信验证码:手机号
+     */
+    getCode: function(data) {
+        return post('/index/login-init', data)
+    },
+    /**
+     * 二维码登录 / 短信验证码登录
+     * @param {String} type - qrcode(二维码)  smspin(短信验证码)
+     * @param {String} randomCode - 
+     * @param {String} pin_code - 短信验证码
+     * @param {String} account - +区号-手机号
+     */
+    loginCheck: function(data) {
+        return post('/index/login-check', data)
+    },
+
+    // 管理页面
+
+
+    // 学生页面
+}

BIN
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/icon/icon_play.png


+ 49 - 3
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/iconfont/demo_index.html

@@ -54,6 +54,18 @@
       <div class="content unicode" style="display: block;">
           <ul class="icon_lists dib-box">
           
+            <li class="dib">
+              <span class="icon element-icons">&#xe806;</span>
+                <div class="name">激活</div>
+                <div class="code-name">&amp;#xe806;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon element-icons">&#xe809;</span>
+                <div class="name">激活</div>
+                <div class="code-name">&amp;#xe809;</div>
+              </li>
+          
             <li class="dib">
               <span class="icon element-icons">&#xe71f;</span>
                 <div class="name">物品-书笔</div>
@@ -156,9 +168,9 @@
 <pre><code class="language-css"
 >@font-face {
   font-family: 'element-icons';
-  src: url('iconfont.woff2?t=1735287119647') format('woff2'),
-       url('iconfont.woff?t=1735287119647') format('woff'),
-       url('iconfont.ttf?t=1735287119647') format('truetype');
+  src: url('iconfont.woff2?t=1736838450792') format('woff2'),
+       url('iconfont.woff?t=1736838450792') format('woff'),
+       url('iconfont.ttf?t=1736838450792') format('truetype');
 }
 </code></pre>
           <h3 id="-iconfont-">第二步:定义使用 iconfont 的样式</h3>
@@ -184,6 +196,24 @@
       <div class="content font-class">
         <ul class="icon_lists dib-box">
           
+          <li class="dib">
+            <span class="icon element-icons el-icon-jihuo"></span>
+            <div class="name">
+              激活
+            </div>
+            <div class="code-name">.el-icon-jihuo
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon element-icons el-icon-jihuo1"></span>
+            <div class="name">
+              激活
+            </div>
+            <div class="code-name">.el-icon-jihuo1
+            </div>
+          </li>
+          
           <li class="dib">
             <span class="icon element-icons el-icon-my-note"></span>
             <div class="name">
@@ -337,6 +367,22 @@
       <div class="content symbol">
           <ul class="icon_lists dib-box">
           
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#el-icon-jihuo"></use>
+                </svg>
+                <div class="name">激活</div>
+                <div class="code-name">#el-icon-jihuo</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#el-icon-jihuo1"></use>
+                </svg>
+                <div class="name">激活</div>
+                <div class="code-name">#el-icon-jihuo1</div>
+            </li>
+          
             <li class="dib">
                 <svg class="icon svg-icon" aria-hidden="true">
                   <use xlink:href="#el-icon-my-note"></use>

+ 11 - 3
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/iconfont/iconfont.css

@@ -1,8 +1,8 @@
 @font-face {
   font-family: "element-icons"; /* Project id 4795944 */
-  src: url('iconfont.woff2?t=1735287119647') format('woff2'),
-       url('iconfont.woff?t=1735287119647') format('woff'),
-       url('iconfont.ttf?t=1735287119647') format('truetype');
+  src: url('iconfont.woff2?t=1736838450792') format('woff2'),
+       url('iconfont.woff?t=1736838450792') format('woff'),
+       url('iconfont.ttf?t=1736838450792') format('truetype');
 }
 
 .element-icons {
@@ -13,6 +13,14 @@
   -moz-osx-font-smoothing: grayscale;
 }
 
+.el-icon-jihuo:before {
+  content: "\e806";
+}
+
+.el-icon-jihuo1:before {
+  content: "\e809";
+}
+
 .el-icon-my-note:before {
   content: "\e71f";
 }

File diff suppressed because it is too large
+ 1 - 1
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/iconfont/iconfont.js


+ 14 - 0
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/iconfont/iconfont.json

@@ -5,6 +5,20 @@
   "css_prefix_text": "el-icon-",
   "description": "",
   "glyphs": [
+    {
+      "icon_id": "10082384",
+      "name": "激活",
+      "font_class": "jihuo",
+      "unicode": "e806",
+      "unicode_decimal": 59398
+    },
+    {
+      "icon_id": "10087237",
+      "name": "激活",
+      "font_class": "jihuo1",
+      "unicode": "e809",
+      "unicode_decimal": 59401
+    },
     {
       "icon_id": "6536038",
       "name": "物品-书笔",

BIN
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/iconfont/iconfont.ttf


BIN
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/iconfont/iconfont.woff


BIN
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/iconfont/iconfont.woff2


BIN
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/image/tmd_logo.png


BIN
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/source/audio.png


BIN
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/source/excel.png


BIN
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/source/folder.png


BIN
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/source/image.png


BIN
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/source/item.png


BIN
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/source/link.png


BIN
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/source/pdf.png


BIN
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/source/ppt.png


BIN
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/source/unknow.png


BIN
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/source/video.png


BIN
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/source/word.png


BIN
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/assets/source/zip.png


+ 2 - 0
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/main.js

@@ -3,6 +3,7 @@ import App from './App.vue'
 import router from './router/router'
 import apiTools from '@/api'
 import { fetch, post } from '@/api/http'
+import tools from '@/utils/public.js'
 
 import ElementUI from 'element-ui'
 import 'element-ui/lib/theme-chalk/index.css'
@@ -15,6 +16,7 @@ Vue.config.productionTip = false
 
 Vue.prototype.$api = apiTools
 Vue.prototype.$axios = axios
+Vue.prototype.$tools = tools
 Vue.prototype.$post = post
 Vue.prototype.$get = fetch
 

+ 734 - 0
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/utils/blobTool.js

@@ -0,0 +1,734 @@
+// import { GLOBAL } from '@/static/Global.js'
+import JsFn from '@/utils/js-fn.js'
+import Tools from '@/utils/public.js'
+import API from '@/api/index.js'
+// import store from '@/store'
+// import FileSaver from "file-saver";
+// import JSZip from "jszip";
+
+const BLOB_PATH = ['jointexam', 'avatar', 'audio', 'doc', 'exam', 'image', 'elegant', 'item', 'notice', 'other', 'paper', 'syllabus', 'res', 'records', 'student', 'survey', 'temp', 'thum', 'video', 'vote', 'jyzx', 'train', 'yxpt', 'homework', 'policy', 'public', 'art', 'activity', 'banner']
+const { BlobServiceClient } = require("@azure/storage-blob")
+
+//获取文件后缀和类型
+function getExAndType(fileName) {
+    let ex = fileName.substring(fileName.lastIndexOf('.') + 1)
+    let type = 'other'
+    ex = ex.toUpperCase()
+    for (let key in GLOBAL.CONTENT_TYPES) {
+        if (GLOBAL.CONTENT_TYPES[key].indexOf(ex) != -1) {
+            type = key
+            break
+        }
+    }
+    return {
+        ex, type
+    }
+}
+
+export default class BlobTool {
+    /**
+     * BlobTool生产方法
+     * @param {string} scope 学校(school)/个人(private)
+     * @param {object} options 初始化所需参数 host、container、sas
+     */
+    static CreateBlobTool(scope, options) {
+        if (!scope) {
+            throw new Error("CreateBlobTool参数(scope)错误,创建BlobTool失败")
+        }
+        //优先使用传入参数初始化
+        if (options) {
+            let { host, container, sas } = options
+            if (host && container && sas) {
+                return new BlobTool(host, container, '?' + sas, scope)
+            } else {
+                throw new Error("CreateBlobTool参数(options)异常,创建BlobTool失败")
+            }
+        }
+        let sas = scope == 'school' ? store.state?.user?.schoolProfile?.blob_sas : store.state?.user?.userProfile?.blob_sas
+        let blobUrl = scope == 'school' ? JSON.parse(decodeURIComponent(localStorage.school_profile, "utf-8"))?.blob_uri : JSON.parse(decodeURIComponent(localStorage.user_profile, "utf-8"))?.blob_uri
+        let host = blobUrl?.substring(0, blobUrl?.lastIndexOf('/'))
+        let cont = blobUrl?.substring(blobUrl?.lastIndexOf('/') + 1)
+        return new BlobTool(host, cont, '?' + sas, scope)
+    }
+    /**
+     * 公共读写容器 BlobTool生产方法
+     * @param {object} options 初始化所需参数 host、container
+     */
+    static CreatePublicBlobTool(options) {
+        if (!options) {
+            throw new Error("CreatePublicBlobTool参数(options)异常,创建BlobTool失败")
+        }
+        //优先使用传入参数初始化
+        let { host, container } = options
+        if (host && container) {
+            return new BlobTool(host, container, '', 'public')
+        } else {
+            throw new Error("CreatePublicBlobTool参数(options)异常,创建BlobTool失败")
+        }
+    }
+
+    /**
+     * 初始化Blob,需要先调用授权API
+     * @param {string} blobUrl blob地址
+     * @param {string} container 容器名称
+     * @param {string} sasString 授权 需要前面有 '?'
+     * @param {string} scope 学校(school)/个人(private) 计算空间大小
+     * */
+    constructor(blobUrl, container, sasString, scope) {
+        this.initBlob(blobUrl, container, sasString, scope)
+    }
+
+    /**
+     * 初始化Blob,需要先调用授权API
+     * @param {string} blobUrl blob地址
+     * @param {string} container 容器名称
+     * @param {string} sasString 授权
+     * @param {string} scope 学校(school)/个人(private) 计算空间大小
+     * */
+    initBlob(blobUrl, container, sasString, scope) {
+        if (blobUrl && container && scope) {
+            //初始化containerClient
+            this.blobService = new BlobServiceClient(blobUrl + sasString)
+            let containerClient = this.blobService.getContainerClient(container)
+            if (containerClient) {
+                this.containerClient = containerClient
+                this.container = container
+                this.blobUrl = blobUrl
+                this.sasString = sasString
+                this.scope = scope
+                if (scope == 'private') {
+                    let user_profile = localStorage.getItem('user_profile')
+                    this.blobSpace = user_profile ? JSON.parse(decodeURIComponent(user_profile, "utf-8")).total : 0
+                } else if (scope == 'school') {
+                    let school_profile_str = localStorage.getItem('school_profile') || '{}'
+                    let school_profile = JSON.parse(decodeURIComponent(school_profile_str, "utf-8"))
+                    this.blobSpace = school_profile && school_profile.school_base ? school_profile.school_base.size : 0
+                } else if(scope == 'area') {
+                    this.blobSpace = 0
+                }
+            } else {
+                throw new Error("initBlob参数错误,初始化失败")
+            }
+        } else {
+            throw new Error("initBlob初始化参数不完整,初始化失败")
+        }
+    }
+
+    /**
+     * 获取容器信息 (授权失败,需要账号级别的授权)
+     * @param {object} 
+     */
+    getProperties(options) {
+        return new Promise((r, j) => {
+            //const blockBlobClient = this.containerClient.getBlockBlobClient('res/基础操作范例_16x9.HTE') //blob获取成功
+            this.containerClient.getProperties(options).then(
+                res => {
+                    console.log('获取信息成功')
+                    r(res)
+                },
+                err => {
+                    console.log('获取信息失败')
+                    j(err)
+                }
+            )
+        })
+    }
+
+    /**
+     * 获取指定分块的md5值
+     * @param {any} url 相对路径 eg: video/test.mp4
+     */
+    getBlockMD5(url) {
+        let blobClient = this.containerClient.getBlockBlobClient(url)
+        return blobClient.download(0, 4 * 1024 * 1024 - 100, { rangeGetContentMD5: true })
+    }
+    /**
+     * 通过地址下载文件
+     * @param {any} url 相对路径 eg: video/test.mp4
+     */
+    downloadToFile(url) {
+        let blobClient = this.containerClient.getBlockBlobClient(url)
+        blobClient.download().then(
+            res => {
+                console.log('返回信息', res)
+                res.blobBody.then(
+                    blobRes => {
+                        console.log('blob结果', blobRes)
+                        let a = document.createElement("a")
+                        let url = window.URL.createObjectURL(blobRes)
+                        a.href = url
+                        a.download = 'test'
+                        a.click()
+                        window.URL.revokeObjectURL(url)
+                    },
+                    blobErr => {
+                        console.error('blob结果', blobErr)
+                    }
+                )
+            },
+            err => {
+                console.error('下载失败')
+            }
+        )
+    }
+
+    /**
+     * 上传文件方法,带回调上传进度
+     * @param {File} file 文件对象
+     * @param {Object} config 配置项 {path,root,checkExist,checkSize}
+     * {string} 必填 path 文件夹路径 只需要文件夹名称 前后都不需要加‘/’
+     * {string} 选填 root path验证的根目录 默认为'/' 
+     * {boolean} 选填 checkSize 上传时是否检查容器空间 默认为 true
+     * {boolean} 选填 checkExist 是否检查文件重复 默认为 false(直接覆盖) 如果为ture会重命名上传
+     * 
+     * @param {any} option 官方可配置项
+     * @returns {object} {url, name,size,createTime,extension,type}
+     */
+    // upload(file, path, option = {}, checkSize = true, root = '/') { //原来参数格式
+    upload(file, config = {}, option = {}) {
+        let { path, root, checkSize, checkExist } = config
+        console.log(config)
+        //验证和初始化参数
+        if (!path) throw new Error('上传失败:config.path必传')
+        root = root || '/'
+        checkSize = checkSize == undefined ? true : checkSize
+        checkExist = checkExist == undefined ? false : checkExist
+
+        let checkPath = root === '/' ? path : path.replace(root, '')
+        if (!BLOB_PATH.includes(checkPath.split('/')[0])) throw new Error('上传路径不合法,请检查上传路径:' + path)
+
+        return new Promise(async (r, j) => {
+            //检查容器空间大小
+            let isFull = false
+            if (checkSize) {
+                try {
+                    isFull = await this.isContainerFull(this.scope)
+                } catch (e) {
+                    j({ spaceError: '容器空间计算失败,无法上传文件' })
+                    return
+                }
+                if (isFull) {
+                    j({
+                        code: 1,
+                        spaceError: 'Blob空间已满,无法上传'
+                    })
+                    return
+                }
+            }
+
+            //检查文件是否存在
+            let fileName = file.name
+            let isExist = false
+            if (checkExist) {
+                let index = 1
+                while (await this.exists(path + '/' + fileName)) {
+                    fileName = `${file.name.slice(0, file.name.lastIndexOf('.'))}(${index})${file.name.slice(file.name.lastIndexOf('.'))}`
+                    isExist = true
+                    index++
+                }
+            }
+            const blockBlobClient = this.containerClient.getBlockBlobClient(path + "/" + fileName)
+            blockBlobClient.uploadBrowserData(file, option).then(
+                async res => {
+                    //设置blob MD5 (解决大文件分块上传没有MD5的问题)
+                    let md5value = res.contentMD5
+                    if (!md5value) {
+                        try {
+                            md5value = await Tools.getFileMD5(file)
+                            let option = {
+                                blobContentMD5: md5value
+                            }
+                            this.setFileProperties(path + "/" + fileName, option).then(
+                                setRes => {
+                                    console.log('MD5设置成功', setRes)
+                                },
+                                setErr => {
+                                    console.error('MD5设置失败', setErr)
+                                }
+                            )
+                        } catch (e) {
+                            console.error('前端获取MD5失败', e)
+                        }
+                    }
+                    let url = decodeURIComponent(res._response.request.url)
+                    url = url.substring(0, url.lastIndexOf('?'))
+                    let info = getExAndType(fileName)
+                    r({
+                        url: url,
+                        md5: md5value,
+                        blob: '/' + path + "/" + fileName,
+                        name: fileName,
+                        size: file.size,
+                        createTime: res.lastModified.getTime(),
+                        extension: info.ex,
+                        type: info.type,
+                        isExist: isExist
+                    })
+                },
+                err => {
+                    j(err)
+                }
+            )
+        })
+    }
+
+    /**
+     * 处理HTEX文件类型
+     * @param {blobList} fileList 
+     */
+    handleHTEXFile(fileList) {
+        let parseRes = []
+        let names = []
+        fileList.forEach((item, index) => {
+            if (item.url.indexOf('/res/') > 0 && item.url.indexOf('.HTE') < 0) {
+                let fileItem = {}
+                let startIndex = JsFn.findChartIndex(item.blob, '/', 1)
+                let endIndex = JsFn.findChartIndex(item.blob, '/', 2)
+                let name = item.blob.substring(startIndex + 1, endIndex)
+                let nameIndex = names.indexOf(name)
+                if (nameIndex == -1) {
+                    fileItem.url = this.blobUrl + '/' + this.container + '/res/' + name
+                    fileItem.blob = `/res/${name}/index.json`
+                    fileItem.name = name + '.HTEX'
+                    fileItem.size = item.size
+                    fileItem.createTime = item.createTime
+                    fileItem.extension = 'HTEX'
+                    fileItem.type = 'res'
+                    names.push(name)
+                    parseRes.push(fileItem)
+                } else {
+                    parseRes[nameIndex].size += item.size
+                }
+            } else {
+                parseRes.push(item)
+            }
+        })
+        return parseRes
+    }
+
+    /**
+     * 下载文件夹
+     * @param {string} folder 
+     * @param {number} options 
+     * {
+     *      fileName:'xxxx', 打包下载的文件名(必填)
+     *      exclude:'aaa', 在文件夹内但是不需要下载的内容
+     * }
+     * @returns 
+     */
+    downloadFolder(folder, options) {
+        const { fileName, exclude } = options
+        this.listBlob({
+            prefix: folder
+        }).then(
+            async res => {
+                const zip = new JSZip()
+                for (const blob of res.blobList) {
+                    try {
+                        if (exclude && blob.blob?.includes(exclude)) {
+                            continue
+                        }
+                        let blobPath = blob.blob.substring(1)
+                        let blobClient = this.containerClient.getBlockBlobClient(blobPath)
+                        const dwRes = await blobClient.download()
+                        const blobRes = await dwRes.blobBody
+                        const filePath = blobPath.replace(folder, '')
+                        zip.file(filePath, blobRes, {
+                            binary: true
+                        }) // 逐个添加文件
+                    } catch (e) {
+                        console.error('下载失败')
+                    }
+                }
+                zip.generateAsync({
+                    type: "blob"
+                }).then(content => {
+                    // 生成二进制流
+                    FileSaver.saveAs(content, fileName + ".zip"); // 利用file-saver保存文件
+                }).catch(err => {
+                    console.log(err);
+                })
+            }
+        )
+    }
+
+    /**
+     * 列出目录结构
+     * @param {string} delimiter  分割符 默认'/'
+     * @param {object} option 配置项 eg: option.prefix = 'res' 查某个目录的下级目录
+     * @returns 
+     */
+    listFolder(option, delimiter = '/') {
+        return new Promise(async (r, j) => {
+            let folderList = []
+            if (this.containerClient) {
+                let iter = this.containerClient.listBlobsByHierarchy(delimiter, option ? option : {})
+                try {
+                    let blobItem = await iter.next()
+                    while (!blobItem.done) {
+                        if (blobItem.value.kind === 'prefix') {
+                            folderList.push(blobItem.value.name)
+                        }
+                        blobItem = await iter.next()
+                    }
+                    r(folderList)
+                } catch (e) {
+                    console.error(e)
+                    j('获取目录结构异常')
+                }
+            } else {
+                j('初始化异常')
+            }
+        })
+    }
+
+    /**
+     * 列出所有(查询)
+     * @param {Object} option ContainerListBlobsOptions
+     * eg: option.prefix = 'res' 只查res文件夹下的blob
+     * @returns {object} {blobList, continuationToken}
+     */
+    listBlob(option, hendleHTEX = true) {
+        return new Promise(async (r, j) => {
+            let blobList = []
+            if (this.containerClient) {
+                let iter = this.containerClient.listBlobsFlat(option ? option : {});
+                let blobItem = await iter.next();
+                while (!blobItem.done) {
+                    let blobName = blobItem.value.name
+                    let info = getExAndType(blobItem.value.name)
+                    blobList.push(
+                        {
+                            url: this.blobUrl + '/' + this.container + '/' + blobName,
+                            blob: '/' + blobName,
+                            // name: blobName.substring(JsFn.findChartIndex(blobName, '/', 0) + 1),
+                            name: blobName.substring(blobName.lastIndexOf('/') + 1),
+                            size: blobItem.value.properties.contentLength,
+                            createTime: blobItem.value.properties.createdOn.getTime(),
+                            extension: info.ex,
+                            type: info.type
+                        }
+                    )
+                    blobItem = await iter.next();
+                }
+                if (hendleHTEX) {
+                    blobList = this.handleHTEXFile(blobList)
+                }
+                r({
+                    blobList,
+                    continuationToken: 'end'
+                })
+            } else {
+                j('containerClient 错误')
+            }
+        })
+    }
+
+    /**
+     * 分页列出(查询)
+     * @param {Object} option ContainerListBlobsOptions
+     * eg: option.prefix = 'res' 只查res文件夹下的blob
+     * @param {Object} pageInfo 
+     * eg: pageInfo.maxPageSize 当前请求条数
+     * eg: pageInfo.continuationToken 首次请求不需要,后面需要(首次请求会返回,下次需要传入)
+     * @returns {object} {blobList, continuationToken}
+     */
+    async listBlobByPage(option, pageInfo) {
+        return new Promise(async (r, j) => {
+            let page = {}
+            let blobList = []
+            if (pageInfo && JSON.stringify(pageInfo) != '{}') {
+                page.maxPageSize = pageInfo.maxPageSize ? pageInfo.maxPageSize : 50
+                if (pageInfo.continuationToken) {
+                    page.continuationToken = pageInfo.continuationToken ? pageInfo.continuationToken : ''
+                }
+            }
+            if (this.containerClient) {
+                let iterator = this.containerClient.listBlobsFlat(option ? option : {}).byPage(page)
+                let response = (await iterator.next()).value
+                let prefixLen = response.prefix ? response.prefix.length + 1 : 0
+                for (const blob of response.segment.blobItems) {
+                    let info = getExAndType(blob.name)
+                    blobList.push(
+                        {
+                            url: response.serviceEndpoint + response.containerName + '/' + blob.name,
+                            blob: '/' + blob.name,
+                            name: blob.name.substring(prefixLen),
+                            size: blob.properties.contentLength,
+                            createTime: blob.properties.lastModified.getTime(),
+                            extension: info.ex,
+                            type: info.type
+                        }
+                    )
+                }
+                let continuationToken = response.continuationToken ? response.continuationToken : 'end'
+                r({
+                    blobList,
+                    continuationToken
+                })
+            } else {
+                j('containerClient 错误')
+            }
+        })
+    }
+
+    /**
+     * 删除Blob
+     * @param {string} filePath 文件url + 容器 之后的路径
+     */
+    deleteBlob(filePath) {
+        if (filePath) {
+            filePath = filePath.substring(1)
+            return new Promise((r, j) => {
+                this.containerClient.deleteBlob(filePath).then(
+                    async res => {
+                        r(200)
+                    },
+                    err => {
+                        j(err)
+                    }
+                )
+
+            })
+        } else {
+            throw new Error("filePath参数错误")
+        }
+    }
+
+    /**
+     * 批量删除Blob 官方API授权失败(需要账号级别的授权,批量删除请访问后端API)
+     * @param {string} files 文件url + 容器 之后的路径
+     */
+    deleteBlobs(files) {
+        this.getProperties()
+        let blobBatchClient = this.blobService.getBlobBatchClient()
+        return new Promise((r, j) => {
+            for (let i in files) {
+                files[i] = files[i].substring(0, files[i].lastIndexOf('?'))
+            }
+            blobBatchClient.deleteBlobs(files, this.blobService.credential).then(
+                res => {
+                    console.log('批量删除成功')
+                },
+                err => {
+                    console.log('批量删除失败')
+                }
+            )
+        })
+    }
+
+    /**
+     * 批量删除Blob 循环操作
+     * @param {string} files 
+     */
+    deleteBlobBatch(files) {
+        return new Promise((r, j) => {
+            let promises = []
+            for (let item of files) {
+                let f = item.substring(1)
+                promises.push(this.containerClient.deleteBlob(f))
+            }
+            Promise.all(promises).then(
+                res => {
+                    r(res)
+                },
+                err => {
+                    j(err)
+                }
+            )
+        })
+    }
+
+    /**
+     * 获取blob属性
+     * @param {string} url //blob完整路径包含授权 eg:'doc/醍摩豆账号.xlsx'
+     */
+    getFileProperties(url) {
+        let blobClient = this.containerClient.getBlockBlobClient(url)
+        return blobClient.getProperties()
+    }
+
+    /**
+     * 设置文件header
+     * @param {string} url //blob完整路径包含授权 eg:'doc/醍摩豆账号.xlsx'
+     * @param {BlobHTTPHeaders} option //设置blob的属性
+     * eg:
+     * {
+     *      blobContentMD5: new Uint8Array(16)
+     * }
+     */
+    setFileProperties(url, option) {
+        let blobClient = this.containerClient.getBlockBlobClient(url)
+        return blobClient.setHTTPHeaders(option)
+    }
+
+    /**
+     * 复制单个Blob
+     * @param {string} targetUrl 
+     * @param {string} sourceUrl 
+     * @param {string} sas 
+     * 1、目标url(targetUrl)容器之后的文件路径 eg:'paper/預設試卷名稱00/gfWxV2p8lx07QGBSz5k401041200fIJd0E010.mp4'
+     * 2、源文件url(sourceUrl)1、完整路径(包括Host); 2、如果需要授权的容器,则url需要凭借授权。
+     */
+    copyBlob(targetUrl, sourceUrl, sas) {
+        return new Promise((r, j) => {
+            console.log(...arguments)
+            let newBlob = this.containerClient.getBlobClient(targetUrl)
+            let encodeUrl = encodeURI(sourceUrl)
+            newBlob.beginCopyFromURL(encodeUrl + sas).then(
+                res => {
+                    console.log('复制成功返回数据', res)
+                    r(200)
+                },
+                err => {
+                    console.log('11111111111', err);
+                    j(500)
+                }
+            )
+        })
+    }
+
+    /**
+     * 复制‘文件夹’
+     * @param {string} targetFolder eg:'exam/评测id/paper/8b94c6b6-2572-41e5-89b9-a82fcf13891e/' 注意开头不加‘/’, 结尾需要加‘/’
+     * @param {string} sourceFolder eg:'paper/JEFF組卷測試01'
+     * @param {BlobTool} blobTool 非必传参数, 当目标文件和源文件不在同一个容器的时候,需要传源文件容器初始化的BlobTool
+     * @param {boolean} handleSize 是否内部处理blobsize
+     */
+    copyFolder(targetFolder, sourceFolder, blobTool, handleSize = true) {
+        console.log(...arguments);
+        return new Promise(async (r, j) => {
+            try {
+                let blobs = undefined
+                let sasString = ''
+                if (blobTool) {
+                    blobs = await blobTool.listBlob({
+                        prefix: sourceFolder + '/'
+                    }, false)
+                    sasString = blobTool.sasString
+                } else {
+                    blobs = await this.listBlob({
+                        prefix: sourceFolder + '/'
+                    }, false)
+                    sasString = this.sasString
+                }
+                //上传之前检查文件夹大小
+                let beforeSize = 0
+                let cont = ''
+                if (handleSize) {
+                    cont = this.container
+                }
+                if (blobs && blobs.blobList.length) {
+                    let count = 0
+                    blobs.blobList.forEach(blobItem => {
+                        let newUrl = targetFolder + blobItem.name
+                        let newBlob = this.containerClient.getBlobClient(newUrl)
+                        let resourceUrl = encodeURI(blobItem.url)
+                        newBlob.beginCopyFromURL(resourceUrl + sasString).then(
+                            async res => {
+                                if (++count == (blobs.blobList.length)) {
+                                    if (handleSize) {
+                                        //复制之后更新大小
+                                    }
+                                    r(blobItem)
+                                }
+                            },
+                            err => {
+                                j('copy error')
+                            }
+                        )
+                    })
+                } else {
+                    j('sourceBlob error or 404 : ' + sourceFolder)
+                }
+            } catch {
+                j('copy error')
+            }
+
+        })
+    }
+
+    /**
+     * 判断文件是否存在
+     * @param {string} filePath 文件路径 正确 'res/基础操作范例_16x9.HTE ' 错误 '/res/基础操作范例_16x9.HTE'
+     * @param {object} 
+     * return true/false
+     */
+    exists(filePath, options) {
+        const blockBlobClient = this.containerClient.getBlockBlobClient(filePath)
+        return new Promise((r, j) => {
+            blockBlobClient.exists(options).then(
+                res => {
+                    r(res)
+                },
+                err => {
+                    j(err)
+                }
+            )
+        })
+    }
+
+    /**
+     * 判断容器的空间是否已满
+     * @params {containerName} 容器名称
+     * @params {scope} 'school' or 'private'
+     * return true/false
+     */
+    async isContainerFull(scope) {
+        return new Promise(async (r, j) => {
+            try {
+                let sizeRes = await BlobTool.getContainerSize(this.container, scope)
+                console.log(scope)
+                console.log(sizeRes)
+                if (sizeRes) {
+                    r(sizeRes.total > this.blobSpace * 1024 * 1024 * 1024)
+                } else {
+                    j('容器空间判断失败!')
+                }
+            } catch (e) {
+                j('容器空间判断失败!')
+            }
+
+        })
+    }
+
+    /**
+     * 新版获取blob空间大小
+     * @params {containerName} 容器名称
+     * @params {scope} 'school' or 'private'
+     */
+    static getContainerSize(containerName, scope) {
+        if (containerName, scope) {
+            return new Promise((r, j) => {
+                API.blob.getContainerSize({
+                    scope,
+                    containerName
+                }).then(
+                    res => {
+                        if (res) {
+                            let contentSize = 0
+                            let defined = ['image', 'res', 'video', 'audio', 'doc', 'other']
+                            for (let key in res.catalog) {
+                                if (defined.includes(key)) {
+                                    contentSize += res.catalog[key]
+                                }
+                            }
+                            res.catalog.appData = res.size - contentSize
+                            res.catalog.total = res.size
+                            res.catalog.teachSpace = res.teach ? res.teach * 1024 * 1024 * 1024 : 0 //查询学校空间的时候,返回此学校已分配给教室的空间;查询个人容器返回为零,不需要处理
+                            r(res.catalog)
+                        } else {
+                            j('API error')
+                        }
+                    },
+                    err => {
+                        j('API error')
+                    }
+                )
+            })
+        } else {
+            throw new Error("参数不完整")
+        }
+
+    }
+}

+ 490 - 0
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/utils/js-fn.js

@@ -0,0 +1,490 @@
+
+// import store from '@/store'
+import { app } from '@/main.js'
+import _tools from "./public.js"
+// import _editorTools from "./editorTools.js"
+/*
+ * 根据某个属性进行分组
+ */
+function groupBy(array, key) {
+    const groups = {}
+    array.forEach(function (item) {
+        const group = JSON.stringify(item[key])
+        groups[group] = groups[group] || []
+        groups[group].push(item)
+    })
+    return Object.keys(groups).map(function (group) {
+        return groups[group]
+    })
+}
+/*
+ * 判断两个对象是否相等
+ */
+function isObjEqual(o1, o2) {
+    var props1 = Object.keys(o1)
+    var props2 = Object.keys(o2)
+    if (props1.length != props2.length) {
+        return false
+    }
+    for (var i = 0, max = props1.length; i < max; i++) {
+        var propName = props1[i]
+        if (o1[propName] !== o2[propName]) {
+            return false
+        }
+    }
+    return true
+}
+/*
+ * 获取对象在对象数组的index
+ */
+function getIndex(_arr, _obj) {
+    var len = _arr.length
+    for (let i = 0; i < len; i++) {
+        if (isObjEqual(_arr[i], _obj)) {
+            return parseInt(i)
+        }
+    }
+    return -1
+}
+/*
+ * 产生某个范围的随机数
+ */
+function getBtwRandom(start, end) {
+    return Math.floor(Math.random() * (end - start)) + start
+}
+
+/*
+ *根据图片链接压缩图片得到缩略图 
+ * @params url: 文件url
+ */
+function compressImgByUrl(url, name, quality) {
+    return new Promise((r, j) => {
+        try {
+            let img = new Image()
+            img.setAttribute('crossOrigin', 'Anonymous')
+            img.src = url
+            img.onload = function () {
+                let canvas = document.createElement('canvas')
+                canvas.width = 200
+                canvas.height = 200 * img.height / img.width
+                let ctx = canvas.getContext('2d')
+                ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, canvas.width, canvas.height)
+                let newImgData = canvas.toDataURL('image/png', quality)
+                //let resultFile = dataURLtoFile(newImgData, name)
+                //r(resultFile)
+                r(newImgData)
+            }
+            img.onerror = function (e) {
+                console.error('图片Error', e)
+                j('Format Error')
+            }
+        }
+        catch (err) {
+            j(err)
+        }
+    })
+}
+/*
+ *根据视频链接创建封面图
+ * @params url: 文件url
+ */
+function createVideoPoster(url, name, quality) {
+    return new Promise(
+        (r, j) => {
+            try {
+                let video = document.createElement('video')
+                video.setAttribute('crossOrigin', 'Anonymous')
+                video.setAttribute('width', '300')
+                video.setAttribute('controls', 'controls')
+                video.setAttribute('crossOrigin', 'Anonymous')
+                video.setAttribute('src', url)
+                video.currentTime = 1
+                console.log(url)
+                video.addEventListener('loadeddata', () => {
+                    let canvas = document.createElement('canvas')
+                    canvas.width = 300
+                    canvas.height = 300 * video.videoHeight / video.videoWidth
+                    let ctx = canvas.getContext('2d')
+                    ctx.drawImage(video, 0, 0, video.videoWidth, video.videoHeight, 0, 0, canvas.width, canvas.height)
+                    let newVideoData = canvas.toDataURL('image/png', quality)
+                    //let resultFile = dataURLtoFile(newVideoData, name)
+                    r(newVideoData)
+                })
+                video.addEventListener('error', (e) => {
+                    console.error(e)
+                    j('Format Error')
+                })
+            }
+            catch (err) {
+                j(err)
+            }
+        }
+    )
+}
+
+/*
+ *dataUrl转文件 
+ */
+function dataURLtoFile(dataurl, filename) {
+    console.log(...arguments);
+    let arr = dataurl.split(',')
+    let mime = arr[0].match(/:(.*?);/)[1]
+    let bstr = atob(arr[1])
+    let n = bstr.length
+    let u8arr = new Uint8Array(n)
+    while (n--) {
+        u8arr[n] = bstr.charCodeAt(n)
+    }
+    return new File([u8arr], filename, { type: mime })
+}
+/*
+ *文件转dataUrl
+ */
+function fileToURL(file) {
+    return new Promise((r, j) => {
+        try {
+            var reader = new FileReader()
+            reader.onloadend = function (e) {
+                r(e.target.result)
+            }
+            reader.readAsDataURL(file)
+        } catch (e) {
+            j(e)
+        }
+    })
+
+}
+
+/*
+ * 函数防抖
+ */
+function debounce(func, delay) {
+    return function () {
+        window.clearTimeout(window.timeout)
+        window.timeout = setTimeout(() => {
+            clearTimeout(window.timeout)
+            func.apply(this, arguments)
+        }, delay)
+    }
+}
+
+/*
+ * 函数节流
+ */
+function throttle(func, delay) {
+    let run = true
+    return function () {
+        if (!run) {
+            return  // 如果开关关闭了,那就直接不执行下边的代码
+        }
+        run = false // 持续触发的话,run一直是false,就会停在上边的判断那里
+        setTimeout(() => {
+            func.apply(this, arguments)
+            run = true // 定时器到时间之后,会把开关打开,我们的函数就会被执行
+        }, delay)
+    }
+}
+//转换bytes
+function formatBytes(bytes) {
+    bytes = bytes || 0
+    return bytes / 1024 < 1024 ? (bytes / 1024).toFixed(1) + 'KB' : bytes / 1024 / 1024 < 1024 ? (bytes / 1024 / 1024).toFixed(1) + 'M' : (bytes / 1024 / 1024 / 1024).toFixed(1) + 'G'
+}
+//生成uuid
+function uuid() {
+    function S4() {
+        return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1)
+    }
+    return (S4() + S4() + '-' + S4() + '-' + S4() + '-' + S4() + '-' + S4() + S4() + S4())
+}
+// 获取某个字符在字符串中第num次出现的index
+function findChartIndex(str, cha, num) {
+    var x = str.indexOf(cha);
+    for (var i = 0; i < num; i++) {
+        x = str.indexOf(cha, x + 1);
+    }
+    return x;
+}
+
+function secondTimeFormat(timestamp) {
+    timestamp = timestamp < 10000000000 ? timestamp * 1000 : timestamp
+    let date = new Date(timestamp)
+    let Y = date.getFullYear()
+    let M = date.getMonth()
+    let D = date.getDate()
+    let H = date.getHours()
+    let MIN = date.getMinutes()
+    let SEC = date.getSeconds()
+    return `${Y}/${M < 9 ? '0' + (M + 1) : M + 1}/${D < 10 ? '0' + D : D} ${H < 10 ? '0' + H : H}:${MIN < 10 ? '0' + MIN : MIN}:${SEC < 10 ? '0' + SEC : SEC}`
+}
+
+function timeFormat(timestamp) {
+    timestamp = timestamp < 10000000000 ? timestamp * 1000 : timestamp
+    let date = new Date(timestamp)
+    let Y = date.getFullYear()
+    let M = date.getMonth()
+    let D = date.getDate()
+    let H = date.getHours()
+    let MIN = date.getMinutes()
+    return `${Y}/${M < 9 ? '0' + (M + 1) : M + 1}/${D < 9 ? '0' + D : D} ${H < 10 ? '0' + H : H}:${MIN < 10 ? '0' + MIN : MIN}`
+}
+//時間戳轉換
+function dateFormat(timestamp) {
+    if (timestamp <= 0) return ''
+    timestamp = timestamp < 10000000000 ? timestamp * 1000 : timestamp
+    let date = new Date(timestamp)
+    let Y = date.getFullYear()
+    let M = date.getMonth()
+    let D = date.getDate()
+    return `${Y}/${M < 9 ? '0' + (M + 1) : M + 1}/${D < 9 ? '0' + D : D}`
+}
+
+/**
+ * 根据学年获取年级名称
+ * @param year 学年
+ */
+function getGradeNameByYear(year) {
+    if (year && year > 0) {
+        let curPeriod = store.state.user?.curPeriod
+        if (curPeriod) {
+            let date = new Date()
+            let curYear = date.getFullYear()
+            let month = date.getMonth() + 1
+            let start = curPeriod.semesters.find(item => {
+                return item.start == 1
+            })
+            // 根据入学月份确定当前年级和学级的关系
+            if (start && month < start.month) {
+                curYear--
+            }
+            let res = curPeriod.grades[curYear - year]
+            if (curYear - year >= curPeriod.grades.length) {
+                return app.$t('schoolBaseInfo.graduated')
+            }
+            return res ? res : app.$t('schoolBaseInfo.untimed')
+        } else {
+            return '--'
+        }
+    } else {
+        return '--'
+    }
+}
+
+/**
+ * 根据学年获取年级信息
+ * @param year 学年
+ * @param periodId 学段id(非必填)
+ */
+function getGradeInfoByYear(year, periodId) {
+    if (year && year > 0) {
+        let curPeriod
+        if (periodId) {
+            curPeriod = store.state.user?.schoolProfile?.school_base?.period?.find(item => item.id == periodId)
+        } else {
+            curPeriod = store.state.user?.curPeriod
+        }
+        if (curPeriod) {
+            let date = new Date()
+            let curYear = date.getFullYear()
+            let month = date.getMonth() + 1
+            let start = curPeriod.semesters.find(item => {
+                return item.start == 1
+            })
+            // 根据入学月份确定当前年级和学级的关系
+            if (start && month < start.month) {
+                curYear--
+            }
+            let res = curPeriod.grades[curYear - year]
+            let gradeName
+            if (curYear - year >= curPeriod.grades.length) {
+                gradeName = app.$t('schoolBaseInfo.graduated')
+            }
+            gradeName = res ? res : app.$t('schoolBaseInfo.untimed')
+            return {
+                id: curYear - year,
+                name: gradeName
+            }
+        } else {
+            return undefined
+        }
+    } else {
+        return undefined
+    }
+}
+
+/**
+ * 根据班级学年年级名称
+ * @param data 学校基础数据 schoolProfile.school_base
+ * @param curPd 当前学段id
+ * @param grade 年级index
+ */
+function getYearByGrade(grade) {
+    let date = new Date()
+    let curYear = date.getFullYear()
+    let month = date.getMonth() + 1
+    grade = parseInt(grade)
+    let curPeriod = store.state.user?.curPeriod
+    if (grade > -1 && curPeriod) {
+        let start = curPeriod.semesters.find(item => {
+            return item.start == 1
+        })
+        // 根据入学月份确定当前年级和学级的关系
+        if (start && month < start.month) {
+            curYear--
+        }
+        return curYear - grade
+    } else {
+        return curYear
+    }
+}
+/**
+ * 根据教师绑定的学科id获取学科和学段名称
+ * @param ids 学科ids
+ * @returns 
+ * [
+ *  {
+ *      periodId:'ddd'
+ *      periodName:'小学',
+ *      subjectId:'wwww',
+ *      subjectName:'语文'
+ *  }
+ * ]
+ */
+function getTeacherSubjects(ids) {
+    let schoolPeriod = store.state.user?.schoolProfile?.school_base?.period
+    if (ids && ids.length && schoolPeriod) {
+        let data = []
+        ids.forEach(sid => {
+            for (let i = 0; i < schoolPeriod.length; i++) {
+                let subjectInfo = schoolPeriod[i].subjects.find(subject => subject.id === sid)
+                if (subjectInfo) {
+                    data.push({
+                        periodId: schoolPeriod[i].id,
+                        periodName: schoolPeriod[i].name,
+                        subjectId: subjectInfo.id,
+                        subjectName: subjectInfo.name
+                    })
+                    break
+                }
+            }
+        })
+        return data
+    } else {
+        return []
+    }
+
+}
+//根据域名判断菜单是否显示
+function checkJinNiu() {
+    let host = window.location.host
+    return host == 'jinniu.teammodel.cn'
+}
+//根据域名判断菜单是否显示 知音
+function checkZhiYin() {
+    let host = window.location.host
+    // return host == 'zhiyin.teammodel.cn'
+    return host=='zhiyin-test.teammodel.cn'
+}
+//根据域名判断菜单是否显示
+function checkTrain() {
+    let host = window.location.host
+    return host == 'scyx.teammodel.cn'
+}
+/**
+ * 统一处理学生作答数据
+ * @param {string} fullUrl ans.json完成路径含授权
+ * @param {string} richPrefix 富文本前缀(到stuId)
+ * @param {string} sas 授权(不需要“?”)
+ */
+function handleStudentAnswer(fullUrl, richPrefix, sas) {
+    return new Promise(async (r, j) => {
+        try {
+            let ansStr = await _tools.getFile(fullUrl)
+            if (ansStr) {
+                let ans = JSON.parse(ansStr)
+                ans.forEach((item, index) => {
+                    // 数据异常
+                    if (!item) {
+                        ans[index] = [app.$t('learnActivity.score.ansErr')]
+                    }
+                    // 未作答
+                    // 2024.4.7 题型增加问答题,保存字段为blob地址,需判断是否作答,因此返回[]
+                    else if (!item.length) {
+                        // ans[index] = [app.$t('learnActivity.score.noStuAns')]
+                        ans[index] = []
+                    }
+                    // 处理富文本中多媒体
+                    else {
+                        item.forEach((as, ai) => {
+                            item[ai] = _editorTools.getMideaFullPath(as, richPrefix + '/', '?' + sas)
+                        })
+                        ans[index] = item
+                    }
+                })
+                r(ans)
+            } else {
+                r([])
+            }
+        } catch (e) {
+            r([])
+        }
+    })
+}
+function getBlobInfo(scope) {
+    console.log(store)
+    let blobProfile
+    if (scope == 'school') {
+        blobProfile = store.state.user.schoolProfile
+    } else if (scope == 'private') {
+        blobProfile = store.state.user.userProfile
+    }
+    if (blobProfile) {
+        let blobUrl = blobProfile.blob_uri
+        let blobName = blobUrl.substring(blobUrl.lastIndexOf('/') + 1)
+        let blobHost = blobUrl.substring(0, blobUrl.lastIndexOf('/'))
+        let blobSas = blobProfile.blob_sas
+        return {
+            blobUrl, blobName, blobHost, blobSas
+        }
+    } else {
+        return {}
+    }
+}
+
+function setLocalLang() {
+    // 自动根据浏览器系统语言设置语言(优先判断本地设置的语言,如果有则使用本地设置的语言,如果没有则使用浏览器系统语言)
+    const curLang = localStorage.getItem('cloudSetting') ? JSON.parse(localStorage.getItem('cloudSetting')).curLang : null
+    const navLang = curLang || navigator.language.toLowerCase()
+    const localLang = (navLang === 'zh' || navLang === 'zh-tw' || navLang === 'zh-cn' || navLang === 'zh-hk') ? navLang : false
+    let lang = localLang || 'en-us'
+    localStorage.setItem('local', lang)
+    return lang
+}
+
+export default {
+    groupBy,
+    isObjEqual,
+    getIndex,
+    getBtwRandom,
+    compressImgByUrl,
+    createVideoPoster,
+    dataURLtoFile,
+    fileToURL,
+    debounce,
+    throttle,
+    formatBytes,
+    uuid,
+    findChartIndex,
+    getGradeNameByYear,
+    getGradeInfoByYear,
+    getYearByGrade,
+    dateFormat,
+    timeFormat,
+    secondTimeFormat,
+    getTeacherSubjects,
+    checkJinNiu,
+    checkZhiYin,
+    checkTrain,
+    handleStudentAnswer,
+    getBlobInfo,
+    setLocalLang
+}

File diff suppressed because it is too large
+ 2249 - 0
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/utils/public.js


+ 113 - 6
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/view/admin/ActivityManage.less

@@ -5,22 +5,129 @@
     .el-footer {
         background-color: #b3c0d1;
         color: #333;
-        /* text-align: center; */
         line-height: 60px;
     }
 
     .el-aside {
-        background-color: #d3dce6;
+        // background-color: #d3dce6;
         color: #333;
-        text-align: center;
         line-height: 200px;
+        border-right: 1px solid #ccc;
     }
 
     .el-main {
-        background-color: #e9eef3;
         color: #333;
-        text-align: center;
-        line-height: 160px;
+        height: 100%;
+
+        .name-box {
+            font-size: 20px;
+            display: flex;
+            margin-bottom: 20px;
+
+            &>span {
+                font-weight: bold;
+            }
+
+            .paper-item-school {
+                display: flex;
+                margin-bottom: 5px;
+                font-size: 17px;
+                margin-right: 15px;
+                cursor: pointer;
+
+                &>span {
+                    padding: 2px 10px;
+                    // white-space: pre;
+                    display: flex;
+                    align-items: center;
+                }
+
+                .paper-owner {
+                    background-color: #ababab;
+                    color: #fff;
+                    border-radius: 5px 0 0 5px;
+                }
+
+                .paper-extType {
+                    border: 1px solid #ababab;
+                    border-radius: 0 5px 5px 0;
+                    border-left: none;
+                }
+            }
+
+            .short-code {
+                position: absolute;
+                right: 0;
+                margin-right: 20px;
+                // font-weight: bold;
+                display: flex;
+
+                span {
+                    font-weight: bold;
+                }
+
+                .el-button {
+                    margin-left: 15px;
+                }
+            }
+        }
+
+        .activity-info {
+            margin-top: 15px;
+            font-size: 16px;
+            height: 100%;
+
+            &>div {
+                margin-top: 15px;
+            }
+
+            .time-tag {
+                color: #4093E9;
+                font-weight: bolder;
+            }
+
+            .subject-tag {
+                background: #4093e9;
+                color: #fff;
+                border-radius: 5px;
+                padding: 5px 15px;
+                margin-right: 15px;
+            }
+
+            .group-tag {
+                background: #e6eff8;
+                // color: #fff;
+                border-radius: 5px;
+                padding: 5px 15px;
+                margin-right: 15px;
+                margin-bottom: 10px;
+                display: inline-block;
+            }
+
+            .paper-content {
+                background: #f7f7f7;
+                padding: 20px;
+                margin: 10px 20px 10px 0;
+                border-radius: 6px;
+
+                &>div {
+                    margin-bottom: 20px;
+                }
+
+                .subject-name {
+                    font-weight: 600;
+                    color: #2d8cf0;
+                    margin-right: 10px;
+                    margin-bottom: 15px;
+                }
+
+                .el-tag {
+                    margin-bottom: 10px;
+                    margin-right: 15px;
+                    cursor: pointer;
+                }
+            }
+        }
     }
 }
 

File diff suppressed because it is too large
+ 299 - 16
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/view/admin/ActivityManage.vue


+ 247 - 20
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/view/login/Admin.vue

@@ -8,20 +8,33 @@
                 <div class="body-right">
                     <div class="right-input" v-show="!isQRCode">
                         <h1>手机号登录</h1>
-                        <el-input v-model="loginForm.id" placeholder="手机号码" size="medium" />
+                        <el-input v-model="loginForm.phone" placeholder="手机号码" size="medium" />
                         <div>
-                            <el-input v-model="loginForm.password" type="password" placeholder="密码" size="medium" style="width: 60%;" />
-                            <el-button type="primary" size="medium" @click="login()" style="width: calc(40% - 20px); margin-left: 20px;">获取验证码</el-button>
+                            <el-input v-model="loginForm.smspin" placeholder="密码" size="medium" style="width: 60%;" />
+                            <el-button type="primary" size="medium" @click="getCode('smspin')" style="width: calc(40% - 20px); margin-left: 20px;">获取验证码</el-button>
                         </div>
-                        <el-button type="primary" size="medium" @click="login()">登录</el-button>
+                        <el-button type="primary" size="medium" @click="toLogin('smspin')">登录</el-button>
                     </div>
                     <div class="right-input" v-show="isQRCode">
-                        <h1>扫码登录</h1>
-                        <el-button type="primary" size="medium" @click="login()">登录</el-button>
+                        <h1>HiTA扫码登录</h1>
+                        https://www.teammodel.cn/joinSchool?schoolCode=login:7817&m=登录&o=1&code=Code0a249e16-d2dd-499f-80f8-1f2a30f5427f
+                        需在IES5 新增一个登录确认页面,参考 joinSchool 页面数据
+                        <img :src="qrCodeImg" alt="" style="width: 250px;">
+                        <el-button type="primary" size="medium" @click="toLogin('qrcode')">登录</el-button>
+                    </div>
+                    <!-- 未联网时使用 -->
+                    <div>评测开卷码登录
+                        (如果没有获取到评测信息,则显示跳过,到管理页面,管理页面没有评测则显示导入按钮,用户自行导入文件夹,有评测则使用开卷码获取详细信息)
+                    </div>
+                    <div class="network-info">
+                        <span @click="showDevice()">
+                            <i v-show="hybridType" class="el-icon-success" style="color: #0eb90e;"></i>
+                            <i v-show="!hybridType" class="el-icon-error" style="color: #fb3636;"></i>
+                            服务端信息
+                        </span>
                     </div>
-                    <div>试卷验证码登录</div>
                 </div>
-                <div class="qr-code" @click="isQRCode = !isQRCode">
+                <div class="qr-code" @click="changeLoginType()">
                     <img src="@/assets/qrCode.png" alt="">
                 </div>
             </div>
@@ -30,6 +43,61 @@
             <a class="footer-info-item">蜀ICP备18027363号</a>
             <span class="footer-info-item">© 2021 HABOOK Group 醍摩豆</span>
         </div>
+        <el-drawer title="服务端信息" :visible.sync="isDeviceDrawer">
+            <el-form ref="form" label-width="120px" v-if="deviceInfo" style="margin-right: 10px;">
+                <el-form-item label="登录用户:">
+                    <span>{{ deviceInfo.server.userName }}</span>
+                </el-form-item>
+                <el-form-item label="设备名称:">
+                    <span>{{ deviceInfo.server.name }}</span>
+                </el-form-item>
+                <el-form-item label="操作系统:">
+                    <span>{{ deviceInfo.server.os }}</span>
+                </el-form-item>
+                <el-form-item label="系统类型:">
+                    <span>{{ deviceInfo.server.bit }}位操作系统 基于{{ deviceInfo.server.arch }}的处理器</span>
+                </el-form-item>
+                <el-form-item label="设备编号:">
+                    <span>{{ deviceInfo.server.deviceId }}</span>
+                </el-form-item>
+                <el-form-item label="网络地址:">
+                    <span>{{ deviceInfo.ip }}</span>
+                </el-form-item>
+                <el-form-item label="内存大小:">
+                    <span>{{ deviceInfo.server.shwoRam }}GB</span>
+                </el-form-item>
+                <el-form-item label="处理器:">
+                    <span v-for="(item, index) in deviceInfo.server.cpuInfos" :key="index" style="display: block;">{{ item.name }} {{ item.showHZ }}Hz({{ deviceInfo.server.cpu }}核)</span>
+                </el-form-item>
+                <el-form-item label="网卡信息:">
+                    <span v-for="(item, index) in deviceInfo.server.networks" :key="index" style="display: block;">{{ item.name }}({{ item.mac }}) </span>
+                </el-form-item>
+                <el-form-item label="数据中心:">
+                    <span>{{ deviceInfo.centerUrl }}</span>
+                </el-form-item>
+                <el-form-item label="服务端地址:">
+                    <span v-for="(item, index) in deviceInfo.server.host" :key="index" style="display: block;">
+                        {{ item }}
+                        <i class="el-icon-copy-document" @click="copyUrl(item)" style="cursor: pointer; margin-left: 5px;"></i>
+                    </span>
+                </el-form-item>
+            </el-form>
+        </el-drawer>
+        <el-dialog title="请绑定学校" width="30%" :visible.sync="isBindSchool" :show-close="false" :close-on-press-escape="false" :close-on-click-modal="false">
+            <el-form ref="form" :model="schoolInfo" label-width="80px">
+                <el-form-item label="学校名称">
+                    <el-select size="medium" v-model="schoolInfo.name" placeholder="请选择学校" style="width: 100%;">
+                        <el-option v-for="item in schoolList" :key="item.code" :label="item.name" :value="item.code"></el-option>
+                    </el-select>
+                </el-form-item>
+                <el-form-item label="学校简码">
+                    <el-input size="medium" v-model="schoolInfo.code"></el-input>
+                </el-form-item>
+            </el-form>
+            <span slot="footer" class="dialog-footer">
+                <el-button type="primary" @click="dialogVisible = false">绑定学校</el-button>
+            </span>
+        </el-dialog>
     </div>
 </template>
 
@@ -38,12 +106,121 @@ export default {
     data() {
         return {
             loginForm: {
-                id: '',
-                password: '',
+                phone: '',
+                smspin: '',
             },
             isQRCode: false,
+            hybridType: 0, //是否连接云端服务器
+            isDeviceDrawer: false,
+            deviceInfo: undefined,
+            qrCodeImg: '',
+            isBindSchool: false,
+            schoolInfo: {
+                name: '',
+                code: '',
+            },
+            schoolList: [
+                {id: '111', name: '醍摩豆学校', code: 'hbcn'},
+                {id: '222', name: '研发学校', code: 'habook'},
+                {id: '111', name: '醍摩豆学校', code: 'hbcn'},
+                {id: '222', name: '研发学校', code: 'habook'},
+                {id: '111', name: '醍摩豆学校', code: 'hbcn'},
+                {id: '222', name: '研发学校', code: 'habook'},
+                {id: '111', name: '醍摩豆学校', code: 'hbcn'},
+                {id: '222', name: '研发学校', code: 'habook'},
+                {id: '111', name: '醍摩豆学校', code: 'hbcn'},
+                {id: '222', name: '研发学校', code: 'habook'},
+                {id: '111', name: '醍摩豆学校', code: 'hbcn'},
+                {id: '222', name: '研发学校', code: 'habook'},
+            ],
         }
     },
+    mounted() {
+        this.viewNetworkInfo()
+    },
+    methods: {
+        async viewNetworkInfo() {
+            let params = {
+                fp: await this.$tools.getFingerprint()
+            }
+            this.$api.getDevice(params).then(res => {
+                res.data.server.host = []
+                res.data.server.uris.forEach(item => {
+                    res.data.server.networks.forEach(network => {
+                        // https://192.168.8.140:5001/login/student
+                        let url = `${item.protocol}://${network.ip}:${item.port}/login/student`
+                        res.data.server.host.push(url)
+                    })
+                })
+                res.data.server.shwoRam = (res.data.server.ram / 1024 / 1024 / 1024).toFixed(1)
+                res.data.server.cpuInfos.forEach(item => {
+                    item.showHZ = item.hz ? (item.hz / 1000) : 0
+                });
+                if(!res.data.school) this.isBindSchool = true
+                this.deviceInfo = res.data
+                this.hybridType = res.data?.hybrid
+            })
+        },
+        showDevice() {
+            // 打开弹窗,显示详细信息
+            this.isDeviceDrawer = true
+        },
+        copyUrl(url) {
+            navigator.clipboard.writeText(url).then(() => {
+                this.$message({
+                    message: '文本已复制到剪切板',
+                    type: 'success'
+                });
+            }).catch(err => {
+                console.error('无法将文本复制到剪切板:', err);
+            });
+        },
+        getCode(type) {
+            let params = {
+                type: type,
+            }
+            if(type === 'smspin') {
+                params.area = '86'
+                params.to = this.loginForm.phone
+            }
+            this.$api.getCode(params).then(res => {
+                if(res.code === 200) {
+                    if(type === 'smspin' && res.send === 1) {
+                        this.$message({
+                            message: '验证码已发送',
+                            type: 'success'
+                        });
+                    } else {
+                        this.qrCodeImg = res.qrcode
+                    }
+                }
+            })
+        },
+        changeLoginType() {
+            this.isQRCode = !this.isQRCode
+            if(this.isQRCode) this.getCode('qrcode')
+        },
+        toLogin(type) {
+            let params = {
+                type: type,
+            }
+            if(type === 'smspin') {
+                params.pin_code = this.loginForm.smspin
+                params.account = `+86-${this.loginForm.phone}`
+            } else {
+                params.randomCode = ''
+            }
+            this.$api.loginCheck(params).then(res => {
+                if(res.code === 200) {
+                    localStorage.setItem('auth_token', res.x_auth_token)
+                    /* localStorage.setItem('access_token', res.implicit_token.access_token)
+                    localStorage.setItem('id_token', res.implicit_token.id_token)
+                    localStorage.setItem('expires_in', res.implicit_token.expires_in) */
+                    this.$router.push({path: '/admin'})
+                }
+            })
+        },
+    }
 }
 </script>
 
@@ -121,19 +298,32 @@ export default {
                         }
                     }
                 }
-            }
-        }
-    }
-                .qr-code {
+                
+                .network-info {
                     position: absolute;
-                    top: 20px;
-                    right: 20px;
-                    cursor: pointer;
+                    bottom: -10%;
+                    right: 0;
+                    width: 100%;
+                    text-align: center;
+                    margin: auto;
 
-                    img {
-                        width: 75px;
+                    span {
+                        cursor: pointer;
                     }
                 }
+            }
+        }
+    }
+    .qr-code {
+        position: absolute;
+        top: 20px;
+        right: 20px;
+        cursor: pointer;
+
+        img {
+            width: 75px;
+        }
+    }
 }
 .login-footer {
     width: 100%;
@@ -151,6 +341,34 @@ export default {
         font-size: 12px;
     }
 }
+
+@media screen and (max-width: 1280px) {
+    .login .login-body .login-box {
+        .body-left {
+            display: none;
+        }
+        .body-right {
+            width: 100%;
+        }
+    }
+}
+
+@media screen and (max-width: 768px) {
+    .login .login-body .login-box {
+        width: 100%;
+        height: 100%;
+        margin-top: auto;
+        border-radius: 0;
+
+        .body-right {
+            margin: 6% 25px;
+
+            .network-info {
+                bottom: 1%;
+            }
+        }
+    }
+}
 </style>
 
 <style lang="less">
@@ -162,5 +380,14 @@ export default {
         background-color: #ededed;
         border: none;
     }
-} 
+    .el-drawer {
+        min-width: 600px;
+    }
+}
+@media screen and (max-width: 768px) {
+    .login .el-drawer {
+        min-width: auto;
+        width: 100% !important;
+    }
+}
 </style>

+ 306 - 3
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/view/student/ActivityAnswer.vue

@@ -9,14 +9,313 @@
             </span>
         </el-header>
         <el-main>
-            <div class="question-content">作答区</div>
-            <div class="answer-sheet">答题卡</div>
+            <!-- 附件展示 -->
+            <div v-if="instantPaper">
+                <div v-for="(img, index) in imgList" :key="index">
+                    <img :src="img" alt="">
+                </div>
+            </div>
+            <div class="question-content">
+                <div v-if="!showExam.length">暂没有试题</div>
+                <template v-else>
+                    <!-- 整卷作答 -->
+                    <div v-if="instantPaper || entireExam">
+                        <div v-for="(item, index) in showExam" :key="index" ref="questionBox">
+                            <div v-if="instantPaper">
+                                <div>
+                                    <span v-if="!['single', 'multiple', 'judge'].includes(item.type)">{{ index + 1 }}. </span>
+                                    <div v-if="item.parent === undefined && (index === 0 ? true :
+                                            ((item.type === 'subjective' && showExam[index-1].type === 'subjective') ?
+                                            item.answerType != showExam[index-1].answerType : item.type != showExam[index-1].type))"
+                                    >
+                                        <span>{{ item.typeName }}(处理数据,给类型赋名)</span>
+                                        <span v-if="item.type === 'subjective' && item.answerType">-{{ item.answerType }}(此处也要处理)</span>
+                                    </div>
+                                </div>
+                            </div>
+                            <div v-else>
+                                <div v-if="item.parent === undefined">
+                                    <span>{{ item.typeName }}</span>
+                                    <span v-if="item.type === 'subjective' && showExam[index].answerType">-{{ showExam[index].answerType }}</span>
+                                </div>
+                                <div class="que-item" v-if="item.type != 'compose' && item.parent === undefined">
+                                    <span>{{ index + 1 }}.</span>
+                                    <div id="answer-box" v-html="item.question"></div>
+                                </div>
+                                <!-- 综合题 -->
+                                <div v-if="item.type === 'compose' || item.parent !== undefined">
+                                    <div class="compose-content" v-if="item.parent !== undefined">
+                                        <div class="question-type" v-show="entireExam ? item.pid != showExam[index-1].pid : true">
+                                            <span>{{ item.parentInfo.typeName }}</span>
+                                            <span v-if="item.type === 'subjective' && showExam[index].answerType">({{ showExam[index].answerType }})</span>
+                                        </div>
+                                        <div class="compose-box" v-show="entireExam ? item.pid != showExam[index-1].pid : true">
+                                            <span style="margin-left:10px;font-weight:800;font-size:15px">题目:</span>
+                                            <br />
+                                            <div class="compose-item">
+                                                <div v-html="item.parentInfo.question"></div>
+                                            </div>
+                                        </div>
+                                        <div class="que-content">
+                                            <span style="width:51px">{{ item.paperIndex }}:</span>
+                                            <div class="que-items" id="answer-box" v-html="item.question"></div>
+                                        </div>
+                                    </div>
+                                </div>
+                            </div>
+                            <div>
+                                <div class="questionNo" v-if="!instantPaper">我的作答:</div>
+                                <div>
+                                    <!--判断题选项-->
+                                    <div v-if="item.type == 'judge'" align="center">
+                                        <span style="font-weight: 900;" v-if="instantPaper">{{ index + 1 }}. </span>
+                                        <label class="testBtn yesNoBtn">
+                                            <input type="radio" value="A" v-model="checkers[index][0]" :disabled="!closeTest" />
+                                            <div class="testbg" :style="{'background-color': isWrong && showEnd && checkers[index][0] === 'A' ? (item.answer[0] === 'A' ? '' : 'red !important') : ''}">
+                                                <i class="el-icon-gou1"></i>
+                                            </div>
+                                        </label>
+                                        <label class="testBtn yesNoBtn">
+                                            <input type="radio" value="B" v-model="checkers[index][0]" :disabled="!closeTest" />
+                                            <div class="testbg" :style="{'background-color': isWrong && showEnd && checkers[index][0] === 'B' ? (item.answer[0] === 'B' ? '' : 'red !important') : ''}">
+                                                <i class="el-icon-chacha"></i>
+                                            </div>
+                                        </label>
+                                    </div>
+                                    <!--选择题选项-->
+                                    <div class="select-box" v-else-if="item.type === 'single' || item.type === 'multiple'">
+                                        <span style="font-weight: 900;" v-if="instantPaper">{{ index + 1 }}. </span>
+                                        <label class="testBtn" v-for="(option, oIndex) in item.option" :key="oIndex">
+                                            <input type="checkbox" :value="option.code" v-model="checkers[index]" @click="getAns(index, oIndex)" :disabled="!closeTest" />
+                                            <div class="testbg">
+                                                <div style="display:flex">
+                                                    <span>{{ option.code }}</span>
+                                                    <div v-html="option.value" @click.stop.native.prevent="showImg($event)"></div>
+                                                    <span v-show="showEnd" style="margin-left: 5px;">
+                                                        <Icon type="md-checkmark-circle" color="#44b5f9" size="20" v-if="item.answer.includes(option.code)" />
+                                                        <Icon type="md-close-circle" color="red" size="20" v-if="checkers[index].includes(option.code) && !item.answer.includes(option.code)" />
+                                                    </span>
+                                                </div>
+                                                <div style="clear:both"></div>
+                                            </div>
+                                        </label>
+                                    </div>
+                                    <!--问答题-->
+                                    <div class="compose-content" v-else-if="item.type === 'subjective'">
+                                        <Compose v-if="item.answerType === 'text' || item.answerType === 'text_Image'" ref="compose" :itemInfo="item" :close="!closeTest" :textData="checkers[index]" :index="index" @dataGet="getComposeAns"></Compose>
+                                        <template v-else-if="item.answerType === 'audio'">
+                                            <AudioRecorder :textData="checkers[index]" :index="index" @dataGet="getComposeAns" />
+                                        </template>
+                                        <template v-else-if="item.answerType === 'file' || item.answerType === 'image'">
+                                            <Upload type="drag" :accept="subjectiveAccept(item)" action="" :before-upload="file => customUpload(file, index)">
+                                                <div style="padding: 20px 0" ref="upload1">
+                                                    <Icon type="ios-cloud-upload" size="52" :style="{'color': checkers[index].length ? '#b4b4b4' : '#2d8cf0'}"></Icon>
+                                                    <p>
+                                                        <span>
+                                                            {{ checkers[index].length ? '重新上传' : '上传相关文件' }}
+                                                        </span>
+                                                    </p>
+                                                </div>
+                                            </Upload>
+                                            <div class="repair-link-wrap-item-box" v-if="checkers[index].length">
+                                                <div class="file-icon">
+                                                    <img :src="$tools.getFileThum(getFileType(checkers[index][0]), getFileName(checkers[index][0]))"/>
+                                                </div>
+                                                <div class="file-info">
+                                                    <p class="file-name">{{ getFileName(checkers[index][0]) }}</p>
+                                                    <div>
+                                                        <span @click="onPreview(checkers[index][0], true)">预览</span>
+                                                        <span @click="onDownload(checkers[index][0], true)">下载</span>
+                                                    </div>
+                                                </div>
+                                            </div>
+                                        </template>
+                                        <div class="compose-content" v-else>
+                                            <Compose ref="compose" :itemInfo="item" :close="!closeTest" :textData="checkers[index]" :index="index" @dataGet="getComposeAns"></Compose>
+                                        </div>
+                                    </div>
+                                    <div class="compose-content" v-else>
+                                        <Compose ref="compose" :itemInfo="item" :close="!closeTest" :textData="checkers[index]" :index="index" @dataGet="getComposeAns"></Compose>
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                    <!-- 单题作答 -->
+                    <div v-if="!instantPaper && !entireExam">
+                        <div ref="questionBox">
+                            <div>
+                                <div v-if="showQuesInfo.parent === undefined">
+                                    <span>{{ showQuesInfo.typeName }}</span>
+                                    <span v-if="showQuesInfo.type === 'subjective' && showExam[queNo].answerType">-{{ showExam[queNo].answerType }}</span>
+                                </div>
+                                
+                                <div class="que-item" v-if="showQuesInfo.type != 'compose' && showQuesInfo.parent === undefined">
+                                    <span>{{ queNo + 1 }}.</span>
+                                    <div id="answer-box" v-html="showQuesInfo.question"></div>
+                                </div>
+                                <!--综合题-->
+                                <div v-if="showQuesInfo.type === 'compose' || showQuesInfo.parent !== undefined">
+                                    <div class="compose-content" v-if="showQuesInfo.parent !== undefined">
+                                        <div class="questionType">
+                                            <span>{{ showQuesInfo.parentInfo.typeName }}</span>
+                                            <span v-if="showQuesInfo.type === 'subjective' && showExam[queNo].answerType">({{ showExam[queNo].answerType }})</span>
+                                        </div>
+                                        <div class="compose-box">
+                                            <span style="margin-left:10px;font-weight:800;font-size:15px">题目:</span>
+                                            <br />
+                                            <div class="compose-item">
+                                                <div v-html="showQuesInfo.parentInfo.question"></div>
+                                            </div>
+                                        </div>
+                                        <div class="que-content">
+                                            <span style="width:51px">{{ showQuesInfo.paperIndex }}:</span>
+                                            <div class="que-items" id="answer-box" v-html="showQuesInfo.question"></div>
+                                        </div>
+                                    </div>
+                                </div>
+                            </div>
+                            <div class="answers">
+                                <div class="questionNo">我的作答:</div>
+                                <div class="answers-box">
+                                    <!--判断题选项-->
+                                    <div v-if="showQuesInfo.type == 'judge'" align="center">
+                                        <label class="testBtn yesNoBtn">
+                                            <input type="radio" value="A" v-model="checkers[queNo][0]" :disabled="!closeTest" />
+                                            <div class="testbg" :style="{'background-color': isWrong && showEnd && checkers[queNo][0] === 'A' ? (showQuesInfo.answer[0] === 'A' ? '' : 'red !important') : ''}">
+                                                <!-- <Icon type="ios-radio-button-off" /> -->
+                                                <Icon type="md-checkmark" />
+                                            </div>
+                                        </label>
+                                        <label class="testBtn yesNoBtn">
+                                            <input type="radio" value="B" v-model="checkers[queNo][0]" :disabled="!closeTest" />
+                                            <div class="testbg" :style="{'background-color': isWrong && showEnd && checkers[queNo][0] === 'B' ? (showQuesInfo.answer[0] === 'B' ? '' : 'red !important') : ''}">
+                                                <Icon type="md-close" />
+                                            </div>
+                                        </label>
+                                    </div>
+                                    <!--选择题选项-->
+                                    <div class="select-box" v-else-if="showQuesInfo.type === 'single' || showQuesInfo.type === 'multiple'">
+                                        <label class="testBtn" v-for="(item, index) in showQuesInfo.option" :key="index">
+                                            <span v-show="showEnd">
+                                                <Icon type="md-checkmark-circle" color="#24B880" size="20"
+                                                    v-if="showQuesInfo.answer.includes(item.code)"
+                                                />
+                                                <Icon type="md-close-circle" color="red" size="20"
+                                                    v-if="checkers[queNo].includes(item.code) && !showQuesInfo.answer.includes(item.code)"
+                                                />
+                                            </span>
+                                            <input type="checkbox"
+                                                :value="showQuesInfo.option[index].code"
+                                                v-model="checkers[queNo]"
+                                                @click="getAns(queNo,index)"
+                                                :disabled="!closeTest" />
+                                            <div class="testbg">
+                                                <div style="display:flex">
+                                                    <span>{{ showQuesInfo.option[index].code }}. </span>
+                                                    <div v-html="item.value" @click.stop.native.prevent="showImg($event)"></div>
+                                                </div>
+                                                <div style="clear:both"></div>
+                                            </div>
+                                        </label>
+                                    </div>
+                                    <!--问答题-->
+                                    <div class="compose-content" v-else-if="showQuesInfo.type === 'subjective'">
+                                        <Compose v-if="showExam[queNo].answerType === 'text' || showExam[queNo].answerType === 'text_Image'" ref="compose" :itemInfo="showQuesInfo" :close="!closeTest" :textData="checkers[queNo]" :index="queNo" @dataGet="getComposeAns"></Compose>
+                                        <template v-else-if="showExam[queNo].answerType === 'audio'">
+                                            <AudioRecorder :textData="checkers[queNo]" :index="queNo" @dataGet="getComposeAns" />
+                                        </template>
+                                        <template v-else-if="showExam[queNo].answerType === 'file' || showExam[queNo].answerType === 'image'">
+                                            <Upload type="drag" :accept="subjectiveAccept(showExam[queNo])" action="" :before-upload="file => customUpload(file, queNo)">
+                                                <div style="padding: 20px 0" ref="upload1">
+                                                    <Icon type="ios-cloud-upload" size="52" :style="{'color': checkers[queNo].length ? '#b4b4b4' : '#2d8cf0' }"></Icon>
+                                                    <p>
+                                                        <span>
+                                                            {{ checkers[queNo].length ? '重新上传' : '上传相关文件'}}
+                                                        </span>
+                                                    </p>
+                                                </div>
+                                            </Upload>
+                                            <div class="repair-link-wrap-item-box" v-if="checkers[queNo].length">
+                                                <div class="file-icon">
+                                                    <img :src="$tools.getFileThum(getFileType(checkers[queNo][0]), getFileName(checkers[queNo][0]))"/>
+                                                </div>
+                                                <div class="file-info">
+                                                    <p class="file-name">{{ getFileName(checkers[queNo][0]) }}</p>
+                                                    <div>
+                                                        <span @click="onPreview(checkers[queNo][0], true)">{{ $t('ability.review.preview')}}</span>
+                                                        <span @click="onDownload(checkers[queNo][0], true)">{{ $t('ability.review.download')}}</span>
+                                                    </div>
+                                                </div>
+                                            </div>
+                                        </template>
+                                        <div class="compose-content" v-else>
+                                            <Compose ref="compose" :itemInfo="showQuesInfo" :close="!closeTest" :textData="checkers[queNo]" :index="queNo" @dataGet="getComposeAns"></Compose>
+                                        </div>
+                                    </div>
+                                    <div class="compose-content" v-else>
+                                        <Compose ref="compose" :itemInfo="showQuesInfo" :close="!closeTest" :textData="checkers[queNo]" :index="queNo" @dataGet="getComposeAns"></Compose>
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </template>
+            </div>
+            <div class="answer-sheet">
+                <div v-if="needCountDown">
+                    <span>作答时间:{{ surplus }}</span>
+                </div>
+                <div v-if="widthLimit">
+                    <div>
+                        <div>
+                            <span>我的答题卡</span>
+                            <el-switch style="display: block" v-model="entireExam" active-color="#13ce66"
+                                active-text="整卷作答" inactive-text="单题作答">
+                            </el-switch>
+                        </div>
+                        <div>
+                            <div v-if="showExam.length">
+                                <div>
+                                    <span style="margin-right: 10px;">
+                                        <el-button type="info" size="mini" plain>
+                                            <i class="el-icon-arrow-left"></i>
+                                            上一题
+                                        </el-button>
+                                    </span>
+                                    <span>
+                                        <el-button type="info" size="mini" plain>
+                                            下一题
+                                            <i class="el-icon-arrow-right"></i>
+                                        </el-button>
+                                    </span>
+                                </div>
+                                <p>题号:</p>
+                                <span v-for="(item, index) in showExam" :key="index" style="padding:5px 5px;cursor:pointer">
+                                    {{ item.parent === undefined ? index + 1 : item.paperIndex }}
+                                </span>
+                                <p>
+                                    <el-button type="info" size="mini" plain>信息按钮</el-button>
+                                </p>
+                            </div>
+                            <div v-else>暂没有试题</div>
+                        </div>
+                    </div>
+                </div>
+            </div>
         </el-main>
+        <div class="top-icon" @click="gotoTop" v-if="instantPaper || entireExam">
+            <i class="el-icon-arrow-up" style="font-size: 30px; color: #249e35;"></i>
+        </div>
     </el-container>
 </template>
 
 <script>
+import AudioRecorder from './AudioRecorder.vue';
 export default {
+    components: {
+        AudioRecorder
+    },
     data() {
         return {
             loading: false,
@@ -24,9 +323,13 @@ export default {
                 name: '艺术评测'
             },
             needCountDown: true,
-            showExam: [{}],
+            showExam: [{}, {}, {}],
             surplus: "", //倒计时的字符
             diffSeconds: 0, //秒数
+            entireExam: false,
+            widthLimit: true,
+            instantPaper: false,
+            showQuesInfo: undefined,
         }
     },
     methods: {

+ 197 - 0
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/src/view/student/AudioRecorder.vue

@@ -0,0 +1,197 @@
+<template>
+    <div>
+        <div class="audio-box" v-if="audioType === 2">
+            <audio controls :key="audioKey" :id="`audioId${audioId}`">
+                <source :src="fileFullPath">
+                {{ $t('teachContent.notAudio') }}
+            </audio>
+            <Icon custom="iconfont icon-shuaxin1" @click="startRecorder(true)" />
+        </div>
+        <div class="audio-recorder">
+            <p v-show="audioType != 2">{{ stuDuration }}</p>
+            <Icon custom="iconfont icon-luyin" v-show="!audioType" @click="startRecorder()" />
+            <Icon custom="iconfont icon-luyin-zanting" v-show="audioType === 1" @click="stopRecorder()" />
+            <!-- <Icon custom="iconfont icon-start" v-show="audioType === 2" /> -->
+        </div>
+    </div>
+</template>
+
+<script>
+import Recorder from 'js-audio-recorder'
+import BlobTool from '@/utils/blobTool.js'
+// import { mapGetters } from 'vuex'
+export default {
+    props: {
+        index: {
+            type: Number,
+            default: -1,
+        },
+        textData: {
+            type: Array,
+            default: () => {
+                return []
+            },
+        },
+    },
+    data () {
+        return {
+            recorder: undefined,
+            audioType: 0, //未开始(0) 进行中(1) 停止(2)
+            currentUrl: null,
+            answerUrl: [],
+            fileFullPath: '',
+            sasData: undefined,
+            audioId: '',
+            audioKey: '',
+        }
+    },
+    created () {
+        this.audioId = this.$jsFn.getBtwRandom(0, 100000000)
+        this.recorder = new Recorder({
+            sampleBits: 16,
+            sampleRate: 16000,
+            numChannels: 1,
+            // compiling: false,
+        })
+    },
+    async mounted () {
+        let { scope, cntr } = this.getComposeData
+        // getSchoolSas有加?
+        this.sasData = scope === 'school' ? await this.$tools.getSchoolSas(cntr) : await this.$api.blob.blobSasRCW({ name: cntr, role: 'teacher' })
+        this.sasData.sas = scope === 'school' ? this.sasData.sas : ('?' + this.sasData.sas)
+        this.getAnsInfo()
+    },
+    computed: {
+        /* ...mapGetters([
+            "getComposeData",
+        ]), */
+        stuDuration() {
+            if(this.recorder?.duration) {
+                // 分钟
+                let minutes = Math.floor(this.recorder.duration / 60) % 60
+                minutes = minutes >= 10 ? minutes : ("0" + minutes)
+                // 秒数
+                let seconds = Math.floor(this.recorder.duration % 60)
+                seconds = seconds >= 10 ? seconds : ("0" + seconds)
+                return `${minutes}:${seconds}`
+            } else {
+                return '00:00'
+            }
+        },
+    },
+    watch: {
+        index() {
+            this.recorder = undefined
+            this.recorder = new Recorder({
+                sampleBits: 16,
+                sampleRate: 16000,
+                numChannels: 1,
+                // compiling: false,
+            })
+            this.getAnsInfo()
+            deep: true
+            immediate: true
+        }
+    },
+    methods: {
+        getAnsInfo() {
+            this.audioType = 0
+            this.answerUrl = []
+            this.currentUrl = null
+            this.fileFullPath = ''
+            this.audioKey = this.$jsFn.getBtwRandom(0, 10000000000)
+            if(this.textData.length) {
+                let { cntr } = this.getComposeData
+                this.answerUrl = [...this.textData]
+                this.currentUrl = this.textData[0]
+                this.fileFullPath = `${this.sasData.url}/${cntr}${this.currentUrl}${this.sasData.sas}`
+                this.audioType = 2
+            }
+        },
+        startRecorder(isRefresh) {
+            // return
+            Recorder.getPermission().then(() => {
+                if(isRefresh) {
+                    /* this.fileFullPath = ''
+                    document.getElementById(`audioId${this.audioId}`).setAttribute('src', this.fileFullPath) */
+                }
+                this.recorder.start()
+                this.audioType = 1
+            }, error => {
+                console.log('报错:', error);
+                this.$Message.warning('请允许网页使用麦克风')
+            })
+        },
+        playRecorder() {
+            this.recorder.play()
+        },
+        async stopRecorder() {
+            this.recorder.stop()
+            if(!this.recorder || !this.recorder.duration) {
+                this.$Message.warning('请先录音')
+                return
+            }
+            this.audioType = 2
+            // return
+            // this.recorder.downloadWAV() //下载文件
+            // 停止录音后马上上传到blob
+            const blob = this.recorder.getWAVBlob()
+            const newBlob = new Blob([blob], {type: 'audio/wav'})
+            const fileBlob = new File([newBlob], `audio${this.index + 1}.wav`,{type: 'audio/wav'})
+            
+            let { scope, cntr, examId, subjectId, stuId } = this.getComposeData
+            let sas = '?' + this.sasData.sas
+            let containerClient = new BlobTool(this.sasData.url, this.sasData.name, this.sasData.sas, scope)
+            let path = `exam/${examId}/${subjectId}/${stuId}`
+            let that = this
+            containerClient.upload(fileBlob, {
+                path,
+                checkSize: false
+            }).then(res => {
+                that.currentUrl = res.blob
+                let random = this.$jsFn.getBtwRandom(0, 1000000)
+                that.fileFullPath = res.url + this.sasData.sas + '&t=' + random
+                document.getElementById(`audioId${that.audioId}`).setAttribute('src', that.fileFullPath)
+                that.$emit("dataGet", that.currentUrl, that.index)
+                that.$Message.warning(that.$t('cusMgt.saveOk'))
+            }).catch(err => {
+                console.log(err);
+                that.$Message.warning(that.$t('cusMgt.saveErr'))
+                that.fileFullPath = `${that.sasData.url}/${cntr}${that.currentUrl}${this.sasData.sas}`
+            })
+        },
+    }
+}
+</script>
+
+<style lang="less" scoped>
+.audio-recorder {
+    text-align: center;
+    width: 10rem;
+
+    &>p {
+        font-size: 30px;
+    }
+
+    .ivu-icon {
+        font-size: 10rem;
+        cursor: pointer;
+        color: #65af5b;
+    }
+}
+.audio-box {
+    display: flex;
+    align-items: center;
+
+    .ivu-icon {
+        margin-left: 10px;
+        font-size: 3rem;
+        cursor: pointer;
+        color: #888888;
+        &:hover {
+            color: #65af5b;
+        }
+    }
+}
+
+</style>

+ 5 - 1
TEAMModelOS.Extension/IES.Exam/IES.ExamServer/ClientApp/vue.config.js

@@ -11,5 +11,9 @@ module.exports = defineConfig({
 	outputDir: '../wwwroot',
 	lintOnSave: false,
 	//transpileDependencies: ['@azure'],
-	 
+	/* devServer: { //局域网内可以使用
+		host: '0.0.0.0', //允许所有IP访问
+		port: 8081,
+		disableHostCheck: true, // 禁用主机检查,允许局域网访问
+	} */
 })