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..e0c845f 100644 --- a/backend/src/authenticate.ts +++ b/backend/src/authenticate.ts @@ -4,14 +4,16 @@ import jwt from "jsonwebtoken"; const authenticate: (req: Request, res: Response, next: NextFunction) => Response | void = (req, res, next) => { const authHeader = req.get("Authorization"); - if (!authHeader) { + if (!authHeader || !authHeader.startsWith("Bearer ")) { return res.status(401).send("401 Unauthorized: Missing Token"); } const token = authHeader.substring(7); - return jwt.verify(token, process.env.JWT_SECRET!, async (err, decoded) => { - if (err || !decoded) { + return jwt.verify(token, process.env.JWT_SECRET!, (err, decoded) => { + const uuid = (decoded as { uuid?: string } | undefined)?.uuid; + if (err || !uuid) { return res.status(401).send("401 Unauthorized: Token expired or invalid"); } + res.locals.userUuid = uuid; return next(); }); }; diff --git a/backend/src/file.ts b/backend/src/file.ts index 7e77582..60f55e9 100644 --- a/backend/src/file.ts +++ b/backend/src/file.ts @@ -74,4 +74,33 @@ router.get("/download", authenticate, (req, res) => { } }); +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +router.post("/delete", authenticate, (req, res) => { + const { room, filename } = req.body; + if ( + typeof room !== "string" || + typeof filename !== "string" || + !uuidPattern.test(room) || + !filename || + filename !== path.basename(filename) || + filename === "." || + filename === ".." + ) { + return res.status(422).send("422 Unprocessable Entity: Invalid room or filename"); + } + + const filePath = path.resolve(baseDir, room, filename); + try { + if (!fs.existsSync(filePath)) { + return res.status(404).send("404 Not Found: File does not exist"); + } + fs.unlinkSync(filePath); + return res.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..fdebce4 100644 --- a/backend/src/graphql.ts +++ b/backend/src/graphql.ts @@ -78,6 +78,13 @@ export type Message = { __typename?: 'message'; content: Scalars['String']['output']; created_at: Scalars['timestamp']['output']; + /** An array relationship */ + messages: Array; + /** An aggregate relationship */ + messages_aggregate: Message_Aggregate; + /** An object relationship */ + reply_to_message?: Maybe; + reply_to_message_uuid?: Maybe; /** An object relationship */ room: Room; room_uuid: Scalars['uuid']['output']; @@ -87,6 +94,26 @@ export type Message = { uuid: Scalars['uuid']['output']; }; + +/** columns and relationships of "message" */ +export type MessageMessagesArgs = { + distinct_on?: InputMaybe>; + limit?: InputMaybe; + offset?: InputMaybe; + order_by?: InputMaybe>; + where?: InputMaybe; +}; + + +/** columns and relationships of "message" */ +export type MessageMessages_AggregateArgs = { + distinct_on?: InputMaybe>; + limit?: InputMaybe; + offset?: InputMaybe; + order_by?: InputMaybe>; + where?: InputMaybe; +}; + /** aggregated selection of "message" */ export type Message_Aggregate = { __typename?: 'message_aggregate'; @@ -141,6 +168,10 @@ export type Message_Bool_Exp = { _or?: InputMaybe>; content?: InputMaybe; created_at?: InputMaybe; + messages?: InputMaybe; + messages_aggregate?: InputMaybe; + reply_to_message?: InputMaybe; + reply_to_message_uuid?: InputMaybe; room?: InputMaybe; room_uuid?: InputMaybe; user?: InputMaybe; @@ -158,6 +189,9 @@ export enum Message_Constraint { export type Message_Insert_Input = { content?: InputMaybe; created_at?: InputMaybe; + messages?: InputMaybe; + reply_to_message?: InputMaybe; + reply_to_message_uuid?: InputMaybe; room?: InputMaybe; room_uuid?: InputMaybe; user?: InputMaybe; @@ -170,6 +204,7 @@ export type Message_Max_Fields = { __typename?: 'message_max_fields'; content?: Maybe; created_at?: Maybe; + reply_to_message_uuid?: Maybe; room_uuid?: Maybe; user_uuid?: Maybe; uuid?: Maybe; @@ -179,6 +214,7 @@ export type Message_Max_Fields = { export type Message_Max_Order_By = { content?: InputMaybe; created_at?: InputMaybe; + reply_to_message_uuid?: InputMaybe; room_uuid?: InputMaybe; user_uuid?: InputMaybe; uuid?: InputMaybe; @@ -189,6 +225,7 @@ export type Message_Min_Fields = { __typename?: 'message_min_fields'; content?: Maybe; created_at?: Maybe; + reply_to_message_uuid?: Maybe; room_uuid?: Maybe; user_uuid?: Maybe; uuid?: Maybe; @@ -198,6 +235,7 @@ export type Message_Min_Fields = { export type Message_Min_Order_By = { content?: InputMaybe; created_at?: InputMaybe; + reply_to_message_uuid?: InputMaybe; room_uuid?: InputMaybe; user_uuid?: InputMaybe; uuid?: InputMaybe; @@ -212,6 +250,13 @@ export type Message_Mutation_Response = { returning: Array; }; +/** input type for inserting object relation for remote table "message" */ +export type Message_Obj_Rel_Insert_Input = { + data: Message_Insert_Input; + /** upsert condition */ + on_conflict?: InputMaybe; +}; + /** on_conflict condition type for table "message" */ export type Message_On_Conflict = { constraint: Message_Constraint; @@ -223,6 +268,9 @@ export type Message_On_Conflict = { export type Message_Order_By = { content?: InputMaybe; created_at?: InputMaybe; + messages_aggregate?: InputMaybe; + reply_to_message?: InputMaybe; + reply_to_message_uuid?: InputMaybe; room?: InputMaybe; room_uuid?: InputMaybe; user?: InputMaybe; @@ -242,6 +290,8 @@ export enum Message_Select_Column { /** column name */ CreatedAt = 'created_at', /** column name */ + ReplyToMessageUuid = 'reply_to_message_uuid', + /** column name */ RoomUuid = 'room_uuid', /** column name */ UserUuid = 'user_uuid', @@ -253,6 +303,7 @@ export enum Message_Select_Column { export type Message_Set_Input = { content?: InputMaybe; created_at?: InputMaybe; + reply_to_message_uuid?: InputMaybe; room_uuid?: InputMaybe; user_uuid?: InputMaybe; uuid?: InputMaybe; @@ -270,6 +321,7 @@ export type Message_Stream_Cursor_Input = { export type Message_Stream_Cursor_Value_Input = { content?: InputMaybe; created_at?: InputMaybe; + reply_to_message_uuid?: InputMaybe; room_uuid?: InputMaybe; user_uuid?: InputMaybe; uuid?: InputMaybe; @@ -282,6 +334,8 @@ export enum Message_Update_Column { /** column name */ CreatedAt = 'created_at', /** column name */ + ReplyToMessageUuid = 'reply_to_message_uuid', + /** column name */ RoomUuid = 'room_uuid', /** column name */ UserUuid = 'user_uuid', @@ -1492,21 +1546,29 @@ export type Uuid_Comparison_Exp = { _nin?: InputMaybe>; }; +export type GetReplyTargetQueryVariables = Exact<{ + uuid: Scalars['uuid']['input']; +}>; + + +export type GetReplyTargetQuery = { __typename?: 'query_root', message_by_pk?: { __typename?: 'message', uuid: any, reply_to_message_uuid?: any | null } | null }; + export type AddMessageMutationVariables = Exact<{ user_uuid: Scalars['uuid']['input']; room_uuid: Scalars['uuid']['input']; content: Scalars['String']['input']; + reply_to_message_uuid?: InputMaybe; }>; -export type AddMessageMutation = { __typename?: 'mutation_root', insert_message_one?: { __typename?: 'message', uuid: any } | null }; +export type AddMessageMutation = { __typename?: 'mutation_root', insert_message_one?: { __typename?: 'message', uuid: any, reply_to_message_uuid?: any | null } | null }; export type GetMessagesByRoomSubscriptionVariables = Exact<{ room_uuid: Scalars['uuid']['input']; }>; -export type GetMessagesByRoomSubscription = { __typename?: 'subscription_root', message: Array<{ __typename?: 'message', uuid: any, content: string, created_at: any, user: { __typename?: 'user', uuid: any, username: string } }> }; +export type GetMessagesByRoomSubscription = { __typename?: 'subscription_root', message: Array<{ __typename?: 'message', uuid: any, content: string, created_at: any, reply_to_message_uuid?: any | null, user: { __typename?: 'user', uuid: any, username: string }, reply_to_message?: { __typename?: 'message', uuid: any, content: string, created_at: any, user: { __typename?: 'user', uuid: any, username: string } } | null }> }; export type AddRoomMutationVariables = Exact<{ name: Scalars['String']['input']; @@ -1554,13 +1616,29 @@ 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 GetReplyTargetDocument = gql` + query getReplyTarget($uuid: uuid!) { + message_by_pk(uuid: $uuid) { + uuid + reply_to_message_uuid + } +} + `; export const AddMessageDocument = gql` - mutation addMessage($user_uuid: uuid!, $room_uuid: uuid!, $content: String!) { + mutation addMessage($user_uuid: uuid!, $room_uuid: uuid!, $content: String!, $reply_to_message_uuid: uuid) { insert_message_one( - object: {user_uuid: $user_uuid, room_uuid: $room_uuid, content: $content} + object: {user_uuid: $user_uuid, room_uuid: $room_uuid, content: $content, reply_to_message_uuid: $reply_to_message_uuid} ) { uuid + reply_to_message_uuid } } `; @@ -1574,6 +1652,16 @@ export const GetMessagesByRoomDocument = gql` } content created_at + reply_to_message_uuid + reply_to_message { + uuid + user { + uuid + username + } + content + created_at + } } } `; @@ -1627,6 +1715,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; @@ -1635,6 +1730,9 @@ const defaultWrapper: SdkFunctionWrapper = (action, _operationName, _operationTy export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper = defaultWrapper) { return { + getReplyTarget(variables: GetReplyTargetQueryVariables, requestHeaders?: GraphQLClientRequestHeaders): Promise { + return withWrapper((wrappedRequestHeaders) => client.request(GetReplyTargetDocument, variables, {...requestHeaders, ...wrappedRequestHeaders}), 'getReplyTarget', 'query', variables); + }, addMessage(variables: AddMessageMutationVariables, requestHeaders?: GraphQLClientRequestHeaders): Promise { return withWrapper((wrappedRequestHeaders) => client.request(AddMessageDocument, variables, {...requestHeaders, ...wrappedRequestHeaders}), 'addMessage', 'mutation', variables); }, @@ -1658,7 +1756,10 @@ 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); } }; } -export type Sdk = ReturnType; +export type Sdk = ReturnType; \ No newline at end of file diff --git a/backend/src/user.ts b/backend/src/user.ts index a93bd91..a91b442 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,17 @@ router.post("/register", async (req, res) => { } }); +router.get("/delete", authenticate, async (_req, res) => { + try { + const mutationResult = await graphql.deleteUser({ uuid: res.locals.userUuid }); + if (!mutationResult.delete_user_by_pk) { + return res.status(404).send("404 Not Found: User does not exist"); + } + return res.send("User deleted successfully"); + } catch (err) { + console.error(err); + return res.sendStatus(500); + } +}); + export default router; diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 790742b..b5dc408 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -27,7 +27,7 @@ /* Modules */ "module": "CommonJS", /* Specify what module code is generated. */ - // "rootDir": "./", /* Specify the root folder within your source files. */ + "rootDir": "./src", /* Specify the root folder within your source files. */ // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ diff --git a/database/graphql/message.graphql b/database/graphql/message.graphql index 994647c..2301e30 100644 --- a/database/graphql/message.graphql +++ b/database/graphql/message.graphql @@ -1,6 +1,26 @@ -mutation addMessage($user_uuid: uuid!, $room_uuid: uuid!, $content: String!) { - insert_message_one(object: {user_uuid: $user_uuid, room_uuid: $room_uuid, content: $content}) { +query getReplyTarget($uuid: uuid!) { + message_by_pk(uuid: $uuid) { uuid + reply_to_message_uuid + } +} + +mutation addMessage( + $user_uuid: uuid! + $room_uuid: uuid! + $content: String! + $reply_to_message_uuid: uuid +) { + insert_message_one( + object: { + user_uuid: $user_uuid + room_uuid: $room_uuid + content: $content + reply_to_message_uuid: $reply_to_message_uuid + } + ) { + uuid + reply_to_message_uuid } } @@ -13,5 +33,15 @@ subscription getMessagesByRoom($room_uuid: uuid!) { } content created_at + reply_to_message_uuid + reply_to_message { + uuid + user { + uuid + username + } + content + 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..dfab10e 100644 --- a/database/sql/message.sql +++ b/database/sql/message.sql @@ -3,6 +3,7 @@ create table if not exists public.message ( uuid uuid default gen_random_uuid() not null, user_uuid uuid not null, room_uuid uuid not null, + reply_to_message_uuid uuid, content text not null, created_at timestamp default current_timestamp not null, primary key (uuid) @@ -11,6 +12,8 @@ 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_message_uuid_fkey foreign key (reply_to_message_uuid) references public.message (uuid) on update cascade on delete cascade; insert into public.message (user_uuid, room_uuid, content) values ('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-100000000001', '大家好,我叫张三'), diff --git a/frontend/.env b/frontend/.env new file mode 100644 index 0000000..6b25edb --- /dev/null +++ b/frontend/.env @@ -0,0 +1,3 @@ +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/craco.config.js b/frontend/craco.config.js new file mode 100644 index 0000000..1ae0534 --- /dev/null +++ b/frontend/craco.config.js @@ -0,0 +1,40 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +const WebpackBar = require("webpackbar"); +// const { BundleAnalyzerPlugin } = require("webpack-bundle-analyzer"); + +module.exports = { + devServer: { + historyApiFallback: { + rewrites: [ + { from: /^\/main/, to: "/main.html" }, + { from: /^\/about/, to: "/about.html" }, + { from: /^\/about-me/, to: "/about-me.html" }, + ], + }, + }, + webpack: { + plugins: { + remove: [ + "HtmlWebpackPlugin", + ], + add: [ + new HtmlWebpackPlugin( + { + inject: false, + filename: "index.html", + template: "public/index.html", + } + ), + new HtmlWebpackPlugin( + { + inject: true, + filename: "main.html", + template: "public/main.html", + } + ), + new WebpackBar(), + // new BundleAnalyzerPlugin(), + ] + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..5dbb77b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,97 @@ +{ + "homepage": "./", + "dependencies": { + "@ant-design/pro-components": "2.7.15", + "@apollo/client": "3.11.4", + "antd": "5.20.2", + "axios": "1.7.4", + "graphql": "16.9.0", + "graphql-ws": "5.16.0", + "jwt-decode": "4.0.0", + "md5": "2.3.0", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-draggable": "4.4.6", + "react-router-dom": "6.26.1" + }, + "devDependencies": { + "@craco/craco": "7.1.0", + "@types/md5": "2.3.5", + "@types/react": "18.3.3", + "@types/react-dom": "18.3.0", + "react-scripts": "5.0.1", + "typescript": "5.5.4", + "webpack-bundle-analyzer": "4.10.2", + "webpackbar": "6.0.1" + }, + "resolutions": { + "@babel/plugin-proposal-private-property-in-object": "7.21.11" + }, + "scripts": { + "start": "craco start", + "build": "craco build", + "typecheck": "tsc --noEmit", + "lint": "eslint src --ext .ts,.tsx", + "electron": "yarn build && electron .", + "electron:build": "electron-builder" + }, + "eslintConfig": { + "extends": [ + "react-app" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "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/about-me.html b/frontend/public/about-me.html new file mode 100644 index 0000000..cc96731 --- /dev/null +++ b/frontend/public/about-me.html @@ -0,0 +1,107 @@ + + + + + + 关于我 + + + + +
+

你好,我是张以昶

+

我是一名正在学习网页开发的学生。

+ +
+ +
+

我的爱好

+

+ 游泳
+ 交通
+ 美食 +

+
+ +
+

个人信息

+

+ from:浙江温州 + grade:2025级,大二 +

+
+ +
+

我的学习计划

+ + + + + + + + + + + + + +
学习内容当前状态
HTML能用元素组织内容
CSS练习调整颜色和间距
+
+ +

+ +

+ +
+

网易云热歌榜

+

点击下面按钮,加载并播放一首热歌。

+

+ +
+ +

返回首页

+
+ + diff --git a/frontend/public/about-me.js b/frontend/public/about-me.js new file mode 100644 index 0000000..8732731 --- /dev/null +++ b/frontend/public/about-me.js @@ -0,0 +1,62 @@ +const themeButtonDOM = document.getElementById("theme-button"); +const pageDOM = document.getElementById("page"); +const sectionDOMList = document.getElementsByClassName("section"); + +let isDarkMode = false; + +function changeTheme() { + let pageBackgroundColor = "white"; + let bodyBackgroundColor = "white"; + let textColor = "black"; + let buttonText = "切换为暗色模式"; + + isDarkMode = !isDarkMode; + + if (isDarkMode) { + pageBackgroundColor = "black"; + bodyBackgroundColor = "black"; + textColor = "white"; + buttonText = "切换为亮色模式"; + } + + document.body.style.backgroundColor = bodyBackgroundColor; + pageDOM.style.backgroundColor = pageBackgroundColor; + pageDOM.style.color = textColor; + + for (let i = 0; i < sectionDOMList.length; i++) { + const sectionDOM = sectionDOMList[i]; + sectionDOM.style.backgroundColor = pageBackgroundColor; + sectionDOM.style.color = textColor; + } + + themeButtonDOM.innerText = buttonText; +} + +themeButtonDOM.addEventListener("click", changeTheme); + +const musicButtonDOM = document.getElementById("music-button"); +const musicInfoDOM = document.getElementById("music-info"); +const musicPlayerDOM = document.getElementById("music-player"); + +async function loadHotSong() { + try { + musicInfoDOM.innerText = "正在加载热歌……"; + musicPlayerDOM.src = + "https://free.wqwlkj.cn/wqwlapi/wyy_random.php?type=jump"; + + await musicPlayerDOM.play(); + musicInfoDOM.innerText = "正在播放热歌。"; + } catch (error) { + console.error(error); + musicInfoDOM.innerText = + "热歌已加载;若未自动播放,请点击播放器中的播放按钮。"; + } +} + +function showMusicError() { + musicInfoDOM.innerText = "热歌加载失败,请检查网络后重试。"; +} + +musicButtonDOM.addEventListener("click", loadHotSong); +musicPlayerDOM.addEventListener("ended", loadHotSong); +musicPlayerDOM.addEventListener("error", showMusicError); diff --git a/frontend/public/about.html b/frontend/public/about.html index 6e2a043..07f3702 100644 --- a/frontend/public/about.html +++ b/frontend/public/about.html @@ -2,6 +2,7 @@ + 关于这个工程