附錄 -- HydroOJ 整體架構流程圖
HydroOJ 整體架構流程圖
注:下圖為 HydroOJ 整體架構流程圖,參考自 官方repo 非官方之圖式,有誤請不吝告知我們進行修正,謝謝!
HydroOJ 套件資料夾結構
my-addon/
├── package.json
├── frontend?/
├── \[a-zA-Z0-9_\]+.page.tsx?
├── locales?/
├── ko.yml?
├── zh_TW.yml?
├── en.yml?
├── zh.yml?
├── public?/
├── templates?/
├── <>.html?
├── index.ts
有?的資料夾或檔案表示是選用的不是必須的
package.json:套件的設定檔案 (預設就會有)frontend/:注射前端頁面檔案 (選用)\[a-zA-Z0-9_\]+.page.tsx:注射前端頁面檔案 (選用)
locales/:多國語系檔案 (選用)public/:靜態資源檔案 (選用)templates/:路由渲染前端模板檔案 (選用)index.ts:套件主要程式碼 (預設就會有)
Handler 相關附錄
*路由名即該頁面的data-page屬性(可在瀏覽器開發者工具(按F12)中查看 會在最外層的<html>標籤中)
假設有一個路由是ctx.Route('contest_create', '/contest/create', ContestEditHandler)
- 路由名為
contest_create - Handler 名稱為
ContestEditHandler
如何查詢 Handler 名稱
可以在 HydroOJ repo (或你要查的addon)中搜尋路由定義ctx.Route()
你會看到ctx.Route('problem_list', '/p', ProblemListHandler);之類的程式碼
來查看各個路由所對應的 Handler 名稱
ProblemListHandler 就是該路由(/p)所對應的 Handler 名稱
注:一個路由只會對應到一個 Handler 名稱
但一個 Handler 名稱可能會對應到多個路由(像是編輯頁面和查看頁面可能會共用同一個 Handler 名稱)
常用 Handler 名稱對照表
-
首頁與用戶相關
Handler 名稱 路由路徑 功能 常見擴展用途 HomeHandler / 首頁 添加輪播圖、公告、統計信息 UserDetailHandler /user/:uid 用戶詳情頁 添加 Rating、徽章、自定義資料 UserLoginHandler /login 登錄頁 添加第三方登錄、驗證邏輯 UserRegisterHandler /register 註冊頁 自定義註冊流程、邀請碼 HomeSecurityHandler /home/security 安全設置 添加兩步驗證、設備管理 HomeSettingsHandler /home/settings/:category 個人設置 添加自定義設置項 -
題目相關
Handler 名稱 路由路徑 功能 常見擴展用途 ProblemListHandler /p 題目列表 添加篩選、標籤、難度分級 ProblemDetailHandler /p/:pid 題目詳情 添加題解、討論、統計數據 ProblemEditHandler /p/:pid/edit 編輯題目 添加批量操作、模板 ProblemCreateHandler /p/create 創建題目 添加題目導入功能 ProblemSubmitHandler /p/:pid/submit 提交代碼 添加代碼模板、預處理 ProblemSolutionHandler /p/:pid/solution 題解列表 添加點贊、評論 ProblemManageHandler /p/:pid/manage 題目管理 添加批量測試數據操作 -
記錄與評測相關
Handler 名稱 路由路徑 功能 常見擴展用途 RecordListHandler /record 提交記錄列表 添加統計、篩選器 RecordDetailHandler /record/:rid 提交記錄詳情 添加代碼高亮、性能分析 JudgeConnectionHandler /judge/conn 評測機連接 自定義評測邏輯 JudgeFilesDownloadHandler /judge/files 評測文件下載 自定義文件處理 -
比賽相關
Handler 名稱 路由路徑 功能 常見擴展用途 ContestListHandler /contest 比賽列表 添加分類、報名狀態 ContestDetailHandler /contest/:tid 比賽詳情 添加比賽公告、賽前提醒 ContestScoreboardHandler /contest/:tid/scoreboard 排行榜 添加 Rating 變化、封榜 ContestEditHandler /contest/:tid/edit 編輯比賽 添加模板、批量導入 ContestCodeHandler /contest/:tid/code 查看代碼 添加代碼對比、查重 -
作業相關
Handler 名稱 路由路徑 功能 常見擴展用途 HomeworkMainHandler /homework 作業列表 添加進度追蹤 HomeworkDetailHandler /homework/:tid 作業詳情 添加提交狀態、截止提醒 HomeworkScoreboardHandler /homework/:tid/scoreboard 作業排行榜 添加完成度統計 -
討論相關
Handler 名稱 路由路徑 功能 常見擴展用途 DiscussionMainHandler /discuss 討論列表 添加分類、熱門話題 DiscussionDetailHandler /discuss/:did 討論詳情 添加點贊、回覆樹 DiscussionEditHandler /discuss/:did/edit 編輯討論 添加富文本編輯器 DiscussionReplyHandler /discuss/:did/reply 回覆討論 添加 @ 提醒、表情 -
域管理相關
Handler 名稱 路由路徑 功能 常見擴展用途 DomainDetailHandler /domain/:domainId 域詳情 添加域統計、公告 DomainEditHandler /domain/:domainId/edit 編輯域 添加自定義配置項 DomainUserHandler /domain/:domainId/user 域用戶管理 添加批量操作、角色管理 DomainRankHandler /domain/:domainId/rank 域排行榜 添加多維度排名 -
訓練相關
Handler 名稱 路由路徑 功能 常見擴展用途 TrainingListHandler /training 訓練計劃列表 添加推薦算法 TrainingDetailHandler /training/:tid 訓練計劃詳情 添加進度可視化 -
系統管理相關
Handler 名稱 路由路徑 功能 常見擴展用途 SystemMainHandler /manage 系統管理主頁 添加儀表板組件 SystemDashboardHandler /manage/dashboard 系統儀表板 添加監控圖表 SystemSettingHandler /manage/setting 系統設置 添加自定義設置項 SystemUserImportHandler /manage/userimport 用戶導入 添加批量導入格式
Handler 實例的詳細結構
interface Handler {
// ===== 用戶相關 =====
user: User; // 當前登錄用戶
// ===== 域相關 =====
domain: DomainDoc; // 當前域文檔
// ===== 請求相關 =====
request: {
path: string; // 請求路徑,如 /p/1000
method: string; // HTTP 方法,如 GET, POST
params: Record<string, any>; // URL 參數,如 { pid: '1000' }
query: Record<string, any>; // 查詢參數,如 { page: '1' }
body: Record<string, any>; // POST 數據
ip: string; // 客戶端 IP
headers: Record<string, string>; // HTTP 頭
cookies: Record<string, string>; // Cookies
files?: Record<string, any>; // 上傳的文件
json?: boolean; // 是否 JSON 請求
};
// ===== 響應相關 =====
response: {
body: any; // 響應數據(傳給模板)
template?: string; // 模板文件名
type?: string; // Content-Type
status?: number; // HTTP 狀態碼
redirect?: string; // 重定向 URL
disposition?: string; // Content-Disposition
etag?: string; // ETag
headers?: Record<string, string>; // 自定義響應頭
};
// ===== 會話相關 =====
session: {
uid: number; // 用戶 ID
viewLang?: string; // 界面語言
sudo?: number; // sudo 模式時間戳
[key: string]: any; // 其他會話數據
};
// ===== 解析後的參數 =====
args: Record<string, any>; // 通過 @param 裝飾器解析的參數
// ===== 上下文 =====
ctx: Context; // 插件上下文
}
特定 Handler 的額外屬性(範例)
// ProblemDetailHandler
interface ProblemDetailHandler extends Handler {
pdoc: ProblemDoc; // 題目文檔
udoc?: User; // 題目作者信息
psdoc?: any; // 題目狀態
}
// ContestDetailHandler
interface ContestDetailHandler extends Handler {
tdoc: Tdoc; // 比賽文檔
tsdoc?: any; // 比賽狀態
}
// RecordDetailHandler
interface RecordDetailHandler extends Handler {
rdoc: RecordDoc; // 記錄文檔
pdoc?: ProblemDoc; // 關聯的題目
udoc?: User; // 提交用戶
}
// UserDetailHandler
interface UserDetailHandler extends Handler {
udoc: User; // 用戶文檔
}
Handler 生命週期流程圖
渲染機制流程圖
template 模板系統相關附錄
模板引擎使用 Nunjucks
// 來源: packages/ui-default/backendlib/template.ts
export class TemplateService extends Service {
constructor(ctx: Context) {
super(ctx, 'template');
// 設置 Nunjucks 環境
const env = new Nunjucks(Loader);
// 註冊過濾器
env.addFilter('json', JSON.stringify);
env.addFilter('translate', (str) => this.translate(str));
// 註冊全局變量
env.addGlobal('UiContext', UiContext);
env.addGlobal('formatDate', formatDate);
}
// 渲染模板
render(name: string, context: any) {
return this.env.render(name, context);
}
}
模板上下文
// Handler 渲染時自動注入的上下文
const templateContext = {
// Handler 相關
handler: this, // Handler 實例
user: this.user, // 當前用戶
domain: this.domain, // 當前域
// 請求相關
request: this.request, // 請求對象
args: this.args, // URL 參數
// 響應數據
...this.response.body, // 響應體數據
// UI 上下文
UiContext: this.UiContext, // UI 配置
// 輔助函數
url: this.url.bind(this), // URL 生成
_: this.translate.bind(this), // 翻譯函數
formatDate: (date) => ..., // 日期格式化
avatar: (hash) => ..., // 頭像 URL
};
Service 服務相關附錄
如何查詢 Service 服務名稱
可以在 HydroOJ repo (或你要查的addon)中搜尋服務註冊代碼範例
// 示例 1: MongoService
// 來源: packages/hydrooj/src/service/db.ts
export class MongoService extends Service {
constructor(ctx: Context, private config: MongoConfig = {}) {
super(ctx, 'db'); // ← 註冊為 'db'
// ^^^^ 這就是服務名稱
}
}
這樣就可以知道服務註冊名稱db對應的服務類型MongoService
常用 Service 名稱對照表
| 服務註冊名稱 | 服務類型/實例 (TypeScript 類型) | 功能描述 |
|---|---|---|
| db | MongoService | MongoDB 數據庫操作服務 |
| server | WebService | HTTP 服務 |
| setting | SettingService | 系統設置服務 |
| loader | Loader | 加載器服務 |
| worker | WorkerService | 工作線程服務 |
| i18n | I18nService | 國際化服務 |
| oauth | OauthModel | OAuth 認證模型 |
| api | ApiService | API 服務 |
| check | CheckService | 檢查服務 |
| template | TemplateService | 模板服務 |
| scoreboard | ScoreboardService | 排行榜服務 |
事件相關附錄
事件系統架構圖
所以只要查ctx.emit就可以找到事件名字了
事件類型
- Handler 生命週期事件
handler/before/<HandlerClassName>:在指定的處理器執行前觸發handler/after/<HandlerClassName>:在指定的處理器執行後觸發handler/before/<routeName>:在指定的路徑載入前觸發handler/after/<routeName>:在指定的路徑載入後觸發
- 應用生命週期事件
app/started:應用啟動完成
Service 服務相關附錄
HydroOJ 提供許多內置服務,可以通過 ctx.inject() 進行依賴注入。
常用服務列表
| 服務名稱 | 說明 | 使用方式 |
|---|---|---|
db | MongoDB 數據庫連接 | ctx.db.collection(...) |
storage | 文件存儲服務 | await storage.put(path, file) |
bus | 事件總線 | ctx.on(eventName, handler) |
problemModel | 題目模型 | await ProblemModel.get(...) |
userModel | 用戶模型 | await UserModel.getById(...) |
documentModel | 文檔模型 | await DocumentModel.add(...) |
domainModel | 域模型 | await DomainModel.getById(...) |
mail | 郵件服務 | await mail.sendMail(...) |
server | 伺服器配置 | server.config |
依賴注入範例
export async function apply(ctx: Context) {
// 等待 db 和 storage 服務就緒
ctx.inject(['db', 'storage'], (c) => {
// c.db 和 c.storage 現在已就緒
const db = c.db;
const storage = c.storage;
// 可以設置定時任務
const timer = setInterval(async () => {
const docs = await db.collection('items').find({}).toArray();
console.log('Found', docs.length, 'items');
}, 60000);
// 返回清理函數(插件卸載時會調用)
return () => {
clearInterval(timer);
console.log('Cleanup done');
};
});
}
前端 API 完整參考
注:這些 API 需要在 frontend/*.page.tsx 中導入使用。
頁面系統
import { Page, NamedPage, AutoloadPage, addPage } from '@hydrooj/ui-default';
// 在所有頁面運行
addPage(new AutoloadPage('MyAutoload', () => {
console.log('Running on every page');
}));
// 在特定頁面運行
addPage(new NamedPage('problem_detail', () => {
console.log('Running on problem detail page');
}));
// 在多個頁面運行
addPage(new NamedPage(['problem_detail', 'problem_main'], () => {
console.log('Running on problem pages');
}));
對話框與通知
import {
Dialog,
ActionDialog,
InfoDialog,
ConfirmDialog,
alert,
confirm,
prompt,
Notification
} from '@hydrooj/ui-default';
// 簡單提示
alert('This is an alert');
confirm('Are you sure?');
prompt('Please enter:', 'default value');
// 通知
Notification.success('Success!');
Notification.error('Error!');
Notification.warning('Warning!');
Notification.info('Info!');
// 高級對話框
new InfoDialog({
title: 'Information',
message: 'This is information'
}).show();
HTTP 請求
import { request } from '@hydrooj/ui-default';
// GET
const res = await request.get('/api/data');
console.log(res.data);
// POST
const res = await request.post('/api/data', { key: 'value' });
// PUT、DELETE、PATCH
await request.put('/api/data', { key: 'value' });
await request.delete('/api/data');
await request.patch('/api/data', { key: 'value' });
// 錯誤處理
try {
const res = await request.get('/api/data');
} catch (error) {
console.error('Request failed:', error.message);
}
多國語系
import { i18n } from '@hydrooj/ui-default';
// 獲取翻譯文本
const text = i18n('key');
console.log(text);
// 替換模板
import { substitute } from '@hydrooj/ui-default';
const result = substitute('Hello, {0}!', ['World']);
// 結果: 'Hello, World!'
工具函數
import {
delay,
secureRandomString,
getTheme,
downloadFile,
uploadFiles,
pjax,
tpl,
rawHtml,
base64,
mongoId,
zIndexManager,
getAvailableLangs,
loadReactRedux
} from '@hydrooj/ui-default';
// 延遲
await delay(1000); // 延遲 1 秒
// 生成隨機字符串
const token = secureRandomString(32);
// 獲取主題
const theme = getTheme(); // 'light' 或 'dark'
// 下載文件
downloadFile('/api/download', 'filename.txt');
// 上傳文件
const files = await uploadFiles();
// PJAX 導航(無整頁刷新)
pjax('/new/path');
// HTML 模板
const html = tpl`<div>${variable}</div>`;
// 原始 HTML(避免轉義)
const html = rawHtml('<strong>Bold</strong>');
// Base64 編碼/解碼
const encoded = base64.encode('hello');
const decoded = base64.decode(encoded);
// 生成 MongoDB ObjectId
const id = mongoId();
// 獲取可用的語言
const langs = getAvailableLangs(); // ['zh_TW', 'en', ...]
常見錯誤與解決方案
1. 模塊找不到
錯誤: Cannot find module '@hydrooj/ui-default'
解決: 確保在 package.json 中正確安裝了依賴
{
"peerDependencies": {
"hydrooj": "*"
}
}
2. Handler 方法未執行
錯誤: Handler 的方法沒有被調用
可能原因:
- 路由名稱拼寫錯誤
- Handler 類繼承錯誤
- 權限檢查失敗
解決:
// 檢查路由註冊
ctx.Route('correct_name', '/path', MyHandler);
// 檢查 Handler 繼承
class MyHandler extends Handler {
async get() {
// 實現邏輯
}
}
3. 模板變量未渲染
錯誤: {{ variable }} 在模板中顯示為空
可能原因:
- 傳入的數據缺少該字段
- 變量名拼寫不正確
- 模板繼承錯誤
解決:
class MyHandler extends Handler {
async get() {
this.response.template = 'my_template.html';
this.response.body = {
variable: 'Value' // 確保名稱與模板中的變量匹配
};
}
}
4. UI 注射不顯示
錯誤: UI 注射的元素沒有出現在頁面上
可能原因:
- 注射點名稱錯誤
- 路由名稱不存在
- 權限檢查未通過
解決:
// 確認注射點名稱
ctx.injectUI('Nav', 'correct_route', {
prefix: 'my_addon'
});
// 檢查路由是否存在
ctx.Route('my_addon_page', '/my/page', MyHandler);
// 檢查權限設置
import { PRIV } from 'hydrooj';
ctx.injectUI('UserDropdown', 'route', {}, PRIV.PRIV_USER_PROFILE);
最佳實踐建議
-
命名規範
- 路由名以插件名開頭,並用下劃線分隔
- 集合名以
插件名.開頭 - i18n key 使用小寫並用下劃線分隔
-
型別安全
- 總是為集合定義 TypeScript 接口
- 使用
@param裝飾器驗證輸入
-
錯誤處理
- 捕捉所有 async 操作的異常
- 使用適當的 HTTP 狀態碼
-
性能優化
- 為常用查詢建立索引
- 使用分頁避免一次載入過多數據
- 緩存不常變化的數據
-
代碼組織
- 將 Handler 類分離到不同文件
- 使用模型層封裝數據庫邏輯
- 提取公共邏輯到工具函數
更新日誌
章節結構
- 第 0 章:介紹(基礎概念)
- 第 1 章:安裝與設定
- 第 2 章:前端基礎
- 第 3 章:多國語系與模板
- 第 4 章:服務基礎
- 第 5 章:Context 物件
- 第 6 章:Handler 基礎
- 第 7 章:UI 注射(新增)
- 第 8 章:數據庫與模型(新增)
- 第 9 章:Handler 進階(新增)
- 附錄:架構與參考
最近更新
- 新增 UI 注射完整指南(第 7 章)
- 新增數據庫與模型部分(第 8 章)
- 新增 Handler 進階用法(第 9 章)
- 擴展前端 API 文檔(第 2 章)
- 增加模板系統複雜用法(第 3 章)
- 新增常見錯誤與解決方案
- 新增最佳實踐建議
