├── .env.development
├── .github
└── workflows
│ └── build.yml
├── .gitignore
├── CHANGELOG.md
├── CHANGELOG_ZH.md
├── LICENSE
├── README.md
├── biome.json
├── design
└── img
│ ├── 512.png
│ ├── bg.ai
│ └── bg.svg
├── entrypoints
├── about.html
├── background.ts
└── index.html
├── lingui.config.ts
├── package.json
├── pnpm-lock.yaml
├── public
├── _locales
│ ├── en_US
│ │ └── messages.json
│ └── zh_CN
│ │ └── messages.json
└── icons
│ ├── 128.png
│ ├── 16.png
│ ├── 19.png
│ └── 48.png
├── src
├── about.tsx
├── components
│ ├── CardList.tsx
│ ├── ContentCard.tsx
│ ├── ContentDetail.tsx
│ ├── CourseList.tsx
│ ├── IframeWrapper.tsx
│ ├── SettingList.tsx
│ ├── SummaryList.tsx
│ └── dialogs
│ │ ├── ChangeSemesterDialog.tsx
│ │ ├── ClearDataDialog.tsx
│ │ ├── LoginDialog.tsx
│ │ ├── LogoutDialog.tsx
│ │ ├── NetworkErrorDialog.tsx
│ │ ├── NewSemesterDialog.tsx
│ │ └── index.ts
├── constants
│ ├── index.ts
│ └── ui.tsx
├── css
│ ├── card.module.css
│ ├── doc.module.css
│ ├── list.module.css
│ ├── main.module.css
│ ├── page.module.css
│ └── scrollbar.css
├── i18n.ts
├── image
│ ├── bg.png
│ ├── bg_dark.png
│ ├── creampaper.png
│ ├── creampaper_black.png
│ ├── file.png
│ ├── homework_expired.png
│ ├── homework_submitted.png
│ ├── new_item.png
│ ├── settings_1.png
│ ├── settings_2.png
│ ├── starred_item.png
│ ├── switch_course.png
│ ├── switch_filter.png
│ └── title_filter.png
├── init.ts
├── locales
│ ├── en.po
│ └── zh.po
├── main.tsx
├── pages
│ ├── _app.tsx
│ ├── content.tsx
│ ├── doc
│ │ ├── _doc.tsx
│ │ ├── about
│ │ │ ├── _en.mdx
│ │ │ ├── _zh.mdx
│ │ │ └── index.tsx
│ │ ├── changelog.tsx
│ │ └── readme
│ │ │ ├── _en.mdx
│ │ │ ├── _zh.mdx
│ │ │ └── index.tsx
│ ├── settings.tsx
│ ├── web.tsx
│ └── welcome.tsx
├── redux
│ ├── actions.ts
│ ├── hooks.ts
│ ├── reducers
│ │ ├── data.ts
│ │ ├── helper.ts
│ │ └── ui.ts
│ ├── selectors.ts
│ └── store.ts
├── theme.ts
├── types
│ ├── data.ts
│ └── ui.ts
├── utils
│ ├── console.ts
│ ├── crypto.ts
│ ├── csrf.ts
│ ├── download.ts
│ ├── format.ts
│ ├── permission.ts
│ └── storage.ts
└── vite-env.d.ts
├── tsconfig.json
└── wxt.config.ts
/.env.development:
--------------------------------------------------------------------------------
1 | # username & password for auto login when developing locally
2 | # copy this file to .env.development.local
3 | VITE_USERNAME=
4 | VITE_PASSWORD=
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 | if: ${{ !contains(github.event.head_commit.message, 'ci skip') }}
9 | steps:
10 | - uses: actions/checkout@v4
11 | - name: Install pnpm
12 | uses: pnpm/action-setup@v4
13 | - uses: actions/setup-node@v4
14 | with:
15 | node-version: 'lts/*'
16 | cache: 'pnpm'
17 | - name: Install dependencies
18 | run: pnpm install
19 | - name: Build Chrome version
20 | run: pnpm zip:chrome
21 | - name: Upload Chrome version
22 | uses: actions/upload-artifact@v4
23 | with:
24 | name: chrome
25 | path: |
26 | dist/chrome-mv3/
27 | stats.html
28 | - name: Build Firefox version
29 | run: pnpm zip:firefox
30 | - name: Upload Firefox version
31 | uses: actions/upload-artifact@v4
32 | with:
33 | name: firefox
34 | path: |
35 | dist/firefox-mv3/
36 | stats.html
37 | - name: Upload Zip files
38 | uses: actions/upload-artifact@v4
39 | with:
40 | name: zip-files
41 | path: |
42 | dist/*.zip
43 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist/
12 | dist-zip/
13 | stats.html
14 | stats-*.json
15 | .wxt
16 | web-ext.config.ts
17 | *.local
18 | .swc
19 |
20 | # Editor directories and files
21 | .vscode/*
22 | !.vscode/extensions.json
23 | .idea
24 | .DS_Store
25 | *.suo
26 | *.ntvs*
27 | *.njsproj
28 | *.sln
29 | *.sw?
30 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## 4.7.0
4 |
5 | - FIX The "Load more" button is invisible in dark mode
6 | - ADD Introduce basic page routing, allow the use of the browser's forward and backward buttons to switch content, and retaining the previous content when refreshing or reopening the page
7 | - FIX Prevent multiple page openings to avoid data conflicts ([#160](https://github.com/Harry-Chen/Learn-Helper/issues/160))
8 | - FIX The dropdown symbol is not aligned when the course name is too long ([#165](https://github.com/Harry-Chen/Learn-Helper/issues/165))
9 | - FIX Escape issue with the display of homeworks content ([#167](https://github.com/Harry-Chen/Learn-Helper/issues/167))
10 | - FIX The "Submission content" of homeworks is always displayed
11 | - ADD Use [WXT](https://wxt.dev/) as the development framework
12 | - ADD Upgrade thu-learn-lib to v4.1.0 and add support for the following:
13 | - Deadline for late submission, completion types and submission types of homeworks
14 | - Expiration time of notifications
15 | - File category (currently only supports displaying the category of files. Filtering files by category will be implemented in future versions)
16 |
17 | ## 4.6.2
18 |
19 | - FIX empty course time and location ([#145](https://github.com/Harry-Chen/Learn-Helper/issues/145))
20 | - FIX only inject csrf token in extension tab
21 | - ADD sort homework by submission status ([#148](https://github.com/Harry-Chen/Learn-Helper/issues/148))
22 |
23 | ## 4.6.1
24 |
25 | - Change i18n backend to [Lingui](https://lingui.dev/) and support language switching ([#134](https://github.com/Harry-Chen/Learn-Helper/issues/134))
26 | - FIX csrf injection ([#135](https://github.com/Harry-Chen/Learn-Helper/issues/135))
27 | - Try to FIX PKU course compatibility ([#139](https://github.com/Harry-Chen/Learn-Helper/issues/139))
28 | - FIX open in new window ([#140](https://github.com/Harry-Chen/Learn-Helper/issues/140))
29 | - FIX card list scroll height reset after open item ([AsakuraMizu/Learn-Helper#2](https://github.com/AsakuraMizu/Learn-Helper/issues/2))
30 | - FIX card filter not reset after switching semester ([AsakuraMizu/Learn-Helper#3](https://github.com/AsakuraMizu/Learn-Helper/issues/3))
31 | - Remove fontawesome and use [unplugin-icons](https://github.com/unplugin/unplugin-icons) to render icons, reduce package size
32 |
33 | ## v4.6.0
34 |
35 | (Contributed by @AsakuraMizu as part of OSPP'2023 project)
36 |
37 | - REFACTOR React components as functional component and Redux logic with Redux Toolkit, change build toolchain from Webpack to Vite
38 | - MIGRATE to Manifest V3
39 | - ADD dark mode and switcher
40 | - ADD i18n and partial English translations Due to technical restrictions language can only follow browser config
41 | - Upgrade thu-learn-lib to v3.1.0
42 | - FIX snackbar conflict
43 |
44 | ## v4.5.1
45 |
46 | - Upgrade to latest dependencies (TypeScript 4.6.1, MUI 5.5.2)
47 |
48 | ## v4.5.0
49 |
50 | Note: User data (except configuration) will be **automatically cleared** after upgrade to `v4.5.0` from any previous version due to breaking changes of `thu-learn-lib`.
51 |
52 | - FIX problem that notifications might be (occasionally) changed to incorrect publish time (Harry-Chen/thu-learn-lib#36)
53 | - ADD preview frame to homework and notifications (if applicable)
54 | - ADD login status check before triggering bulk download to avoid garbage artifacts
55 | - Upgrade to latest dependencies
56 |
57 | ## v4.4.3
58 |
59 | - FIX problem when opening notification or submitting homework from detail panel (#103)
60 | - FIX problem when certain course has disabled some functionalities of Web Learning (#104)
61 |
62 | ## v4.4.2
63 |
64 | - FIX login error (update to `thu-learn-helper` 2.5.0 to support CSRF token)
65 |
66 | ## v4.4.1
67 |
68 | - FIX possible missing current semester in semester choice dialog
69 | - Upgrade dependencies to mitigate security issues
70 |
71 | ## v4.4.0
72 |
73 | - FIX error when reading data from disabled functionalities (#90)
74 | - ADD more version info on welcome page
75 | - Deploy self-host version of FireFox Addons (see https://harrychen.xyz/learn/)
76 | - Upgrade to TypeScript v4.1, Webpack v5.15, React v17, Material-UI v4.11
77 |
78 | ## v4.3.1
79 |
80 | - FIX login error when not saving credentials
81 | - FIX error on first use after upgrade
82 | - ADD refresh button on error page to avoid unnecessary data cleaning
83 | - FIX iframe same-origin problem (probably a Chrome bug) (by Starrah)
84 | - FIX alignment of icons on content cards (bt Starrah)
85 |
86 | ## v4.3.0
87 |
88 | - ADD dialog to change current semester
89 | - ADD link for file preview in detail page (workaround for a Chrome bug)
90 | - FIX sorting function of cards, showing undue homework in deadline order
91 |
92 | ## v4.2.1
93 |
94 | - ADD preview window to the detail page of files
95 | - REMOVE permission of learn2018.tsinghua.edu.cn
96 | - Rewrite welcome page using modern CSS
97 |
98 | ## v4.2.0
99 |
100 | - ADD switch to `preact` and adjust bundle configuration to reduce code size (but also increase the minimal browser version)
101 | - ADD more information in error recovery page
102 | - FIX crash after dropping a course (again)
103 |
104 | ## v4.1.7
105 |
106 | - FIX wrong UI logic when login failed
107 | - ADD more specific error recovery message
108 |
109 | ## v4.1.6
110 |
111 | - FIX inability to login after login failed
112 | - FIX crash triggered by wrong badge counting
113 |
114 | ## v4.1.5
115 |
116 | - FIX scrolling problem of sidebar
117 |
118 | ## v4.1.4
119 |
120 | - FIX crash after dropping courses
121 | - FIX wrong order of undue homework in card list
122 | - FIX ignored homework counting in badges
123 |
124 | ## v4.1.3
125 |
126 | - FIX wrong logic in version migration which clears data at each start
127 |
128 | ## v4.1.2
129 |
130 | - FIX error when loading content from disabled modules of courses
131 | - ADD general handler to clear all data when fatal error occurs
132 |
133 | ## v4.1.1
134 |
135 | - FIX wrong encoding of notifications (caused by `thu-learn-lib`)
136 |
137 | ## v4.1.0
138 |
139 | - ADD conversion to non-numerical grades (A+/B/C/...)
140 | - ADD ignoring of single item, refine the logic of course module hiding
141 | - ADD bulk downloading of unread files
142 | - ADD detailed time in detail pane
143 | - FIX state storing problem in FireFox
144 | - FIX homework not marked as unread after being graded
145 | - FIX garbled text shown in detail pane when notification has empty body
146 | - FIX wrong notice when certain module of course is disabled
147 |
148 | ## v4.0.3
149 |
150 | - FIX bugs in `ContentDetail` and `ContentIgnoreSettings`
151 | - Avoid opening too many instances when clicking on extension icon
152 | - Switch to WebExtension API for file downloading
153 | - Release Firefox version!
154 |
155 | ## v4.0.2
156 |
157 | - FIX whitespace warping problem in detail page
158 | - ADD timeout judgement for login process
159 | - ADD detail page for files
160 |
161 | ## v4.0.1
162 |
163 | - FIX url error in attachment of notification
164 | - FIX too wide detail pane on narrow-screen devices
165 |
166 | ## v4.0.0
167 |
168 | - Rewrite use React (with Material-UI) + Redux
169 | - ADD support for learn2018
170 | - REMOVE support for all other versions
171 | - ADD chrome badge to remind unread message count
172 | - ADD card title filter
173 |
174 | ## v3.3.1
175 |
176 | - FIX discusstion reply bug
177 |
178 | ## v3.3.0
179 |
180 | - ADD discusstion collection
181 | - FIX icon error
182 | - rebuild project, using grunt build completely
183 |
184 | ## v3.2.3
185 |
186 | - FIX display items repeatly when refresh
187 |
188 | ## v3.2.2
189 |
190 | - add installation checking code
191 |
192 | ## v3.2.0
193 |
194 | - hide term-model when first time open
195 | - move to Chrome Web Store
196 |
197 | ## v3.1.0
198 |
199 | - FIX clear div when clearing data
200 | - automaticaly switch to new term
201 |
202 | ## v3.0.1
203 |
204 | - css fix
205 |
206 | ## v3.0.0
207 |
208 | - move all logic to `background.iced`
209 | - auto login after long time no-operation
210 | - do not refresh data when reopen in 5min
211 | - add off-line mode for homework and announcement by adding a cache
212 |
213 | ## v2.2.1
214 |
215 | - bug fixed
216 |
217 | ## v2.2.0
218 |
219 | - FIX wrong href target
220 | - ignore unfinished homework that exceed the time limit
221 |
222 | ## v2.1.0
223 |
224 | - ADD file collection function
225 | - add function to update database smoothly
226 | - ADD changelog page
227 | - FIX bug of displaying course name #4
228 | - FIX unread message number
229 |
230 | ## v2.0.1
231 |
232 | - FIX refresh button bug
233 | - FIX security problem of saved password
234 | - show version info in index page
235 |
236 | ## v2.0.0
237 |
238 | - evernote-like UI
239 | - collect Homework and Announcement
240 |
--------------------------------------------------------------------------------
/CHANGELOG_ZH.md:
--------------------------------------------------------------------------------
1 | # 更新记录
2 |
3 | ## 4.7.0
4 |
5 | - [FIX] 暗色模式下“加载更多”按钮不可见
6 | - [ADD] 初步实现页面路由化,允许使用浏览器的前进后退功能切换内容,刷新或重新打开页面时保留之前的内容
7 | - [FIX] 禁止多开页面以避免出现数据冲突 ([#160](https://github.com/Harry-Chen/Learn-Helper/issues/160))
8 | - [FIX] 课程名过长时下拉符号显示位置未对齐 ([#165](https://github.com/Harry-Chen/Learn-Helper/issues/165))
9 | - [FIX] 作业内容显示转义问题 ([#167](https://github.com/Harry-Chen/Learn-Helper/issues/167))
10 | - [FIX] 作业“提交内容”总是被显示
11 | - [ADD] 使用 [WXT](https://wxt.dev/) 作为开发框架
12 | - [ADD] 升级 thu-learn-lib v4.1.0,并新增以下内容的支持:
13 | - 作业补交截止时间、作业完成方式、作业提交方式
14 | - 通知过期时间
15 | - 文件分类(目前仅支持显示文件所属分类,按分类查看等功能请期待后续版本)
16 |
17 | ## 4.6.2
18 |
19 | - [FIX] 修复课程时间地点为空时无法获取课程信息 ([#145](https://github.com/Harry-Chen/Learn-Helper/issues/145))
20 | - [FIX] 只在插件页面注入 csrf 以免影响网络学堂正常使用
21 | - [ADD] 作业列表优先显示未完成作业 ([#148](https://github.com/Harry-Chen/Learn-Helper/issues/148))
22 |
23 | ## 4.6.1
24 |
25 | - [FIX] 更换 i18n 后端,支持切换语言 ([#134](https://github.com/Harry-Chen/Learn-Helper/issues/134))
26 | - [FIX] csrf 注入失败导致无法提交作业等问题 ([#135](https://github.com/Harry-Chen/Learn-Helper/issues/135))
27 | - [FIX] 尝试修复北大课程兼容 ([#139](https://github.com/Harry-Chen/Learn-Helper/issues/139))
28 | - [FIX] 无法正确在新窗口打开 ([#140](https://github.com/Harry-Chen/Learn-Helper/issues/140))
29 | - [FIX] 打开项目后滚动重置 ([AsakuraMizu/Learn-Helper#2](https://github.com/AsakuraMizu/Learn-Helper/issues/2))
30 | - [FIX] 切换学期后过滤未重置 ([AsakuraMizu/Learn-Helper#3](https://github.com/AsakuraMizu/Learn-Helper/issues/3))
31 | - [FIX] 移除 fontawesome,使用 [unplugin-icons](https://github.com/unplugin/unplugin-icons) 渲染图标,减小包体积
32 |
33 | ## 4.6.0
34 |
35 | (由 @AsakuraMizu 作为 2023 年 OSPP 项目贡献)
36 |
37 | - [REFACTOR] 切换工具链为 Vite,重构了 React 组件和 Redux 逻辑,增加项目可维护性
38 | - [ADD] 全面迁移为 Manifest V3,支持 Chrome / Firefox
39 | - [ADD] 添加暗色模式,支持跟随系统或手动设置
40 | - [ADD] 国际化支持,添加了英文翻译(部分,待校对) 由于技术限制,语言跟随浏览器,暂不可切换
41 | - [ADD] 升级 thu-learn-lib v3.1.0
42 | - [FIX] snackbar 有多条消息时只显示最后一条
43 |
44 | ## 4.5.1
45 |
46 | - [ADD] 升级到 TypeScript v4.3.6,MUI v5.5.2(界面风格略有变化)
47 |
48 | ## 4.5.0
49 |
50 | - [FIX] 升级到最新版本依赖。由于 thu-learn-lib 的改动, **此版本升级将会自动清空数据** ,敬请理解
51 | - [FIX] 修复公告发布时间在刷新后可能错误变化的问题
52 | - [ADD] 为公告和作业添加文件预览(#109)
53 | - [ADD] 在开始批量下载前检查登录状态,防止获得大量垃圾文件
54 |
55 | ## 4.4.3
56 |
57 | - [FIX] 修复无法打开公告页面、无法提交作业的问题(#103),感谢喵喵!
58 | - [FIX] 修复课程关闭部分功能时无法获取数据的问题(#104)
59 |
60 | ## 4.4.2
61 |
62 | - [FIX] 紧急修复无法登录的问题(升级到最新版 thu-learn-lib 以支持网络学堂的 CSRF token)
63 |
64 | ## 4.4.1
65 |
66 | - [FIX] 修复切换学期对话框中可能没有网络学堂当前学期的问题(#92)
67 | - [ADD] 升级依赖以增强安全性
68 |
69 | ## 4.4.0
70 |
71 | - [FIX] 修复读取课程关闭的功能时发生的错误(#90)
72 | - [ADD] 在欢迎页面添加更多版本信息
73 | - [ADD] 由于 FireFox Addons 下架,部署私有版本(见 [此页面](https://harrychen.xyz/learn/) )
74 | - [ADD] 升级到 TypeScript v4.1, Webpack v5.15, React v17, Material-UI v4.11
75 |
76 | ## 4.3.1
77 |
78 | - [FIX] 修复不选择保存密码无法正常使用的问题
79 | - [FIX] 修复版本升级后首次启动时报错的问题
80 | - [ADD] 在错误页面增加刷新提示,避免在非必要情况下清除数据
81 | - [FIX] 绕过(由于 Chrome bug 导致的)嵌入的网络学堂页面无法正常打开的问题(by Starrah)
82 | - [FIX] 修复作业图标不对齐的问题(by Starrah)
83 |
84 | ## 4.3.0
85 |
86 | - [ADD] 添加切换学期功能
87 | - [ADD] 添加文件预览查看链接(绕过 Chrome bug)
88 | - [FIX] 对于尚未到期的作业,按照 deadline 从早到晚排序
89 |
90 | ## 4.2.1
91 |
92 | - [ADD] 在文件详情中添加预览(仅限于支持的格式)
93 | - [DEL] 移除对 learn2018.tsinghua.edu.cn 的权限要求
94 | - [FIX] 使用现代 HTML 重写欢迎页面
95 |
96 | ## 4.2.0
97 |
98 | - [ADD] 切换到 preact,减少代码体积,提升最低 Chrome 版本
99 | - [ADD] 在错误恢复页面添加更多信息
100 | - [FIX] 修复另一个退课后崩溃的问题
101 |
102 | ## 4.1.7
103 |
104 | - [FIX] 修复登录失败后的 UI 逻辑
105 | - [ADD] 在错误恢复页面中增加更多信息
106 |
107 | ## 4.1.6
108 |
109 | - [FIX] 修复登录失败后登录对话框状态问题
110 | - [FIX] 修复侧边栏计数导致的插件崩溃问题
111 |
112 | ## 4.1.5
113 |
114 | - [FIX] 修复侧边栏无法独立滚动问题
115 |
116 | ## 4.1.4
117 |
118 | - [FIX] 修复退课导致插件崩溃的问题
119 | - [FIX] 修复未截止作业排序混乱的问题
120 | - [FIX] 不再将被忽略的作业计入统计数量中
121 |
122 | ## 4.1.3
123 |
124 | - [FIX] 修复导致每次启动时数据被清除的错误的版本迁移逻辑
125 |
126 | ## 4.1.2
127 |
128 | - [FIX] 修复课程模块被关闭时加载失败的问题
129 | - [FIX] 修复取消切换学期导致插件错误的问题
130 | - [ADD] 增加致命错误恢复功能
131 |
132 | ## 4.1.1
133 |
134 | - [FIX] 紧急修复课程公告编码错误的问题
135 |
136 | ## 4.1.0
137 |
138 | - [ADD] 增加非百分制的作业成绩显示
139 | - [ADD] 允许忽略单个任意项目,完善隐藏课程模块逻辑
140 | - [ADD] 增加未读文件批量下载功能
141 | - [ADD] 在详情面板中显示详细时间
142 | - [FIX] 修复在 FireFox 中无法存储状态的问题
143 | - [FIX] 在作业被评阅后标记为未读,在 DDL 前将未完成作业置顶显示
144 | - [FIX] 修复公告内容为空时显示乱码的问题
145 | - [FIX] 修复错误提示,增加在老师关闭课程模块时的提醒
146 |
147 | ## 4.0.3
148 |
149 | - [FIX] 修复点击图标重复打开页面的问题
150 | - [FIX] 修复打开详情页面崩溃的问题
151 | - [FIX] 修复退课后管理忽略项崩溃的问题
152 | - [FIX] 修复 FireFox 中文件下载问题
153 |
154 | ## 4.0.2
155 |
156 | - [ADD] 为文件添加详情页面(by gjz010)
157 | - [ADD] 添加登录超时判断,减少等待时间
158 | - [FIX] 修复详情页面中的空格问题
159 |
160 | ## 4.0.1
161 |
162 | - [FIX] 修复公告中附件下载链接错误问题(by zhaoxh16)
163 | - [FIX] 修复过窄屏幕上详情面板宽度问题
164 |
165 | ## 4.0.0
166 |
167 | (为 2018 版网络学堂完全重写 by Harry Chen)
168 |
169 | - [ADD] 使用 React 进行前后端分离
170 | - [ADD] 界面部分 Material 化
171 | - [ADD] 增加课程答疑模块
172 | - [ADD] 增加 Chrome 插件徽章显示未读数量
173 | - [ADD] 增加卡片标签过滤功能
174 |
175 | ## 3.5.3
176 |
177 | - [ADD] 可以折叠侧边栏以适应低分辨率屏幕(by yaoht)
178 |
179 | ## 3.5.2
180 |
181 | - [FIX] 新版网络学堂登陆问题(by yaoht)
182 |
183 | ## 3.5.0
184 |
185 | - [ADD] 初步支持新版网络学堂(by moreD)
186 |
187 | ## 3.4.0
188 |
189 | - [FIX] 移除新版学堂课程,保证旧版可以正常使用
190 |
191 | ## 3.3.1
192 |
193 | - [FIX] 讨论区无法回复
194 |
195 | ## 3.3.0
196 |
197 | - [ADD] 添加讨论区提醒功能
198 | - [FIX] 功能板块图标错误
199 | - [FIX] 开发目录结构重构
200 |
201 | ## 3.2.3
202 |
203 | - [FIX] 修复强制刷新后重复显示 item
204 |
205 | ## 3.2.2
206 |
207 | - [ADD] 迁移到 Chrome 商店,添加安装检测代码
208 |
209 | ## 3.2
210 |
211 | - [FIX] 修复首次打开时显示学期切换窗口
212 | - [FIX] 简化前后台交互及弹出方式
213 | - [ADD] 整体迁移至 Chrome Web Store,升级地址修改
214 |
215 | ## 3.1
216 |
217 | - [FIX] 修复清空数据时不清空课程列表问题
218 | - [ADD] 自动判断学期切换
219 |
220 | ## 3.0.1
221 |
222 | - [FIX] 修复网络学堂官方修复标签所带来的副作用,使标签背景有效
223 |
224 | ## 3.0
225 |
226 | - [ADD] 增加对公告页面的格式修正,保证换行能正常显示
227 | - [ADD] 逻辑前后台拆分,提高性能
228 | - [FIX] 若干点击之后的显示更新问题
229 | - [ADD] 长时间未使用后,自动登录功能(无需刷新)
230 | - [ADD] 5 分钟内重复进入系统,不进行刷新(减轻服务器压力)
231 | - [ADD] 通知、作业的离线缓存功能
232 |
233 | ## 2.2.1
234 |
235 | - [FIX] 合并函数时遗留的作业已读无效 bug
236 |
237 | ## 2.2
238 |
239 | - [FIX] 查看批阅打开 Target 错误
240 | - [FIX] 简化显示逻辑,合并函数
241 | - [FIX] 未完成作业算上已过期错误
242 | - [ADD] 增加作业计数上传
243 |
244 | ## 2.1
245 |
246 | - [ADD] 查找课程文件功能
247 | - [FIX] 添加数据库升级函数
248 | - [ADD] Changelog 页面
249 | - [FIX] 含括号课程名显示 bug, ISSUE 4
250 | - [FIX] 未读信息数量显示 bug
251 |
252 | ## 2.0.1
253 |
254 | - [FIX] 修复了更换用户后「刷新」无用,必须「强制刷新」然后再「刷新」方能生效的问题
255 | - [FIX] 修复了 localStroage 的安全性问题
256 | - [ADD] Version 信息
257 |
258 | ## 2.0
259 |
260 | - 新版本推出
261 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2012-2021 Shengqi Chen, Xinran Xu, Brian Li
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Learn Helper
2 |
3 | [](https://github.com/Harry-Chen/Learn-Helper/actions)
4 | [](https://github.com/Harry-Chen/Learn-Helper)
5 | [](https://chrome.google.com/webstore/detail/learn-helper/mdehapphdlihjjgkhmoiknmnhcjpjall)
6 | 
7 | 
8 |
9 | A browser extension for [Web Learning](https://learn.tsinghua.edu.cn) of Tsinghua University.
10 |
11 | ## Authors & Maintainers
12 |
13 | * [AsakuraMizu](https://github.com/AsakuraMizu)
14 | * current maintainer
15 | * developer from v4.6.0
16 | * [Harry-Chen](https://github.com/Harry-Chen)
17 | * current maintainer
18 | * developer of v4.0.0 - v4.5.1
19 | * [xxr3376](https://github.com/xxr3376)
20 | * original author
21 | * developer till v3.3.1
22 |
23 | ## Install
24 |
25 | [Chrome Store](https://chrome.google.com/webstore/detail/learn-helper/mdehapphdlihjjgkhmoiknmnhcjpjall), [Self-hosted Firefox Add-on](https://harrychen.xyz/learn/), [Edge Addons](https://microsoftedge.microsoft.com/addons/detail/dhddjfhadejlhiaafnbadhaeichbkgil)
26 |
27 | Or you can install the unpacked version from releases.
28 |
29 | ## License
30 |
31 | This project is licensed under the terms of MIT License from version 4.0.3 __EXCLUDING any of the following conditions__:
32 |
33 | * You are working / have worked for *Informatization Office* or *Information Technology Center* of Tsinghua University.
34 | * Your project is funded or supported in any way by an affiliate of Tsinghua University or any other institution associated with Tsinghua University.
35 |
36 | If any of these criteria is met, any use of code, without explicit authorization from the authors, from this project will be considered as infringement of copyright. The word ‘use’ may refer to making copies of, modifying, redistributing of the source code or derivatives (such as browser extension) of this project, whether or not for commercial use. However you can still install and run the browser extension released by the author without being constrained by this exception.
37 |
38 | ## 版权说明
39 |
40 | 本项目从 4.0.3 版本起,依照 MIT License 开源,但 __不包含下列任意情况__:
41 |
42 | * 您过去或者目前为清华大学信息化工作办公室或信息化技术中心工作;
43 | * 您的项目受到清华大学的下属机构或其他任何与清华大学有关的机构的任何形式的资助或支持。
44 |
45 | 如果上述任意条件成立,任何未经授权的对本项目中代码的使用将会被认为是侵权。上文中的“使用”包括对项目的源代码或衍生品(如浏览器插件)制作拷贝、修改、重分发,无论是否用作商业用途。但安装并运行作者发布的浏览器插件的行为不受此例外约束。
46 |
47 | ## Features
48 |
49 | * provide a Evernote-like materialized UI
50 | * collect all data of Web Learning
51 | * Homework
52 | * Notification
53 | * File
54 | * Discussion
55 | * Question
56 | * provide new message reminder and highlight messages
57 | * provide a `Priority Inbox` like Gmail
58 | * provide a off-line mode, cache all message that you have already read
59 |
60 | ## Build
61 |
62 | Learn Helper is built using `yarn`:
63 |
64 | ```bash
65 | yarn --frozen-lockfile
66 | yarn build:chrome # for chrome build
67 | yarn build:firefox # for firefox build
68 | ```
69 |
70 | The compiled Chrome/Firefox plugin is in `dist/` (unpacked).
71 |
72 | ## Development
73 |
74 | ```bash
75 | yarn dev
76 | # in another terminal window
77 | yarn serve:chrome
78 | ```
79 |
80 | Due to technical restrictions, dev mode works only for chrome.
81 |
82 | ### Auto login
83 |
84 | Copy `.env.development` to `.env.development.local` and fill in your username & password to login automatically on start. (development mode only)
85 |
86 | ## Revision History
87 |
88 | See [CHANGELOG.md](https://github.com/Harry-Chen/Learn-Helper/blob/master/CHANGELOG.md).
89 |
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/1.9.2/schema.json",
3 | "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
4 | "files": { "ignoreUnknown": false, "ignore": [] },
5 | "formatter": {
6 | "enabled": true,
7 | "useEditorconfig": true,
8 | "formatWithErrors": false,
9 | "indentStyle": "space",
10 | "lineEnding": "lf",
11 | "lineWidth": 100
12 | },
13 | "organizeImports": { "enabled": true },
14 | "linter": {
15 | "enabled": true,
16 | "rules": {
17 | "style": {
18 | "noNonNullAssertion": "off"
19 | },
20 | "suspicious": {
21 | "noEmptyBlock": "off"
22 | }
23 | }
24 | },
25 | "javascript": {
26 | "formatter": {
27 | "trailingCommas": "all",
28 | "quoteStyle": "single"
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/design/img/512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Harry-Chen/Learn-Helper/4d538afad1fbb13de0b2b94380b845e11a9bb5aa/design/img/512.png
--------------------------------------------------------------------------------
/design/img/bg.ai:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Harry-Chen/Learn-Helper/4d538afad1fbb13de0b2b94380b845e11a9bb5aa/design/img/bg.ai
--------------------------------------------------------------------------------
/design/img/bg.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
24 |
25 |
26 |
27 |
28 |
31 |
34 |
36 |
38 |
39 |
42 |
44 |
47 |
50 |
52 |
53 |
54 | 致力于帮助你及时了解网络学堂的信息
55 |
56 |
57 |
58 |
59 |
60 |
--------------------------------------------------------------------------------
/entrypoints/about.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | 关于我们
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/entrypoints/background.ts:
--------------------------------------------------------------------------------
1 | const open = async () => {
2 | const url = browser.runtime.getURL('/index.html');
3 | const tabs = await browser.tabs.query({ url });
4 | if (tabs.length) {
5 | await browser.tabs.update(tabs[0].id, { active: true });
6 | } else {
7 | await browser.tabs.create({ url });
8 | }
9 | };
10 |
11 | export default defineBackground({
12 | type: 'module',
13 | main() {
14 | if (!browser.action.onClicked.hasListener(open)) {
15 | browser.action.onClicked.addListener(open);
16 | }
17 | },
18 | });
19 |
--------------------------------------------------------------------------------
/entrypoints/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Learn Helper
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/lingui.config.ts:
--------------------------------------------------------------------------------
1 | import type { LinguiConfig } from '@lingui/conf';
2 |
3 | const config: LinguiConfig = {
4 | locales: ['en', 'zh'],
5 | sourceLocale: 'zh',
6 | orderBy: 'origin',
7 | catalogs: [
8 | {
9 | path: '/src/locales/{locale}',
10 | include: ['src'],
11 | },
12 | ],
13 | };
14 |
15 | export default config;
16 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "learn-helper",
3 | "version": "4.7.0.1",
4 | "author": "Harry Chen",
5 | "license": "MIT",
6 | "private": true,
7 | "type": "module",
8 | "repository": {
9 | "type": "git",
10 | "url": "https://github.com/Harry-Chen/Learn-Helper"
11 | },
12 | "scripts": {
13 | "dev": "wxt",
14 | "build:chrome": "wxt build",
15 | "build:firefox": "wxt build -b firefox",
16 | "zip:chrome": "wxt zip",
17 | "zip:firefox": "wxt zip -b firefox",
18 | "clean": "wxt clean",
19 | "postinstall": "wxt prepare",
20 | "check": "biome check",
21 | "lingui:extract": "lingui extract"
22 | },
23 | "devDependencies": {
24 | "@biomejs/biome": "^1.9.4",
25 | "@lingui/cli": "^5.2.0",
26 | "@lingui/swc-plugin": "5.4.0",
27 | "@lingui/vite-plugin": "^5.2.0",
28 | "@mdx-js/rollup": "^3.1.0",
29 | "@svgr/core": "^8.1.0",
30 | "@svgr/plugin-jsx": "^8.1.0",
31 | "@types/chrome": "^0.0.309",
32 | "@types/mdx": "^2.0.13",
33 | "@types/node": "^22.13.10",
34 | "@types/randomstring": "^1.3.0",
35 | "@types/react": "^19.0.11",
36 | "@types/react-dom": "^19.0.4",
37 | "@types/redux-logger": "^3.0.13",
38 | "@vitejs/plugin-react-swc": "^3.8.0",
39 | "randomstring": "^1.3.1",
40 | "rehype-unwrap-images": "^1.0.0",
41 | "rollup-preserve-directives": "^1.1.3",
42 | "terser": "^5.39.0",
43 | "typescript": "^5.8.2",
44 | "unplugin-icons": "^22.1.0",
45 | "vite-plugin-chunk-split": "^0.5.0",
46 | "wxt": "^0.19.29"
47 | },
48 | "dependencies": {
49 | "@iconify-json/fa6-solid": "^1.2.3",
50 | "@lingui/core": "^5.2.0",
51 | "@lingui/message-utils": "^5.2.0",
52 | "@lingui/react": "^5.2.0",
53 | "@mui/material": "^6.4.8",
54 | "@reduxjs/toolkit": "^2.6.1",
55 | "classnames": "^2.5.1",
56 | "compare-versions": "^6.1.1",
57 | "fake-parse5": "^0.0.1",
58 | "material-ui-popup-state": "^5.3.3",
59 | "notistack": "^3.0.2",
60 | "proxy-memoize": "^3.0.1",
61 | "react": "^19.0.0",
62 | "react-dom": "^19.0.0",
63 | "react-error-boundary": "^5.0.0",
64 | "react-iframe": "^1.8.5",
65 | "react-redux": "^9.2.0",
66 | "redux-logger": "^3.0.6",
67 | "rehype-mdx-import-media": "^1.2.0",
68 | "thu-learn-lib": "^4.1.0",
69 | "wouter": "^3.6.0"
70 | },
71 | "pnpm": {
72 | "onlyBuiltDependencies": [
73 | "@biomejs/biome",
74 | "@swc/core",
75 | "dtrace-provider",
76 | "esbuild",
77 | "spawn-sync"
78 | ]
79 | },
80 | "packageManager": "pnpm@10.6.4+sha512.da3d715bfd22a9a105e6e8088cfc7826699332ded60c423b14ec613a185f1602206702ff0fe4c438cb15c979081ce4cb02568e364b15174503a63c7a8e2a5f6c"
81 | }
82 |
--------------------------------------------------------------------------------
/public/_locales/en_US/messages.json:
--------------------------------------------------------------------------------
1 | {
2 | "appDesc": {
3 | "message": "Helper of Web Learning of Tsinghua University, showing course notices, homeworks, deadlines and other information in one page, providing one-stop service"
4 | },
5 | "appName": {
6 | "message": "Learn Helper"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/public/_locales/zh_CN/messages.json:
--------------------------------------------------------------------------------
1 | {
2 | "appDesc": {
3 | "message": "清华大学网络学堂助手,集中展示课程公告、作业、DDL等信息,提供一站式服务"
4 | },
5 | "appName": {
6 | "message": "Learn Helper"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/public/icons/128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Harry-Chen/Learn-Helper/4d538afad1fbb13de0b2b94380b845e11a9bb5aa/public/icons/128.png
--------------------------------------------------------------------------------
/public/icons/16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Harry-Chen/Learn-Helper/4d538afad1fbb13de0b2b94380b845e11a9bb5aa/public/icons/16.png
--------------------------------------------------------------------------------
/public/icons/19.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Harry-Chen/Learn-Helper/4d538afad1fbb13de0b2b94380b845e11a9bb5aa/public/icons/19.png
--------------------------------------------------------------------------------
/public/icons/48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Harry-Chen/Learn-Helper/4d538afad1fbb13de0b2b94380b845e11a9bb5aa/public/icons/48.png
--------------------------------------------------------------------------------
/src/about.tsx:
--------------------------------------------------------------------------------
1 | import { i18n } from '@lingui/core';
2 | import { I18nProvider } from '@lingui/react';
3 | import { CssBaseline, ThemeProvider } from '@mui/material';
4 | import classNames from 'classnames';
5 | import { createRoot } from 'react-dom/client';
6 |
7 | import { theme } from './theme';
8 | import './i18n';
9 |
10 | import styles from './css/doc.module.css';
11 | import About from './pages/doc/about';
12 |
13 | const root = createRoot(document.querySelector('#main')!);
14 | root.render(
15 |
16 |
17 |
18 |
19 |
22 |
23 |
24 | ,
25 | );
26 |
--------------------------------------------------------------------------------
/src/components/CardList.tsx:
--------------------------------------------------------------------------------
1 | import { Trans } from '@lingui/react/macro';
2 | import { Button, List, ListSubheader } from '@mui/material';
3 | import cn from 'classnames';
4 | import { memoize } from 'proxy-memoize';
5 | import { useEffect, useRef, useState } from 'react';
6 | import { ContentType } from 'thu-learn-lib';
7 |
8 | import { downloadAllUnreadFiles, loadMoreCard } from '../redux/actions';
9 | import { useAppDispatch, useAppSelector } from '../redux/hooks';
10 |
11 | import styles from '../css/list.module.css';
12 | import ContentCard from './ContentCard';
13 |
14 | const CardList = () => {
15 | const dispatch = useAppDispatch();
16 | const threshold = useAppSelector((state) => state.ui.cardVisibilityThreshold);
17 | const originalCardList = useAppSelector((state) => state.ui.cardList);
18 |
19 | const [_onTop, setOnTop] = useState(true);
20 | const scrollRef = useRef(null);
21 |
22 | useEffect(() => {
23 | void originalCardList;
24 | if (scrollRef.current) {
25 | scrollRef.current.scrollTop = 0;
26 | setOnTop(true);
27 | }
28 | }, [originalCardList]);
29 |
30 | const cards = useAppSelector(
31 | memoize((state) =>
32 | state.helper.loggedIn
33 | ? originalCardList
34 | .map(({ type, id }) => state.data[`${type}Map`][id])
35 | .filter(
36 | (c) =>
37 | !!c &&
38 | c.title
39 | .toLocaleLowerCase()
40 | .includes(state.ui.titleFilter?.toLocaleLowerCase() ?? ''),
41 | )
42 | : [],
43 | ),
44 | );
45 | const filtered = cards.slice(0, threshold);
46 | const unreadFileCount = cards.reduce((count, c) => {
47 | if (c.type === ContentType.FILE && !c.hasRead) return count + 1;
48 | return count;
49 | }, 0);
50 | const canLoadMore = threshold < cards.length;
51 |
52 | return (
53 | {
56 | const self = ev.target as HTMLDivElement;
57 | setOnTop(self.scrollTop === 0);
58 |
59 | if (!canLoadMore) return;
60 | const bottomLine = self.scrollTop + self.clientHeight;
61 | if (bottomLine + 180 > self.scrollHeight) {
62 | // 80 px on load more hint
63 | dispatch(loadMoreCard());
64 | }
65 | }}
66 | ref={scrollRef}
67 | >
68 |
76 | {unreadFileCount !== 0 && (
77 | {
81 | dispatch(downloadAllUnreadFiles(cards));
82 | }}
83 | >
84 | 下载所有未读文件(共 {unreadFileCount?.toString()} 个)
85 |
86 | )}
87 |
88 | }
89 | >
90 | {filtered.map((c) => (
91 |
92 | ))}
93 |
94 | {filtered.length === 0 && (
95 |
96 | 这里什么也没有
97 |
98 | )}
99 |
100 | {canLoadMore && (
101 | dispatch(loadMoreCard())}
104 | onKeyDown={(e) => e.key === 'Enter' && dispatch(loadMoreCard())}
105 | >
106 | 加载更多
107 |
108 | )}
109 |
110 |
111 | );
112 | };
113 |
114 | export default CardList;
115 |
--------------------------------------------------------------------------------
/src/components/ContentCard.tsx:
--------------------------------------------------------------------------------
1 | import { t } from '@lingui/core/macro';
2 | import { useLingui } from '@lingui/react';
3 | import classnames from 'classnames';
4 | import { ContentType } from 'thu-learn-lib';
5 | import { useLocation } from 'wouter';
6 |
7 | import {
8 | Avatar,
9 | Badge,
10 | Card,
11 | CardActionArea,
12 | CardActions,
13 | CardContent,
14 | Chip,
15 | IconButton,
16 | Tooltip,
17 | } from '@mui/material';
18 |
19 | import IconCheck from '~icons/fa6-solid/check';
20 | import IconClipboard from '~icons/fa6-solid/clipboard';
21 | import IconClipboardCheck from '~icons/fa6-solid/clipboard-check';
22 | import IconDownload from '~icons/fa6-solid/download';
23 | import IconPaperclip from '~icons/fa6-solid/paperclip';
24 | import IconStar from '~icons/fa6-solid/star';
25 | import IconTrash from '~icons/fa6-solid/trash';
26 | import IconTrashCan from '~icons/fa6-solid/trash-can';
27 | import IconUpload from '~icons/fa6-solid/upload';
28 |
29 | import { COURSE_MAIN_FUNC } from '../constants/ui';
30 | import styles from '../css/card.module.css';
31 | import { toggleIgnoreState, toggleReadState, toggleStarState } from '../redux/actions';
32 | import { useAppDispatch, useAppSelector } from '../redux/hooks';
33 | import { initiateFileDownload } from '../utils/download';
34 | import { formatDate, formatHomeworkGradeLevel } from '../utils/format';
35 |
36 | interface ContentCardProps {
37 | type: ContentType;
38 | id: string;
39 | }
40 |
41 | const ContentCard = ({ type, id }: ContentCardProps) => {
42 | const { _ } = useLingui();
43 | const dispatch = useAppDispatch();
44 | const [_location, navigate] = useLocation();
45 |
46 | const content = useAppSelector((state) => state.data[`${type}Map`][id]);
47 |
48 | const onTitleClick = () => {
49 | switch (content.type) {
50 | // show details in DetailPane
51 | case ContentType.FILE:
52 | case ContentType.NOTIFICATION:
53 | case ContentType.HOMEWORK:
54 | navigate(`/content/${content.type}/${content.id}`);
55 | break;
56 | // navigate iframe in DetailPane to given url
57 | case ContentType.DISCUSSION:
58 | case ContentType.QUESTION:
59 | navigate(`/web/${encodeURIComponent(content.url)}`);
60 | break;
61 | }
62 | // mark card as read
63 | dispatch(toggleReadState({ type: content.type, id: content.id, state: true }));
64 | };
65 |
66 | const diffDays = Math.floor((content.date.getTime() - new Date().getTime()) / (1000 * 3600 * 24));
67 |
68 | return (
69 |
70 |
71 |
72 |
73 |
74 |
77 | {content.type === ContentType.HOMEWORK && content.submitted ? (
78 |
79 | ) : (
80 | COURSE_MAIN_FUNC[content.type].icon
81 | )}
82 |
83 | }
84 | label={
85 |
86 | {content.type === ContentType.HOMEWORK
87 | ? diffDays > 99
88 | ? '99+'
89 | : diffDays < 0
90 | ? _(COURSE_MAIN_FUNC[content.type].name)
91 | : diffDays.toString()
92 | : _(COURSE_MAIN_FUNC[content.type].name)}
93 |
94 | }
95 | className={classnames(
96 | styles[
97 | content.type === ContentType.HOMEWORK
98 | ? `chip_homework_${
99 | diffDays < 0
100 | ? 'due'
101 | : content.submitted
102 | ? 'submitted'
103 | : diffDays >= 10
104 | ? 'far'
105 | : diffDays >= 5
106 | ? 'near'
107 | : diffDays >= 3
108 | ? 'close'
109 | : 'urgent'
110 | }`
111 | : `chip_${content.type}`
112 | ],
113 | styles.card_func_chip,
114 | )}
115 | />
116 |
117 |
{content.title}
118 |
119 |
120 |
121 |
122 | {formatDate(content.date)}
123 | {content.type === ContentType.HOMEWORK
124 | ? ` · ${content.submitted ? t`已提交` : t`未提交`} · ${
125 | content.graded
126 | ? (
127 | content.grade
128 | ? content.gradeLevel
129 | ? _(formatHomeworkGradeLevel(content.gradeLevel))
130 | : t`${content.grade}分`
131 | : t`无评分`
132 | ) + t`(${content.graderName ?? ''})`
133 | : t`未批阅`
134 | }`
135 | : content.type === ContentType.NOTIFICATION || content.type === ContentType.FILE
136 | ? (content.markedImportant ? ` · ${t`重要`}` : '') +
137 | (content.type === ContentType.NOTIFICATION
138 | ? ` · ${t`发布者:${content.publisher}`}`
139 | : ` · ${content.size}${
140 | content.description.trim() !== ''
141 | ? ` · ${content.description.trim()}`
142 | : ''
143 | }`)
144 | : content.type === ContentType.DISCUSSION || content.type === ContentType.QUESTION
145 | ? ` · ${t`回复:${content.replyCount}`}${
146 | content.replyCount !== 0
147 | ? ` · ${t`最后回复:${content.lastReplierName}`}`
148 | : ''
149 | }`
150 | : null}
151 |
152 | {_({ id: `course-${content.courseId}` })}
153 |
154 |
155 |
156 |
157 | {
164 | dispatch(
165 | toggleStarState({ type: content.type, id: content.id, state: !content.starred }),
166 | );
167 | ev.stopPropagation();
168 | }}
169 | onMouseDown={(ev) => ev.stopPropagation()}
170 | size="small"
171 | >
172 |
173 |
174 |
175 |
176 | {
181 | dispatch(
182 | toggleReadState({ type: content.type, id: content.id, state: !content.hasRead }),
183 | );
184 | ev.stopPropagation();
185 | }}
186 | onMouseDown={(ev) => ev.stopPropagation()}
187 | size="small"
188 | >
189 | {content.hasRead ? : }
190 |
191 |
192 |
193 | {
198 | dispatch(
199 | toggleIgnoreState({
200 | type: content.type,
201 | id: content.id,
202 | state: !content.ignored,
203 | }),
204 | );
205 | ev.stopPropagation();
206 | }}
207 | onMouseDown={(ev) => ev.stopPropagation()}
208 | size="small"
209 | >
210 | {content.ignored ? : }
211 |
212 |
213 | {content.type === ContentType.HOMEWORK && (
214 |
215 | {
220 | navigate(`/web/${encodeURIComponent(content.submitUrl)}`);
221 | ev.stopPropagation();
222 | }}
223 | onMouseDown={(ev) => ev.stopPropagation()}
224 | size="small"
225 | >
226 |
227 |
228 |
229 | )}
230 | {content.type === ContentType.FILE && (
231 |
232 | {
237 | initiateFileDownload(content.remoteFile.downloadUrl);
238 | }}
239 | size="small"
240 | >
241 |
242 |
243 |
244 | )}
245 | {(content.type === ContentType.NOTIFICATION || content.type === ContentType.HOMEWORK) &&
246 | content.attachment && (
247 |
248 | {
253 | if (content.attachment)
254 | initiateFileDownload(content.attachment.downloadUrl, content.attachment.name);
255 | }}
256 | size="small"
257 | >
258 |
259 |
260 |
261 | )}
262 |
263 |
264 |
265 | );
266 | };
267 |
268 | export default ContentCard;
269 |
--------------------------------------------------------------------------------
/src/components/ContentDetail.tsx:
--------------------------------------------------------------------------------
1 | import type { MessageDescriptor } from '@lingui/core';
2 | import { msg, t } from '@lingui/core/macro';
3 | import { useLingui } from '@lingui/react';
4 | import { Trans } from '@lingui/react/macro';
5 | import { Button, Paper } from '@mui/material';
6 | import { type ReactNode, useState } from 'react';
7 | import { ContentType, type RemoteFile } from 'thu-learn-lib';
8 | import { useLocation } from 'wouter';
9 |
10 | import styles from '../css/page.module.css';
11 | import type { ContentInfo, FileInfo, HomeworkInfo, NotificationInfo } from '../types/data';
12 | import {
13 | formatDateTime,
14 | formatHomeworkCompletionType,
15 | formatHomeworkGradeLevel,
16 | formatHomeworkSubmissionType,
17 | html2text,
18 | } from '../utils/format';
19 | import IframeWrapper from './IframeWrapper';
20 |
21 | // const initialState = {
22 | // frameUrl: undefined as string | undefined,
23 | // loadPreview: false,
24 | // };
25 |
26 | interface LineProps {
27 | title: MessageDescriptor;
28 | children?: ReactNode;
29 | __html?: string;
30 | }
31 |
32 | const Line = ({ title, children, __html }: LineProps) => {
33 | const { _ } = useLingui();
34 |
35 | return (
36 |
37 | {_(title)}
38 | {/* biome-ignore lint/security/noDangerouslySetInnerHtml: no reason */}
39 | {__html ? : {children} }
40 |
41 | );
42 | };
43 |
44 | interface LinkProps {
45 | url: string;
46 | inApp?: boolean;
47 | children: ReactNode;
48 | }
49 |
50 | const ContentLink = ({ url, inApp, children }: LinkProps) => {
51 | const [_location, navigate] = useLocation();
52 |
53 | if (inApp)
54 | return (
55 | // don't use here as we want to keep the behaviour of middle click to open in new tab
56 | {
59 | navigate(`/web/${encodeURIComponent(url)}`);
60 | ev.preventDefault();
61 | }}
62 | >
63 | {children}
64 |
65 | );
66 |
67 | return (
68 |
69 | {children}
70 |
71 | );
72 | };
73 |
74 | const canFilePreview = (file: RemoteFile): boolean => {
75 | // black list for files that could not be previewed
76 | const BLACK_LIST = ['.exe', '.zip', '.rar', '.7z'];
77 | return !BLACK_LIST.some((suffix) => file.name.endsWith(suffix));
78 | };
79 |
80 | interface FileLinkProps {
81 | downloadTitle: MessageDescriptor;
82 | previewTitle: MessageDescriptor;
83 | file: RemoteFile;
84 | }
85 |
86 | const FileLinks = ({ downloadTitle, previewTitle, file }: FileLinkProps) => (
87 | <>
88 |
89 |
90 |
91 | {file.name}({file.size})
92 |
93 |
94 |
95 | {canFilePreview(file) && (
96 |
97 |
98 | 在新窗口打开
99 |
100 |
101 | )}
102 | >
103 | );
104 |
105 | const translateFileType = (type: string): string =>
106 | // TODO: Translate file type to human-readable representation.
107 | type;
108 |
109 | interface ContentDetailProps {
110 | content: T;
111 | }
112 |
113 | const FileDetails = ({ content: file }: ContentDetailProps) => (
114 | <>
115 | {formatDateTime(file.uploadTime)}
116 | {file.visitCount}
117 | {file.downloadCount}
118 | {file.size}
119 | {translateFileType(file.type)}
120 | {file.category && {file.category.title} }
121 |
126 | >
127 | );
128 |
129 | const HomeworkDetails = ({ content: homework }: ContentDetailProps) => {
130 | const { _ } = useLingui();
131 |
132 | const submittedContent = html2text(homework.submittedContent ?? '');
133 | const answerContent = html2text(homework.answerContent ?? '');
134 |
135 | return (
136 | <>
137 | {formatDateTime(homework.deadline)}
138 | {homework.lateSubmissionDeadline && (
139 | {formatDateTime(homework.lateSubmissionDeadline)}
140 | )}
141 |
142 | {_(formatHomeworkCompletionType(homework.completionType))}
143 |
144 |
145 | {_(formatHomeworkSubmissionType(homework.submissionType))}
146 |
147 | {homework.submitted && (
148 |
149 | {formatDateTime(homework.submitTime)}
150 |
151 | )}
152 | {submittedContent && {submittedContent} }
153 | {homework.submittedAttachment && (
154 |
159 | )}
160 | {homework.graded && (
161 | <>
162 | {formatDateTime(homework.gradeTime)}
163 | {homework.graderName}
164 |
165 | {homework.gradeLevel
166 | ? _(formatHomeworkGradeLevel(homework.gradeLevel))
167 | : (homework.grade ?? 无评分 )}
168 |
169 |
170 | {homework.gradeAttachment && (
171 |
176 | )}
177 | >
178 | )}
179 | {answerContent && {answerContent} }
180 | {homework.answerAttachment && (
181 |
186 | )}
187 | {homework.attachment && (
188 |
193 | )}
194 |
195 |
196 | 在本窗口打开
197 |
198 |
199 | >
200 | );
201 | };
202 |
203 | const NotificationDetails = ({ content: notification }: ContentDetailProps) => (
204 | <>
205 | {formatDateTime(notification.publishTime)}
206 | {notification.expireTime && (
207 | {formatDateTime(notification.expireTime)}
208 | )}
209 | {notification.publisher}
210 |
211 | {notification.markedImportant ? 高 : 普通 }
212 |
213 | {notification.attachment && (
214 |
219 | )}
220 | >
221 | );
222 |
223 | const ContentDetail = ({ content }: ContentDetailProps) => {
224 | const { _ } = useLingui();
225 |
226 | const contentDetail =
227 | (content.type === ContentType.HOMEWORK
228 | ? content.description
229 | : content.type === ContentType.FILE
230 | ? content.description
231 | : content.type === ContentType.NOTIFICATION
232 | ? content.content
233 | : undefined
234 | )?.trim() || t`详情为空`;
235 |
236 | const fileToPreview: RemoteFile | undefined =
237 | (content as NotificationInfo | HomeworkInfo).attachment ?? (content as FileInfo).remoteFile;
238 | const showPreviewFrame = fileToPreview && canFilePreview(fileToPreview);
239 |
240 | const [preview, setPreview] = useState(content.type === ContentType.FILE);
241 |
242 | return (
243 |
244 | {content.title}
245 |
246 |
247 |
248 | {_({ id: `course-${content.courseId}` })}
249 | {content.type === ContentType.FILE && }
250 | {content.type === ContentType.HOMEWORK && }
251 | {content.type === ContentType.NOTIFICATION && }
252 |
253 |
254 |
255 |
260 | {showPreviewFrame && !preview && (
261 | setPreview(true)}>
262 | 加载预览({fileToPreview!.size})
263 |
264 | )}
265 | {showPreviewFrame && preview && (
266 |
267 | )}
268 |
269 | );
270 | };
271 |
272 | export default ContentDetail;
273 |
--------------------------------------------------------------------------------
/src/components/CourseList.tsx:
--------------------------------------------------------------------------------
1 | import { useLingui } from '@lingui/react';
2 | import { Trans } from '@lingui/react/macro';
3 | import { useState } from 'react';
4 | import { useLocation } from 'wouter';
5 |
6 | import {
7 | Collapse,
8 | List,
9 | ListItemButton,
10 | ListItemIcon,
11 | ListItemText,
12 | ListSubheader,
13 | } from '@mui/material';
14 |
15 | import IconAngleDown from '~icons/fa6-solid/angle-down';
16 | import IconAngleUp from '~icons/fa6-solid/angle-up';
17 | import IconBook from '~icons/fa6-solid/book';
18 | import IconInbox from '~icons/fa6-solid/inbox';
19 |
20 | import { COURSE_FUNC_LIST } from '../constants/ui';
21 | import { refreshCardList, setCardFilter } from '../redux/actions';
22 | import { useAppDispatch, useAppSelector } from '../redux/hooks';
23 | import { selectCourseList } from '../redux/selectors';
24 |
25 | import styles from '../css/list.module.css';
26 |
27 | const CourseList = () => {
28 | const { _ } = useLingui();
29 | const [_location, navigate] = useLocation();
30 | const dispatch = useAppDispatch();
31 | const courseList = useAppSelector(selectCourseList);
32 |
33 | const [opened, setOpened] = useState();
34 |
35 | return (
36 |
41 |
42 |
43 | 本学期课程
44 |
45 |
46 | }
47 | >
48 | {courseList.length === 0 ? (
49 |
50 | 这里什么也没有,快去选点课吧!
51 |
52 | ) : (
53 | courseList.map((c) => (
54 |
55 | {
58 | setOpened(opened === c.id ? undefined : c.id);
59 | }}
60 | >
61 |
62 |
63 |
64 |
68 |
69 | {opened === c.id ? : }
70 |
71 |
72 |
73 |
74 | {COURSE_FUNC_LIST.map((func) => (
75 | {
79 | if (func.type !== 'homepage') {
80 | // show cards
81 | dispatch(setCardFilter({ type: func.type, courseId: c.id }));
82 | dispatch(refreshCardList());
83 | } else {
84 | navigate(`/web/${encodeURIComponent(c.url)}`);
85 | }
86 | }}
87 | >
88 | {func.icon}
89 |
90 |
91 | ))}
92 |
93 |
94 |
95 | ))
96 | )}
97 |
98 | );
99 | };
100 |
101 | export default CourseList;
102 |
--------------------------------------------------------------------------------
/src/components/IframeWrapper.tsx:
--------------------------------------------------------------------------------
1 | import { Fragment } from 'react';
2 | import Iframe from 'react-iframe';
3 |
4 | interface IframeWrapperProps {
5 | id?: string;
6 | className?: string;
7 | url: string;
8 | }
9 |
10 | const IframeWrapper = ({ id, className, url }: IframeWrapperProps) => {
11 | return (
12 |
13 |
27 |
28 | );
29 | };
30 |
31 | export default IframeWrapper;
32 |
--------------------------------------------------------------------------------
/src/components/SettingList.tsx:
--------------------------------------------------------------------------------
1 | import { useLingui } from '@lingui/react';
2 | import { Trans } from '@lingui/react/macro';
3 | import { useLocation } from 'wouter';
4 |
5 | import { List, ListItemButton, ListItemIcon, ListItemText, ListSubheader } from '@mui/material';
6 |
7 | import IconWrench from '~icons/fa6-solid/wrench';
8 |
9 | import { SETTINGS_FUNC_LIST } from '../constants/ui';
10 | import { useAppDispatch } from '../redux/hooks';
11 |
12 | import styles from '../css/list.module.css';
13 |
14 | const SettingList = () => {
15 | const { _ } = useLingui();
16 | const [_location, navigate] = useLocation();
17 | const dispatch = useAppDispatch();
18 |
19 | return (
20 |
25 |
26 |
27 | 设置
28 |
29 |
30 | }
31 | >
32 | {SETTINGS_FUNC_LIST.map((i) => (
33 | {
37 | i.handler?.(dispatch, navigate);
38 | }}
39 | >
40 | {i.icon}
41 |
42 |
43 | ))}
44 |
45 | );
46 | };
47 |
48 | export default SettingList;
49 |
--------------------------------------------------------------------------------
/src/components/SummaryList.tsx:
--------------------------------------------------------------------------------
1 | import { useLingui } from '@lingui/react';
2 | import { Trans } from '@lingui/react/macro';
3 | import { useEffect, useMemo } from 'react';
4 |
5 | import {
6 | Badge,
7 | List,
8 | ListItemButton,
9 | ListItemIcon,
10 | ListItemText,
11 | ListSubheader,
12 | } from '@mui/material';
13 |
14 | import IconThumbtack from '~icons/fa6-solid/thumbtack';
15 |
16 | import { SUMMARY_FUNC_LIST } from '../constants/ui';
17 | import { refreshCardList, setCardFilter } from '../redux/actions';
18 | import { useAppDispatch, useAppSelector } from '../redux/hooks';
19 |
20 | import styles from '../css/list.module.css';
21 | import { selectUnreadMap } from '../redux/selectors';
22 |
23 | const SummaryList = () => {
24 | const { _ } = useLingui();
25 | const dispatch = useAppDispatch();
26 |
27 | const unreadMap = useAppSelector(selectUnreadMap);
28 | const unreadTotal = useMemo(
29 | () => Object.values(unreadMap).reduce((total, c) => total + c, 0),
30 | [unreadMap],
31 | );
32 | useEffect(
33 | () =>
34 | void browser.action.setBadgeText({ text: unreadTotal === 0 ? '' : unreadTotal.toString() }),
35 | [unreadTotal],
36 | );
37 |
38 | return (
39 |
44 |
45 |
46 | 项目汇总
47 |
48 |
49 | }
50 | >
51 | {SUMMARY_FUNC_LIST.map((func) => (
52 | {
56 | dispatch(setCardFilter({ type: func.type }));
57 | dispatch(refreshCardList());
58 | }}
59 | >
60 | {func.icon}
61 |
66 |
67 |
68 |
69 | ))}
70 |
71 | );
72 | };
73 |
74 | export default SummaryList;
75 |
--------------------------------------------------------------------------------
/src/components/dialogs/ChangeSemesterDialog.tsx:
--------------------------------------------------------------------------------
1 | import { Trans } from '@lingui/react/macro';
2 | import { useState } from 'react';
3 |
4 | import {
5 | Button,
6 | Dialog,
7 | DialogActions,
8 | DialogContent,
9 | DialogTitle,
10 | FormControl,
11 | InputLabel,
12 | MenuItem,
13 | Select,
14 | } from '@mui/material';
15 |
16 | import {
17 | insistSemester,
18 | refresh,
19 | setCardFilter,
20 | toggleChangeSemesterDialog,
21 | toggleIgnoreWrongSemester,
22 | updateSemester,
23 | } from '../../redux/actions';
24 | import { useAppDispatch, useAppSelector } from '../../redux/hooks';
25 | import { selectSemesters } from '../../redux/selectors';
26 | import { formatSemesterId, semesterFromId } from '../../utils/format';
27 |
28 | import styles from '../../css/main.module.css';
29 |
30 | const ChangeSemesterDialog = () => {
31 | const dispatch = useAppDispatch();
32 |
33 | const open = useAppSelector((state) => state.ui.showChangeSemesterDialog);
34 | const semester = useAppSelector((state) => state.data.semester?.id ?? '');
35 | const currentWebSemester = useAppSelector((state) => state.data.fetchedSemester.id);
36 | const semesters = useAppSelector(selectSemesters);
37 |
38 | const [newSemester, setNewSemester] = useState(
39 | semesters.includes(semester) ? semester : (semesters[0] ?? ''),
40 | );
41 |
42 | return (
43 |
44 |
45 | 切换学期
46 |
47 |
48 |
49 |
50 | 切换学期将导致所有配置(隐藏)和状态(已读、星标)丢失,请三思!
51 |
52 | 当前 Learn Helper 学期:{formatSemesterId(semester)}
53 |
54 | 当前网络学堂学期(注册中心控制):{formatSemesterId(currentWebSemester)}
55 |
56 |
57 |
58 |
59 | 选择学期
60 |
61 | setNewSemester(e.target.value)}
65 | >
66 | {semesters.map((s) => (
67 |
68 | {formatSemesterId(s)}
69 |
70 | ))}
71 |
72 |
73 |
74 |
75 |
76 | {
79 | if (newSemester !== semester) {
80 | dispatch(updateSemester(semesterFromId(newSemester)));
81 | dispatch(insistSemester(false));
82 | dispatch(toggleIgnoreWrongSemester(true));
83 | dispatch(setCardFilter({}));
84 | dispatch(refresh());
85 | }
86 | dispatch(toggleChangeSemesterDialog(false));
87 | }}
88 | >
89 | 确定
90 |
91 | dispatch(toggleChangeSemesterDialog(false))}>
92 | 取消
93 |
94 |
95 |
96 | );
97 | };
98 |
99 | export default ChangeSemesterDialog;
100 |
--------------------------------------------------------------------------------
/src/components/dialogs/ClearDataDialog.tsx:
--------------------------------------------------------------------------------
1 | import { Trans } from '@lingui/react/macro';
2 |
3 | import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material';
4 |
5 | import { clearAllData, refresh, toggleClearDataDialog } from '../../redux/actions';
6 | import { useAppDispatch, useAppSelector } from '../../redux/hooks';
7 |
8 | const ClearDataDialog = () => {
9 | const dispatch = useAppDispatch();
10 |
11 | const open = useAppSelector((state) => state.ui.showClearDataDialog);
12 |
13 | return (
14 |
15 |
16 | 清除所有缓存
17 |
18 |
19 | 确认要清除吗?所有缓存的数据和已读状态将会被清除。
20 |
21 |
22 | {
25 | dispatch(toggleClearDataDialog(false));
26 | dispatch(clearAllData());
27 | dispatch(refresh());
28 | }}
29 | >
30 | 是
31 |
32 | dispatch(toggleClearDataDialog(false))}>
33 | 否
34 |
35 |
36 |
37 | );
38 | };
39 |
40 | export default ClearDataDialog;
41 |
--------------------------------------------------------------------------------
/src/components/dialogs/LoginDialog.tsx:
--------------------------------------------------------------------------------
1 | import { t } from '@lingui/core/macro';
2 | import { Trans } from '@lingui/react/macro';
3 | import { useState } from 'react';
4 |
5 | import {
6 | Button,
7 | Checkbox,
8 | CircularProgress,
9 | Dialog,
10 | DialogActions,
11 | DialogContent,
12 | DialogContentText,
13 | DialogTitle,
14 | FormControlLabel,
15 | TextField,
16 | } from '@mui/material';
17 |
18 | import { login, refresh, toggleLoginDialog } from '../../redux/actions';
19 | import { useAppDispatch, useAppSelector } from '../../redux/hooks';
20 | import { requestPermission } from '../../utils/permission';
21 |
22 | const LoginDialog = () => {
23 | const dispatch = useAppDispatch();
24 |
25 | const open = useAppSelector((state) => state.ui.showLoginDialog);
26 | const inLoginProgress = useAppSelector((state) => state.ui.inLoginProgress);
27 |
28 | const [username, setUsername] = useState('');
29 | const [password, setPassword] = useState('');
30 | const [save, setSave] = useState(false);
31 |
32 | return (
33 |
34 |
35 | 登录网络学堂
36 |
37 |
38 |
39 |
40 | 请输入您的学号/用户名和密码以登录到网络学堂。
41 |
42 | 请注意,本插件默认不会保存您的凭据;每次打开新的学堂助手页面时,您都需要重新输入。
43 | 如果您选择保存凭据,则本插件会将其 保存在本地 ,并启用自动登录功能。
44 |
45 | 我们对凭据进行了简单的加密,但并不能完全防止其被第三方读取。
46 | 在长时间不使用或者出借计算机时,请务必退出登录,以免您的凭据被泄露。
47 |
48 | 如果您选择登录,则视为您已经阅读并同意
49 |
50 | 此页面
51 |
52 | 中的所有内容。否则,请立刻停止使用并从浏览器中卸载本插件。
53 |
54 |
55 |
56 |
57 | setUsername(e.target.value)}
66 | />
67 | setPassword(e.target.value)}
76 | />
77 | setSave(e.target.checked)} />}
79 | label={t`保存凭据以自动登录`}
80 | />
81 |
82 |
83 | {inLoginProgress && }
84 | {
88 | try {
89 | await requestPermission();
90 | await dispatch(login(username, password, save));
91 | await dispatch(refresh());
92 | } catch (e) {
93 | // here we catch only login problems
94 | // for refresh() has a try-catch block in itself
95 | console.error(e);
96 | dispatch(toggleLoginDialog(true));
97 | }
98 | }}
99 | type="submit"
100 | >
101 | 确定
102 |
103 |
104 |
105 | );
106 | };
107 |
108 | export default LoginDialog;
109 |
--------------------------------------------------------------------------------
/src/components/dialogs/LogoutDialog.tsx:
--------------------------------------------------------------------------------
1 | import { Trans } from '@lingui/react/macro';
2 | import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material';
3 |
4 | import {
5 | clearAllData,
6 | loggedOut,
7 | refreshCardList,
8 | setCardFilter,
9 | toggleLoginDialog,
10 | toggleLogoutDialog,
11 | } from '../../redux/actions';
12 | import { useAppDispatch, useAppSelector } from '../../redux/hooks';
13 | import { removeStoredCredential } from '../../utils/storage';
14 |
15 | const LogoutDialog = () => {
16 | const dispatch = useAppDispatch();
17 |
18 | const open = useAppSelector((state) => state.ui.showLogoutDialog);
19 |
20 | return (
21 |
22 |
23 | 退出登录
24 |
25 |
26 | 您确认要退出登录吗?如果只是更换登录密码,请不要选择清除数据。
27 |
28 |
29 | {
32 | await removeStoredCredential();
33 | dispatch(toggleLogoutDialog(false));
34 | dispatch(loggedOut());
35 | dispatch(toggleLoginDialog(true));
36 | }}
37 | >
38 | 退出
39 |
40 | {
43 | await removeStoredCredential();
44 | dispatch(toggleLogoutDialog(false));
45 | dispatch(loggedOut());
46 | dispatch(clearAllData());
47 | dispatch(setCardFilter({}));
48 | dispatch(refreshCardList());
49 | dispatch(toggleLoginDialog(true));
50 | }}
51 | >
52 | 退出并清除数据
53 |
54 | dispatch(toggleLogoutDialog(false))}>
55 | 取消
56 |
57 |
58 |
59 | );
60 | };
61 |
62 | export default LogoutDialog;
63 |
--------------------------------------------------------------------------------
/src/components/dialogs/NetworkErrorDialog.tsx:
--------------------------------------------------------------------------------
1 | import { Trans } from '@lingui/react/macro';
2 | import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material';
3 |
4 | import {
5 | loggedIn,
6 | refresh,
7 | toggleLoginDialog,
8 | toggleNetworkErrorDialog,
9 | } from '../../redux/actions';
10 | import { useAppDispatch, useAppSelector } from '../../redux/hooks';
11 | import { requestPermission } from '../../utils/permission';
12 |
13 | const NetworkErrorDialog = () => {
14 | const dispatch = useAppDispatch();
15 |
16 | const open = useAppSelector((state) => state.ui.showNetworkErrorDialog);
17 |
18 | return (
19 |
20 |
21 | 刷新课程信息失败
22 |
23 |
24 |
25 | 可能原因有:
26 | · 网络不太给力
27 | · 服务器去思考人生了
28 | · 保存的用户凭据不正确(最近修改过密码?)
29 |
30 | 您可以选择重试、放弃刷新,或者更换新的凭据。
31 |
32 |
33 |
34 | {
37 | await requestPermission();
38 | dispatch(toggleNetworkErrorDialog(false));
39 | dispatch(refresh());
40 | }}
41 | >
42 | 重试刷新
43 |
44 | {
47 | dispatch(toggleNetworkErrorDialog(false));
48 | dispatch(loggedIn());
49 | }}
50 | >
51 | 离线查看
52 |
53 | {
56 | dispatch(toggleNetworkErrorDialog(false));
57 | dispatch(toggleLoginDialog(true));
58 | }}
59 | >
60 | 更新凭据
61 |
62 |
63 |
64 | );
65 | };
66 |
67 | export default NetworkErrorDialog;
68 |
--------------------------------------------------------------------------------
/src/components/dialogs/NewSemesterDialog.tsx:
--------------------------------------------------------------------------------
1 | import { Trans } from '@lingui/react/macro';
2 | import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material';
3 |
4 | import {
5 | insistSemester,
6 | refresh,
7 | setCardFilter,
8 | syncSemester,
9 | toggleIgnoreWrongSemester,
10 | toggleNewSemesterDialog,
11 | } from '../../redux/actions';
12 | import { useAppDispatch, useAppSelector } from '../../redux/hooks';
13 | import { formatSemester } from '../../utils/format';
14 |
15 | const NewSemesterDialog = () => {
16 | const dispatch = useAppDispatch();
17 |
18 | const open = useAppSelector((state) => state.ui.showNewSemesterDialog);
19 | const semester = useAppSelector((state) => state.data.semester);
20 | const fetchedSemester = useAppSelector((state) => state.data.fetchedSemester);
21 |
22 | return (
23 |
24 |
25 | 检测到新学期
26 |
27 |
28 |
29 | 当前 Learn Helper 学期:{formatSemester(semester)}
30 |
31 | 当前网络学堂学期:{formatSemester(fetchedSemester)}
32 |
33 | 是否要进行学期切换(本学期已读、星标等状态将会被清空)?
34 |
35 | 如果选择“否”,则在下一次打开 Learn Helper 前都将保持当前学期。
36 |
37 | 如果选择“不再询问”,则需要手动进行学期切换。
38 |
39 |
40 |
41 | {
44 | dispatch(toggleNewSemesterDialog(false));
45 | dispatch(syncSemester());
46 | dispatch(setCardFilter({}));
47 | dispatch(refresh());
48 | }}
49 | >
50 | 是
51 |
52 | {
55 | dispatch(toggleNewSemesterDialog(false));
56 | dispatch(toggleIgnoreWrongSemester(true));
57 | dispatch(refresh());
58 | }}
59 | >
60 | 否
61 |
62 | {
65 | dispatch(toggleNewSemesterDialog(false));
66 | dispatch(insistSemester(true));
67 | dispatch(refresh());
68 | }}
69 | >
70 | 不再询问
71 |
72 |
73 |
74 | );
75 | };
76 |
77 | export default NewSemesterDialog;
78 |
--------------------------------------------------------------------------------
/src/components/dialogs/index.ts:
--------------------------------------------------------------------------------
1 | export { default as LoginDialog } from './LoginDialog';
2 | export { default as LogoutDialog } from './LogoutDialog';
3 | export { default as NetworkErrorDialog } from './NetworkErrorDialog';
4 | export { default as NewSemesterDialog } from './NewSemesterDialog';
5 | export { default as ClearDataDialog } from './ClearDataDialog';
6 | export { default as ChangeSemesterDialog } from './ChangeSemesterDialog';
7 |
--------------------------------------------------------------------------------
/src/constants/index.ts:
--------------------------------------------------------------------------------
1 | export const STORAGE_KEY_USERNAME = 'username';
2 | export const STORAGE_KEY_PASSWORD = 'password';
3 | export const STORAGE_SALT = 'L3arN_He1per_4.@_lS_G0od!';
4 |
5 | export const STORAGE_KEY_VERSION = 'version';
6 | export const STORAGE_KEY_LANGUAGE = 'language';
7 |
8 | export const STORAGE_KEY_REDUX = 'data_redux';
9 | export const STORAGE_KEY_REDUX_LEGACY = 'persist:data_redux';
10 |
11 | export const CARD_BATCH_LOAD_SIZE = 10;
12 |
--------------------------------------------------------------------------------
/src/constants/ui.tsx:
--------------------------------------------------------------------------------
1 | import { msg } from '@lingui/core/macro';
2 | import { ContentType } from 'thu-learn-lib';
3 |
4 | import IconArrowsRotate from '~icons/fa6-solid/arrows-rotate';
5 | import IconBan from '~icons/fa6-solid/ban';
6 | import IconBullhorn from '~icons/fa6-solid/bullhorn';
7 | import IconChalkboardUser from '~icons/fa6-solid/chalkboard-user';
8 | import IconCircleInfo from '~icons/fa6-solid/circle-info';
9 | import IconDownload from '~icons/fa6-solid/download';
10 | import IconEnvelopeOpen from '~icons/fa6-solid/envelope-open';
11 | import IconHouse from '~icons/fa6-solid/house';
12 | import IconPencil from '~icons/fa6-solid/pencil';
13 | import IconQuestion from '~icons/fa6-solid/question';
14 | import IconShuffle from '~icons/fa6-solid/shuffle';
15 | import IconTrash from '~icons/fa6-solid/trash';
16 | import IconUpRightFromSquare from '~icons/fa6-solid/up-right-from-square';
17 | import IconUser from '~icons/fa6-solid/user';
18 |
19 | import {
20 | markAllRead,
21 | refresh,
22 | toggleChangeSemesterDialog,
23 | toggleClearDataDialog,
24 | toggleLogoutDialog,
25 | } from '../redux/actions';
26 | import type { AppDispatch } from '../redux/store';
27 |
28 | export type TUIFunc = ContentType | 'summary' | 'ignored' | 'homepage';
29 |
30 | export const UI_NAME_SUMMARY = {
31 | summary: msg`主页`,
32 | [ContentType.NOTIFICATION]: msg`所有公告`,
33 | [ContentType.FILE]: msg`所有文件`,
34 | [ContentType.HOMEWORK]: msg`所有作业`,
35 | [ContentType.DISCUSSION]: msg`所有讨论`,
36 | [ContentType.QUESTION]: msg`所有答疑`,
37 | ignored: msg`所有忽略`,
38 | } as const;
39 |
40 | export const UI_NAME_COURSE = {
41 | summary: msg`课程综合`,
42 | [ContentType.NOTIFICATION]: msg`课程公告`,
43 | [ContentType.FILE]: msg`课程文件`,
44 | [ContentType.HOMEWORK]: msg`课程作业`,
45 | [ContentType.DISCUSSION]: msg`课程讨论`,
46 | [ContentType.QUESTION]: msg`课程答疑`,
47 | homepage: msg`课程主页`,
48 | } as const;
49 |
50 | export const COURSE_MAIN_FUNC = {
51 | [ContentType.NOTIFICATION]: {
52 | type: ContentType.NOTIFICATION,
53 | icon: ,
54 | name: msg`公告`,
55 | },
56 | [ContentType.FILE]: {
57 | type: ContentType.FILE,
58 | icon: ,
59 | name: msg`文件`,
60 | },
61 | [ContentType.HOMEWORK]: {
62 | type: ContentType.HOMEWORK,
63 | icon: ,
64 | name: msg`作业`,
65 | },
66 | [ContentType.DISCUSSION]: {
67 | type: ContentType.DISCUSSION,
68 | icon: ,
69 | name: msg`讨论`,
70 | },
71 | [ContentType.QUESTION]: {
72 | type: ContentType.QUESTION,
73 | icon: ,
74 | name: msg`答疑`,
75 | },
76 | } as const;
77 |
78 | export const COURSE_FUNC_LIST = [
79 | {
80 | type: undefined,
81 | icon: ,
82 | name: UI_NAME_COURSE.summary,
83 | },
84 | {
85 | ...COURSE_MAIN_FUNC[ContentType.NOTIFICATION],
86 | name: UI_NAME_COURSE[ContentType.NOTIFICATION],
87 | },
88 | {
89 | ...COURSE_MAIN_FUNC[ContentType.FILE],
90 | name: UI_NAME_COURSE[ContentType.FILE],
91 | },
92 | {
93 | ...COURSE_MAIN_FUNC[ContentType.HOMEWORK],
94 | name: UI_NAME_COURSE[ContentType.HOMEWORK],
95 | },
96 | {
97 | ...COURSE_MAIN_FUNC[ContentType.DISCUSSION],
98 | name: UI_NAME_COURSE[ContentType.DISCUSSION],
99 | },
100 | {
101 | ...COURSE_MAIN_FUNC[ContentType.QUESTION],
102 | name: UI_NAME_COURSE[ContentType.QUESTION],
103 | },
104 | {
105 | type: 'homepage',
106 | icon: ,
107 | name: UI_NAME_COURSE.homepage,
108 | },
109 | ] as const;
110 |
111 | export const SUMMARY_FUNC_LIST = [
112 | {
113 | type: undefined,
114 | icon: ,
115 | name: UI_NAME_SUMMARY.summary,
116 | },
117 | {
118 | ...COURSE_MAIN_FUNC[ContentType.HOMEWORK],
119 | name: UI_NAME_SUMMARY[ContentType.HOMEWORK],
120 | },
121 | {
122 | ...COURSE_MAIN_FUNC[ContentType.NOTIFICATION],
123 | name: UI_NAME_SUMMARY[ContentType.NOTIFICATION],
124 | },
125 | {
126 | ...COURSE_MAIN_FUNC[ContentType.FILE],
127 | name: UI_NAME_SUMMARY[ContentType.FILE],
128 | },
129 | {
130 | ...COURSE_MAIN_FUNC[ContentType.DISCUSSION],
131 | name: UI_NAME_SUMMARY[ContentType.DISCUSSION],
132 | },
133 | {
134 | ...COURSE_MAIN_FUNC[ContentType.QUESTION],
135 | name: UI_NAME_SUMMARY[ContentType.QUESTION],
136 | },
137 | {
138 | type: 'ignored',
139 | icon: ,
140 | name: UI_NAME_SUMMARY.ignored,
141 | },
142 | ] as const;
143 |
144 | export const SETTINGS_FUNC_LIST = [
145 | {
146 | icon: ,
147 | name: msg`管理隐藏项`,
148 | handler: (_dispatch: AppDispatch, navigate: (path: string) => void) => {
149 | navigate('/settings');
150 | },
151 | },
152 | {
153 | icon: ,
154 | name: msg`全部标记已读`,
155 | handler: (dispatch: AppDispatch) => {
156 | dispatch(markAllRead());
157 | },
158 | },
159 | {
160 | icon: ,
161 | name: msg`手动刷新`,
162 | handler: (dispatch: AppDispatch) => {
163 | dispatch(refresh());
164 | },
165 | },
166 | {
167 | icon: ,
168 | name: msg`切换学期`,
169 | handler: (dispatch: AppDispatch) => {
170 | dispatch(toggleChangeSemesterDialog(true));
171 | },
172 | },
173 | {
174 | icon: ,
175 | name: msg`清空缓存`,
176 | handler: (dispatch: AppDispatch) => {
177 | dispatch(toggleClearDataDialog(true));
178 | },
179 | },
180 | {
181 | icon: ,
182 | name: msg`退出登录`,
183 | handler: (dispatch: AppDispatch) => {
184 | dispatch(toggleLogoutDialog(true));
185 | },
186 | },
187 | ] as const;
188 |
--------------------------------------------------------------------------------
/src/css/card.module.css:
--------------------------------------------------------------------------------
1 | .detail_card {
2 | margin: 5px;
3 | }
4 |
5 | .card_func_icon {
6 | width: 32px !important;
7 | height: 32px !important;
8 | background-color: rgba(0, 0, 0, 0.3) !important;
9 | color: inherit !important;
10 | font-size: 1rem !important;
11 | margin-left: 0 !important;
12 | margin-left: 0 !important;
13 | padding: 8px;
14 | }
15 |
16 | .card_first_line {
17 | width: 100%;
18 | display: flex;
19 | align-items: center;
20 | }
21 |
22 | .card_func_chip {
23 | float: left;
24 | }
25 |
26 | .card_chip_text {
27 | min-width: 26px;
28 | text-align: center;
29 | }
30 |
31 | .card_title {
32 | flex: 1;
33 | margin-left: 8px;
34 | overflow: hidden;
35 | white-space: nowrap;
36 | text-overflow: ellipsis;
37 | font-size: larger;
38 | }
39 |
40 | .card_second_line {
41 | margin-top: 8px;
42 | margin-bottom: -8px;
43 | font-size: 1em;
44 | color: #777777;
45 | }
46 |
47 | .card_status {
48 | float: left;
49 | text-align: left;
50 | max-width: 65%;
51 | overflow: hidden;
52 | white-space: nowrap;
53 | text-overflow: ellipsis;
54 | line-height: 1.2em;
55 | }
56 |
57 | .card_course {
58 | float: right;
59 | text-align: right;
60 | max-width: 35%;
61 | overflow: hidden;
62 | white-space: nowrap;
63 | text-overflow: ellipsis;
64 | line-height: 1.2em;
65 | }
66 |
67 | .card_action_line {
68 | margin: -8px 4px -4px 4px !important;
69 | display: inline-block;
70 | width: 100%;
71 | }
72 |
73 | .card_action_button {
74 | font-size: 12px !important;
75 | }
76 |
77 | .card_starred {
78 | color: #eeee00 !important;
79 | }
80 |
81 | .chip_file {
82 | color: #fad1e5 !important;
83 | background-color: #e91981 !important;
84 | text-shadow: 0 -1px 0 #bc1267;
85 | }
86 |
87 | .chip_discussion,
88 | .chip_notification {
89 | color: #e5cdf2 !important;
90 | background-color: #7e08be !important;
91 | text-shadow: 0 -1px 0 #650698;
92 | }
93 |
94 | .chip_homework_far {
95 | color: #d2e7ff !important;
96 | text-shadow: 0 -1px 0 #006be7;
97 | background-color: #28f !important;
98 | }
99 |
100 | .chip_homework_near {
101 | color: #2f2f00 !important;
102 | background-color: #ee0 !important;
103 | text-shadow: 0 1px 0 #ffff25;
104 | }
105 |
106 | .chip_homework_close {
107 | color: #d9eed9 !important;
108 | background-color: #e60 !important;
109 | text-shadow: 0 -1px 0 #be5200;
110 | }
111 |
112 | .chip_homework_urgent {
113 | color: #fbcccc !important;
114 | background-color: #e00 !important;
115 | text-shadow: 0 -1px 0 #be0000;
116 | }
117 |
118 | .chip_homework_submitted {
119 | color: #d9eed9 !important;
120 | background-color: #4a4 !important;
121 | text-shadow: 0 -1px 0 #368836;
122 | }
123 |
124 | .chip_homework_due {
125 | color: #e0e0e0 !important;
126 | background-color: #666 !important;
127 | text-shadow: 0 -1px 0 #525252;
128 | }
129 |
--------------------------------------------------------------------------------
/src/css/doc.module.css:
--------------------------------------------------------------------------------
1 | .wrapper {
2 | flex-grow: 1;
3 | font: 16px / 1.5 Helvetica, "Source Han Sans SC", "Noto Sans CJK SC", "Microsoft Yahei",
4 | "Hiragino Sans GB", sans-serif;
5 | background: url("../image/creampaper.png");
6 | }
7 |
8 | [data-dark] .wrapper {
9 | background: url("../image/creampaper_black.png");
10 | }
11 |
12 | .wrapper a {
13 | color: #28f;
14 | text-decoration: none;
15 | }
16 |
17 | .wrapper a:hover,
18 | .wrapper a:focus {
19 | color: #04f;
20 | }
21 |
22 | .welcome_wrapper {
23 | display: flex;
24 | flex-direction: column;
25 | justify-content: center;
26 | align-items: center;
27 | text-align: center;
28 | }
29 |
30 | .welcome_content {
31 | margin-top: 30vh;
32 | }
33 |
34 | .welcome_banner {
35 | }
36 |
37 | .welcome_banner_light {
38 | display: inherit;
39 | }
40 | [data-dark] .welcome_banner_light {
41 | display: none;
42 | }
43 |
44 | .welcome_banner_dark {
45 | display: none;
46 | }
47 | [data-dark] .welcome_banner_dark {
48 | display: inherit;
49 | }
50 |
51 | .welcome_menu {
52 | margin-top: 5px;
53 | line-height: 30px;
54 | width: 100%;
55 | color: #888;
56 | }
57 |
58 | .welcome_navigation {
59 | background: none;
60 | border: none;
61 | padding: 0;
62 | cursor: pointer;
63 | color: #28f;
64 | text-decoration: none;
65 | font-size: inherit;
66 | }
67 |
68 | .welcome_navigation:hover,
69 | .welcome_navigation:focus {
70 | color: #04f;
71 | }
72 |
73 | .welcome_version {
74 | font-size: 0.9em;
75 | font-family: "JetBrains Mono", "Fira Code Retina", consolas, monospace;
76 | }
77 |
78 | .welcome_packages {
79 | font-size: 0.8em;
80 | font-family: "JetBrains Mono", "Fira Code Retina", consolas, monospace;
81 | }
82 |
83 | .welcome_footer {
84 | flex: 1;
85 | display: flex;
86 | flex-direction: column;
87 | }
88 |
89 | .welcome_copyright {
90 | font-size: 0.8em;
91 | color: #999;
92 | margin-top: auto;
93 | }
94 |
95 | .welcome_copyright a {
96 | text-decoration: underline;
97 | }
98 |
99 | .doc_wrapper {
100 | padding: 30px 0;
101 | }
102 |
103 | .doc {
104 | margin: 0 auto;
105 | max-width: 650px;
106 | min-width: 400px;
107 | background: ghostwhite;
108 | border: 1px solid #999;
109 | border-radius: 3px;
110 | box-shadow: 0px 0px 2px #aaa;
111 | }
112 |
113 | [data-dark] .doc {
114 | background: #070700;
115 | border-color: #666;
116 | box-shadow: 0px 0px 2px #555;
117 | }
118 |
119 | .doc ul {
120 | list-style: square;
121 | }
122 |
123 | .doc > h1 {
124 | margin-bottom: 0;
125 | }
126 |
127 | .doc h1 {
128 | text-align: center;
129 | margin-top: 0;
130 | border-bottom: 1px solid #999;
131 | }
132 |
133 | .doc h1,
134 | .doc h2,
135 | .doc h3,
136 | .doc h4,
137 | .doc h5,
138 | .doc h6 {
139 | font-weight: normal;
140 | }
141 |
142 | .doc_text_block {
143 | color: var(--mui-palette-primary-main);
144 | padding: 10px;
145 | font-weight: bold;
146 | }
147 |
148 | .doc_text_block h2,
149 | .doc_text_block ul {
150 | margin: 5px;
151 | }
152 |
153 | .doc_image_block {
154 | border-top: 1px solid #999;
155 | }
156 |
157 | .doc_image_block:first-child {
158 | border: none;
159 | }
160 |
161 | .doc_image_block h2 {
162 | margin: 0 0 5px 0;
163 | color: #333;
164 | }
165 |
166 | [data-dark] .doc_image_block h2 {
167 | color: #ccc;
168 | }
169 |
170 | .doc_image_block p {
171 | margin: 0;
172 | }
173 |
174 | .doc_image_block img {
175 | height: 90px;
176 | width: 170px;
177 | border: 1px solid #aaa;
178 | border-radius: 3px;
179 | margin: 20px 30px 20px 20px;
180 | float: left;
181 | }
182 |
183 | .doc_image_block:nth-child(even) img {
184 | float: right;
185 | margin-left: 30px;
186 | margin-right: 20px;
187 | }
188 |
189 | .doc_image_block .doc_image_block_text {
190 | padding: 20px 20px 20px 0;
191 | color: #999;
192 | }
193 |
194 | .doc_image_block:nth-child(even) .doc_image_block_text {
195 | padding-right: 0;
196 | padding-left: 20px;
197 | }
198 |
199 | .doc_image_block:before,
200 | .doc_image_block:after {
201 | content: " ";
202 | display: table;
203 | }
204 |
205 | .doc_image_block:after {
206 | clear: both;
207 | }
208 |
209 | .doc_image_block {
210 | zoom: 1;
211 | }
212 |
213 | .doc_back_link {
214 | background: none;
215 | border: none;
216 | border-top: 1px solid #999;
217 | width: 100%;
218 | cursor: pointer;
219 | display: block;
220 | padding: 10px;
221 | text-align: center;
222 | color: #999;
223 | text-decoration: none;
224 | }
225 |
226 | .doc_back_link:hover {
227 | color: #666;
228 | }
229 |
--------------------------------------------------------------------------------
/src/css/list.module.css:
--------------------------------------------------------------------------------
1 | .numbered_list,
2 | .course_list {
3 | width: 100%;
4 | }
5 |
6 | .sidebar_list_item {
7 | padding-top: 8px !important;
8 | padding-bottom: 8px !important;
9 | height: 40px !important;
10 | }
11 |
12 | .sidebar_list_item_arrow {
13 | min-width: 0 !important;
14 | }
15 |
16 | .course_list_item_text,
17 | .settings_list_item_text {
18 | padding-left: 0 !important;
19 | padding-right: 0 !important;
20 | }
21 |
22 | .course_list_item_text > span {
23 | overflow: hidden;
24 | white-space: nowrap;
25 | text-overflow: ellipsis;
26 | }
27 |
28 | .list_title_header {
29 | display: flex;
30 | align-items: center;
31 | }
32 |
33 | .list_title {
34 | margin-left: 10px;
35 | }
36 |
37 | .list_item_icon {
38 | width: 18px;
39 | min-width: 0 !important;
40 | margin-right: 16px !important;
41 | }
42 |
43 | .summary_list_item_text {
44 | margin-right: 16px;
45 | }
46 |
47 | .card_list {
48 | max-height: 100%;
49 | overflow-y: auto;
50 | overflow-x: hidden;
51 | }
52 |
53 | .card_list_inner {
54 | }
55 |
56 | .card_list_header {
57 | width: 100%;
58 | text-align: center;
59 | transition: box-shadow 0.2s ease;
60 | padding-left: 0 !important;
61 | padding-right: 0 !important;
62 | }
63 |
64 | .card_list_header_title {
65 | max-width: 70%;
66 | overflow: hidden;
67 | white-space: nowrap;
68 | text-overflow: ellipsis;
69 | font-size: larger;
70 | }
71 |
72 | .card_list_header_grow {
73 | flex-grow: 1;
74 | }
75 |
76 | .card_list_header_search {
77 | position: relative;
78 | border-radius: 5px;
79 | /* background-color: rgba(255, 255, 255, 0.75); */
80 | margin-left: 10px;
81 | max-width: 40%;
82 | width: auto;
83 | height: 30px;
84 | }
85 |
86 | .card_list_header_search:hover {
87 | /* background-color: rgba(255, 255, 255, 0.85); */
88 | }
89 |
90 | .card_list_header_search_icon {
91 | width: 10px;
92 | height: 100%;
93 | margin-left: 10px;
94 | position: absolute;
95 | pointer-events: none;
96 | display: flex;
97 | align-items: center;
98 | justify-content: center;
99 | }
100 |
101 | .card_list_header_input_root {
102 | color: inherit;
103 | width: auto;
104 | margin-left: 30px;
105 | vertical-align: top;
106 | }
107 |
108 | .card_list_header_floating {
109 | box-shadow: rgba(0, 0, 0, 0.3) 0 2px 3px;
110 | z-index: 100 !important; /* Need to float on top of badges */
111 | }
112 |
113 | .card_list_header_text {
114 | font-size: 20px;
115 | }
116 |
117 | .card_list_load_more {
118 | height: 60px;
119 | font-size: 14px;
120 | display: flex;
121 | align-items: center;
122 | justify-content: center;
123 | opacity: 0.38;
124 | transition: opacity 0.2s ease;
125 | margin-top: 20px;
126 | cursor: pointer;
127 | }
128 |
129 | .card_list_load_more:hover {
130 | opacity: 0.7;
131 | }
132 |
133 | .subfunc_list {
134 | margin-left: 10px !important;
135 | }
136 |
--------------------------------------------------------------------------------
/src/css/main.module.css:
--------------------------------------------------------------------------------
1 | .app_bar_btn {
2 | width: 48px;
3 | height: 48px;
4 | margin-left: -8px !important;
5 | }
6 |
7 | .app_error_section {
8 | display: flex;
9 | flex-direction: column;
10 | align-items: center;
11 | }
12 |
13 | .app_error_text {
14 | /* text-align: center; */
15 | white-space: pre-wrap;
16 | margin: 10px auto !important;
17 | display: block !important;
18 | }
19 |
20 | .sidebar {
21 | }
22 |
23 | .sidebar_wrapper {
24 | display: flex;
25 | flex-direction: column;
26 | width: 670px;
27 | height: 100%; /* Relatives to body */
28 | }
29 |
30 | .sidebar_content {
31 | display: flex;
32 | flex-direction: row;
33 | flex: 1;
34 | overflow-y: hidden;
35 | }
36 |
37 | .sidebar_component {
38 | height: 100%;
39 | overflow: hidden;
40 | overflow-y: auto;
41 | }
42 |
43 | .sidebar_folder {
44 | width: 270px;
45 | }
46 |
47 | .sidebar_cards {
48 | width: 400px;
49 | border-right: 1px solid var(--mui-palette-divider);
50 | border-left: 1px solid var(--mui-palette-divider);
51 | box-sizing: border-box;
52 | background: #f6f6f6;
53 | }
54 |
55 | [data-dark] .sidebar_cards {
56 | background: #0a0a0a;
57 | }
58 |
59 | .sidebar_header {
60 | border-bottom: 1px solid var(--mui-palette-divider);
61 | }
62 |
63 | .sidebar_header_left {
64 | width: 270px;
65 | box-sizing: border-box;
66 | display: inline-flex;
67 | }
68 |
69 | .sidebar_master_title {
70 | margin-left: 8px !important;
71 | margin-right: 8px !important;
72 | }
73 |
74 | .sidebar_master_notify_icon {
75 | position: absolute !important;
76 | right: 16px !important;
77 | color: darkred !important;
78 | }
79 |
80 | .sidebar_header_right {
81 | width: 400px;
82 | box-sizing: border-box;
83 | border-left: 1px solid var(--mui-palette-divider);
84 | display: inline-flex;
85 |
86 | overflow: hidden;
87 | }
88 |
89 | .sidebar_header_content {
90 | display: flex;
91 | border-right: 1px solid var(--mui-palette-divider);
92 | }
93 |
94 | .sidebar_cardlist_name {
95 | flex: 1;
96 | display: block;
97 |
98 | transition: transform 0.2s ease, opacity 0.2s ease;
99 | }
100 |
101 | .sidebar_filter_group {
102 | margin-right: -8px;
103 | width: 48px;
104 | display: flex;
105 | align-items: center;
106 |
107 | transition: transform 0.2s ease;
108 | }
109 |
110 | .sidebar_filter_shown .sidebar_cardlist_name {
111 | transform: translateX(-320px); /* 400 - 24*2 - 48 + 8*2 = 320 */
112 | opacity: 0;
113 | }
114 |
115 | .sidebar_filter_shown .sidebar_filter_group {
116 | transform: translateX(-320px);
117 | }
118 |
119 | .filter_input {
120 | padding-left: 8px;
121 | width: 312px;
122 |
123 | transition: opacity 0.2s ease;
124 | }
125 |
126 | .filter_input_inner {
127 | width: 304px;
128 | }
129 |
130 | .filter_btn {
131 | height: 48px;
132 | width: 48px;
133 | }
134 |
135 | .pane_content {
136 | position: absolute;
137 | display: flex;
138 | flex-direction: column;
139 | top: 0;
140 | left: 671px;
141 | bottom: 0;
142 | right: 0;
143 | overflow-x: auto;
144 | overflow-y: auto;
145 | transition: left 225ms cubic-bezier(0, 0, 0.2, 1) 0ms;
146 | background-color: #eee;
147 | }
148 |
149 | [data-dark] .pane_content {
150 | background-color: #111;
151 | }
152 |
153 | @media (max-width: 1024px) {
154 | .pane_content {
155 | min-width: 350px;
156 | }
157 | }
158 |
159 | .pane_fullscreen {
160 | left: 0;
161 | transition: left 195ms cubic-bezier(0.4, 0, 0.6, 1) 0ms;
162 | }
163 |
164 | .progress_area {
165 | z-index: 1500;
166 | position: absolute;
167 | top: -1px;
168 | left: 0;
169 | width: 100%;
170 | }
171 |
172 | .web_frame_wrapper {
173 | flex-grow: 1;
174 | position: relative;
175 | }
176 |
177 | .web_frame {
178 | position: absolute;
179 | display: block;
180 | height: 100%;
181 | width: 100%;
182 | border-width: 0;
183 | }
184 |
185 | .form_control {
186 | margin: 10px 0 !important;
187 | min-width: 150px !important;
188 | }
189 |
--------------------------------------------------------------------------------
/src/css/page.module.css:
--------------------------------------------------------------------------------
1 | .ignore_setting,
2 | .content_detail {
3 | display: block;
4 | text-align: center;
5 | margin-top: 50px;
6 | }
7 |
8 | .ignore_setting_title {
9 | font-size: 30px;
10 | margin: 10px auto 0;
11 | }
12 |
13 | .ignore_setting_description {
14 | font-size: 15px;
15 | margin: 10px auto;
16 | }
17 |
18 | .ignore_setting_container {
19 | width: fit-content;
20 | margin: 20px auto;
21 | min-width: 700px;
22 | }
23 |
24 | .ignore_setting_reset_button {
25 | margin: 20px 0 0;
26 | }
27 |
28 | .content_detail {
29 | margin-bottom: 40px;
30 | padding: 0 20px;
31 | width: 100%;
32 | }
33 |
34 | .content_detail_title {
35 | font-size: 25px;
36 | margin: 10px auto 0;
37 | }
38 |
39 | .content_detail_lines {
40 | text-align: left;
41 | margin: 10px 80px;
42 | }
43 |
44 | .content_detail_lines a {
45 | color: inherit;
46 | }
47 |
48 | .content_detail_line {
49 | margin: 10px 0;
50 | font-size: 15px;
51 | color: var(--mui-palette-text-secondary);
52 | }
53 |
54 | .content_detail_line > td {
55 | min-width: 80px;
56 | }
57 |
58 | .content_detail_content {
59 | text-align: left;
60 | padding: 10px 20px;
61 | margin: 10px 80px;
62 | }
63 |
64 | .content_detail_preview {
65 | margin: 10px 80px;
66 | border: none;
67 | min-width: 400px;
68 | width: -webkit-fill-available;
69 | width: -moz-available;
70 | min-height: 600px;
71 | height: -webkit-fill-available;
72 | height: -moz-available;
73 | }
74 |
75 | @media (max-width: 1280px) {
76 | .content_detail_title,
77 | .content_detail_lines,
78 | .content_detail_content,
79 | .content_detail_preview {
80 | margin: 10px 40px;
81 | }
82 | .content_detail_title {
83 | font-size: 25px;
84 | }
85 | .content_detail_line {
86 | font-size: 14px;
87 | }
88 | }
89 |
90 | @media (max-width: 1024px) {
91 | .content_detail_title,
92 | .content_detail_lines,
93 | .content_detail_content,
94 | .content_detail_preview {
95 | margin: 10px 15px;
96 | }
97 | .content_detail_title {
98 | font-size: 20px;
99 | }
100 | .content_detail_line {
101 | font-size: 12px;
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/src/css/scrollbar.css:
--------------------------------------------------------------------------------
1 | ::-webkit-scrollbar {
2 | width: 4px;
3 | height: 4px;
4 | }
5 |
6 | ::-webkit-scrollbar-thumb {
7 | border-radius: 2px;
8 | background-color: #999;
9 | }
10 |
11 | ::-webkit-scrollbar-button {
12 | display: none;
13 | }
14 |
15 | ::-webkit-scrollbar-track {
16 | background-color: #ddd;
17 | }
18 |
--------------------------------------------------------------------------------
/src/i18n.ts:
--------------------------------------------------------------------------------
1 | import { i18n } from '@lingui/core';
2 |
3 | import { STORAGE_KEY_LANGUAGE } from './constants';
4 | import { messages as messagesEN } from './locales/en.po';
5 | import { messages as messagesZH } from './locales/zh.po';
6 |
7 | export type Language = 'zh' | 'en';
8 |
9 | i18n.load({
10 | en: messagesEN,
11 | zh: messagesZH,
12 | });
13 |
14 | const { [STORAGE_KEY_LANGUAGE]: storedLanguage } = await browser.storage.local.get([
15 | STORAGE_KEY_LANGUAGE,
16 | ]);
17 | i18n.activate(storedLanguage || new Intl.Locale(browser.i18n.getUILanguage()).language);
18 |
19 | i18n.on('change', async () => {
20 | await browser.storage.local.set({ [STORAGE_KEY_LANGUAGE]: i18n.locale });
21 | });
22 |
--------------------------------------------------------------------------------
/src/image/bg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Harry-Chen/Learn-Helper/4d538afad1fbb13de0b2b94380b845e11a9bb5aa/src/image/bg.png
--------------------------------------------------------------------------------
/src/image/bg_dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Harry-Chen/Learn-Helper/4d538afad1fbb13de0b2b94380b845e11a9bb5aa/src/image/bg_dark.png
--------------------------------------------------------------------------------
/src/image/creampaper.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Harry-Chen/Learn-Helper/4d538afad1fbb13de0b2b94380b845e11a9bb5aa/src/image/creampaper.png
--------------------------------------------------------------------------------
/src/image/creampaper_black.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Harry-Chen/Learn-Helper/4d538afad1fbb13de0b2b94380b845e11a9bb5aa/src/image/creampaper_black.png
--------------------------------------------------------------------------------
/src/image/file.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Harry-Chen/Learn-Helper/4d538afad1fbb13de0b2b94380b845e11a9bb5aa/src/image/file.png
--------------------------------------------------------------------------------
/src/image/homework_expired.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Harry-Chen/Learn-Helper/4d538afad1fbb13de0b2b94380b845e11a9bb5aa/src/image/homework_expired.png
--------------------------------------------------------------------------------
/src/image/homework_submitted.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Harry-Chen/Learn-Helper/4d538afad1fbb13de0b2b94380b845e11a9bb5aa/src/image/homework_submitted.png
--------------------------------------------------------------------------------
/src/image/new_item.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Harry-Chen/Learn-Helper/4d538afad1fbb13de0b2b94380b845e11a9bb5aa/src/image/new_item.png
--------------------------------------------------------------------------------
/src/image/settings_1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Harry-Chen/Learn-Helper/4d538afad1fbb13de0b2b94380b845e11a9bb5aa/src/image/settings_1.png
--------------------------------------------------------------------------------
/src/image/settings_2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Harry-Chen/Learn-Helper/4d538afad1fbb13de0b2b94380b845e11a9bb5aa/src/image/settings_2.png
--------------------------------------------------------------------------------
/src/image/starred_item.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Harry-Chen/Learn-Helper/4d538afad1fbb13de0b2b94380b845e11a9bb5aa/src/image/starred_item.png
--------------------------------------------------------------------------------
/src/image/switch_course.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Harry-Chen/Learn-Helper/4d538afad1fbb13de0b2b94380b845e11a9bb5aa/src/image/switch_course.png
--------------------------------------------------------------------------------
/src/image/switch_filter.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Harry-Chen/Learn-Helper/4d538afad1fbb13de0b2b94380b845e11a9bb5aa/src/image/switch_filter.png
--------------------------------------------------------------------------------
/src/image/title_filter.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Harry-Chen/Learn-Helper/4d538afad1fbb13de0b2b94380b845e11a9bb5aa/src/image/title_filter.png
--------------------------------------------------------------------------------
/src/init.ts:
--------------------------------------------------------------------------------
1 | async function checkDuplicate() {
2 | const url = browser.runtime.getURL('/index.html');
3 | const tabs = await browser.tabs.query({ url });
4 | const current = await browser.tabs.getCurrent();
5 | if (current)
6 | for (const tab of tabs)
7 | if (tab.id !== current.id) {
8 | await Promise.all([
9 | browser.tabs.update(tab.id!, { active: true }),
10 | browser.tabs.remove(current.id!),
11 | ]);
12 | return;
13 | }
14 | }
15 | checkDuplicate();
16 |
--------------------------------------------------------------------------------
/src/locales/zh.po:
--------------------------------------------------------------------------------
1 | msgid ""
2 | msgstr ""
3 | "POT-Creation-Date: 2023-09-16 20:24+0800\n"
4 | "MIME-Version: 1.0\n"
5 | "Content-Type: text/plain; charset=utf-8\n"
6 | "Content-Transfer-Encoding: 8bit\n"
7 | "X-Generator: @lingui/cli\n"
8 | "Language: zh\n"
9 | "Project-Id-Version: \n"
10 | "Report-Msgid-Bugs-To: \n"
11 | "PO-Revision-Date: \n"
12 | "Last-Translator: \n"
13 | "Language-Team: \n"
14 | "Plural-Forms: \n"
15 |
16 | #. placeholder {0}: unreadFileCount?.toString()
17 | #: src/components/CardList.tsx:84
18 | msgid "下载所有未读文件(共 {0} 个)"
19 | msgstr "下载所有未读文件(共 {0} 个)"
20 |
21 | #: src/components/CardList.tsx:96
22 | msgid "这里什么也没有"
23 | msgstr "这里什么也没有"
24 |
25 | #: src/components/CardList.tsx:106
26 | msgid "加载更多"
27 | msgstr "加载更多"
28 |
29 | #: src/components/ContentCard.tsx:124
30 | msgid "已提交"
31 | msgstr "已提交"
32 |
33 | #: src/components/ContentCard.tsx:124
34 | msgid "未提交"
35 | msgstr "未提交"
36 |
37 | #. placeholder {0}: content.grade
38 | #: src/components/ContentCard.tsx:130
39 | msgid "{0}分"
40 | msgstr "{0}分"
41 |
42 | #: src/components/ContentCard.tsx:131
43 | #: src/components/ContentDetail.tsx:167
44 | msgid "无评分"
45 | msgstr "无评分"
46 |
47 | #. placeholder {0}: content.graderName ?? ''
48 | #: src/components/ContentCard.tsx:132
49 | msgid "({0})"
50 | msgstr "({0})"
51 |
52 | #: src/components/ContentCard.tsx:133
53 | msgid "未批阅"
54 | msgstr "未批阅"
55 |
56 | #: src/components/ContentCard.tsx:136
57 | msgid "重要"
58 | msgstr "重要"
59 |
60 | #. placeholder {0}: content.publisher
61 | #: src/components/ContentCard.tsx:138
62 | msgid "发布者:{0}"
63 | msgstr "发布者:{0}"
64 |
65 | #. placeholder {0}: content.replyCount
66 | #: src/components/ContentCard.tsx:145
67 | msgid "回复:{0}"
68 | msgstr "回复:{0}"
69 |
70 | #. placeholder {0}: content.lastReplierName
71 | #: src/components/ContentCard.tsx:147
72 | msgid "最后回复:{0}"
73 | msgstr "最后回复:{0}"
74 |
75 | #: src/components/ContentCard.tsx:156
76 | msgid "取消星标"
77 | msgstr "取消星标"
78 |
79 | #: src/components/ContentCard.tsx:156
80 | msgid "加星标"
81 | msgstr "加星标"
82 |
83 | #: src/components/ContentCard.tsx:175
84 | msgid "标记为未读"
85 | msgstr "标记为未读"
86 |
87 | #: src/components/ContentCard.tsx:175
88 | msgid "标记为已读"
89 | msgstr "标记为已读"
90 |
91 | #: src/components/ContentCard.tsx:192
92 | msgid "取消忽略此项"
93 | msgstr "取消忽略此项"
94 |
95 | #: src/components/ContentCard.tsx:192
96 | msgid "忽略此项"
97 | msgstr "忽略此项"
98 |
99 | #: src/components/ContentCard.tsx:214
100 | msgid "提交作业"
101 | msgstr "提交作业"
102 |
103 | #: src/components/ContentCard.tsx:231
104 | msgid "下载文件"
105 | msgstr "下载文件"
106 |
107 | #. placeholder {0}: content.attachment.name
108 | #: src/components/ContentCard.tsx:247
109 | msgid "附件:{0}"
110 | msgstr "附件:{0}"
111 |
112 | #. placeholder {0}: file.name
113 | #. placeholder {1}: file.size
114 | #: src/components/ContentDetail.tsx:90
115 | msgid "{0}({1})"
116 | msgstr "{0}({1})"
117 |
118 | #: src/components/ContentDetail.tsx:98
119 | msgid "在新窗口打开"
120 | msgstr "在新窗口打开"
121 |
122 | #: src/components/ContentDetail.tsx:115
123 | msgid "上传时间:"
124 | msgstr "上传时间:"
125 |
126 | #: src/components/ContentDetail.tsx:116
127 | msgid "访问量:"
128 | msgstr "访问量:"
129 |
130 | #: src/components/ContentDetail.tsx:117
131 | msgid "下载量:"
132 | msgstr "下载量:"
133 |
134 | #: src/components/ContentDetail.tsx:118
135 | msgid "文件大小:"
136 | msgstr "文件大小:"
137 |
138 | #: src/components/ContentDetail.tsx:119
139 | msgid "文件类型:"
140 | msgstr "文件类型:"
141 |
142 | #: src/components/ContentDetail.tsx:120
143 | msgid "文件分类:"
144 | msgstr "文件分类:"
145 |
146 | #: src/components/ContentDetail.tsx:122
147 | msgid "文件下载:"
148 | msgstr "文件下载:"
149 |
150 | #: src/components/ContentDetail.tsx:123
151 | msgid "文件预览:"
152 | msgstr "文件预览:"
153 |
154 | #: src/components/ContentDetail.tsx:137
155 | msgid "截止时间:"
156 | msgstr "截止时间:"
157 |
158 | #: src/components/ContentDetail.tsx:139
159 | msgid "补交截止时间:"
160 | msgstr "补交截止时间:"
161 |
162 | #: src/components/ContentDetail.tsx:141
163 | msgid "完成方式:"
164 | msgstr "完成方式:"
165 |
166 | #: src/components/ContentDetail.tsx:144
167 | msgid "提交方式:"
168 | msgstr "提交方式:"
169 |
170 | #: src/components/ContentDetail.tsx:148
171 | msgid "补交时间:"
172 | msgstr "补交时间:"
173 |
174 | #: src/components/ContentDetail.tsx:148
175 | msgid "提交时间:"
176 | msgstr "提交时间:"
177 |
178 | #: src/components/ContentDetail.tsx:152
179 | msgid "提交内容:"
180 | msgstr "提交内容:"
181 |
182 | #: src/components/ContentDetail.tsx:155
183 | msgid "提交附件:"
184 | msgstr "提交附件:"
185 |
186 | #: src/components/ContentDetail.tsx:156
187 | msgid "提交附件预览:"
188 | msgstr "提交附件预览:"
189 |
190 | #: src/components/ContentDetail.tsx:162
191 | msgid "评阅时间:"
192 | msgstr "评阅时间:"
193 |
194 | #: src/components/ContentDetail.tsx:163
195 | msgid "评阅者:"
196 | msgstr "评阅者:"
197 |
198 | #: src/components/ContentDetail.tsx:164
199 | msgid "成绩:"
200 | msgstr "成绩:"
201 |
202 | #: src/components/ContentDetail.tsx:169
203 | msgid "评阅内容:"
204 | msgstr "评阅内容:"
205 |
206 | #: src/components/ContentDetail.tsx:172
207 | msgid "评阅附件:"
208 | msgstr "评阅附件:"
209 |
210 | #: src/components/ContentDetail.tsx:173
211 | msgid "评阅附件预览:"
212 | msgstr "评阅附件预览:"
213 |
214 | #: src/components/ContentDetail.tsx:179
215 | msgid "答案内容:"
216 | msgstr "答案内容:"
217 |
218 | #: src/components/ContentDetail.tsx:182
219 | msgid "答案附件:"
220 | msgstr "答案附件:"
221 |
222 | #: src/components/ContentDetail.tsx:183
223 | msgid "答案附件预览:"
224 | msgstr "答案附件预览:"
225 |
226 | #: src/components/ContentDetail.tsx:189
227 | msgid "作业附件:"
228 | msgstr "作业附件:"
229 |
230 | #: src/components/ContentDetail.tsx:190
231 | msgid "作业附件预览:"
232 | msgstr "作业附件预览:"
233 |
234 | #: src/components/ContentDetail.tsx:194
235 | msgid "作业详情:"
236 | msgstr "作业详情:"
237 |
238 | #: src/components/ContentDetail.tsx:196
239 | msgid "在本窗口打开"
240 | msgstr "在本窗口打开"
241 |
242 | #: src/components/ContentDetail.tsx:205
243 | msgid "发布时间:"
244 | msgstr "发布时间:"
245 |
246 | #: src/components/ContentDetail.tsx:207
247 | msgid "过期时间:"
248 | msgstr "过期时间:"
249 |
250 | #: src/components/ContentDetail.tsx:209
251 | msgid "发布人:"
252 | msgstr "发布人:"
253 |
254 | #: src/components/ContentDetail.tsx:210
255 | msgid "重要性:"
256 | msgstr "重要性:"
257 |
258 | #: src/components/ContentDetail.tsx:211
259 | msgid "高"
260 | msgstr "高"
261 |
262 | #: src/components/ContentDetail.tsx:211
263 | msgid "普通"
264 | msgstr "普通"
265 |
266 | #: src/components/ContentDetail.tsx:215
267 | msgid "公告附件:"
268 | msgstr "公告附件:"
269 |
270 | #: src/components/ContentDetail.tsx:216
271 | msgid "公告附件预览:"
272 | msgstr "公告附件预览:"
273 |
274 | #: src/components/ContentDetail.tsx:234
275 | msgid "详情为空"
276 | msgstr "详情为空"
277 |
278 | #: src/components/ContentDetail.tsx:248
279 | msgid "课程名称:"
280 | msgstr "课程名称:"
281 |
282 | #. placeholder {0}: fileToPreview!.size
283 | #: src/components/ContentDetail.tsx:262
284 | msgid "加载预览({0})"
285 | msgstr "加载预览({0})"
286 |
287 | #: src/components/CourseList.tsx:43
288 | msgid "本学期课程"
289 | msgstr "本学期课程"
290 |
291 | #: src/components/CourseList.tsx:50
292 | msgid "这里什么也没有,快去选点课吧!"
293 | msgstr "这里什么也没有,快去选点课吧!"
294 |
295 | #: src/components/SettingList.tsx:27
296 | msgid "设置"
297 | msgstr "设置"
298 |
299 | #: src/components/SummaryList.tsx:46
300 | msgid "项目汇总"
301 | msgstr "项目汇总"
302 |
303 | #: src/components/dialogs/ChangeSemesterDialog.tsx:45
304 | #: src/constants/ui.tsx:168
305 | msgid "切换学期"
306 | msgstr "切换学期"
307 |
308 | #. placeholder {0}: formatSemesterId(semester)
309 | #. placeholder {1}: formatSemesterId(currentWebSemester)
310 | #: src/components/dialogs/ChangeSemesterDialog.tsx:49
311 | msgid "切换学期将导致所有配置(隐藏)和状态(已读、星标)丢失,请三思!<0/>当前 Learn Helper 学期:{0}<1/>当前网络学堂学期(注册中心控制):{1}"
312 | msgstr "切换学期将导致所有配置(隐藏)和状态(已读、星标)丢失,请三思!<0/>当前 Learn Helper 学期:{0}<1/>当前网络学堂学期(注册中心控制):{1}"
313 |
314 | #: src/components/dialogs/ChangeSemesterDialog.tsx:59
315 | msgid "选择学期"
316 | msgstr "选择学期"
317 |
318 | #: src/components/dialogs/ChangeSemesterDialog.tsx:89
319 | #: src/components/dialogs/LoginDialog.tsx:101
320 | msgid "确定"
321 | msgstr "确定"
322 |
323 | #: src/components/dialogs/ChangeSemesterDialog.tsx:92
324 | #: src/components/dialogs/LogoutDialog.tsx:55
325 | msgid "取消"
326 | msgstr "取消"
327 |
328 | #: src/components/dialogs/ClearDataDialog.tsx:16
329 | msgid "清除所有缓存"
330 | msgstr "清除所有缓存"
331 |
332 | #: src/components/dialogs/ClearDataDialog.tsx:19
333 | msgid "确认要清除吗?所有缓存的数据和已读状态将会被清除。"
334 | msgstr "确认要清除吗?所有缓存的数据和已读状态将会被清除。"
335 |
336 | #: src/components/dialogs/ClearDataDialog.tsx:30
337 | #: src/components/dialogs/NewSemesterDialog.tsx:50
338 | msgid "是"
339 | msgstr "是"
340 |
341 | #: src/components/dialogs/ClearDataDialog.tsx:33
342 | #: src/components/dialogs/NewSemesterDialog.tsx:60
343 | msgid "否"
344 | msgstr "否"
345 |
346 | #: src/components/dialogs/LoginDialog.tsx:35
347 | msgid "登录网络学堂"
348 | msgstr "登录网络学堂"
349 |
350 | #: src/components/dialogs/LoginDialog.tsx:39
351 | msgid "请输入您的学号/用户名和密码以登录到网络学堂。<0/>请注意,本插件默认不会保存您的凭据;每次打开新的学堂助手页面时,您都需要重新输入。 如果您选择保存凭据,则本插件会将其 <1>保存在本地1> ,并启用自动登录功能。<2/>我们对凭据进行了简单的加密,但并不能完全防止其被第三方读取。 在长时间不使用或者出借计算机时,请务必退出登录,以免您的凭据被泄露。<3/>如果您选择登录,则视为您已经阅读并同意<4>此页面4>中的所有内容。否则,请立刻停止使用并从浏览器中卸载本插件。"
352 | msgstr "请输入您的学号/用户名和密码以登录到网络学堂。<0/>请注意,本插件默认不会保存您的凭据;每次打开新的学堂助手页面时,您都需要重新输入。 如果您选择保存凭据,则本插件会将其 <1>保存在本地1> ,并启用自动登录功能。<2/>我们对凭据进行了简单的加密,但并不能完全防止其被第三方读取。 在长时间不使用或者出借计算机时,请务必退出登录,以免您的凭据被泄露。<3/>如果您选择登录,则视为您已经阅读并同意<4>此页面4>中的所有内容。否则,请立刻停止使用并从浏览器中卸载本插件。"
353 |
354 | #: src/components/dialogs/LoginDialog.tsx:61
355 | msgid "用户名/学号"
356 | msgstr "用户名/学号"
357 |
358 | #: src/components/dialogs/LoginDialog.tsx:71
359 | msgid "密码"
360 | msgstr "密码"
361 |
362 | #: src/components/dialogs/LoginDialog.tsx:79
363 | msgid "保存凭据以自动登录"
364 | msgstr "保存凭据以自动登录"
365 |
366 | #: src/components/dialogs/LogoutDialog.tsx:23
367 | #: src/constants/ui.tsx:182
368 | msgid "退出登录"
369 | msgstr "退出登录"
370 |
371 | #: src/components/dialogs/LogoutDialog.tsx:26
372 | msgid "您确认要退出登录吗?如果只是更换登录密码,请不要选择清除数据。"
373 | msgstr "您确认要退出登录吗?如果只是更换登录密码,请不要选择清除数据。"
374 |
375 | #: src/components/dialogs/LogoutDialog.tsx:38
376 | msgid "退出"
377 | msgstr "退出"
378 |
379 | #: src/components/dialogs/LogoutDialog.tsx:52
380 | msgid "退出并清除数据"
381 | msgstr "退出并清除数据"
382 |
383 | #: src/components/dialogs/NetworkErrorDialog.tsx:21
384 | msgid "刷新课程信息失败"
385 | msgstr "刷新课程信息失败"
386 |
387 | #: src/components/dialogs/NetworkErrorDialog.tsx:24
388 | msgid "可能原因有:<0/>· 网络不太给力<1/>· 服务器去思考人生了<2/>· 保存的用户凭据不正确(最近修改过密码?)<3/>您可以选择重试、放弃刷新,或者更换新的凭据。"
389 | msgstr "可能原因有:<0/>· 网络不太给力<1/>· 服务器去思考人生了<2/>· 保存的用户凭据不正确(最近修改过密码?)<3/>您可以选择重试、放弃刷新,或者更换新的凭据。"
390 |
391 | #: src/components/dialogs/NetworkErrorDialog.tsx:42
392 | msgid "重试刷新"
393 | msgstr "重试刷新"
394 |
395 | #: src/components/dialogs/NetworkErrorDialog.tsx:51
396 | msgid "离线查看"
397 | msgstr "离线查看"
398 |
399 | #: src/components/dialogs/NetworkErrorDialog.tsx:60
400 | msgid "更新凭据"
401 | msgstr "更新凭据"
402 |
403 | #: src/components/dialogs/NewSemesterDialog.tsx:25
404 | msgid "检测到新学期"
405 | msgstr "检测到新学期"
406 |
407 | #. placeholder {0}: formatSemester(semester)
408 | #. placeholder {1}: formatSemester(fetchedSemester)
409 | #: src/components/dialogs/NewSemesterDialog.tsx:28
410 | msgid "当前 Learn Helper 学期:{0}<0/>当前网络学堂学期:{1}<1/>是否要进行学期切换(本学期已读、星标等状态将会被清空)?<2/>如果选择“否”,则在下一次打开 Learn Helper 前都将保持当前学期。<3/>如果选择“不再询问”,则需要手动进行学期切换。"
411 | msgstr "当前 Learn Helper 学期:{0}<0/>当前网络学堂学期:{1}<1/>是否要进行学期切换(本学期已读、星标等状态将会被清空)?<2/>如果选择“否”,则在下一次打开 Learn Helper 前都将保持当前学期。<3/>如果选择“不再询问”,则需要手动进行学期切换。"
412 |
413 | #: src/components/dialogs/NewSemesterDialog.tsx:70
414 | msgid "不再询问"
415 | msgstr "不再询问"
416 |
417 | #: src/constants/ui.tsx:31
418 | msgid "主页"
419 | msgstr "主页"
420 |
421 | #: src/constants/ui.tsx:32
422 | msgid "所有公告"
423 | msgstr "所有公告"
424 |
425 | #: src/constants/ui.tsx:33
426 | msgid "所有文件"
427 | msgstr "所有文件"
428 |
429 | #: src/constants/ui.tsx:34
430 | msgid "所有作业"
431 | msgstr "所有作业"
432 |
433 | #: src/constants/ui.tsx:35
434 | msgid "所有讨论"
435 | msgstr "所有讨论"
436 |
437 | #: src/constants/ui.tsx:36
438 | msgid "所有答疑"
439 | msgstr "所有答疑"
440 |
441 | #: src/constants/ui.tsx:37
442 | msgid "所有忽略"
443 | msgstr "所有忽略"
444 |
445 | #: src/constants/ui.tsx:41
446 | msgid "课程综合"
447 | msgstr "课程综合"
448 |
449 | #: src/constants/ui.tsx:42
450 | msgid "课程公告"
451 | msgstr "课程公告"
452 |
453 | #: src/constants/ui.tsx:43
454 | msgid "课程文件"
455 | msgstr "课程文件"
456 |
457 | #: src/constants/ui.tsx:44
458 | msgid "课程作业"
459 | msgstr "课程作业"
460 |
461 | #: src/constants/ui.tsx:45
462 | msgid "课程讨论"
463 | msgstr "课程讨论"
464 |
465 | #: src/constants/ui.tsx:46
466 | msgid "课程答疑"
467 | msgstr "课程答疑"
468 |
469 | #: src/constants/ui.tsx:47
470 | msgid "课程主页"
471 | msgstr "课程主页"
472 |
473 | #: src/constants/ui.tsx:54
474 | msgid "公告"
475 | msgstr "公告"
476 |
477 | #: src/constants/ui.tsx:59
478 | msgid "文件"
479 | msgstr "文件"
480 |
481 | #: src/constants/ui.tsx:64
482 | msgid "作业"
483 | msgstr "作业"
484 |
485 | #: src/constants/ui.tsx:69
486 | msgid "讨论"
487 | msgstr "讨论"
488 |
489 | #: src/constants/ui.tsx:74
490 | msgid "答疑"
491 | msgstr "答疑"
492 |
493 | #: src/constants/ui.tsx:147
494 | #: src/pages/settings.tsx:30
495 | msgid "管理隐藏项"
496 | msgstr "管理隐藏项"
497 |
498 | #: src/constants/ui.tsx:154
499 | msgid "全部标记已读"
500 | msgstr "全部标记已读"
501 |
502 | #: src/constants/ui.tsx:161
503 | msgid "手动刷新"
504 | msgstr "手动刷新"
505 |
506 | #: src/constants/ui.tsx:175
507 | msgid "清空缓存"
508 | msgstr "清空缓存"
509 |
510 | #: src/pages/_app.tsx:133
511 | msgid "跟随系统"
512 | msgstr "跟随系统"
513 |
514 | #: src/pages/_app.tsx:141
515 | msgid "亮"
516 | msgstr "亮"
517 |
518 | #: src/pages/_app.tsx:149
519 | msgid "暗"
520 | msgstr "暗"
521 |
522 | #: src/pages/_app.tsx:225
523 | msgid "非网络学堂当前学期"
524 | msgstr "非网络学堂当前学期"
525 |
526 | #: src/pages/_app.tsx:258
527 | msgid "筛选"
528 | msgstr "筛选"
529 |
530 | #: src/pages/_app.tsx:309
531 | msgid "哎呀,出错了!"
532 | msgstr "哎呀,出错了!"
533 |
534 | #: src/pages/_app.tsx:313
535 | msgid "发生了不可恢复的错误,请尝试刷新页面。如果错误继续出现,请清除数据重新来过。"
536 | msgstr "发生了不可恢复的错误,请尝试刷新页面。如果错误继续出现,请清除数据重新来过。"
537 |
538 | #: src/pages/_app.tsx:323
539 | msgid "刷新"
540 | msgstr "刷新"
541 |
542 | #: src/pages/_app.tsx:331
543 | msgid "清除数据"
544 | msgstr "清除数据"
545 |
546 | #: src/pages/_app.tsx:335
547 | msgid "请将下面的错误信息发送给开发者,以协助解决问题,感谢支持!"
548 | msgstr "请将下面的错误信息发送给开发者,以协助解决问题,感谢支持!"
549 |
550 | #: src/pages/_app.tsx:339
551 | msgid "错误信息:"
552 | msgstr "错误信息:"
553 |
554 | #: src/pages/_app.tsx:344
555 | msgid "错误组件:"
556 | msgstr "错误组件:"
557 |
558 | #: src/pages/_app.tsx:345
559 | msgid "无此信息"
560 | msgstr "无此信息"
561 |
562 | #: src/pages/doc/_doc.tsx:18
563 | msgid "返回"
564 | msgstr "返回"
565 |
566 | #: src/pages/settings.tsx:33
567 | msgid "此处的更改在下一次刷新时生效,并且只在汇总功能中起作用。<0/>如果您重新启用一个隐藏的项目,原本的项目属性(是否已读、加星标)不会发生变化。"
568 | msgstr "此处的更改在下一次刷新时生效,并且只在汇总功能中起作用。<0/>如果您重新启用一个隐藏的项目,原本的项目属性(是否已读、加星标)不会发生变化。"
569 |
570 | #: src/pages/settings.tsx:45
571 | msgid "课程名称"
572 | msgstr "课程名称"
573 |
574 | #: src/pages/settings.tsx:83
575 | msgid "重置"
576 | msgstr "重置"
577 |
578 | #: src/pages/welcome.tsx:19
579 | msgid "使用手册"
580 | msgstr "使用手册"
581 |
582 | #: src/pages/welcome.tsx:23
583 | msgid "关于我们"
584 | msgstr "关于我们"
585 |
586 | #: src/pages/welcome.tsx:27
587 | msgid "更新记录"
588 | msgstr "更新记录"
589 |
590 | #: src/redux/actions.ts:88
591 | #: src/redux/actions.ts:271
592 | #: src/utils/format.ts:102
593 | msgid "未知错误"
594 | msgstr "未知错误"
595 |
596 | #. placeholder {0}: failReasonToString(error?.reason) ?? error.toString() ?? t`未知错误`
597 | #: src/redux/actions.ts:88
598 | msgid "登录失败:{0}"
599 | msgstr "登录失败:{0}"
600 |
601 | #: src/redux/actions.ts:97
602 | msgid "登录成功"
603 | msgstr "登录成功"
604 |
605 | #: src/redux/actions.ts:112
606 | msgid "离上次成功刷新不足15分钟,若需要可手动刷新"
607 | msgstr "离上次成功刷新不足15分钟,若需要可手动刷新"
608 |
609 | #: src/redux/actions.ts:243
610 | msgid "更新成功"
611 | msgstr "更新成功"
612 |
613 | #: src/redux/actions.ts:245
614 | msgid "部分内容更新失败"
615 | msgstr "部分内容更新失败"
616 |
617 | #. placeholder {0}: failReasonToString(error?.reason) ?? error.toString() ?? t`未知错误`
618 | #: src/redux/actions.ts:270
619 | msgid "设置网络学堂语言失败:{0}"
620 | msgstr "设置网络学堂语言失败:{0}"
621 |
622 | #: src/redux/actions.ts:356
623 | msgid "登录已过期,请刷新后重试"
624 | msgstr "登录已过期,请刷新后重试"
625 |
626 | #: src/redux/actions.ts:394
627 | msgid "升级成功,所有本地数据已经被清除"
628 | msgstr "升级成功,所有本地数据已经被清除"
629 |
630 | #: src/redux/actions.ts:407
631 | msgid "升级成功,所有本地数据(除配置)已经被清除"
632 | msgstr "升级成功,所有本地数据(除配置)已经被清除"
633 |
634 | #: src/redux/actions.ts:438
635 | msgid "升级成功,数据没有受到影响"
636 | msgstr "升级成功,数据没有受到影响"
637 |
638 | #: src/redux/actions.ts:441
639 | msgid "迁移失败,已清除全部数据"
640 | msgstr "迁移失败,已清除全部数据"
641 |
642 | #: src/redux/actions.ts:465
643 | msgid "加载数据失败,已清除数据"
644 | msgstr "加载数据失败,已清除数据"
645 |
646 | #: src/redux/selectors.ts:84
647 | msgid "加载中..."
648 | msgstr "加载中..."
649 |
650 | #: src/utils/format.ts:19
651 | msgid "秋季学期"
652 | msgstr "秋季学期"
653 |
654 | #: src/utils/format.ts:20
655 | msgid "春季学期"
656 | msgstr "春季学期"
657 |
658 | #: src/utils/format.ts:21
659 | msgid "夏季学期"
660 | msgstr "夏季学期"
661 |
662 | #: src/utils/format.ts:79
663 | #: src/utils/format.ts:86
664 | msgid "无"
665 | msgstr "无"
666 |
667 | #: src/utils/format.ts:92
668 | msgid "用户名或密码错误"
669 | msgstr "用户名或密码错误"
670 |
671 | #: src/utils/format.ts:93
672 | msgid "无法从 id.tsinghua.edu.cn 获取票据"
673 | msgstr "无法从 id.tsinghua.edu.cn 获取票据"
674 |
675 | #: src/utils/format.ts:94
676 | msgid "无法使用票据漫游至 learn.tsinghua.edu.cn"
677 | msgstr "无法使用票据漫游至 learn.tsinghua.edu.cn"
678 |
679 | #: src/utils/format.ts:95
680 | msgid "功能尚未实现"
681 | msgstr "功能尚未实现"
682 |
683 | #: src/utils/format.ts:96
684 | msgid "尚未登录"
685 | msgstr "尚未登录"
686 |
687 | #: src/utils/format.ts:97
688 | msgid "未提供用户名或密码"
689 | msgstr "未提供用户名或密码"
690 |
691 | #: src/utils/format.ts:98
692 | msgid "非预期的 HTTP 响应状态"
693 | msgstr "非预期的 HTTP 响应状态"
694 |
695 | #: src/utils/format.ts:99
696 | msgid "无效的 HTTP 响应"
697 | msgstr "无效的 HTTP 响应"
698 |
699 | #: src/utils/format.ts:100
700 | msgid "操作失败"
701 | msgstr "操作失败"
702 |
703 | #: src/utils/format.ts:101
704 | msgid "请求超时"
705 | msgstr "请求超时"
706 |
707 | #: src/utils/format.ts:110
708 | msgid "已阅"
709 | msgstr "已阅"
710 |
711 | #: src/utils/format.ts:111
712 | msgid "优秀"
713 | msgstr "优秀"
714 |
715 | #: src/utils/format.ts:112
716 | msgid "免课"
717 | msgstr "免课"
718 |
719 | #: src/utils/format.ts:113
720 | msgid "免修"
721 | msgstr "免修"
722 |
723 | #: src/utils/format.ts:114
724 | msgid "通过"
725 | msgstr "通过"
726 |
727 | #: src/utils/format.ts:115
728 | msgid "不通过"
729 | msgstr "不通过"
730 |
731 | #: src/utils/format.ts:116
732 | msgid "缓考"
733 | msgstr "缓考"
734 |
735 | #: src/utils/format.ts:132
736 | msgid "每人独立完成一份作业"
737 | msgstr "每人独立完成一份作业"
738 |
739 | #: src/utils/format.ts:133
740 | msgid "每组共同完成一份作业"
741 | msgstr "每组共同完成一份作业"
742 |
743 | #: src/utils/format.ts:143
744 | msgid "网络学堂"
745 | msgstr "网络学堂"
746 |
747 | #: src/utils/format.ts:144
748 | msgid "无需在网络学堂提交"
749 | msgstr "无需在网络学堂提交"
750 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { i18n } from '@lingui/core';
2 | import { I18nProvider } from '@lingui/react';
3 | import { CssBaseline, ThemeProvider } from '@mui/material';
4 | import { SnackbarProvider } from 'notistack';
5 | import { StrictMode } from 'react';
6 | import { createRoot } from 'react-dom/client';
7 | import { Provider } from 'react-redux';
8 | import { Router } from 'wouter';
9 | import { useHashLocation } from 'wouter/use-hash-location';
10 |
11 | import { store } from './redux/store';
12 | import { theme } from './theme';
13 | import { printWelcomeMessage } from './utils/console';
14 | import './i18n';
15 | import './css/scrollbar.css';
16 | import App from './pages/_app';
17 |
18 | const root = createRoot(document.querySelector('#main')!);
19 | root.render(
20 |
21 |
22 |
23 |
24 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | ,
38 | );
39 |
40 | printWelcomeMessage();
41 |
--------------------------------------------------------------------------------
/src/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import { t } from '@lingui/core/macro';
2 | import { useLingui } from '@lingui/react';
3 | import { Trans } from '@lingui/react/macro';
4 | import classnames from 'classnames';
5 | import { type ErrorInfo, useEffect, useRef, useState } from 'react';
6 | import { ErrorBoundary, type FallbackProps } from 'react-error-boundary';
7 | import { Route, Switch, useLocation } from 'wouter';
8 |
9 | import {
10 | Button,
11 | Divider,
12 | Drawer,
13 | IconButton,
14 | InputBase,
15 | LinearProgress,
16 | ListItemIcon,
17 | ListItemText,
18 | Menu,
19 | MenuItem,
20 | AppBar as MuiAppBar,
21 | Toolbar,
22 | Tooltip,
23 | Typography,
24 | useColorScheme,
25 | } from '@mui/material';
26 | import { bindMenu, bindTrigger, usePopupState } from 'material-ui-popup-state/hooks';
27 |
28 | import IconAngleLeft from '~icons/fa6-solid/angle-left';
29 | import IconBars from '~icons/fa6-solid/bars';
30 | import IconCircleHalfStroke from '~icons/fa6-solid/circle-half-stroke';
31 | import IconFilter from '~icons/fa6-solid/filter';
32 | import IconLanguage from '~icons/fa6-solid/language';
33 | import IconMoon from '~icons/fa6-solid/moon';
34 | import IconStarOfLife from '~icons/fa6-solid/star-of-life';
35 | import IconSun from '~icons/fa6-solid/sun';
36 | import IconXmark from '~icons/fa6-solid/xmark';
37 |
38 | import type { Language } from '../i18n';
39 | import {
40 | clearAllData,
41 | loadApp,
42 | setTitleFilter,
43 | syncLanguage,
44 | toggleChangeSemesterDialog,
45 | togglePaneHidden,
46 | tryLoginSilently,
47 | } from '../redux/actions';
48 | import { useAppDispatch, useAppSelector } from '../redux/hooks';
49 | import { selectCardListTitle } from '../redux/selectors';
50 | import type { ColorMode } from '../types/ui';
51 | import { interceptCsrfRequest } from '../utils/csrf';
52 | import { formatSemester } from '../utils/format';
53 | import { removeStoredCredential } from '../utils/storage';
54 |
55 | import CardList from '../components/CardList';
56 | import CourseList from '../components/CourseList';
57 | import SettingList from '../components/SettingList';
58 | import SummaryList from '../components/SummaryList';
59 | import {
60 | ChangeSemesterDialog,
61 | ClearDataDialog,
62 | LoginDialog,
63 | LogoutDialog,
64 | NetworkErrorDialog,
65 | NewSemesterDialog,
66 | } from '../components/dialogs';
67 |
68 | import Content from './content';
69 | import Doc from './doc/_doc';
70 | import ContentIgnoreSetting from './settings';
71 | import Web from './web';
72 | import Welcome from './welcome';
73 |
74 | import styles from '../css/main.module.css';
75 |
76 | const LanguageSwitcher = () => {
77 | const { i18n } = useLingui();
78 | const dispatch = useAppDispatch();
79 |
80 | const popupState = usePopupState({ variant: 'popover', popupId: 'languageMenu' });
81 | const handle = (lang: Language) => {
82 | i18n.activate(lang);
83 | dispatch(syncLanguage());
84 | popupState.close();
85 | };
86 |
87 | return (
88 | <>
89 |
95 |
96 |
97 |
98 | handle('zh')}>
99 | 中文
100 |
101 | handle('en')}>
102 | English
103 |
104 |
105 | >
106 | );
107 | };
108 |
109 | const ColorModeSwitcher = () => {
110 | const popupState = usePopupState({ variant: 'popover', popupId: 'colorModeMenu' });
111 | const { mode, setMode } = useColorScheme();
112 | const handle = (m: ColorMode) => {
113 | setMode(m);
114 | popupState.close();
115 | };
116 |
117 | return (
118 | <>
119 |
125 |
126 |
127 |
128 | handle('system')}>
129 |
130 |
131 |
132 |
133 | 跟随系统
134 |
135 |
136 | handle('light')}>
137 |
138 |
139 |
140 |
141 | 亮
142 |
143 |
144 | handle('dark')}>
145 |
146 |
147 |
148 |
149 | 暗
150 |
151 |
152 |
153 | >
154 | );
155 | };
156 |
157 | const AppBar = () => {
158 | const dispatch = useAppDispatch();
159 |
160 | const openSidebar = () => dispatch(togglePaneHidden(false));
161 |
162 | return (
163 |
164 |
165 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 | );
180 | };
181 |
182 | const AppDrawer = () => {
183 | const { _ } = useLingui();
184 | const dispatch = useAppDispatch();
185 |
186 | const paneHidden = useAppSelector((state) => state.ui.paneHidden);
187 | const cardListTitle = useAppSelector(selectCardListTitle);
188 | const semesterTitle = useAppSelector((state) => formatSemester(state.data.semester));
189 | const isLatestSemester = useAppSelector(
190 | (state) => state.data.semester.id === state.data.fetchedSemester.id,
191 | );
192 |
193 | const inputRef = useRef();
194 | const [filterShown, setFilterShown] = useState(false);
195 | const [filter, setFilter] = useState('');
196 |
197 | const toggleFilter = () => {
198 | if (filterShown) {
199 | setFilterShown(false);
200 | setFilter('');
201 | dispatch(setTitleFilter(undefined));
202 | } else {
203 | setTimeout(() => inputRef.current?.focus(), 250);
204 | setFilterShown(true);
205 | }
206 | };
207 |
208 | return (
209 |
210 |
211 |
212 |
213 |
214 | dispatch(togglePaneHidden(true))}
217 | size="large"
218 | >
219 |
220 |
221 |
222 | {semesterTitle}
223 |
224 | {!isLatestSemester && (
225 |
226 | dispatch(toggleChangeSemesterDialog(true))}
229 | size="small"
230 | >
231 |
232 |
233 |
234 | )}
235 |
236 |
241 |
242 | {cardListTitle.map((part) => _(part)).join('-')}
243 |
244 |
245 |
246 |
251 | {filterShown ? : }
252 |
253 | {filterShown && (
254 |
255 | {
261 | setFilter(ev.target.value);
262 | dispatch(setTitleFilter(ev.target.value.trim() || undefined));
263 | }}
264 | inputProps={{
265 | onBlur: () => {
266 | if (!filterShown && filter === '') setFilterShown(false);
267 | },
268 | }}
269 | />
270 |
271 | )}
272 |
273 |
274 |
275 |
276 |
277 |
278 |
279 |
280 |
281 |
282 |
283 |
284 | {/* list of cards */}
285 |
286 |
287 |
288 |
289 |
290 |
291 | );
292 | };
293 |
294 | const Fallback = ({ error, errorInfo }: FallbackProps & { errorInfo: ErrorInfo | null }) => {
295 | const dispatch = useAppDispatch();
296 |
297 | const resetApp = async () => {
298 | // clear all data
299 | await removeStoredCredential();
300 | dispatch(clearAllData());
301 | // refresh page
302 | window.location.replace(window.location.href);
303 | };
304 |
305 | return (
306 |
307 |
308 |
309 | 哎呀,出错了!
310 |
311 |
312 |
313 | 发生了不可恢复的错误,请尝试刷新页面。如果错误继续出现,请清除数据重新来过。
314 |
315 | {
320 | window.location.replace(window.location.href);
321 | }}
322 | >
323 | 刷新
324 |
325 |
331 | 清除数据
332 |
333 |
334 |
335 | 请将下面的错误信息发送给开发者,以协助解决问题,感谢支持!
336 |
337 |
338 |
339 | 错误信息:
340 |
341 | {error.stack ?? `${error.name}: ${error.message}`}
342 |
343 |
344 | 错误组件:
345 | {errorInfo?.componentStack ?? 无此信息 }
346 |
347 |
348 | );
349 | };
350 |
351 | const App = () => {
352 | const [errorInfo, setErrorInfo] = useState(null);
353 |
354 | const dispatch = useAppDispatch();
355 |
356 | const loadingProgress = useAppSelector((state) => state.ui.loadingProgress);
357 | const paneHidden = useAppSelector((state) => state.ui.paneHidden);
358 | const csrf = useAppSelector((state) => state.helper.helper.getCSRFToken());
359 |
360 | const [_, navigate] = useLocation();
361 |
362 | useEffect(() => {
363 | dispatch(loadApp()).then((res) => {
364 | if (res.navigate) {
365 | navigate(`/doc/${res.navigate}`);
366 | }
367 | });
368 | // keep login state
369 | const handle = window.setInterval(() => dispatch(tryLoginSilently()), 14 * 60 * 1000); // < 15 minutes and as long as possible
370 | return () => window.clearInterval(handle);
371 | }, [dispatch, navigate]);
372 |
373 | useEffect(() => {
374 | interceptCsrfRequest(csrf);
375 | }, [csrf]);
376 |
377 | return (
378 | {
380 | setErrorInfo(info);
381 | console.error(error);
382 | }}
383 | fallbackRender={(fallbackProps) => }
384 | >
385 |
386 | {/* sidebar */}
387 |
388 | {/* progress bar */}
389 |
390 | {loadingProgress !== undefined && (
391 |
392 | )}
393 |
394 |
395 | {/* detail area */}
396 |
401 |
402 |
403 |
404 |
405 |
406 |
407 |
408 |
409 |
410 | {/* dialogs */}
411 |
412 |
413 |
414 |
415 |
416 |
417 |
418 |
419 | );
420 | };
421 |
422 | export default App;
423 |
--------------------------------------------------------------------------------
/src/pages/content.tsx:
--------------------------------------------------------------------------------
1 | import type { ContentType } from 'thu-learn-lib';
2 | import { useParams } from 'wouter';
3 |
4 | import ContentDetail from '../components/ContentDetail';
5 | import { useAppSelector } from '../redux/hooks';
6 |
7 | export default function Content() {
8 | const { type, id } = useParams();
9 |
10 | const content = useAppSelector((state) =>
11 | type && id ? state.data[`${type as ContentType}Map`][id] : undefined,
12 | );
13 |
14 | return content && ;
15 | }
16 |
--------------------------------------------------------------------------------
/src/pages/doc/_doc.tsx:
--------------------------------------------------------------------------------
1 | import { Trans } from '@lingui/react/macro';
2 | import classNames from 'classnames';
3 | import { Link, Route } from 'wouter';
4 |
5 | import styles from '../../css/doc.module.css';
6 | import About from './about';
7 | import Changelog from './changelog';
8 | import Readme from './readme';
9 |
10 | const Doc = () => {
11 | return (
12 |
13 |
14 |
15 |
16 |
17 |
18 | 返回
19 |
20 |
21 |
22 | );
23 | };
24 |
25 | export default Doc;
26 |
--------------------------------------------------------------------------------
/src/pages/doc/about/_en.mdx:
--------------------------------------------------------------------------------
1 | # About
2 |
3 | > TODO: translation
4 |
5 | 做为一只贵系(计算机系的别称)的孩子,每周上双位数的作业实在是让人叫苦不迭。
6 | 我的脑子总是记不住繁多的作业 TT,网络学堂设计的不合理以及经常挂掉,使我有了兴趣来做这个网络学堂改造器。
7 |
8 | Contact us:
9 |
10 | * [xxr3376](mailto:xxr3376@gmail.com): Creator
11 | * [Harry-Chen](mailto:harry-chen@outlook.com): Current maintainer
12 | * [AsakuraMizu](mailto:asakuramizu111@gmail.com): Current maintainer
13 |
14 | This extension is open source on [GitHub](https://github.com/Harry-Chen/Learn-Helper) under the MIT license with additional terms.
15 | Before using the source code of this extension, please ensure that you have fully understood and accepted the content of the agreement and its additional terms.
16 |
17 | This extension will not send any information to any site except Tsinghua University Web Learning and Electric ID Service System.
18 | With the user's explicit consent, the plugin stores user credentials locally.
19 |
20 | This extension provides a best-effort service, but the author is not responsible for any consequences caused by using this extension,
21 | This includes, but is not limited to, downloaded files being corrupted, assignment deadlines missed, notifications not being updated in a timely manner, Web Learning being unavailable or being attacked,
22 | and any collateral consequences they result in, such as failing a course, missing an exam, or having a user's computer compromised by malware.
23 |
24 | Thanks:
25 |
26 | - jiegec、CircuitCoder:为 4.0 的诞生做出了巨大的贡献
27 | - yaoht、moreD 等贡献者:对 2015 版网络学堂修复和支持
28 | - 李百恩:无敌的技术支持以及最好的美工
29 | - 贵系老师:繁多的作业催生了这个插件的产生
30 | - React 框架:清晰的逻辑,前后端分离
31 | - MUI & FontAwesome:清新漂亮的界面
32 |
--------------------------------------------------------------------------------
/src/pages/doc/about/_zh.mdx:
--------------------------------------------------------------------------------
1 | # 关于我们
2 |
3 | 做为一只贵系(计算机系的别称)的孩子,每周上双位数的作业实在是让人叫苦不迭。
4 | 我的脑子总是记不住繁多的作业 TT,网络学堂设计的不合理以及经常挂掉,使我有了兴趣来做这个网络学堂改造器。
5 |
6 | 联系我们:
7 |
8 | - [xxr3376](mailto:xxr3376@gmail.com):初始开发者
9 | - [Harry-Chen](mailto:harry-chen@outlook.com):当前维护者
10 | - [AsakuraMizu](mailto:asakuramizu111@gmail.com):当前维护者
11 |
12 | 本插件以含附加条款的 MIT 协议在 [GitHub](https://github.com/Harry-Chen/Learn-Helper) 开放源代码。
13 | 在使用本插件的源代码前,请确保您已经充分理解并接受协议及其附加条款的内容。
14 |
15 | 本插件不会向除清华大学网络学堂服务以及身份认证服务外的任何站点发送任何信息。
16 | 在用户明确同意的情况下,本插件会在本地存储用户凭据。
17 |
18 | 本插件提供尽力而为的服务,但作者不对使用本插件导致的任何后果负任何责任,
19 | 包括且不限于下载文件损坏、错过作业截止日期、通知未及时更新、网络学堂不可用或被攻击,
20 | 以及它们导致的任何连带后果,如课程不及格、考试缺席或用户计算机被恶意软件侵入。
21 |
22 | 致谢:
23 |
24 | - jiegec、CircuitCoder:为 4.0 的诞生做出了巨大的贡献
25 | - yaoht、moreD 等贡献者:对 2015 版网络学堂修复和支持
26 | - 李百恩:无敌的技术支持以及最好的美工
27 | - 贵系老师:繁多的作业催生了这个插件的产生
28 | - React 框架:清晰的逻辑,前后端分离
29 | - MUI & FontAwesome:清新漂亮的界面
30 |
--------------------------------------------------------------------------------
/src/pages/doc/about/index.tsx:
--------------------------------------------------------------------------------
1 | import { useLingui } from '@lingui/react';
2 |
3 | import styles from '../../../css/doc.module.css';
4 |
5 | import AboutEN from './_en.mdx';
6 | import AboutZH from './_zh.mdx';
7 |
8 | export default function About() {
9 | const { i18n } = useLingui();
10 | return (
11 | {i18n.locale === 'zh' ?
:
}
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/src/pages/doc/changelog.tsx:
--------------------------------------------------------------------------------
1 | import { useLingui } from '@lingui/react';
2 |
3 | import ChangelogEN from '../../../CHANGELOG.md';
4 | import ChangelogZH from '../../../CHANGELOG_ZH.md';
5 | import styles from '../../css/doc.module.css';
6 |
7 | export default function Changelog() {
8 | const { i18n } = useLingui();
9 | return (
10 |
11 | {i18n.locale === 'zh' ? : }
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/src/pages/doc/readme/_en.mdx:
--------------------------------------------------------------------------------
1 | import styles from '../../../css/doc.module.css';
2 |
3 | # Manual
4 |
5 |
6 | 
7 |
8 | ## New item
9 | New items will appear at the top of the list, with a red dot indicating unread. This is a new homework, labeled with the number of days remaining.
10 |
11 |
12 |
13 | 
14 |
15 | ## Starred item
16 | Starred items will always remain at the top of the list. This is a starred notification, click to see its details.
17 |
18 |
19 |
20 | 
21 |
22 | ## Homework
23 | There are three colors: red, orange, and blue indicating the remaining time, and a submit button in the lower left corner. Homeworks that are submitted is marked with a green tick.
24 |
25 |
26 |
27 | 
28 |
29 | ## Expired homework
30 | Homeworks that have passed the deadline will become grey, and display the score and the reviewer if graded.
31 |
32 |
33 |
34 |
35 | 
36 |
37 | ## File
38 | Click on the title to download the file, or directly mark it as read (not synchronized to Web Learning).
39 |
40 |
41 |
42 |
43 | 
44 |
45 | ## Switch content
46 | Switch to view the overview panel of different type of items, with the corner mark showing unread count.
47 |
48 |
49 |
50 |
51 | 
52 |
53 | ## Switch course
54 | Click on the course name on the left to view the contents of the course, or open the course homepage on Web Learning.
55 |
56 |
57 |
58 |
59 | 
60 |
61 | ## Filter
62 | Use filters to easily find the one you want among hundreds or thousands of items.
63 |
64 |
65 |
66 |
67 | 
68 |
69 | ## Extension settings
70 | You can hide part of the content of some courses. And we also thoughtfully provide a one-click function to mark everying as read.
71 |
72 |
73 |
74 |
75 | 
76 |
77 | ## Other settings
78 | If you encounter an error, try refreshing, clearing data or logging out.
79 |
80 |
81 |
--------------------------------------------------------------------------------
/src/pages/doc/readme/_zh.mdx:
--------------------------------------------------------------------------------
1 | import styles from '../../../css/doc.module.css';
2 |
3 | # 使用手册
4 |
5 |
6 | 
7 |
8 | ## 未读项目
9 | 新的项目会出现在列表顶部,红色的点表示未读。这是一份新作业,标有剩余天数。
10 |
11 |
12 |
13 | 
14 |
15 | ## 星标项目
16 | 加星标的项目会始终保持在列表顶部。这是一条加星的通知,点击即可进入详情。
17 |
18 |
19 |
20 | 
21 |
22 | ## 作业提示
23 | 有红、橙、蓝三种颜色对应剩余时间,左下角有提交按钮。已交的作业是绿色的勾。
24 |
25 |
26 |
27 | 
28 |
29 | ## 截止作业
30 | 过了截止日期的作业是灰色的笔或者勾,会显示提交和批阅状态(分数和评阅人)。
31 |
32 |
33 |
34 |
35 | 
36 |
37 | ## 课程文件
38 | 点击标题即可下载文件,也可直接标记为已读(不同步到网络学堂)。
39 |
40 |
41 |
42 |
43 | 
44 |
45 | ## 切换内容
46 | 在左上角可以切换查看各项信息的总览面板,角标是未读数量。
47 |
48 |
49 |
50 |
51 | 
52 |
53 | ## 切换课程
54 | 点击左侧课程名字,可以查看该课程的各项内容,或者打开课程主页。
55 |
56 |
57 |
58 |
59 | 
60 |
61 | ## 卡片过滤
62 | 使用过滤器,轻松地在成百上千个卡片中找到你要的那一个。
63 |
64 |
65 |
66 |
67 | 
68 |
69 | ## 插件设置
70 | 可以隐藏某些课程的部分内容以免刷屏,也贴心地提供了一键解决强迫症的功能。
71 |
72 |
73 |
74 |
75 | 
76 |
77 | ## 其他设置
78 | 遇到错误不妨试一下,强制刷新、清空缓存、退出登录,总有一个适合你!
79 |
80 |
81 |
--------------------------------------------------------------------------------
/src/pages/doc/readme/index.tsx:
--------------------------------------------------------------------------------
1 | import { useLingui } from '@lingui/react';
2 |
3 | import ReadmeEN from './_en.mdx';
4 | import ReadmeZH from './_zh.mdx';
5 |
6 | export default function Readme() {
7 | const { i18n } = useLingui();
8 | return i18n.locale === 'zh' ? : ;
9 | }
10 |
--------------------------------------------------------------------------------
/src/pages/settings.tsx:
--------------------------------------------------------------------------------
1 | import { useLingui } from '@lingui/react';
2 | import { Trans } from '@lingui/react/macro';
3 |
4 | import {
5 | Button,
6 | Paper,
7 | Switch,
8 | Table,
9 | TableBody,
10 | TableCell,
11 | TableHead,
12 | TableRow,
13 | } from '@mui/material';
14 |
15 | import { COURSE_MAIN_FUNC } from '../constants/ui';
16 | import styles from '../css/page.module.css';
17 | import { resetContentIgnore, toggleContentIgnore } from '../redux/actions';
18 | import { useAppDispatch, useAppSelector } from '../redux/hooks';
19 |
20 | const ContentIgnoreSetting = () => {
21 | const { _ } = useLingui();
22 | const dispatch = useAppDispatch();
23 | const courses = useAppSelector((state) => state.data.courseMap);
24 | const contentIgnore = useAppSelector((state) => state.data.contentIgnore);
25 |
26 | return (
27 |
28 |
29 | 管理隐藏项
30 |
31 |
32 |
33 | 此处的更改在下一次刷新时生效,并且只在汇总功能中起作用。
34 |
35 | 如果您重新启用一个隐藏的项目,原本的项目属性(是否已读、加星标)不会发生变化。
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | 课程名称
45 |
46 | {Object.values(COURSE_MAIN_FUNC).map((func) => (
47 |
48 | {_(func.name)}
49 |
50 | ))}
51 |
52 |
53 |
54 | {Object.entries(contentIgnore).map(([cid, ignore]) => (
55 |
56 |
57 | {_({ id: `course-${courses[cid].id}` })}
58 |
59 | {Object.values(COURSE_MAIN_FUNC).map((func) => (
60 |
61 | {
64 | dispatch(
65 | toggleContentIgnore({
66 | id: cid,
67 | type: func.type,
68 | state: !ignore[func.type],
69 | }),
70 | );
71 | }}
72 | />
73 |
74 | ))}
75 |
76 | ))}
77 |
78 |
79 |
80 |
81 | {
85 | dispatch(resetContentIgnore());
86 | }}
87 | >
88 | 重置
89 |
90 |
91 |
92 |
93 | );
94 | };
95 |
96 | export default ContentIgnoreSetting;
97 |
--------------------------------------------------------------------------------
/src/pages/web.tsx:
--------------------------------------------------------------------------------
1 | import { useParams } from 'wouter';
2 |
3 | import IframeWrapper from '../components/IframeWrapper';
4 | import styles from '../css/main.module.css';
5 |
6 | export default function Web() {
7 | const { url } = useParams();
8 |
9 | return (
10 | url && (
11 |
18 | )
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/pages/welcome.tsx:
--------------------------------------------------------------------------------
1 | import { Trans } from '@lingui/react/macro';
2 | import classNames from 'classnames';
3 | import { Link } from 'wouter';
4 |
5 | import styles from '../css/doc.module.css';
6 | import bg from '../image/bg.png';
7 | import bgDark from '../image/bg_dark.png';
8 |
9 | const Welcome = () => {
10 | return (
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | 使用手册
20 |
21 | {' | '}
22 |
23 | 关于我们
24 |
25 | {' | '}
26 |
27 | 更新记录
28 |
29 |
30 |
35 | Chrome Store
36 |
37 | {' | '}
38 |
43 | Edge Addons
44 |
45 | {' | '}
46 |
47 | FireFox / 开发版
48 |
49 |
50 | {__GIT_VERSION__} (built on {__BUILD_HOSTNAME__} at {__BUILD_TIME__})
51 |
52 |
53 |
54 | thu-learn-lib: v{__THU_LEARN_LIB_VERSION__}
55 | React: v{__REACT_VERSION__}
56 | MUI: v{__MUI_VERSION__}
57 |
58 |
59 |
60 |
67 |
68 | );
69 | };
70 |
71 | export default Welcome;
72 |
--------------------------------------------------------------------------------
/src/redux/actions.ts:
--------------------------------------------------------------------------------
1 | import { i18n } from '@lingui/core';
2 | import { t } from '@lingui/core/macro';
3 | import { compileMessage } from '@lingui/message-utils/compileMessage';
4 | import type { Action, ThunkAction } from '@reduxjs/toolkit';
5 | import { compare as compareVersion } from 'compare-versions';
6 | import { enqueueSnackbar } from 'notistack';
7 | import { type ApiError, ContentType, CourseType, type Language, SemesterType } from 'thu-learn-lib';
8 |
9 | import { version as currentVersion } from '../../package.json';
10 | import { STORAGE_KEY_REDUX, STORAGE_KEY_REDUX_LEGACY, STORAGE_KEY_VERSION } from '../constants';
11 | import type { ContentInfo, FileInfo } from '../types/data';
12 | import { initiateFileDownload } from '../utils/download';
13 | import { failReasonToString } from '../utils/format';
14 | import { getStoredCredential, storeCredential } from '../utils/storage';
15 |
16 | import { dataSlice } from './reducers/data';
17 | import { helperSlice } from './reducers/helper';
18 | import { uiSlice } from './reducers/ui';
19 | import { selectContentIgnore, selectDataLists } from './selectors';
20 | import type { RootState } from './store';
21 |
22 | export const {
23 | newSemester,
24 | insistSemester,
25 | updateSemesterList,
26 | updateSemester,
27 | syncSemester,
28 | updateCourses,
29 | updateNotification,
30 | updateFile,
31 | updateHomework,
32 | updateDiscussion,
33 | updateQuestion,
34 | updateFinished,
35 | toggleReadState,
36 | toggleStarState,
37 | toggleIgnoreState,
38 | toggleContentIgnore,
39 | resetContentIgnore,
40 | markAllRead,
41 | clearAllData,
42 | clearFetchedData,
43 | loadData,
44 | } = dataSlice.actions;
45 | export const { loggedIn, loggedOut } = helperSlice.actions;
46 | export const {
47 | setLoadingProgress,
48 | togglePaneHidden,
49 | toggleLoginDialog,
50 | toggleLoginDialogProgress,
51 | loginEnd,
52 | toggleNetworkErrorDialog,
53 | toggleNewSemesterDialog,
54 | toggleIgnoreWrongSemester,
55 | toggleLogoutDialog,
56 | toggleClearDataDialog,
57 | toggleChangeSemesterDialog,
58 | resetCardVisibilityThreshold,
59 | loadMoreCard,
60 | setCardList,
61 | setCardFilter,
62 | setTitleFilter,
63 | } = uiSlice.actions;
64 |
65 | export type AppThunk = ThunkAction;
66 |
67 | // here we don't catch errors in login(), for there are two cases:
68 | // 1. silent login when starting, then NetworkErrorDialog should be shown
69 | // 2. explicit login in LoginDialog, then login dialog should still be shown
70 | export const login =
71 | (username: string, password: string, save: boolean): AppThunk> =>
72 | async (dispatch, getState) => {
73 | dispatch(toggleLoginDialogProgress(true));
74 | const helperState = getState().helper;
75 | const helper = helperState.helper;
76 |
77 | // wait at most 5 seconds for timeout
78 | const timeout = new Promise((_, reject) => {
79 | setTimeout(() => {
80 | reject({ reason: 'TIMEOUT' });
81 | }, 5000);
82 | });
83 |
84 | try {
85 | await Promise.race([helper.login(username, password), timeout]);
86 | } catch (e) {
87 | const error = e as ApiError;
88 | enqueueSnackbar(
89 | t`登录失败:${failReasonToString(error?.reason) ?? error.toString() ?? t`未知错误`}`,
90 | { variant: 'error' },
91 | );
92 | dispatch(loginEnd());
93 | return Promise.reject(`login failed: ${error}`);
94 | }
95 |
96 | // login succeeded
97 | // hide login dialog (if shown), show success notice
98 | enqueueSnackbar(t`登录成功`, { variant: 'success' });
99 | // save salted user credential if asked
100 | if (save) {
101 | await storeCredential(username, password);
102 | }
103 | dispatch(loggedIn());
104 | dispatch(loginEnd());
105 | dispatch(syncLanguage());
106 | return Promise.resolve();
107 | };
108 |
109 | export const refreshIfNeeded = (): AppThunk> => async (dispatch, getState) => {
110 | const data = getState().data;
111 | const justUpdated = new Date().getTime() - data.lastUpdateTime.getTime() <= 15 * 60 * 1000;
112 | if (data.updateFinished && justUpdated) {
113 | enqueueSnackbar(t`离上次成功刷新不足15分钟,若需要可手动刷新`, { variant: 'info' });
114 | } else {
115 | await dispatch(refresh());
116 | }
117 | };
118 |
119 | export const refresh = (): AppThunk> => async (dispatch, getState) => {
120 | dispatch(setLoadingProgress(0));
121 | const helperState = getState().helper;
122 | const helper = helperState.helper;
123 |
124 | let allCourseIds: string[] = [];
125 |
126 | const progresses = [0, 10, 20, 38, 52, 68, 84, 100];
127 | const nextProgress = () => {
128 | const uiState = getState().ui;
129 | const currentProgress = uiState.loadingProgress!;
130 | const index = progresses.indexOf(currentProgress);
131 | let nextProgress = 100;
132 | if (index >= 0 && index < progresses.length) {
133 | nextProgress = progresses[index + 1];
134 | } else {
135 | console.warn(`Next progress not found, current ${currentProgress}`, progresses);
136 | }
137 | dispatch(setLoadingProgress(nextProgress));
138 | };
139 |
140 | try {
141 | // login on every refresh (if stored)
142 | const credential = await getStoredCredential();
143 | credential && (await helper.login(credential.username, credential.password));
144 | dispatch(loggedIn());
145 |
146 | const semesters = await helper.getSemesterIdList();
147 | dispatch(updateSemesterList(semesters));
148 |
149 | const s = await helper.getCurrentSemester();
150 | dispatch(newSemester(s));
151 |
152 | // user required to ignore semester problem
153 | const data = getState().data;
154 | const ui = getState().ui;
155 | const ignoreSemester = data.insistSemester || ui.ignoreWrongSemester;
156 |
157 | if (data.semester.type === SemesterType.UNKNOWN) {
158 | // no semester info yet
159 | dispatch(updateSemester(s));
160 | } else if (s.id !== data.semester.id && !ignoreSemester) {
161 | // stored semester differ with fetched one
162 | dispatch(toggleNewSemesterDialog(true));
163 | return;
164 | }
165 |
166 | nextProgress();
167 |
168 | // get the latest semester id, since it can either be changed or not
169 | const currentSemesterId = getState().data.semester.id;
170 | // get all courses
171 | const courses = await helper.getCourseList(currentSemesterId);
172 | allCourseIds = courses.map((c) => c.id);
173 | dispatch(updateCourses(courses));
174 |
175 | dispatch(updateCourseNames());
176 | nextProgress();
177 | } catch (e) {
178 | console.error(e);
179 | dispatch(setLoadingProgress());
180 | dispatch(toggleNetworkErrorDialog(true));
181 | return;
182 | }
183 |
184 | // send all requests in parallel
185 | const fetchAll = [
186 | async () => {
187 | const res = await helper.getAllContents(
188 | allCourseIds,
189 | ContentType.NOTIFICATION,
190 | CourseType.STUDENT,
191 | true,
192 | );
193 | dispatch(updateNotification(res));
194 | nextProgress();
195 | },
196 | async () => {
197 | const res = await helper.getAllContents(
198 | allCourseIds,
199 | ContentType.HOMEWORK,
200 | CourseType.STUDENT,
201 | true,
202 | );
203 | dispatch(updateHomework(res));
204 | nextProgress();
205 | },
206 | async () => {
207 | const res = await helper.getAllContents(
208 | allCourseIds,
209 | ContentType.FILE,
210 | CourseType.STUDENT,
211 | true,
212 | );
213 | dispatch(updateFile(res));
214 | nextProgress();
215 | },
216 | async () => {
217 | const res = await helper.getAllContents(
218 | allCourseIds,
219 | ContentType.DISCUSSION,
220 | CourseType.STUDENT,
221 | true,
222 | );
223 | dispatch(updateDiscussion(res));
224 | nextProgress();
225 | },
226 | async () => {
227 | const res = await helper.getAllContents(
228 | allCourseIds,
229 | ContentType.QUESTION,
230 | CourseType.STUDENT,
231 | true,
232 | );
233 | dispatch(updateQuestion(res));
234 | nextProgress();
235 | },
236 | ];
237 |
238 | // check results
239 | const failures = (await Promise.allSettled(fetchAll.map((f) => f()))).filter(
240 | (p) => p.status === 'rejected',
241 | );
242 | const allSuccess = failures.length === 0;
243 | if (allSuccess) {
244 | enqueueSnackbar(t`更新成功`, { variant: 'success' });
245 | } else {
246 | enqueueSnackbar(t`部分内容更新失败`, { variant: 'warning' });
247 | console.warn('Failures occurred in fetching data', failures);
248 | }
249 |
250 | // finish refreshing
251 | dispatch(updateFinished());
252 | dispatch(refreshCardList());
253 |
254 | // wait some time before hiding progress bar
255 | new Promise((resolve) => {
256 | setTimeout(() => {
257 | dispatch(setLoadingProgress());
258 | resolve();
259 | }, 1000);
260 | });
261 | };
262 |
263 | export const syncLanguage = (): AppThunk> => async (_dispatch, getState) => {
264 | // try to sync language with Web Learning
265 | const { helper } = getState().helper;
266 | try {
267 | await helper.setLanguage(i18n.locale as Language);
268 | } catch (e) {
269 | const error = e as ApiError;
270 | enqueueSnackbar(
271 | t`设置网络学堂语言失败:${
272 | failReasonToString(error?.reason) ?? error.toString() ?? t`未知错误`
273 | }`,
274 | { variant: 'error' },
275 | );
276 | }
277 | };
278 |
279 | export const updateCourseNames = (): AppThunk => (_dispatch, getState) => {
280 | const { courseMap } = getState().data;
281 | // load course names to i18n
282 | i18n.load(
283 | 'zh',
284 | Object.fromEntries(
285 | Object.values(courseMap).map(({ id, chineseName }) => [
286 | `course-${id}`,
287 | compileMessage(chineseName),
288 | ]),
289 | ),
290 | );
291 | i18n.load(
292 | 'en',
293 | Object.fromEntries(
294 | Object.values(courseMap).map(({ id, englishName }) => [
295 | `course-${id}`,
296 | compileMessage(englishName),
297 | ]),
298 | ),
299 | );
300 | };
301 |
302 | const compareBoolean = (a: boolean, b: boolean) => {
303 | if (a === b) return 0;
304 | if (a) return -1;
305 | if (b) return 1;
306 | };
307 |
308 | export const refreshCardList = (): AppThunk => (dispatch, getState) => {
309 | const state = getState();
310 | const data = selectDataLists(state);
311 | const contentIgnore = selectContentIgnore(state);
312 | const { type, courseId } = state.ui.cardFilter;
313 |
314 | let contents: ContentInfo[];
315 | if (type && type !== 'ignored') {
316 | contents = data[`${type}List`].slice(0);
317 | } else {
318 | contents = ([] as ContentInfo[]).concat(
319 | data.notificationList,
320 | data.fileList,
321 | data.homeworkList,
322 | data.discussionList,
323 | data.questionList,
324 | );
325 | }
326 |
327 | dispatch(
328 | setCardList(
329 | contents
330 | .filter((c) =>
331 | type === 'ignored'
332 | ? c.ignored
333 | : courseId
334 | ? c.courseId === courseId
335 | : !contentIgnore[c.courseId]?.[c.type] && !c.ignored,
336 | )
337 | .sort((a, b) => {
338 | const aNotDue =
339 | a.type === ContentType.HOMEWORK && a.date.getTime() > new Date().getTime();
340 | const bNotDue =
341 | b.type === ContentType.HOMEWORK && b.date.getTime() > new Date().getTime();
342 | return (
343 | compareBoolean(a.starred, b.starred) ||
344 | compareBoolean(!a.hasRead, !b.hasRead) ||
345 | compareBoolean(aNotDue, bNotDue) ||
346 | compareBoolean(aNotDue && !a.submitted, bNotDue && !b.submitted) ||
347 | (a.date.getTime() - b.date.getTime()) * (aNotDue && bNotDue ? 1 : -1)
348 | );
349 | })
350 | .map(({ type, id }) => ({ type, id })),
351 | ),
352 | );
353 | dispatch(resetCardVisibilityThreshold());
354 | };
355 |
356 | export const downloadAllUnreadFiles =
357 | (contents: ContentInfo[]): AppThunk> =>
358 | async (dispatch, getState) => {
359 | const helper = getState().helper.helper;
360 | try {
361 | await helper.getSemesterIdList();
362 | } catch (e) {
363 | enqueueSnackbar(t`登录已过期,请刷新后重试`, { variant: 'error' });
364 | return;
365 | }
366 | for (const c of contents) {
367 | if (c.type === ContentType.FILE && !c.hasRead) {
368 | const file = c as FileInfo;
369 | initiateFileDownload(file.downloadUrl);
370 | dispatch(toggleReadState({ id: file.id, type: ContentType.FILE, state: true }));
371 | }
372 | }
373 | };
374 |
375 | export interface LoadResult {
376 | navigate?: 'readme' | 'changelog';
377 | }
378 |
379 | export const loadApp = (): AppThunk> => async (dispatch) => {
380 | if (import.meta.env.DEV) {
381 | const { VITE_USERNAME: username, VITE_PASSWORD: password } = import.meta.env;
382 | if (username && password) {
383 | await browser.storage.local.set({ [STORAGE_KEY_VERSION]: currentVersion });
384 | await storeCredential(username, password);
385 | }
386 | }
387 |
388 | const { [STORAGE_KEY_VERSION]: oldVersion, [STORAGE_KEY_REDUX]: oldData } =
389 | await browser.storage.local.get([STORAGE_KEY_VERSION, STORAGE_KEY_REDUX]);
390 |
391 | const result: LoadResult = {};
392 |
393 | if (oldVersion === undefined) {
394 | // migrate from version < 4.0.0 or newly installed, clearing all data
395 | console.info('Migrating from legacy version, all data cleaned');
396 | await browser.storage.local.clear();
397 | await browser.storage.local.set({
398 | [STORAGE_KEY_VERSION]: currentVersion,
399 | });
400 | result.navigate = 'readme';
401 | enqueueSnackbar(t`升级成功,所有本地数据已经被清除`, { variant: 'warning' });
402 | } else if (oldVersion !== currentVersion) {
403 | // for future migration
404 | result.navigate = 'changelog';
405 | // set stored version to current one
406 | console.info(`Migrating from version ${oldVersion} to ${currentVersion}`);
407 | await browser.storage.local.set({
408 | [STORAGE_KEY_VERSION]: currentVersion,
409 | });
410 |
411 | // migrate from < 4.5, clearing all data except credential & config
412 | if (compareVersion(oldVersion, '4.5.0', '<')) {
413 | dispatch(clearFetchedData());
414 | enqueueSnackbar(t`升级成功,所有本地数据(除配置)已经被清除`, { variant: 'warning' });
415 | } else {
416 | try {
417 | if (compareVersion(oldVersion, '4.6.0', '<')) {
418 | dispatch(
419 | loadData(
420 | JSON.parse(
421 | JSON.parse(
422 | JSON.parse(
423 | (await browser.storage.local.get([STORAGE_KEY_REDUX_LEGACY]))[
424 | STORAGE_KEY_REDUX_LEGACY
425 | ],
426 | ).data,
427 | ),
428 | (key, value) => {
429 | if (typeof value === 'object' && '$jsan' in value) {
430 | // parse jsan
431 | // from https://github.com/kolodny/jsan/blob/7216568a9a7969dfa81b834236595e862fdde984/lib/utils.js#L23C48-L23C48
432 | const type = value.$jsan[0];
433 | const rest = value.$jsan.slice(1);
434 | if (type === 'd') return new Date(+rest);
435 | if (type === 'u') return undefined;
436 | // other types is not needed;
437 | }
438 | if (key.endsWith('Map')) return value.data;
439 | return value;
440 | },
441 | ),
442 | ),
443 | );
444 | }
445 | enqueueSnackbar(t`升级成功,数据没有受到影响`, { variant: 'info' });
446 | } catch (e) {
447 | await browser.storage.local.clear();
448 | enqueueSnackbar(t`迁移失败,已清除全部数据`, { variant: 'error' });
449 | }
450 | }
451 | } else {
452 | console.info('Migration not necessary.');
453 |
454 | if (oldData !== undefined) {
455 | try {
456 | dispatch(
457 | loadData(
458 | JSON.parse(oldData, (key, value) => {
459 | if (
460 | key === 'date' ||
461 | key === 'deadline' ||
462 | key.endsWith('Date') ||
463 | key.endsWith('Time')
464 | )
465 | return new Date(value);
466 | return value;
467 | }),
468 | ),
469 | );
470 | } catch (e) {
471 | await browser.storage.local.remove(STORAGE_KEY_REDUX);
472 | enqueueSnackbar(t`加载数据失败,已清除数据`, { variant: 'error' });
473 | }
474 | }
475 | }
476 |
477 | dispatch(updateCourseNames());
478 | dispatch(refreshCardList());
479 | dispatch(tryLoginSilently());
480 | return result;
481 | };
482 |
483 | export const tryLoginSilently = (): AppThunk> => async (dispatch) => {
484 | const credential = await getStoredCredential();
485 | if (!credential) {
486 | dispatch(toggleLoginDialog(true));
487 | return;
488 | }
489 | try {
490 | await dispatch(login(credential.username, credential.password, false));
491 | await dispatch(refreshIfNeeded());
492 | } catch (e) {
493 | // here we catch only login problems
494 | // for refresh() has a try-catch block in itself
495 | dispatch(toggleNetworkErrorDialog(true));
496 | }
497 | };
498 |
--------------------------------------------------------------------------------
/src/redux/hooks.ts:
--------------------------------------------------------------------------------
1 | import { type TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
2 | import type { AppDispatch, RootState } from './store';
3 |
4 | // Use throughout your app instead of plain `useDispatch` and `useSelector`
5 | export const useAppDispatch: () => AppDispatch = useDispatch;
6 | export const useAppSelector: TypedUseSelectorHook = useSelector;
7 |
--------------------------------------------------------------------------------
/src/redux/reducers/data.ts:
--------------------------------------------------------------------------------
1 | import { type PayloadAction, createSlice } from '@reduxjs/toolkit';
2 | import {
3 | ContentType,
4 | type CourseContent,
5 | type CourseInfo,
6 | type Homework,
7 | type SemesterInfo,
8 | SemesterType,
9 | } from 'thu-learn-lib';
10 |
11 | import type {
12 | DiscussionInfo,
13 | FileInfo,
14 | HomeworkInfo,
15 | NotificationInfo,
16 | QuestionInfo,
17 | SupportedContentType,
18 | } from '../../types/data';
19 |
20 | interface IContentIgnore {
21 | [courseId: string]: {
22 | [type: string]: boolean;
23 | };
24 | }
25 |
26 | export interface DataState {
27 | semesters: string[]; // all available semesters return by Web Learning
28 | semester: SemesterInfo; // current semester of Learn Helper
29 | fetchedSemester: SemesterInfo; // current semester of Web Learning
30 | insistSemester: boolean;
31 | courseMap: Record;
32 | notificationMap: Record;
33 | fileMap: Record;
34 | homeworkMap: Record;
35 | discussionMap: Record;
36 | questionMap: Record;
37 | lastUpdateTime: Date;
38 | updateFinished: boolean;
39 | contentIgnore: IContentIgnore;
40 | }
41 |
42 | const semesterPlaceholder: SemesterInfo = {
43 | id: '',
44 | startDate: new Date(),
45 | endDate: new Date(),
46 | startYear: 0,
47 | endYear: 0,
48 | type: SemesterType.UNKNOWN,
49 | };
50 |
51 | const initialState: DataState = {
52 | semesters: [],
53 | semester: semesterPlaceholder,
54 | fetchedSemester: semesterPlaceholder,
55 | insistSemester: false,
56 | courseMap: {},
57 | notificationMap: {},
58 | fileMap: {},
59 | homeworkMap: {},
60 | discussionMap: {},
61 | questionMap: {},
62 | lastUpdateTime: new Date(0),
63 | updateFinished: false,
64 | contentIgnore: {},
65 | };
66 |
67 | function update(
68 | state: DataState,
69 | contentType: T,
70 | fetchedData: CourseContent,
71 | ) {
72 | const oldData = state[`${contentType}Map`];
73 |
74 | const result = {} as typeof oldData;
75 | const dateKeyMap = {
76 | [ContentType.NOTIFICATION]: 'publishTime',
77 | [ContentType.FILE]: 'uploadTime',
78 | [ContentType.HOMEWORK]: 'deadline',
79 | [ContentType.DISCUSSION]: 'publishTime',
80 | [ContentType.QUESTION]: 'publishTime',
81 | } as const;
82 | const dateKey = dateKeyMap[contentType];
83 |
84 | // we always use the fetched data
85 | for (const [cid, contents] of Object.entries(fetchedData)) {
86 | for (const c of contents) {
87 | // compare the time of two contents (including undefined)
88 | // if they differ, mark the content as unread
89 | const oldContent = oldData[c.id];
90 | // FIXME: type issues
91 | const newDate = c[dateKey];
92 | let updated = true;
93 | if (oldContent) {
94 | if (newDate.getTime() === oldContent[dateKey].getTime()) {
95 | // the date is not modified
96 | updated = false;
97 | if (contentType === ContentType.HOMEWORK) {
98 | const oldGradeTime = (oldContent as Homework).gradeTime;
99 | const newGradeTime = (c as Homework).gradeTime;
100 | if (newGradeTime && !oldGradeTime) {
101 | // newly-graded homework
102 | updated = true;
103 | } else if (
104 | newGradeTime &&
105 | oldGradeTime &&
106 | // re-graded homework
107 | newGradeTime.getTime() !== oldGradeTime.getTime()
108 | ) {
109 | updated = true;
110 | }
111 | }
112 | }
113 | }
114 | // copy other attributes either way
115 | result[c.id] = {
116 | ...c,
117 | courseId: cid,
118 | ignored: oldContent?.ignored ?? false,
119 | type: contentType,
120 | date: newDate,
121 | hasRead: !updated && (oldContent?.hasRead ?? false),
122 | starred: oldContent?.starred ?? false,
123 | };
124 | }
125 | }
126 |
127 | state[`${contentType}Map`] = result;
128 | }
129 |
130 | interface ToggleStatePayload {
131 | id: string;
132 | type: SupportedContentType;
133 | state: boolean;
134 | }
135 |
136 | const IGNORE_UNSET_ALL = {
137 | [ContentType.NOTIFICATION]: false,
138 | [ContentType.FILE]: false,
139 | [ContentType.HOMEWORK]: false,
140 | [ContentType.QUESTION]: false,
141 | [ContentType.DISCUSSION]: false,
142 | };
143 |
144 | export const dataSlice = createSlice({
145 | name: 'data',
146 | initialState,
147 | reducers: {
148 | newSemester: (state, action: PayloadAction) => {
149 | state.fetchedSemester = action.payload;
150 | },
151 | insistSemester: (state, action: PayloadAction) => {
152 | state.insistSemester = action.payload;
153 | },
154 | updateSemesterList: (state, action: PayloadAction) => {
155 | state.semesters = action.payload;
156 | },
157 | updateSemester: (state, action: PayloadAction) => {
158 | state.semester = action.payload;
159 | },
160 | syncSemester: (state) => {
161 | state.semester = state.fetchedSemester;
162 | },
163 | updateCourses: (state, action: PayloadAction) => {
164 | action.payload.sort((a, b) => a.id.localeCompare(b.id));
165 | state.courseMap = Object.fromEntries(action.payload.map((course) => [course.id, course]));
166 | for (const cid of Object.keys(state.contentIgnore)) {
167 | if (!state.courseMap[cid]) delete state.contentIgnore[cid];
168 | }
169 | for (const cid of Object.keys(state.courseMap)) {
170 | state.contentIgnore[cid] ??= { ...IGNORE_UNSET_ALL };
171 | }
172 | },
173 | updateNotification: (state, action: PayloadAction>) => {
174 | update(state, ContentType.NOTIFICATION, action.payload);
175 | state.lastUpdateTime = new Date();
176 | state.updateFinished = false;
177 | },
178 | updateFile: (state, action: PayloadAction>) => {
179 | update(state, ContentType.FILE, action.payload);
180 | state.lastUpdateTime = new Date();
181 | state.updateFinished = false;
182 | },
183 | updateHomework: (state, action: PayloadAction>) => {
184 | update(state, ContentType.HOMEWORK, action.payload);
185 | state.lastUpdateTime = new Date();
186 | state.updateFinished = false;
187 | },
188 | updateDiscussion: (state, action: PayloadAction>) => {
189 | update(state, ContentType.DISCUSSION, action.payload);
190 | state.lastUpdateTime = new Date();
191 | state.updateFinished = false;
192 | },
193 | updateQuestion: (state, action: PayloadAction>) => {
194 | update(state, ContentType.QUESTION, action.payload);
195 | state.lastUpdateTime = new Date();
196 | state.updateFinished = false;
197 | },
198 | updateFinished: (state) => {
199 | state.updateFinished = true;
200 | },
201 | toggleReadState: (state, action: PayloadAction) => {
202 | state[`${action.payload.type}Map`][action.payload.id].hasRead = action.payload.state;
203 | },
204 | toggleStarState: (state, action: PayloadAction) => {
205 | state[`${action.payload.type}Map`][action.payload.id].starred = action.payload.state;
206 | },
207 | toggleIgnoreState: (state, action: PayloadAction) => {
208 | state[`${action.payload.type}Map`][action.payload.id].ignored = action.payload.state;
209 | },
210 | toggleContentIgnore: (state, action: PayloadAction) => {
211 | state.contentIgnore[action.payload.id][action.payload.type] = action.payload.state;
212 | },
213 | resetContentIgnore: (state) => {
214 | state.contentIgnore = Object.fromEntries(
215 | Object.keys(state.courseMap).map((cid) => [cid, { ...IGNORE_UNSET_ALL }]),
216 | );
217 | state.updateFinished = false;
218 | },
219 | markAllRead: (state) => {
220 | for (const c of Object.values(state.notificationMap)) c.hasRead = true;
221 | for (const c of Object.values(state.fileMap)) c.hasRead = true;
222 | for (const c of Object.values(state.homeworkMap)) c.hasRead = true;
223 | for (const c of Object.values(state.discussionMap)) c.hasRead = true;
224 | for (const c of Object.values(state.questionMap)) c.hasRead = true;
225 | },
226 | clearAllData: () => {
227 | return initialState;
228 | },
229 | clearFetchedData: (state) => {
230 | state.courseMap = {};
231 | state.notificationMap = {};
232 | state.fileMap = {};
233 | state.homeworkMap = {};
234 | state.discussionMap = {};
235 | state.questionMap = {};
236 | state.lastUpdateTime = new Date(0);
237 | },
238 | loadData: (_state, action: PayloadAction>) => {
239 | return { ...initialState, ...action.payload };
240 | },
241 | },
242 | });
243 |
244 | export default dataSlice.reducer;
245 |
--------------------------------------------------------------------------------
/src/redux/reducers/helper.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from '@reduxjs/toolkit';
2 | import { type HelperConfig, Learn2018Helper } from 'thu-learn-lib';
3 |
4 | export interface HelperState {
5 | helper: Learn2018Helper;
6 | loggedIn: boolean;
7 | }
8 |
9 | const config: HelperConfig = {};
10 |
11 | const initialState: HelperState = {
12 | helper: new Learn2018Helper(config),
13 | loggedIn: false,
14 | };
15 |
16 | export const helperSlice = createSlice({
17 | name: 'helper',
18 | initialState,
19 | reducers: {
20 | loggedIn: (state) => {
21 | state.loggedIn = true;
22 | },
23 | loggedOut: (state) => {
24 | state.helper = new Learn2018Helper(config);
25 | state.loggedIn = false;
26 | },
27 | },
28 | });
29 |
30 | export default helperSlice.reducer;
31 |
--------------------------------------------------------------------------------
/src/redux/reducers/ui.ts:
--------------------------------------------------------------------------------
1 | import { type PayloadAction, createSlice } from '@reduxjs/toolkit';
2 |
3 | import { CARD_BATCH_LOAD_SIZE } from '../../constants';
4 | import type { SupportedContentType } from '../../types/data';
5 |
6 | interface CardEntry {
7 | type: SupportedContentType;
8 | id: string;
9 | }
10 | interface CardFilter {
11 | type?: SupportedContentType | 'ignored';
12 | courseId?: string;
13 | }
14 |
15 | export interface UiState {
16 | loadingProgress?: number;
17 | paneHidden: boolean;
18 | showLoginDialog: boolean;
19 | inLoginProgress: boolean;
20 | showNetworkErrorDialog: boolean;
21 | showNewSemesterDialog: boolean;
22 | showChangeSemesterDialog: boolean;
23 | ignoreWrongSemester: boolean;
24 | showLogoutDialog: boolean;
25 | showClearDataDialog: boolean;
26 | cardVisibilityThreshold: number;
27 | cardList: CardEntry[];
28 | cardFilter: CardFilter;
29 | titleFilter?: string;
30 | }
31 |
32 | const initialState: UiState = {
33 | loadingProgress: undefined,
34 | paneHidden: false,
35 | showLoginDialog: false,
36 | inLoginProgress: false,
37 | showNetworkErrorDialog: false,
38 | showNewSemesterDialog: false,
39 | ignoreWrongSemester: false,
40 | showLogoutDialog: false,
41 | showClearDataDialog: false,
42 | showChangeSemesterDialog: false,
43 | cardVisibilityThreshold: CARD_BATCH_LOAD_SIZE,
44 | cardList: [],
45 | cardFilter: {},
46 | titleFilter: undefined,
47 | };
48 |
49 | export const uiSlice = createSlice({
50 | name: 'ui',
51 | initialState,
52 | reducers: {
53 | setLoadingProgress: (state, action: PayloadAction) => {
54 | state.loadingProgress = action.payload;
55 | },
56 | togglePaneHidden: (state, action: PayloadAction) => {
57 | state.paneHidden = action.payload;
58 | },
59 | toggleLoginDialog: (state, action: PayloadAction) => {
60 | state.showLoginDialog = action.payload;
61 | },
62 | toggleLoginDialogProgress: (state, action: PayloadAction) => {
63 | state.inLoginProgress = action.payload;
64 | },
65 | loginEnd: (state) => {
66 | state.showLoginDialog = false;
67 | state.inLoginProgress = false;
68 | },
69 | toggleNetworkErrorDialog: (state, action: PayloadAction) => {
70 | state.showNetworkErrorDialog = action.payload;
71 | },
72 | toggleNewSemesterDialog: (state, action: PayloadAction) => {
73 | state.showNewSemesterDialog = action.payload;
74 | },
75 | toggleIgnoreWrongSemester: (state, action: PayloadAction) => {
76 | state.ignoreWrongSemester = action.payload;
77 | },
78 | toggleLogoutDialog: (state, action: PayloadAction) => {
79 | state.showLogoutDialog = action.payload;
80 | },
81 | toggleClearDataDialog: (state, action: PayloadAction) => {
82 | state.showClearDataDialog = action.payload;
83 | },
84 | toggleChangeSemesterDialog: (state, action: PayloadAction) => {
85 | state.showChangeSemesterDialog = action.payload;
86 | },
87 | resetCardVisibilityThreshold: (state) => {
88 | state.cardVisibilityThreshold = CARD_BATCH_LOAD_SIZE;
89 | },
90 | loadMoreCard: (state) => {
91 | state.cardVisibilityThreshold += CARD_BATCH_LOAD_SIZE;
92 | },
93 | setCardList: (state, action: PayloadAction) => {
94 | state.cardList = action.payload;
95 | },
96 | setCardFilter: (state, action: PayloadAction) => {
97 | state.cardFilter = action.payload;
98 | },
99 | setTitleFilter: (state, action: PayloadAction) => {
100 | state.titleFilter = action.payload;
101 | },
102 | },
103 | });
104 |
105 | export default uiSlice.reducer;
106 |
--------------------------------------------------------------------------------
/src/redux/selectors.ts:
--------------------------------------------------------------------------------
1 | import type { MessageDescriptor } from '@lingui/core';
2 | import { msg } from '@lingui/core/macro';
3 | import { memoize } from 'proxy-memoize';
4 | import { ContentType } from 'thu-learn-lib';
5 |
6 | import { UI_NAME_COURSE, UI_NAME_SUMMARY } from '../constants/ui';
7 | import type { ContentInfo } from '../types/data';
8 | import type { RootState } from './store';
9 |
10 | export const selectCourseList = memoize((state: RootState) => Object.values(state.data.courseMap));
11 |
12 | export const selectNotificationList = memoize((state: RootState) =>
13 | Object.values(state.data.notificationMap),
14 | );
15 | export const selectFileList = memoize((state: RootState) => Object.values(state.data.fileMap));
16 | export const selectHomeworkList = memoize((state: RootState) =>
17 | Object.values(state.data.homeworkMap),
18 | );
19 | export const selectDiscussionList = memoize((state: RootState) =>
20 | Object.values(state.data.discussionMap),
21 | );
22 | export const selectQuestionList = memoize((state: RootState) =>
23 | Object.values(state.data.questionMap),
24 | );
25 | export const selectDataLists = memoize((state: RootState) => ({
26 | notificationList: selectNotificationList(state),
27 | fileList: selectFileList(state),
28 | homeworkList: selectHomeworkList(state),
29 | discussionList: selectDiscussionList(state),
30 | questionList: selectQuestionList(state),
31 | }));
32 |
33 | export const selectContentIgnore = (state: RootState) => state.data.contentIgnore;
34 |
35 | export const selectUnreadMap = memoize((state: RootState) => {
36 | const { notificationList, fileList, homeworkList, discussionList, questionList } =
37 | selectDataLists(state);
38 | const contentIgnore = selectContentIgnore(state);
39 |
40 | const count = (type: ContentType, list: ContentInfo[]) =>
41 | list.reduce(
42 | (cnt, c) =>
43 | cnt +
44 | Number(
45 | !c.ignored &&
46 | contentIgnore[c.courseId]?.[type] === false &&
47 | (!c.hasRead || // all unread content
48 | // unfinished homework before deadline
49 | (c.type === ContentType.HOMEWORK &&
50 | !c.submitted &&
51 | c?.deadline?.getTime() > new Date().getTime())),
52 | ),
53 | 0,
54 | );
55 |
56 | return state.helper.loggedIn
57 | ? {
58 | notification: count(ContentType.NOTIFICATION, notificationList),
59 | file: count(ContentType.FILE, fileList),
60 | homework: count(ContentType.HOMEWORK, homeworkList),
61 | discussion: count(ContentType.DISCUSSION, discussionList),
62 | question: count(ContentType.QUESTION, questionList),
63 | ignored: 0,
64 | }
65 | : {};
66 | });
67 |
68 | export const selectSemesters = memoize((state: RootState) => {
69 | const { semesters, fetchedSemester } = state.data;
70 | if (!semesters.includes(fetchedSemester.id)) return [fetchedSemester.id, ...semesters];
71 | return semesters;
72 | });
73 |
74 | export const selectCardListTitle = memoize((state: RootState): MessageDescriptor[] => {
75 | if (state.helper.loggedIn) {
76 | if (state.ui.cardFilter.courseId && state.ui.cardFilter.type !== 'ignored') {
77 | return [
78 | UI_NAME_COURSE[state.ui.cardFilter.type ?? 'summary'],
79 | { id: `course-${state.ui.cardFilter.courseId}` },
80 | ];
81 | }
82 | return [UI_NAME_SUMMARY[state.ui.cardFilter.type ?? 'summary']];
83 | }
84 | return [msg`加载中...`];
85 | });
86 |
--------------------------------------------------------------------------------
/src/redux/store.ts:
--------------------------------------------------------------------------------
1 | import {
2 | type Action,
3 | type TypedStartListening,
4 | configureStore,
5 | createListenerMiddleware,
6 | } from '@reduxjs/toolkit';
7 | import logger from 'redux-logger';
8 |
9 | import { STORAGE_KEY_REDUX } from '../constants';
10 | import data from './reducers/data';
11 | import helper from './reducers/helper';
12 | import ui from './reducers/ui';
13 |
14 | const listenerMiddleware = createListenerMiddleware();
15 |
16 | export const store = configureStore({
17 | reducer: {
18 | data,
19 | helper,
20 | ui,
21 | },
22 | middleware: (getDefaultMiddleware) =>
23 | getDefaultMiddleware({ serializableCheck: false })
24 | .prepend(listenerMiddleware.middleware)
25 | .concat(logger),
26 | });
27 |
28 | export type RootState = ReturnType;
29 | export type AppDispatch = typeof store.dispatch;
30 |
31 | export type AppStartListening = TypedStartListening;
32 | export const startAppListening = listenerMiddleware.startListening as AppStartListening;
33 |
34 | startAppListening({
35 | matcher: (action: Action): action is Action =>
36 | typeof action.type === 'string' && action.type.startsWith('data/'),
37 | effect: (_action, { getState }) => {
38 | const { data } = getState();
39 | browser.storage.local.set({ [STORAGE_KEY_REDUX]: JSON.stringify(data) });
40 | },
41 | });
42 |
--------------------------------------------------------------------------------
/src/theme.ts:
--------------------------------------------------------------------------------
1 | import { createTheme } from '@mui/material';
2 |
3 | export const theme = createTheme({
4 | colorSchemes: { light: true, dark: true },
5 | cssVariables: {
6 | colorSchemeSelector: 'data',
7 | },
8 | });
9 |
--------------------------------------------------------------------------------
/src/types/data.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | ContentType,
3 | Discussion,
4 | File,
5 | Homework,
6 | Notification,
7 | Question,
8 | } from 'thu-learn-lib';
9 |
10 | interface ICourseRef {
11 | courseId: string;
12 | }
13 |
14 | interface ICardStatus {
15 | type: ContentType;
16 | id: string;
17 | date: Date;
18 | hasRead: boolean;
19 | starred: boolean;
20 | ignored: boolean;
21 | }
22 |
23 | type ICardData = ICourseRef & ICardStatus;
24 |
25 | export interface NotificationInfo extends Notification, ICardData {
26 | type: ContentType.NOTIFICATION;
27 | }
28 | export interface HomeworkInfo extends Homework, ICardData {
29 | type: ContentType.HOMEWORK;
30 | }
31 | export interface FileInfo extends File, ICardData {
32 | type: ContentType.FILE;
33 | }
34 | export interface DiscussionInfo extends Discussion, ICardData {
35 | type: ContentType.DISCUSSION;
36 | }
37 | export interface QuestionInfo extends Question, ICardData {
38 | type: ContentType.QUESTION;
39 | }
40 |
41 | export type ContentInfo =
42 | | NotificationInfo
43 | | HomeworkInfo
44 | | FileInfo
45 | | DiscussionInfo
46 | | QuestionInfo;
47 |
48 | export type SupportedContentType = Exclude;
49 |
--------------------------------------------------------------------------------
/src/types/ui.ts:
--------------------------------------------------------------------------------
1 | export type ColorMode = 'system' | 'light' | 'dark';
2 |
--------------------------------------------------------------------------------
/src/utils/console.ts:
--------------------------------------------------------------------------------
1 | const MESSAGE_FORMAT = 'color: blue; font-size: larger';
2 |
3 | export const printWelcomeMessage = () => {
4 | console.log('%c欢迎使用 Learn Helper!', MESSAGE_FORMAT);
5 | console.log('%c诚邀一起参与开发工作,详见 GitHub Harry-Chen/Learn-Helper', MESSAGE_FORMAT);
6 | };
7 |
--------------------------------------------------------------------------------
/src/utils/crypto.ts:
--------------------------------------------------------------------------------
1 | const textToChars = (text: string) => text.split('').map((c) => c.charCodeAt(0));
2 | const byteHex = (n: number) => `0${Number(n).toString(16)}`.slice(-2);
3 | const applySaltToChar = (salt: string) => (code: number) =>
4 | textToChars(salt).reduce((a, b) => a ^ b, code);
5 |
6 | export const cipher = (salt: string) => (text: string) =>
7 | textToChars(text).map(applySaltToChar(salt)).map(byteHex).join('');
8 |
9 | export const decipher = (salt: string) => (encoded: string) =>
10 | encoded
11 | .match(/.{1,2}/g)!
12 | .map((hex) => Number.parseInt(hex, 16))
13 | .map(applySaltToChar(salt))
14 | .map((charCode) => String.fromCharCode(charCode))
15 | .join('');
16 |
--------------------------------------------------------------------------------
/src/utils/csrf.ts:
--------------------------------------------------------------------------------
1 | const id = 1;
2 |
3 | export async function interceptCsrfRequest(csrf: string) {
4 | if (await browser.permissions.contains({ permissions: ['declarativeNetRequest'] })) {
5 | browser.declarativeNetRequest.updateDynamicRules({ removeRuleIds: [id] });
6 | browser.declarativeNetRequest.updateSessionRules({
7 | removeRuleIds: [id],
8 | addRules: [
9 | {
10 | id,
11 | condition: {
12 | requestDomains: ['learn.tsinghua.edu.cn'],
13 | tabIds: [(await browser.tabs.getCurrent())!.id!],
14 | },
15 | action: {
16 | type: browser.declarativeNetRequest.RuleActionType.REDIRECT,
17 | redirect: {
18 | transform: {
19 | queryTransform: {
20 | // @ts-expect-error wrong type @types/chrome
21 | addOrReplaceParams: [{ key: '_csrf', value: csrf, replaceOnly: true }],
22 | },
23 | },
24 | },
25 | },
26 | },
27 | ],
28 | });
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/utils/download.ts:
--------------------------------------------------------------------------------
1 | export async function initiateFileDownload(url: string, filename?: string) {
2 | try {
3 | const id = await browser.downloads.download({
4 | url,
5 | filename,
6 | });
7 | console.log(`Download ${url} starts with id ${id}`);
8 | } catch (e) {
9 | console.log(`Download ${url} failed: ${e}`);
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/utils/format.ts:
--------------------------------------------------------------------------------
1 | import { type MessageDescriptor, i18n } from '@lingui/core';
2 | import { msg, t } from '@lingui/core/macro';
3 | import {
4 | FailReason,
5 | HomeworkCompletionType,
6 | HomeworkGradeLevel,
7 | HomeworkSubmissionType,
8 | type SemesterInfo,
9 | SemesterType,
10 | } from 'thu-learn-lib';
11 |
12 | export function html2text(html: string): string {
13 | const node = document.createElement('div');
14 | node.innerHTML = html;
15 | return node.innerText.trim();
16 | }
17 |
18 | const semesterName = {
19 | [SemesterType.FALL]: msg`秋季学期`,
20 | [SemesterType.SPRING]: msg`春季学期`,
21 | [SemesterType.SUMMER]: msg`夏季学期`,
22 | };
23 |
24 | export function formatSemester(semester: SemesterInfo): string {
25 | if (semester.type !== SemesterType.UNKNOWN) {
26 | return `${semester.startYear}-${semester.endYear}-${i18n._(semesterName[semester.type])}`;
27 | }
28 | return SemesterType.UNKNOWN;
29 | }
30 |
31 | export function semesterFromId(id: string): SemesterInfo {
32 | let type = SemesterType.UNKNOWN;
33 | switch (id.charAt(id.length - 1)) {
34 | case '1':
35 | type = SemesterType.FALL;
36 | break;
37 | case '2':
38 | type = SemesterType.SPRING;
39 | break;
40 | case '3':
41 | type = SemesterType.SUMMER;
42 | break;
43 | }
44 | return {
45 | id,
46 | startDate: new Date(),
47 | endDate: new Date(),
48 | startYear: Number.parseInt(id.substring(0, 4)),
49 | endYear: Number.parseInt(id.substring(5, 9)),
50 | type,
51 | };
52 | }
53 |
54 | export function formatSemesterId(id: string): string {
55 | return formatSemester(semesterFromId(id));
56 | }
57 |
58 | function zeroPad(num: number, length: number): string {
59 | const zero = length - num.toString().length + 1;
60 | return Array(+(zero > 0 && zero)).join('0') + num;
61 | }
62 |
63 | function toDateString(date: Date, padding: boolean): string {
64 | if (padding) {
65 | return `${date.getFullYear()}-${zeroPad(date.getMonth() + 1, 2)}-${zeroPad(date.getDate(), 2)}`;
66 | }
67 | return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;
68 | }
69 |
70 | function toTimeString(date: Date): string {
71 | return `${zeroPad(date.getHours(), 2)}:${zeroPad(date.getMinutes(), 2)}:${zeroPad(
72 | date.getSeconds(),
73 | 2,
74 | )}`;
75 | }
76 |
77 | export function formatDate(date?: Date): string {
78 | if (date === undefined) {
79 | return t`无`;
80 | }
81 | return `${toDateString(date, false)}`;
82 | }
83 |
84 | export function formatDateTime(date?: Date): string {
85 | if (date === undefined) {
86 | return t`无`;
87 | }
88 | return `${toDateString(date, true)} ${toTimeString(date)}`;
89 | }
90 |
91 | const FAIL_REASON_MAPPING = {
92 | [FailReason.BAD_CREDENTIAL]: msg`用户名或密码错误`,
93 | [FailReason.ERROR_FETCH_FROM_ID]: msg`无法从 id.tsinghua.edu.cn 获取票据`,
94 | [FailReason.ERROR_ROAMING]: msg`无法使用票据漫游至 learn.tsinghua.edu.cn`,
95 | [FailReason.NOT_IMPLEMENTED]: msg`功能尚未实现`,
96 | [FailReason.NOT_LOGGED_IN]: msg`尚未登录`,
97 | [FailReason.NO_CREDENTIAL]: msg`未提供用户名或密码`,
98 | [FailReason.UNEXPECTED_STATUS]: msg`非预期的 HTTP 响应状态`,
99 | [FailReason.INVALID_RESPONSE]: msg`无效的 HTTP 响应`,
100 | [FailReason.OPERATION_FAILED]: msg`操作失败`,
101 | TIMEOUT: msg`请求超时`,
102 | UNKNOWN: msg`未知错误`,
103 | };
104 |
105 | export function failReasonToString(reason: FailReason): string {
106 | return i18n._(FAIL_REASON_MAPPING[reason] ?? FAIL_REASON_MAPPING.UNKNOWN);
107 | }
108 |
109 | const HomeworkGradeLevelNames = {
110 | [HomeworkGradeLevel.CHECKED]: msg`已阅`,
111 | [HomeworkGradeLevel.DISTINCTION]: msg`优秀`,
112 | [HomeworkGradeLevel.EXEMPTED_COURSE]: msg`免课`,
113 | [HomeworkGradeLevel.EXEMPTION]: msg`免修`,
114 | [HomeworkGradeLevel.PASS]: msg`通过`,
115 | [HomeworkGradeLevel.FAILURE]: msg`不通过`,
116 | [HomeworkGradeLevel.INCOMPLETE]: msg`缓考`,
117 | };
118 |
119 | export function formatHomeworkGradeLevel(gradeLevel: HomeworkGradeLevel): MessageDescriptor;
120 | export function formatHomeworkGradeLevel(gradeLevel: undefined): undefined;
121 | export function formatHomeworkGradeLevel(
122 | gradeLevel?: HomeworkGradeLevel,
123 | ): MessageDescriptor | undefined {
124 | if (gradeLevel) {
125 | return gradeLevel in HomeworkGradeLevelNames
126 | ? HomeworkGradeLevelNames[gradeLevel as keyof typeof HomeworkGradeLevelNames]
127 | : { id: gradeLevel };
128 | }
129 | }
130 |
131 | const HomeworkCompletionTypeNames = {
132 | [HomeworkCompletionType.INDIVIDUAL]: msg`每人独立完成一份作业`,
133 | [HomeworkCompletionType.GROUP]: msg`每组共同完成一份作业`,
134 | };
135 |
136 | export function formatHomeworkCompletionType(
137 | completionType: HomeworkCompletionType,
138 | ): MessageDescriptor {
139 | return HomeworkCompletionTypeNames[completionType];
140 | }
141 |
142 | const HomeworkSubmissionTypeNames = {
143 | [HomeworkSubmissionType.WEB_LEARNING]: msg`网络学堂`,
144 | [HomeworkSubmissionType.OFFLINE]: msg`无需在网络学堂提交`,
145 | };
146 |
147 | export function formatHomeworkSubmissionType(
148 | submissionType: HomeworkSubmissionType,
149 | ): MessageDescriptor {
150 | return HomeworkSubmissionTypeNames[submissionType];
151 | }
152 |
--------------------------------------------------------------------------------
/src/utils/permission.ts:
--------------------------------------------------------------------------------
1 | // firefox only issue
2 | export async function requestPermission() {
3 | await browser.permissions.request({
4 | origins: ['*://learn.tsinghua.edu.cn/*', '*://id.tsinghua.edu.cn/*'],
5 | });
6 | }
7 |
--------------------------------------------------------------------------------
/src/utils/storage.ts:
--------------------------------------------------------------------------------
1 | import { STORAGE_KEY_PASSWORD, STORAGE_KEY_USERNAME, STORAGE_SALT } from '../constants';
2 | import { cipher, decipher } from './crypto';
3 |
4 | export async function storeCredential(username: string, password: string) {
5 | const cipherImpl = cipher(STORAGE_SALT);
6 | await browser.storage.local.set({
7 | [STORAGE_KEY_USERNAME]: cipherImpl(username),
8 | [STORAGE_KEY_PASSWORD]: cipherImpl(password),
9 | });
10 | }
11 |
12 | export async function getStoredCredential() {
13 | const res = await browser.storage.local.get([STORAGE_KEY_USERNAME, STORAGE_KEY_PASSWORD]);
14 | const username = res[STORAGE_KEY_USERNAME];
15 | const password = res[STORAGE_KEY_PASSWORD];
16 | if (username !== undefined && password !== undefined) {
17 | const decipherImpl = decipher(STORAGE_SALT);
18 | return {
19 | username: decipherImpl(username),
20 | password: decipherImpl(password),
21 | };
22 | }
23 | return undefined;
24 | }
25 |
26 | export async function removeStoredCredential() {
27 | await browser.storage.local.remove([STORAGE_KEY_USERNAME, STORAGE_KEY_PASSWORD]);
28 | }
29 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 | ///
4 |
5 | declare const __HELPER_VERSION__: string;
6 | declare const __GIT_VERSION__: string;
7 | declare const __GIT_COMMIT_HASH__: string;
8 | declare const __GIT_COMMIT_DATE__: string;
9 | declare const __GIT_BRANCH__: string;
10 | declare const __BUILD_HOSTNAME__: string;
11 | declare const __BUILD_TIME__: string;
12 | declare const __THU_LEARN_LIB_VERSION__: string;
13 | declare const __MUI_VERSION__: string;
14 | declare const __REACT_VERSION__: string;
15 | declare const __LEARN_HELPER_CSRF_TOKEN_PARAM__: string;
16 | declare const __LEARN_HELPER_CSRF_TOKEN_INJECTOR__: string;
17 | declare const __BROWSER__: string;
18 |
19 | declare module '*.po' {
20 | import type { Messages } from '@lingui/core';
21 | export const messages: Messages;
22 | }
23 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./.wxt/tsconfig.json",
3 | "compilerOptions": {
4 | "jsx": "react-jsx",
5 | "experimentalDecorators": true,
6 | "allowSyntheticDefaultImports": true,
7 | "noImplicitThis": true,
8 | "verbatimModuleSyntax": true,
9 | "strictNullChecks": true
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/wxt.config.ts:
--------------------------------------------------------------------------------
1 | import { execSync } from 'node:child_process';
2 | import path from 'node:path';
3 | import { lingui } from '@lingui/vite-plugin';
4 | import mdx from '@mdx-js/rollup';
5 | import react from '@vitejs/plugin-react-swc';
6 | import Randomstring from 'randomstring';
7 | import rehypeMdxImportMedia from 'rehype-mdx-import-media';
8 | import rehypeUnwrapImages from 'rehype-unwrap-images';
9 | import preserveDirectives from 'rollup-preserve-directives';
10 | import icons from 'unplugin-icons/vite';
11 | import { chunkSplitPlugin } from 'vite-plugin-chunk-split';
12 | import { defineConfig } from 'wxt';
13 |
14 | import { version as versionMui } from './node_modules/@mui/material/package.json';
15 | import { version as versionReact } from './node_modules/react/package.json';
16 | import { version as versionThuLearnLib } from './node_modules/thu-learn-lib/package.json';
17 | import { author, version } from './package.json';
18 |
19 | const runCmd = (cmd: string) => execSync(cmd).toString().trim();
20 |
21 | const randomSuffix = Randomstring.generate(4);
22 | const helperVersion = version;
23 | const gitVersion = runCmd('git describe --always --dirty');
24 | const gitBranch = runCmd('git branch --show-current').replaceAll('/', '-');
25 | const date = new Date();
26 |
27 | // See https://wxt.dev/api/config.html
28 | export default defineConfig({
29 | vite: ({ mode }) => ({
30 | define: {
31 | __HELPER_VERSION__: JSON.stringify(helperVersion),
32 | __GIT_VERSION__: JSON.stringify(gitVersion),
33 | __GIT_COMMIT_HASH__: JSON.stringify(runCmd('git rev-parse HEAD')),
34 | __GIT_COMMIT_DATE__: JSON.stringify(
35 | runCmd('git log -1 --date=format:"%Y/%m/%d %T" --format="%ad"'),
36 | ),
37 | __GIT_BRANCH__: JSON.stringify(runCmd('git rev-parse --abbrev-ref HEAD')),
38 | __BUILD_HOSTNAME__: JSON.stringify(runCmd('hostname')),
39 | __BUILD_TIME__: JSON.stringify(date.toLocaleString('zh-CN')),
40 | __THU_LEARN_LIB_VERSION__: JSON.stringify(versionThuLearnLib),
41 | __MUI_VERSION__: JSON.stringify(versionMui),
42 | __REACT_VERSION__: JSON.stringify(versionReact),
43 | __LEARN_HELPER_CSRF_TOKEN_PARAM__: JSON.stringify(
44 | `__learn-helper-csrf-token-${randomSuffix}__`,
45 | ),
46 | __LEARN_HELPER_CSRF_TOKEN_INJECTOR__: JSON.stringify(
47 | `__learn_helper_csrf_token_injector_${randomSuffix}__`,
48 | ),
49 | },
50 | plugins: [
51 | {
52 | enforce: 'pre',
53 | ...mdx({
54 | rehypePlugins: [rehypeMdxImportMedia, rehypeUnwrapImages],
55 | }),
56 | },
57 | icons({ compiler: 'jsx', jsx: 'react' }),
58 | react({ plugins: [['@lingui/swc-plugin', {}]] }),
59 | preserveDirectives(),
60 | lingui(),
61 | chunkSplitPlugin({
62 | customSplitting: {
63 | 'thu-learn-lib-vendor': [/thu-learn-lib/],
64 | 'ui-vendor': [/@mui/, /@emotion/],
65 | },
66 | }),
67 | ],
68 | resolve: {
69 | alias: {
70 | '~': path.resolve(__dirname, 'src'),
71 | parse5: path.resolve(__dirname, 'node_modules/fake-parse5/'),
72 | 'parse5-htmlparser2-tree-adapter': path.resolve(__dirname, 'node_modules/fake-parse5/'),
73 | },
74 | },
75 | build: {
76 | target: 'esnext',
77 | sourcemap: mode === 'development',
78 | ...(mode === 'production' && {
79 | minify: 'terser',
80 | terserOptions: {
81 | format: {
82 | comments: false,
83 | ecma: 2018,
84 | },
85 | },
86 | }),
87 | },
88 | }),
89 | extensionApi: 'chrome',
90 | manifestVersion: 3,
91 | manifest: ({ browser }) => ({
92 | name: '__MSG_appName__',
93 | description: '__MSG_appDesc__',
94 | default_locale: 'zh_CN',
95 | author: author,
96 | version,
97 | action: {},
98 | host_permissions: ['*://learn.tsinghua.edu.cn/*', '*://id.tsinghua.edu.cn/*'],
99 | permissions:
100 | browser === 'firefox'
101 | ? ['storage', 'downloads']
102 | : ['storage', 'downloads', 'declarativeNetRequest'],
103 | ...(browser === 'firefox' && {
104 | browser_specific_settings: {
105 | gecko: {
106 | update_url: 'https://harrychen.xyz/learn/updates.json',
107 | id: '{b3a44052-5d0d-4ef9-9744-93b6f5ca7398}',
108 | },
109 | },
110 | }),
111 | }),
112 | analysis: {
113 | enabled: true,
114 | },
115 | zip: {
116 | artifactTemplate: `{{name}}-{{version}}-${gitBranch}-${gitVersion}-{{browser}}.zip`,
117 | sourcesTemplate: `{{name}}-{{version}}-${gitBranch}-${gitVersion}-sources.zip`,
118 | },
119 | outDir: 'dist',
120 | });
121 |
--------------------------------------------------------------------------------