diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..1a91fcf --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,9 @@ +### 2025暑培-网站作业提交 +#### 基本信息 +- **姓名**: +- **班级**: +- **学号**: + +#### 提交说明 + +- [ ] 已阅读并理解本次作业要求 diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..f8b3196 --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,16 @@ +# Github 和 Docker Hub 相关网页设置 + +### 前端(Github Pages) + +在你复刻的仓库中,进入设置标签页(https://github.com/[username]/web-workshop/settings),点击左边栏的 Pages,在 Build and deployment 下方的 Source,选择 Github Actions。意思是通过自定义的 action 来部署静态 Github Pages(与之相对的是根据仓库中的 markdown 文件自动部署) + +本仓库最终版本的 Github Pages 根路径用于展示教学文档,`frontend.yml` 会先构建文档站,再把前端构建产物复制到 `demo/` 子路径。因此官方演示页面位于 [https://eesast.github.io/web-workshop/demo/](https://eesast.github.io/web-workshop/demo/)。如果你在自己的复刻仓库中沿用当前 workflow,前端页面对应地址通常是 `https://[username].github.io/web-workshop/demo/`。 + +### 后端(Docker) + +1. 注册 Dockers Hub 账号([Signup | Docker](https://app.docker.com/signup)),建议使用 Github 注册。如果使用其他方式注册,请将用户名与 Github 保持一致(大小写不敏感) +2. 在 Docker Hub 设置界面的 Personal access tokens(个人访问 Token)([Personal access tokens | Docker](https://app.docker.com/settings/personal-access-tokens)),新增一个 token(至少要有写权限)并复制下来 +3. 在 Github 上复刻仓库的设置页,点击左边栏的 Secrets and variables -> Actions,添加一个 Secret(即密钥,加密防护)和两个 Variables(即变量,明文显示)如下: + - [Secret] `DOCKERHUB_TOKEN`,值为之前复制的个人访问 Token + - [Variable] `DOCKERHUB_USERNAME`,值为你的 Docker Hub 账号名 + - [Variable] `DOCKER_TAG`,值为你的 Docker 容器标识名,形如`:latest`,其中`repo-name`任意,不需要与仓库同名 diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml new file mode 100644 index 0000000..91a74e9 --- /dev/null +++ b/.github/workflows/backend.yml @@ -0,0 +1,72 @@ +name: backend + +on: + push: + branches: [ main ] + +permissions: + packages: write + contents: read + id-token: write + +defaults: + run: + working-directory: backend + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: yarn + cache-dependency-path: ./backend/yarn.lock + + - name: Install dependencies + run: | + yarn install --frozen-lockfile + + - name: Check grammar + run: | + yarn typecheck + + build: + needs: test + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Downcase GitHub username + run: echo "USERNAME_LC=${USERNAME@L}" >> $GITHUB_ENV + env: + USERNAME: ${{ github.repository_owner }} + + - name: Build and push docker image + uses: docker/build-push-action@v6 + with: + context: ./backend + push: true + tags: | + ghcr.io/${{ env.USERNAME_LC }}/${{ vars.DOCKER_TAG }} + ${{ vars.DOCKERHUB_USERNAME }}/${{ vars.DOCKER_TAG }} diff --git a/.github/workflows/build-gh-pages.yml b/.github/workflows/build-gh-pages.yml new file mode 100644 index 0000000..3656836 --- /dev/null +++ b/.github/workflows/build-gh-pages.yml @@ -0,0 +1,55 @@ +name: build-gh-pages + +on: + pull_request: + branches: ["main"] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Pages + uses: actions/configure-pages@v5 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: yarn + cache-dependency-path: ./frontend/yarn.lock + + - name: Convert TOC syntax + run: node assets/js/convert-toc.js + + - name: Build documentation with Jekyll + uses: actions/jekyll-build-pages@v1 + with: + source: ./ + destination: ./_site + + - name: Fix documentation site permissions + run: sudo chown -R "$(id -u):$(id -g)" ./_site + + - name: Install dependencies + working-directory: frontend + run: yarn install --frozen-lockfile + + - name: Check grammar + working-directory: frontend + run: | + yarn typecheck + yarn lint + + - name: Build + working-directory: frontend + run: yarn build + + - name: Copy frontend demo into documentation site + working-directory: frontend + run: | + mkdir -p ../_site/demo + cp -R build/. ../_site/demo/ diff --git a/.github/workflows/electron.yml b/.github/workflows/electron.yml new file mode 100644 index 0000000..da0b11f --- /dev/null +++ b/.github/workflows/electron.yml @@ -0,0 +1,83 @@ +name: electron + +on: + push: + tags: + - v* + +permissions: + contents: write + +defaults: + run: + working-directory: frontend + +jobs: + build: + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + include: + - os: ubuntu-latest + output-file: | + ./frontend/electron/*.AppImage + ./frontend/electron/*.deb + ./frontend/electron/*.rpm + ./frontend/electron/*.tar.gz + - os: windows-latest + output-file: | + ./frontend/electron/*.exe + - os: macos-latest + output-file: | + ./frontend/electron/*.dmg + ./frontend/electron/*.zip + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: yarn + cache-dependency-path: ./frontend/yarn.lock + + - name: Install dependencies + run: | + yarn install --frozen-lockfile + yarn add electron electron-builder --dev + + - name: Build + run: | + yarn build + yarn electron:build + + - name: Upload executables for publish + uses: actions/upload-artifact@v4 + with: + name: my-artifact-${{ matrix.os }} + path: ${{ matrix.output-file }} + + release: + runs-on: ubuntu-latest + needs: build + + steps: + - name: Download executables + uses: actions/download-artifact@v4 + with: + pattern: my-artifact-* + merge-multiple: true + path: dist + + - name: Deploy to GitHub Releases + uses: softprops/action-gh-release@v2 + with: + files: ./dist/* + name: Release ${{ github.ref_name }} + generate_release_notes: true + prerelease: true diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml new file mode 100644 index 0000000..de23a6a --- /dev/null +++ b/.github/workflows/frontend.yml @@ -0,0 +1,84 @@ +# Build the documentation site and publish the frontend demo below /demo. +name: frontend + +on: + push: + branches: ["main"] + + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Pages + uses: actions/configure-pages@v5 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: yarn + cache-dependency-path: ./frontend/yarn.lock + + - name: Convert TOC syntax + run: node assets/js/convert-toc.js + + - name: Build documentation with Jekyll + uses: actions/jekyll-build-pages@v1 + with: + source: ./ + destination: ./_site + + - name: Fix documentation site permissions + run: sudo chown -R "$(id -u):$(id -g)" ./_site + + - name: Install dependencies + working-directory: frontend + run: yarn install --frozen-lockfile + + - name: Check grammar + working-directory: frontend + run: | + yarn typecheck + yarn lint + + - name: Build + working-directory: frontend + run: yarn build + + - name: Copy frontend demo into documentation site + working-directory: frontend + run: | + mkdir -p ../_site/demo + cp -R build/. ../_site/demo/ + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: "./_site" + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 diff --git a/.gitignore b/.gitignore index 5d8e194..b1fc481 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,8 @@ node_modules build electron +_site +.jekyll-cache +.sass-cache .local.env diff --git a/.mergify.yml b/.mergify.yml new file mode 100644 index 0000000..e7befa9 --- /dev/null +++ b/.mergify.yml @@ -0,0 +1,9 @@ +pull_request_rules: + - name: 🏷️ Label homework + description: Label a homework with 'homework' label by detecting keyword + conditions: + - body~=作业提交 + actions: + label: + add: + - homework diff --git a/404.md b/404.md new file mode 100644 index 0000000..0c0ea11 --- /dev/null +++ b/404.md @@ -0,0 +1,5 @@ +# Unavailable + +This resource is unavailable. + +## [Back to Home](./) diff --git a/README.md b/README.md index 9dc0172..83045b8 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# 科协暑培(网站部分)学习型工程 +# 科协暑培(网站部分)学习型工程 ### 介绍 ​ 由于暑培的特性——时间短、覆盖面广、且每人负责一部分,每位主讲人都希望在自己的部分倾囊相授、达到“速成”的效果,因此我们倾向于选择知识密集型的教学方式,或多或少造成了“填鸭式”、“量子波动速读”的效果。一项技术(特别是编程领域)的知识点何其之多,即便主讲人们努力抓住主干脉络,也难免落入长篇累牍堆砌知识点的境地,不仅让听者产生厌烦,也不利于同学们实打实地掌握这门技术。 -​ 在反思这种教学方式的弊端过程中,我们打算在今年对暑培的形式做出新的尝试:贯穿始终的学习型工程。在不影响核心知识点的讲解前提下,主讲者们通过演示一个实际工程的搭建过程,来提高同学们对暑培内容的掌握程度。 +​ 在反思这种教学方式的弊端过程中,我们从去年开始对暑培的形式做出新的尝试:贯穿始终的学习型工程。在不影响核心知识点的讲解前提下,主讲者们通过演示一个实际工程的搭建过程,来提高同学们对暑培内容的掌握程度。 ​ 这个做法有三大好处: @@ -14,32 +14,62 @@ ​ 这个学习型工程的主题是**一个趣味会议软件**,希望实现的基本功能有:用户创建和登录、会议创建和加入、会议中倒计时、随机点名等趣味功能,同学们可以把他理解为不含直播的“雨课堂”或“腾讯会议”,也可以理解成一款桌游辅助工具。 +**项目文档主页:**[https://eesast.github.io/web-workshop/](https://eesast.github.io/web-workshop/) + +**项目演示页面:**[https://eesast.github.io/web-workshop/demo/](https://eesast.github.io/web-workshop/demo/) + +### 项目目录 + +- [HTML & CSS](./tutorials/01-HTML&CSS.md) +- [JS & TS](./tutorials/02-JS&TS.md) +- [DataBase (SQL & GraphQL)](./tutorials/03-Database.md) +- [Backend (NodeJS & Express)](./tutorials/04-Backend.md) +- [Frontend (React & Webpack)](./tutorials/05-Frontend.md) +- [Deployment (CI/CD & Server)](./tutorials/06-Deployment.md) + ​ 以下是各讲对应的演示内容及其在整个工程中的作用: -1. `HTML&CSS` +1. `HTML & CSS` HTML、CSS、JS 是网页三大语言,是网页的基础和本质。其中只需 HTML 和 CSS 文件就已经可以构建好看的静态网页了。我们在本节中将“画”出整个应用的首页、主页和“关于这个工程”页,并用简单的素材美化这些页面。在此过程中,我们希望同学们感受到“原来网页就是这么简单的东西“。 -2. `JS&TS` +2. `JS & TS` JS 是让网页动起来的关键,也是一种通用编程语言。这里的”动“不是移动,而是”动态“——不同的情况显示不同的内容。在本节中,我们对之前的页面施加一些魔法,使网页的背景可以随机变化、菜单内容可以展开收缩、表单提交后数据可以保存到文件中以备后用。此外,我们还会介绍 TS——带有类型系统的 JS。 -3. `DataBase (SQL&GraphQL)` +3. `DataBase (SQL & GraphQL)` 当数据的关系复杂度、规模、并发需求提高到用简单文件保存已不能满足,数据库便应运而生,并成为互联网中最重要的基础设施。在本节中,我们将对用户、会议二个对象和它们之间的关系进行数据库设计和创建(使用 SQL),并使用 Hasura 和 GraphQL 进行数据访存,从而为用户创建和登录、会议创建和加入功能作铺垫。 -4. `Backend (NodeJS&Express)` +4. `Backend (NodeJS & Express)` 在浏览器的操作是受限的、在客户端的身份是可伪造的,因此我们需要在服务器端完成诸如复杂计算、身份验证等功能——即后端。NodeJS 和 Express 是后端的一种实现方式,其中 NodeJS 使 JS 脱离浏览器环境独立运行成为可能。我们在本节中将配合数据库构建完整的用户系统,并探索邮件验证功能。 -5. `Frontend (React&Webpack)` +5. `Frontend (React & Webpack)` - 使用纯 HTML、CSS、JS 搭建网页,我们面临两个挑战:(1) 如果一次只改变部分(但很多)的页面元素,无论是用 JS 改 DOM 树还是重新写一个 HTML 都太费力 (2) 相同的页面元素组合只能复制粘贴,无法简单复用。为此,声明式、组件化的前端框架出现了。在本节中,我们会使用前端框架之一的 React 实现大部分的会议趣味功能,完成所有页面搭建。 + 使用纯 HTML、CSS、JS 搭建网页,我们面临两个挑战:(1) 如果一次只改变部分(但很多)的页面元素,无论是用 JS 改 DOM 树还是重新写一个 HTML 都太费力; (2) 相同的页面元素组合只能复制粘贴,无法简单复用。为此,声明式、组件化的前端框架出现了。在本节中,我们会使用前端框架之一的 React 实现大部分的会议趣味功能,完成所有页面搭建。 -6. `Deployment (CI/CD&Server)` +6. `Deployment (CI/CD & Server)` 在前 5 节中,我们已经在本地完成了网站的全部开发工作,但如何让世界上所有人都能 24 小时访问你的网站呢?在本节,我们将运用 Github CI/CD 来构建前端和后端的 Docker 镜像,使用 Github Pages 来托管前端页面,并尝试自己购买一个云服务器来提供网站的后端和数据库服务。 + 注:本仓库的 Github Pages 根路径用于展示教学文档,最终前端演示页面部署在 [`/demo/`](https://eesast.github.io/web-workshop/demo/) 子路径下;Deployment 一节中介绍的前端构建和 Pages 托管流程仍然适用。 + +### 关于 Vibe Coding +随着 Coding Agent 的迅速发展,截止今日(2026.7),使用先进的大模型已经能轻松完成本项目的大部分内容。要求同学们手动完成作业既浪费过多时间,又难以进行监管。暑培允许使用AI辅助完成作业,但需遵循如下的几条限制: +- 应先在AI协助下理解项目整体框架,并挑选你觉得重要部分的代码进行仔细阅读 +- 避免用简短的 prompt 向 AI 许愿。你应该编写足够详细的 prompt,明确你想要的功能和实现方式(和模型进行多轮交流来明确需求,完善 prompt,保证你对项目的细节有充分的理解) +- AI 生成的所有代码都应该经过人工 review,这对你理解所学内容至关重要 +- **针对 Web Workshop,推荐在 N 选 1 的任务中选一个手动完成,其余的交给 Agent** + +我们相信同学们参加暑培是为了精进开发能力,而不是为了完成而完成。经过暑培的学习,你将具备一名 **Developer** 应有的**品味(taste)**,指引你在软件开发的广阔世界中不断前行。 + +> 在AI时代,大部分简单的需求都能够通过AI在短时间内完成。但现实中的软件系统往往面临着复杂的业务逻辑、多变的需求,以及来自团队协作和长期维护的挑战。 +> +> 一个常见的例子是,AI快速生成了一个功能模块的代码,但带有许多不必要的条件检查和异常处理逻辑,使得代码变得冗长且难以理解(过度的防御性编程)。如果不对这种情况加以审查和优化,时间长了,整个系统便会成为“屎山”。 +> +> 面对复杂的业务需求,如何简洁、高效地实现功能,如何在长期维护中保持代码的可读性和可扩展性,这都需要开发者具备良好的代码品味(taste)。 + ### 使用方法 ##### 复刻仓库(Fork Repo) @@ -64,13 +94,13 @@ ![clone_repo](./assets/clone_repo.png) -在本地文件夹中,用任意终端(可右键打开)运行 +在本地文件夹中,用任意终端(可右键打开)运行: ```bash git clone <先前复制的仓库URI> ``` -克隆应当在几秒内完成,并在当前文件夹中创建一个名为`web-workshop`的子文件夹(即本工程)。 +克隆应当在几秒内完成,并在当前文件夹中创建一个名为 `web-workshop` 的子文件夹(即本工程)。 若出现网络问题,请自行根据现象/报错搜索解决方案,也可在暑培群中反馈。 @@ -82,7 +112,7 @@ git clone <先前复制的仓库URI> - `/assets`:说明文档中插入的图片素材,无需关心 - `/backend`:后端代码存放位置,Backend 一节中会用到 - `/database`:数据库相关代码存放位置,Database 一节中会用到 -- `/frontend`:前端代码存放位置,HTML&CSS、JS&TS、Frontend 三节中会用到 +- `/frontend`:前端代码存放位置,HTML & CSS、JS & TS、Frontend 三节中会用到 - `/server`:部署云服务相关配置文件,在 Deployment 一节中会用到 - `/tutorials`:**每一节演示内容和作业的说明**,以及授课的讲义 @@ -90,36 +120,36 @@ git clone <先前复制的仓库URI> > 注:以下 git 指令都可以使用 vscode 图形化界面操作替代,有需要的请自行摸索 -1. 切换到本节演示内容对应的分支(本地只有主分支是正常的,请在 Github 云端仓库查看所需的分支名) +1. 切换到本节演示内容对应的分支(本地只有主分支是正常的,请在 Github 云端仓库查看所需的分支名): ```bash git checkout "" ``` -2. 请先阅读`/tutorials/.md`,确保你已经正确地配置了环境 +2. 请先阅读 `/tutorials/.md`,确保你已经正确地配置了环境 -3. 分支上已有了一些提交,每个提交都对应新增的功能,你可以在`/tutorials/.md`中找到说明。若要查看运行每次提交的修改内容和实际效果,请找到提交对应的 Hash 值并运行以下命令 +3. 分支上已有了一些提交,每个提交都对应新增的功能,你可以在 `/tutorials/.md` 中找到说明。若要查看运行每次提交的修改内容和实际效果,请找到提交对应的 hash 值并运行以下命令: ```bash git checkout ``` -4. 在你对代码做任何修改前,请确保你已经切换回到分支的最新提交 +4. 在你对代码做任何修改前,请确保你已经切换回到分支的最新提交: ```bash git checkout "" ``` -5. 你可以根据`/tutorials/.md`中的作业要求编码代码,或自由地修改对应代码来探索效果 +5. 你可以根据 `/tutorials/.md` 中的作业要求编码代码,或自由地修改对应代码来探索效果 -6. 在修改完成后,记得保存并提交你的修改,建议使用规范化地提交命名 +6. 在修改完成后,记得保存并提交你的修改,建议使用 [规范化地提交命名](https://www.conventionalcommits.org/zh-hans/): ```bash git add git commit -m "" ``` -7. 为了与之前几节中你的修改内容配合起来,需要将新增的提交合并到主分支 +7. 为了与之前几节中你的修改内容配合起来,需要将新增的提交合并到主分支: ```bash git checkout main @@ -131,3 +161,58 @@ git clone <先前复制的仓库URI> ```bash git push --all ``` + +### 作业提交 + +每一讲的作业提交采用如下流程: +- 本地修改对应分支 +- 提交修改到对应分支 +- 向本仓库对应分支提交PR +- 关联 PR 到对应 issue +- 查看作业批改结果 + +##### 本地修改对应分支 + +Fork 本仓库所有分支后,根据 [Issue](https://github.com/eesast/web-workshop/issues) 对应讲作业要求,在本地切换到对应分支进行修改: + +``` +git checkout "01-HTML&CSS" +``` + +##### 提交修改到对应分支 + +完成修改后,将改动提交到本地并推送到云端 fork 仓库: + +``` +git push origin "01-HTML&CSS" +``` + +##### 向本仓库对应分支提交 PR + +打开在 GitHub 上 fork 的仓库页面后,切换到刚刚推送的 对应分支(如 lesson1) + +点击“Compare & pull request”按钮,并在 PR 创建页面填写相关信息 + +##### 关联 PR 到对应 issue + +在 PR 模板填写界面,需手动关联 PR 到对应 issue + +你可以在 PR 正文中手动关联对应 issue,方法是添加 `#ISSUE-NUMBER` 到正文后。例如,需要链接的 issue 对应的 id 是 4,则添加一行 `#4` + +你也可以在 PR 编辑界面点击右上方的“Reference”,选择需要链接的 PR,最终效果与上述方法相同 + +image + +[示例 PR](https://github.com/eesast/web-workshop/pull/12) + +关联完成后,提交 PR,则作业提交完毕 + +##### 查看作业批改结果 + +作业由讲师批改后,对应 PR 会被打上标签: +- accepted ✅:作业通过,PR 会被关闭。 +- require revision 🔄:需要修改,PR 保持 open 状态。 + + +若需修改,按 PR 下方的评论提示进行更改,然后重复 步骤 2 → 步骤 3 提交更新。 + diff --git a/_config.yml b/_config.yml new file mode 100644 index 0000000..9bb5b2a --- /dev/null +++ b/_config.yml @@ -0,0 +1,22 @@ +title: 科协暑培(网站部分)学习型工程 +description: EESAST Web Workshop +theme: jekyll-theme-hacker +plugins: + - jekyll-optional-front-matter + - jekyll-readme-index + - jekyll-default-layout + - jekyll-relative-links + - jekyll-seo-tag +relative_links: + enabled: true + collections: true +include: + - README.md + - tutorials + - database/design.md + - .github/workflows/README.md +exclude: + - frontend + - backend/node_modules + - database/node_modules + - _site diff --git a/_layouts/default.html b/_layouts/default.html new file mode 100644 index 0000000..7c4d09e --- /dev/null +++ b/_layouts/default.html @@ -0,0 +1,153 @@ + + + + + + + + + + + + {% include head-custom.html %} + + {% seo %} + + + + +
+
+
+ +

{{ site.title | default: site.github.repository_name }}

+
+

{{ site.description | default: site.github.project_tagline }}

+ +
+ {% if site.show_downloads %} + Download as .zip + Download as .tar.gz + {% endif %} + Demo + View on + GitHub + +
+
+
+ 返回首页 + +
+
+
+ +
+
+ {{ content }} +
+
+ + + + + + + + diff --git a/assets/css/custom.css b/assets/css/custom.css new file mode 100644 index 0000000..c6987ed --- /dev/null +++ b/assets/css/custom.css @@ -0,0 +1,131 @@ +body, +h1, +h2, +h3, +h4, +h5, +h6 { + font-family: + Monaco, "Bitstream Vera Sans Mono", "Lucida Console", Terminal; +} + +code { + font-family: "Source Code Pro", Consolas, monospace; +} + +.head_wrapper { + display: flex; + justify-content: space-between; + flex-direction: row; +} + +.extra-buttons { + display: flex; + align-items: center; + margin-right: 10px; +} + +.btn-extra { + display: inline-block; + margin: 5px; + white-space: nowrap; +} + +@media screen and (max-width: 768px) { + .head_wrapper { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + } + + .container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + } +} + +.markdown-alert { + padding: 0.5rem 1rem; + margin: 1rem 0; + border-left: 0.25em solid; + background-color: transparent; +} + +.markdown-alert > :first-child { + margin-top: 0; +} + +.markdown-alert > :last-child { + margin-bottom: 0; +} + +.markdown-alert-title { + display: flex; + align-items: center; + gap: 0.35rem; + font-weight: 600; + margin-bottom: 0.5rem; +} + +.markdown-alert-icon { + fill: currentColor; + flex-shrink: 0; +} + +.markdown-alert-note { + border-left-color: #0969da; +} + +.markdown-alert-note .markdown-alert-title { + color: #0969da; +} + +.markdown-alert-tip { + border-left-color: #1a7f37; +} + +.markdown-alert-tip .markdown-alert-title { + color: #1a7f37; +} + +.markdown-alert-important { + border-left-color: #8250df; +} + +.markdown-alert-important .markdown-alert-title { + color: #8250df; +} + +.markdown-alert-warning { + border-left-color: #9a6700; +} + +.markdown-alert-warning .markdown-alert-title { + color: #9a6700; +} + +.markdown-alert-caution { + border-left-color: #cf222e; +} + +.markdown-alert-caution .markdown-alert-title { + color: #cf222e; +} + +.toc { + padding: 1rem; + margin: 1rem 0 2rem; + border-left: 0.25rem solid #30363d; + background: rgba(110, 118, 129, 0.1); +} + +.toc ul { + margin-bottom: 0; +} + +.toc a { + text-decoration: none; +} diff --git a/assets/js/convert-toc.js b/assets/js/convert-toc.js new file mode 100644 index 0000000..1f04edd --- /dev/null +++ b/assets/js/convert-toc.js @@ -0,0 +1,37 @@ +const fs = require("fs"); +const path = require("path"); + +const root = "."; +const ignoredDirs = new Set([ + ".git", + "_site", + "node_modules", + "build", + "electron", +]); + +function walk(dir) { + for (const item of fs.readdirSync(dir)) { + if (ignoredDirs.has(item)) continue; + + const full = path.join(dir, item); + const stat = fs.statSync(full); + + if (stat.isDirectory()) { + walk(full); + } else if (full.endsWith(".md")) { + convertFile(full); + } + } +} + +function convertFile(file) { + let text = fs.readFileSync(file, "utf8"); + text = text.replace( + /^\[TOC\]\s*$/gm, + '
\n* TOC\n{:toc}\n
', + ); + fs.writeFileSync(file, text, "utf8"); +} + +walk(root); diff --git a/assets/js/prepare.js b/assets/js/prepare.js new file mode 100644 index 0000000..feba56f --- /dev/null +++ b/assets/js/prepare.js @@ -0,0 +1,37 @@ +const fullUrl = window.location.href; +const currentUrl = window.location.origin + window.location.pathname; +const paths = currentUrl.split("/"); +const isMainPage = currentUrl.replace(/\/$/, "") === baseUrl.replace(/\/$/, ""); + +document.documentElement.lang = "zh-CN"; + +const getDemoUrl = () => { + return `${baseUrl.replace(/\/$/, "")}/demo/`; +}; + +const getViewOnGitHubUrl = () => { + if (!repoUrl || isMainPage) { + return `${repoUrl}/`; + } + + let target = `${repoUrl}/blob/${repoBranch}/${repoPath.replace(/\/$/, "")}`; + if (!target.endsWith("/")) { + target += "/"; + } + + target += currentUrl.slice(baseUrl.length + 1); + if (target.endsWith("/")) { + target += "README.md"; + } else if (target.endsWith(".html")) { + target = target.replace(/\.html$/, ".md"); + } + + return target; +}; + +const getReturnToHomeUrl = () => { + return `${baseUrl.replace(/\/$/, "")}/`; +}; + +void fullUrl; +void paths; diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..3ae5208 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,30 @@ +# Builder stage +FROM node:20 AS builder +WORKDIR /home/node/app + +# Install Dependencies +COPY package.json yarn.lock ./ +RUN yarn install --frozen-lockfile --no-cache + +# Copy source code +COPY . . + +# Build +RUN yarn build + + +# Runner stage +FROM node:20-alpine AS runner +WORKDIR /home/node/app +ENV NODE_ENV=production + +# Install Production Dependencies +COPY package.json yarn.lock ./ +RUN yarn install --frozen-lockfile --no-cache --production + +# Copy build files +COPY --from=builder /home/node/app/build ./build + +# Expose port and run +EXPOSE 8888 +CMD yarn serve diff --git a/backend/src/authenticate.ts b/backend/src/authenticate.ts index 676643c..1eb1761 100644 --- a/backend/src/authenticate.ts +++ b/backend/src/authenticate.ts @@ -1,6 +1,14 @@ import { Request, Response, NextFunction } from "express"; import jwt from "jsonwebtoken"; +interface userJWTPayload { + uuid: string; + "https://hasura.io/jwt/claims": { + "x-hasura-allowed-roles": string[]; + "x-hasura-default-role": string; + }; +} + const authenticate: (req: Request, res: Response, next: NextFunction) => Response | void = (req, res, next) => { const authHeader = req.get("Authorization"); @@ -12,6 +20,7 @@ const authenticate: (req: Request, res: Response, next: NextFunction) => Respons if (err || !decoded) { return res.status(401).send("401 Unauthorized: Token expired or invalid"); } + res.locals.user = decoded as userJWTPayload; return next(); }); }; diff --git a/backend/src/file.ts b/backend/src/file.ts index 7e77582..8fd5d65 100644 --- a/backend/src/file.ts +++ b/backend/src/file.ts @@ -8,6 +8,15 @@ const router = express.Router(); const baseDir = process.env.FILE_DIR || path.resolve(process.cwd(), "upload"); +// Guard against path traversal: the resolved path must stay inside baseDir +const safeResolve = (...segments: string[]) => { + const resolved = path.resolve(baseDir, ...segments); + if (!resolved.startsWith(baseDir + path.sep)) { + throw new Error("Path escapes base directory"); + } + return resolved; +}; + const limits = { parts: 2, // 1 file and 0 fields fileSize: 10 * 1024 * 1024, // 10 MB @@ -74,4 +83,30 @@ router.get("/download", authenticate, (req, res) => { } }); +router.post("/delete", authenticate, (req, res) => { + const { room, filename } = req.body; + if (!room || !filename) { + return res.status(422).send("422 Unprocessable Entity: Missing room or filename"); + } + let dir: string; + try { + dir = safeResolve(room, filename); + } catch (err) { + return res.status(422).send("422 Unprocessable Entity: Invalid room or filename"); + } + try { + if (!fs.existsSync(dir)) { + return res.status(404).send("404 Not Found: File does not exist"); + } + if (!fs.statSync(dir).isFile()) { + return res.status(422).send("422 Unprocessable Entity: Not a file"); + } + fs.rmSync(dir); + return res.status(200).send("File deleted successfully"); + } catch (err) { + console.error(err); + return res.sendStatus(500); + } +}); + export default router; diff --git a/backend/src/graphql.ts b/backend/src/graphql.ts index 8e65cb6..4d1a34c 100644 --- a/backend/src/graphql.ts +++ b/backend/src/graphql.ts @@ -1554,6 +1554,13 @@ export type GetUsersByUsernameQueryVariables = Exact<{ export type GetUsersByUsernameQuery = { __typename?: 'query_root', user: Array<{ __typename?: 'user', uuid: any, password: string }> }; +export type DeleteUserMutationVariables = Exact<{ + uuid: Scalars['uuid']['input']; +}>; + + +export type DeleteUserMutation = { __typename?: 'mutation_root', delete_user_by_pk?: { __typename?: 'user', uuid: any } | null }; + export const AddMessageDocument = gql` mutation addMessage($user_uuid: uuid!, $room_uuid: uuid!, $content: String!) { @@ -1627,6 +1634,13 @@ export const GetUsersByUsernameDocument = gql` } } `; +export const DeleteUserDocument = gql` + mutation deleteUser($uuid: uuid!) { + delete_user_by_pk(uuid: $uuid) { + uuid + } +} + `; export type SdkFunctionWrapper = (action: (requestHeaders?:Record) => Promise, operationName: string, operationType?: string, variables?: any) => Promise; @@ -1658,6 +1672,9 @@ export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper = }, getUsersByUsername(variables: GetUsersByUsernameQueryVariables, requestHeaders?: GraphQLClientRequestHeaders): Promise { return withWrapper((wrappedRequestHeaders) => client.request(GetUsersByUsernameDocument, variables, {...requestHeaders, ...wrappedRequestHeaders}), 'getUsersByUsername', 'query', variables); + }, + deleteUser(variables: DeleteUserMutationVariables, requestHeaders?: GraphQLClientRequestHeaders): Promise { + return withWrapper((wrappedRequestHeaders) => client.request(DeleteUserDocument, variables, {...requestHeaders, ...wrappedRequestHeaders}), 'deleteUser', 'mutation', variables); } }; } diff --git a/backend/src/user.ts b/backend/src/user.ts index a93bd91..15b03d3 100644 --- a/backend/src/user.ts +++ b/backend/src/user.ts @@ -1,6 +1,7 @@ import express from "express"; import jwt from "jsonwebtoken"; import { sdk as graphql } from "./index"; +import authenticate from "./authenticate"; interface userJWTPayload { uuid: string; @@ -71,4 +72,21 @@ router.post("/register", async (req, res) => { } }); +router.get("/delete", authenticate, async (req, res) => { + const uuid = res.locals.user?.uuid as string | undefined; + if (!uuid) { + return res.status(401).send("401 Unauthorized: Invalid token payload"); + } + try { + const mutationResult = await graphql.deleteUser({ uuid: uuid }); + if (!mutationResult.delete_user_by_pk) { + return res.status(404).send("404 Not Found: User does not exist"); + } + return res.status(200).send("User deleted successfully"); + } catch (err) { + console.error(err); + return res.sendStatus(500); + } +}); + export default router; diff --git a/database/design.md b/database/design.md index 508dc80..c5a19d5 100644 --- a/database/design.md +++ b/database/design.md @@ -78,10 +78,11 @@ _一般来说,一个实体对应一张表,多对多的关系也可对应一 | | created_at | timestamp | | | | user_room | user_uuid | uuid | 是 | user.uuid | | | room_uuid | uuid | 是 | room.uuid | -| message | uuid | uuid | 是 | | -| | user_uuid | uuid | | user.uuid | -| | room_uuid | uuid | | room.uuid | -| | content | text | | | -| | created_at | timestamp | | | +| message | uuid | uuid | 是 | | +| | user_uuid | uuid | | user.uuid | +| | room_uuid | uuid | | room.uuid | +| | content | text | | | +| | reply_to_uuid | uuid | | message.uuid | +| | created_at | timestamp | | | 注:由于使用的是 PostgreSQL,其`text`类型指长度可变的字符串,与其他数据库可能不同([PostgreSQL: Documentation: 16: Chapter 8. Data Types](https://www.postgresql.org/docs/current/datatype.html)) diff --git a/database/graphql/message.graphql b/database/graphql/message.graphql index 994647c..2cad4cd 100644 --- a/database/graphql/message.graphql +++ b/database/graphql/message.graphql @@ -1,5 +1,7 @@ -mutation addMessage($user_uuid: uuid!, $room_uuid: uuid!, $content: String!) { - insert_message_one(object: {user_uuid: $user_uuid, room_uuid: $room_uuid, content: $content}) { +mutation addMessage($user_uuid: uuid!, $room_uuid: uuid!, $content: String!, $reply_to_uuid: uuid) { + insert_message_one( + object: {user_uuid: $user_uuid, room_uuid: $room_uuid, content: $content, reply_to_uuid: $reply_to_uuid} + ) { uuid } } @@ -12,6 +14,14 @@ subscription getMessagesByRoom($room_uuid: uuid!) { username } content + reply_to_uuid + reply_to { + uuid + content + user { + username + } + } created_at } } diff --git a/database/graphql/user.graphql b/database/graphql/user.graphql index d7780cd..6123b4c 100644 --- a/database/graphql/user.graphql +++ b/database/graphql/user.graphql @@ -10,3 +10,9 @@ query getUsersByUsername($username: String!) { password } } + +mutation deleteUser($uuid: uuid!) { + delete_user_by_pk(uuid: $uuid) { + uuid + } +} diff --git a/database/sql/message.sql b/database/sql/message.sql index 51c8108..836f3fd 100644 --- a/database/sql/message.sql +++ b/database/sql/message.sql @@ -4,6 +4,7 @@ create table if not exists public.message ( user_uuid uuid not null, room_uuid uuid not null, content text not null, + reply_to_uuid uuid, created_at timestamp default current_timestamp not null, primary key (uuid) ); @@ -11,6 +12,9 @@ alter table public.message add constraint message_user_uuid_fkey foreign key (user_uuid) references public.user (uuid) on update cascade on delete cascade; alter table public.message add constraint message_room_uuid_fkey foreign key (room_uuid) references public.room (uuid) on update cascade on delete cascade; +-- 回复消息:可空自引用外键,为空表示不是回复;指向被回复的那条消息 +alter table public.message +add constraint message_reply_to_uuid_fkey foreign key (reply_to_uuid) references public.message (uuid) on update cascade on delete set null; insert into public.message (user_uuid, room_uuid, content) values ('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-100000000001', '大家好,我叫张三'), @@ -42,3 +46,11 @@ insert into public.message (user_uuid, room_uuid, content) values ('00000000-0000-0000-0000-000000000004', '00000000-0000-0000-0000-100000000003', '好了,今天就到这里吧'); update public.message set created_at = '2021-01-01 00:00:00' where room_uuid = '00000000-0000-0000-0000-100000000002'; + +-- 回复消息测试数据(回复公共聊天室中的消息,均为单层回复) +insert into public.message (user_uuid, room_uuid, content, reply_to_uuid) +select '00000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-100000000001', '是呀,适合出去玩', m.uuid + from public.message m where m.content = '今天天气真好'; +insert into public.message (user_uuid, room_uuid, content, reply_to_uuid) +select '00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-100000000001', '吃了,您呐', m.uuid + from public.message m where m.content = '吃了吗您'; diff --git a/frontend/.env b/frontend/.env index c005e86..6b25edb 100644 --- a/frontend/.env +++ b/frontend/.env @@ -1,3 +1,3 @@ -REACT_APP_BACKEND_URL=http://localhost:8888 -REACT_APP_HASURA_HTTPLINK=https://web-workshop.hasura.app/v1/graphql -REACT_APP_HASURA_WSLINK=wss://web-workshop.hasura.app/v1/graphql +REACT_APP_BACKEND_URL=https://workshop.eesast.com +REACT_APP_HASURA_HTTPLINK=https://workshop.eesast.com/v1/graphql +REACT_APP_HASURA_WSLINK=wss://workshop.eesast.com/v1/graphql diff --git a/frontend/package.json b/frontend/package.json index b832b0d..5dbb77b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,7 +31,9 @@ "start": "craco start", "build": "craco build", "typecheck": "tsc --noEmit", - "lint": "eslint src --ext .ts,.tsx" + "lint": "eslint src --ext .ts,.tsx", + "electron": "yarn build && electron .", + "electron:build": "electron-builder" }, "eslintConfig": { "extends": [ @@ -49,5 +51,47 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "name": "web-workshop", + "version": "2024.0.0", + "main": "build/electron.js", + "build": { + "productName": "EESAST Web Workshop", + "appId": "web-workshop", + "icon": "build/logo.png", + "directories": { + "output": "electron" + }, + "win": { + "target": [ + "nsis", + "portable" + ] + }, + "nsis": { + "shortcutName": "EESAST", + "oneClick": false, + "perMachine": true, + "allowElevation": true, + "allowToChangeInstallationDirectory": true, + "createDesktopShortcut": true, + "createStartMenuShortcut": true + }, + "mac": { + "target": [ + "dmg", + "zip" + ] + }, + "linux": { + "category": "Utility", + "maintainer": "EESAST", + "target": [ + "AppImage", + "deb", + "rpm", + "tar.gz" + ] + } } } diff --git a/frontend/public/assets/cat.jpg b/frontend/public/assets/cat.jpg new file mode 100644 index 0000000..f2fc70c Binary files /dev/null and b/frontend/public/assets/cat.jpg differ diff --git a/frontend/public/config.js b/frontend/public/config.js index 3f16e1e..4babf9b 100644 --- a/frontend/public/config.js +++ b/frontend/public/config.js @@ -1 +1 @@ -export const apiUrl = "http://localhost:8888"; +export const apiUrl = "https://workshop.eesast.com"; diff --git a/frontend/public/electron.js b/frontend/public/electron.js new file mode 100644 index 0000000..654fb13 --- /dev/null +++ b/frontend/public/electron.js @@ -0,0 +1,34 @@ +const { app, BrowserWindow } = require("electron"); +const path = require("path"); + +function createWindow() { + const windowOptions = { + width: 1280, + height: 720, + }; + const mainWindow = new BrowserWindow(windowOptions); + mainWindow.loadFile(path.join(__dirname, "index.html")); + // 打开新窗口时的配置 + mainWindow.webContents.setWindowOpenHandler(() => { + return { + action: "allow", + overrideBrowserWindowOptions: windowOptions, + }; + }); +} + +app.whenReady().then(() => { + createWindow(); + // 如果没有窗口打开则打开一个窗口 (macOS) + app.on("activate", function () { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } + }); +}); +// 关闭所有窗口时退出应用 (Windows & Linux) +app.on("window-all-closed", () => { + if (process.platform !== "darwin") { + app.quit(); + } +}); diff --git a/frontend/public/index.css b/frontend/public/index.css index 2f457c2..ca8a1c7 100644 --- a/frontend/public/index.css +++ b/frontend/public/index.css @@ -78,3 +78,33 @@ text-align: right; width: 40vw; } + +/* 橘猫页面(“关于我”作业) */ +#cat-main-frame { + min-height: 100%; + box-sizing: border-box; + padding: 24px; + /* 垂直 + 水平居中 */ + display: flex; + align-items: center; + justify-content: center; + /* 橘色系渐变背景,参考 https://color.oulu.me/ */ + background: linear-gradient(to top, #ffd89b 0%, #ff9a44 100%); +} + +#cat-outer { + width: 640px; + max-width: 100%; + margin: 0 auto; +} + +.cat-subtitle { + font-size: 1.1em; + color: #8a4b00; +} + +#cat-img { + display: block; + margin: 0 auto; + padding: 0; +} diff --git a/frontend/public/logo.png b/frontend/public/logo.png new file mode 100644 index 0000000..efba5b5 Binary files /dev/null and b/frontend/public/logo.png differ diff --git a/frontend/public/orange-cat.html b/frontend/public/orange-cat.html new file mode 100644 index 0000000..5ba1b25 --- /dev/null +++ b/frontend/public/orange-cat.html @@ -0,0 +1,109 @@ + + + + + + + 关于一只橘猫 + + + + + +
+
+ + 返回首页 + +
+ + +

咪咪

+ + + KING + + +

基本档案

+ + + + + + + + + + + + + + + + + + + + + +
名字咪咪
年龄[1,2]
体重≥10.5斤
花色橘条纹,白围巾,白手套
绝育
+ + +

我的爱好

+
    +
  • 跟小猫打架
  • +
  • 在人吃饭的时候求投喂
  • +
  • 在竹编的猫窝里面睡觉
  • +
  • 在书柜顶部俯瞰众生
  • +
+ + +

关于我的趣事

+

+ 曾经从家里出逃 +

+ + +

猫咪冷知识加载中... ...

+ + +
+
+

给橘猫留言 / 投喂

+

+ 你的称呼: +

+

+ 你想对我说:

+ +

+

+ 你是猫派还是狗派? + 猫派 + 狗派 +

+

+ + +

+
+ + +

最近留言

+
    +
    +
    +
    + + diff --git a/frontend/public/orange-cat.js b/frontend/public/orange-cat.js new file mode 100644 index 0000000..e707586 --- /dev/null +++ b/frontend/public/orange-cat.js @@ -0,0 +1,57 @@ +// === 改动 1:表单提交处理 + 浏览器存储 === +// 拦截“留言 / 投喂”表单的默认提交,把留言存到 localStorage,并弹出提示。 +const catForm = document.getElementById("cat-leave-message"); +catForm.addEventListener("submit", (event) => { + event.preventDefault(); + const data = new FormData(catForm); + const record = { + name: data.get("name"), + message: data.get("message"), + faction: data.get("faction"), + time: new Date().toLocaleString("zh-CN"), + }; + // 读取已有留言,追加新留言后再存回 + const history = JSON.parse(localStorage.getItem("catMessages") || "[]"); + history.push(record); + localStorage.setItem("catMessages", JSON.stringify(history)); + alert(`谢谢 ${record.name} 的投喂!咪咪已经记下了你的留言。`); + catForm.reset(); +}); + +// === 改动 2:网络资源 —— 获取一条猫咪冷知识 === +// 免费的猫咪冷知识接口:https://catfact.ninja/ +const catFactDOM = document.getElementById("cat-fact"); +const getCatFact = async (objDOM) => { + try { + const response = await fetch("https://catfact.ninja/fact"); + const responseJSON = await response.json(); + objDOM.innerText = `🐱 猫咪冷知识:${responseJSON.fact}`; + } catch (err) { + console.error(err); + objDOM.innerText = "猫咪冷知识加载失败"; + } +}; +getCatFact(catFactDOM); + +// === 改动 3:展示 localStorage 中的历史留言 === +// 读取之前存下的留言,渲染成列表展示在页面底部;没有留言时给出默认文案。 +const renderCatMessages = () => { + const listDOM = document.getElementById("cat-message-list"); + if (!listDOM) return; + const history = JSON.parse(localStorage.getItem("catMessages") || "[]"); + listDOM.innerHTML = ""; + if (history.length === 0) { + const empty = document.createElement("li"); + empty.innerText = "还没有人给咪咪留言,快来抢占沙发!"; + listDOM.appendChild(empty); + return; + } + for (const record of history.slice(-5).reverse()) { + const item = document.createElement("li"); + item.innerText = `[${record.time}] ${record.name}(${record.faction === "cat" ? "猫派" : "狗派"}):${record.message}`; + listDOM.appendChild(item); + } +}; +renderCatMessages(); +// 表单提交后同步刷新留言列表(提交处理在前面已把数据存入 localStorage) +catForm.addEventListener("submit", () => renderCatMessages()); diff --git a/frontend/src/ChatBox.tsx b/frontend/src/ChatBox.tsx index b2619b7..ccd4f4d 100644 --- a/frontend/src/ChatBox.tsx +++ b/frontend/src/ChatBox.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { Button, Input, message, Spin } from "antd"; import { user } from "./getUser"; import * as graphql from "./graphql"; +import { isGameMessage } from "./game"; import { Bubble, Card, Container, Scroll, Text } from "./Components"; interface ChatBoxProps { @@ -124,14 +125,13 @@ const MessageFeed: React.FC = ({ user, messages }) => { return ( {messages ? ( - messages.map((message, index) => ( -
    - -
    - )) + messages + .filter((message) => !isGameMessage(message.content)) + .map((message, index, list) => ( +
    + +
    + )) ) : ( diff --git a/frontend/src/MainPanel.tsx b/frontend/src/MainPanel.tsx index fa05e44..62d9d2b 100644 --- a/frontend/src/MainPanel.tsx +++ b/frontend/src/MainPanel.tsx @@ -17,6 +17,7 @@ interface MainPanelProps { refetchRooms: () => void; addChatBox: (id: number) => void; addFileShare: (id: number) => void; + addTruthOrDare: (id: number) => void; } const MainPanel: React.FC = (props) => { @@ -183,6 +184,7 @@ const RoomList: React.FC = ({ refetchRooms, addChatBox, addFileShare, + addTruthOrDare, }) => { const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); @@ -248,6 +250,7 @@ const RoomList: React.FC = ({ room={item.room} handleOpenChat={() => addChatBox(index)} handleOpenFileShare={() => addFileShare(index)} + handleOpenTruthOrDare={() => addTruthOrDare(index)} /> )} /> @@ -287,12 +290,14 @@ interface RoomListItemProps { room: graphql.GetJoinedRoomsQuery["user_room"][0]["room"]; handleOpenChat: () => void; handleOpenFileShare: () => void; + handleOpenTruthOrDare: () => void; } const RoomListItem: React.FC = ({ room, handleOpenChat, handleOpenFileShare, + handleOpenTruthOrDare, }) => { const dateUTC = new Date(room.created_at); const date = new Date( @@ -335,6 +340,9 @@ const RoomListItem: React.FC = ({ 打开文件共享空间 + + 打开真心话大冒险 + 退出会议 diff --git a/frontend/src/TruthOrDare.tsx b/frontend/src/TruthOrDare.tsx new file mode 100644 index 0000000..d456228 --- /dev/null +++ b/frontend/src/TruthOrDare.tsx @@ -0,0 +1,366 @@ +import { useEffect, useMemo, useState } from "react"; +import { Button, Input, message as antdMessage, Modal, Tag } from "antd"; +import { user } from "./getUser"; +import * as graphql from "./graphql"; +import { + DARE_LIST, + GamePayload, + encodeGameMessage, + isGameMessage, + parseGameMessage, +} from "./game"; +import { Bubble, Card, Container, Link, Scroll, Text } from "./Components"; + +interface TruthOrDareProps { + user: user | null; + room: graphql.GetJoinedRoomsQuery["user_room"][0]["room"] | undefined; + handleClose: () => void; +} + +// 游戏状态机:把整条消息流折叠成一个"当前轮次"视图。 +// 依赖 subscription 首次订阅会返回全部历史消息这一事实, +// 新加入或刷新页面的客户端重放同样的消息即可重建状态 +interface Round { + round: number; + winner: string; + loser: string; + action?: { kind: "truth"; question: string } | { kind: "dare"; index: number; text: string }; + done: boolean; +} + +const foldMessages = ( + messages: graphql.GetMessagesByRoomSubscription["message"] | undefined +): Round | null => { + if (!messages) return null; + let current: Round | null = null; + for (const m of messages) { + if (!isGameMessage(m.content)) continue; + const payload = parseGameMessage(m.content); + if (!payload) continue; + if (payload.type === "round_start") { + current = { + round: payload.round, + winner: payload.winner, + loser: payload.loser, + done: false, + }; + } else if (current && payload.type === "truth") { + current.action = { kind: "truth", question: payload.question }; + } else if (current && payload.type === "dare") { + current.action = { kind: "dare", index: payload.index, text: payload.text }; + } else if (current && payload.type === "done") { + current.done = true; + } + } + return current; +}; + +const TruthOrDare: React.FC = ({ user, room, handleClose }) => { + const [loading, setLoading] = useState(false); + const [question, setQuestion] = useState(""); + const [showDareList, setShowDareList] = useState(false); + + // 成员名单,供庄家抽取赢家/输家 + const { data: memberData, error: memberError } = graphql.useGetRoomMembersQuery( + { + skip: !room, + variables: { room_uuid: room?.uuid }, + } + ); + useEffect(() => { + if (memberError) { + console.error(memberError); + antdMessage.error("获取房间成员失败!"); + } + }, [memberError]); + + // 复用聊天室的订阅:游戏状态完全来自消息流 + const { data, error } = graphql.useGetMessagesByRoomSubscription({ + skip: !room, + variables: { room_uuid: room?.uuid }, + }); + useEffect(() => { + if (error) { + console.error(error); + antdMessage.error("获取消息失败!"); + } + }, [error]); + + const [addMessageMutation] = graphql.useAddMessageMutation(); + + const members = useMemo( + () => memberData?.user_room.map((ur) => ur.user) ?? [], + [memberData] + ); + const currentRound = useMemo(() => foldMessages(data?.message), [data]); + + const send = async (payload: GamePayload) => { + setLoading(true); + const result = await addMessageMutation({ + variables: { + user_uuid: user?.uuid, + room_uuid: room?.uuid, + content: encodeGameMessage(payload), + }, + }); + if (result.errors) { + console.error(result.errors); + antdMessage.error("发送消息失败!"); + } + setLoading(false); + }; + + // 庄家 = 上一轮的输家;首轮无庄家,任何人都可开局 + const isHost = currentRound + ? !currentRound.done + ? currentRound.winner === user?.uuid + : false + : true; + const isWinner = currentRound && currentRound.winner === user?.uuid; + const nameOf = (uuid: string) => + members.find((m) => m.uuid === uuid)?.username ?? "未知用户"; + + const handleStart = async () => { + if (members.length < 2) { + antdMessage.error("房间成员不足两人!"); + return; + } + // 方案 A(伪联机):本地随机抽取赢家和输家,结果通过消息广播 + const winnerIdx = Math.floor(Math.random() * members.length); + let loserIdx = Math.floor(Math.random() * (members.length - 1)); + if (loserIdx >= winnerIdx) loserIdx += 1; + await send({ + type: "round_start", + round: (currentRound?.round ?? 0) + 1, + winner: members[winnerIdx].uuid, + loser: members[loserIdx].uuid, + }); + }; + + const handleTruth = async () => { + if (!question) { + antdMessage.error("问题不能为空!"); + return; + } + await send({ type: "truth", round: currentRound!.round, question }); + setQuestion(""); + }; + + const handleDare = async () => { + if (!currentRound) return; + const index = Math.floor(Math.random() * DARE_LIST.length); + await send({ + type: "dare", + round: currentRound.round, + index, + text: DARE_LIST[index], + }); + }; + + const handleDone = async () => { + if (!currentRound) return; + await send({ type: "done", round: currentRound.round }); + }; + + const Close = () => ( + + ); + + if (!user || !room) { + return null; + } + return ( + + + + + 真心话大冒险 + + + {room.name} + + setShowDareList(true)}> + 📋 查看大冒险公示名单({DARE_LIST.length} 项) + + + + {currentRound ? ( + + ) : ( + + 还没有开始过的对局,快开一局吧! + + )} + +
    + {currentRound && !currentRound.done ? ( + <> + {currentRound.action ? ( + // 惩罚完成后由赢家点击进入下一轮 + isWinner ? ( + + ) : ( + + 等待 {nameOf(currentRound.winner)} 确认惩罚完成 + + ) + ) : isWinner ? ( + <> + setQuestion(e.target.value)} + style={{ fontSize: "16px", height: "40px", flex: 1 }} + /> + + + + ) : ( + + + 等待 {nameOf(currentRound.winner)} 出题…… + + + )} + + ) : ( + // 未开局或上一轮已结束:庄家(上轮输家)或任何人(首轮)可开局 + isHost || !currentRound ? ( + + ) : ( + + 等待庄家 {nameOf(currentRound.loser)} 开局 + + ) + )} +
    + setShowDareList(false)} + > + + {DARE_LIST.map((item, index) => ( + + {index + 1}. {item} + {currentRound?.action?.kind === "dare" && + currentRound.action.index === index && + !currentRound.done ? ( + + 本轮抽中 + + ) : null} + + ))} + + +
    + ); +}; + +interface RoundViewProps { + round: Round; + usernameOf: (uuid: string) => string; +} + +const RoundView: React.FC = ({ round, usernameOf }) => ( + + + + 第 {round.round} 轮{" "} + {round.done ? ( + + 已结束 + + ) : ( + + 进行中 + + )} + + + + 赢家:{usernameOf(round.winner)}(出题) + + + 输家:{usernameOf(round.loser)}(受罚) + {round.done ? "" : " 👈"} + + {round.action && + (round.action.kind === "truth" ? ( + + 真心话:{round.action.question} + + ) : ( + + + 大冒险(公示名单第 {round.action.index + 1} 项):{round.action.text} + + + ))} + +); + +export default TruthOrDare; diff --git a/frontend/src/game.ts b/frontend/src/game.ts new file mode 100644 index 0000000..a4c2b92 --- /dev/null +++ b/frontend/src/game.ts @@ -0,0 +1,59 @@ +// 真心话大冒险的消息协议与大冒险名单 +// 约定:所有游戏消息以 GAME_PREFIX 开头,后接 JSON 载荷; +// 聊天室中以此前缀开头的消息会被过滤出来,在游戏面板中单独渲染 + +export const GAME_PREFIX = "【真心话大冒险】"; + +export type GamePayload = + | { type: "round_start"; round: number; winner: string; loser: string } + | { type: "truth"; round: number; question: string } + | { type: "dare"; round: number; index: number; text: string } + | { type: "done"; round: number }; + +export const isGameMessage = (content: string): boolean => + content.startsWith(GAME_PREFIX); + +export const parseGameMessage = (content: string): GamePayload | null => { + if (!isGameMessage(content)) return null; + try { + const payload = JSON.parse(content.slice(GAME_PREFIX.length)); + if (payload && typeof payload === "object" && typeof payload.type === "string") { + return payload as GamePayload; + } + return null; + } catch { + return null; + } +}; + +export const encodeGameMessage = (payload: GamePayload): string => + GAME_PREFIX + JSON.stringify(payload); + +// 大冒险惩罚项目名单,硬编码在前端以保证所有客户端一致; +// 抽取结果随消息广播(带 index 和 text),展示以消息为准,避免名单版本漂移 +export const DARE_LIST: string[] = [ + "模仿一种动物的叫声,持续 10 秒", + "用屁股写自己的名字", + "唱一首歌的副歌部分", + "用方言大声说“我是世界上最帅的人”", + "保持大笑 15 秒,不许中断", + "做 10 个俯卧撑或深蹲", + "对窗外大喊“我热爱学习”", + "模仿在场一位成员的口头禅和动作,直到有人猜出是谁", + "用表情包的方式演绎“开心、愤怒、委屈”三种情绪", + "朗读你手机里最近一条搜索记录", + "让大家给你摆一个搞笑自拍姿势并拍照留念", + "表演一段 10 秒的即兴舞蹈", + "用歌声说出你接下来想说的三句话", + "倒着说出在场所有人的名字", + "扮演新闻主播,播报“某人输了真心话大冒险”这条新闻", + "说三个形容自己的词,不许重复别人的", + "闭上眼睛,准确指出房间里的三个物品", + "模仿一位老师或名人的语气说“下课”", + "单脚站立 30 秒,同时背一首古诗", + "给左边的人一个真诚的赞美", + "学婴儿哭 10 秒", + "用手比划一道菜名,直到有人猜出来", + "宣布自己将主导下一轮游戏的惩罚规则(仅口头,无实权)", + "站起来转三圈然后走直线", +]; diff --git a/frontend/src/graphql.tsx b/frontend/src/graphql.tsx index 4a7f6f0..6a6afc4 100644 --- a/frontend/src/graphql.tsx +++ b/frontend/src/graphql.tsx @@ -1858,4 +1858,42 @@ export function useGetUsersByUsernameSuspenseQuery(baseOptions?: Apollo.Suspense export type GetUsersByUsernameQueryHookResult = ReturnType; export type GetUsersByUsernameLazyQueryHookResult = ReturnType; export type GetUsersByUsernameSuspenseQueryHookResult = ReturnType; -export type GetUsersByUsernameQueryResult = Apollo.QueryResult; \ No newline at end of file +export type GetUsersByUsernameQueryResult = Apollo.QueryResult; +export type GetRoomMembersQueryVariables = Exact<{ + room_uuid: Scalars['uuid']['input']; +}>; + + +export type GetRoomMembersQuery = { __typename?: 'query_root', user_room: Array<{ __typename?: 'user_room', user: { __typename?: 'user', uuid: any, username: string } }> }; + +export const GetRoomMembersDocument = gql` + query getRoomMembers($room_uuid: uuid!) { + user_room(where: {room_uuid: {_eq: $room_uuid}}) { + user { + uuid + username + } + } +} + `; + +/** + * __useGetRoomMembersQuery__ + * + * To run a query within a React component, call `useGetRoomMembersQuery` and pass it any options that fit your needs. + * When `useGetRoomMembersQuery` renders, it returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @example + * const { data, loading, error } = useGetRoomMembersQuery({ + * variables: { + * room_uuid: // value for 'room_uuid' + * }, + * }); + */ +export function useGetRoomMembersQuery(baseOptions?: Apollo.QueryHookOptions & ({ variables: GetRoomMembersQueryVariables; skip?: boolean; } | { skip: boolean; }) ) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetRoomMembersDocument, options); + } +export type GetRoomMembersQueryHookResult = ReturnType; +export type GetRoomMembersQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 3abb96e..492b67d 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -16,6 +16,7 @@ const MainPanel = React.lazy(() => import("./MainPanel")); const LoginPage = React.lazy(() => import("./LoginPage")); const ChatBox = React.lazy(() => import("./ChatBox")); const FileShare = React.lazy(() => import("./FileShare")); +const TruthOrDare = React.lazy(() => import("./TruthOrDare")); axios.defaults.baseURL = process.env.REACT_APP_BACKEND_URL!; axios.interceptors.request.use((config) => { @@ -65,6 +66,7 @@ const App = () => { const user = getUser(); const [chatBoxList, setChatBoxList] = useState([]); const [fileShareList, setFileShareList] = useState([]); + const [truthOrDareList, setTruthOrDareList] = useState([]); const [currentDrag, setCurrentDrag] = useState(""); const draggableProps = { @@ -88,6 +90,14 @@ const App = () => { const removeFileShare = (idx: number) => { setFileShareList(fileShareList.filter((id) => id !== idx)); }; + const addTruthOrDare = (idx: number) => { + if (!truthOrDareList.includes(idx)) { + setTruthOrDareList([...truthOrDareList, idx]); + } + }; + const removeTruthOrDare = (idx: number) => { + setTruthOrDareList(truthOrDareList.filter((id) => id !== idx)); + }; const { data, error, refetch } = graphql.useGetJoinedRoomsQuery({ skip: !user, @@ -111,6 +121,7 @@ const App = () => { refetchRooms={refetch} addChatBox={addChatBox} addFileShare={addFileShare} + addTruthOrDare={addTruthOrDare} /> @@ -150,6 +161,22 @@ const App = () => { ))} + {truthOrDareList.map((idx) => ( + + + removeTruthOrDare(idx)} + /> + + + ))} ); }; diff --git a/server/backend/.local.env.template b/server/backend/.local.env.template new file mode 100644 index 0000000..6b8dcf6 --- /dev/null +++ b/server/backend/.local.env.template @@ -0,0 +1,12 @@ +HASURA_GRAPHQL_ENDPOINT=
    :/v1/graphql +HASURA_GRAPHQL_ADMIN_SECRET= + +JWT_SECRET= + +EMAIL_HOST=smtp.163.com +EMAIL_PORT=465 +EMAIL_SECURE=true +EMAIL_ADDRESS= +EMAIL_PASSWORD= + +FILE_DIR=/data/upload diff --git a/server/backend/README.md b/server/backend/README.md new file mode 100644 index 0000000..1fcd1a3 --- /dev/null +++ b/server/backend/README.md @@ -0,0 +1,62 @@ +# 后端(via Docker)设置步骤 + +1. 确保服务器安装了 docker、docker compose + + ```bash + # 确认是否已安装 + docker -v + docker compose version + # docker 安装方法:https://docs.docker.com/engine/install/ + # docker compose 安装方法: https://docs.docker.com/compose/install/ + ``` + +2. 在本地电脑的仓库中使用 scp 或其他工具将`docker-compose.yml`和`.local.env`复制到服务器任意文件夹 + + ```bash + cd ./server/backend + scp ./docker-compose.yml.template @:/docker-compose.yml + scp ./.local.env.template @:/.local.env + ``` + +3. 在服务器上修改`docker-compose.yml`,正确填写 Docker Hub 或 Github Container Registry 的 Docker Tag,形如`/:latest`或`ghcr.io//:latest` + + ```bash + vim ./docker-compose.yml + ``` + +4. 在服务器上修改`.local.env`,与本地`/backend/.local.env`内容相同,新增了一个`FILE_DIR`,一般不需要修改 + + ```bash + vim ./.local.env + ``` + +5. 在该文件夹中执行 docker compose + + ```bash + docker compose up -d + ``` + +6. 如果后续拉取镜像时遇到网络问题,可以配置 docker hub 的国内镜像源([Docker Hub 国内镜像源配置 - 飞仔 FeiZai - 博客园 (cnblogs.com)](https://www.cnblogs.com/yuzhihui/p/17461781.html)) + +7. 如果提示 Permission denied,可以 sudo 运行,也可以将本用户添加到 docker 用户组 + + ```bash + sudo gpasswd -a docker + newgrp docker + ``` + +8. 确认 docker 容器已启动 + + ```bash + docker ps + ``` + +9. 查找对应的 Docker ID,查看其日志。若出现`Server running at http://localhost:8888/`,则说明成功启动 + + ```bash + docker logs + ``` + +10. 使用 Postman 执行任意请求(需要换成`
    :`,给出的`docker-compose.yml`使用端口 20248),若行为表现与本地后端相同,则说明部署成功 + +11. 如果无法访问,且服务器在国内地域(或在国外地域但其他网站访问正常),则很可能是服务器端口没放通,新增规则放通 TCP 协议的 20248 端口即可 diff --git a/server/backend/docker-compose.yml.template b/server/backend/docker-compose.yml.template new file mode 100644 index 0000000..c155bab --- /dev/null +++ b/server/backend/docker-compose.yml.template @@ -0,0 +1,10 @@ +services: + backend: + image: + restart: always + ports: + - 20248:8888 + env_file: + - .local.env + volumes: + - /data/upload:/data/upload diff --git a/server/database/.local.env.template b/server/database/.local.env.template new file mode 100644 index 0000000..e776a33 --- /dev/null +++ b/server/database/.local.env.template @@ -0,0 +1 @@ +HASURA_GRAPHQL_JWT_SECRET={"type":"HS256", "key": ""} diff --git a/server/database/README.md b/server/database/README.md new file mode 100644 index 0000000..d842cb0 --- /dev/null +++ b/server/database/README.md @@ -0,0 +1,66 @@ +# Hasura(via Docker)设置步骤 + +> 如果你已经使用 hasura.io 官网创建 Hasura 服务和数据库、并且希望继续使用,则以下内容不是必需的,在前端和后端的`.env`文件中填写 hasura.io 提供的 endpoint 和 secret 即可 + +1. 确保服务器安装了 docker、docker compose + + ```bash + # 确认是否已安装 + docker -v + docker compose version + # docker 安装方法:https://docs.docker.com/engine/install/ + # docker compose 安装方法: https://docs.docker.com/compose/install/ + ``` + +2. 创建`/data/postgresql`文件夹或其他用于存储数据库数据的文件夹(并相应修改`docker-compose.yml`中的挂载点) + + ```bash + mkdir /data/postgresql + ``` + +3. 在本地电脑的仓库中使用 scp 或其他工具将`docker-compose.yml`和`.local.env`复制到服务器任意文件夹 + + ```bash + cd ./server/database + scp ./docker-compose.yml @:/docker-compose.yml + scp ./.local.env.template @:/.local.env + ``` + +4. 在服务器上修改`.local.env`,正确填写 JWT secret(详见`/tutorials/04-Backend.md`) + + ```bash + vim ./.local.env + ``` + +5. 在该文件夹中执行 docker compose + + ```bash + docker compose up -d + ``` + +6. 如果后续拉取镜像时遇到网络问题,可以配置 docker hub 的国内镜像源([Docker Hub 国内镜像源配置 - 飞仔 FeiZai - 博客园 (cnblogs.com)](https://www.cnblogs.com/yuzhihui/p/17461781.html)) + +7. 如果提示 Permission denied,可以 sudo 运行,也可以将本用户添加到 docker 用户组 + + ```bash + sudo gpasswd -a docker + newgrp docker + ``` + +8. 确认 docker 容器已启动 + + ```bash + docker ps + ``` + +9. 浏览器访问`
    :/console`,给出的`docker-compose.yml`使用端口 20247 + +10. 如果无法访问,且服务器在国内地域(或在国外地域但其他网站访问正常),则很可能是服务器端口没放通,新增规则放通 TCP 协议的 20247 端口即可 + +11. 使用`docker-compose.yml`中定义的`HASURA_GRAPHQL_ADMIN_SECRET`登录 Hasura 后台 + +12. 在 Data 标签页连接数据库(Connect Database),选择 Postgres,点击 Connect Existing Database + +13. 数据库名称随意自取,使用环境变量连接数据库(Connect Database via Environment variable),环境变量是之前`docker-compose.yml`中定义的`PG_DATABASE_URL`,其他设置无需调整,点击 Connect Database + +14. 数据库连接完成,后续操作参照`/tutorials/03-Database.md` diff --git a/server/database/docker-compose.yml b/server/database/docker-compose.yml new file mode 100644 index 0000000..5bd5b0f --- /dev/null +++ b/server/database/docker-compose.yml @@ -0,0 +1,31 @@ +# https://github.com/hasura/graphql-engine/blob/stable/install-manifests/docker-compose/docker-compose.yaml +services: + postgres: + image: postgres:15 + restart: always + volumes: + - /data/postgresql:/var/lib/postgresql/data + environment: + POSTGRES_PASSWORD: mypostgrespassword + graphql-engine: + image: hasura/graphql-engine:v2.40.0 + ports: + - 20247:8080 + restart: always + environment: + ## postgres database to store Hasura metadata + HASURA_GRAPHQL_METADATA_DATABASE_URL: postgres://postgres:mypostgrespassword@postgres:5432/postgres + ## this env var can be used to add the above postgres database to Hasura as a data source. this can be removed/updated based on your needs + PG_DATABASE_URL: postgres://postgres:mypostgrespassword@postgres:5432/postgres + ## enable the console served by server + HASURA_GRAPHQL_ENABLE_CONSOLE: "true" # set to "false" to disable console + ## enable debugging mode. It is recommended to disable this in production + # HASURA_GRAPHQL_DEV_MODE: "true" + HASURA_GRAPHQL_ENABLED_LOG_TYPES: startup, http-log, webhook-log, websocket-log, query-log + ## uncomment next line to run console offline (i.e load console assets from server instead of CDN) + # HASURA_GRAPHQL_CONSOLE_ASSETS_DIR: /srv/console-assets + ## uncomment next line to set an admin secret + HASURA_GRAPHQL_ADMIN_SECRET: myhasuragraphqladminsecret + env_file: + # JWT secret, optional. Use https://jwtsecret.com/generate to generate a base64 secret + - .local.env diff --git a/server/nginx/README.md b/server/nginx/README.md new file mode 100644 index 0000000..df7bbcb --- /dev/null +++ b/server/nginx/README.md @@ -0,0 +1,82 @@ +# 域名、HTTPS 和反向代理设置方法 + +1. 向域名提供商(如腾讯云)购买域名(注:由于这是学习型工程,大家可以挑最便宜的域名,一般 10 元左右可以包年,次年续费可能要贵一些;所有的域名在技术上都是一样的,只有好听与否的差异) + +2. 配置 DNS 解析记录。一般域名提供商会连带提供简单的 DNS 解析服务,设置一条解析到你部署后端用到的服务器的 IPv4 地址(A 记录)即可,其他设置可按需选择 + +3. 等待十几分钟 DNS 传播,此时你应当已经可以通过如下 URL 访问后端服务(仅将 IP 地址改为域名) + + ```http + POST http://:20248/user/login + ``` + +4. 接下来,我们希望使用 HTTPS 通信(TLS/SSL 加密)来使访问更安全,需要生成 SSL 证书(即带有授权的一对密钥)。我们使用 Let's Encrypt 提供的免费证书及其自助签发软件 certbot + + ```bash + sudo apt-get install certbot + ``` + +5. 安装完成后,运行以下命令即可生成 SSL 证书(如果生成失败,可能是未放通 80 端口,请根据报错信息调整) + + ```bash + sudo certbot certonly --standalone -d + ``` + + 证书生成在`/etc/letsencrypt/live//`文件夹下,其中`privkey.pem`为私钥、`fullchain.pem`包含公钥。这些文件实际上是指向`/etc/letsencrypt/archive`目录下存储的真实证书的链接,由 certbot 定期更新从而指向最新的证书,在 docker 中使用证书必须将这两个目录都映射到容器内。 + +6. 接下来,我们使用 Nginx(via Docker)来提供 HTTPS 和反向代理服务,请确保服务器安装了 docker、docker compose + + ```bash + # 确认是否已安装 + docker -v + docker compose version + # docker 安装方法:https://docs.docker.com/engine/install/ + # docker compose 安装方法: https://docs.docker.com/compose/install/ + ``` + +7. 在本地电脑的仓库中使用 scp 或其他工具将`docker-compose.yml`和`nginx.conf`复制到服务器任意文件夹 + + ```bash + cd ./server/nginx + scp ./docker-compose.yml @:/docker-compose.yml + scp ./nginx.conf.template @:/nginx.conf + ``` + +8. 在服务器上修改`nginx.conf`,正确填写域名和服务器 IP 地址 + + ```bash + vim ./nginx.conf + ``` + +9. 在该文件夹中执行 docker compose + + ```bash + docker compose up -d + ``` + +10. 如果后续拉取镜像时遇到网络问题,可以配置 docker hub 的国内镜像源([Docker Hub 国内镜像源配置 - 飞仔 FeiZai - 博客园 (cnblogs.com)](https://www.cnblogs.com/yuzhihui/p/17461781.html)) + +11. 如果提示 Permission denied,可以 sudo 运行,也可以将本用户添加到 docker 用户组 + + ```bash + sudo gpasswd -a docker + newgrp docker + ``` + +12. 确认 docker 容器已启动 + + ```bash + docker ps + ``` + +13. 查找对应的 Docker ID,查看其日志。若没有报错,则说明成功启动 + + ```bash + docker logs + ``` + +14. 此时,你应当可以通过如下 URL 访问后端服务(将 http 改为 https,且不需要端口号) + + ```http + POST https:///user/login + ``` diff --git a/server/nginx/docker-compose.yml b/server/nginx/docker-compose.yml new file mode 100644 index 0000000..f4f9373 --- /dev/null +++ b/server/nginx/docker-compose.yml @@ -0,0 +1,9 @@ +services: + nginx: + image: nginx:stable-alpine + ports: + - 443:443 + restart: always + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf + - /etc/letsencrypt:/etc/letsencrypt diff --git a/server/nginx/nginx.conf.template b/server/nginx/nginx.conf.template new file mode 100644 index 0000000..1c2c91e --- /dev/null +++ b/server/nginx/nginx.conf.template @@ -0,0 +1,27 @@ +events { + worker_connections 1024; +} + +http { + server { + listen 443 ssl; # listen to IPv4, with SSL + listen [::]:443 ssl; # listen to IPv6, with SSL + + server_name ; # domain name + + ssl_certificate /etc/letsencrypt/live//fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live//privkey.pem; + + location / { + proxy_pass http://:20248; # proxy to backend server + client_max_body_size 100M; # allow large file upload + } + + location /v1/graphql { + proxy_pass http://:20247; # proxy to hasura graphql endpoint + # Important: Do not add / to the end of URL, ref: https://blog.csdn.net/q1298252589/article/details/120729989 + proxy_set_header Upgrade $http_upgrade; # enable websocket + proxy_set_header Connection "upgrade"; # enable websocket + } + } +} diff --git a/tutorials/05-Frontend.md b/tutorials/05-Frontend.md index 7bf3117..efdb320 100644 --- a/tutorials/05-Frontend.md +++ b/tutorials/05-Frontend.md @@ -87,7 +87,7 @@ rimraf node_modules # 删除某个文件夹,如 node_modules ### 作业 -对于已完成数据库或后端作业的同学,可以选择其中之一完成相应的前端界面,如下: +对于未完成数据库或后端作业的同学,可以选择其中之一完成相应的前端界面,如下: - 【数据库】聊天室的消息可以选择回复之前的某条消息,但没有多层回复或多重回复 - 提示:可以使用右键菜单实现回复功能(JSX 元素的`onContextMenu`属性),可以使用如 react-contextify 的 npm 包来简化代码([fkhadra/react-contexify: 👌 Add a context menu to your react app with ease (github.com)](https://github.com/fkhadra/react-contexify)) @@ -100,7 +100,7 @@ rimraf node_modules # 删除某个文件夹,如 node_modules - 【后端】“痕迹抹除”:允许删除用户和删除文件 - 提示:需要注意删除后的 UI 表现(如删除用户后应当退出登录) -对于尚未完成以上前置作业的同学,也可以从以下几组功能需求中选择一组实现: +对于已完成以上前置作业的同学,也可以从以下几组功能需求中选择一组实现: 提示:你有可能需要修改 graphql 文件并重新生成 graphql.tsx 来完成一些数据库操作 diff --git a/tutorials/06-Deployment.md b/tutorials/06-Deployment.md new file mode 100644 index 0000000..2f144e9 --- /dev/null +++ b/tutorials/06-Deployment.md @@ -0,0 +1,23 @@ +# Deployment (CI/CD & Server) + +在前 5 节中,我们已经在本地完成了网站的全部开发工作,但如何让世界上所有人都能 24 小时访问你的网站呢?在本节,我们将运用 Github CI/CD 来构建前端和后端的 Docker 镜像,使用 Github Pages 来托管前端页面,并尝试自己购买一个云服务器来提供网站的后端和数据库服务。 + +本仓库的最终 Github Pages 根路径用于展示教学文档,前端演示页面部署在 [https://eesast.github.io/web-workshop/demo/](https://eesast.github.io/web-workshop/demo/);这不会改变本节要学习的 Github Pages 托管前端页面的基本流程。 + +### 已实现的功能 + +| 哈希值前 7 位 | 提交信息 | 对应知识点 | 实现效果 | +| ------------- | ------------------------------------------ | -------------------------------------------------- | ------------------------------------------------------- | +| cf00ffc | feat(06): frontend on Github Pages | Github Actions 基本语法、Github Pages 配置方法 | 通过 Github Pages 的公开网址访问前端网页 | +| e2bc962 | feat(06): build backend as docker | Dockerfile 基本语法、Github Actions 与 Docker 配合 | 后端服务被构建为 Docker 镜像 | +| b67e770 | feat(06): above the cloud | 网站服务架构、Docker Compose 使用 | 可以通过 IP 访问服务器上的后端和数据库服务 | +| f97998b | feat(06): upgrade to https | HTTPS、域名、反向代理 | 可以通过域名访问服务器上的服务,不会警告`mixed content` | +| 1c9d3cd | feat(06): desktop application via electron | Electron 基本概念 | 基于 Electron 的桌面应用 | + +### 操作方法 + +见各文件夹中的`README.md` + +### 作业 + +自行购置云服务器和域名,根据各文件夹中的`README.md`,部署前端、后端、数据库服务,从而最终可以公网访问你的网站