├── .devcontainer ├── Dockerfile ├── devcontainer.json └── docker-compose.yml ├── .editorconfig ├── .eslintrc.cjs ├── .gitignore ├── .nvmrc ├── .prettierrc.cjs ├── .vscode ├── extensions.json └── settings.json ├── .yarn └── plugins │ └── @yarnpkg │ └── plugin-workspace-tools.cjs ├── .yarnrc.yml ├── LICENCE ├── README.md ├── docs ├── README.md ├── assets │ └── hpcgame_platform.svg ├── design.md ├── server │ └── README.md └── ui │ └── README.md ├── env └── test │ ├── .gitignore │ ├── README.md │ ├── data │ └── .gitkeep │ ├── docker-compose.yml │ └── nginx │ └── hpc.conf ├── external └── .gitkeep ├── package.json ├── packages ├── server │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── src │ │ ├── cache │ │ │ └── index.ts │ │ ├── captcha │ │ │ └── index.ts │ │ ├── config │ │ │ └── index.ts │ │ ├── db │ │ │ ├── base.ts │ │ │ ├── index.ts │ │ │ ├── message.ts │ │ │ ├── problem.ts │ │ │ ├── ranklist.ts │ │ │ ├── scow.ts │ │ │ ├── submission.ts │ │ │ ├── syskv.ts │ │ │ └── user.ts │ │ ├── index.ts │ │ ├── logger │ │ │ └── index.ts │ │ ├── mail │ │ │ └── index.ts │ │ ├── mq │ │ │ ├── index.ts │ │ │ └── writer.ts │ │ ├── scow │ │ │ └── index.ts │ │ ├── services │ │ │ ├── collector │ │ │ │ └── index.ts │ │ │ ├── fake-runner │ │ │ │ └── index.ts │ │ │ ├── main │ │ │ │ ├── api │ │ │ │ │ ├── admin.ts │ │ │ │ │ ├── auth.ts │ │ │ │ │ ├── base.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── kv.ts │ │ │ │ │ ├── message.ts │ │ │ │ │ ├── problem.ts │ │ │ │ │ ├── ranklist.ts │ │ │ │ │ ├── submission.ts │ │ │ │ │ └── user.ts │ │ │ │ └── index.ts │ │ │ └── ranker │ │ │ │ └── index.ts │ │ ├── storage │ │ │ └── index.ts │ │ └── utils │ │ │ ├── debounce.ts │ │ │ ├── paging.ts │ │ │ ├── rules.ts │ │ │ └── type.ts │ └── tsconfig.json └── ui │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── README.md │ ├── docs │ ├── home.md │ ├── problems.md │ ├── ranking.md │ ├── staff.md │ └── term.md │ ├── env.d.ts │ ├── index.html │ ├── package.json │ ├── public │ ├── favicon.ico │ ├── icon │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ └── hpcgame_logo_text_0th.svg │ └── site.webmanifest │ ├── src │ ├── App.vue │ ├── api │ │ └── index.ts │ ├── assets │ │ ├── hpcgame_logo.svg │ │ └── scow.svg │ ├── components │ │ ├── admin │ │ │ ├── AbstractEditor.vue │ │ │ ├── MessageEditor.vue │ │ │ ├── ModelTable.vue │ │ │ ├── ProblemEditor.vue │ │ │ ├── RanklistEditor.vue │ │ │ ├── SubmissionEditor.vue │ │ │ ├── SysEditor.vue │ │ │ └── UserEditor.vue │ │ ├── app │ │ │ ├── AppBody.vue │ │ │ ├── AppFooter.vue │ │ │ └── AppHeader.vue │ │ ├── message │ │ │ ├── GlobalMessages.vue │ │ │ ├── MessageCard.vue │ │ │ └── SelfMessages.vue │ │ ├── misc │ │ │ ├── AsyncState.vue │ │ │ ├── ErrorAlert.vue │ │ │ ├── FileDownloader.vue │ │ │ ├── FileUploader.vue │ │ │ ├── JSONEditor.vue │ │ │ ├── MdiIcon.vue │ │ │ ├── ReCaptcha.vue │ │ │ └── TaskContext.vue │ │ ├── problem │ │ │ ├── ProblemList.vue │ │ │ └── ProblemSubmit.vue │ │ ├── ranklist │ │ │ ├── RanklistDisplay.vue │ │ │ ├── RanklistScores.vue │ │ │ └── RanklistTopstars.vue │ │ ├── scow │ │ │ └── ConnectScow.vue │ │ ├── submission │ │ │ ├── ResultCase.vue │ │ │ ├── ResultView.vue │ │ │ ├── ScoreSpan.vue │ │ │ └── SubmissionList.vue │ │ └── user │ │ │ ├── UserEdit.vue │ │ │ ├── UserGroup.vue │ │ │ ├── UserIndicator.vue │ │ │ ├── UserIndicatorProxy.vue │ │ │ ├── UserLoginBtn.vue │ │ │ └── UserTags.vue │ ├── globals.d.ts │ ├── layouts │ │ └── DefaultLayout.vue │ ├── main.ts │ ├── router │ │ ├── admin.ts │ │ ├── index.ts │ │ ├── login.ts │ │ ├── problems.ts │ │ └── user.ts │ ├── shims.d.ts │ ├── styles │ │ └── main.css │ ├── utils │ │ ├── analysis.ts │ │ ├── async.ts │ │ ├── avatar.ts │ │ ├── countdown.ts │ │ ├── error.ts │ │ ├── format.ts │ │ ├── md.ts │ │ ├── meta.ts │ │ ├── misc.ts │ │ ├── notification.ts │ │ ├── permissions.ts │ │ ├── problems.ts │ │ ├── ranklist.ts │ │ ├── renderIcon.ts │ │ ├── schedule.ts │ │ ├── scow.ts │ │ ├── shared.ts │ │ ├── storage.ts │ │ └── sync.ts │ ├── views │ │ ├── AboutView.vue │ │ ├── AdminView.vue │ │ ├── HomeView.vue │ │ ├── LoginView.vue │ │ ├── MessagesView.vue │ │ ├── NotFoundView.vue │ │ ├── ProblemsView.vue │ │ ├── RankingView.vue │ │ ├── RanklistView.vue │ │ ├── StaffView.vue │ │ ├── TermsView.vue │ │ ├── UserView.vue │ │ ├── admin │ │ │ ├── HomeView.vue │ │ │ ├── MessageEditView.vue │ │ │ ├── MessageNewView.vue │ │ │ ├── MessageView.vue │ │ │ ├── ProblemEditView.vue │ │ │ ├── ProblemNewView.vue │ │ │ ├── ProblemView.vue │ │ │ ├── RanklistEditView.vue │ │ │ ├── RanklistNewView.vue │ │ │ ├── RanklistView.vue │ │ │ ├── ScowView.vue │ │ │ ├── SubmissionEditView.vue │ │ │ ├── SubmissionView.vue │ │ │ ├── SysEditView.vue │ │ │ ├── SysNewView.vue │ │ │ ├── SysView.vue │ │ │ ├── UserEditView.vue │ │ │ └── UserView.vue │ │ ├── auth │ │ │ ├── DevAuth.vue │ │ │ ├── IaaaAuth.vue │ │ │ ├── IaaaCallback.vue │ │ │ └── MailAuth.vue │ │ ├── problems │ │ │ ├── HomeView.vue │ │ │ ├── ProblemView.vue │ │ │ └── SubmissionView.vue │ │ └── user │ │ │ ├── LogoutView.vue │ │ │ └── ProfileView.vue │ └── workers │ │ ├── index.ts │ │ └── message.ts │ ├── tsconfig.config.json │ ├── tsconfig.json │ ├── vite.config.ts │ └── windi.config.ts └── yarn.lock /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/javascript-node:0-18 2 | 3 | # Install MongoDB command line tools - though mongo-database-tools not available on arm64 4 | ARG MONGO_TOOLS_VERSION=6.0 5 | RUN . /etc/os-release \ 6 | && curl -sSL "https://www.mongodb.org/static/pgp/server-${MONGO_TOOLS_VERSION}.asc" | gpg --dearmor > /usr/share/keyrings/mongodb-archive-keyring.gpg \ 7 | && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/mongodb-archive-keyring.gpg] http://repo.mongodb.org/apt/debian ${VERSION_CODENAME}/mongodb-org/${MONGO_TOOLS_VERSION} main" | tee /etc/apt/sources.list.d/mongodb-org-${MONGO_TOOLS_VERSION}.list \ 8 | && apt-get update && export DEBIAN_FRONTEND=noninteractive \ 9 | && apt-get install -y mongodb-mongosh \ 10 | && if [ "$(dpkg --print-architecture)" = "amd64" ]; then apt-get install -y mongodb-database-tools; fi \ 11 | && apt-get clean -y && rm -rf /var/lib/apt/lists/* 12 | 13 | RUN corepack enable 14 | 15 | # [Optional] Uncomment this section to install additional OS packages. 16 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 17 | # && apt-get -y install --no-install-recommends 18 | 19 | # [Optional] Uncomment if you want to install an additional version of node using nvm 20 | # ARG EXTRA_NODE_VERSION=10 21 | # RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}" 22 | 23 | # [Optional] Uncomment if you want to install more global node modules 24 | # RUN su node -c "npm install -g " 25 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node-mongo 3 | { 4 | "name": "HPCGame Platform", 5 | "dockerComposeFile": "docker-compose.yml", 6 | "service": "app", 7 | "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", 8 | // Features to add to the dev container. More info: https://containers.dev/features. 9 | // "features": {}, 10 | // Configure tool-specific properties. 11 | "customizations": { 12 | // Configure properties specific to VS Code. 13 | "vscode": { 14 | // Add the IDs of extensions you want installed when the container is created. 15 | "extensions": [ 16 | "mongodb.mongodb-vscode", 17 | "dbaeumer.vscode-eslint", 18 | "esbenp.prettier-vscode", 19 | "VisualStudioExptTeam.intellicode-api-usage-examples", 20 | "VisualStudioExptTeam.vscodeintellicode", 21 | "voorjaar.windicss-intellisense", 22 | "Vue.volar", 23 | "wix.vscode-import-cost", 24 | "GitHub.copilot", 25 | "cweijan.vscode-redis-client" 26 | ] 27 | } 28 | }, 29 | "forwardPorts": [ 30 | "nsqadmin:4171", 31 | "minio:9000", 32 | "minio:9090" 33 | ], 34 | "postCreateCommand": "corepack yarn install" 35 | } -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | app: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | volumes: 9 | - ../..:/workspaces:cached 10 | 11 | command: sleep infinity 12 | 13 | db: 14 | image: mongo:latest 15 | restart: unless-stopped 16 | volumes: 17 | - mongodb-data:/data/db 18 | 19 | minio: 20 | restart: always 21 | image: minio/minio:latest 22 | volumes: 23 | - minio-data:/data 24 | ports: 25 | - '9000' 26 | - '9090' 27 | environment: 28 | MINIO_ACCESS_KEY: HPC 29 | MINIO_SECRET_KEY: HPCGAMEOSS 30 | command: server /data --console-address ":9090" 31 | 32 | nsqlookupd: 33 | image: nsqio/nsq 34 | command: /nsqlookupd 35 | ports: 36 | - '4160' 37 | - '4161' 38 | 39 | nsqd: 40 | image: nsqio/nsq 41 | command: /nsqd --lookupd-tcp-address=nsqlookupd:4160 42 | depends_on: 43 | - nsqlookupd 44 | ports: 45 | - '4150' 46 | - '4151' 47 | 48 | nsqadmin: 49 | image: nsqio/nsq 50 | command: /nsqadmin --lookupd-http-address=nsqlookupd:4161 51 | depends_on: 52 | - nsqlookupd 53 | ports: 54 | - '4171' 55 | 56 | redis: 57 | image: redis:latest 58 | restart: unless-stopped 59 | ports: 60 | - '6379' 61 | 62 | volumes: 63 | mongodb-data: 64 | minio-data: 65 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [*.{js,json,yml}] 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | es2021: true, 5 | node: true 6 | }, 7 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], 8 | overrides: [], 9 | parser: '@typescript-eslint/parser', 10 | parserOptions: { 11 | ecmaVersion: 'latest', 12 | sourceType: 'module' 13 | }, 14 | plugins: ['@typescript-eslint'], 15 | rules: {} 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node 2 | node_modules 3 | 4 | # Yarn 5 | .yarn/* 6 | !.yarn/patches 7 | !.yarn/plugins 8 | !.yarn/releases 9 | !.yarn/sdks 10 | !.yarn/versions 11 | .pnp.* 12 | 13 | # DotEnv 14 | .env 15 | .env.* 16 | 17 | # TypeScript 18 | *.tsbuildinfo 19 | 20 | # External Deps 21 | external/**/* 22 | !external/.gitkeep 23 | 24 | # macOS 25 | .DS_Store -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.12.1 2 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: 'none', 3 | tabWidth: 2, 4 | semi: false, 5 | singleQuote: true, 6 | printWidth: 80, 7 | endOfLine: 'lf' 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "VisualStudioExptTeam.intellicode-api-usage-examples", 6 | "VisualStudioExptTeam.vscodeintellicode", 7 | "voorjaar.windicss-intellisense", 8 | "Vue.volar", 9 | "wix.vscode-import-cost", 10 | ], 11 | "unwantedRecommendations": [] 12 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | plugins: 4 | - path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs 5 | spec: "@yarnpkg/plugin-workspace-tools" 6 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Linux Club of Peking University 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | logo 3 |

HPCGame Platform

4 | 5 | [文档](./docs/README.md) 6 | 7 |
8 | 9 | Related Projects: 10 | 11 | - [lcpu-club/hpcjudge](https://github.com/lcpu-club/hpcjudge) 12 | - [PKUHPC/SCOW](https://github.com/PKUHPC/SCOW) 13 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # HPCGamePlatform MonoRepo 2 | 3 | 您好,亲爱的开发者。本文档将指导您参与到 HPCGame 平台的开发工作中。倘若您是使用者,也不要灰心。您依然可以在本文档中找到有用的信息。 4 | 5 | 阅读[设计文档](./design.md)以了解 HPCGame 平台的设计思路。 6 | 7 | 请您**使用 DevContainers 进行开发**,或先按照下文所述的步骤配置开发环境,之后再阅读对应部件的文档。 8 | 9 | ## 开发环境配置 10 | 11 | 推荐使用 [Visual Studio Code](https://code.visualstudio.com/) 作为开发工具。 12 | 请安装项目推荐的插件。 13 | 14 | 1. 安装 nvm: https://github.com/nvm-sh/nvm 15 | 2. 在项目根目录处运行`nvm use`,安装项目所需的 node 版本 16 | 3. 运行`corepack enable` 17 | 4. 运行`yarn --version`,确保 yarn 版本为 `3.3.0` 18 | 5. 运行`yarn`安装依赖 19 | 20 | ## 选择一个组件 21 | 22 | 接下来,您需要选择一个组件进行开发。所有的组件以包的形式拆分,均位于`packages/`目录下。 23 | 24 | 目前,我们有如下组件: 25 | 26 | - [`ui`](./ui/README.md): HPCGame 平台的前端组件 27 | - [`server`](./server/README.md): HPCGame 平台的后端组件 28 | 29 | 请阅读对应的文档。 30 | -------------------------------------------------------------------------------- /docs/design.md: -------------------------------------------------------------------------------- 1 | # 系统设计 2 | 3 | ```mermaid 4 | graph LR 5 | FR[SPA] <-->|HTTP+WS| GATE{Gateway} 6 | GATE <-->|REST| MAIN[MainAPI] 7 | GATE <-->|HTTP+WS| PROXY[Proxier] 8 | MAIN <--> DB[Mongo] 9 | MAIN <-->|NSQ| RUNNER[external runner] 10 | MAIN -->|NSQ| RANK[Ranker] 11 | RANK <--> DB 12 | GATE <-->|HTTP| OSS[Minio] 13 | ``` 14 | 15 | - 评测和试验场是由单独的外部组件负责的,不在本 MonoRepo 中。 16 | - 组件之间使用 NSQ 进行通信。 17 | - 所有文件统一存储在 Minio 对象存储中。 18 | 19 | ## 约定 20 | 21 | ### 对象存储键值 22 | 23 | - 题目 24 | - 题目数据:`problem/{problem_id}/data` 25 | - 题面附件:`problem/{problem_id}/attachment/{filename}` 26 | - 提交 27 | - 提交文件:`submission/{submission_id}/data` 28 | - 评测日志:`submission/{submission_id}/result.json` 29 | - 评测产物:`submission/{submission_id}/artifact/{filename}` 30 | 31 | ### NSQ 消息结构 32 | 33 | 统一使用 JSON 格式 34 | 35 | #### Runner 相关 36 | 37 | ##### 评测请求 38 | 39 | ```ts 40 | // topic: runner.judge.request 41 | interface JudgeRequest { 42 | // 运行参数。该参数在Problem模型中定义。 43 | runner_args: string 44 | // 题面ID。请Runner从OSS获取题面数据。 45 | problem_id: string 46 | // 提交ID。请Runner从OSS获取提交数据。 47 | submission_id: string 48 | } 49 | ``` 50 | 51 | ##### 评测状态上报 52 | 53 | ```ts 54 | // topic: runner.judge.status 55 | interface JudgeStatus { 56 | // 提交ID。MainAPI将根据该ID更新评测状态。 57 | submission_id: string 58 | // 评测是否完成。MainAPI将根据此字段更新Submission的状态。 59 | done: boolean 60 | // 评测是否成功 61 | success: boolean 62 | // 评测错误消息 63 | error: string 64 | // 得分。倘若没有评测完成,请上报0。 65 | score: number 66 | // 消息。展示给用户的消息。 67 | message: string 68 | // 时间戳。在NSQ无序消息情形中,将据此字段判断是否更新。 69 | timestamp: number 70 | } 71 | ``` 72 | 73 | #### 其他消息 74 | 75 | ##### 排名重计算请求 76 | 77 | ```ts 78 | interface RankRequest { 79 | ranklist_id: string 80 | } 81 | ``` 82 | -------------------------------------------------------------------------------- /docs/server/README.md: -------------------------------------------------------------------------------- 1 | # HPCGamePlatform Server 2 | 3 | 确保完成了 MonoRepo 的环境配置。参考下文完成开发本包需要的环境配置。 4 | 5 | ## 开发环境配置 6 | 7 | - 配置 MongoDB 8 | - `docker run --rm --name hpc-mongo -p 27017:27017 -d mongo` 9 | - 配置 Minio 10 | - `docker run --rm --name hpc-minio -p 9000:9000 -p 9090:9090 -e "MINIO_ROOT_USER=HPC" -e "MINIO_ROOT_PASSWORD=HPCGAMEOSS" minio/minio server /data --console-address ":9090"` 11 | - 配置 NSQ 12 | - `docker run --rm --name hpc-lookupd -p 4160:4160 -p 4161:4161 -d nsqio/nsq /nsqlookupd` 13 | - `docker run --rm --name hpc-nsqd -p 4150:4150 -p 4151:4151 -d nsqio/nsq /nsqd --broadcast-address=172.17.0.1 --lookupd-tcp-address=172.17.0.1:4160` 14 | 15 | 注意,上述 docker 命令均没有进行数据持久化。在开发过程中,这并不是必要的。 16 | 17 | 然后,按照`src/config/index.ts`中的描述设置对应的环境变量。您可以在这个包的根目录下创建`.env`文件,将环境变量写入其中。一个示例文件如下: 18 | 19 | ```env 20 | HPC_DEV_MODE=true 21 | ``` 22 | 23 | ## 包结构 24 | 25 | - `src`:源代码 26 | - `config`:配置相关。所有配置都以环境变量的形式提供。 27 | - `db`:数据库相关。本项目使用 MongoDB。 28 | - `logger`:pino 的单例。 29 | - `mq`:消息队列相关。 30 | - `services`:诸服务。 31 | - `main`:主服务,负责提供包括鉴权、用户管理、题面、提交等功能的 RESTFul API。 32 | - `ranker`:排名服务,负责计算排名。 33 | - `proxy`:代理服务,负责代理用户启动的开发环境。 34 | - `storage`:Minio 相关的对象存储。 35 | - `utils`:工具函数。 36 | 37 | ## 常用开发命令 38 | 39 | - `yarn build -w`:启动 watch 模式,监听文件变化并自动编译。 40 | - `yarn start [-s ]`:启动服务。默认启动`main`服务。 41 | - `yarn lint`:检查代码问题。 42 | -------------------------------------------------------------------------------- /docs/ui/README.md: -------------------------------------------------------------------------------- 1 | # HPCGamePlatform UI 2 | 3 | 确保完成了 MonoRepo 的环境配置。您还需要配置好 Server 的所有环境。 4 | 5 | ## 配置文件 6 | 7 | 下面是示例的开发配置文件。请将其保存为包根目录下的`.env`文件。 8 | 9 | ```env 10 | VITE_DEV_MODE=true 11 | VITE_MAIN_API=http://localhost:10721 12 | VITE_GRAVATAR_URL=https://cravatar.cn/avatar/ 13 | ``` 14 | 15 | ## 包结构 16 | 17 | - `public`:将直接暴露在公网的资源。 18 | - `src`:源代码 19 | - `api`:和 Server 交互的 API。 20 | - `assets`:需要编译期确定位置的资源。 21 | - `components`:组件。 22 | - `layouts`:页面布局。目前仅需一个默认布局,无需更改。 23 | - `router`:前端路由。 24 | - `styles`:样式表。注意尽量使用 WindiCSS 类,能不写自定义类就别写。 25 | - `utils`:工具函数。 26 | - `views`:页面。 27 | 28 | ## 常用开发命令 29 | 30 | - `yarn dev`:启动 Dev Server。 31 | - `yarn lint`:检查代码问题。 32 | -------------------------------------------------------------------------------- /env/test/.gitignore: -------------------------------------------------------------------------------- 1 | data/**/* 2 | !data/.gitkeep 3 | -------------------------------------------------------------------------------- /env/test/README.md: -------------------------------------------------------------------------------- 1 | # 测试环境说明 2 | 3 | 测试环境直接将整个 Workspace 挂载到容器里。因此,对于部署者,你需要在本地安装好所有的依赖。 4 | 5 | 请参考文档中的工作区环境配置,并运行`yarn workspaces foreach run build`来构建所有的包。 6 | 7 | 然后,使用`docker compose up -d`来启动所有的服务。 8 | -------------------------------------------------------------------------------- /env/test/data/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcpu-club/hpcgame-platform/0b13cba3e2431c5b6e886f588b533158e0f8ba91/env/test/data/.gitkeep -------------------------------------------------------------------------------- /env/test/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | services: 3 | server-main: 4 | restart: always 5 | image: node:18 6 | volumes: 7 | - ../..:/workspace 8 | command: bash -c "cd /workspace; corepack yarn workspace @hpcgame-platform/server start" 9 | ports: 10 | - 13000:13000 11 | depends_on: 12 | mongo: 13 | condition: service_healthy 14 | links: 15 | - mongo 16 | environment: 17 | HPC_DEV_MODE: 'true' 18 | HPC_API_HOST: '0.0.0.0' 19 | HPC_API_PORT: '10721' 20 | HPC_TRUST_PROXY: 'true' 21 | HPC_MONGO_URI: 'mongodb://mongo:27017/hpc' 22 | HPC_NSQ_NSQD_HOST: 'nsqd' 23 | HPC_NSQ_NSQD_PORT: '4150' 24 | HPC_NSQ_LOOKUPD_ADDR: 'nsqlookupd:4161' 25 | HPC_MINIO_ENDPOINT: 'minio:9000' 26 | HPC_MINIO_ACCESS_KEY: '${HPC_MINIO_ACCESS_KEY}' 27 | HPC_MINIO_SECRET_KEY: '${HPC_MINIO_SECRET_KEY}' 28 | 29 | mongo: 30 | restart: always 31 | image: mongo:latest 32 | volumes: 33 | - ./data/mongo:/data/db 34 | healthcheck: 35 | test: echo 'db.stats().ok' | mongosh mongo:27017/test --quiet 36 | interval: 10s 37 | timeout: 10s 38 | retries: 5 39 | 40 | minio: 41 | restart: always 42 | image: minio/minio:latest 43 | volumes: 44 | - ./data/minio:/data 45 | ports: 46 | - 9000:9000 47 | - 9090:9090 48 | environment: 49 | MINIO_ACCESS_KEY: HPC 50 | MINIO_SECRET_KEY: HPCGAMEOSS 51 | command: server /data --console-address ":9090" 52 | 53 | nsqlookupd: 54 | image: nsqio/nsq 55 | command: /nsqlookupd 56 | ports: 57 | - '4160' 58 | - '4161' 59 | nsqd: 60 | image: nsqio/nsq 61 | command: /nsqd --lookupd-tcp-address=nsqlookupd:4160 62 | depends_on: 63 | - nsqlookupd 64 | ports: 65 | - '4150' 66 | - '4151' 67 | 68 | nsqadmin: 69 | image: nsqio/nsq 70 | command: /nsqadmin --lookupd-http-address=nsqlookupd:4161 71 | depends_on: 72 | - nsqlookupd 73 | ports: 74 | - '4171' 75 | 76 | nginx: 77 | restart: always 78 | image: nginx:latest 79 | volumes: 80 | - ./nginx:/etc/nginx/conf.d 81 | - ../..:/workspace 82 | ports: 83 | - 8000:8000 84 | depends_on: 85 | - server-main 86 | - mongo 87 | - minio 88 | - nsqd 89 | - nsqlookupd 90 | -------------------------------------------------------------------------------- /env/test/nginx/hpc.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 8000; 3 | server_name _; 4 | root /workspace/packages/ui/dist; 5 | 6 | location / { 7 | try_files $uri $uri/ /index.html; 8 | } 9 | 10 | location /api/ { 11 | proxy_pass http://server-main:10721/; 12 | proxy_set_header X-Forwarded-Host $http_host; 13 | proxy_set_header X-Forwarded-For $remote_addr; 14 | proxy_set_header X-Forwarded-Proto $scheme; 15 | proxy_set_header X-Real-IP $remote_addr; 16 | } 17 | 18 | location /oss/ { 19 | proxy_pass http://minio:9000/; 20 | proxy_set_header X-Forwarded-Host $http_host; 21 | proxy_set_header X-Forwarded-For $remote_addr; 22 | proxy_set_header X-Forwarded-Proto $scheme; 23 | proxy_set_header X-Real-IP $remote_addr; 24 | } 25 | } -------------------------------------------------------------------------------- /external/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcpu-club/hpcgame-platform/0b13cba3e2431c5b6e886f588b533158e0f8ba91/external/.gitkeep -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hpcgame-platform/workspace", 3 | "packageManager": "yarn@3.3.0", 4 | "private": true, 5 | "type": "module", 6 | "license": "MIT", 7 | "workspaces": [ 8 | "packages/*" 9 | ], 10 | "devDependencies": { 11 | "@types/node": "^18.11.17", 12 | "@typescript-eslint/eslint-plugin": "latest", 13 | "@typescript-eslint/parser": "latest", 14 | "eslint": "^8.30.0", 15 | "pino-pretty": "^9.1.1", 16 | "prettier": "^2.8.1", 17 | "ts-node": "^10.9.1", 18 | "typescript": "^4.9.4" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/server/.gitignore: -------------------------------------------------------------------------------- 1 | lib/ -------------------------------------------------------------------------------- /packages/server/README.md: -------------------------------------------------------------------------------- 1 | # server 2 | -------------------------------------------------------------------------------- /packages/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hpcgame-platform/server", 3 | "packageManager": "yarn@3.3.0", 4 | "type": "module", 5 | "version": "0.0.1", 6 | "license": "MIT", 7 | "dependencies": { 8 | "@aws-sdk/client-s3": "^3.238.0", 9 | "@aws-sdk/s3-request-presigner": "^3.238.0", 10 | "@fastify/cors": "^8.2.0", 11 | "@fastify/rate-limit": "^7.6.0", 12 | "@fastify/sensible": "^5.2.0", 13 | "@fastify/type-provider-typebox": "^2.4.0", 14 | "@pku-internals/iaaa": "portal:../../external/iaaa", 15 | "@sinclair/typebox": "^0.25.16", 16 | "dotenv": "^16.0.3", 17 | "fastify": "^4.10.2", 18 | "fastify-typeful": "^0.1.1", 19 | "ioredis": "^5.2.4", 20 | "isemail": "^3.2.0", 21 | "js-sdsl": "^4.2.0", 22 | "minimist": "^1.2.7", 23 | "mongodb": "^4.13.0", 24 | "nanoid": "^4.0.0", 25 | "node-fetch": "^3.3.0", 26 | "nodemailer": "^6.8.0", 27 | "nsqjs": "^0.13.0", 28 | "pino": "^8.8.0", 29 | "scow-api": "^0.0.8" 30 | }, 31 | "devDependencies": { 32 | "@fastify/swagger": "^8.2.1", 33 | "@fastify/swagger-ui": "^1.3.0", 34 | "@types/minimist": "^1.2.2", 35 | "@types/nodemailer": "^6.4.7", 36 | "@types/nsqjs": "^0.12.1", 37 | "typeful-fetch": "^0.1.4" 38 | }, 39 | "peerDependencies": { 40 | "typeful-fetch": "*" 41 | }, 42 | "scripts": { 43 | "build": "run -T tsc", 44 | "lint": "run -T eslint . --ext .js,.cjs,.mjs,.ts,.cts,.mts --fix --ignore-path .gitignore", 45 | "start": "node -r dotenv/config lib/index.js" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/server/src/cache/index.ts: -------------------------------------------------------------------------------- 1 | import Redis from 'ioredis' 2 | import { REDIS_URL } from '../config/index.js' 3 | 4 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 5 | // @ts-ignore: ioredis types are broken 6 | export const redis = new Redis.default(REDIS_URL) 7 | -------------------------------------------------------------------------------- /packages/server/src/captcha/index.ts: -------------------------------------------------------------------------------- 1 | import fetch, { FetchError } from 'node-fetch' 2 | import { RECAPTCHA_SECRET } from '../config/index.js' 3 | import { logger } from '../logger/index.js' 4 | import { httpErrors } from '../services/main/index.js' 5 | 6 | export async function recaptchaVerify(response: string) { 7 | try { 8 | // Verify using recaptcha v2 9 | const recaptchaResponse = await fetch( 10 | 'https://www.recaptcha.net/recaptcha/api/siteverify', 11 | { 12 | method: 'POST', 13 | headers: { 14 | 'Content-Type': 'application/x-www-form-urlencoded' 15 | }, 16 | body: new URLSearchParams({ 17 | secret: RECAPTCHA_SECRET, 18 | response 19 | }).toString() 20 | } 21 | ) 22 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 23 | const recaptchaResult = await recaptchaResponse.json() 24 | if (!recaptchaResult.success) { 25 | logger.error(recaptchaResult) 26 | throw httpErrors.badRequest(`Failed to verify recaptcha`) 27 | } 28 | } catch (err) { 29 | if (err instanceof FetchError) { 30 | logger.error(err) 31 | throw httpErrors.badGateway(`Failed to connect to recaptcha server`) 32 | } 33 | throw err 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/server/src/config/index.ts: -------------------------------------------------------------------------------- 1 | const PREFIX = 'HPC_' 2 | 3 | function transformer(transform: (val: string) => T) { 4 | return (key: string, init?: T) => { 5 | key = PREFIX + key 6 | const raw = process.env[key] 7 | let value: T | undefined = init 8 | if (typeof raw === 'string') { 9 | value = transform(raw) 10 | } 11 | if (value === undefined) 12 | throw new Error(`Missing environment variable: ${key}`) 13 | return value 14 | } 15 | } 16 | const string = transformer((val) => val) 17 | const number = transformer((val) => parseInt(val, 10)) 18 | const boolean = transformer((val) => val === 'true') 19 | const json = transformer((val) => JSON.parse(val)) 20 | 21 | // MongoDB 22 | export const MONGO_URI = string('MONGO_URI', 'mongodb://localhost:27017/hpc') 23 | 24 | // Fastify 25 | export const API_HOST = string('API_HOST', 'localhost') 26 | export const API_PORT = number('API_PORT', 10721) 27 | export const TRUST_PROXY = json('TRUST_PROXY', false) // boolean | string | string[] | number 28 | export const ENABLE_CORS = boolean('ENABLE_CORS', false) 29 | 30 | // NSQ 31 | export const NSQ_NSQD_HOST = string('NSQ_NSQD_HOST', 'localhost') 32 | export const NSQ_NSQD_PORT = number('NSQ_NSQD_PORT', 4150) 33 | export const NSQ_LOOKUPD_ADDR = string('NSQ_LOOKUPD_ADDR', '127.0.0.1:4161') 34 | 35 | // Minio 36 | export const MINIO_ENDPOINT = string('MINIO_ENDPOINT', 'localhost:9000') 37 | export const MINIO_ACCESS_KEY = string('MINIO_ACCESS_KEY') 38 | export const MINIO_SECRET_KEY = string('MINIO_SECRET_KEY') 39 | export const MINIO_BUCKET_SUBMISSION = string( 40 | 'MINIO_BUCKET_SUBMISSION', 41 | 'submission' 42 | ) 43 | export const MINIO_BUCKET_PROBLEM = string('MINIO_BUCKET_PROBLEM', 'problem') 44 | 45 | // Application 46 | export const DEV_MODE = boolean('DEV_MODE', false) 47 | 48 | // Auth 49 | export const IAAA_ID = string('IAAA_ID') 50 | export const IAAA_KEY = string('IAAA_KEY') 51 | 52 | // Mail 53 | export const SMTP_HOST = string('SMTP_HOST') 54 | export const SMTP_PORT = number('SMTP_PORT') 55 | export const SMTP_USER = string('SMTP_USER') 56 | export const SMTP_PASS = string('SMTP_PASS') 57 | export const MAIL_FROM = string('MAIL_FROM') 58 | export const MAIL_SENDER = string('MAIL_SENDER', 'HPC Game System') 59 | 60 | // Redis 61 | export const REDIS_URL = string('REDIS_URL', 'redis://localhost:6379') 62 | 63 | // Recaptcha 64 | export const RECAPTCHA_SECRET = string('RECAPTCHA_SECRET') 65 | 66 | // SCOW 67 | export const SCOW_GRPC_ADDR = string('SCOW_GRPC_ADDR') 68 | export const SCOW_TENANT_NAME = string('SCOW_TENANT_NAME', 'hpcgame') 69 | export const SCOW_ADMIN_NAME = string('SCOW_ADMIN_NAME', 'hpcgame_admin') 70 | export const SCOW_ADMIN_PASS = string('SCOW_ADMIN_PASS', 'hpcgame_admin') 71 | 72 | // Runner 73 | export const RUNNER_SECRET = string('RUNNER_SECRET', '') 74 | -------------------------------------------------------------------------------- /packages/server/src/db/base.ts: -------------------------------------------------------------------------------- 1 | import { MongoClient } from 'mongodb' 2 | import { MONGO_URI } from '../config/index.js' 3 | 4 | export const client = new MongoClient(MONGO_URI) 5 | export const db = client.db() 6 | -------------------------------------------------------------------------------- /packages/server/src/db/index.ts: -------------------------------------------------------------------------------- 1 | export * from './base.js' 2 | export * from './message.js' 3 | export * from './problem.js' 4 | export * from './submission.js' 5 | export * from './user.js' 6 | export * from './ranklist.js' 7 | -------------------------------------------------------------------------------- /packages/server/src/db/message.ts: -------------------------------------------------------------------------------- 1 | import { db } from './base.js' 2 | 3 | export interface IMessage { 4 | _id: string 5 | global: boolean 6 | group: string 7 | userId: string 8 | 9 | title: string 10 | content: string 11 | metadata: Record 12 | 13 | createdAt: number 14 | } 15 | 16 | export const Messages = db.collection('messages') 17 | 18 | await Messages.createIndex({ createdAt: -1 }, { unique: false }) 19 | await Messages.createIndex({ userId: 1 }, { unique: false }) 20 | await Messages.createIndex({ group: 1 }, { unique: false }) 21 | await Messages.createIndex({ global: 1 }, { unique: false }) 22 | -------------------------------------------------------------------------------- /packages/server/src/db/problem.ts: -------------------------------------------------------------------------------- 1 | import { db } from './base.js' 2 | 3 | export interface IProblem { 4 | _id: string 5 | public: boolean 6 | title: string 7 | content: string 8 | score: number 9 | maxSubmissionCount: number 10 | maxSubmissionSize: number 11 | runnerArgs: string 12 | category: string 13 | tags: string[] 14 | metadata: Record 15 | } 16 | 17 | export const Problems = db.collection('problems') 18 | -------------------------------------------------------------------------------- /packages/server/src/db/ranklist.ts: -------------------------------------------------------------------------------- 1 | import type { Filter } from 'mongodb' 2 | import type { IUser } from './user.js' 3 | import { db } from './base.js' 4 | 5 | export interface IRanklistPlayer { 6 | userId: string 7 | score: number 8 | scores: Record 9 | last?: number 10 | } 11 | 12 | export interface IRanklistOptions { 13 | filter: Filter 14 | playerCount: number 15 | topstarCount: number 16 | } 17 | 18 | export interface IRanklistTopstarMutation { 19 | score: number 20 | timestamp: number 21 | } 22 | 23 | export interface IRanklistTopstar { 24 | userId: string 25 | mutations: IRanklistTopstarMutation[] 26 | } 27 | 28 | export interface IRanklist { 29 | _id: string 30 | public: boolean 31 | name: string 32 | options: IRanklistOptions 33 | players: IRanklistPlayer[] 34 | topstars: IRanklistTopstar[] 35 | updatedAt: number 36 | } 37 | 38 | export const Ranklists = db.collection('ranklists') 39 | -------------------------------------------------------------------------------- /packages/server/src/db/scow.ts: -------------------------------------------------------------------------------- 1 | import { nanoid, customAlphabet } from 'nanoid/async' 2 | import { 3 | createSCOWUser, 4 | getRunnerAccount, 5 | getUserAccount 6 | } from '../scow/index.js' 7 | import { db } from './base.js' 8 | import { defaultUserChargeLimit, kUserChargeLimit, sysGet } from './syskv.js' 9 | import { Users } from './user.js' 10 | import { execuateRules } from '../utils/rules.js' 11 | 12 | export interface ISCOWCredential { 13 | _id: string 14 | userId: string 15 | problemId: string 16 | password: string 17 | synced: boolean 18 | } 19 | 20 | export const SCOWCredentials = db.collection('scowCredentials') 21 | 22 | await SCOWCredentials.createIndex({ userId: 1, problemId: 1 }, { unique: true }) 23 | 24 | // Valid Linux username: [a-z_][a-z0-9_-]* 25 | // ~2^157 unique IDs 26 | const SCOWId = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789_-', 30) 27 | 28 | async function generateSCOWId() { 29 | // Valid Linux username: [a-z_][a-z0-9_-]* 30 | return 'hp' + (await SCOWId()) 31 | } 32 | 33 | export async function getSCOWCredentialsForUser(userId: string) { 34 | let cred = await SCOWCredentials.findOne({ userId, problemId: '' }) 35 | if (!cred) { 36 | cred = { 37 | _id: await generateSCOWId(), 38 | userId, 39 | problemId: '', 40 | password: await nanoid(32), 41 | synced: false 42 | } 43 | await SCOWCredentials.insertOne(cred) 44 | } 45 | if (!cred.synced) { 46 | const user = await Users.findOne( 47 | { _id: userId }, 48 | { projection: { metadata: 1, authEmail: 1, group: 1, tags: 1 } } 49 | ) 50 | if (!user) throw new Error('User not found') 51 | const limits = await sysGet(kUserChargeLimit, defaultUserChargeLimit) 52 | const config = limits[user.group] 53 | const limit = 54 | typeof config === 'number' ? config : execuateRules(config, user, 1) 55 | await createSCOWUser( 56 | cred._id, 57 | cred.password, 58 | getUserAccount(user.group), 59 | user.metadata.realname, 60 | user.authEmail, 61 | limit 62 | ) 63 | await SCOWCredentials.updateOne( 64 | { _id: cred._id }, 65 | { $set: { synced: true } } 66 | ) 67 | } 68 | return { _id: cred._id, password: cred.password } 69 | } 70 | 71 | export async function getSCOWCredentialsForProblem( 72 | userId: string, 73 | problemId: string 74 | ) { 75 | let cred = await SCOWCredentials.findOne({ userId, problemId }) 76 | if (!cred) { 77 | cred = { 78 | _id: await generateSCOWId(), 79 | userId, 80 | problemId, 81 | password: await nanoid(32), 82 | synced: false 83 | } 84 | await SCOWCredentials.insertOne(cred) 85 | } 86 | if (!cred.synced) { 87 | const user = await Users.findOne( 88 | { _id: userId }, 89 | { projection: { metadata: 1, authEmail: 1, group: 1 } } 90 | ) 91 | if (!user) throw new Error('User not found') 92 | await createSCOWUser( 93 | cred._id, 94 | cred.password, 95 | getRunnerAccount(user.group), 96 | `${problemId}-${userId}` 97 | ) 98 | await SCOWCredentials.updateOne( 99 | { _id: cred._id }, 100 | { $set: { synced: true } } 101 | ) 102 | } 103 | return { _id: cred._id, password: cred.password } 104 | } 105 | -------------------------------------------------------------------------------- /packages/server/src/db/submission.ts: -------------------------------------------------------------------------------- 1 | import type { Static } from '@sinclair/typebox' 2 | import { StringEnum } from '../utils/type.js' 3 | import { db } from './base.js' 4 | 5 | export const SubmissionStatusSchema = StringEnum([ 6 | 'created', 7 | 'pending', 8 | 'running', 9 | 'finished' 10 | ]) 11 | export type SubmissionStatus = Static 12 | 13 | export interface ISubmission { 14 | _id: string 15 | userId: string 16 | problemId: string 17 | score: number 18 | status: SubmissionStatus 19 | message: string 20 | 21 | createdAt: number 22 | updatedAt: number 23 | 24 | metadata: { 25 | ext?: string 26 | } 27 | } 28 | 29 | export const Submissions = db.collection('submissions') 30 | await Submissions.createIndex({ userId: 1, problemId: 1 }) 31 | await Submissions.createIndex( 32 | { userId: 1, problemId: 1 }, 33 | { 34 | unique: true, 35 | partialFilterExpression: { 36 | status: { 37 | $in: ['created', 'pending', 'running'] 38 | } 39 | }, 40 | name: 'unique_submission_per_user' 41 | } 42 | ) 43 | -------------------------------------------------------------------------------- /packages/server/src/db/syskv.ts: -------------------------------------------------------------------------------- 1 | import { redis } from '../cache/index.js' 2 | import type { IRule } from '../utils/rules.js' 3 | import { db } from './base.js' 4 | import type { UserGroup } from './user.js' 5 | 6 | export interface ISysKVItem { 7 | _id: string 8 | value: unknown 9 | } 10 | 11 | export const SysKVItems = db.collection('syskv') 12 | 13 | export type ISysKey = string & { __type: T } 14 | export type InferSysType = K extends ISysKey 15 | ? T 16 | : unknown 17 | 18 | export async function sysGet(key: K, init: InferSysType) { 19 | const cached = await redis.get(`syskv:${key}`) 20 | if (cached) return JSON.parse(cached) as InferSysType 21 | const item = await SysKVItems.findOne({ _id: key }) 22 | if (!item) { 23 | await sysSet(key, init) 24 | return init 25 | } 26 | await redis.set(`syskv:${key}`, JSON.stringify(item.value)) 27 | return item.value as InferSysType 28 | } 29 | 30 | export async function sysSet(key: K, value: InferSysType) { 31 | await SysKVItems.updateOne( 32 | { _id: key }, 33 | { $set: { value } }, 34 | { upsert: true } 35 | ) 36 | await redis.set(`syskv:${key}`, JSON.stringify(value)) 37 | } 38 | 39 | export async function sysTryGet(key: K) { 40 | const cached = await redis.get(`syskv:${key}`) 41 | if (cached) return JSON.parse(cached) as InferSysType 42 | const item = await SysKVItems.findOne({ _id: key }) 43 | return item?.value as InferSysType | null 44 | } 45 | 46 | export const kGameSchedule = 'game_schedule' as ISysKey<{ 47 | start: number 48 | end: number 49 | }> 50 | export const defaultGameSchedule = { 51 | start: 0, 52 | end: 0 53 | } 54 | 55 | export const kUserChargeLimit = 'user_charge_limit' as ISysKey< 56 | Record 57 | > 58 | export const defaultUserChargeLimit: Record = { 59 | admin: 0, 60 | banned: 1, 61 | pku: 20, 62 | social: 10, 63 | staff: 50 64 | } 65 | 66 | export const kEmailConfig = 'email_config' as ISysKey<{ 67 | whitelist: string[] 68 | blacklist: string[] 69 | }> 70 | export const defaultEmailConfig = { 71 | whitelist: ['.edu.cn'], 72 | blacklist: [] 73 | } 74 | 75 | export const kTagRules = 'tag_rules' as ISysKey<{ 76 | rules: IRule[] 77 | }> 78 | export const defaultTagRules = { 79 | rules: [] 80 | } 81 | -------------------------------------------------------------------------------- /packages/server/src/db/user.ts: -------------------------------------------------------------------------------- 1 | import type { Static } from '@sinclair/typebox' 2 | import { nanoid } from 'nanoid/async' 3 | import { redis } from '../cache/index.js' 4 | import { StringEnum } from '../utils/type.js' 5 | import { db } from './base.js' 6 | 7 | export const UserGroups = ['banned', 'pku', 'social', 'staff', 'admin'] as const 8 | export const UserGroupSchema = StringEnum(UserGroups) 9 | export type UserGroup = Static 10 | 11 | export interface IUserAuthSource { 12 | iaaa?: string // IAAA Identifier 13 | } 14 | 15 | export interface ProblemStatus { 16 | score?: number 17 | submissionCount: number 18 | effectiveSubmissionId: string 19 | } 20 | 21 | export interface IUser { 22 | _id: string 23 | name: string 24 | group: UserGroup 25 | tags: string[] 26 | email: string 27 | 28 | authToken: string 29 | iaaaId?: string 30 | authEmail?: string 31 | problemStatus: Record 32 | 33 | metadata: { 34 | qq?: string 35 | realname?: string 36 | organization?: string 37 | } 38 | } 39 | 40 | export const Users = db.collection('users') 41 | 42 | await Users.createIndex({ iaaaId: 1 }, { unique: true, sparse: true }) 43 | await Users.createIndex({ authEmail: 1 }, { unique: true, sparse: true }) 44 | 45 | export async function generateAuthToken(userId: string) { 46 | const token = await nanoid(32) 47 | return userId + ':' + token 48 | } 49 | 50 | export type IUserInfo = Pick 51 | 52 | export async function expireUserInfo(_id: string) { 53 | const keys = await redis.keys('user:' + _id + ':*') 54 | if (keys.length) { 55 | const pipeline = redis.pipeline() 56 | for (const key of keys) { 57 | pipeline.del(key) 58 | } 59 | await pipeline.exec() 60 | } 61 | } 62 | 63 | export async function verifyAuthToken(token: unknown) { 64 | if (typeof token !== 'string') return null 65 | const [userId] = token.split(':') 66 | if (typeof userId !== 'string') return null 67 | const cached = await redis.get('user:' + token) 68 | if (!cached) { 69 | const user = await Users.findOne( 70 | { _id: userId }, 71 | { projection: { _id: 1, group: 1, authToken: 1 } } 72 | ) 73 | if (!user) return null 74 | const { authToken, ...rest } = user 75 | if (authToken !== token) return null 76 | await redis.set('user:' + authToken, JSON.stringify(rest)) 77 | return user 78 | } 79 | return JSON.parse(cached) as IUserInfo 80 | } 81 | 82 | export async function createUser(user: Omit) { 83 | const id = await nanoid() 84 | const authToken = await generateAuthToken(id) 85 | await Users.insertOne({ _id: id, ...user, authToken }) 86 | return { _id: id, ...user, authToken } 87 | } 88 | -------------------------------------------------------------------------------- /packages/server/src/index.ts: -------------------------------------------------------------------------------- 1 | import cluster from 'cluster' 2 | import minimist from 'minimist' 3 | 4 | const argv = minimist(process.argv.slice(2)) 5 | const service = String(argv.service ?? argv.s ?? 'main') 6 | const workers = Number(argv.workers ?? argv.w ?? 1) 7 | 8 | if (cluster.isPrimary) { 9 | console.log(`Primary ${process.pid} started`) 10 | for (let i = 0; i < workers; i++) { 11 | cluster.fork() 12 | } 13 | cluster.on('exit', (worker, code, signal) => { 14 | console.log( 15 | `worker ${worker.process.pid} died with code ${code} and signal ${signal}` 16 | ) 17 | }) 18 | } else { 19 | console.log(`Worker ${process.pid} started`) 20 | await import(`./services/${service}/index.js`) 21 | } 22 | -------------------------------------------------------------------------------- /packages/server/src/logger/index.ts: -------------------------------------------------------------------------------- 1 | import { pino } from 'pino' 2 | 3 | export const logger = pino() 4 | -------------------------------------------------------------------------------- /packages/server/src/mail/index.ts: -------------------------------------------------------------------------------- 1 | import { createTransport } from 'nodemailer' 2 | import { 3 | SMTP_HOST, 4 | SMTP_PORT, 5 | SMTP_USER, 6 | SMTP_PASS, 7 | MAIL_FROM, 8 | MAIL_SENDER 9 | } from '../config/index.js' 10 | 11 | const transport = createTransport({ 12 | host: SMTP_HOST, 13 | port: SMTP_PORT, 14 | auth: { 15 | user: SMTP_USER, 16 | pass: SMTP_PASS 17 | } 18 | }) 19 | 20 | export async function sendMail(to: string, subject: string, html: string) { 21 | await transport.sendMail({ 22 | from: MAIL_FROM, 23 | sender: MAIL_SENDER, 24 | to, 25 | subject, 26 | html 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /packages/server/src/mq/index.ts: -------------------------------------------------------------------------------- 1 | import { Message, Reader, Writer } from 'nsqjs' 2 | import { 3 | NSQ_NSQD_HOST, 4 | NSQ_LOOKUPD_ADDR, 5 | NSQ_NSQD_PORT 6 | } from '../config/index.js' 7 | import { logger } from '../logger/index.js' 8 | 9 | export async function createWriter() { 10 | const writer = new Writer(NSQ_NSQD_HOST, NSQ_NSQD_PORT) 11 | writer.connect() 12 | await new Promise((resolve) => { 13 | writer.on('ready', resolve) 14 | }) 15 | return writer 16 | } 17 | 18 | export function consume( 19 | topic: string, 20 | channel: string, 21 | handler: (data: T, message: Message) => unknown, 22 | options?: { manual?: boolean } 23 | ) { 24 | const reader = new Reader(topic, channel, { 25 | lookupdHTTPAddresses: NSQ_LOOKUPD_ADDR 26 | }) 27 | reader.connect() 28 | reader.on('ready', () => 29 | logger.info(`NSQ Reader ${topic}:${channel} is ready`) 30 | ) 31 | reader.on('error', (err) => 32 | logger.error(err, `NSQ Reader ${topic}:${channel} Internal Error`) 33 | ) 34 | reader.on('message', async (msg) => { 35 | try { 36 | options?.manual || msg.touch() 37 | await Promise.resolve(handler(msg.json(), msg)) 38 | options?.manual || msg.finish() 39 | } catch (err) { 40 | logger.error(err, `NSQ Reader ${topic}:${channel} Handler Error`) 41 | options?.manual || msg.requeue() 42 | } 43 | }) 44 | return reader 45 | } 46 | 47 | export const judgeRequestTopic = 'runner.judge.request' 48 | export interface IJudgeRequestMsg { 49 | runner_args: string 50 | runner_user: string 51 | runner_pass: string 52 | problem_id: string 53 | submission_id: string 54 | user_id: string 55 | user_group: string 56 | } 57 | 58 | export const judgeStatusTopic = 'runner.judge.status' 59 | export interface IJudgeStatusMsg { 60 | submission_id: string 61 | done: boolean 62 | success: boolean 63 | error: string 64 | score: number 65 | message: string 66 | timestamp: number 67 | } 68 | 69 | export const rankRequestTopic = 'ranker.request' 70 | export interface IRankRequestMsg { 71 | effectiveSubmissionId?: string 72 | effectiveUserId?: string 73 | } 74 | -------------------------------------------------------------------------------- /packages/server/src/mq/writer.ts: -------------------------------------------------------------------------------- 1 | import { createWriter } from './index.js' 2 | 3 | export const writer = await createWriter() 4 | export function publishAsync(topic: string, value: T) { 5 | return new Promise((resolve, reject) => { 6 | writer.publish(topic, JSON.stringify(value), (err) => { 7 | if (err) reject(err) 8 | else resolve() 9 | }) 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /packages/server/src/services/collector/index.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from 'nanoid' 2 | import { Messages } from '../../db/message.js' 3 | import { Submissions } from '../../db/submission.js' 4 | import { Users } from '../../db/user.js' 5 | import { logger } from '../../logger/index.js' 6 | import { 7 | consume, 8 | IJudgeStatusMsg, 9 | IRankRequestMsg, 10 | judgeStatusTopic, 11 | rankRequestTopic 12 | } from '../../mq/index.js' 13 | import { publishAsync } from '../../mq/writer.js' 14 | 15 | consume(judgeStatusTopic, 'default', async (data) => { 16 | logger.info(data) 17 | const { value } = await Submissions.findOneAndUpdate( 18 | { 19 | _id: data.submission_id, 20 | updatedAt: { $lt: data.timestamp } 21 | }, 22 | { 23 | $set: { 24 | status: data.done ? 'finished' : 'running', 25 | score: data.score, 26 | message: data.message, 27 | updatedAt: data.timestamp 28 | } 29 | }, 30 | { projection: { userId: 1, problemId: 1, status: 1 } } 31 | ) 32 | if (value) { 33 | if (data.done) { 34 | await Users.updateOne( 35 | { 36 | _id: value.userId, 37 | [`problemStatus.${value.problemId}.effectiveSubmissionId`]: 38 | data.submission_id 39 | }, 40 | { 41 | $set: { 42 | [`problemStatus.${value.problemId}.score`]: data.score 43 | } as never 44 | } 45 | ) 46 | } 47 | if (value.status !== 'finished') { 48 | if (data.done) { 49 | await Messages.insertOne({ 50 | _id: nanoid(), 51 | global: false, 52 | group: '', 53 | userId: value.userId, 54 | title: '评测完成', 55 | content: `您的提交\`${value._id}\`已经完成评测,得分为\`${data.score}\`。`, 56 | metadata: { 57 | submissionId: value._id, 58 | problemId: value.problemId 59 | }, 60 | createdAt: Date.now() 61 | }) 62 | await publishAsync(rankRequestTopic, { 63 | effectiveSubmissionId: value._id, 64 | effectiveUserId: value.userId 65 | }) 66 | } else if (value.status === 'pending') { 67 | await Messages.insertOne({ 68 | _id: nanoid(), 69 | global: false, 70 | group: '', 71 | userId: value.userId, 72 | title: '评测开始', 73 | content: `您的提交\`${value._id}\`已经开始评测,请耐心等候。`, 74 | metadata: { 75 | submissionId: value._id, 76 | problemId: value.problemId 77 | }, 78 | createdAt: Date.now() 79 | }) 80 | } 81 | } 82 | } 83 | }) 84 | -------------------------------------------------------------------------------- /packages/server/src/services/fake-runner/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | consume, 3 | IJudgeRequestMsg, 4 | IJudgeStatusMsg, 5 | judgeRequestTopic, 6 | judgeStatusTopic 7 | } from '../../mq/index.js' 8 | import { publishAsync } from '../../mq/writer.js' 9 | 10 | consume(judgeRequestTopic, 'default', async (data) => { 11 | console.log(data) 12 | await publishAsync(judgeStatusTopic, { 13 | submission_id: data.submission_id, 14 | done: false, 15 | score: 0, 16 | message: 'Running', 17 | timestamp: Date.now() * 1000, 18 | success: true, 19 | error: '' 20 | }) 21 | console.log('Running...') 22 | setTimeout(() => { 23 | publishAsync(judgeStatusTopic, { 24 | submission_id: data.submission_id, 25 | done: true, 26 | score: 100, 27 | message: 'Accepted', 28 | timestamp: Date.now() * 1000, 29 | success: true, 30 | error: '' 31 | }) 32 | console.log('Accepted!') 33 | }, 20000) 34 | }) 35 | -------------------------------------------------------------------------------- /packages/server/src/services/main/api/base.ts: -------------------------------------------------------------------------------- 1 | import type { TypeBoxTypeProvider } from '@fastify/type-provider-typebox' 2 | import { Type } from '@sinclair/typebox' 3 | import type { FastifyRequest } from 'fastify' 4 | import { createRoot } from 'fastify-typeful' 5 | import { RUNNER_SECRET } from '../../../config/index.js' 6 | import { SCOWCredentials } from '../../../db/scow.js' 7 | import { Users, verifyAuthToken, type IUserInfo } from '../../../db/user.js' 8 | import { unsafePagingSchema } from '../../../utils/paging.js' 9 | import { server } from '../index.js' 10 | 11 | function requires(this: { user: IUserInfo }, cond: boolean) { 12 | if (this.user.group === 'admin') return 13 | if (!cond) throw server.httpErrors.forbidden() 14 | } 15 | 16 | export const rootChain = createRoot() 17 | 18 | async function loadUserForRunner(req: FastifyRequest) { 19 | if (req.headers['x-runner-secret'] !== RUNNER_SECRET) return null 20 | const scowUser = req.headers['x-runner-scow-user'] 21 | if (!scowUser) throw server.httpErrors.badRequest('Missing scow user') 22 | const cred = await SCOWCredentials.findOne({ _id: scowUser }) 23 | if (!cred) throw server.httpErrors.badRequest('Invalid scow user') 24 | const user = await Users.findOne( 25 | { _id: cred.userId }, 26 | { projection: { _id: 1, group: 1, authToken: 1 } } 27 | ) 28 | return user 29 | } 30 | 31 | async function loadUser(req: FastifyRequest) { 32 | let user = await verifyAuthToken(req.headers['auth-token']) 33 | user ??= await loadUserForRunner(req) 34 | if (user?.group === 'banned') throw server.httpErrors.forbidden() 35 | return user 36 | } 37 | 38 | export const protectedChain = rootChain.transform(async (ctx, req) => { 39 | const user = await loadUser(req) 40 | if (!user) throw server.httpErrors.badRequest() 41 | return { user, requires } 42 | }) 43 | 44 | export const unprotectedChain = rootChain.transform(async (ctx, req) => { 45 | const user = await loadUser(req) 46 | return { user } 47 | }) 48 | 49 | export const adminChain = protectedChain.transform(async (ctx) => { 50 | ctx.requires(false) 51 | return ctx 52 | }) 53 | 54 | export const adminFilterSchema = Type.Object({ 55 | filter: Type.Any() 56 | }) 57 | 58 | export const adminSearchSchema = Type.Intersect([ 59 | unsafePagingSchema, 60 | adminFilterSchema 61 | ]) 62 | -------------------------------------------------------------------------------- /packages/server/src/services/main/api/index.ts: -------------------------------------------------------------------------------- 1 | import { adminRouter } from './admin.js' 2 | import { authRouter } from './auth.js' 3 | import { rootChain } from './base.js' 4 | import { kvRouter } from './kv.js' 5 | import { messageRouter } from './message.js' 6 | import { problemRouter } from './problem.js' 7 | import { ranklistRouter } from './ranklist.js' 8 | import { submissionRouter } from './submission.js' 9 | import { userRouter } from './user.js' 10 | 11 | export const rootRouter = rootChain 12 | .router() 13 | .route('/auth', authRouter) 14 | .route('/kv', kvRouter) 15 | .route('/message', messageRouter) 16 | .route('/user', userRouter) 17 | .route('/problem', problemRouter) 18 | .route('/submission', submissionRouter) 19 | .route('/ranklist', ranklistRouter) 20 | .route('/admin', adminRouter) 21 | -------------------------------------------------------------------------------- /packages/server/src/services/main/api/kv.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@sinclair/typebox' 2 | import { SysKVItems, sysSet, sysTryGet } from '../../../db/syskv.js' 3 | import { rootChain, adminChain } from './base.js' 4 | 5 | export const kvRouter = rootChain 6 | .router() 7 | .handle('GET', '/load/:key', (C) => 8 | C.handler() 9 | .params( 10 | Type.Object({ 11 | key: Type.String() 12 | }) 13 | ) 14 | .handle(async (ctx, req) => { 15 | return sysTryGet(req.params.key) 16 | }) 17 | ) 18 | .handle( 19 | 'PUT', 20 | '/admin', 21 | adminChain 22 | .handler() 23 | .body( 24 | Type.Object({ 25 | _id: Type.String(), 26 | value: Type.Unknown() 27 | }) 28 | ) 29 | .handle(async (ctx, req) => { 30 | await sysSet(req.body._id, req.body.value) 31 | return 0 32 | }) 33 | ) 34 | .handle( 35 | 'DELETE', 36 | '/admin', 37 | adminChain 38 | .handler() 39 | .body( 40 | Type.Object({ 41 | _id: Type.String() 42 | }) 43 | ) 44 | .handle(async (ctx, req) => { 45 | await SysKVItems.deleteOne({ _id: req.body._id }) 46 | return 0 47 | }) 48 | ) 49 | .handle( 50 | 'GET', 51 | '/admin/list', 52 | adminChain.handler().handle(async () => { 53 | return SysKVItems.find({}, { projection: { _id: 1 } }).toArray() 54 | }) 55 | ) 56 | -------------------------------------------------------------------------------- /packages/server/src/services/main/index.ts: -------------------------------------------------------------------------------- 1 | import { fastify } from 'fastify' 2 | import { fastifySensible } from '@fastify/sensible' 3 | import fastifyRateLimit from '@fastify/rate-limit' 4 | import { fastifySwaggerUi } from '@fastify/swagger-ui' 5 | import { fastifySwagger } from '@fastify/swagger' 6 | import fastifyCors from '@fastify/cors' 7 | import type { GetRouterDescriptor } from 'fastify-typeful' 8 | import { API_HOST, API_PORT, DEV_MODE } from '../../config/index.js' 9 | import { logger } from '../../logger/index.js' 10 | import { redis } from '../../cache/index.js' 11 | import { rootRouter } from './api/index.js' 12 | import { client } from '../../db/index.js' 13 | 14 | export const server = fastify({ logger }) 15 | await server.register(fastifySensible) 16 | export const { httpErrors } = server 17 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 18 | // @ts-ignore: fastify-rate-limit types are broken 19 | await server.register(fastifyRateLimit.default, { 20 | redis 21 | }) 22 | 23 | if (DEV_MODE) { 24 | await server.register(fastifyCors, { 25 | origin: true 26 | }) 27 | await server.register(fastifySwagger, { 28 | openapi: { 29 | info: { 30 | title: 'HPCGame', 31 | description: 'HPCGame Main Service API', 32 | version: 'latest' 33 | }, 34 | components: { 35 | securitySchemes: { 36 | tokenAuth: { 37 | type: 'apiKey', 38 | name: 'auth-token', 39 | in: 'header' 40 | } 41 | } 42 | } 43 | }, 44 | transform({ schema, url }) { 45 | return { 46 | schema: { 47 | ...schema, 48 | security: [{ tokenAuth: [] }] 49 | }, 50 | url 51 | } as never 52 | } 53 | }) 54 | await server.register(fastifySwaggerUi, { 55 | routePrefix: '/docs' 56 | }) 57 | } 58 | 59 | await server.register(rootRouter.toPlugin()) 60 | 61 | await client.connect() 62 | logger.info(`Connected to MongoDB`) 63 | 64 | await server.listen({ 65 | host: API_HOST, 66 | port: API_PORT 67 | }) 68 | 69 | export type MainDescriptor = GetRouterDescriptor 70 | -------------------------------------------------------------------------------- /packages/server/src/services/ranker/index.ts: -------------------------------------------------------------------------------- 1 | import { PriorityQueue } from 'js-sdsl' 2 | import { Users } from '../../db/user.js' 3 | import { 4 | IRanklistPlayer, 5 | IRanklistOptions, 6 | IRanklistTopstar, 7 | IRanklistTopstarMutation, 8 | Ranklists 9 | } from '../../db/ranklist.js' 10 | import { consume, IRankRequestMsg, rankRequestTopic } from '../../mq/index.js' 11 | import { Debouncer } from '../../utils/debounce.js' 12 | import { logger } from '../../logger/index.js' 13 | import { Submissions } from '../../db/submission.js' 14 | 15 | async function generatePlayers({ filter, playerCount }: IRanklistOptions) { 16 | const users = Users.find(filter, { projection: { _id: 1, problemStatus: 1 } }) 17 | const queue = new PriorityQueue( 18 | [], 19 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 20 | (x, y) => (x.score === y.score ? y.last! - x.last! : x.score - y.score), 21 | false 22 | ) 23 | for await (const user of users) { 24 | // Ignore users that do not submit 25 | if (Object.keys(user.problemStatus).length === 0) continue 26 | 27 | const scores = Object.fromEntries( 28 | Object.entries(user.problemStatus).map(([key, value]) => [ 29 | key, 30 | value.score ?? 0 31 | ]) 32 | ) 33 | const score = Object.values(user.problemStatus).reduce( 34 | (acc, cur) => acc + (cur.score ?? 0), 35 | 0 36 | ) 37 | const lastSubmission = await Submissions.findOne( 38 | { userId: user._id }, 39 | { 40 | sort: { createdAt: -1 }, 41 | limit: 1, 42 | projection: { createdAt: 1 } 43 | } 44 | ) 45 | if (!lastSubmission) continue 46 | const item: IRanklistPlayer = { 47 | userId: user._id, 48 | score, 49 | scores, 50 | last: lastSubmission.createdAt 51 | } 52 | queue.push(item) 53 | if (queue.size() > playerCount) queue.pop() 54 | } 55 | const players: IRanklistPlayer[] = [] 56 | while (queue.size()) { 57 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 58 | players.push(queue.pop()!) 59 | } 60 | return players.reverse() 61 | } 62 | 63 | function mergeMutations( 64 | oldMutations: IRanklistTopstarMutation[], 65 | score: number, 66 | now: number 67 | ) { 68 | if (oldMutations[oldMutations.length - 1]?.score !== score) { 69 | return [...oldMutations, { score, timestamp: now }] 70 | } 71 | return oldMutations 72 | } 73 | 74 | async function generateTopstars( 75 | { topstarCount }: IRanklistOptions, 76 | players: IRanklistPlayer[], 77 | prev: IRanklistTopstar[] 78 | ) { 79 | const topPlayers = players.slice(0, topstarCount) 80 | const prevInfo = Object.fromEntries(prev.map((item) => [item.userId, item])) 81 | const now = Date.now() 82 | const topStars: IRanklistTopstar[] = topPlayers.map(({ userId, score }) => ({ 83 | userId, 84 | mutations: mergeMutations(prevInfo[userId]?.mutations ?? [], score, now) 85 | })) 86 | return topStars 87 | } 88 | 89 | // Delay must be smaller than NSQ's timeout 90 | const debouncer = new Debouncer(30000) 91 | consume( 92 | rankRequestTopic, 93 | 'default', 94 | async (data, msg) => { 95 | msg.finish() 96 | logger.info(data) 97 | if (!(await debouncer.wait())) return 98 | logger.info('Ranking all ranklists') 99 | const start = Date.now() 100 | const ranklists = await Ranklists.find().toArray() 101 | for (const ranklist of ranklists) { 102 | const { options } = ranklist 103 | const players = await generatePlayers(options) 104 | const topstars = await generateTopstars( 105 | options, 106 | players, 107 | ranklist.topstars 108 | ) 109 | await Ranklists.updateOne( 110 | { _id: ranklist._id }, 111 | { $set: { players, topstars, updatedAt: Date.now() } } 112 | ) 113 | } 114 | const elapsed = (Date.now() - start) / 1000 115 | logger.info(`Ranking done in ${elapsed.toFixed(2)} s`) 116 | }, 117 | { manual: true } 118 | ) 119 | -------------------------------------------------------------------------------- /packages/server/src/storage/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GetObjectCommand, 3 | PutObjectCommand, 4 | S3Client 5 | } from '@aws-sdk/client-s3' 6 | import { getSignedUrl } from '@aws-sdk/s3-request-presigner' 7 | import { 8 | MINIO_ACCESS_KEY, 9 | MINIO_ENDPOINT, 10 | MINIO_SECRET_KEY 11 | } from '../config/index.js' 12 | 13 | export const s3 = new S3Client({ 14 | credentials: { 15 | accessKeyId: MINIO_ACCESS_KEY, 16 | secretAccessKey: MINIO_SECRET_KEY 17 | }, 18 | endpoint: MINIO_ENDPOINT, 19 | forcePathStyle: true, 20 | region: 'us-east-1' 21 | }) 22 | 23 | const urlBase = MINIO_ENDPOINT.endsWith('/') 24 | ? MINIO_ENDPOINT 25 | : MINIO_ENDPOINT + '/' 26 | const urlBaseLength = urlBase.length 27 | 28 | function normalizeUrl(url: string) { 29 | return url.substring(urlBaseLength) 30 | } 31 | 32 | export async function getUploadUrl( 33 | bucket: string, 34 | key: string, 35 | size: number, 36 | expiresIn = 60 37 | ) { 38 | const command = new PutObjectCommand({ 39 | Bucket: bucket, 40 | Key: key, 41 | ContentLength: size 42 | }) 43 | return normalizeUrl(await getSignedUrl(s3, command, { expiresIn })) 44 | } 45 | 46 | export async function getDownloadUrl( 47 | bucket: string, 48 | key: string, 49 | expiresIn = 60 50 | ) { 51 | const command = new GetObjectCommand({ 52 | Bucket: bucket, 53 | Key: key 54 | }) 55 | return normalizeUrl(await getSignedUrl(s3, command, { expiresIn })) 56 | } 57 | -------------------------------------------------------------------------------- /packages/server/src/utils/debounce.ts: -------------------------------------------------------------------------------- 1 | export class Debouncer { 2 | private resolve?: (result: boolean) => void 3 | private timeout?: ReturnType 4 | 5 | constructor(public delay: number = 1000) {} 6 | 7 | wait(): Promise { 8 | this.timeout && clearTimeout(this.timeout) 9 | this.resolve?.(false) 10 | this.resolve = undefined 11 | 12 | return new Promise((resolve) => { 13 | this.resolve = resolve 14 | this.timeout = setTimeout(() => { 15 | resolve(true) 16 | if (this.resolve === resolve) { 17 | this.resolve = undefined 18 | } 19 | }, this.delay) 20 | }) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/server/src/utils/paging.ts: -------------------------------------------------------------------------------- 1 | import { type Static, Type } from '@sinclair/typebox' 2 | 3 | export const pagingSchema = Type.Object({ 4 | page: Type.Integer({ minimum: 1 }), 5 | perPage: Type.Integer({ minimum: 5, maximum: 50 }) 6 | }) 7 | 8 | export function pagingToOptions(paging: Static) { 9 | const skip = (paging.page - 1) * paging.perPage 10 | const limit = paging.perPage 11 | return { skip, limit } 12 | } 13 | 14 | export const unsafePagingSchema = Type.Intersect([ 15 | pagingSchema, 16 | Type.Object({ 17 | sort: Type.Any() 18 | }) 19 | ]) 20 | 21 | export function unsafePagingToOptions( 22 | paging: Static 23 | ) { 24 | const skip = (paging.page - 1) * paging.perPage 25 | const limit = paging.perPage 26 | const sort = paging.sort 27 | return { skip, limit, sort } 28 | } 29 | -------------------------------------------------------------------------------- /packages/server/src/utils/rules.ts: -------------------------------------------------------------------------------- 1 | export interface IMatchClause { 2 | $eq?: unknown 3 | $startsWith?: string 4 | $endsWith?: string 5 | $includes?: string 6 | } 7 | 8 | export interface IMatchExpr { 9 | [key: string]: IMatchClause 10 | } 11 | 12 | export type IRuleMatch = { 13 | $and?: IMatchExpr[] 14 | $or?: IMatchExpr[] 15 | } & IMatchExpr 16 | 17 | export interface IRule { 18 | $match: IRuleMatch 19 | $returns: unknown 20 | } 21 | 22 | // Get a nested property from an object 23 | // Using dot to separate the path 24 | export function deepGet(obj: unknown, path: string) { 25 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 26 | return path.split('.').reduce((o, i) => o?.[i], obj as any) as T 27 | } 28 | 29 | export function matchExpr( 30 | expr: IMatchExpr, 31 | variables: Record 32 | ) { 33 | for (const [key, cond] of Object.entries(expr)) { 34 | const value = deepGet(variables, key) 35 | if ('$eq' in cond) { 36 | if (value !== cond.$eq) return false 37 | } 38 | if ('$startsWith' in cond) { 39 | if (typeof value !== 'string') return false 40 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 41 | if (!value.startsWith(cond.$startsWith!)) return false 42 | } 43 | if ('$endsWith' in cond) { 44 | if (typeof value !== 'string') return false 45 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 46 | if (!value.endsWith(cond.$endsWith!)) return false 47 | } 48 | if ('$includes' in cond) { 49 | if (typeof value !== 'string' && !(value instanceof Array)) return false 50 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 51 | if (!value.includes(cond.$includes!)) return false 52 | } 53 | } 54 | return true 55 | } 56 | 57 | export function matchRule( 58 | match: IRuleMatch, 59 | variables: Record 60 | ) { 61 | const { $and, $or, ...rest } = match 62 | if ($and && !$and.every((clause) => matchExpr(clause, variables))) { 63 | return false 64 | } 65 | if (!matchExpr(rest, variables)) { 66 | return false 67 | } 68 | return !$or || $or.some((clause) => matchExpr(clause, variables)) 69 | } 70 | 71 | export function execuateRules( 72 | rules: IRule[], 73 | variables: Record, 74 | defaultReturns: T 75 | ): T { 76 | for (const rule of rules) { 77 | if (matchRule(rule.$match, variables)) return rule.$returns as T 78 | } 79 | return defaultReturns 80 | } 81 | -------------------------------------------------------------------------------- /packages/server/src/utils/type.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@sinclair/typebox' 2 | 3 | export function StringEnum(values: readonly [...T]) { 4 | return Type.Unsafe({ type: 'string', enum: values }) 5 | } 6 | -------------------------------------------------------------------------------- /packages/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": true, 4 | "composite": true, 5 | "rootDir": "src", 6 | "target": "ESNext", 7 | "lib": [], 8 | // "experimentalDecorators": true, 9 | // "emitDecoratorMetadata": true, 10 | "module": "NodeNext", 11 | "moduleResolution": "NodeNext", 12 | "types": [ 13 | "@types/node" 14 | ], 15 | "declaration": true, 16 | "sourceMap": true, 17 | "outDir": "lib", 18 | "stripInternal": true, 19 | "esModuleInterop": true, 20 | "forceConsistentCasingInFileNames": true, 21 | "strict": true, 22 | "skipLibCheck": true 23 | }, 24 | "include": [ 25 | "src/**/*.ts" 26 | ] 27 | } -------------------------------------------------------------------------------- /packages/ui/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | require('@rushstack/eslint-patch/modern-module-resolution') 3 | 4 | module.exports = { 5 | root: true, 6 | extends: [ 7 | 'plugin:vue/vue3-essential', 8 | 'eslint:recommended', 9 | '@vue/eslint-config-typescript', 10 | '@vue/eslint-config-prettier' 11 | ], 12 | parserOptions: { 13 | ecmaVersion: 'latest' 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/ui/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist 13 | dist-ssr 14 | coverage 15 | *.local 16 | 17 | /cypress/videos/ 18 | /cypress/screenshots/ 19 | 20 | # Editor directories and files 21 | .vscode/* 22 | !.vscode/extensions.json 23 | .idea 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | -------------------------------------------------------------------------------- /packages/ui/README.md: -------------------------------------------------------------------------------- 1 | # ui 2 | 3 | This template should help get you started developing with Vue 3 in Vite. 4 | 5 | ## Recommended IDE Setup 6 | 7 | [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). 8 | 9 | ## Type Support for `.vue` Imports in TS 10 | 11 | TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types. 12 | 13 | If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps: 14 | 15 | 1. Disable the built-in TypeScript Extension 16 | 1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette 17 | 2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)` 18 | 2. Reload the VSCode window by running `Developer: Reload Window` from the command palette. 19 | 20 | ## Customize configuration 21 | 22 | See [Vite Configuration Reference](https://vitejs.dev/config/). 23 | 24 | ## Project Setup 25 | 26 | ```sh 27 | yarn 28 | ``` 29 | 30 | ### Compile and Hot-Reload for Development 31 | 32 | ```sh 33 | yarn dev 34 | ``` 35 | 36 | ### Type-Check, Compile and Minify for Production 37 | 38 | ```sh 39 | yarn build 40 | ``` 41 | 42 | ### Lint with [ESLint](https://eslint.org/) 43 | 44 | ```sh 45 | yarn lint 46 | ``` 47 | -------------------------------------------------------------------------------- /packages/ui/docs/problems.md: -------------------------------------------------------------------------------- 1 | # 欢迎参加 HPC Game 2 | 3 | 请在左侧选择您希望解决的题目以继续。 4 | -------------------------------------------------------------------------------- /packages/ui/docs/staff.md: -------------------------------------------------------------------------------- 1 | # 工作人员 2 | 3 | ## 命题人 4 | 5 | | 分类 | 题目 | 命题人 | 6 | | :------------------ | :------------------ | :------------------------------------ | 7 | | **高性能计算简介** | A. 欢迎参赛! | 徐天乐 | 8 | | | B. 实验室的新机器 | 徐天乐 | 9 | | | C. 小北问答:超速版 | 北大超算队,徐天乐(题面) | 10 | | | D. 简单题 | 孙远航, 徐天乐(题面) | 11 | | **并行与大规模** | A. 求积分! | 梁圣通, 徐天乐(题面) | 12 | | | B. 乘一乘! | 梁圣通, 徐天乐(题面) | 13 | | | C. 解方程! | 梁圣通, 徐天乐(题面) | 14 | | | D. 道生一 | 叶博文,徐天乐,孙远航 | 15 | | | E. 卷?寄! | 游震邦,孙远航 | 16 | | | F. MPI 算个 PI | 司嘉祺,孙远航,徐天乐 | 17 | | **GPU上的并行计算** | cuFFT并非不可战胜! | 路登辉,李泽宇,孙远航 | 18 | | | 从头开始造AI | 耿浩然 | 19 | | | 共轭梯度法 | Taichi社区 | 20 | | | 神奇的焦散 | 郑希诠 | 21 | | **综合应用提升** | A. 走,我们扫雷去 | 刘胜与,孙远航 | 22 | | | B. AI 算子优化 | 付振新老师,徐天乐 | 23 | | | C. RDMA:就是快 | 北京大学n2sys实验室:黄群老师,吴永彤 | 24 | | **第二阶段** | DEM | 包乾,李泽宇 | 25 | | | linpack | 超聚变 | 26 | | | raid | 张杰老师,易舒舒 | 27 | 28 | ## 比赛组织人员 29 | 30 | | 工作 | 参与 | 31 | | :------------- | :----------------------------------------------------------- | 32 | | 平台和技术指导 | 北京大学高性能计算平台:樊春老师,付振新老师,马银萍老师 | 33 | | 赛前讲座 | 北大超算队 | 34 | | 组委会组织 | 孙远航 | 35 | | 学生组委会 | (Linux 俱乐部)孙远航,张子苏,徐天乐,郑希诠,李泽宇,刘胜与,司嘉祺 | 36 | | 比赛平台 | 张子苏(比赛平台),徐天乐(评测系统),孙远航(运行维护) | 37 | | 比赛平台文案 | 孙远航 | 38 | | 公众号推送文案 | 信科学生会宣传部,孙远航 | 39 | | 题目设计 | 孙远航,徐天乐 | 40 | | 奖品购买 | 徐天乐 | 41 | | 验题 | 李泽宇,郑希诠,各位选手 | 42 | | 反作弊系统测试 | ~~作弊选手们~~ | 43 | | 资料存档整理 | 徐天乐 | 44 | | 赞助商对接 | 孙远航 | 45 | 46 | ## 相关单位 47 | 48 | **主办单位:** 49 | 50 | 北京大学计算中心 51 | 52 | **联合主办:** 53 | 54 | 北京大学计算与数字经济研究院 55 | 56 | 共青团北京大学计算机学院委员会 57 | 58 | 共青团北京大学信息科学技术学院委员会 59 | 60 | **承办单位:** 61 | 62 | 北京大学学生 Linux 俱乐部 63 | 64 | 北京大学未名超算队 65 | 66 | **协办单位** 67 | 68 | (按拼音顺序排列) 69 | 70 | 北京航空航天大学超算社 71 | 72 | 北京邮电大学周行算法爱好者协会 73 | 74 | 东南大学计智软科协 75 | 76 | 复旦大学计算机科学技术学院学生会 77 | 78 | 华中科技大学 OverClock 团队 79 | 80 | 同济大学开源社 81 | 82 | 西安交通大学学生网络管理协会 83 | 84 | 中国科学技术大学 Linux 用户协会 85 | 86 | 中山大学超算队 87 | 88 | ## 特别鸣谢 89 | 90 | [超聚变数字技术有限公司](https://www.xfusion.com/) 91 | 92 | ![xfusion](https://partner.xfusion.com/static/img/common/pc/logo.svg) -------------------------------------------------------------------------------- /packages/ui/docs/term.md: -------------------------------------------------------------------------------- 1 | 请认真阅读下面的《比赛须知》和《隐私政策》。 2 | 3 | ## 比赛须知 4 | 5 | 1. 第一阶段比赛为个人线上赛,选手应该用自己的账号查看题目、提交答案。 **使用他人账号答题或与他人(包括同学、室友等)合作答题都属于作弊行为,** 一经查明将取消参赛资格并在网站上公示。 第二阶段为团队赛,合作范围仅限于该团队成员,与其他人任何人合作答题将被视为作弊。 6 | 2. **每名选手只能使用一个账号答题。** 如同时注册多个账号答题,将撤销相关题目成绩或取消评奖资格。 7 | 3. 在比赛完全结束前,选手 **通过任何手段公布解法、提示或答案,向他人索取解法、提示或答案等行为均属于作弊行为,** 一经查明将被取消参赛资格并在网站上公示。 若发现作弊现象,应拒绝加入并且告知组委会。 8 | 4. 为帮助鉴别作弊行为,组委会将对获奖的同学进行代码审查,并有权要求选手给出代码思路说明。 9 | 5. 比赛提供的算力,选手只可以用于比赛相关用途,不得另作他用。选手不得攻击竞赛平台和其他系统。 如果选手向组委会指出竞赛平台存在的漏洞,我们将公开感谢,但不会有分数奖励。 如果选手利用平台漏洞影响比赛公平,我们将视情节严重撤销相关或全部成绩,并视情况追究责任。 10 | 6. 选手的代码和思路的一切权利归属选手所有, **参赛选手同意比赛平台和出题人拥有代码和思路的使用权** 。任何应用选手代码和思路解决的问题应标注选手的贡献。 11 | 7. 北京大学高性能计算综合能力竞赛组委会对比赛相关信息具有最终解释权。 12 | 13 | 希望各位选手能展现出我们应有的诚信水准,营造公平公正的比赛氛围,在比赛中有所收获! 14 | 15 | ## 隐私政策 16 | 17 | 1. 参加比赛需要你填写少量个人信息。 **你的昵称、评奖资格信息和答题情况将在排行榜实时公布。校内获奖选手的姓名将在颁奖典礼上公布。** 除此之外的信息将仅用于维护比赛秩序,不会被公开。你的邮箱将在MD5后被用于生成你的头像,所以标记为“公开信息”,但实际上这些信息对于其他用户不可见。 18 | 2. 为鉴别作弊行为, **你在比赛中的解题流量、访问历史和设备信息将被记录,** 并可能被工作人员和自动脚本检查。这些信息不会被公开。 19 | 3. 在比赛中提交的数据可能会经过脱敏编入官方题解或花絮等。我们会进行匿名化处理使之不泄露选手隐私。 20 | -------------------------------------------------------------------------------- /packages/ui/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | PKU HPCGame 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /packages/ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hpcgame-platform/ui", 3 | "packageManager": "yarn@3.3.0", 4 | "version": "0.1.0", 5 | "license": "MIT", 6 | "private": true, 7 | "scripts": { 8 | "dev": "vite", 9 | "build": "run-p type-check build-only", 10 | "preview": "vite preview", 11 | "build-only": "vite build", 12 | "type-check": "vue-tsc --noEmit", 13 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore" 14 | }, 15 | "dependencies": { 16 | "@mdi/js": "^7.1.96", 17 | "@vueuse/core": "^9.9.0", 18 | "crypto-js": "^4.1.1", 19 | "echarts": "^5.4.1", 20 | "github-markdown-css": "^5.1.0", 21 | "katex": "^0.16.4", 22 | "naive-ui": "^2.34.2", 23 | "nanoid": "^4.0.0", 24 | "rehype-highlight": "^6.0.0", 25 | "rehype-katex": "^6.0.2", 26 | "rehype-stringify": "^9.0.3", 27 | "remark-gfm": "^3.0.1", 28 | "remark-math": "^5.1.1", 29 | "remark-parse": "^10.0.1", 30 | "remark-rehype": "^10.1.0", 31 | "typeful-fetch": "^0.1.4", 32 | "unified": "^10.1.2", 33 | "vue": "^3.2.45", 34 | "vue-echarts": "^6.5.1", 35 | "vue-recaptcha": "^2.0.3", 36 | "vue-router": "^4.1.6" 37 | }, 38 | "devDependencies": { 39 | "@rushstack/eslint-patch": "^1.1.4", 40 | "@types/crypto-js": "^4.1.1", 41 | "@types/node": "^18.11.12", 42 | "@vitejs/plugin-vue": "^4.0.0", 43 | "@vue/eslint-config-prettier": "^7.0.0", 44 | "@vue/eslint-config-typescript": "^11.0.0", 45 | "@vue/tsconfig": "^0.1.3", 46 | "eslint": "^8.22.0", 47 | "eslint-plugin-vue": "^9.3.0", 48 | "npm-run-all": "^4.1.5", 49 | "prettier": "^2.7.1", 50 | "typescript": "~4.7.4", 51 | "vite": "^4.0.0", 52 | "vite-plugin-windicss": "^1.8.10", 53 | "vue-tsc": "^1.0.12", 54 | "windicss": "^3.5.6" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /packages/ui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcpu-club/hpcgame-platform/0b13cba3e2431c5b6e886f588b533158e0f8ba91/packages/ui/public/favicon.ico -------------------------------------------------------------------------------- /packages/ui/public/icon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcpu-club/hpcgame-platform/0b13cba3e2431c5b6e886f588b533158e0f8ba91/packages/ui/public/icon/android-chrome-192x192.png -------------------------------------------------------------------------------- /packages/ui/public/icon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcpu-club/hpcgame-platform/0b13cba3e2431c5b6e886f588b533158e0f8ba91/packages/ui/public/icon/android-chrome-512x512.png -------------------------------------------------------------------------------- /packages/ui/public/icon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcpu-club/hpcgame-platform/0b13cba3e2431c5b6e886f588b533158e0f8ba91/packages/ui/public/icon/apple-touch-icon.png -------------------------------------------------------------------------------- /packages/ui/public/icon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcpu-club/hpcgame-platform/0b13cba3e2431c5b6e886f588b533158e0f8ba91/packages/ui/public/icon/favicon-16x16.png -------------------------------------------------------------------------------- /packages/ui/public/icon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lcpu-club/hpcgame-platform/0b13cba3e2431c5b6e886f588b533158e0f8ba91/packages/ui/public/icon/favicon-32x32.png -------------------------------------------------------------------------------- /packages/ui/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "HPC Game", 3 | "short_name": "HPC Game", 4 | "icons": [ 5 | { 6 | "src": "/icon/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/icon/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } -------------------------------------------------------------------------------- /packages/ui/src/App.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 26 | -------------------------------------------------------------------------------- /packages/ui/src/api/index.ts: -------------------------------------------------------------------------------- 1 | import type { MainDescriptor } from '@hpcgame-platform/server/src/services/main' 2 | import type { IUser } from '@hpcgame-platform/server/src/db' 3 | import { createClient, HandlerFetchError } from 'typeful-fetch' 4 | import { useLocalStorage } from '@vueuse/core' 5 | import { computed } from 'vue' 6 | import { finalizeLogout } from '@/utils/sync' 7 | import { PREFIX } from '@/utils/storage' 8 | 9 | export const userInfo = useLocalStorage( 10 | PREFIX + 'user-info', 11 | {} as unknown as IUser, 12 | { deep: true } 13 | ) 14 | export const authToken = computed(() => userInfo.value.authToken ?? '') 15 | export const loggedIn = computed(() => !!authToken.value) 16 | export const showAdmin = computed( 17 | () => loggedIn.value && ['admin', 'staff'].includes(userInfo.value.group) 18 | ) 19 | 20 | export const mainApi = createClient( 21 | import.meta.env.VITE_MAIN_API!, 22 | () => ({ 23 | headers: { 24 | 'auth-token': authToken.value 25 | } 26 | }) 27 | ) 28 | 29 | export function tryUpdateUser() { 30 | mainApi.user.$get 31 | .query({ userId: userInfo.value._id }) 32 | .fetch() 33 | .then((user) => { 34 | if (user) { 35 | userInfo.value = user 36 | } else { 37 | userInfo.value.authToken = '' 38 | } 39 | }) 40 | .catch((err) => { 41 | if (err instanceof HandlerFetchError) { 42 | if (err.response.status === 403) { 43 | userInfo.value.authToken = '' 44 | finalizeLogout() 45 | } 46 | } 47 | }) 48 | } 49 | 50 | if (loggedIn.value) { 51 | tryUpdateUser() 52 | } 53 | -------------------------------------------------------------------------------- /packages/ui/src/assets/hpcgame_logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/ui/src/assets/scow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /packages/ui/src/components/admin/AbstractEditor.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 55 | -------------------------------------------------------------------------------- /packages/ui/src/components/admin/MessageEditor.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 37 | -------------------------------------------------------------------------------- /packages/ui/src/components/admin/ModelTable.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 80 | -------------------------------------------------------------------------------- /packages/ui/src/components/admin/ProblemEditor.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 88 | -------------------------------------------------------------------------------- /packages/ui/src/components/admin/RanklistEditor.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 35 | -------------------------------------------------------------------------------- /packages/ui/src/components/admin/SubmissionEditor.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 87 | -------------------------------------------------------------------------------- /packages/ui/src/components/admin/SysEditor.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 25 | -------------------------------------------------------------------------------- /packages/ui/src/components/admin/UserEditor.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 41 | -------------------------------------------------------------------------------- /packages/ui/src/components/app/AppBody.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 21 | 22 | 33 | -------------------------------------------------------------------------------- /packages/ui/src/components/app/AppFooter.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /packages/ui/src/components/app/AppHeader.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 75 | 76 | 85 | -------------------------------------------------------------------------------- /packages/ui/src/components/message/GlobalMessages.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 29 | -------------------------------------------------------------------------------- /packages/ui/src/components/message/MessageCard.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 57 | -------------------------------------------------------------------------------- /packages/ui/src/components/message/SelfMessages.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 64 | -------------------------------------------------------------------------------- /packages/ui/src/components/misc/AsyncState.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 21 | -------------------------------------------------------------------------------- /packages/ui/src/components/misc/ErrorAlert.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 20 | -------------------------------------------------------------------------------- /packages/ui/src/components/misc/FileDownloader.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 30 | -------------------------------------------------------------------------------- /packages/ui/src/components/misc/JSONEditor.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 22 | 23 | 54 | -------------------------------------------------------------------------------- /packages/ui/src/components/misc/MdiIcon.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | -------------------------------------------------------------------------------- /packages/ui/src/components/misc/ReCaptcha.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 23 | -------------------------------------------------------------------------------- /packages/ui/src/components/misc/TaskContext.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 40 | -------------------------------------------------------------------------------- /packages/ui/src/components/problem/ProblemList.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 62 | -------------------------------------------------------------------------------- /packages/ui/src/components/problem/ProblemSubmit.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 86 | 87 | 92 | -------------------------------------------------------------------------------- /packages/ui/src/components/ranklist/RanklistDisplay.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 105 | -------------------------------------------------------------------------------- /packages/ui/src/components/ranklist/RanklistScores.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 36 | -------------------------------------------------------------------------------- /packages/ui/src/components/ranklist/RanklistTopstars.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 72 | -------------------------------------------------------------------------------- /packages/ui/src/components/scow/ConnectScow.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 27 | -------------------------------------------------------------------------------- /packages/ui/src/components/submission/ResultCase.vue: -------------------------------------------------------------------------------- 1 | 64 | 65 | 117 | -------------------------------------------------------------------------------- /packages/ui/src/components/submission/ResultView.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 96 | -------------------------------------------------------------------------------- /packages/ui/src/components/submission/ScoreSpan.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 29 | -------------------------------------------------------------------------------- /packages/ui/src/components/submission/SubmissionList.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 66 | -------------------------------------------------------------------------------- /packages/ui/src/components/user/UserEdit.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | 85 | -------------------------------------------------------------------------------- /packages/ui/src/components/user/UserGroup.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 36 | -------------------------------------------------------------------------------- /packages/ui/src/components/user/UserIndicator.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 19 | -------------------------------------------------------------------------------- /packages/ui/src/components/user/UserIndicatorProxy.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 11 | -------------------------------------------------------------------------------- /packages/ui/src/components/user/UserLoginBtn.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 20 | -------------------------------------------------------------------------------- /packages/ui/src/components/user/UserTags.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 21 | -------------------------------------------------------------------------------- /packages/ui/src/globals.d.ts: -------------------------------------------------------------------------------- 1 | import type { useDialog, useLoadingBar, useNotification } from 'naive-ui' 2 | 3 | declare global { 4 | interface Window { 5 | $loadingBar?: ReturnType 6 | $notification?: ReturnType 7 | $dialog?: ReturnType 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/ui/src/layouts/DefaultLayout.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 27 | -------------------------------------------------------------------------------- /packages/ui/src/main.ts: -------------------------------------------------------------------------------- 1 | import '@/utils/analysis' 2 | import 'virtual:windi.css' 3 | import 'virtual:windi-devtools' 4 | import '@/styles/main.css' 5 | import { createApp } from 'vue' 6 | import router from '@/router' 7 | import { syncVersion } from '@/utils/sync' 8 | import App from '@/App.vue' 9 | 10 | const app = createApp(App) 11 | 12 | app.use(router) 13 | 14 | app.mount('#app') 15 | 16 | syncVersion() 17 | -------------------------------------------------------------------------------- /packages/ui/src/router/admin.ts: -------------------------------------------------------------------------------- 1 | import type { RouteRecordRaw } from 'vue-router' 2 | 3 | export const adminRoutes: RouteRecordRaw[] = [ 4 | { 5 | path: '/admin', 6 | component: () => import('@/views/AdminView.vue'), 7 | children: [ 8 | { 9 | path: '', 10 | component: () => import('@/views/admin/HomeView.vue') 11 | }, 12 | 13 | { 14 | path: 'sys', 15 | component: () => import('@/views/admin/SysView.vue') 16 | }, 17 | { 18 | path: 'sys/new', 19 | component: () => import('@/views/admin/SysNewView.vue') 20 | }, 21 | { 22 | path: 'sys/edit/:id', 23 | component: () => import('@/views/admin/SysEditView.vue'), 24 | props: true 25 | }, 26 | 27 | { 28 | path: 'user', 29 | component: () => import('@/views/admin/UserView.vue') 30 | }, 31 | { 32 | path: 'user/edit/:id', 33 | component: () => import('@/views/admin/UserEditView.vue'), 34 | props: true 35 | }, 36 | 37 | { 38 | path: 'problem', 39 | component: () => import('@/views/admin/ProblemView.vue') 40 | }, 41 | { 42 | path: 'problem/new', 43 | component: () => import('@/views/admin/ProblemNewView.vue') 44 | }, 45 | { 46 | path: 'problem/edit/:id', 47 | component: () => import('@/views/admin/ProblemEditView.vue'), 48 | props: true 49 | }, 50 | 51 | { 52 | path: 'submission', 53 | component: () => import('@/views/admin/SubmissionView.vue') 54 | }, 55 | { 56 | path: 'submission/edit/:id', 57 | component: () => import('@/views/admin/SubmissionEditView.vue'), 58 | props: true 59 | }, 60 | 61 | { 62 | path: 'message', 63 | component: () => import('@/views/admin/MessageView.vue') 64 | }, 65 | { 66 | path: 'message/new', 67 | component: () => import('@/views/admin/MessageNewView.vue') 68 | }, 69 | { 70 | path: 'message/edit/:id', 71 | component: () => import('@/views/admin/MessageEditView.vue'), 72 | props: true 73 | }, 74 | 75 | { 76 | path: 'ranklist', 77 | component: () => import('@/views/admin/RanklistView.vue') 78 | }, 79 | { 80 | path: 'ranklist/new', 81 | component: () => import('@/views/admin/RanklistNewView.vue') 82 | }, 83 | { 84 | path: 'ranklist/edit/:id', 85 | component: () => import('@/views/admin/RanklistEditView.vue'), 86 | props: true 87 | }, 88 | 89 | { 90 | path: 'scow', 91 | component: () => import('@/views/admin/ScowView.vue') 92 | } 93 | ] 94 | } 95 | ] 96 | -------------------------------------------------------------------------------- /packages/ui/src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { showAdmin, loggedIn } from '@/api' 2 | import { createRouter, createWebHistory } from 'vue-router' 3 | import { adminRoutes } from './admin' 4 | import { loginRoutes } from './login' 5 | import { problemsRoutes } from './problems' 6 | import { userRoutes } from './user' 7 | 8 | const router = createRouter({ 9 | history: createWebHistory(import.meta.env.BASE_URL), 10 | routes: [ 11 | { 12 | path: '/', 13 | component: () => import('@/views/HomeView.vue') 14 | }, 15 | { 16 | path: '/about', 17 | component: () => import('@/views/AboutView.vue') 18 | }, 19 | { 20 | path: '/ranklist', 21 | component: () => import('@/views/RanklistView.vue') 22 | }, 23 | { 24 | path: '/messages', 25 | component: () => import('@/views/MessagesView.vue') 26 | }, 27 | { 28 | path: '/terms', 29 | component: () => import('@/views/TermsView.vue') 30 | }, 31 | { 32 | path: '/staff', 33 | component: () => import('@/views/StaffView.vue') 34 | }, 35 | { 36 | path: '/ranking', 37 | component: () => import('@/views/RankingView.vue') 38 | }, 39 | ...loginRoutes, 40 | ...userRoutes, 41 | ...adminRoutes, 42 | ...problemsRoutes, 43 | { 44 | path: '/:pathMatch(.*)*', 45 | component: () => import('@/views/NotFoundView.vue') 46 | } 47 | ] 48 | }) 49 | 50 | router.beforeEach((to, from, next) => { 51 | window.$loadingBar?.start() 52 | const pfx = (s: string) => to.path.startsWith(s) 53 | if (pfx('/login')) { 54 | return loggedIn.value ? next('/') : next() 55 | } 56 | if (pfx('/problems') || pfx('/user') || pfx('/ranklist')) { 57 | return loggedIn.value ? next() : next('/login') 58 | } 59 | if (pfx('/admin')) { 60 | return showAdmin.value ? next() : next('/') 61 | } 62 | return next() 63 | }) 64 | 65 | router.afterEach(() => { 66 | window.$loadingBar?.finish() 67 | }) 68 | 69 | export default router 70 | -------------------------------------------------------------------------------- /packages/ui/src/router/login.ts: -------------------------------------------------------------------------------- 1 | import type { RouteRecordRaw } from 'vue-router' 2 | 3 | export const loginRoutes: RouteRecordRaw[] = [ 4 | { 5 | path: '/login', 6 | name: 'login', 7 | component: () => import('@/views/LoginView.vue') 8 | }, 9 | { 10 | path: '/login/iaaa', 11 | component: () => import('@/views/auth/IaaaAuth.vue') 12 | }, 13 | { 14 | path: '/auth/iaaa', 15 | component: () => import('@/views/auth/IaaaCallback.vue') 16 | }, 17 | { 18 | path: '/login/mail', 19 | component: () => import('@/views/auth/MailAuth.vue') 20 | } 21 | ] 22 | 23 | if (import.meta.env.VITE_DEV_MODE) { 24 | loginRoutes.push({ 25 | path: '/login/dev', 26 | component: () => import('@/views/auth/DevAuth.vue') 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /packages/ui/src/router/problems.ts: -------------------------------------------------------------------------------- 1 | import type { RouteRecordRaw } from 'vue-router' 2 | 3 | export const problemsRoutes: RouteRecordRaw[] = [ 4 | { 5 | path: '/problems', 6 | component: () => import('@/views/ProblemsView.vue'), 7 | children: [ 8 | { 9 | path: '', 10 | component: () => import('@/views/problems/HomeView.vue') 11 | }, 12 | { 13 | path: ':id', 14 | component: () => import('@/views/problems/ProblemView.vue'), 15 | props: ({ params: { id } }) => ({ id, key: id }) 16 | }, 17 | { 18 | path: ':problemId/submissions/:id', 19 | component: () => import('@/views/problems/SubmissionView.vue'), 20 | props: ({ params: { id, problemId } }) => ({ id, problemId, key: id }) 21 | } 22 | ] 23 | } 24 | ] 25 | -------------------------------------------------------------------------------- /packages/ui/src/router/user.ts: -------------------------------------------------------------------------------- 1 | import type { RouteRecordRaw } from 'vue-router' 2 | 3 | export const userRoutes: RouteRecordRaw[] = [ 4 | { 5 | path: '/user', 6 | component: () => import('@/views/UserView.vue'), 7 | children: [ 8 | { 9 | path: '', 10 | component: () => import('@/views/user/ProfileView.vue') 11 | }, 12 | { 13 | path: 'logout', 14 | component: () => import('@/views/user/LogoutView.vue') 15 | } 16 | ] 17 | } 18 | ] 19 | -------------------------------------------------------------------------------- /packages/ui/src/shims.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'markdown-it' { 2 | declare const exports: any 3 | export default exports 4 | } 5 | 6 | declare module 'markdown-it-katex' { 7 | declare const exports: any 8 | export default exports 9 | } 10 | 11 | declare module '@wooorm/starry-night' { 12 | export declare const createStarryNight: any, common: any 13 | } 14 | 15 | declare module 'hast-util-to-html' { 16 | export declare const toHtml: any 17 | } 18 | -------------------------------------------------------------------------------- /packages/ui/src/styles/main.css: -------------------------------------------------------------------------------- 1 | .hg-content-h-full { 2 | min-height: calc(100vh - 6rem); 3 | } 4 | 5 | pre code.hljs { 6 | padding: 0 !important; 7 | } -------------------------------------------------------------------------------- /packages/ui/src/utils/analysis.ts: -------------------------------------------------------------------------------- 1 | if (import.meta.env.VITE_CF_BEACON_TOKEN) { 2 | const elem = document.createElement('script') 3 | elem.src = `https://static.cloudflareinsights.com/beacon.min.js` 4 | elem.setAttribute( 5 | 'data-cf-beacon', 6 | `{"token": "${import.meta.env.VITE_CF_BEACON_TOKEN}"}` 7 | ) 8 | document.body.appendChild(elem) 9 | } 10 | -------------------------------------------------------------------------------- /packages/ui/src/utils/async.ts: -------------------------------------------------------------------------------- 1 | import { useNotification } from 'naive-ui' 2 | import { inject, ref, type InjectionKey, type Ref } from 'vue' 3 | import { getErrorMessage } from './error' 4 | 5 | export const kTaskContext: InjectionKey<{ 6 | running: Ref 7 | run: (task: () => Promise) => Promise<[null, T] | [unknown, null]> 8 | }> = Symbol() 9 | 10 | export function useTaskContext() { 11 | const injected = inject(kTaskContext) 12 | if (!injected) throw new Error('Must be used within ') 13 | return injected 14 | } 15 | 16 | export function useSimpleAsyncTask( 17 | task: () => Promise, 18 | options?: { 19 | notifyOnSuccess?: boolean 20 | } 21 | ) { 22 | const notification = useNotification() 23 | const running = ref(false) 24 | const run = async () => { 25 | try { 26 | running.value = true 27 | await task() 28 | if (options?.notifyOnSuccess) { 29 | notification.success({ 30 | title: '操作成功', 31 | duration: 5000 32 | }) 33 | } 34 | } catch (err) { 35 | notification.error({ 36 | title: '操作失败', 37 | description: await getErrorMessage(err), 38 | duration: 5000 39 | }) 40 | } finally { 41 | running.value = false 42 | } 43 | } 44 | return { running, run } 45 | } 46 | -------------------------------------------------------------------------------- /packages/ui/src/utils/avatar.ts: -------------------------------------------------------------------------------- 1 | import md5 from 'crypto-js/md5' 2 | 3 | const gravatarUrl = import.meta.env.VITE_GRAVATAR_URL 4 | 5 | export function gravatar(email: string, size = 80) { 6 | if (!email) return '' 7 | const hash = email.includes('@') ? md5(email.trim().toLowerCase()) : email 8 | return `${gravatarUrl}${hash}?s=${size}&d=mp` 9 | } 10 | -------------------------------------------------------------------------------- /packages/ui/src/utils/countdown.ts: -------------------------------------------------------------------------------- 1 | import { computed, onUnmounted, ref } from 'vue' 2 | 3 | export function useCountdown() { 4 | const remaining = ref(0) 5 | const waiting = computed(() => remaining.value > 0) 6 | let intervalId: ReturnType 7 | function start(seconds: number) { 8 | remaining.value = seconds 9 | intervalId = setInterval(() => { 10 | remaining.value-- 11 | if (remaining.value <= 0) clearInterval(intervalId) 12 | }, 1000) 13 | } 14 | function stop() { 15 | intervalId && clearInterval(intervalId) 16 | remaining.value = 0 17 | } 18 | onUnmounted(stop) 19 | return { remaining, waiting, start, stop } 20 | } 21 | -------------------------------------------------------------------------------- /packages/ui/src/utils/error.ts: -------------------------------------------------------------------------------- 1 | import { HandlerFetchError } from 'typeful-fetch' 2 | 3 | export async function getErrorMessage(error: unknown): Promise { 4 | if (!(error instanceof Error)) return `${error}` 5 | if (error instanceof HandlerFetchError) { 6 | try { 7 | const { message } = await error.response.json() 8 | return message 9 | } catch (err) { 10 | return getErrorMessage(err) 11 | } 12 | } 13 | return error.message 14 | } 15 | -------------------------------------------------------------------------------- /packages/ui/src/utils/format.ts: -------------------------------------------------------------------------------- 1 | export function prettyPrintBytes(bytes: number, decimals = 2): string { 2 | if (bytes === 0) return '0B' 3 | const k = 1024 4 | const dm = decimals < 0 ? 0 : decimals 5 | const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] 6 | const i = Math.floor(Math.log(bytes) / Math.log(k)) 7 | return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i] 8 | } 9 | -------------------------------------------------------------------------------- /packages/ui/src/utils/md.ts: -------------------------------------------------------------------------------- 1 | import { unified } from 'unified' 2 | import remarkParse from 'remark-parse' 3 | import remarkMath from 'remark-math' 4 | import remarkGfm from 'remark-gfm' 5 | import remarkRehype from 'remark-rehype' 6 | import rehypeKatex from 'rehype-katex' 7 | import rehypeStringify from 'rehype-stringify' 8 | import rehypeHighlight from 'rehype-highlight' 9 | import 'github-markdown-css/github-markdown-light.css' 10 | import 'katex/dist/katex.css' 11 | import 'highlight.js/styles/github.css' 12 | import 'lowlight/lib/all' 13 | 14 | export function render(source: string): string { 15 | const file = unified() 16 | .use(remarkParse) 17 | .use(remarkMath) 18 | .use(remarkGfm) 19 | .use(remarkRehype, { allowDangerousHtml: true }) 20 | .use(rehypeHighlight) 21 | .use(rehypeKatex) 22 | .use(rehypeStringify, { allowDangerousHtml: true }) 23 | .processSync(source) 24 | 25 | return String(file) 26 | } 27 | -------------------------------------------------------------------------------- /packages/ui/src/utils/meta.ts: -------------------------------------------------------------------------------- 1 | import { version } from '@/../package.json' 2 | 3 | export { version } 4 | 5 | export const hash = import.meta.env.VITE_GIT_HASH 6 | export const buildTime = import.meta.env.VITE_BUILD_TIME 7 | -------------------------------------------------------------------------------- /packages/ui/src/utils/misc.ts: -------------------------------------------------------------------------------- 1 | export function s3url(url: string) { 2 | return import.meta.env.VITE_MINIO_ENDPOINT + url 3 | } 4 | -------------------------------------------------------------------------------- /packages/ui/src/utils/notification.ts: -------------------------------------------------------------------------------- 1 | import { startMessageWorker } from '@/workers' 2 | import { requestPermissions } from '@/utils/permissions' 3 | 4 | function isSupportedEnvironment() { 5 | // Safari Mobile 6 | if (!('Notification' in window)) { 7 | return false 8 | } 9 | // Chrome Mobile 10 | // @ts-ignore 11 | if (navigator.userAgentData?.mobile) { 12 | return false 13 | } 14 | // Must support SharedWorker 15 | if (!('SharedWorker' in window)) { 16 | return false 17 | } 18 | return true 19 | } 20 | 21 | export function enableNotification() { 22 | if (!isSupportedEnvironment()) { 23 | window.$notification?.warning({ 24 | title: '不支持推送通知', 25 | content: '建议使用桌面浏览器以获得更好的体验' 26 | }) 27 | return 28 | } 29 | requestPermissions() 30 | startMessageWorker() 31 | } 32 | -------------------------------------------------------------------------------- /packages/ui/src/utils/permissions.ts: -------------------------------------------------------------------------------- 1 | export function requestPermissions() { 2 | if (Notification.permission === 'default') { 3 | window.$dialog?.info({ 4 | title: '授予通知权限', 5 | content: '请授予网站通知权限以便接收平台消息', 6 | positiveText: '好', 7 | onPositiveClick: () => { 8 | Notification.requestPermission((permission) => { 9 | if (permission === 'granted') { 10 | window.$notification?.success({ 11 | title: '成功授予通知权限', 12 | content: '将通过系统通知推送平台消息' 13 | }) 14 | new Notification('通知权限已授予', { 15 | body: '将通过系统通知推送平台消息' 16 | }) 17 | } else { 18 | window.$notification?.warning({ 19 | title: '未授予通知权限', 20 | content: '将通过站内通知推送平台消息' 21 | }) 22 | } 23 | }) 24 | } 25 | }) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/ui/src/utils/problems.ts: -------------------------------------------------------------------------------- 1 | import { mainApi, userInfo } from '@/api' 2 | import { computed, inject, type InjectionKey, type Ref } from 'vue' 3 | 4 | export async function loadProblemsData() { 5 | const problems = await mainApi.problem.list.$get.fetch() 6 | const categories = [...new Set(problems.map((p) => p.category))] 7 | .sort() 8 | .map((key) => ({ 9 | category: key, 10 | problems: problems 11 | .filter((p) => p.category === key) 12 | .sort((a, b) => a.title.localeCompare(b.title)) 13 | })) 14 | return { problems, categories } 15 | } 16 | 17 | export const kProblemsData: InjectionKey< 18 | Ref>> 19 | > = Symbol() 20 | 21 | export function useProblemsData() { 22 | const problemsData = inject(kProblemsData) 23 | if (!problemsData) throw new Error('No problems data') 24 | return problemsData 25 | } 26 | 27 | export function getProblemStatus(id: string) { 28 | return userInfo.value.problemStatus[id] 29 | } 30 | 31 | export function getProblemStatusRef(id: string) { 32 | return computed(() => getProblemStatus(id)) 33 | } 34 | 35 | function interpolate(start: number, end: number, rate: number) { 36 | return Math.floor((end - start) * rate + start) 37 | } 38 | 39 | export function getColorByScore(score: number, maxScore: number) { 40 | score = score / maxScore 41 | // from hsl(6deg 63% 46%) to hsl(145deg 63% 42%) 42 | const h = interpolate(6, 145, score) 43 | const s = interpolate(63, 63, score) 44 | const l = interpolate(46, 42, score) 45 | return `hsl(${h}deg ${s}% ${l}%)` 46 | } 47 | 48 | export function getProblemColor(id: string, maxScore: number) { 49 | const status = getProblemStatus(id) 50 | if (!status) return 'gray' 51 | return getColorByScore(status.score ?? 0, maxScore) 52 | } 53 | -------------------------------------------------------------------------------- /packages/ui/src/utils/ranklist.ts: -------------------------------------------------------------------------------- 1 | import { mainApi } from '@/api' 2 | import { type InjectionKey, type Ref, inject } from 'vue' 3 | 4 | export async function loadRanklistsData() { 5 | return mainApi.ranklist.list.$get.fetch() 6 | } 7 | 8 | export const kRanklistsData: InjectionKey< 9 | Ref>> 10 | > = Symbol() 11 | 12 | export function useRanklistsData() { 13 | const ranklistsData = inject(kRanklistsData) 14 | if (!ranklistsData) throw new Error('No ranklists data') 15 | return ranklistsData 16 | } 17 | -------------------------------------------------------------------------------- /packages/ui/src/utils/renderIcon.ts: -------------------------------------------------------------------------------- 1 | import { NIcon, type IconProps } from 'naive-ui' 2 | import { h } from 'vue' 3 | import MdiIcon from '@/components/misc/MdiIcon.vue' 4 | 5 | export function renderMdiIcon(path: string) { 6 | return () => h(MdiIcon, { path }) 7 | } 8 | 9 | export function renderNIcon(path: string, props: IconProps | null = null) { 10 | return () => 11 | h(NIcon, props, { 12 | default: renderMdiIcon(path) 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /packages/ui/src/utils/schedule.ts: -------------------------------------------------------------------------------- 1 | import { mainApi } from '@/api' 2 | import { ref } from 'vue' 3 | import type { 4 | kGameSchedule, 5 | InferSysType 6 | } from '@hpcgame-platform/server/src/db/syskv' 7 | 8 | export type IGameSchedule = InferSysType 9 | 10 | const defaultSchedule: IGameSchedule = { 11 | start: 0, 12 | end: 0 13 | } 14 | 15 | export async function loadSchedule() { 16 | try { 17 | const data = await mainApi.kv['load/:key'].$get 18 | .params({ key: 'game_schedule' }) 19 | .fetch() 20 | return (data ?? defaultSchedule) as IGameSchedule 21 | } catch (err) { 22 | console.log(err) 23 | return defaultSchedule 24 | } 25 | } 26 | 27 | export const schedule = ref(await loadSchedule()) 28 | 29 | // export function updateCurrentStage() { 30 | // currentStage.value = getCurrentStage(schedule.value) 31 | // } 32 | 33 | // function updateTask() { 34 | // updateCurrentStage() 35 | // requestIdleCallback(updateTask, { 36 | // timeout: 5000 37 | // }) 38 | // } 39 | 40 | // updateTask() 41 | -------------------------------------------------------------------------------- /packages/ui/src/utils/scow.ts: -------------------------------------------------------------------------------- 1 | import { useNotification } from 'naive-ui' 2 | 3 | export function useSCOW() { 4 | const notification = useNotification() 5 | 6 | async function open(username: string, password: string) { 7 | const win = window.open(import.meta.env.VITE_SCOW_URL) 8 | if (!win) { 9 | notification.error({ 10 | title: '无法打开SCOW', 11 | content: '请允许网页打开新窗口' 12 | }) 13 | return 14 | } 15 | for (let i = 0; i < 20; i++) { 16 | await new Promise((resolve) => setTimeout(resolve, 500)) 17 | try { 18 | if (new URL(win.location.href).pathname.endsWith('auth/public/auth')) { 19 | const usernameInput = win.document.querySelector( 20 | 'input[name="username"]' 21 | ) 22 | usernameInput && (usernameInput.value = username) 23 | const passwordInput = win.document.querySelector( 24 | 'input[name="password"]' 25 | ) 26 | passwordInput && (passwordInput.value = password) 27 | const submitButton = win.document.querySelector( 28 | 'button[type="submit"]' 29 | ) 30 | submitButton && submitButton.click() 31 | } 32 | } catch (err) { 33 | console.log(err) 34 | } 35 | } 36 | } 37 | return { open } 38 | } 39 | -------------------------------------------------------------------------------- /packages/ui/src/utils/shared.ts: -------------------------------------------------------------------------------- 1 | export const hpcSyncChannel = new BroadcastChannel('hpc-sync') 2 | -------------------------------------------------------------------------------- /packages/ui/src/utils/storage.ts: -------------------------------------------------------------------------------- 1 | export const PREFIX = import.meta.env.VITE_STORAGE_PREFIX || 'app' 2 | 3 | export function getItem(key: string) { 4 | return localStorage.getItem(PREFIX + key) 5 | } 6 | 7 | export function setItem(key: string, value: string) { 8 | return localStorage.setItem(PREFIX + key, value) 9 | } 10 | -------------------------------------------------------------------------------- /packages/ui/src/utils/sync.ts: -------------------------------------------------------------------------------- 1 | import { hash } from './meta' 2 | import { hpcSyncChannel } from './shared' 3 | import { setItem } from './storage' 4 | 5 | hpcSyncChannel.addEventListener('message', (ev) => { 6 | const { type, payload } = ev.data 7 | switch (type) { 8 | case 'login': 9 | return location.reload() 10 | case 'logout': 11 | return location.reload() 12 | case 'notify': 13 | return window.$notification?.create(payload) 14 | case 'log': 15 | return console.log(payload) 16 | case 'set': 17 | setItem(payload.key, payload.value) 18 | return 19 | case 'sync': 20 | if (payload !== hash) { 21 | location.reload() 22 | } 23 | } 24 | }) 25 | 26 | export function finalizeLogout() { 27 | hpcSyncChannel.postMessage({ type: 'logout' }) 28 | location.reload() 29 | } 30 | 31 | export function finalizeLogin() { 32 | hpcSyncChannel.postMessage({ type: 'login' }) 33 | location.reload() 34 | } 35 | 36 | export function syncVersion() { 37 | hpcSyncChannel.postMessage({ type: 'sync', payload: hash }) 38 | } 39 | -------------------------------------------------------------------------------- /packages/ui/src/views/AboutView.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 35 | -------------------------------------------------------------------------------- /packages/ui/src/views/AdminView.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 60 | -------------------------------------------------------------------------------- /packages/ui/src/views/HomeView.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 13 | -------------------------------------------------------------------------------- /packages/ui/src/views/LoginView.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | 54 | -------------------------------------------------------------------------------- /packages/ui/src/views/MessagesView.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 22 | -------------------------------------------------------------------------------- /packages/ui/src/views/NotFoundView.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 22 | -------------------------------------------------------------------------------- /packages/ui/src/views/ProblemsView.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 62 | -------------------------------------------------------------------------------- /packages/ui/src/views/RankingView.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 16 | -------------------------------------------------------------------------------- /packages/ui/src/views/RanklistView.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 80 | -------------------------------------------------------------------------------- /packages/ui/src/views/StaffView.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 16 | -------------------------------------------------------------------------------- /packages/ui/src/views/TermsView.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 16 | -------------------------------------------------------------------------------- /packages/ui/src/views/UserView.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 45 | -------------------------------------------------------------------------------- /packages/ui/src/views/admin/HomeView.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 106 | -------------------------------------------------------------------------------- /packages/ui/src/views/admin/MessageEditView.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 34 | -------------------------------------------------------------------------------- /packages/ui/src/views/admin/MessageNewView.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 39 | -------------------------------------------------------------------------------- /packages/ui/src/views/admin/MessageView.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 65 | -------------------------------------------------------------------------------- /packages/ui/src/views/admin/ProblemEditView.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 34 | -------------------------------------------------------------------------------- /packages/ui/src/views/admin/ProblemNewView.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 42 | -------------------------------------------------------------------------------- /packages/ui/src/views/admin/ProblemView.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 67 | -------------------------------------------------------------------------------- /packages/ui/src/views/admin/RanklistEditView.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 35 | -------------------------------------------------------------------------------- /packages/ui/src/views/admin/RanklistNewView.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 45 | -------------------------------------------------------------------------------- /packages/ui/src/views/admin/RanklistView.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 58 | -------------------------------------------------------------------------------- /packages/ui/src/views/admin/SubmissionEditView.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 38 | -------------------------------------------------------------------------------- /packages/ui/src/views/admin/SubmissionView.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 68 | -------------------------------------------------------------------------------- /packages/ui/src/views/admin/SysEditView.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 36 | -------------------------------------------------------------------------------- /packages/ui/src/views/admin/SysNewView.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 31 | -------------------------------------------------------------------------------- /packages/ui/src/views/admin/SysView.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 48 | -------------------------------------------------------------------------------- /packages/ui/src/views/admin/UserEditView.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 24 | -------------------------------------------------------------------------------- /packages/ui/src/views/admin/UserView.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 56 | -------------------------------------------------------------------------------- /packages/ui/src/views/auth/DevAuth.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 29 | -------------------------------------------------------------------------------- /packages/ui/src/views/auth/IaaaAuth.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 83 | -------------------------------------------------------------------------------- /packages/ui/src/views/auth/IaaaCallback.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 20 | -------------------------------------------------------------------------------- /packages/ui/src/views/auth/MailAuth.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 114 | -------------------------------------------------------------------------------- /packages/ui/src/views/problems/HomeView.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 14 | -------------------------------------------------------------------------------- /packages/ui/src/views/problems/ProblemView.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 110 | -------------------------------------------------------------------------------- /packages/ui/src/views/user/LogoutView.vue: -------------------------------------------------------------------------------- 1 | 6 | 19 | -------------------------------------------------------------------------------- /packages/ui/src/views/user/ProfileView.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | -------------------------------------------------------------------------------- /packages/ui/src/workers/index.ts: -------------------------------------------------------------------------------- 1 | import { authToken } from '@/api' 2 | import { getItem } from '@/utils/storage' 3 | import MessageWorker from './message?sharedworker' 4 | 5 | export function startMessageWorker() { 6 | try { 7 | const worker = new MessageWorker() 8 | worker.port.start() 9 | worker.port.postMessage({ 10 | type: 'start', 11 | payload: { 12 | since: parseInt(getItem('messageTimestamp') ?? '0') || Date.now(), 13 | token: authToken.value 14 | } 15 | }) 16 | } catch (err) { 17 | console.log(err) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/ui/src/workers/message.ts: -------------------------------------------------------------------------------- 1 | import { hpcSyncChannel } from '@/utils/shared' 2 | import type { MainDescriptor } from '@hpcgame-platform/server/src/services/main' 3 | import { createClient } from 'typeful-fetch' 4 | 5 | function log(msg: unknown) { 6 | hpcSyncChannel.postMessage({ 7 | type: 'log', 8 | payload: msg 9 | }) 10 | } 11 | 12 | function set(key: string, value: unknown) { 13 | hpcSyncChannel.postMessage({ 14 | type: 'set', 15 | payload: { key, value } 16 | }) 17 | } 18 | 19 | let timestamp = 0 20 | let authToken = '' 21 | const batchSize = 10 22 | const mainApi = createClient( 23 | import.meta.env.VITE_MAIN_API!, 24 | () => ({ headers: { 'auth-token': authToken } }) 25 | ) 26 | 27 | export async function pollForChange() { 28 | try { 29 | log(`Poll for change since ${timestamp}`) 30 | const data = await mainApi.message.poll.$get 31 | .query({ since: timestamp }) 32 | .fetch() 33 | log(`${data.length} messages`) 34 | for (const message of data) { 35 | timestamp = Math.max(timestamp, message.createdAt + 1) 36 | log(`Update timestamp to ${timestamp}`) 37 | if (Notification.permission === 'granted') { 38 | new Notification( 39 | message.global ? 'HPCGame系统消息' : 'HPCGame用户消息', 40 | { 41 | body: message.title 42 | } 43 | ) 44 | } else { 45 | hpcSyncChannel.postMessage({ 46 | type: 'notify', 47 | payload: { 48 | type: 'info', 49 | title: '收到新消息', 50 | content: message.title, 51 | duration: 5000 52 | } 53 | }) 54 | } 55 | } 56 | set('messageTimestamp', timestamp) 57 | return data.length === batchSize 58 | } catch (err) { 59 | log(`An error occurred: ${err}`) 60 | return false 61 | } 62 | } 63 | 64 | async function doSync() { 65 | while (await pollForChange()) { 66 | // do nothing 67 | } 68 | setTimeout(doSync, 10000) 69 | } 70 | 71 | // TypeScript do not support shared worker... 72 | // @ts-ignore 73 | onconnect = function (event) { 74 | const port = event.ports[0] 75 | 76 | port.onmessage = function (e: MessageEvent) { 77 | const { type, payload } = e.data 78 | switch (type) { 79 | case 'start': 80 | if (timestamp) { 81 | log('Message Worker Already Started...') 82 | return 83 | } 84 | timestamp = payload.since 85 | authToken = payload.token 86 | doSync() 87 | log('Message Worker Started...') 88 | return 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /packages/ui/tsconfig.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.node.json", 3 | "include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "playwright.config.*"], 4 | "compilerOptions": { 5 | "composite": true, 6 | "types": ["node"] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.web.json", 3 | "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], 4 | "compilerOptions": { 5 | "baseUrl": ".", 6 | "paths": { 7 | "@/*": ["./src/*"] 8 | } 9 | }, 10 | 11 | "references": [ 12 | { 13 | "path": "./tsconfig.config.json" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /packages/ui/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url' 2 | 3 | import { defineConfig } from 'vite' 4 | import vue from '@vitejs/plugin-vue' 5 | import windiCSS from 'vite-plugin-windicss' 6 | import { execSync } from 'node:child_process' 7 | 8 | const hash = execSync('git rev-parse --short HEAD').toString().trim() 9 | process.env.VITE_GIT_HASH = hash 10 | process.env.VITE_BUILD_TIME = new Date().toISOString() 11 | 12 | // https://vitejs.dev/config/ 13 | export default defineConfig({ 14 | plugins: [vue(), windiCSS()], 15 | resolve: { 16 | alias: { 17 | '@': fileURLToPath(new URL('./src', import.meta.url)) 18 | } 19 | } 20 | }) 21 | -------------------------------------------------------------------------------- /packages/ui/windi.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite-plugin-windicss' 2 | 3 | export default defineConfig({ 4 | preflight: { 5 | blocklist: 'h1 h2 h3 ul ol li p' 6 | }, 7 | safelist: 'flex justify-between items-center text-xs' 8 | }) 9 | --------------------------------------------------------------------------------