CrazyIter_Bin 1 year ago
parent
commit
1968776b12
71 changed files with 6563 additions and 0 deletions
  1. 24 0
      TEAMModelContest/Contest.Server/Contest.Server.csproj
  2. 15 0
      TEAMModelContest/Contest.Server/Program.cs
  3. 113 0
      TEAMModelContest/Contest.Server/Properties/ServiceDependencies/contest - Web Deploy/profile.arm.json
  4. 113 0
      TEAMModelContest/Contest.Server/Properties/ServiceDependencies/contest-test - Web Deploy/profile.arm.json
  5. 113 0
      TEAMModelContest/Contest.Server/Properties/ServiceDependencies/contest-test - Web Deploy1/profile.arm.json
  6. 34 0
      TEAMModelContest/Contest.Server/Properties/launchSettings.json
  7. 8 0
      TEAMModelContest/Contest.Server/appsettings.Development.json
  8. 9 0
      TEAMModelContest/Contest.Server/appsettings.json
  9. 11 0
      TEAMModelContest/contest.client/.eslintrc.cjs
  10. 30 0
      TEAMModelContest/contest.client/.gitignore
  11. 35 0
      TEAMModelContest/contest.client/README.md
  12. 5 0
      TEAMModelContest/contest.client/babel.config.js
  13. 11 0
      TEAMModelContest/contest.client/contest.client.esproj
  14. 13 0
      TEAMModelContest/contest.client/index.html
  15. 8 0
      TEAMModelContest/contest.client/jsconfig.json
  16. 10 0
      TEAMModelContest/contest.client/nuget.config
  17. 55 0
      TEAMModelContest/contest.client/package.json
  18. BIN
      TEAMModelContest/contest.client/public/favicon.ico
  19. 18 0
      TEAMModelContest/contest.client/public/index.html
  20. 48 0
      TEAMModelContest/contest.client/public/reset.css
  21. 27 0
      TEAMModelContest/contest.client/src/App.vue
  22. 296 0
      TEAMModelContest/contest.client/src/api/http.js
  23. 39 0
      TEAMModelContest/contest.client/src/api/index.js
  24. BIN
      TEAMModelContest/contest.client/src/assets/img/events.jpg
  25. BIN
      TEAMModelContest/contest.client/src/assets/img/fengj.jpg
  26. BIN
      TEAMModelContest/contest.client/src/assets/img/fengjing.jpg
  27. BIN
      TEAMModelContest/contest.client/src/assets/img/no-poster-cn.jpg
  28. BIN
      TEAMModelContest/contest.client/src/assets/img/no-poster-cn.png
  29. BIN
      TEAMModelContest/contest.client/src/assets/img/noData.png
  30. BIN
      TEAMModelContest/contest.client/src/assets/img/noData1.jpg
  31. BIN
      TEAMModelContest/contest.client/src/assets/img/zhProcess.png
  32. BIN
      TEAMModelContest/contest.client/src/assets/logo.png
  33. BIN
      TEAMModelContest/contest.client/src/assets/source/audio.png
  34. BIN
      TEAMModelContest/contest.client/src/assets/source/excel.png
  35. BIN
      TEAMModelContest/contest.client/src/assets/source/folder.png
  36. BIN
      TEAMModelContest/contest.client/src/assets/source/image.png
  37. BIN
      TEAMModelContest/contest.client/src/assets/source/item.png
  38. BIN
      TEAMModelContest/contest.client/src/assets/source/link.png
  39. BIN
      TEAMModelContest/contest.client/src/assets/source/pdf.png
  40. BIN
      TEAMModelContest/contest.client/src/assets/source/ppt.png
  41. BIN
      TEAMModelContest/contest.client/src/assets/source/unknow.png
  42. BIN
      TEAMModelContest/contest.client/src/assets/source/video.png
  43. BIN
      TEAMModelContest/contest.client/src/assets/source/word.png
  44. BIN
      TEAMModelContest/contest.client/src/assets/source/zip.png
  45. 30 0
      TEAMModelContest/contest.client/src/common/Loading.vue
  46. 58 0
      TEAMModelContest/contest.client/src/components/HelloWorld.vue
  47. 39 0
      TEAMModelContest/contest.client/src/main.js
  48. 43 0
      TEAMModelContest/contest.client/src/pinia/common.js
  49. 5 0
      TEAMModelContest/contest.client/src/pinia/index.js
  50. 113 0
      TEAMModelContest/contest.client/src/router/index.js
  51. 23 0
      TEAMModelContest/contest.client/src/store/index.js
  52. 7 0
      TEAMModelContest/contest.client/src/store/module/config.js
  53. 15 0
      TEAMModelContest/contest.client/src/utils/Global.js
  54. 553 0
      TEAMModelContest/contest.client/src/utils/blobTool.js
  55. 116 0
      TEAMModelContest/contest.client/src/utils/common.js
  56. 47 0
      TEAMModelContest/contest.client/src/utils/directive.js
  57. 332 0
      TEAMModelContest/contest.client/src/view/Home.vue
  58. 153 0
      TEAMModelContest/contest.client/src/view/Login.vue
  59. 220 0
      TEAMModelContest/contest.client/src/view/activitylist/ActivityInfo.less
  60. 630 0
      TEAMModelContest/contest.client/src/view/activitylist/ActivityInfo.vue
  61. 116 0
      TEAMModelContest/contest.client/src/view/activitylist/ActivityList.less
  62. 147 0
      TEAMModelContest/contest.client/src/view/activitylist/ActivityList.vue
  63. 277 0
      TEAMModelContest/contest.client/src/view/activitylist/united.json
  64. 119 0
      TEAMModelContest/contest.client/src/view/basicInfo/BasicInfo.vue
  65. 336 0
      TEAMModelContest/contest.client/src/view/homepage/HomePage.vue
  66. 355 0
      TEAMModelContest/contest.client/src/view/myactivity/MyActivity.less
  67. 1048 0
      TEAMModelContest/contest.client/src/view/myactivity/MyActivity.vue
  68. 222 0
      TEAMModelContest/contest.client/src/view/myactivity/MyReview.less
  69. 490 0
      TEAMModelContest/contest.client/src/view/myactivity/MyReview.vue
  70. 16 0
      TEAMModelContest/contest.client/vue.config.js
  71. 18 0
      TEAMModelOS.sln

+ 24 - 0
TEAMModelContest/Contest.Server/Contest.Server.csproj

@@ -0,0 +1,24 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+  <PropertyGroup>
+    <TargetFramework>net6.0</TargetFramework>
+    <Nullable>enable</Nullable>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <SpaRoot>..\contest.client</SpaRoot>
+    <SpaProxyLaunchCommand>npm run dev</SpaProxyLaunchCommand>
+    <SpaProxyServerUrl>https://localhost:5173</SpaProxyServerUrl>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\contest.client\contest.client.esproj">
+      <ReferenceOutputAssembly>false</ReferenceOutputAssembly>
+    </ProjectReference>
+  </ItemGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Microsoft.AspNetCore.SpaProxy">
+      <Version>6.*-*</Version>
+    </PackageReference>
+  </ItemGroup>
+
+</Project>

+ 15 - 0
TEAMModelContest/Contest.Server/Program.cs

@@ -0,0 +1,15 @@
+var builder = WebApplication.CreateBuilder(args);
+
+// Add services to the container.
+
+var app = builder.Build();
+
+app.UseDefaultFiles();
+app.UseStaticFiles();
+
+// Configure the HTTP request pipeline.
+app.UseHttpsRedirection();
+app.MapFallbackToFile("/index.html");
+app.Run();
+
+ 

+ 113 - 0
TEAMModelContest/Contest.Server/Properties/ServiceDependencies/contest - Web Deploy/profile.arm.json

@@ -0,0 +1,113 @@
+{
+  "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#",
+  "contentVersion": "1.0.0.0",
+  "metadata": {
+    "_dependencyType": "compute.appService.windows"
+  },
+  "parameters": {
+    "resourceGroupName": {
+      "type": "string",
+      "defaultValue": "TEAMModelChengdu",
+      "metadata": {
+        "description": "Name of the resource group for the resource. It is recommended to put resources under same resource group for better tracking."
+      }
+    },
+    "resourceGroupLocation": {
+      "type": "string",
+      "defaultValue": "",
+      "metadata": {
+        "description": "Location of the resource group. Resource groups could have different location than resources, however by default we use API versions from latest hybrid profile which support all locations for resource types we support."
+      }
+    },
+    "resourceName": {
+      "type": "string",
+      "defaultValue": "contest",
+      "metadata": {
+        "description": "Name of the main resource to be created by this template."
+      }
+    },
+    "resourceLocation": {
+      "type": "string",
+      "defaultValue": "[parameters('resourceGroupLocation')]",
+      "metadata": {
+        "description": "Location of the resource. By default use resource group's location, unless the resource provider is not supported there."
+      }
+    }
+  },
+  "variables": {
+    "appServicePlan_name": "[concat('Plan', uniqueString(concat(parameters('resourceName'), subscription().subscriptionId)))]",
+    "appServicePlan_ResourceId": "[concat('/subscriptions/', subscription().subscriptionId, '/resourceGroups/', parameters('resourceGroupName'), '/providers/Microsoft.Web/serverFarms/', variables('appServicePlan_name'))]"
+  },
+  "resources": [
+    {
+      "type": "Microsoft.Resources/resourceGroups",
+      "name": "[parameters('resourceGroupName')]",
+      "location": "[parameters('resourceGroupLocation')]",
+      "apiVersion": "2019-10-01"
+    },
+    {
+      "type": "Microsoft.Resources/deployments",
+      "name": "[concat(parameters('resourceGroupName'), 'Deployment', uniqueString(concat(parameters('resourceName'), subscription().subscriptionId)))]",
+      "resourceGroup": "[parameters('resourceGroupName')]",
+      "apiVersion": "2019-10-01",
+      "dependsOn": [
+        "[parameters('resourceGroupName')]"
+      ],
+      "properties": {
+        "mode": "Incremental",
+        "template": {
+          "$schema": "http://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
+          "contentVersion": "1.0.0.0",
+          "resources": [
+            {
+              "location": "[parameters('resourceLocation')]",
+              "name": "[parameters('resourceName')]",
+              "type": "Microsoft.Web/sites",
+              "apiVersion": "2015-08-01",
+              "tags": {
+                "[concat('hidden-related:', variables('appServicePlan_ResourceId'))]": "empty"
+              },
+              "dependsOn": [
+                "[variables('appServicePlan_ResourceId')]"
+              ],
+              "kind": "app",
+              "properties": {
+                "name": "[parameters('resourceName')]",
+                "kind": "app",
+                "httpsOnly": true,
+                "reserved": false,
+                "serverFarmId": "[variables('appServicePlan_ResourceId')]",
+                "siteConfig": {
+                  "metadata": [
+                    {
+                      "name": "CURRENT_STACK",
+                      "value": "dotnetcore"
+                    }
+                  ]
+                }
+              },
+              "identity": {
+                "type": "SystemAssigned"
+              }
+            },
+            {
+              "location": "[parameters('resourceLocation')]",
+              "name": "[variables('appServicePlan_name')]",
+              "type": "Microsoft.Web/serverFarms",
+              "apiVersion": "2015-08-01",
+              "sku": {
+                "name": "S1",
+                "tier": "Standard",
+                "family": "S",
+                "size": "S1"
+              },
+              "properties": {
+                "name": "[variables('appServicePlan_name')]"
+              }
+            }
+          ]
+        }
+      }
+    }
+  ]
+}

+ 113 - 0
TEAMModelContest/Contest.Server/Properties/ServiceDependencies/contest-test - Web Deploy/profile.arm.json

@@ -0,0 +1,113 @@
+{
+  "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#",
+  "contentVersion": "1.0.0.0",
+  "metadata": {
+    "_dependencyType": "compute.appService.windows"
+  },
+  "parameters": {
+    "resourceGroupName": {
+      "type": "string",
+      "defaultValue": "TEAMModelChengdu",
+      "metadata": {
+        "description": "Name of the resource group for the resource. It is recommended to put resources under same resource group for better tracking."
+      }
+    },
+    "resourceGroupLocation": {
+      "type": "string",
+      "defaultValue": "",
+      "metadata": {
+        "description": "Location of the resource group. Resource groups could have different location than resources, however by default we use API versions from latest hybrid profile which support all locations for resource types we support."
+      }
+    },
+    "resourceName": {
+      "type": "string",
+      "defaultValue": "test",
+      "metadata": {
+        "description": "Name of the main resource to be created by this template."
+      }
+    },
+    "resourceLocation": {
+      "type": "string",
+      "defaultValue": "[parameters('resourceGroupLocation')]",
+      "metadata": {
+        "description": "Location of the resource. By default use resource group's location, unless the resource provider is not supported there."
+      }
+    }
+  },
+  "variables": {
+    "appServicePlan_name": "[concat('Plan', uniqueString(concat(parameters('resourceName'), subscription().subscriptionId)))]",
+    "appServicePlan_ResourceId": "[concat('/subscriptions/', subscription().subscriptionId, '/resourceGroups/', parameters('resourceGroupName'), '/providers/Microsoft.Web/serverFarms/', variables('appServicePlan_name'))]"
+  },
+  "resources": [
+    {
+      "type": "Microsoft.Resources/resourceGroups",
+      "name": "[parameters('resourceGroupName')]",
+      "location": "[parameters('resourceGroupLocation')]",
+      "apiVersion": "2019-10-01"
+    },
+    {
+      "type": "Microsoft.Resources/deployments",
+      "name": "[concat(parameters('resourceGroupName'), 'Deployment', uniqueString(concat(parameters('resourceName'), subscription().subscriptionId)))]",
+      "resourceGroup": "[parameters('resourceGroupName')]",
+      "apiVersion": "2019-10-01",
+      "dependsOn": [
+        "[parameters('resourceGroupName')]"
+      ],
+      "properties": {
+        "mode": "Incremental",
+        "template": {
+          "$schema": "http://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
+          "contentVersion": "1.0.0.0",
+          "resources": [
+            {
+              "location": "[parameters('resourceLocation')]",
+              "name": "[parameters('resourceName')]",
+              "type": "Microsoft.Web/sites",
+              "apiVersion": "2015-08-01",
+              "tags": {
+                "[concat('hidden-related:', variables('appServicePlan_ResourceId'))]": "empty"
+              },
+              "dependsOn": [
+                "[variables('appServicePlan_ResourceId')]"
+              ],
+              "kind": "app",
+              "properties": {
+                "name": "[parameters('resourceName')]",
+                "kind": "app",
+                "httpsOnly": true,
+                "reserved": false,
+                "serverFarmId": "[variables('appServicePlan_ResourceId')]",
+                "siteConfig": {
+                  "metadata": [
+                    {
+                      "name": "CURRENT_STACK",
+                      "value": "dotnetcore"
+                    }
+                  ]
+                }
+              },
+              "identity": {
+                "type": "SystemAssigned"
+              }
+            },
+            {
+              "location": "[parameters('resourceLocation')]",
+              "name": "[variables('appServicePlan_name')]",
+              "type": "Microsoft.Web/serverFarms",
+              "apiVersion": "2015-08-01",
+              "sku": {
+                "name": "S1",
+                "tier": "Standard",
+                "family": "S",
+                "size": "S1"
+              },
+              "properties": {
+                "name": "[variables('appServicePlan_name')]"
+              }
+            }
+          ]
+        }
+      }
+    }
+  ]
+}

+ 113 - 0
TEAMModelContest/Contest.Server/Properties/ServiceDependencies/contest-test - Web Deploy1/profile.arm.json

@@ -0,0 +1,113 @@
+{
+  "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#",
+  "contentVersion": "1.0.0.0",
+  "metadata": {
+    "_dependencyType": "compute.appService.windows"
+  },
+  "parameters": {
+    "resourceGroupName": {
+      "type": "string",
+      "defaultValue": "TEAMModelChengdu",
+      "metadata": {
+        "description": "Name of the resource group for the resource. It is recommended to put resources under same resource group for better tracking."
+      }
+    },
+    "resourceGroupLocation": {
+      "type": "string",
+      "defaultValue": "",
+      "metadata": {
+        "description": "Location of the resource group. Resource groups could have different location than resources, however by default we use API versions from latest hybrid profile which support all locations for resource types we support."
+      }
+    },
+    "resourceName": {
+      "type": "string",
+      "defaultValue": "test",
+      "metadata": {
+        "description": "Name of the main resource to be created by this template."
+      }
+    },
+    "resourceLocation": {
+      "type": "string",
+      "defaultValue": "[parameters('resourceGroupLocation')]",
+      "metadata": {
+        "description": "Location of the resource. By default use resource group's location, unless the resource provider is not supported there."
+      }
+    }
+  },
+  "variables": {
+    "appServicePlan_name": "[concat('Plan', uniqueString(concat(parameters('resourceName'), subscription().subscriptionId)))]",
+    "appServicePlan_ResourceId": "[concat('/subscriptions/', subscription().subscriptionId, '/resourceGroups/', parameters('resourceGroupName'), '/providers/Microsoft.Web/serverFarms/', variables('appServicePlan_name'))]"
+  },
+  "resources": [
+    {
+      "type": "Microsoft.Resources/resourceGroups",
+      "name": "[parameters('resourceGroupName')]",
+      "location": "[parameters('resourceGroupLocation')]",
+      "apiVersion": "2019-10-01"
+    },
+    {
+      "type": "Microsoft.Resources/deployments",
+      "name": "[concat(parameters('resourceGroupName'), 'Deployment', uniqueString(concat(parameters('resourceName'), subscription().subscriptionId)))]",
+      "resourceGroup": "[parameters('resourceGroupName')]",
+      "apiVersion": "2019-10-01",
+      "dependsOn": [
+        "[parameters('resourceGroupName')]"
+      ],
+      "properties": {
+        "mode": "Incremental",
+        "template": {
+          "$schema": "http://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
+          "contentVersion": "1.0.0.0",
+          "resources": [
+            {
+              "location": "[parameters('resourceLocation')]",
+              "name": "[parameters('resourceName')]",
+              "type": "Microsoft.Web/sites",
+              "apiVersion": "2015-08-01",
+              "tags": {
+                "[concat('hidden-related:', variables('appServicePlan_ResourceId'))]": "empty"
+              },
+              "dependsOn": [
+                "[variables('appServicePlan_ResourceId')]"
+              ],
+              "kind": "app",
+              "properties": {
+                "name": "[parameters('resourceName')]",
+                "kind": "app",
+                "httpsOnly": true,
+                "reserved": false,
+                "serverFarmId": "[variables('appServicePlan_ResourceId')]",
+                "siteConfig": {
+                  "metadata": [
+                    {
+                      "name": "CURRENT_STACK",
+                      "value": "dotnetcore"
+                    }
+                  ]
+                }
+              },
+              "identity": {
+                "type": "SystemAssigned"
+              }
+            },
+            {
+              "location": "[parameters('resourceLocation')]",
+              "name": "[variables('appServicePlan_name')]",
+              "type": "Microsoft.Web/serverFarms",
+              "apiVersion": "2015-08-01",
+              "sku": {
+                "name": "S1",
+                "tier": "Standard",
+                "family": "S",
+                "size": "S1"
+              },
+              "properties": {
+                "name": "[variables('appServicePlan_name')]"
+              }
+            }
+          ]
+        }
+      }
+    }
+  ]
+}

+ 34 - 0
TEAMModelContest/Contest.Server/Properties/launchSettings.json

@@ -0,0 +1,34 @@
+{
+  "$schema": "https://json.schemastore.org/launchsettings.json",
+  "iisSettings": {
+    "windowsAuthentication": false,
+    "anonymousAuthentication": true,
+    "iisExpress": {
+      "applicationUrl": "http://localhost:7509",
+      "sslPort": 44333
+    }
+  },
+  "profiles": {
+    "Contest.Server": {
+      "commandName": "Project",
+      "dotnetRunMessages": true,
+      "launchBrowser": true,
+      "launchUrl": "weatherforecast",
+      "applicationUrl": "https://localhost:7024;http://localhost:5031",
+      "environmentVariables": {
+        "ASPNETCORE_ENVIRONMENT": "Development",
+        "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "Microsoft.AspNetCore.SpaProxy"
+      }
+    },
+    "IIS Express": {
+      "commandName": "IISExpress",
+      "launchBrowser": true,
+      "launchUrl": "weatherforecast",
+      "environmentVariables": {
+        "ASPNETCORE_ENVIRONMENT": "Development",
+        "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "Microsoft.AspNetCore.SpaProxy"
+      }
+    }
+  }
+}
+

+ 8 - 0
TEAMModelContest/Contest.Server/appsettings.Development.json

@@ -0,0 +1,8 @@
+{
+  "Logging": {
+    "LogLevel": {
+      "Default": "Information",
+      "Microsoft.AspNetCore": "Warning"
+    }
+  }
+}

+ 9 - 0
TEAMModelContest/Contest.Server/appsettings.json

@@ -0,0 +1,9 @@
+{
+  "Logging": {
+    "LogLevel": {
+      "Default": "Information",
+      "Microsoft.AspNetCore": "Warning"
+    }
+  },
+  "AllowedHosts": "*"
+}

+ 11 - 0
TEAMModelContest/contest.client/.eslintrc.cjs

@@ -0,0 +1,11 @@
+/* eslint-env node */
+module.exports = {
+  root: true,
+  'extends': [
+    'plugin:vue/vue3-essential',
+    'eslint:recommended'
+  ],
+  parserOptions: {
+    ecmaVersion: 'latest'
+  }
+}

+ 30 - 0
TEAMModelContest/contest.client/.gitignore

@@ -0,0 +1,30 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+.DS_Store
+dist
+dist-ssr
+coverage
+*.local
+
+/cypress/videos/
+/cypress/screenshots/
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+*.tsbuildinfo

+ 35 - 0
TEAMModelContest/contest.client/README.md

@@ -0,0 +1,35 @@
+# contest.client
+
+This template should help get you started developing with Vue 3 in Vite.
+
+## Recommended IDE Setup
+
+[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
+
+## Customize configuration
+
+See [Vite Configuration Reference](https://vitejs.dev/config/).
+
+## Project Setup
+
+```sh
+npm install
+```
+
+### Compile and Hot-Reload for Development
+
+```sh
+npm run dev
+```
+
+### Compile and Minify for Production
+
+```sh
+npm run build
+```
+
+### Lint with [ESLint](https://eslint.org/)
+
+```sh
+npm run lint
+```

+ 5 - 0
TEAMModelContest/contest.client/babel.config.js

@@ -0,0 +1,5 @@
+module.exports = {
+  presets: [
+    '@vue/cli-plugin-babel/preset'
+  ]
+}

+ 11 - 0
TEAMModelContest/contest.client/contest.client.esproj

@@ -0,0 +1,11 @@
+<Project Sdk="Microsoft.VisualStudio.JavaScript.Sdk/0.5.128-alpha">
+  <PropertyGroup>
+    <StartupCommand>npm run dev</StartupCommand>
+    <JavaScriptTestRoot>.\</JavaScriptTestRoot>
+    <JavaScriptTestFramework>Jest</JavaScriptTestFramework>
+    <!-- Allows the build (or compile) script located on package.json to run on Build -->
+    <ShouldRunBuildScript>false</ShouldRunBuildScript>
+    <!-- Folder where production build objects will be placed -->
+    <PublishAssetsDirectory>$(MSBuildProjectDirectory)\dist</PublishAssetsDirectory>
+  </PropertyGroup>
+</Project>

+ 13 - 0
TEAMModelContest/contest.client/index.html

@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8">
+    <link rel="icon" href="/favicon.ico">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Vite App</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.js"></script>
+  </body>
+</html>

+ 8 - 0
TEAMModelContest/contest.client/jsconfig.json

@@ -0,0 +1,8 @@
+{
+  "compilerOptions": {
+    "paths": {
+      "@/*": ["./src/*"]
+    }
+  },
+  "exclude": ["node_modules", "dist"]
+}

+ 10 - 0
TEAMModelContest/contest.client/nuget.config

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<configuration>
+  <packageSources>
+    <clear />
+    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
+  </packageSources>
+  <disabledPackageSources>
+    <clear />
+  </disabledPackageSources>
+</configuration>

+ 55 - 0
TEAMModelContest/contest.client/package.json

@@ -0,0 +1,55 @@
+{
+  "name": "activity",
+  "version": "0.1.0",
+  "private": true,
+  "scripts": {
+    "serve": "vue-cli-service serve",
+    "build": "vue-cli-service build",
+    "lint": "vue-cli-service lint"
+  },
+  "dependencies": {
+    "@azure/storage-blob": "^12.17.0",
+    "@element-plus/icons-vue": "^2.1.0",
+    "axios": "^1.5.1",
+    "core-js": "^3.6.5",
+    "element-plus": "^2.3.9",
+    "jwt-decode": "^3.1.2",
+    "less": "^4.2.0",
+    "less-loader": "^7.3.0",
+    "pinia": "^2.1.7",
+    "spark-md5": "^3.0.2",
+    "vue": "^3.0.0",
+    "vue-router": "^4.2.4",
+    "vuex": "^4.1.0"
+  },
+  "devDependencies": {
+    "@vue/cli-plugin-babel": "~4.5.13",
+    "@vue/cli-plugin-eslint": "~4.5.13",
+    "@vue/cli-service": "~4.5.13",
+    "@vue/compiler-sfc": "^3.0.0",
+    "babel-eslint": "^10.1.0",
+    "eslint": "^6.7.2",
+    "eslint-plugin-vue": "^7.0.0"
+  },
+  "eslintConfig": {
+    "root": true,
+    "env": {
+      "node": true
+    },
+    "extends": [
+      "plugin:vue/vue3-essential",
+      "eslint:recommended"
+    ],
+    "parserOptions": {
+      "parser": "babel-eslint"
+    },
+    "rules": {
+      "no-unused-vars": "off"
+    }
+  },
+  "browserslist": [
+    "> 1%",
+    "last 2 versions",
+    "not dead"
+  ]
+}

BIN
TEAMModelContest/contest.client/public/favicon.ico


+ 18 - 0
TEAMModelContest/contest.client/public/index.html

@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<html lang="">
+  <head>
+    <meta charset="utf-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport" content="width=device-width,initial-scale=1.0">
+    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
+		<link id="theme" type="text/css" rel="stylesheet" href="<%= BASE_URL %>reset.css" />
+    <title>赛课系统</title>
+  </head>
+  <body>
+    <noscript>
+      <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
+    </noscript>
+    <div id="app"></div>
+    <!-- built files will be auto injected -->
+  </body>
+</html>

+ 48 - 0
TEAMModelContest/contest.client/public/reset.css

@@ -0,0 +1,48 @@
+/* http://meyerweb.com/eric/tools/css/reset/ 
+   v2.0 | 20110126
+   License: none (public domain)
+*/
+
+html, body, div, span, applet, object, iframe,
+h1, h2, h3, h4, h5, h6, p, blockquote, pre,
+a, abbr, acronym, address, big, cite, code,
+del, dfn, em, img, ins, kbd, q, s, samp,
+small, strike, strong, sub, sup, tt, var,
+b, u, i, center,
+dl, dt, dd, ol, ul, li,
+fieldset, form, label, legend,
+table, caption, tbody, tfoot, thead, tr, th, td,
+article, aside, canvas, details, embed, 
+figure, figcaption, footer, header, hgroup, 
+menu, nav, output, ruby, section, summary,
+time, mark, audio, video {
+	margin: 0;
+	padding: 0;
+	border: 0;
+	/*font-size: 100%;*/
+	/*font: inherit;*/
+	vertical-align: baseline;
+}
+/* HTML5 display-role reset for older browsers */
+article, aside, details, figcaption, figure, 
+footer, header, hgroup, menu, nav, section {
+	display: block;
+}
+body {
+	line-height: 1;
+}
+ol, ul {
+	list-style: none;
+}
+blockquote, q {
+	quotes: none;
+}
+blockquote:before, blockquote:after,
+q:before, q:after {
+	content: '';
+	content: none;
+}
+table {
+	border-collapse: collapse;
+	border-spacing: 0;
+}

+ 27 - 0
TEAMModelContest/contest.client/src/App.vue

@@ -0,0 +1,27 @@
+<template>
+    <Suspense>
+        <router-view></router-view>
+    </Suspense>
+</template>
+
+<script>
+
+export default {
+    name: "App",
+}
+</script>
+
+<style>
+html,
+body,
+#app {
+    height: 100%;
+    margin: 0;
+}
+.el-button:focus {
+    outline: none;
+}
+.el-empty {
+    margin: auto;
+}
+</style>

+ 296 - 0
TEAMModelContest/contest.client/src/api/http.js

@@ -0,0 +1,296 @@
+import axios from 'axios';
+import {ElLoading, ElMessage} from 'element-plus'
+import { useRouter } from "vue-router"
+import config from '@/store/module/config'
+import { reactive } from 'vue';
+
+// 不需携带access_token
+const NO_ACCESS_API = [
+    '/activity/get-website',
+    '/activity/login-portal',
+    '/activity/list-portal',
+]
+// 需要携带access_token 不需要auth-token
+const NO_AUTH_API = []
+// 不进行错误提示白名单
+const NO_WARNING = []
+
+const router = useRouter()
+
+// token刷新中,挂载的请求
+let requestStack = []
+// 记录token刷新中发起的请求
+function track(prms) {
+    requestStack.push(prms)
+}
+// token刷新完成触发挂起的请求继续请求
+function trigger() {
+    let index = requestStack.length
+    while(index > 0) {
+        requestStack[index - 1]()
+        requestStack.pop()
+        index--
+    }
+}
+
+let refreshing = false //是否刷新token中
+axios.defaults.timeout = 30000 //设置超时时长
+axios.defaults.baseURL = ''
+
+let loading = reactive({
+    lock: true,
+    text: '加载中',
+    background: 'rgba(0, 0, 0, 0.7)'
+})
+
+// http request 拦截器
+axios.interceptors.request.use(config => {
+    loading = ElLoading.service({
+        lock: true,
+        text: '加载中',
+        background: 'rgba(0, 0, 0, 0.7)'
+    })
+    let isNeedAccess = true
+    // 1. 登录及登录前,不检查API是否需要accesstoken
+    for (let apiUrl of NO_ACCESS_API) {
+        if(config.url.includes(apiUrl)) {
+            isNeedAccess = false
+            break
+        }
+    }
+    if(!isNeedAccess) return config
+
+    // 2. 登录后,
+    // 检查操作时间
+    let webEndTime = localStorage.getItem('webEndTime')
+    let time_now = new Date().getTime()
+    if(webEndTime && time_now > webEndTime) {
+        loginOut()
+        sessionStorage.setItem('loginOut', '长时间未操作,重新登录')
+        return
+    }
+    // 检查是否有access_token
+    let access_token = localStorage.getItem('access_token')
+    if(!access_token) {
+        loginOut()
+        sessionStorage.setItem('loginOut', 'token无效')
+        return
+    }
+    // 检查是否快到期
+    let isExpired = checkToken()
+    // token未过期
+    if(!isExpired) {
+        return handleHeader(config)
+    }
+    // 刷新token
+    let handleRefresh = new Promise((resolve, reject) => {
+        track(() => {
+            handleHeader(config)
+            resolve(config)
+        })
+    })
+    sessionStorage.setItem('apiCount', requestStack.length)
+    if(!refreshing) {
+        refreshing = true
+        refreshToken()
+    }
+    return handleRefresh
+}, error => {
+    return Promise.reject(error)
+})
+
+// http response 拦截器
+axios.interceptors.response.use(response => {
+    if(response.data.errCode === 2) {
+        /* router.push({
+            path: '/login',
+            query: {
+                redirect: router.currentRoute.fullPath
+            }
+        }) */
+        console.log('errCode');
+    }
+    // 保存最新的服务端时间
+    if(response.headers.date) {
+        localStorage.setItem('serverTime', new Date(response.headers.date).getTime())
+    }
+    // 四小时没操作过则需重新登录
+    let endTime = (new Date().getTime() + (4 * 60 * 60 * 1000))
+    localStorage.setItem('webEndTime', endTime)
+    setTimeout(() => {
+        loading.close()
+    }, 1500)
+    return response
+}, error => {
+    console.log('vbfbbtfnt', error);
+    if(!error.response) {
+        if(!NO_WARNING.includes(error.config.url)) {
+            ElMessage({
+                type: 'error',
+                message: 'http.error'
+            })
+        }
+    } else if(error.response && error.response.status === 401) {
+        localStorage.clear()
+        sessionStorage.setItem('loginOut', error.config.url + ':API401,重新登录')
+        sessionStorage.setItem('APIInfo', JSON.stringify(error))
+        console.log('loginOut', error.config.url + ':API401,重新登录');
+        window.location.href = '/home/homePage'
+        ElMessage({
+            type: 'error',
+            message: 'http.error401'
+        })
+    } else if(error.response.status === 500) {
+        ElMessage({
+            type: 'error',
+            message: 'http.error500'
+        })
+    } else if(error.response.status === 404) {
+        ElMessage({
+            type: 'error',
+            message: 'http.error404'
+        })
+    } else if(error.response.status === 501) {
+        ElMessage({
+            type: 'error',
+            message: 'http.error501'
+        })
+    } else {
+        ElMessage({
+            type: 'error',
+            message: 'http.error400'
+        })
+    }
+    loading.close()
+    return Promise.reject(error)
+})
+
+function handleHeader(config) {
+    config.headers['Authorization'] = 'Bearer ' + localStorage.getItem('access_token')
+    config.headers['Content-Type'] = 'application/json'
+    config.headers['lang'] = localStorage.getItem('local') || navigator.language.toLowerCase()
+
+    let isNeedAuth = true
+    for (let apiUrl of NO_AUTH_API) {
+        if(config.url.includes(apiUrl)) {
+            console.log('auth-token无效', config)
+            isNeedAuth = false
+            break
+        }
+    }
+    if(!isNeedAuth) return config
+
+    // 检查auth-token是否存在
+    let identity = sessionStorage.getItem('identity')
+    // 正式使用要调整为个人和专家
+    let auth_token = identity === 'student' ? localStorage.getItem('stu_auth_token') : localStorage.getItem('auth_token')
+    if(!auth_token) {
+        console.log('auth_token失败', config)
+        loginOut()
+        sessionStorage.setItem('loginOut', 'localStorage没有auth_token:auth_token失败,重新登录')
+        return
+    }
+    // 通过验证,设置对应参数
+    config.headers['X-Auth-AuthToken'] = auth_token
+    return config
+}
+
+// 检查token是否快过期
+function checkToken() {
+    if(!localStorage.getItem('expires_in')) return false
+
+    var nowTime = new Date()
+    var offset = nowTime.getTimezoneOffset() / 60
+    let cT = Date.parse(nowTime)
+    let eT = Date.parse(localStorage.getItem('expires_in'))
+    let oT = 0
+    let btw = eT - oT - cT
+    if(btw > 10 * 60 * 1000) {
+        return false
+    } else {
+        return true
+    }
+}
+
+// 刷新token
+function refreshToken() {
+    refreshing = true
+    let areaRoute = window.location.pathname.split('/')
+    axios.post('/activity/login-portal', {
+        "route": areaRoute[1],
+        "token": localStorage.getItem('auth_token')
+    }).then(res => {
+        if(res.data.code === 200) {
+            localStorage.setItem("auth_token", res.data.token)
+            localStorage.setItem("access_token", res.data.auth_token.access_token)
+            localStorage.setItem("expires_in", res.data.auth_token.expires_in)
+            // token刷新完成,触发挂载的API
+            trigger()
+            refreshing = false
+        } else {
+            refreshing = false
+            requestStack = []
+            loginOut()
+            sessionStorage.setItem('loginOut', 'Token验证失败')
+        }
+    },err => {
+        refreshing = false
+        requestStack = []
+        loginOut()
+        sessionStorage.setItem('loginOut', 'token刷新失败,退出重新登录')
+    })
+}
+
+// 超时退出重新登录
+function loginOut() {
+    localStorage.clear()
+    console.log('超时退出');
+    // router.push({path: '/home/homePage'})
+    window.location.href = window.location.origin + '/home/homePage'
+}
+
+/**
+ * 封装get方法
+ * @param url
+ * @param data
+ * @returns {Promise}
+ */
+export function fetch(url, params) {
+    let data = {}
+    data.method = url
+    data.params = params
+    data.lang = localStorage.getItem('local')
+    return new Promise((resolve, reject) => {
+        axios.get(url, data).then(response => {
+            resolve(response.data)
+            ElMessage({
+                type: 'success',
+                message: '数据访问成功!'
+            })
+        }).catch(err => {
+            reject(err)
+        })
+    })
+}
+
+/**
+ * 封装post请求
+ * @param url
+ * @param data
+ * @returns {Promise}
+ */
+export function post(url, params) {
+    let data = {}
+    data.method = url
+    data.params = params
+    data.lang = localStorage.getItem('local')
+    return new Promise((resolve, reject) => {
+        axios.post(url, params).then(response => {
+            if(response) {
+                resolve(response.data)
+            }
+        }, err => {
+            reject(err)
+        })
+    })
+}

+ 39 - 0
TEAMModelContest/contest.client/src/api/index.js

@@ -0,0 +1,39 @@
+import { post } from '@/api/http'
+
+export default {
+    loginPortal(data) {
+        return post('/activity/login-portal', data)
+    },
+    getWebsite(data) {
+        return post('/activity/get-website', data)
+    },
+    getActivityList(data) {
+        return post('/activity/list-portal', data)
+    },
+    teaContest(data) {
+        return post('/activity/teacher-contest', data)
+    },
+    actManage(data) {
+        return post('/activity/manage', data)
+    },
+    getActInfo(data) {
+        return post('/activity/read-activity', data)
+    },
+    getInfoInWeb(data) {
+        return post('/activity/get-activity-website', data)
+    },
+    getTaskList(data) {
+        return post('/activity/expert-contest', data)
+    },
+
+
+
+    //获取blob容器读写创建
+    blobSasRCW(data) {
+        return post('/blob/sas-rcwld', data)
+    },
+    // 删除blob指定目录下的所有文件
+    deletePrefix(data) {
+        return post('/blob/delete-prefix', data)
+    },
+}

BIN
TEAMModelContest/contest.client/src/assets/img/events.jpg


BIN
TEAMModelContest/contest.client/src/assets/img/fengj.jpg


BIN
TEAMModelContest/contest.client/src/assets/img/fengjing.jpg


BIN
TEAMModelContest/contest.client/src/assets/img/no-poster-cn.jpg


BIN
TEAMModelContest/contest.client/src/assets/img/no-poster-cn.png


BIN
TEAMModelContest/contest.client/src/assets/img/noData.png


BIN
TEAMModelContest/contest.client/src/assets/img/noData1.jpg


BIN
TEAMModelContest/contest.client/src/assets/img/zhProcess.png


BIN
TEAMModelContest/contest.client/src/assets/logo.png


BIN
TEAMModelContest/contest.client/src/assets/source/audio.png


BIN
TEAMModelContest/contest.client/src/assets/source/excel.png


BIN
TEAMModelContest/contest.client/src/assets/source/folder.png


BIN
TEAMModelContest/contest.client/src/assets/source/image.png


BIN
TEAMModelContest/contest.client/src/assets/source/item.png


BIN
TEAMModelContest/contest.client/src/assets/source/link.png


BIN
TEAMModelContest/contest.client/src/assets/source/pdf.png


BIN
TEAMModelContest/contest.client/src/assets/source/ppt.png


BIN
TEAMModelContest/contest.client/src/assets/source/unknow.png


BIN
TEAMModelContest/contest.client/src/assets/source/video.png


BIN
TEAMModelContest/contest.client/src/assets/source/word.png


BIN
TEAMModelContest/contest.client/src/assets/source/zip.png


+ 30 - 0
TEAMModelContest/contest.client/src/common/Loading.vue

@@ -0,0 +1,30 @@
+<template>
+    <div class="loading-screen">
+    </div>
+</template>
+
+<script setup>
+import { ElLoading } from "element-plus";
+
+let loadingInstance = ElLoading.service({
+    lock: true,
+    text: '加载中',
+    background: 'rgba(0, 0, 0, 0.1)'
+})
+
+
+</script>
+
+<style lang="scss" scoped>
+.loading-screen {
+    position: fixed; /* 设置位置为fixed */
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100vh; /* 高度与视口相同,占据整个页面空间 */
+    background-color: rgba(0, 0, 0, 0.5); /* 设置背景色及透明度 */
+    display: flex;
+    justify-content: center;
+    align-items: center;
+}
+</style>

+ 58 - 0
TEAMModelContest/contest.client/src/components/HelloWorld.vue

@@ -0,0 +1,58 @@
+<template>
+  <div class="hello">
+    <h1>{{ msg }}</h1>
+    <p>
+      For a guide and recipes on how to configure / customize this project,<br>
+      check out the
+      <a href="https://cli.vuejs.org" target="_blank" rel="noopener">vue-cli documentation</a>.
+    </p>
+    <h3>Installed CLI Plugins</h3>
+    <ul>
+      <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel" target="_blank" rel="noopener">babel</a></li>
+      <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint" target="_blank" rel="noopener">eslint</a></li>
+    </ul>
+    <h3>Essential Links</h3>
+    <ul>
+      <li><a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a></li>
+      <li><a href="https://forum.vuejs.org" target="_blank" rel="noopener">Forum</a></li>
+      <li><a href="https://chat.vuejs.org" target="_blank" rel="noopener">Community Chat</a></li>
+      <li><a href="https://twitter.com/vuejs" target="_blank" rel="noopener">Twitter</a></li>
+      <li><a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a></li>
+    </ul>
+    <h3>Ecosystem</h3>
+    <ul>
+      <li><a href="https://router.vuejs.org" target="_blank" rel="noopener">vue-router</a></li>
+      <li><a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a></li>
+      <li><a href="https://github.com/vuejs/vue-devtools#vue-devtools" target="_blank" rel="noopener">vue-devtools</a></li>
+      <li><a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener">vue-loader</a></li>
+      <li><a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">awesome-vue</a></li>
+    </ul>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'HelloWorld',
+  props: {
+    msg: String
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+h3 {
+  margin: 40px 0 0;
+}
+ul {
+  list-style-type: none;
+  padding: 0;
+}
+li {
+  display: inline-block;
+  margin: 0 10px;
+}
+a {
+  color: #42b983;
+}
+</style>

+ 39 - 0
TEAMModelContest/contest.client/src/main.js

@@ -0,0 +1,39 @@
+import { createApp } from 'vue'
+import App from './App.vue'
+import router from './router/index.js'
+// import store from '@/store'
+import pinia from './pinia'
+import api from '@/api/index.js'
+import tools from '@/utils/common.js'
+import specialChar from'@/utils/directive.js'
+import axios from 'axios'
+
+import Less from 'less'
+import ElementPlus from 'element-plus'
+import 'element-plus/dist/index.css'
+import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
+// import jwtDecode from 'jwt-decode'
+
+let app = createApp(App)
+
+// 根据域名判断调用测试站接口
+let hostName = window.location.hostname
+
+axios.defaults.baseURL = hostName === 'localhost' ? 'https://localhost:5001' : 'https://test.teammodel.cn'
+
+app.config.globalProperties.$axios = axios
+app.config.globalProperties.$api = api
+app.config.globalProperties.$tools = tools
+// app.config.globalProperties.$jwtDecode = jwtDecode
+
+// 设置全局自定义指令
+/* app.directive('special-char', specialChar)
+app.directive('file-name', fileName) */
+specialChar(app)
+
+app.use(router)
+// app.use(store)
+app.use(pinia)
+app.use(Less)
+app.use(ElementPlus, {locale: zhCn})
+app.mount('#app')

+ 43 - 0
TEAMModelContest/contest.client/src/pinia/common.js

@@ -0,0 +1,43 @@
+import { defineStore } from 'pinia'
+
+/**
+ * 参数一:名字,必填且唯一
+ * 参数二:选项式(state/getters/actions) 组合式采用函数形式(ref/computed()/functions) 组合式最后需return {}
+ */
+export const useStore = defineStore('main', {
+    state: () => {
+        return {
+            srvAdr: '', // 伺服器位置 China, Global
+            srvAdrType: '', // 正式站 product 測試站 test
+            userInfo: {},
+            schoolList: [],
+            website: {},
+        }
+    },
+    getters: {
+        getSrvAdr: (state) => {
+            return state.srvAdr
+        },
+    },
+    actions: {
+        setSrvAdr(value) {
+            this.srvAdr = value
+        },
+        setSrvAdrType(value) {
+            this.srvAdrType = value
+        },
+        setSchoolList(value) {
+            this.schoolList = value
+        },
+        setUserInfo(value) {
+            this.userInfo = value
+        },
+        setWebsite(value) {
+            this.website = value
+        },
+        checkSrvAdr(context) {
+            let hostname = window.location.hostname
+            let domainUrl = hostname
+        }
+    }
+})

+ 5 - 0
TEAMModelContest/contest.client/src/pinia/index.js

@@ -0,0 +1,5 @@
+import { createPinia } from 'pinia'
+
+const pinia = createPinia()
+
+export default pinia

+ 113 - 0
TEAMModelContest/contest.client/src/router/index.js

@@ -0,0 +1,113 @@
+import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router'
+
+let pathname = window.location.pathname
+let areaRoute = pathname.substring(0, pathname.indexOf('/', 1) === -1 ? pathname.length : pathname.indexOf('/', 1))
+areaRoute = ['/', '/home'].includes(areaRoute) ? '/teammodel' : areaRoute
+
+let NO_AUTH_ROUTE = ['/home/homePage']
+
+const routes = [
+    {
+        path: '/',
+        redirect: '/home/homePage',
+    },
+    /* {
+        path: "/login",
+        name: "login",
+        component: () => require.ensure([], (require) => require(`@/view/Login.vue`))
+    }, */
+    {
+        path: '/home',
+        name: 'home',
+        redirect: '/home/homePage',
+        component: () => require.ensure([], require => require('@/view/Home.vue')),
+        children: [
+            // 首页
+            {
+                path: 'homePage',
+                name: 'homePage',
+                isShow: true,
+                component: () => import('@/view/homepage/HomePage.vue'),
+                meta: {
+                    needLogin: false,
+                    activeName: 'home'
+                }
+            },
+            // 活动总览
+            {
+                path: 'activityList',
+                name: 'activityList',
+                isShow: true,
+                component: () => import('@/view/activitylist/ActivityList.vue'),
+                meta: {
+                    needLogin: true
+                }
+            },
+            // 活动数据
+            {
+                path: 'activityInfo/:info',
+                name: 'activityInfo',
+                isShow: true,
+                component: () => import('@/view/activitylist/ActivityInfo.vue'),
+                meta: {
+                    needLogin: true,
+                    activeName: 'home'
+                }
+            },
+            // 我的活动
+            {
+                path: 'myActivity',
+                name: 'myActivity',
+                isShow: true,
+                component: () => import('@/view/myactivity/MyActivity.vue'),
+                meta: {
+                    needLogin: true,
+                    activeName: 'myActivity'
+                }
+            },
+            // 我的审核
+            {
+                path: 'myReview',
+                name: 'myReview',
+                isShow: true,
+                component: () => import('@/view/myactivity/MyReview.vue'),
+                meta: {
+                    needLogin: true,
+                    activeName: 'myReview'
+                }
+            },
+            // 基础信息
+            {
+                path: 'basicInfo',
+                name: 'basicInfo',
+                isShow: true,
+                component: () => import('@/view/basicInfo/BasicInfo.vue'),
+                meta: {
+                    needLogin: true,
+                    activeName: ''
+                }
+            },
+        ],
+    }
+];
+
+const router = createRouter({
+    history: createWebHistory(areaRoute + '/'),
+    // base: areaRoute + '/',
+    routes,
+});
+
+router.beforeEach((to, from, next) => {
+    if(!to.meta.needLogin) {
+        next()
+    } else {
+        let auth_token = localStorage.getItem('auth_token')
+        if(auth_token) {
+            next()
+        } else {
+            location.href = "https://account.teammodel.cn/?callback=" + window.location.href
+        }
+    }
+})
+
+export default router;

+ 23 - 0
TEAMModelContest/contest.client/src/store/index.js

@@ -0,0 +1,23 @@
+import { createStore } from 'vuex'
+
+export default createStore({
+    state: {
+        userInfo: {},
+        schoolList: [],
+        website: {},
+    },
+    getters: {
+    },
+    mutations: {
+        setSchoolList(state, value) {
+            state.schoolList = value
+        },
+        setWebsite(state, value) {
+            state.website = value
+        },
+    },
+    actions: {
+    },
+    modules: {
+    }
+})

+ 7 - 0
TEAMModelContest/contest.client/src/store/module/config.js

@@ -0,0 +1,7 @@
+export default {
+    namespaced: true,
+    state: {
+        srvAdr: '',
+        srvAdrType: '',
+    }
+}

+ 15 - 0
TEAMModelContest/contest.client/src/utils/Global.js

@@ -0,0 +1,15 @@
+const CONTENT_TYPES = {
+    'image': ['JPG', 'JPEG', 'PNG', 'GIF', 'SVG', 'BMP', 'TIF'],
+    'video': ['RMVB', 'WMV', 'ASF', 'AVI', '3GP', 'MPG', 'MKV', 'MP4', 'DVD', 'OGM', 'MOV', 'MPEG2', 'MPEG4'],
+    'audio': ['MP3', 'OGG', 'WAV', 'APE', 'CDA', 'AU', 'MIDI', 'MAC', 'AAC'],
+    'doc': ['PPT', 'PPTX', 'DOC', 'DOCX', 'PDF', 'XLS', 'XLSX', 'CSV'],
+    'res': ['HTE', 'HTEX'],
+}
+
+const GLOBAL = {
+    CONTENT_TYPES
+}
+
+export {
+	GLOBAL
+}

+ 553 - 0
TEAMModelContest/contest.client/src/utils/blobTool.js

@@ -0,0 +1,553 @@
+import API from '@/api/index.js'
+import { GLOBAL } from '@/utils/Global.js'
+import { getCurrentInstance } from "vue"
+
+let proxy = getCurrentInstance()?.proxy
+
+const { BlobServiceClient, BlobClient } = require("@azure/storage-blob")
+const blobPath = ['activity']
+
+/* 获取某个字符在字符串中第num次出现的index */
+function findChartIndex(str, cha, num) {
+    var x = str.indexOf(cha)
+    for (let i = 0; i < num; i++) {
+        x = str.indexOf(cha, x + 1)
+    }
+    return x
+}
+
+/* 获取文件后缀和类型 */
+function getExAndType(fileName) {
+    let ex = fileName.substring(fileName.lastIndexOf('.') + 1)
+    let type = 'other'
+    ex = ex.toUpperCase()
+    for (const key in GLOBAL.CONTENT_TYPES) {
+        if (GLOBAL.CONTENT_TYPES[key].indexOf(ex) != -1) {
+            type = key
+            break
+        }
+    }
+    return {ex, type}
+}
+
+export default class 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 && sasString && 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 {
+                throw new Error("参数错误,初始化失败")
+            }
+        } else {
+            throw new Error("初始化参数不完整")
+        }
+    }
+
+    /**
+     * 获取容器信息(授权失败,需要帐号级别的授权)
+     * @param {Object}
+     */
+    getProperties(options) {
+        return new Promise((resolve, reject) => {
+            this.containerClient.getProperties(options).then(res => {
+                console.log('获取信息成功');
+                resolve(res)
+            }, err => {
+                console.error('获取信息失败');
+                reject(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 => {
+            res.blobBody.then(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 {any} file 文件对象
+     * @param {Object} config 配置项 {path, root, checkExist, checkSize}
+     * {string} path 文件夹路径,只需要文件夹名称,前后都不要'/'
+     * {string} root path验证的根目录,默认为'/'
+     * {boolean} checkSize 上传时是否检查容器空间大小,默认需要检查
+     * {boolean} 选填 checkExist 是否检查文件重复 默认为 false(直接覆盖) 如果为ture会重命名上传
+     * 
+     * @param {any} option 官方可配置项
+     * @returns {object} {url, name, size, createTime, extension, type}
+     */
+    upload(file, config = {}, option = {}) {
+        let { path, root, checkSize, checkExist } = config
+        let checkPath = root === '/' ? path : path.replace(root, '')
+        if(!blobPath.includes(checkPath.split('/')[0])) {
+            throw new Error('上传路径不合法,请检查上传路径:' + path)
+        }
+        return new Promise(async(resolve, reject) => {
+            // 检查容器大小
+            let isFull = false
+            if(checkSize) {
+                try {
+                    isFull = await this.isContainerFull(this.scope)
+                } catch (e) {
+                    reject({spaceError: '容器空间计算失败,无法上传文件'})
+                }
+                if(isFull) {
+                    reject({spaceError: 'Blob空间已满,无法上传'})
+                }
+            }
+            const blockBlobClient = this.containerClient.getBlockBlobClient(path + '/' + file.name)
+            blockBlobClient.uploadBrowserData(file, option).then(res => {
+                // 设置blob MD5 (解决大文件分块上传没有MD5的问题)
+                if(!res.contentMD5) {
+                    proxy.$tools.getFileMD5(file).then(res5 => {
+                        let option = {getBlockMD5: res5}
+                        this.setFileProperties(path + '/' + file.name, option).then(resSet => {
+                            console.log('MD5设置成功', resSet);
+                        }, err => {
+                            console.error('MD5设置失败', err);
+                        })
+                    }, err => {
+                        console.error('前端获取MD5失败', err);
+                    })
+                }
+                let url = decodeURIComponent(res._response.request.url)
+                url = url.substring(0, url.lastIndexOf('?'))
+                let info = getExAndType(file.name)
+                resolve({
+                    name: file.name,
+                    url,
+                    size: file.size,
+                    createTime: res.lastModified.getTime(),
+                    extension: info.ex,
+                    type: info.type,
+                    blob: '/' + path + '/' + file.name,
+                    md5: res.contentMD5,
+                })
+            }, err => {
+                reject(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 = findChartIndex(item.blob, '/', 1)
+                let endIndex = 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} 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 => {
+                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)
+                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 //查询学校空间的时候,返回此学校已分配给教室的空间;查询个人容器返回为零,不需要处理
+                        r(res.catalog)
+                    } else {
+                        j('API error')
+                    }
+                },err => {
+                    j('API error')
+                })
+            })
+        } else {
+            throw new Error("参数不完整")
+        }
+    } */
+
+}

+ 116 - 0
TEAMModelContest/contest.client/src/utils/common.js

@@ -0,0 +1,116 @@
+import SparkMD5 from "spark-md5"
+
+export default {
+    /* 时间格式化 */
+    formatTime(timestamp, fmt = 'yyyy-MM-dd hh:mm:ss') {
+        let d = new Date()
+        d.setTime(timestamp)
+        var o = {
+            'M+': d.getMonth() + 1, //月份
+            'd+': d.getDate(), //日
+            'h+': d.getHours(), //小时
+            'm+': d.getMinutes(), //分
+            's+': d.getSeconds(), //秒
+            'q+': Math.floor((d.getMonth() + 3) / 3), //季度
+            'S': d.getMilliseconds() //毫秒
+        }
+        if (/(y+)/.test(fmt)) fmt = fmt.replace(RegExp.$1, (d.getFullYear() + '').substr(4 - RegExp.$1.length))
+        for (var k in o)
+            if (new RegExp('(' + k + ')').test(fmt)) fmt = fmt.replace(RegExp.$1, (RegExp.$1.length == 1) ? (o[k]) : ((
+                '00' + o[
+                k]).substr(('' + o[k]).length)))
+        return fmt
+    },
+	/* 获取视频文件的持续时长 */
+	getVideoDuration(file, url) {
+		return new Promise((resolve, reject) => {
+            console.log('ferhth', file);
+			var vid = document.createElement('video');
+			var fileURL = url || window.URL.createObjectURL(file);
+			vid.src = fileURL;
+			vid.addEventListener('loadedmetadata', function () {
+				resolve(vid.duration);
+			});
+			vid.remove()
+		})
+	},
+    /* 将文件MD5 Uint8Array格式转换成字符串 */
+	convertFileMD5ToString(fileData) {
+		var dataString = "";
+		for (var i = 0; i < fileData.length; i++) {
+			dataString += fileData[i].toString(16);
+		}
+		return dataString
+	},
+    /* 获取文件的MD5 */
+    getFileMD5(file) {
+        return new Promise((resolve, reject) => {
+            const CHUNK_SIZE = 4194304
+            const fileReader = new FileReader()
+            const chunkFile = file.slice(0, CHUNK_SIZE)
+            fileReader.readAsBinaryString(chunkFile)
+            let spark = new SparkMD5()
+            fileReader.onload = e => {
+                spark.appendBinary(e.target.result)
+                var md5 = spark.end(true)
+                resolve(this.stringToUint8Array(md5))
+            }
+        })
+    },
+	/* 将二进制流字符串转换成Uint8Array */
+	stringToUint8Array(str) {
+		var arr = [];
+		for (var i = 0, j = str.length; i < j; ++i) {
+			arr.push(str.charCodeAt(i));
+		}
+		var tmpUint8Array = new Uint8Array(arr);
+		return tmpUint8Array
+	},
+	/* 根据地址获取blobHOST域名 */
+	getBlobHost(url) {
+		let s = url
+		let pattern = /[a-zA-Z0-9][-a-zA-Z0-9]{0,62}(\.[a-zA-Z0-9][-a-zA-Z0-9]{0,62})+\.?/
+		return s.split('//')[0] + '//' + s.match(pattern)[0]
+	},
+	/* 获取文件后缀名 */
+	getSuffix(name) {
+		return name.substr(name.lastIndexOf(".") + 1)
+	},
+	getFileThum(type, fileName) {
+		const docType = ['doc', 'docx']
+		const excelType = ['xls', 'csv', 'xlsx']
+		const pptType = ['ppt', 'pptx']
+		let thumPath = ''
+		if (type === 'doc' && docType.includes(this.getSuffix(fileName))) {
+			thumPath = require('@/assets/source/word.png')
+		} else if (type === 'doc' && excelType.includes(this.getSuffix(fileName))) {
+			thumPath = require('@/assets/source/excel.png')
+		} else if (type === 'doc' && pptType.includes(this.getSuffix(fileName))) {
+			thumPath = require('@/assets/source/ppt.png')
+		} else if (type === 'image') {
+			thumPath = require('@/assets/source/image.png')
+		} else if (type === 'video') {
+			thumPath = require('@/assets/source/video.png')
+		} else if (type === 'audio') {
+			thumPath = require('@/assets/source/audio.png')
+		} else if (type === 'link') {
+			thumPath = require('@/assets/source/link.png')
+		} else if (type === 'res') {
+			thumPath = require('@/assets/source/zip.png')
+		} else if (type === 'thum') {
+			thumPath = require('@/assets/source/image.png')
+		} else {
+			thumPath = require('@/assets/source/link.png')
+		}
+		return thumPath
+	},
+	/* 弹窗下载文件操作 */
+	doDownloadByUrl(url, fileName) {
+		let a = document.createElement('a');
+		a.href = url;
+		a.download = fileName;
+		a.target = '_blank'
+		a.click()
+		a.remove();
+	},
+}

+ 47 - 0
TEAMModelContest/contest.client/src/utils/directive.js

@@ -0,0 +1,47 @@
+let findEle = (parent, type) => {
+    return parent.tagName.toLowerCase() === type ? parent : parent.querySelector(type)
+}
+
+const trigger = (el, type) => {
+    const e = document.createEvent('HTMLEvents')
+    e.initEvent(type, true, true)
+    el.dispatchEvent(e)
+}
+
+//普通特殊字符过滤
+export default (app) => {
+    app.directive('special-char', {
+        mounted(el, binding, vnode) {
+            // 正则规则可根据需求自定义
+            // var regRule = /[`~!@#$%^&*()\-+=<>?:"{}|,.\/;'\\[\]·~!@#¥%……&*()——\-+={}|《》?:“”【】、;‘’,。、]/g
+            let regRule = /[`~?$^&*\/#$%¥|]/g
+            let isChinese = false
+            let $inp = findEle(el, 'input')
+            el.$inp = $inp
+            if ($inp) {
+                $inp.handle = function () {
+                    if (!isChinese) {
+                        let val = $inp.value
+                        $inp.value = val.replace(regRule, '')
+                        trigger($inp, 'input')
+                    }
+                }
+                $inp.chineseStart = function () {
+                    isChinese = true
+                }
+                $inp.chineseEnd = function () {
+                    isChinese = false
+                }
+                $inp.addEventListener('keyup', $inp.handle)
+                $inp.addEventListener('compositionstart', $inp.chineseStart)
+                $inp.addEventListener('compositionend', $inp.chineseEnd)
+            }
+        },
+        unmounted(el) {
+            el.$inp && el.$inp.removeEventListener('keyup', el.$inp.handle)
+            el.$inp && el.$inp.removeEventListener('compositionend', el.$inp.chineseStart)
+            el.$inp && el.$inp.removeEventListener('compositionstart', el.$inp.chineseStart)
+            // el.$inp && el.$inp.removeEventListener('keydown', el.$inp.handle)
+        },
+    })
+}

+ 332 - 0
TEAMModelContest/contest.client/src/view/Home.vue

@@ -0,0 +1,332 @@
+<template>
+    <div class="common-layout">
+        <el-container style="height: 100%;">
+            <el-header>
+                <el-menu :default-active="activeIndex" class="el-menu-demo" mode="horizontal">
+                    <el-menu-item v-for="(item, index) in menuList.list" :key="index" :index="item.menuName" v-show="item.isShow">
+                        <router-link :to="{name: item.router}">{{ item.name }}</router-link>
+                    </el-menu-item>
+                </el-menu>
+                <span class="website-name">{{ store.website.name }}</span>
+                <div class="head-photo" v-show="userInfo?.exp">
+                    <el-dropdown>
+                        <el-avatar :size="35" :src="userInfo?.picture" />
+                        <template #dropdown>
+                            <el-dropdown-menu v-if="userInfo?.name">
+                                <div style="margin: 5px 10px;">
+                                    <p>{{ userInfo.name }}</p>
+                                    <p>ID: {{ userInfo.sub }}</p>
+                                </div>
+                                <el-dropdown-item @click="gotoInfo()">
+                                    <el-icon><Postcard /></el-icon>
+                                    基础信息
+                                </el-dropdown-item>
+                                <!-- <el-dropdown-item @click="websiteChange = true">
+                                    <el-icon><Postcard /></el-icon>
+                                    切换站点
+                                </el-dropdown-item> -->
+                                <el-dropdown-item @click="loginOut()">
+                                    <el-icon><SwitchButton /></el-icon>
+                                    退出登录
+                                </el-dropdown-item>
+                            </el-dropdown-menu>
+                            <el-dropdown-menu v-else>
+                                <el-dropdown-item @click="toTeammodel()">
+                                    <el-icon><Position /></el-icon>
+                                    前往登录
+                                </el-dropdown-item>
+                            </el-dropdown-menu>
+                        </template>
+                    </el-dropdown>
+                </div>
+                <div class="head-photo" v-show="!userInfo?.exp">
+                    <p @click="toTeammodel()" style="height: 35px; line-height: 35px;">登录</p>
+                </div>
+            </el-header>
+            <el-main>
+                <router-view></router-view>
+            </el-main>
+        </el-container>
+        <el-dialog v-model="websiteChange" title="切换站点" width="30%">
+            <el-select v-model="webSelect" class="m-2" placeholder="请选择要切换的站点">
+                <el-option v-for="item in otherWebsite" :disabled="item.route === currentSite" :key="item.route" :label="item.name" :value="item.route" />
+            </el-select>
+            <template #footer>
+            <span class="dialog-footer">
+                <!-- <el-button @click="dialogVisible = false">Cancel</el-button> -->
+                <el-button type="primary" @click="webSelChange()">切换</el-button>
+            </span>
+            </template>
+        </el-dialog>
+    </div>
+</template>
+
+<script setup>
+import { computed, getCurrentInstance, onMounted, reactive, ref, toRaw, watch } from "vue"
+import { useRoute, useRouter } from 'vue-router'
+// import { useStore } from "vuex"
+import { SwitchButton, Postcard, Position } from '@element-plus/icons-vue'
+import { useStore } from "@/pinia/common"
+import { ElLoading, ElMessage } from "element-plus"
+import jwtDecode from "jwt-decode"
+
+const router = useRouter()
+const route = useRoute()
+let store = useStore()
+let { proxy } = getCurrentInstance()
+
+let activeIndex = ref(route.meta.activeName)
+let websiteChange = ref(false)
+let webSelect = ref("")
+let otherWebsite = ref([])
+let userInfo = ref({})
+
+let isExpert = computed(() => {
+    if(store.userInfo.roles && store.userInfo.roles.includes('expert')) {
+        return true
+    } else {
+        return false
+    }
+})
+
+let menuList = reactive({list: [
+    {
+        name: '首页',
+        menuName: 'home',
+        router: 'homePage',
+        isShow: true
+    },
+    {
+        name: '我的活动',
+        menuName: 'myActivity',
+        router: 'myActivity',
+        isShow: true
+    },
+    {
+        name: '专家评审',
+        menuName: 'myReview',
+        router: 'myReview',
+        isShow: isExpert
+    }
+]})
+
+onMounted(() => {
+    if(localStorage.getItem("auth_token")) {
+        let infos = jwtDecode(localStorage.getItem("auth_token"))
+        userInfo.value = infos
+        store.setUserInfo(infos)
+        let schools = JSON.parse(localStorage.getItem("schools"))
+        store.setSchoolList(schools)
+    }
+})
+
+await getWebsite()
+
+function gotoInfo() {
+    activeIndex.value = '-1'
+    router.push("/home/basicInfo")
+}
+
+function loginOut() {
+    localStorage.removeItem("auth_token")
+    localStorage.removeItem("access_token")
+    localStorage.removeItem("access_token")
+    localStorage.removeItem("expires_in")
+    store.setSchoolList([])
+    store.setUserInfo({})
+    userInfo.value = {}
+    menuList.list.splice(2, 1)
+    router.push({path: '/home/homePage'})
+    ElMessage({
+        type: 'warning',
+        message: '已退出登录'
+    })
+    // location.href = "https://account.teammodel.cn/?callback=" + window.location.href
+}
+
+function getWebsite() {
+    return new Promise((r, j) => {
+        /* const loading = ElLoading.service({
+            lock: true,
+            text: '加载中',
+            background: 'rgba(0, 0, 0, 0.7)'
+        }) */
+        let areaRoute = window.location.pathname.split('/')
+        let params = {
+            route: areaRoute[1]
+        }
+        proxy.$api.getWebsite(params).then(res => {
+            if(res.code === 200) {
+                // res.website.blobUrl = res.blobUrl
+                store.setWebsite(res.website)
+                otherWebsite.value = res.websites
+            } else if(res.code === 2) {
+                ElMessage({
+                    type: 'error',
+                    message: '未匹配分站'
+                })
+            }
+            r(200)
+        }).finally(() => {
+            // loading.close()
+        })
+    })
+    
+}
+
+function webSelChange() {
+    /* let host = window.location.host
+    location.href = host + '/' + webSelect.value + '/home/homePage' */
+    // 切换到对应站点???
+}
+function toTeammodel() {
+    location.href = "https://account.teammodel.cn/?callback=" + window.location.href
+}
+
+function login() {
+    /* const loading = ElLoading.service({
+        lock: true,
+        text: '登录中',
+        background: 'rgba(0, 0, 0, 0.7)'
+    }) */
+    // 获取登录后返回的ticket
+    let search = window.location.search
+    let ticket = ''
+    let token = ''
+    if(search.includes('ticket')) {
+        ticket = search.split('?')[1].split("&")[0].split('ticket=')[1]
+    } else if(search.includes('token')) {
+        token = search.split('?token=')[1]
+    }
+    let areaRoute = window.location.pathname.split('/')
+    // ticket 只用一次,不用存在localStorage
+    // token token过期或从其他活动跳转过来传
+    let params = {
+        route: areaRoute[1],
+    }
+    if(ticket) {
+        params.ticket = ticket
+    } else if(token) {
+        params.token = token
+    }
+    proxy.$api.loginPortal(params).then(res => {
+        // console.log('vrthjjyt', loading);
+        if(res.code === 200) {
+            localStorage.setItem("auth_token", res.token)
+            localStorage.setItem("access_token", res.auth_token.access_token)
+            localStorage.setItem("schools", JSON.stringify(res.schools))
+            localStorage.setItem("expires_in", res.auth_token.expires_in)
+            store.setSchoolList(res.schools)
+            store.setUserInfo(jwtDecode(res.token))
+            userInfo.value = jwtDecode(res.token)
+            router.push({path: '/home/homePage'})
+        }
+    }).finally(() => {
+        // loginLoad.close()
+    })
+}
+
+let currentSite = computed(() => {
+    return window.location.pathname.split('/')[1]
+})
+
+// 监听是否带参数,
+let ticket = computed(() => {
+    let search = window.location.search
+    if(search.includes('ticket')) {
+        return search.split('?')[1].split("&")[0].split('ticket=')[1]
+    } else {
+        return ''
+    }
+})
+
+// 监听是否带参数,
+let authToken = computed(() => {
+    let search = window.location.search
+    if(search.includes('token')) {
+        return search.split('?token=')[1]
+    } else {
+        return ''
+    }
+})
+
+watch(route, (newVal, oldVal) => {
+    activeIndex.value = newVal.meta.activeName
+}, {deep: true}, {immediate: true})
+
+watch([ticket, authToken], ([newValue1, newValue2], [oldValue1, oldValue2]) => {
+    if([newValue1, newValue2]) {
+        login()
+    }
+}, {immediate: true})
+
+</script>
+
+<style lang="less">
+.common-layout {
+    height: 100%;
+
+    .el-main {
+        padding: 0;
+        height: 100%;
+    }
+    .head-photo {
+        position: absolute;
+        top: 11px;
+        right: 40px;
+        margin-right: 40px;
+        cursor: pointer;
+        .user_avatar{
+            width: 36px;
+            height: 36px;
+            border-radius: 50%;
+            background-color: #72727f;
+        }
+    }
+    .website-name {
+        position: absolute;
+        left: 50%;
+        top: 50%;
+        transform: translate(-50%, -50%);
+    }
+    .el-header {
+        position: relative;
+        // padding: 0;
+        // border-bottom: solid 1px var(--el-menu-border-color);
+        background-color: #15559a;
+        color: #fff;
+        
+        a {
+            text-decoration: none; /* 移除链接默认的下划线 */
+        }
+        
+        .el-menu {
+            background-color: #15559a;
+            min-width: 350px;
+        }
+        .el-menu--horizontal.el-menu {
+            border-bottom: none;
+        }
+        .el-menu--horizontal>.el-menu-item {
+            color: #F1F1E6;
+        }
+
+        .el-menu--horizontal>.el-menu-item.is-active {
+            border-bottom-color: #fff;
+            // border-bottom: none;
+            color: #fff !important;
+            // font-size: 16px;
+            font-weight: bold;
+        }
+        .el-menu--horizontal .el-menu-item:not(.is-disabled):focus, .el-menu--horizontal .el-menu-item:not(.is-disabled):hover {
+            background-color: #15559a;
+            // font-size: 16px;
+            // font-weight: bold;
+            border-bottom: 2px solid #fff;
+        }
+    }
+    .el-avatar {
+        outline: none;
+    }
+}
+</style>

File diff suppressed because it is too large
+ 153 - 0
TEAMModelContest/contest.client/src/view/Login.vue


+ 220 - 0
TEAMModelContest/contest.client/src/view/activitylist/ActivityInfo.less

@@ -0,0 +1,220 @@
+.act-info {
+    height: 100%;
+    background: linear-gradient(0deg, #c3d9e9, transparent);
+    overflow: auto;
+
+    .list-header {
+        height: 45px;
+        box-shadow: 0px 2px 5px #e9e9e9;
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+        padding: 0 15px;
+
+        &>div:first-of-type {
+            cursor: pointer;
+            display: flex;
+
+            .el-icon {
+                margin-right: 5px;
+            }
+        }
+
+        .title {
+            font-size: 18px;
+            font-weight: bold;
+        }
+
+        .type-box {
+            background-color: #FF9900;
+            font-size: 15px;
+            font-weight: normal;
+            color: #fff;
+            padding: 3px 6px;
+            border-radius: 5px;
+            margin-right: 5px;
+        }
+
+        .join-btn {
+            background: #67C23A;
+            color: #fff;
+            padding: 7px 10px;
+            border-radius: 5px;
+            cursor: pointer;
+            font-size: 14px;
+            margin-right: 10px;
+
+            &:hover {
+                background: #95D475;
+            }
+        }
+    }
+
+    .info-box {
+        width: 70%;
+        height: 90%;
+        margin: auto;
+        padding: 20px 0;
+        font-size: 16px;
+
+
+
+        .img-box {
+            // height: 500px;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+
+            img {
+                width: auto;
+                max-width: 100%;
+                max-height: 500px;
+                border-radius: 5px;
+            }
+        }
+
+        // .sk-box{
+        .sk-title {
+            background-color: #eaeaea;
+            padding: 10px;
+            margin-bottom: 10px;
+            margin-top: 25px;
+        }
+
+        .sk-info {
+            margin-bottom: 20px;
+
+            // padding: 0 10px;
+            &>p {
+                margin: 10px;
+            }
+
+            .activity-info {
+                background-color: #fff;
+                border-radius: 5px;
+                // padding: 20px;
+                margin-top: 20px;
+
+                .title-time {
+                    display: flex;
+                    justify-content: space-between;
+                    font-size: 20px;
+                    padding: 20px;
+                    border-bottom: 1px dashed #ccc;
+                }
+
+                .detail-info {
+                    padding: 20px;
+                    color: #565656;
+
+                    &>p {
+                        margin-bottom: 15px;
+                    }
+
+                    .attach-name {
+                        cursor: pointer;
+                        margin-bottom: 10px;
+
+                        &:hover {
+                            color: #0d7001;
+                            font-weight: bold;
+                        }
+                    }
+
+                    .score-box {
+                        // padding-top: 20px;
+                        margin-top: 20px;
+                        border-top: 1px dashed #ccc;
+                        display: flex;
+                        justify-content: space-around;
+
+                        &>div {
+                            text-align: center;
+                            max-width: 25%;
+                            border-right: 1px dashed #ccc;
+                            padding-top: 20px;
+
+                            &:last-of-type {
+                                border: none;
+                            }
+                        }
+
+                        .score-lable {
+                            margin-bottom: 10px;
+
+                            &>p {
+                                margin-bottom: 10px;
+                            }
+                        }
+
+                        .score-info {
+                            display: flex;
+                            flex-wrap: wrap;
+                            justify-content: center;
+
+                            &>div {
+                                text-align: center;
+                                margin: 5px 10px;
+                                min-width: 85px;
+                            }
+                        }
+                    }
+
+                    .score-table {
+
+                        // padding-top: 20px;
+                        // margin: 20px 0;
+                        // border-top: 1px dashed #ccc;
+                        display: flex;
+                        justify-content: center;
+
+                        &>div {
+                            text-align: center;
+                            padding: 0 20px;
+                            margin-top: 10px;
+
+                            &:last-of-type {
+                                border: none;
+                            }
+                        }
+
+                        .score-lable {
+                            margin-bottom: 10px;
+
+                            &>p {
+                                margin-bottom: 10px;
+                            }
+                        }
+
+                        .score-info {
+                            display: flex;
+                            flex-wrap: wrap;
+                            justify-content: center;
+
+                            &>div {
+                                text-align: center;
+                                margin: 5px 10px;
+                                min-width: 85px;
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
+        // }
+    }
+
+    .tips-box {
+        margin-bottom: 10px;
+        color: #606266;
+        font-size: 15px;
+        margin-left: 100px;
+    }
+
+    .module-box {
+        cursor: pointer;
+        margin-bottom: 10px;
+        color: #535353;
+    }
+}

+ 630 - 0
TEAMModelContest/contest.client/src/view/activitylist/ActivityInfo.vue

@@ -0,0 +1,630 @@
+<template>
+    <div class="act-info">
+        <div class="list-header">
+            <div @click="router.go(-1)">
+                <el-icon><ArrowLeft /></el-icon>
+                返回活动列表
+            </div>
+            <p class="title">
+                <span class="type-box" :style="{'background-color': actInfo.scope === 'area' ? '#ff9900' : (actInfo.scope === 'school' ? '#19be43' : '#a8a8a8')}">
+                    {{ actInfo.scope === 'public' ? '公开' : (actInfo.scope === 'area' ? '区级' : '校级') }}
+                </span>
+                {{ actInfo.name }}
+            </p>
+            <div class="join-btn" @click="openDraw">
+                {{ registered === 1 ? '已报名' : '报名' }}
+            </div>
+        </div>
+        <div style="height: 94%">
+            <el-scrollbar>
+                <div class="info-box">
+                    <div class="sk-info">
+                        <div class="img-box">
+                            <img :src="actInfo.posterSas" alt="">
+                        </div>
+                        <div class="activity-info">
+                            <h1 class="title-time">
+                                <span>活动信息</span>
+                                <span style="font-size: 16px; color: #205bd0;">
+                                    <span>{{ $tools.formatTime(actInfo.stime, 'yyyy-MM-dd') }}</span> - <span>{{ $tools.formatTime(actInfo.etime, 'yyyy-MM-dd') }}</span>
+                                </span>
+                            </h1>
+                            <div class="detail-info">
+                                <p>主题:{{ actInfo.subject || '-' }}</p>
+                                <p>简介:{{ actInfo.description || '-' }}</p>
+                                <p>地点:{{ actInfo.address || '-' }}</p>
+                                <p>主办:
+                                    <span v-for="(item, index) in actInfo.zb" :key="index">{{ item || '-' }}</span>
+                                </p>
+                                <p>承办:
+                                    <span v-for="(item, index) in actInfo.zb" :key="index">{{ item || '-' }}</span>
+                                </p>
+                                <p>免责声明:{{ actInfo.mzsm || '-' }}</p>
+                                <div style="display: flex;">
+                                    <p style="min-width: 50px;">附件:</p>
+                                    <div v-if="actInfo.attachment.length">
+                                        <p v-for="item in actInfo.attachment" :key="item.blob" @click="onPreview(item)" class="attach-name">{{ item.name }}</p>
+                                    </div>
+                                    <span v-else>-</span>
+                                </div>
+                            </div>
+                        </div>
+                        <template v-if="actInfo.modules">
+                            <div v-if="actInfo.modules.includes('Contest')" class="activity-info">
+                                <h1 class="title-time">赛课活动</h1>
+                                <div class="detail-info">
+                                    <el-steps :active="contestStep" align-center>
+                                        <el-step v-for="(item, index) in contest.modules" :key="index" :title="skModuleList[index]">
+                                            <template v-slot:icon>
+                                                <el-icon v-show="item === 'sign'" size="20"><Edit /></el-icon>
+                                                <el-icon v-show="item === 'upload'" size="20"><Folder /></el-icon>
+                                                <el-icon v-show="item === 'review'" size="20"><Finished /></el-icon>
+                                                <el-icon v-show="item === 'score'" size="20"><CircleCheck /></el-icon>
+                                            </template>
+                                            <template v-slot:description>
+                                                <span>
+                                                    {{ $tools.formatTime(contest[item].stime, 'yyyy-MM-dd') }}
+                                                    -
+                                                    {{ $tools.formatTime(contest[item].etime, 'yyyy-MM-dd') }}
+                                                </span>
+                                            </template>
+                                        </el-step>
+                                    </el-steps>
+                                    <!-- <div v-if="contestScores.length && contest.score.scoreStatus" class="score-box">
+                                        <div v-for="(item, index) in contestScores" :key="index">
+                                            <div class="score-lable" v-show="contest.score.showType">
+                                                <p>{{ item.lable }}</p>
+                                                <el-icon color="#ffd111" size="18" v-for="scores in item.scores" :key="scores.tmdid"><Trophy /></el-icon>
+                                            </div>
+                                            <div class="score-info">
+                                                <div v-for="scores in item.scores" :key="scores.tmdid">
+                                                    <el-avatar :size="35" :src="scores.picture" />
+                                                    <p style="font-size: 14px; margin-top: 5px;">
+                                                        {{ scores.type ? scores.teamName : scores.name }}({{ scores.score }}分)
+                                                    </p>
+                                                </div>
+                                            </div>
+                                        </div>
+                                    </div> -->
+                                    <div v-if="contestScores.length && contest.score.scoreStatus" style="margin-top: 20px;">
+                                        <el-table :data="contestScores" style="width: 100%">
+                                            <el-table-column prop="lable" label="奖项名称" align="center" width="200">
+                                                <template #default="scope">
+                                                    <p>{{ scope.row.lable }}</p>
+                                                    <p>
+                                                        <el-icon color="#ffd111" size="18" v-for="scores in scope.row.scores" :key="scores.tmdid"><Trophy /></el-icon>
+                                                    </p>
+                                                </template>
+                                            </el-table-column>
+                                            <el-table-column label="获奖人员" align="center">
+                                                <template #default="scope">
+                                                    <div class="score-table">
+                                                        <div v-for="scores in scope.row.scores" :key="scores.tmdid">
+                                                            <el-avatar :size="35" :src="scores.picture" />
+                                                            <p style="font-size: 14px; margin-top: 5px;">
+                                                                {{ scores.type ? scores.teamName : scores.name }}({{ scores.score }}分)
+                                                            </p>
+                                                        </div>
+                                                    </div>
+                                                </template>
+                                            </el-table-column>
+                                        </el-table>
+                                    </div>
+                                </div>
+                            </div>
+                        </template>
+                    </div>
+                </div>
+            </el-scrollbar>
+        </div>
+        <el-drawer v-model="joinDra">
+            <template #header>
+                <h4>活动报名</h4>
+            </template>
+            <template #default>
+                <div v-if="actInfo?.modules.includes('Contest')">
+                    <el-form label-width="100px" class="join-box">
+                        <el-form-item label="学校">
+                            <el-select v-model="schoolIndex" placeholder="请选择">
+                                <el-option v-for="(value, index) in schoolList" :key="index" :label="value.name" :value="index" />
+                            </el-select>
+                        </el-form-item>
+                    </el-form>
+                    <p class="module-box" @click="skModule = !skModule">
+                        <el-icon v-show="skModule"><CaretBottom /></el-icon>
+                        <el-icon v-show="!skModule"><CaretRight /></el-icon>
+                        赛课活动
+                    </p>
+                    <div v-show="skModule" v-if="contest.sign.fields.length">
+                        <el-form ref="enrollForm" :rules="rules" :model="enrollData" label-width="100px" class="join-box">
+                            <el-form-item :label="item.label" v-for="(item, index) in contest.sign.fields" :key="index" :prop="item.field">
+                                <el-radio-group v-model="enrollData.enrollInfos[index].val" v-show="item.field === 'sex'">
+                                    <el-radio label="male">男</el-radio>
+                                    <el-radio label="female">女</el-radio>
+                                    <el-radio label="secrecy">保密</el-radio>
+                                </el-radio-group>
+                                <el-input v-special-char v-show="item.type === 'text'" v-model.trim="enrollData.enrollInfos[index].val" placeholder="请输入" />
+                                
+                                <el-select v-show="item.type === 'select'" v-model="enrollData.enrollInfos[index].val" @change="deleteRule(item.field)" placeholder="请选择">
+                                    <el-option v-for="(value, index) in item.item" :key="index" :label="value" :value="value" />
+                                </el-select>
+                                <el-select v-if="item.field === 'school'" v-model="enrollData.enrollInfos[index].val" @change="deleteRule(item.field)" placeholder="请选择">
+                                    <el-option v-for="(value, index) in schoolList" :key="index" :label="value.name" :value="value.schoolId" />
+                                </el-select>
+                            </el-form-item>
+                            <template v-if="contest.sign.type">
+                                <p class="tips-box">
+                                    <el-icon color="#e9b207"><WarningFilled /></el-icon>
+                                    本次活动为团队赛,请创建团队或加入团队
+                                </p>
+                                <el-form-item label="组内身份">
+                                    <el-select v-model="enrollData.leader" placeholder="请选择您在团队赛中的身份">
+                                        <el-option label="组长" :value="1" />
+                                        <el-option label="组员" :value="0" />
+                                    </el-select>
+                                </el-form-item>
+                                <el-form-item label="团队名称" v-if="enrollData.leader">
+                                    <el-input v-special-char v-model.trim="enrollData.teamName" />
+                                </el-form-item>
+                                <el-form-item label="组队口令" v-if="!enrollData.leader" class="code-box">
+                                    <el-input v-special-char v-model.trim="enrollData.cipher" />
+                                </el-form-item>
+                                <template v-if="teamMembers.length">
+                                    <el-form-item label="组队名称">
+                                        <p>{{ enrollData.teamName }}</p>
+                                    </el-form-item>
+                                    <el-form-item label="组队成员">
+                                        <p v-for="(item, index) in teamMembers" :key="index">
+                                            {{ item.tmdName }}
+                                            <span v-show="item.leader">(队长)</span>
+                                        </p>
+                                    </el-form-item>
+                                </template>
+                                <p class="tips-box" v-show="!enrollData.leader">
+                                    <el-button @click="searchCipher()" color="#626aef">搜索团队信息</el-button>
+                                </p>
+                            </template>
+                        </el-form>
+                    </div>
+                </div>
+                <div v-if="actInfo?.modules.includes('Training')">
+                    <p class="module-box" @click="yxModule = !yxModule">
+                        <el-icon v-show="yxModule"><CaretBottom /></el-icon>
+                        <el-icon v-show="!yxModule"><CaretRight /></el-icon>
+                        教培活动
+                    </p>
+                    <div v-show="yxModule">
+                        <el-form label-width="100px" class="join-box">
+                            <el-form-item label="醍摩豆账号">
+                                <el-input v-special-char v-model.trim="enrollData.tmdid" disabled />
+                            </el-form-item>
+                        </el-form>
+                    </div>
+                </div>
+                <div v-if="actInfo?.modules.includes('Research')">
+                    <p class="module-box" @click="jyModule = !jyModule">
+                        <el-icon v-show="jyModule"><CaretBottom /></el-icon>
+                        <el-icon v-show="!jyModule"><CaretRight /></el-icon>
+                        教研活动
+                    </p>
+                    <div v-show="jyModule">
+                        <el-form label-width="100px" class="join-box">
+                            <el-form-item label="醍摩豆账号">
+                                <el-input v-special-char v-model.trim="enrollData.tmdid" disabled />
+                            </el-form-item>
+                        </el-form>
+                    </div>
+                </div>
+            </template>
+            <template #footer>
+                <div style="text-align: left;" v-show="contest.sign?.type && enrollData.teamName || !contest.sign?.type">
+                    <el-button type="primary" @click="genCipher(enrollForm)">报名</el-button>
+                </div>
+            </template>
+        </el-drawer>
+        <div v-if="previewStatus" class="image-viewer">
+			<div style="width:fit-content;position:relative;margin:auto;" v-if="previewFile.type != 'image'">
+                <el-icon class="close-icon" @click="previewStatus = false"><CloseBold /></el-icon>
+				<video v-if="previewFile.type == 'video'" id="previewVideo" :src="previewFile.urlShow" width="870"
+					controls="controls" style="max-height: 800px;">
+					您的浏览器不支持 video 标签。
+				</video>
+				<audio v-else-if="previewFile.type === 'audio'" controls>
+					<source :src="previewFile.urlShow">
+					您的浏览器不支持 audio 元素。
+				</audio>
+			</div>
+            <el-image-viewer v-else hide-on-click-modal @close="previewStatus = false" :url-list="[previewFile.urlShow]" />
+		</div>
+    </div>
+</template>
+
+<script setup>
+import { ArrowLeft, WarningFilled, Search, CircleCheck, CircleClose, CaretBottom, CaretRight, Message, Edit, Finished, Folder, CloseBold, Trophy } from '@element-plus/icons-vue'
+import { ElMessageBox, ElMessage, ElLoading } from 'element-plus'
+import { useStore } from "@/pinia/common"
+import { reactive, ref, onMounted, watch, toRaw, getCurrentInstance } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+let route = useRoute()
+let router = useRouter()
+let store = useStore()
+let { proxy } = getCurrentInstance()
+
+let joinDra = ref(false)
+let skModule = ref(true)
+let yxModule = ref(true)
+let jyModule = ref(true)
+let codeSearch = ref(true) //搜索标识码
+let previewStatus = ref(false)
+let enrollForm = ref(null)
+let registered = ref(0) //是否可以报名 0:可以 1:已经报名 2:所在学校未被邀请 3:不在报名时间
+let contestStep = ref(0)
+let schoolIndex = ref('')
+let contest = ref({})
+let research = ref({})
+let training = ref({})
+const infoId = ref(route.params.info)
+const actInfo = ref({attachment: []})
+let rules = ref({})
+let previewFile = ref({})
+let teamMembers = ref([])
+let contestScores = ref([])
+
+let loading = reactive({
+    lock: true,
+    text: '加载中',
+    background: 'rgba(0, 0, 0, 0.7)'
+})
+let schoolList = reactive([])
+const enrollData = reactive({
+    tmdid: store.userInfo.sub,
+    tmdPicture: store.userInfo.picture,
+    tmdName: store.userInfo.name,
+    schoolName: '',
+    schoolPicture: '',
+    schoolId: '',
+    cipher: '', //团队赛,组队口令不能为空
+    enrollTime: '', //报名时间
+    leader: 0, //1 队长,0 队员
+    teamName: '',
+    type: null, //1团队。0个人
+    enrollInfos: [],
+})
+const skModuleList = ref(['报名', '上传', '评审', '公示'])
+
+const validateInfo = (rule, value, callback) => {
+    let info = enrollData.enrollInfos.find(item => item.code === rule.field)
+    if(!info.val) {
+        callback(new Error('请完善信息'))
+    } else {
+        callback()
+    }
+}
+
+onMounted(() => {
+    getListInfo()
+})
+
+function getListInfo() {
+    /* loading = ElLoading.service({
+        lock: true,
+        text: '加载中',
+        background: 'rgba(0, 0, 0, 0.7)'
+    }) */
+    let params = {activityId: infoId.value}
+    proxy.$api.getActInfo(params).then(async res => {
+        if(res.code === 200) {
+            res.activity.posterSas = !res?.activity.poster ? require('@/assets/img/no-poster-cn.jpg') : res?.activity.url + res?.activity.poster + '?' + res?.activity.sas
+            res?.activity.attachment.forEach(item => {
+                item.urlShow = res?.activity.url + item.url + '?' + res?.activity.sas
+            })
+            actInfo.value = res.activity
+            // 公开 && 未指定学校 可以参加
+            if(!actInfo.value?.invitedSchools.length && actInfo.value?.scope === 'public') {
+                schoolList = reactive(store.schoolList)
+            } else if(actInfo.value?.scope === 'area' || actInfo.value?.invitedSchools.length) {
+                actInfo.value?.confirmedSchools.forEach(item => {
+                    let school = store.schoolList.find(sch => sch.schoolId === item.id)
+                    if(school) {
+                        schoolList.push(school)
+                    }
+                })
+            } else if(actInfo.value?.scope === 'school') {
+                let school = store.schoolList.find(sch => sch.schoolId === actInfo.value.owner)
+                if(school) schoolList.push(school)
+            }
+
+            let nowTime = Date.parse(new Date())
+            res.contest.modules.forEach((item, index) => {
+                res.contest[item].actState = nowTime < res.contest[item].stime ? 'notStart' : (nowTime > res.contest[item].etime ? 'finish' : 'going')
+            })
+            registered.value = nowTime > res.contest.sign.stime && nowTime < res.contest.sign.etime ? 0 : 3
+            let index = res.contest.modules.findIndex(item => {
+                return res.contest[item].actState === 'going'
+            })
+            contestStep.value = index === -1 ? (registered.value === 3 ? -1 : 4) : index
+            contest.value = res.contest
+            enrollData.type = res.contest.sign.type
+            enrollData.enrollInfos = contest.value.sign.fields.map(item => {
+                return {
+                    code: item.field,
+                    val: item.field === 'sex' ? 'secrecy' : ''
+                }
+            })
+            contestScores.value = res.contestScores || []
+            research.value = res.research
+            training.value = res.training
+            getEnroll()
+        } else {
+            ElMessage({
+                type: 'warning',
+                message: '活动不存在!'
+            })
+            router.go(-1)
+        }
+    }).finally(() => {
+        // loading.close()
+    })
+}
+
+// 判断是否已经参赛
+function getEnroll() {
+    let params = {
+        grant_type: 'get-enroll',
+        activityId: infoId.value
+    }
+    proxy.$api.teaContest(params).then(res => {
+        if(res?.enroll) {
+            registered.value = 1
+            contestStep.value = (!contestStep.value && registered.value === 1 && contest.value.upload.actState === 'going') ? 1 : contestStep.value
+            return
+        }
+        if(registered.value != 3 && actInfo.value?.scope != 'school') {
+            registered.value = schoolList.length ? (res.code === 200 ? 1 : 0) : 2
+            contestStep.value = (!contestStep.value && registered.value === 1 && contest.value.upload.actState === 'going') ? 1 : contestStep.value
+        }
+        if(!registered.value) {
+            getTeaEnroll()
+        }
+    }).finally(() => {
+    })
+}
+
+function getTeaEnroll() {
+    let params = {
+        grant_type: 'get-teacher-enroll'
+    }
+    proxy.$api.teaContest(params).then(res => {
+        if(res.code === 200) {
+            res.teacherEnroll.enrollInfos.forEach(item => {
+                let fieldsIndex = contest.value.sign.fields.findIndex(fields => fields.field === item.code)
+                if(fieldsIndex != -1) {
+                    if((item.code === 'period' || item.code === 'subject') && contest.value.sign.fields[fieldsIndex].type === 'select') {
+                        enrollData.enrollInfos[fieldsIndex].val = contest.value.sign.fields[fieldsIndex].item.includes(item.val) ? item.val : ''
+                    } else {
+                        enrollData.enrollInfos[fieldsIndex].val = item.val
+                    }
+                }
+            })
+            schoolIndex.value = schoolList.findIndex(school => school.schoolId === res.teacherEnroll.schoolId)
+            schoolIndex.value = schoolIndex.value === -1 ? '' : schoolIndex.value
+        }
+    })
+}
+
+function searchCipher() {
+    let params = {
+        grant_type: 'search-team-by-cipher',
+        activityId: infoId.value,
+        cipher: enrollData.cipher
+    }
+    proxy.$api.teaContest(params).then(res => {
+        if(res.code === 200) {
+            if(res.teamMembers.length) {
+                teamMembers.value = res.teamMembers
+                enrollData.teamName = res.teamMembers.find(item => item.leader).teamName
+            } else {
+                ElMessage({
+                    type: 'warning',
+                    message: '未找到相关团队信息'
+                })
+            }
+        } else {
+            ElMessage({
+                type: 'warning',
+                message: '未找到相关团队信息'
+            })
+        }
+    }).finally(() => {
+    })
+}
+
+// 队长身份要先生成组队口令
+async function genCipher(formEl) {
+    formEl.validate(async (valid, ddd) => {
+        if(valid && schoolIndex.value !== '') {
+            if(contest.value.sign.type && enrollData.leader) {
+                let params = {
+                    grant_type: 'gen-cipher',
+                    activityId: infoId.value
+                }
+                proxy.$api.teaContest(params).then(async res => {
+                    if(res.code === 200) {
+                        console.log(res);
+                        enrollData.cipher = res.cipher
+                        await signContest()
+                    }
+                }).catch(() => {
+                })
+            } else {
+                await signContest()
+            }
+        } else {
+            console.log('fegjmj,', valid, schoolIndex.value);
+            ElMessage({
+                type: 'warning',
+                message: '请完善信息'
+            })
+        }
+    })
+}
+// 保存报名信息
+function signContest() {
+    return new Promise((resolve, reject) => {
+        enrollData.schoolName = schoolList[schoolIndex.value].name
+        enrollData.schoolPicture = schoolList[schoolIndex.value].picture
+        enrollData.schoolId = schoolList[schoolIndex.value].schoolId
+        enrollData.enrollTime = Date.parse(new Date())
+        let params = {
+            grant_type: 'sign-contest',
+            activityId: infoId.value,
+            enrollData,
+            mock: true, //填写表示是测试数据
+        }
+        proxy.$api.teaContest(params).then(res => {
+            switch(res.code) {
+                case 1:
+                    ElMessage({
+                        type: 'error',
+                        message: '报名信息未完善'
+                    })
+                    break
+                case 2:
+                    ElMessage({
+                        type: 'error',
+                        message: '请以学校教师身份参加本次活动'
+                    })
+                    break
+                case 9:
+                    ElMessage({
+                        type: 'error',
+                        message: '参加组别不一致'
+                    })
+                    break
+                case 11:
+                    ElMessage({
+                        type: 'error',
+                        message: '队伍未组建或不存在'
+                    })
+                    break
+                case 18:
+                    ElMessage({
+                        type: 'error',
+                        message: '报名学校与组队学校不一致'
+                    })
+                    break
+                case 35:
+                    ElMessage({
+                        type: 'error',
+                        message: '已过报名时间'
+                    })
+                    break
+                case 200:
+                    ElMessage({
+                        type: 'success',
+                        message: '报名成功'
+                    })
+                    registered.value = 1
+                    contestStep.value = (!contestStep.value && contest.value.upload.actState === 'going') ? 1 : contestStep.value
+                    joinDra.value = false
+                    break
+                default:
+                    ElMessage({
+                        type: 'error',
+                        message: '报名失败'
+                    })
+                    break
+            }
+        }).finally(() => {
+        }).finally(() => {
+        })
+    })
+}
+
+function openDraw() {
+    if(registered.value === 1) {
+        return
+    }
+    if(registered.value) {
+        ElMessage({
+            type: 'warning',
+            message: registered.value === 1 ? '已经报名该活动' : (registered.value === 2 ?  '您所在学校未被邀请参与活动' : '不在报名时间')
+        })
+    } else {
+        contest.value.sign.fields.forEach(item => {
+            rules.value[item.field] = { required: true, message: '不能为空', trigger: 'blur', validator: validateInfo }
+        })
+        joinDra.value = true
+    }
+}
+
+/* 预览 */
+async function onPreview(item) {
+    let url = item.urlShow
+    console.log('gr5e4gt4h', url);
+    if (proxy.$tools.getSuffix(item.name) === 'pdf') {
+        window.open('https://www.teammodel.cn/web/viewer.html?file=' + encodeURIComponent(url));
+    } else if(item.type === 'doc') {
+        window.open('https://view.officeapps.live.com/op/view.aspx?src=' + escape(url));
+    // } else if(item.type === 'image') {
+        // $hevueImgPreview(url)
+    } else if(item.type === 'link') {
+        window.open(/^(http:|https:)/i.test(url) ? url : "http://" + url)
+    } else {
+        previewFile.value = item
+        previewStatus.value = true
+    }
+}
+
+function deleteRule(type) {
+    enrollForm.value.clearValidate(type)
+}
+
+watch(() => enrollData.cipher, (newdata) => {
+    codeSearch.value = true
+    teamMembers.value = []
+    enrollData.teamName = ''
+})
+
+</script>
+
+<style scoped lang="less">
+@import "./ActivityInfo.less";
+</style>
+
+<style lang="less">
+.join-box {
+    .el-select {
+        width: 100%;
+    }
+    .code-box {
+        position: relative;
+
+        .el-icon {
+            position: absolute;
+            top: 0;
+            right: 1px;
+            padding: 9px;
+        }
+        .icon-Search {
+            cursor: pointer;
+        }
+    }
+}
+.detail-info {
+    .el-step__head.is-finish,
+    .el-step__title.is-finish,
+    .el-step__description.is-finish {
+        color: #85bc73;
+        border-color: #9cba92;
+    }
+
+    .el-step__head.is-process,
+    .el-step__title.is-process,
+    .el-step__description.is-process {
+        color: #409eff;
+        border-color: #409eff;
+
+    }
+}
+</style>

+ 116 - 0
TEAMModelContest/contest.client/src/view/activitylist/ActivityList.less

@@ -0,0 +1,116 @@
+.activity-list {
+    width: 60%;
+    height: 100%;
+    margin: auto;
+    padding: 50px 50px;
+
+    .head-label {
+        height: 20px;
+        border-left: 3px solid green;
+        padding-left: 10px;
+        font-size: 18px;
+        margin-bottom: 15px;
+        font-weight: bold;
+        color: #535353;
+    }
+
+    .review-box {
+        display: flex;
+        margin-bottom: 110px;
+
+        .review-carousel {
+            width: 55%;
+            margin-right: 55px;
+        }
+
+        .lesson-list {
+            margin-bottom: 10px;
+        }
+
+        .lesson-info {
+            margin-bottom: 10px;
+            cursor: pointer;
+            color: #6a6a6a;
+
+            &:hover {
+                color: #3346c2;
+            }
+        }
+    }
+
+    .lesson-list {
+        display: flex;
+        flex-wrap: wrap;
+        width: 100%;
+
+        .lesson-info {
+            width: 30%;
+            margin-right: 30px;
+            margin-bottom: 20px;
+            position: relative;
+            cursor: pointer;
+
+            &:hover {
+                box-shadow: 0 26px 40px -24px rgba(0, 36, 100, .3);
+                -webkit-transform: translateY(-6px);
+                transform: translateY(-6px);
+                -webkit-transition: all .3s ease 0s;
+                transition: all .3s ease 0s;
+            }
+
+            img {
+                width: 100%;
+                display: block;
+            }
+
+            .info-box {
+                height: 150px;
+                border: 1px solid #ccc;
+                padding: 20px 15px;
+                color: #ccc;
+                line-height: 18px;
+
+                .lesson-name {
+                    text-align: center;
+                    overflow: hidden;
+                    text-overflow: ellipsis;
+                    white-space: nowrap;
+                    margin-bottom: 10px;
+                    color: #000;
+                }
+
+                .info-title {
+                    font-size: 14px;
+                    margin-bottom: 7px;
+
+                    &>span {
+                        color: #1B2773;
+                    }
+
+                    color: #6a6a6a;
+                }
+            }
+
+            .lesson-type {
+                position: absolute;
+                top: 0;
+                right: 0;
+                height: 20px;
+                line-height: 20px;
+                font-size: 14px;
+                text-align: center;
+                padding: 2px 10px;
+            }
+
+            .going {
+                background-color: rgb(8, 153, 8);
+                color: #fff;
+            }
+
+            .end {
+                background-color: #ccc;
+                color: #3f3f3f;
+            }
+        }
+    }
+}

+ 147 - 0
TEAMModelContest/contest.client/src/view/activitylist/ActivityList.vue

@@ -0,0 +1,147 @@
+<template>
+    <div class="activity-list">
+        <div class="review-box">
+            <div class="review-carousel">
+                <p class="head-label">精彩回顾</p>
+                <el-carousel trigger="click" :autoplay="false">
+                    <el-carousel-item v-for="(item, index) in carousel" :key="index">
+                        <img :src="item" alt="" />
+                    </el-carousel-item>
+                </el-carousel>
+            </div>
+            <div style="width: 40%;">
+                <p class="head-label">精彩课例</p>
+                <div>
+                    <div v-for="(item, index) in lessonList" :key="index" class="lesson-info">
+                        <p>{{ item.name }}</p>
+                    </div>
+                </div>
+            </div>
+        </div>
+        <div>
+            <p class="head-label">活动总览</p>
+            <div class="lesson-list">
+                <div v-for="(item, index) in lessonList" :key="index" class="lesson-info" @click="openInfo(item)">
+                    <img :src="item.img" alt="">
+                    <div class="info-box">
+                        <p class="lesson-name">{{ item.name }}</p>
+                        <p class="info-title">
+                            <span>简介</span>
+                            :{{ item.jianjie }}
+                        </p>
+                        <p class="info-title">
+                            <span>地点</span>
+                            :{{ item.location }}
+                        </p>
+                        <p class="info-title">
+                            <span>主办</span>
+                            :{{ item.zhuban }}
+                        </p>
+                    </div>
+                    <div :class="['lesson-type', item.type ? 'going' : 'end']">
+                        {{ item.type ? '进行中' : '已结束' }}
+                    </div>
+                </div>
+            </div>
+        </div>
+        <!-- <div>
+            <p class="head-label">已结束的活动</p>
+            <div class="lesson-list">
+                <div v-for="(item, index) in lessonList" :key="index" class="lesson-info">
+                    <img :src="item.img" alt="">
+                    <div>
+                        <p class="lesson-name">{{ item.name }}</p>
+                        <p class="info-title">
+                            <span>简介</span>
+                            :{{ item.jianjie }}
+                        </p>
+                        <p class="info-title">
+                            <span>地点</span>
+                            :{{ item.location }}
+                        </p>
+                        <p class="info-title">
+                            <span>主办</span>
+                            :{{ item.zhuban }}
+                        </p>
+                    </div>
+                </div>
+            </div>
+        </div> -->
+    </div>
+</template>
+
+<script setup>
+import { ref } from "vue"
+import { useRouter } from "vue-router"
+const router = useRouter()
+let carousel = ref([
+    require("@/assets/img/fengj.jpg"),
+    require("@/assets/img/noData1.jpg"),
+    require("@/assets/img/noData.png"),
+])
+let lessonList = ref([
+    {
+        img: require("@/assets/img/fengj.jpg"),
+        name: '2018好課堂_中學數學_高建鋒老師',
+        jianjie: '11111 1111 1111111 1  11111 111111 1 1   1111111',
+        location: '办公大楼101',
+        zhuban: '醍摩豆研究院',
+        type: 1,
+    },
+    {
+        img: require("@/assets/img/fengj.jpg"),
+        name: '2018大師盃_小學英文_李靜《Pets》_紫藤',
+        jianjie: '11111 1111 1111111 1  11111 111111 1 1   1111111',
+        location: '办公大楼101',
+        zhuban: '醍摩豆研究院',
+        type: 1,
+    },
+    {
+        img: require("@/assets/img/fengj.jpg"),
+        name: '2018大師盃_小學數學_王蕾《包裝的學問》_靈雀',
+        jianjie: '11111 1111 1111111 1  11111 111111 1 1   1111111',
+        location: '办公大楼101',
+        zhuban: '醍摩豆研究院',
+        type: 0,
+    },
+    {
+        img: require("@/assets/img/fengj.jpg"),
+        name: '2018特等獎《礼物》小学语文_牟守斌_成都市芳草小学',
+        jianjie: '11111 1111 1111111 1  11111 111111 1 1   1111111',
+        location: '办公大楼101',
+        zhuban: '醍摩豆研究院',
+        type: 0,
+    },
+    {
+        img: require("@/assets/img/fengj.jpg"),
+        name: '2018大师杯_小学语文_黄丽霞《活见鬼》_紫藤',
+        jianjie: '11111 1111 1111111 1  11111 111111 1 1   1111111',
+        location: '办公大楼101',
+        zhuban: '醍摩豆研究院',
+        type: 0,
+    },
+])
+let openInfo = (info) => {
+    router.push({
+        name: 'activityInfo',
+        state: { info }
+    })
+}
+</script>
+
+<style lang="less" scoped>
+@import "./ActivityList.less";
+</style>
+<style lang="less">
+.activity-list {
+    .el-carousel__container {
+        height: 370px;
+    }
+    .el-carousel__item {
+        img{
+            width: 100%;
+            height: 100%;
+        }
+    }
+}
+</style>

+ 277 - 0
TEAMModelContest/contest.client/src/view/activitylist/united.json

@@ -0,0 +1,277 @@
+https: //localhost:5001/activity/teacher-contest
+
+
+移交队长。
+{
+    "grant_type": "change-team-leader",
+    "activityId": "b17e063a-5866-b5d8-e5ba-25dc0d123051",
+    "targetLeader":"新的队长醍摩豆id",
+}
+
+1.
+{
+    "code": 1,
+    "msg": "你不是队长!"
+}
+{
+    "code": 2,
+    "msg": "你未参加本次活动"
+}
+{
+    "code": 3,
+    "msg": "指定的队长未参加本次活动"
+}
+{
+    "code": 4,
+    "msg": "指定的队长不是同一团队人员"
+}
+
+{
+    "code": 5,
+    "msg": "不能将队长移交给自己"
+}
+
+{
+    "code": 200,
+    "msg": "移交成功"
+}
+
+
+生成组队口令。
+{
+    "grant_type": "gen-cipher",
+    "activityId": "b17e063a-5866-b5d8-e5ba-25dc0d123051"
+}
+
+返回
+1.
+{
+    "code": 1,
+    "msg": "组队口令生成失败"
+}
+2. 
+{
+    "code": 200,
+    "cipher": "586895"
+}
+
+
+获取参数数据
+{
+    "grant_type": "get-enroll",
+    "activityId": "b17e063a-5866-b5d8-e5ba-25dc0d123051"
+}
+返回
+1.
+{
+    "code": 1,
+    "msg": "暂无报名数据!"
+}
+2.
+{
+    "code": 200,
+    "enroll": {个人参赛数据
+    }
+}
+
+
+退出参赛
+{
+    "grant_type": "cancel-enroll",
+    "activityId": "b17e063a-5866-b5d8-e5ba-25dc0d123051"
+}
+返回
+
+1.
+{
+    "code": 1,
+    "msg": "暂无报名数据!"
+}
+2.
+{
+    "code": 2,
+    "msg": "退出失败!"
+}
+3.
+{
+    "code": 3,
+    "msg": "请移交队长后再退出参赛!"
+}
+4.
+3.
+{
+    "code": 201,
+    "msg": "退出成功!"
+}
+
+
+
+根据口令获取团队和队员信息
+{
+    "grant_type": "search-team-by-cipher",
+    "activityId": "b17e063a-5866-b5d8-e5ba-25dc0d123051",
+    "cipher": "586895"
+}
+
+返回
+1.
+{
+    "code": 1,
+    "msg": "未找到团队!"
+}
+
+2.
+{
+    "code": 200,
+    "teamMembers": [
+        {
+            "tmdid": "",
+            "tmdPicture": "",
+            "tmdName": "",
+            "schoolName": "",
+            "schoolPicture": "",
+            "schoolId": "",
+            "cipher": "",
+            "enrollTime": 111111,
+            "leader": 1,
+            "teamName": "团队名称",
+            "type": 1
+        }
+    ]
+}
+
+
+教师报名参加
+
+{
+    "grant_type": "sign-contest",
+    "activityId": "b17e063a-5866-b5d8-e5ba-25dc0d123051",
+    "enrollData": {
+        "tmdid": "",
+        "tmdPicture": "",
+        "tmdName": "",
+        "schoolName": "",
+        "schoolPicture": "",
+        "schoolId": "",
+        "cipher": "团队组时,不能为空",
+        "enrollTime": 111111,
+        "leader": 1, //1 队长,0 队员
+        "teamName": "团队名称",
+        "type": 1, //1团队。0个人
+        "enrollInfos": [
+            {
+                "code": "表单key",
+                "val": "表单值"
+            }
+        ]
+    }
+}
+
+异常返回
+code: 0,msg=报名信息未完善
+code: 1,msg=教师未被邀请
+code: 2,msg=请以学校教师身份参加本次活动!
+code: 3,msg=学校未被邀请参与本次活动!
+code: 4,msg=学校未确认参与本次活动!
+code: 5,msg=不是本校的教师不能参加本次活动
+code: 6,msg=活动类型错误!
+code: 7,msg=不在报名时间范围内!
+code: 8,msg=已超过报名人数限制
+code: 9,msg=参加组别不一致!
+code: 10,msg=组队口令已被其他团队使用!
+code: 11,msg=队伍未组建或不存在!
+code: 12,msg=组队口令重复!
+code: 13,msg=活动未发布!
+code: 14,msg=活动未设置赛课模块!
+code: 15,msg=活动未设置信息填报模块!
+code: 16,msg=填报的学校与邀请的学校不一致
+code: 17,msg=你是队长,不能变更身份!
+
+正常返回
+{
+    "code": 200,
+    "enroll": {个人参赛数据
+    },
+    "teamMembers": [
+        {
+            "tmdid": "",
+            "tmdPicture": "",
+            "tmdName": "",
+            "schoolName": "",
+            "schoolPicture": "",
+            "schoolId": "",
+            "cipher": "",
+            "enrollTime": 111111,
+            "leader": 1,
+            "teamName": "团队名称",
+            "type": 1
+        }
+    ]
+}
+
+
+
+https: //localhost:5001/activity/manage
+获取活动邀请的教师。
+{
+    "grant_type": "invited-teachers",
+    "activityId": "b17e063a-5866-b5d8-e5ba-25dc0d123051",
+    "activityOwner": "可选,不填则默认是当前学校,填写可填区级id或者学校id" //如果是区级,则获取的是活动,各个学校邀请的所有教师, 如果是学校的id ,则获取的是本学校邀请的教师。
+}
+ 返回
+ {
+    "code": 200,
+    "inviteTeachers": [
+        {
+            "id": "教师id",
+            "name": "教师名称",
+            "picture": "头像",
+            "school": "学校id",
+            "schoolName": "学校名称",
+            "status": 0 //0未报名(已邀请,可取消邀请),1已报名,不可以取消邀请。
+        }
+    ]
+}
+
+
+展示活动中教师受邀,报名,作品上传状态 列表。用于展示所有报名的教师或邀请教师的列表
+{
+    "grant_type": "invited-and-enroll-teachers",
+    "activityId": "b17e063a-5866-b5d8-e5ba-25dc0d123051",
+    "activityOwner": "可选,不填则默认是当前学校,填写可填区级id或者学校id" //如果是区级,则获取的是活动,各个学校邀请的所有教师, 如果是学校的id ,则获取的是本学校邀请的教师。
+}
+
+返回
+{
+    "code": 200,
+    "inviteEnrollTeachers": [
+        {
+            "id": "教师id",
+            "name": "教师名称",
+            "picture": "头像",
+            "school": "学校id",
+            "schoolName": "学校名称",
+            "inviteStatus": 1, //-1表示非邀请制的默认状态,  0 未报名,1已报名,用于区级发布, publish=1,joinMode=invite,学校可以去进行邀请某一些教师。
+            "contestStatus": 1, //-1表示没有报名模块的默认状态,-2 表示时间未到,0未报名,1已报名
+            "contestTime": 11111111, //报名时间
+            "contestType": 1, //-1表示没有报名模块的默认状态,0个人,1 团队组
+            "uploadStatus": 1, // -1 表示没有上传模块的默认状态 0未上传,1已上传,-2 表示时间未到
+            "uploadTime": 111111111, //上传时间
+            "uploadType": "file", //null没有上传模块的默认状态,file文件  sokrates 苏格拉底
+            "uploadScore": -1, //作品分数,-1未评分
+            "uploadId": "112333333" //作品id
+            
+        }
+    ]
+}
+
+activity/get-activity-website   根据活动id获取活动对应的站点,用于只知道活动id,不知道站点的情况下使用, 例如生成活动的完整链接。
+{
+    "activityId":"活动id"
+}
+
+
+activity/read-activity  根据活动id获取活动的完整信息。包含报名模块,培训模块,教研模块,报名评审规则。  
+{
+    "activityId":"活动id"
+}

+ 119 - 0
TEAMModelContest/contest.client/src/view/basicInfo/BasicInfo.vue

@@ -0,0 +1,119 @@
+<template>
+  <div>
+    <el-alert title="您所填写的信息,会在报名活动时自动填充" type="warning" />
+    <el-form ref="enrollForm" :rules="rules" :model="teacherEnroll" label-width="120px" style="margin-top: 15px;">
+        <el-form-item prop="name" label="姓名">
+            <el-input v-special-char v-model.trim="teacherEnroll.name" />
+        </el-form-item>
+        <el-form-item prop="sex" label="性别">
+            <el-radio-group v-model="teacherEnroll.sex" @change="deleteRule('sex')">
+                <el-radio label="male">男</el-radio>
+                <el-radio label="female">女</el-radio>
+                <el-radio label="secrecy">保密</el-radio>
+            </el-radio-group>
+        </el-form-item>
+        <el-form-item prop="phone" label="手机号码">
+            <el-input v-special-char v-model="teacherEnroll.phone" placeholder="请输入" />
+        </el-form-item>
+        <el-form-item prop="email" label="电子邮箱">
+            <el-input v-special-char v-model="teacherEnroll.email" placeholder="请输入" />
+        </el-form-item>
+        <el-form-item prop="schoolId" label="学校">
+            <el-select v-model="teacherEnroll.schoolId" placeholder="请选择" @change="deleteRule('schoolId')">
+                <el-option v-for="item in store.schoolList" :key="item.schoolId" :label="item.name" :value="item.schoolId" />
+            </el-select>
+        </el-form-item>
+        <el-form-item prop="period" label="学段">
+            <el-input v-special-char v-model.trim="teacherEnroll.period" placeholder="请输入" />
+        </el-form-item>
+        <el-form-item prop="subject" label="学科">
+            <el-input v-special-char v-model.trim="teacherEnroll.subject" placeholder="请输入" />
+        </el-form-item>
+        <el-form-item>
+            <el-button type="primary" @click="setTeaEnroll(enrollForm)">保存</el-button>
+        </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script setup>
+import { ElMessage } from 'element-plus'
+import { ref, reactive, getCurrentInstance } from 'vue'
+import { useStore } from "@/pinia/common"
+
+let { proxy } = getCurrentInstance()
+let store = useStore()
+
+let teacherEnroll = ref({})
+let rules = ref({
+    name: {required: true, message: '不能为空', trigger: 'blur',},
+    sex: {required: true, message: '不能为空', trigger: 'blur',},
+    phone: {required: true, message: '不能为空', trigger: 'blur',},
+    email: {required: true, message: '不能为空', trigger: 'blur',},
+    schoolId: {required: true, message: '不能为空', trigger: 'blur',},
+    period: {required: true, message: '不能为空', trigger: 'blur',},
+    subject: {required: true, message: '不能为空', trigger: 'blur',},
+})
+let enrollForm = ref(null)
+
+getTeaEnroll()
+
+function getTeaEnroll() {
+    let params = {
+        grant_type: 'get-teacher-enroll'
+    }
+    proxy.$api.teaContest(params).then(res => {
+        if(res.code === 200) {
+            res.teacherEnroll.enrollInfos.forEach(item => {
+                teacherEnroll.value[item.code] = item.val
+            })
+            teacherEnroll.value.schoolId = res.teacherEnroll.schoolId
+        }
+    })
+}
+
+function setTeaEnroll(formEl) {
+    formEl.validate(async (valid, ddd) => {
+        if(valid) {
+            let params = {
+                grant_type: 'update-teacher-enroll',
+                teacherEnroll: {
+                    id: store.userInfo.sub,
+                    code: 'TeacherEnroll',
+                    schoolId: teacherEnroll.value.schoolId,
+                    enrollInfos: [],
+                }
+            }
+            Object.keys(teacherEnroll.value).forEach(key => {
+                if(key != 'schoolId') {
+                    params.teacherEnroll.enrollInfos.push({
+                        code: key,
+                        val: teacherEnroll.value[key]
+                    })
+                }
+            })
+            proxy.$api.teaContest(params).then(res => {
+                if(res.code === 200) {
+                    ElMessage({
+                        type: 'success',
+                        message: '保存成功'
+                    })
+                }
+            })
+        } else {
+            ElMessage({
+                type: 'warning',
+                message: '请完善信息'
+            })
+        }
+    })
+}
+
+function deleteRule(type) {
+    enrollForm.value.clearValidate(type)
+}
+</script>
+
+<style>
+
+</style>

+ 336 - 0
TEAMModelContest/contest.client/src/view/homepage/HomePage.vue

@@ -0,0 +1,336 @@
+<template>
+    <div class="home-page">
+        <el-carousel trigger="click" :autoplay="false" style="margin-top: 30px;">
+            <el-carousel-item v-for="(item, index) in carousel" :key="index">
+                <div class="carousel-box" @click="openBanner(item)">
+                    <img :src="item.img" alt="" />
+                    <!-- <div class="title-box">
+                        <p>{{ item.title }}</p>
+                        <p>{{ item.subtitle }}</p>
+                    </div> -->
+                </div>
+            </el-carousel-item>
+        </el-carousel>
+        <div class="act-list-box">
+            <div style="margin-top: 20px; display: flex; justify-content: space-between;">
+                <el-radio-group v-model="actType">
+                    <el-radio-button label="all">全部</el-radio-button>
+                    <!-- <el-radio-button label="notStart">未开始</el-radio-button> -->
+                    <el-radio-button label="going">进行中</el-radio-button>
+                    <el-radio-button label="finish">已结束</el-radio-button>
+                </el-radio-group>
+                <el-input v-model="searchWord" clearable placeholder="搜索活动名称" style="width: 200px;">
+                    <template #append>
+                        <el-button :icon="Search" @click="searchWords(lessonList)" />
+                    </template>
+                </el-input>
+            </div>
+            <div class="act-list" v-if="lessonListShow.length">
+                <div v-for="(item, index) in lessonListShow" :key="index" class="lesson-info" @click="openInfo(item)">
+                    <div class="img-box">
+                        <img :src="item.posterShow" alt="">
+                    </div>
+                    <div class="info-box">
+                        <div class="lesson-name">
+                            <p class="type-box" :style="{'background-color': item.scope === 'area' ? '#ff9900' : (item.scope === 'school' ? '#19be43' : '#a8a8a8')}">
+                                {{ item.scope === 'public' ? '公开' : (item.scope === 'area' ? '区级' : '校级') }}
+                            </p>
+                            <p>{{ item.name }}</p>
+                        </div>
+                        <p class="info-title">
+                            <span>时间</span>:
+                            <span>{{ $tools.formatTime(item.stime, 'yyyy-MM-dd') }}</span> - <span>{{ $tools.formatTime(item.etime, 'yyyy-MM-dd') }}</span>
+                        </p>
+                        
+                        <p class="info-title">
+                            <span>简介</span>
+                            :{{ item.description || '-' }}
+                        </p>
+                        <p class="info-title">
+                            <span>地点</span>
+                            :{{ item.address || '-' }}
+                        </p>
+                        <p class="info-title">
+                            <span>主办</span>:
+                            <span v-for="(zb, zIndex) in item.zb" :key="zIndex">{{ zb || '-' }}</span>
+                        </p>
+                    </div>
+                    <div :class="['lesson-type', item.publish === 1 ? 'going' : 'end']">
+                        {{ item.publish === 1 ? '进行中' : item.publish === 2 ? '已结束' : '' }}
+                    </div>
+                </div>
+            </div>
+            <el-empty v-else description="暂无活动" :image-size="300" />
+            <!-- <div class="act-list" v-else style="justify-content: center; font-size: 20px; margin: 40px 0;">
+                暂无活动
+            </div> -->
+        </div>
+    </div>
+</template>
+
+<script setup>
+import { Search } from '@element-plus/icons-vue'
+import { getCurrentInstance, onMounted, reactive, ref, toRaw, watch } from "vue"
+import { useRouter } from "vue-router"
+// import { useStore } from "vuex"
+import { useStore } from "@/pinia/common"
+import { ElLoading } from "element-plus"
+
+const router = useRouter()
+let store = useStore()
+let { proxy } = getCurrentInstance()
+
+let actType = ref('all')
+let searchWord = ref('')
+let carousel = ref([])
+let lessonList = ref([])
+let lessonListShow = ref([])
+
+onMounted(() => {
+    let banners = store.website.banners
+    banners.forEach(item => {
+        if(item.source === 'upload') {
+            item.img = store.website.url + item.blob + '?' + store.website.sas
+        } else if(item.source === 'activity') {
+            item.img = store.website.url + item.blob + '?' + store.website.sas
+        }
+        carousel.value.push(item)
+    })
+    if(!carousel.value.length) {
+        carousel.value.push({
+            img: require('@/assets/img/no-poster-cn.jpg'),
+            title: '醍摩豆',
+            subtitle: ''
+        })
+    }
+    getActList()
+})
+
+function openInfo(info) {
+    router.push({
+        name: 'activityInfo',
+        params: {
+            info: info.id
+        }
+    })
+}
+
+function getActList() {
+    /* const loading = ElLoading.service({
+        lock: true,
+        text: '加载中',
+        background: 'rgba(0, 0, 0, 0.7)'
+    }) */
+    let areaRoute = window.location.pathname.split('/')
+    let params = {
+        route: areaRoute[1]
+    }
+    proxy.$api.getActivityList(params).then(res => {
+        if(res.activities) {
+            res.activities.forEach(item => {
+                item.posterShow = !item.poster ? require('@/assets/img/no-poster-cn.jpg') : item.url + item.poster + '?' + item.sas
+                lessonList.value.push(item)
+            })
+            lessonList.value = lessonList.value.sort((a, b) => b.createTime - a.createTime)
+            lessonListShow.value = lessonList.value
+        }
+    }).finally(() => {
+        // loading.close()
+    })
+}
+
+function openBanner(data) {
+    if(data.source === 'activity') {
+        router.push({
+            name: 'activityInfo',
+            params: {
+                info: data.url
+            }
+        })
+    } else {
+        window.open(data.url)
+    }
+}
+
+function searchWords() {
+    if(actType.value != 'all') {
+        lessonListShow.value = lessonList.value.filter(item => actType.value === 'going' ? item.publish === 1 : item.publish === 2)
+    } else {
+        lessonListShow.value = lessonList.value
+    }
+    lessonListShow.value = lessonListShow.value.filter(item => item.name.includes(searchWord.value))
+}
+
+watch(actType, ((newValue, oldValue) => {
+    searchWords()
+}))
+</script>
+<style lang="less" scoped>
+.home-page {
+    padding: 0 13%;
+    // padding-top: 20px;
+    height: 100%;
+    background: linear-gradient(0deg, #c3d9e9, transparent);
+    overflow: auto;
+
+    .carousel-box {
+        width: 100%;
+        height: 100%;
+        position: relative;
+        display: flex;
+        align-items: center;
+
+        .title-box {
+            position: absolute;
+            bottom: 4%;
+            left: 5%;
+
+            &>p:first-child {
+                font-size: 20px;
+                font-weight: bold;
+                margin-bottom: 10px;
+            }
+        }
+    }
+
+    .act-list-box {
+        margin: 20px 0;
+        background: #fff;
+        border-radius: 10px;
+        padding: 10px 20px;
+
+        .act-list {
+            margin: 20px 0;
+            display: flex;
+            flex-wrap: wrap;
+            width: 100%;
+            
+            .lesson-info {
+                width: 30%;
+                margin-right: 30px;
+                margin-bottom: 20px;
+                position: relative;
+                cursor: pointer;
+                border-radius: 5px;
+                background-color: #edf3f7;
+
+                &:hover {
+                    box-shadow: 0 26px 40px -24px rgba(0, 36, 100, .3);
+                    -webkit-transform: translateY(-6px);
+                    transform: translateY(-6px);
+                    -webkit-transition: all .3s ease 0s;
+                    transition: all .3s ease 0s;
+                }
+
+                .img-box {
+                    max-width: 95%;
+                    /* border-top-left-radius: 5px;
+                    border-top-right-radius: 5px; */
+                    margin: 10px;
+                    height: 142px;
+                    display: flex;
+                    align-items: center;
+                    justify-content: center;
+
+                    img {
+                        width: auto;
+                        max-width: 100%;
+                        max-height: 100%;
+                        border-radius: 5px;
+                    }
+                }
+
+                .info-box {
+                    height: 150px;
+                    // border: 1px solid #ccc;
+                    padding: 20px 15px;
+                    padding-top: 10px;
+                    color: #ccc;
+                    line-height: 18px;
+                    border-bottom-left-radius: 5px;
+                    border-bottom-right-radius: 5px;
+                    // background-color: #F2F7FC;
+
+                    .lesson-name {
+                        margin-bottom: 15px;
+                        color: #000;
+                        font-size: 17px;
+                        display: flex;
+                        align-items: center;
+
+                        &>p:nth-child(2) {
+                            text-align: center;
+                            overflow: hidden;
+                            text-overflow: ellipsis;
+                            white-space: nowrap;
+                        }
+
+                        .type-box {
+                            background-color: #FF9900;
+                            font-size: 12px;
+                            font-weight: normal;
+                            color: #fff;
+                            padding: 3px 5px;
+                            border-radius: 5px;
+                            margin-right: 5px;
+                            min-width: 25px;
+                        }
+                    }
+
+                    .info-title {
+                        font-size: 14px;
+                        margin-bottom: 7px;
+                        color: #6a6a6a;
+
+                        /* &>span {
+                            color: #1B2773;
+                        } */
+
+                    }
+                }
+
+                .lesson-type {
+                    position: absolute;
+                    top: 10px;
+                    right: 10px;
+                    height: 20px;
+                    line-height: 20px;
+                    font-size: 14px;
+                    text-align: center;
+                    padding: 2px 10px;
+                    border-top-right-radius: 5px;
+                    border-bottom-left-radius: 5px;
+                }
+
+                .going {
+                    background-color: rgb(8, 153, 8);
+                    color: #fff;
+                }
+
+                .end {
+                    background-color: #ccc;
+                    color: #3f3f3f;
+                }
+            }
+        }
+    }
+}
+</style>
+
+<style lang="less">
+.home-page {
+    .el-carousel__container {
+        height: 500px;
+    }
+    .el-carousel__item {
+        img{
+            max-width: 100%;
+            max-height: 100%;
+            display: block;
+            cursor: pointer;
+            border-radius: 10px;
+            margin: auto;
+        }
+    }
+}
+</style>

+ 355 - 0
TEAMModelContest/contest.client/src/view/myactivity/MyActivity.less

@@ -0,0 +1,355 @@
+.my-act {
+    width: 100%;
+    height: 100%;
+    display: flex;
+
+    .act-list {
+        width: 20%;
+        height: 100%;
+        border-right: 1px solid #ccc;
+        // background-color: #15559a;
+        // color: #fff;
+
+        .list-icon {
+            width: auto;
+            margin: 10px 0;
+            height: 25px;
+            display: flex;
+            align-items: center;
+            justify-content: space-between;
+            padding: 0 10px;
+            position: relative;
+
+            .el-icon {
+                display: inline-block;
+                margin-right: 10px;
+                cursor: pointer;
+            }
+
+            .search-input {
+                width: 95%;
+                position: absolute;
+                top: -3px;
+                left: 8px;
+            }
+        }
+
+        .list-info {
+            padding: 15px 20px;
+            // margin: 0 5px;
+
+            &>p {
+                margin-bottom: 10px;
+            }
+
+            .info-name {
+                font-size: 18px;
+            }
+
+            .info-end {
+                border-radius: 3px;
+                padding: 4px 6px;
+                margin-right: 10px;
+                background: #a3a3a3;
+                color: #fff;
+                font-size: 12px;
+            }
+
+            .info-type {
+                font-size: 12px;
+                display: flex;
+                margin-bottom: 10px;
+
+                .bg-area {
+                    background: #ff9900;
+                }
+
+                .bg-school {
+                    background: #19be43;
+                }
+
+                .info-progress {
+                    background: #69bd04;
+                    color: #fff;
+                }
+
+                .info-review {
+                    background: #cda011;
+                    color: #fff;
+
+                }
+            }
+
+            .info-time {
+                display: flex;
+                align-items: center;
+
+                .el-icon {
+                    margin-right: 5px;
+                }
+            }
+        }
+
+        .list-info:hover,
+        .list-info-active {
+            background-color: #EBECED;
+            cursor: pointer;
+            border-radius: 5px 0 0 5px;
+        }
+    }
+
+    .act-info {
+        width: 80%;
+        height: auto;
+        padding: 20px;
+        background-color: #e8f3fd;
+        // background-color: #f4f4f4;
+
+        .info-box {
+            padding: 20px 30px;
+            height: 95%;
+            background-color: #fff;
+            border-radius: 5px;
+
+            .demo-tabs {
+                height: 100%;
+
+                .module-box {
+                    cursor: pointer;
+                }
+            }
+
+            .score-box {
+                // padding-top: 20px;
+                // margin: 20px 0;
+                // border-top: 1px dashed #ccc;
+                display: flex;
+                justify-content: center;
+
+                &>div {
+                    text-align: center;
+                    padding: 0 20px;
+                    margin-top: 10px;
+
+                    &:last-of-type {
+                        border: none;
+                    }
+                }
+
+                .score-lable {
+                    margin-bottom: 10px;
+
+                    &>p {
+                        margin-bottom: 10px;
+                    }
+                }
+
+                .score-info {
+                    display: flex;
+                    flex-wrap: wrap;
+                    justify-content: center;
+
+                    &>div {
+                        text-align: center;
+                        margin: 5px 10px;
+                        min-width: 85px;
+                    }
+                }
+            }
+
+            .upload-demo {
+                margin: 20px 0;
+
+                .tag-box {
+                    margin: 5px 0;
+
+                    .el-tag {
+                        margin-right: 5px;
+                    }
+                }
+
+                .file-list-box {
+                    display: flex;
+                    justify-content: space-between;
+                    margin-bottom: 5px;
+                    font-size: 16px;
+
+                    .el-icon {
+                        cursor: pointer;
+
+                        &:hover {
+                            color: #409eff;
+                        }
+                    }
+                }
+            }
+
+            .sk-info {
+
+                // padding: 0 10px;
+                &>p {
+                    margin: 10px;
+                }
+
+                img {
+                    max-width: 100%;
+                    height: 500px;
+                    display: block;
+                    border-radius: 10px;
+                    margin: auto;
+                }
+
+                .attach-name {
+                    cursor: pointer;
+                    margin-bottom: 10px;
+
+                    &:hover {
+                        color: #0d7001;
+                        font-weight: bold;
+                    }
+                }
+            }
+
+            .cipher-box {
+                // text-align: right;
+                margin: 20px;
+                color: #3d3d3d;
+
+                p {
+                    margin-bottom: 10px;
+                    height: 20px;
+                    line-height: 20px;
+                }
+            }
+
+            .file-show {
+                background-color: #f4f4f4;
+                border-radius: 5px;
+                padding: 20px 25px;
+                margin-top: 10px;
+
+                .answer-show img {
+                    max-width: 50% !important;
+                }
+
+                &>p {
+                    font-size: 18px;
+                    font-weight: bold;
+                    margin-bottom: 10px;
+                }
+
+                .repair-link-wrap-item-box {
+                    display: flex;
+                    position: relative;
+                    // background-color: #e3e3e3;
+                    border-radius: 5px;
+                    padding: 10px 0;
+                    font-size: 14px;
+
+                    &:hover {
+                        background-color: #ebe9e9;
+                    }
+
+                    .file-icon {
+                        img {
+                            width: 45px;
+                        }
+                    }
+
+                    .file-info {
+                        margin-left: 10px;
+
+                        .file-name {
+                            font-weight: bold;
+                            margin-bottom: 5px;
+                        }
+
+                        span {
+                            color: #16a3b5;
+                            margin-right: 15px;
+                            cursor: pointer;
+                        }
+                    }
+                }
+            }
+        }
+
+        .not-sign {
+            // height: 100%;
+            text-align: center;
+            // margin-top: 10%;
+
+            &>p,
+            &>span {
+                font-size: 20px;
+                margin: 20px 0;
+                color: #3d3d3d;
+                font-weight: bold;
+
+                /* &:nth-of-type(2):hover {
+                    cursor: pointer;
+                    color: #19be6b;
+                } */
+            }
+
+            &>span:hover {
+                cursor: pointer;
+                color: #19be6b;
+            }
+        }
+    }
+
+    .image-viewer {
+        background-color: rgba(0, 0, 0, 0.8);
+        z-index: 9999;
+        width: 100%;
+        height: 100%;
+        position: fixed;
+        top: 0;
+        left: 0;
+        overflow-y: scroll;
+        overflow-x: hidden;
+        text-align: center;
+        /*display: flex;
+        justify-content: center;
+        align-items: center;*/
+        padding: 50px;
+        padding-top: 8%;
+
+        .close-icon {
+            position: absolute;
+            right: -16px;
+            top: -16px;
+            font-size: 24px;
+            color: black;
+            cursor: pointer;
+            border-radius: 50px;
+            background: white;
+            padding: 2px;
+        }
+    }
+
+    .not-act {
+        margin: auto;
+        font-size: 2rem;
+    }
+}
+
+.el-popper {
+    .filter-box {
+
+        // margin: 10px;
+        &>p:first-child {
+            border-bottom: 1px solid #ccc;
+            padding: 5px;
+        }
+
+        &>div {
+            padding: 5px;
+
+            &>p {
+                margin-bottom: 5px;
+            }
+        }
+    }
+}

File diff suppressed because it is too large
+ 1048 - 0
TEAMModelContest/contest.client/src/view/myactivity/MyActivity.vue


+ 222 - 0
TEAMModelContest/contest.client/src/view/myactivity/MyReview.less

@@ -0,0 +1,222 @@
+.my-act {
+    width: 100%;
+    height: 100%;
+    display: flex;
+
+    .act-list {
+        width: 20%;
+        border-right: 1px solid #ccc;
+
+        .list-icon {
+            width: 100%;
+            margin: 10px 0;
+            height: 25px;
+            display: flex;
+            align-items: center;
+            flex-direction: row-reverse;
+
+            .el-input {
+                margin-right: 10px;
+                margin-left: 10px;
+            }
+
+            .el-icon {
+                display: inline-block;
+                margin-right: 10px;
+                cursor: pointer;
+            }
+        }
+
+        .list-info {
+            padding: 15px 20px;
+
+            &>p {
+                margin-bottom: 10px;
+            }
+
+            .info-name {
+                font-size: 18px;
+            }
+
+            .info-type {
+                font-size: 12px;
+                display: flex;
+                margin-bottom: 10px;
+
+                &>p {
+                    display: flex;
+                    flex-wrap: wrap;
+                    border: 1px solid #d2d2d2;
+                    width: -moz-fit-content;
+                    width: fit-content;
+                    border-radius: 3px;
+                    overflow: hidden;
+
+                    &>span {
+                        padding: 4px 6px;
+                        white-space: nowrap;
+                        border-right: 1px solid #d2d2d2;
+                    }
+                }
+
+                .info-end {
+                    border-radius: 3px;
+                    padding: 4px 6px;
+                    margin-right: 10px;
+                    background: #a3a3a3;
+                    color: #fff;
+                }
+
+                .info-progress {
+                    background: #69bd04;
+                    color: #fff;
+                }
+
+                .info-review {
+                    background: #cda011;
+                    color: #fff;
+
+                }
+            }
+
+            .info-time {
+                display: flex;
+                align-items: center;
+
+                .el-icon {
+                    margin-right: 5px;
+                }
+            }
+        }
+
+        .list-info:hover,
+        .list-info-active {
+            background-color: #EBECED;
+            cursor: pointer;
+            border-radius: 5px 0 0 5px;
+        }
+    }
+
+    .act-info {
+        width: 80%;
+        height: auto;
+        padding: 20px;
+        background-color: #e8f3fd;
+        // background-color: #f4f4f4;
+
+        .info-box {
+            padding: 20px;
+            padding: 20px 30px;
+            height: 95%;
+            background-color: #fff;
+            border-radius: 5px;
+
+            .demo-tabs {
+                height: 100%;
+            }
+
+            .upload-demo {
+                margin: 20px 0;
+
+                .tag-box {
+                    margin: 5px 0;
+
+                    .el-tag {
+                        margin-right: 5px;
+                    }
+                }
+            }
+
+            .sk-info {
+
+                // padding: 0 10px;
+                &>p {
+                    margin: 10px;
+                }
+
+                img {
+                    max-width: 100%;
+                    height: 500px;
+                    display: block;
+                    border-radius: 10px;
+                    margin: auto;
+                }
+            }
+
+            .module-box {
+                cursor: pointer;
+                margin-bottom: 10px;
+                color: #535353;
+            }
+
+            .review-box {
+                .review-header {
+                    /* display: flex;
+                    justify-content: space-between; */
+                    margin: 20px 20px;
+
+                    .review-header-author {
+                        // width: 15%;
+                        color: #535353;
+                        display: flex;
+
+                        &>p {
+                            margin-bottom: 20px;
+                            display: flex;
+                            align-items: center;
+                            min-width: 20%;
+
+                            .el-icon {
+                                margin-right: 5px;
+                            }
+                        }
+                    }
+
+                    .review-header-works {
+                        display: flex;
+                        height: 300px;
+                        background-color: #f4f4f4;
+                        border-radius: 5px;
+                        // padding: 10px 15px;
+
+                        .works-list {
+                            width: 20%;
+                            height: 90%;
+                            // border-right: 1px solid #ccc;
+                            margin-left: 10px;
+                            margin-top: 10px;
+
+                            .files-list {
+                                padding: 13px 15px;
+                                border-radius: 5px;
+                                color: #535353;
+                                cursor: pointer;
+
+                                &:hover {
+                                    background-color: #638ED4;
+                                    color: #fff;
+                                }
+                            }
+
+                            .active-file {
+                                background-color: #638ED4;
+                                color: #fff;
+                            }
+                        }
+
+                        .works-info {
+                            width: 80%;
+                            text-align: center;
+                            line-height: 300px;
+                            margin: 10px 0;
+
+                            img {
+                                height: 100%;
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+}

+ 490 - 0
TEAMModelContest/contest.client/src/view/myactivity/MyReview.vue

@@ -0,0 +1,490 @@
+<template>
+    <div class="my-act">
+        <div class="act-list">
+            <div class="list-icon">
+                <!-- <el-icon><Delete /></el-icon> -->
+                <!-- <el-icon @click="searShow = true"><Search /></el-icon> -->
+                <el-input v-model="searchName" placeholder="搜索活动名称" class="search-input">
+                    <template #append>
+                        <el-button :icon="Search" @click="searchName()" />
+                    </template>
+                </el-input>
+            </div>
+            <div style="height: 90%;">
+                <template v-if="taskList.length">
+                    <el-scrollbar>
+                        <div v-for="(item, index) in taskList" :key="index" @click="getListInfo(item, index)"
+                            :class="['list-info', taskIndex === index ? 'list-info-active' : '']">
+                            <p class="info-name">{{ item.activityName }}</p>
+                            <div class="info-type">
+                                <!-- <p>
+                                    <span>{{ item.format ? '团队赛' : '个人赛' }}</span>
+                                    <span v-show="item.work">需上传作品</span>
+                                </p> -->
+                                <span v-if="item.isFinish" class="info-end">已结束</span>
+                                <span v-else :class="['info-end', item.completeCount === item.taskCount ? 'info-review' : 'info-progress']">{{ item.completeCount === item.taskCount ? '已评审' : '评审中' }}</span>
+                                <span v-show="!item.isFinish && item.completeCount != item.taskCount" class="info-end">需评审 {{ item.taskCount - item.completeCount }} 个</span>
+                            </div>
+                            <p class="info-time">
+                                <el-icon><Clock /></el-icon>
+                                <span>{{ $tools.formatTime(item.stime, 'yyyy-MM-dd') }}</span> - <span>{{ $tools.formatTime(item.etime, 'yyyy-MM-dd') }}</span>
+                            </p>
+                        </div>
+                    </el-scrollbar>
+                </template>
+                <el-empty v-else description="暂无活动" />
+            </div>
+        </div>
+        <div class="act-info" v-if="taskList.length">
+            <div class="info-box">
+                <el-tabs v-model="actTab" class="demo-tabs">
+                    <el-tab-pane label="作品评审" name="user">
+                        <el-scrollbar>
+                            <div>
+                                <p @click="skModule = !skModule" class="module-box">
+                                    <el-icon v-show="skModule"><CaretBottom /></el-icon>
+                                    <el-icon v-show="!skModule"><CaretRight /></el-icon>
+                                    赛课模块
+                                </p>
+                                <div v-show="skModule">
+                                    <div class="review-box">
+                                        <div class="review-header">
+                                            <div class="review-header-author">
+                                                <p>
+                                                    <el-icon size="30" color="#A3A3A3"><Avatar /></el-icon>
+                                                    作者:{{ workInfo.name }}
+                                                </p>
+                                                <p>
+                                                    <el-icon size="30" color="#A3A3A3"><Avatar /></el-icon>
+                                                    学段:{{ workInfo.period }}
+                                                </p>
+                                                <p>
+                                                    <el-icon size="30" color="#A3A3A3"><Management /></el-icon>
+                                                    学科:{{ workInfo.subject }}
+                                                </p>
+                                            </div>
+                                            <div class="review-header-works" v-if="workInfo.files.length">
+                                                <div class="works-list">
+                                                    <el-scrollbar>
+                                                        <div v-for="(item, index) in workInfo.files" :key="index" @click="changeFile(item, index)" :class="['files-list', {'active-file': fileIndex === index}]">
+                                                            <p>{{ item.name }}</p>
+                                                        </div>
+                                                    </el-scrollbar>
+                                                </div>
+                                                <div class="works-info">
+                                                    <video v-if="fileInfo.type === 'video'" :id="'video'" :src="fileInfo.urlShow" controls="controls" style="border-radius: 5px;max-height: 280px;max-width: 100%;" />
+                                                    <img v-else-if="fileInfo.type === 'image'" :src="fileInfo.urlShow" style="border-radius: 5px;max-height: 700px !important;max-width: 100% !important;" />
+                                                    <audio v-else-if="fileInfo.type === 'audio'" controls>
+                                                        <source :src="fileInfo.urlShow" />
+                                                    </audio>
+                                                    <div v-else>
+                                                        <span>该文件无法在当前页面预览</span>
+                                                        <span @click="onPreview(fileInfo)" v-if="fileInfo.type !== 'other'" style="font-weight: bold; color: #009d18; cursor: pointer;">
+                                                            跳转预览
+                                                        </span>
+                                                        <!-- <p @click="onDownload(fileInfo)" v-if="item.type !== 'link'">
+                                                            下载
+                                                        </p> -->
+                                                    </div>
+                                                </div>
+                                            </div>
+                                            <div v-else-if="workInfo.link">
+                                                作品:
+                                                <span @click="lookSokrates()" style="color: #0c66c3; cursor: pointer;;">{{ workInfo.link.uploadName }}</span>
+                                            </div>
+                                        </div>
+                                        <div style="text-align: right; margin: 10px 20px;" v-show="workList.length && workList.length != 1">
+                                            <el-button type="info" @click="changeWork()" v-show="workIndex">上一位</el-button>
+                                            <el-button type="info" @click="changeWork(true)" v-show="workIndex != (workList.length - 1)">下一位</el-button>
+                                            <el-button type="info" @click="scoring = true" :icon="Notebook" circle />
+                                        </div>
+                                        <div v-if="ruleInfo.scoreDetail">
+                                            <el-table :data="ruleInfo.trees" row-key="id" default-expand-all height="500">
+                                                <el-table-column prop="label" label="规则" align="center" width="170" />
+                                                <el-table-column prop="desc" label="描述" align="center" />
+                                                <el-table-column prop="oldScore" label="分值" align="center" width="100" />
+                                                <el-table-column label="作品分数" align="center" width="150">
+                                                    <template #default="scope">
+                                                        <el-input-number v-model="scope.row.score" @change="value => handleChange(value, scope.row)" :min="0" :max="scope.row.oldScore" placeholder="请打分" :disabled="!!scope.row.children.length" controls-position="right" style="width: 100px;" />
+                                                        <!-- <el-input v-model="scope.row.reviewScore" placeholder="请打分" v-if="!scope.row.children" /> -->
+                                                    </template>
+                                                </el-table-column>
+                                            </el-table>
+                                        </div>
+                                        <div v-else>
+                                            总分:
+                                            <el-input-number v-model="reviewScore" :min="0" :max="100" placeholder="请打分" controls-position="right" />
+                                        </div>
+                                        <div style="text-align: right; margin-top: 10px;">
+                                            <el-button type="success" @click="saveScore()">提交</el-button>
+                                        </div>
+                                    </div>
+                                </div>
+                            </div>
+                        </el-scrollbar>
+                    </el-tab-pane>
+                    <el-tab-pane label="活动信息" name="info">
+                        <el-scrollbar>
+                            <div style="margin-bottom: 20px;" class="sk-info">
+                                <img :src="actInfo.posterSas" alt="">
+                                <p style="text-align: center; font-size: 20px;font-weight: bold; margin: 10px 0;">
+                                    <span>{{ $tools.formatTime(actInfo.stime, 'yyyy-MM-dd') }}</span>
+                                    -
+                                    <span>{{ $tools.formatTime(actInfo.etime, 'yyyy-MM-dd') }}</span>
+                                </p>
+                                <p>主题:{{ actInfo.subject || '-' }}</p>
+                                <p>简介:{{ actInfo.description || '-' }}</p>
+                                <p>地点:{{ actInfo.address || '-' }}</p>
+                                <p>主办:
+                                    <span v-for="item in actInfo.zb" :key="item">{{ item || '-' }}</span>
+                                </p>
+                                <p>承办:
+                                    <span v-for="item in actInfo.cb" :key="item">{{ item || '-' }}</span>
+                                </p>
+                                <p>免责声明:{{ actInfo.mzsm || '-' }}</p>
+                                <p>附件:
+                                    <span v-if="actInfo.attachment.length">
+                                        <span v-for="(item, index) in actInfo.attachment" :key="index" @click="onPreview(item)">{{ item.name }}</span>
+                                    </span>
+                                    <span v-else>-</span>
+                                </p>
+                            </div>
+                        </el-scrollbar>
+                    </el-tab-pane>
+                </el-tabs>
+            </div>
+        </div>
+        <el-empty v-else description="暂无活动" :image-size="300" />
+        <el-dialog v-model="scoring" title="请选择打分作品" width="40%">
+            <!-- <div v-for="item in workList" :key="item.id">
+                姓名:{{ item.name }} 学段:{{ item.period }} 学科:{{ item.subject }}
+            </div> -->
+            <div>
+                <el-table :data="workList" row-key="id" highlight-current-row default-expand-all @current-change="handleCurrentChange">
+                    <el-table-column prop="name" label="姓名" align="center" width="170" />
+                    <el-table-column prop="period" label="学段" align="center" width="100" />
+                    <el-table-column prop="subject" label="学科" align="center" width="100" />
+                    <el-table-column label="成绩" align="center" width="100">
+                        <template #default="scope">
+                            <p>{{ scope.row.score === -1 ? '-' : scope.row.score }}</p>
+                        </template>
+                    </el-table-column>
+                    <!-- <el-table-column label="作品" align="center">
+                        <template #default="scope">
+                            <p>
+                                <el-icon v-if="scope.row.type === 'img'"><Picture /></el-icon>
+                                <el-icon v-else-if="scope.row.type === 'video'"><Film /></el-icon>
+                                <el-icon v-else-if="scope.row.type === 'link'"><Paperclip /></el-icon>
+                                <el-icon v-else><FolderRemove /></el-icon>
+                                {{ scope.row.work }}
+                            </p>
+                        </template>
+                    </el-table-column> -->
+                </el-table>
+            </div>
+            <template #footer>
+                <span class="dialog-footer">
+                    <el-button type="primary" @click="startWork">
+                        进行打分
+                    </el-button>
+                </span>
+            </template>
+        </el-dialog>
+        <div v-if="previewStatus" class="image-viewer">
+			<div style="width:fit-content;position:relative;margin:auto;" v-if="previewFile.type != 'image'">
+                <el-icon class="close-icon" @click="previewStatus = false"><CloseBold /></el-icon>
+				<video v-if="previewFile.type == 'video'" id="previewVideo" :src="previewFile.urlShow" width="870"
+					controls="controls" style="max-height: 800px;">
+					您的浏览器不支持 video 标签。
+				</video>
+				<audio v-else-if="previewFile.type === 'audio'" controls>
+					<source :src="previewFile.urlShow">
+					您的浏览器不支持 audio 元素。
+				</audio>
+			</div>
+            <el-image-viewer v-else hide-on-click-modal @close="previewStatus = false" :url-list="[previewFile.urlShow]" />
+		</div>
+    </div>
+</template>
+
+<script setup>
+import { Search, Delete, Clock, CaretBottom, CaretRight, Avatar, Management, Notebook, Picture, Film, Paperclip, FolderRemove, CloseBold } from '@element-plus/icons-vue'
+import { ElMessageBox, ElMessage, FormRules } from 'element-plus'
+import { getCurrentInstance, onMounted, reactive, ref, watch, watchEffect } from 'vue'
+
+let { proxy } = getCurrentInstance()
+
+// 活动列表
+let taskList = ref([])
+let taskIndex = ref(0)
+let searchName = ref('')
+// 活动信息
+let actInfo = ref({attachment: []})
+// 评审规则
+let ruleInfo = ref({})
+let reviewScore = ref(null)
+// 作品列表
+let workList = ref([])
+let workIndex = ref(0)
+// 展示作品信息
+let workInfo = ref({files: [], link: undefined})
+// 作品信息
+let workInfoShow = ref({})
+let fileIndex = ref(0)
+let fileInfo = ref({})
+let previewFile = ref({})
+let previewStatus = ref(false)
+
+
+
+let searShow = ref(false)
+let actTab = ref('user')
+// 赛课模块
+let skModule = ref(true)
+// 作品列表弹出框
+let scoring = ref(false)
+
+// workInfo = workList[0]
+
+getTaskList()
+
+function getTaskList() {
+    proxy.$api.getTaskList({grant_type: 'list-task'}).then(res => {
+        if(res.code === 200) {
+            const nowTime = Date.parse(new Date())
+            taskList.value = res.activities.map(item => {
+                item.isFinish = nowTime >= item.etime
+                return item
+            })
+            if(taskList.value.length) getListInfo(taskList.value[0], 0)
+        }
+    })
+}
+
+function getListInfo(info, index) {
+    taskIndex.value = index
+    workList.value = info.contestTasks
+    actInfo.value = {attachment: []}
+    proxy.$api.getActInfo({activityId: info.activityId}).then(res => {
+        if(res.code === 200) {
+            res.activity.posterSas = !res?.activity.poster ? require('@/assets/img/no-poster-cn.jpg') : res?.activity.url + res?.activity.poster + '?' + res?.activity.sas
+            res?.activity.attachment.forEach(item => {
+                item.urlShow = res?.activity.url + item.url + '?' + res?.activity.sas
+            })
+            actInfo.value = res.activity
+            ruleInfo.value = res.reviewRule
+            ruleInfo.value.trees = getRuleScore(ruleInfo.value.trees, workList.value[workIndex.value].detailScore)
+        }
+    }).catch(() => {
+    }).finally(() => {
+        if(workList.value.length) {
+            getUploadInfo(workList.value[0])
+        }
+    })
+}
+
+function getRuleScore(arr, workInfo, isRestore) {
+    arr.forEach((item, index) => {
+        if(!isRestore) item.oldScore = item.score
+        let info = workInfo.find(work => work.id === item.id)
+        item.score = info ? info.score : null
+        if(item?.children.length) {
+            getRuleScore(item.children, workInfo, isRestore)
+        }
+    })
+    return arr
+}
+
+function checkNotScore(arr) {
+    let notScore = arr.find(item => {
+        let needBreak = undefined
+        if(item.children.length) {
+            needBreak = checkNotScore(item.children)
+        }
+        return needBreak || item.score === null
+    })
+    return notScore
+}
+
+function getUploadInfo(info) {
+    if(!info?.files) {
+        let params = {
+            grant_type: 'get-enroll-by-uploadId',
+            activityId: taskList.value[taskIndex.value].activityId,
+            uploadId: info.uploadId
+        }
+        proxy.$api.getTaskList(params).then(res => {
+            if(res?.upload) {
+                info.files = []
+                info.link = {url: ''}
+                let arr = res.upload.uploadType.includes('file') ? res.upload.complexes.map(item => item.uploadFile) : res.upload.files
+                arr.map(item => {
+                    item.urlShow = actInfo.value.url + item.url + '?' + actInfo.value.sas
+                    if(item.type === 'video') {
+                        info.files.unshift(item)
+                    } else {
+                        info.files.push(item)
+                    }
+                })
+                info.link = (res.upload.uploadType.length && !res.upload.uploadType.includes('file')) ? res.upload.complexes[0].lessonSokrates : {url: ''}
+                info.link.uploadName = res.upload.name
+                workInfo.value = info
+                // changeFile(workInfo.value.files[0], 0)
+                fileIndex.value = 0
+                if(res.upload.uploadType.includes('file')) fileInfo.value = workInfo.value.files[0]
+            }
+        })
+    } else {
+        workInfo.value = info
+        fileIndex.value = 0
+        if(res.upload.uploadType.includes('file')) fileInfo.value = workInfo.value.files[0]
+    }
+}
+
+function changeWork(type) {
+    ElMessageBox.confirm(`当前分数不会保存,是否切换?`).then(() => {
+        workIndex.value = type ? workIndex.value + 1 : workIndex.value - 1
+        if(ruleInfo.value.scoreDetail) {
+            ruleInfo.value.trees = getRuleScore(ruleInfo.value.trees, workList.value[workIndex.value].detailScore, true)
+        } else {
+            reviewScore.value = null
+        }
+        getUploadInfo(workList.value[workIndex.value])
+    })
+}
+
+function saveScore() {
+    let notScore = undefined
+    if(ruleInfo.value.scoreDetail) {
+        // 排查细项打分时,是否有遗漏(可以打0分)
+        notScore = checkNotScore(ruleInfo.value.trees)
+    } else {
+        notScore = !reviewScore.value
+    }
+    if(notScore) {
+        ElMessage({
+            type: 'warning',
+            message: ruleInfo.value.scoreDetail ? '有细项未打分' : '未打分'
+        })
+        return
+    }
+    let params = {
+        grant_type: 'decide-score',
+        activityId: taskList.value[taskIndex.value].activityId,
+        scoreData: {
+            score: 0,
+            uploadId: workInfo.value.uploadId,
+            detailScore: []
+        }
+    }
+    if(ruleInfo.value.scoreDetail) {
+        params.scoreData.detailScore = ruleInfo.value.trees
+        ruleInfo.value.trees.forEach(item => {
+            params.scoreData.score += item.score
+        })
+    } else {
+        params.scoreData.score += reviewScore.value
+    }
+    // 可以打0分
+    /* if(!params.scoreData.score) {
+        ElMessage({
+            type: 'warning',
+            message: '分数不能为0'
+        })
+        return
+    } */
+    console.log(params);
+    proxy.$api.getTaskList(params).then(res => {
+        if(res.code === 200) {
+            ElMessage({
+                type: 'success',
+                message: '保存成功'
+            })
+            if(!workList.value[workIndex.value].detailScore.length) {
+                taskList.value[taskIndex.value].completeCount = res.completeCount
+            }
+            workList.value = res.contestTasks
+        } else {
+            ElMessage({
+                type: 'warning',
+                message: '保存失败'
+            })
+        }
+    })
+}
+
+function handleChange(value, row) {
+    let info = ruleInfo.value.trees.find(item => item.id === row.pid)
+    if(info) {
+        info.score = null
+        info.children.forEach(item => {
+            info.score = (info.score || 0) + item.score
+        })
+    }
+}
+
+/* 预览 */
+async function onPreview(item) {
+    let url = item.urlShow
+    console.log('gr5e4gt4h', url);
+    if (proxy.$tools.getSuffix(item.name) === 'pdf') {
+        window.open('https://www.teammodel.cn/web/viewer.html?file=' + encodeURIComponent(url));
+    } else if(item.type === 'doc') {
+        window.open('https://view.officeapps.live.com/op/view.aspx?src=' + escape(url));
+    // } else if(item.type === 'image') {
+        // $hevueImgPreview(url)
+    } else if(item.type === 'link') {
+        window.open(/^(http:|https:)/i.test(url) ? url : "http://" + url)
+    } else {
+        previewFile.value = item
+        previewStatus.value = true
+    }
+}
+
+function changeFile(info, index) {
+    if(fileIndex.value != index) {
+        fileIndex.value = index
+        fileInfo.value = info
+    }
+}
+
+// 选择将要打分作品
+function handleCurrentChange(key, keyIndex) {
+    workInfoShow.value = key
+}
+
+// 确认开始打分
+function startWork() {
+    workIndex.value = workList.value.findIndex(item => item.uploadId === workInfoShow.value.uploadId)
+    if(ruleInfo.value.scoreDetail) {
+        ruleInfo.value.trees = getRuleScore(ruleInfo.value.trees, workList.value[workIndex.value].detailScore, true)
+    } else {
+        reviewScore.value = null
+    }
+    workInfo.value = workInfoShow.value
+    getUploadInfo(workInfo.value)
+    scoring.value = false
+}
+
+function lookSokrates() {
+    // 查看苏格拉底单点登录页面所需字段,在此处重新拼接
+    // https://account.teammodel.cn/?callback=https%3A%2F%2Fsokrates.teammodel.cn%2Fauth%2Flogin%2Fcallback-habook%3Fto%3DaHR0cHM6Ly9zb2tyYXRlcy50ZWFtbW9kZWwuY24vZXhoaWJpdGlvbi90YmF2aWRlbyMvY29udGVudC81MDk1Mz9ncm91cElkcz0xNDImY2hhbm5lbElkPTEzMQ%3D%3D
+    let url = "https://account.teammodel.cn/?callback=" + 'https://sokrates.teammodel.cn/auth/login/callback-habook' + '?to=' + btoa(workInfo.value.link.url)
+    window.open(url, '_blank')
+}
+</script>
+
+<style lang="less" scoped>
+@import "./MyReview.less";
+</style>
+<style lang="less">
+.info-box {
+    .el-upload-list__item-info {
+        height: 20px;
+    }
+    .el-tabs__content,
+    .el-tab-pane {
+        height: 97%;
+    }
+}
+</style>

+ 16 - 0
TEAMModelContest/contest.client/vue.config.js

@@ -0,0 +1,16 @@
+const Timestamp = new Date().getTime();
+module.exports = {
+    publicPath: '/', // 默认'/',部署应用包时的基本 URL
+    lintOnSave: false, //关闭语法检查
+    devServer: {
+        /* proxy: {
+            '/api': {
+                target: 'https://localhost:5001', //后端接口
+                changeOrigin: true,
+                pathRewrite: {
+                    '^/api': ''
+                }
+            }
+        }, */
+    },
+}

+ 18 - 0
TEAMModelOS.sln

@@ -15,6 +15,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TEAMModelOS.TEST", "TEAMMod
 EndProject
 EndProject
 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "TEAMModelContest", "TEAMModelContest", "{43694D93-19B4-4AF6-8A37-2B2073E7014C}"
 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "TEAMModelContest", "TEAMModelContest", "{43694D93-19B4-4AF6-8A37-2B2073E7014C}"
 EndProject
 EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Contest.Server", "TEAMModelContest\Contest.Server\Contest.Server.csproj", "{6F32F9BC-BEDA-4B63-9910-18513CFF5300}"
+EndProject
+Project("{54A90642-561A-4BB1-A94E-469ADEE60C69}") = "contest.client", "TEAMModelContest\contest.client\contest.client.esproj", "{84B6D998-91A7-420D-A409-4C7442B91055}"
+EndProject
 Global
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
 		Debug|Any CPU = Debug|Any CPU
@@ -41,10 +45,24 @@ Global
 		{335938F6-8418-497A-AB41-CDD006FB8FD6}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{335938F6-8418-497A-AB41-CDD006FB8FD6}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{335938F6-8418-497A-AB41-CDD006FB8FD6}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{335938F6-8418-497A-AB41-CDD006FB8FD6}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{335938F6-8418-497A-AB41-CDD006FB8FD6}.Release|Any CPU.Build.0 = Release|Any CPU
 		{335938F6-8418-497A-AB41-CDD006FB8FD6}.Release|Any CPU.Build.0 = Release|Any CPU
+		{6F32F9BC-BEDA-4B63-9910-18513CFF5300}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{6F32F9BC-BEDA-4B63-9910-18513CFF5300}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{6F32F9BC-BEDA-4B63-9910-18513CFF5300}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{6F32F9BC-BEDA-4B63-9910-18513CFF5300}.Release|Any CPU.Build.0 = Release|Any CPU
+		{84B6D998-91A7-420D-A409-4C7442B91055}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{84B6D998-91A7-420D-A409-4C7442B91055}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{84B6D998-91A7-420D-A409-4C7442B91055}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
+		{84B6D998-91A7-420D-A409-4C7442B91055}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{84B6D998-91A7-420D-A409-4C7442B91055}.Release|Any CPU.Build.0 = Release|Any CPU
+		{84B6D998-91A7-420D-A409-4C7442B91055}.Release|Any CPU.Deploy.0 = Release|Any CPU
 	EndGlobalSection
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
 		HideSolutionNode = FALSE
 	EndGlobalSection
 	EndGlobalSection
+	GlobalSection(NestedProjects) = preSolution
+		{6F32F9BC-BEDA-4B63-9910-18513CFF5300} = {43694D93-19B4-4AF6-8A37-2B2073E7014C}
+		{84B6D998-91A7-420D-A409-4C7442B91055} = {43694D93-19B4-4AF6-8A37-2B2073E7014C}
+	EndGlobalSection
 	GlobalSection(ExtensibilityGlobals) = postSolution
 	GlobalSection(ExtensibilityGlobals) = postSolution
 		SolutionGuid = {76440725-5E50-4288-851F-BA5C0BC8E8C6}
 		SolutionGuid = {76440725-5E50-4288-851F-BA5C0BC8E8C6}
 	EndGlobalSection
 	EndGlobalSection