不改数据库,用 OpenCC 给 Moodle 加上全站繁体中文转换(含 RequireJS 避坑)
如果你使用 Moodle 做了一套简体中文 AI 课程,既不想忽略掉繁体中文用户,又不想再单独维护一套繁体中文课程,可以通过官方语言包加 OpenCC 前端注入实现免改库全站繁简转换。
1. 起因与结论先行
如果你使用 Moodle 做了一套简体中文 AI 课程,但是又不想忽略掉繁体中文的用户,同时也不想再单独维护一套繁体中文的课程,可以试一下这个办法。
需求很明确:不能改动现有的简体数据库,不能把课程和题库复制成两套维护,也尽量不要去重新编译 Docker 容器或折腾 PHP 后端插件,但要让切换到繁体中文的学员看到全繁体的界面、课件正文与测验题目。
今天我把整套链路在 Moodle 线上环境跑通了。最终验证可行的方案分为两层:
- 系统界面层:直接在 Moodle 后台安装官方的
zh_tw(正体中文)语言包,接管导航栏、按钮和系统提示。 - 课程与题库内容层:通过 Moodle 自带的「额外 HTML(Additional HTML)」功能,在
</body>闭合前注入一段基于opencc-js(cn -> twp)的前端转换脚本,实时把页面 DOM 中的简体正文和地域词汇(比如「文件 → 檔案」「视频 → 影片」)转为台湾繁体。
原本以为只需要往后台贴一行 <script> 引入 opencc-js 就能收工,结果在实际页面里连续踩了 Moodle 内置 RequireJS 劫持 UMD 模块和语言标识属性判断失灵两个坑。下面把原理、排错过程和可直接复制的完整代码整理出来。
2. 核心认知:为什么直觉做法会卡住?
在动手写代码前,有三个关于 Moodle 的机制边界必须先厘清:
2.1 官方语言包不管数据库里的课程正文
Moodle 官方提供的 zh_tw 语言包非常完善,但它只负责替换 Moodle 源码里的语言字符串(比如把页面顶部的“我的主页”变成“我的主頁”)。老师录入在数据库里的课程标题、章节导读、页面资源和测验题干,语言包完全不会触碰。
如果去写后端 PHP Filter 插件,不仅 Moodle 官方插件库没有现成的 OpenCC 插件,还得往服务器容器里编译 C++ 依赖和 PHP 扩展,每次请求都要消耗服务器 CPU 做字典匹配;相比之下,把转换放到浏览器端执行,服务器零负载,随时删掉代码就能回滚。
2.2 判断简繁环境:别信 <html lang>,要看 body.classList
直觉上,前端脚本判断当前页面是不是繁体中文,通常会去读 document.documentElement.lang(也就是 <html lang="...">)。
但在 Moodle 里实测发现,无论是访问 ?lang=zh_cn 还是 ?lang=zh_tw,document.documentElement.lang 返回的值全都是 "zh",根本区分不开简繁。Moodle 真正稳定暴露当前语言包的位置在 <body> 的 class 列表里:
- 简体中文环境:
document.body.className包含lang-zh_cn - 台湾繁体环境:
document.body.className包含lang-zh_tw - 香港繁体环境:
document.body.className包含lang-zh_hk
因此,脚本开头只要检查 document.body.classList.contains('lang-zh_tw') 或 lang-zh_hk,就能确保简体学员访问时脚本直接休眠,不加载任何转换字典。
2.3 前端把题目转成繁体,会影响测验自动判分吗?
这取决于 Moodle 题型的提交机制:
- 单选/多选题(
multichoice)、判断题(truefalse)、配对连线题(matching):完全不受影响。前端只改了文字展示,学员交卷时传给后端的是选项 ID(如1,2,3)。 - 选词填空题(
gapselect):虽然名字叫“填空”,但查看 Moodle 题库 XML 源码就会发现,它在网页上渲染的是<select>下拉菜单(让学员从预设词里下拉挑选),交卷同样提交选项序号,判分零影响。 - 简答题(
shortanswer)与内嵌SHORTANSWER的完形填空(cloze):这类题需要学员在文本框里敲字。如果后台标准答案只有简体,学员敲入繁体字直接交卷就会被判错。针对这类题,可以在表单触发submit时加一道拦截,把输入框里的繁体字自动转回简体再发给服务器。
3. 踩坑记录:被 RequireJS 劫持的 opencc-js
明确了思路后,我第一版代码直接用动态 <script> 标签去加载 CDN 上的 [email protected]/dist/umd/full.js,结果控制台当场报错:
Uncaught ReferenceError: OpenCC is not defined
第一轮排查:为什么 window.OpenCC 没挂载上?
拉取 opencc-js/dist/umd/full.js 的头部源码一看,它是一个标准的 UMD 包装器:
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.OpenCC = {}));
})(this, (function (exports) { 'use strict'; ... }));
再去 Moodle 页面控制台里一查 typeof window.define,结果是 "function",且 window.define.amd 为真——Moodle 底层全局挂载了 RequireJS。当 UMD 脚本发现页面里存在 define.amd 时,就会走第二分支调用 define(['exports'], factory) 注册成 AMD 匿名模块,根本不会走第三分支去给 window.OpenCC 赋值。
第二轮踩坑:临时置空 window.define 为什么在真实页面翻车?
遇到 RequireJS 劫持,最容易想到的绕法是在插入 <script> 前先把 window.define 设为 undefined,等 script.onload 触发后再把原来的 define 还原回去。
我在控制台单步测试时,这个办法确实跑通了。但当我把这段代码填进 Moodle 后台保存、刷新正式页面时,页面上的课程标题依然是简体,打开控制台一看,爆出了更严重的错误:
Uncaught TypeError: Cannot read properties of undefined (reading 'Converter')
Uncaught Error: Mismatched anonymous define() module: function (exports) { 'use strict'; ...
原因在于:Moodle 页面初始化期间,RequireJS 自身正在异步并发加载大量系统组件。在 <script> 异步下载的几百毫秒窗口期内,如果强行把全局的 window.define 置空或者时机稍有交错,要么打崩 Moodle 自己的模块加载,要么在还原瞬间被 RequireJS 捕获到未配对的匿名 define() 调用。
最终解法:fetch + new Function 显式走 CommonJS 分支
要想既不碰全局的 window.define,又能把 opencc-js 干净地导出来,最稳妥的路子是绕过 <script> 标签,直接用 fetch() 把脚本源码当纯文本拉下来,再用 new Function 构造一个局部的闭包作用域来执行它。
注意看上面 UMD 头部的第一行:
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : ...
它同时检查了 exports 和 module 两个变量(如果只传 exports 不传 module,依然会落入后面的分支导致 exports.Converter is not a function)。所以我们只需要给 new Function 声明 'module', 'exports', 'define' 三个形参,把 define 显式传为 undefined,就能 100% 强制它走第一行的 CommonJS 分支:
function loadOpenCC() {
if (window._OpenCC) return Promise.resolve(window._OpenCC);
return fetch('https://cdn.jsdelivr.net/npm/[email protected]/dist/umd/full.js')
.then(function(res) { return res.text(); })
.then(function(code) {
var module = { exports: {} };
var fn = new Function('module', 'exports', 'define', code);
fn(module, module.exports, undefined);
window._OpenCC = module.exports;
return window._OpenCC;
});
}
换上这段加载逻辑后,再次刷新 Moodle 页面,控制台 RequireJS 报错彻底清零,转换器稳定就绪。
4. 完整复现步骤
Step 1:在 Moodle 后台安装繁体语言包
- 用管理员账号登录 Moodle,进入 网站管理(Site administration)→ 语言(Language)→ 语言包(Language packs)。
- 在右侧可用列表中找到 「繁體中文 (zh_tw)」(如果需要兼顾香港学员,可一并安装
zh_hk),点击安装。 - 安装完成后,网站顶栏会出现语言切换菜单。
Step 2:在「额外 HTML」注入转换脚本
- 进入 网站管理(Site administration)→ 外观(Appearance)→ 额外 HTML(Additional HTML),或者直接访问后台路径:
/admin/settings.php?section=additionalhtml - 向下滚动找到第三个输入框:「在 BODY 闭合标签前(Before BODY is closed)」(对应表单字段
#id_s__additionalhtmlfooter)。 - 注意保留该输入框内原有的代码(例如很多站点在这里放了 Google Analytics / Tag Manager 统计代码,不要直接覆盖),在原有代码下方追加以下完整脚本:
<!-- OpenCC 繁简智能转换(Moodle RequireJS 隔离版) -->
<script>
(function() {
// 1. 仅当用户处于繁体语言环境 (zh_tw / zh_hk) 时才执行
var isTraditional = document.body.classList.contains('lang-zh_tw') ||
document.body.classList.contains('lang-zh_hk');
if (!isTraditional) return;
// 2. 使用 fetch + new Function 隔离加载,避免触发 Moodle RequireJS 的 define 冲突
function loadOpenCC() {
if (window._OpenCC) return Promise.resolve(window._OpenCC);
return fetch('https://cdn.jsdelivr.net/npm/[email protected]/dist/umd/full.js')
.then(function(res) { return res.text(); })
.then(function(code) {
var module = { exports: {} };
var fn = new Function('module', 'exports', 'define', code);
fn(module, module.exports, undefined);
window._OpenCC = module.exports;
return window._OpenCC;
});
}
loadOpenCC().then(function(OpenCC) {
// cn -> twp:简体转台湾正体(含台湾地域习惯用词转换)
var converter = OpenCC.Converter({ from: 'cn', to: 'twp' });
var reverseConverter = OpenCC.Converter({ from: 'tw', to: 'cn' });
// 3. 遍历并转换文本节点,跳过代码与输入控件
function convertElement(node) {
if (!node) return;
var walker = document.createTreeWalker(
node,
NodeFilter.SHOW_TEXT,
{
acceptNode: function(textNode) {
var p = textNode.parentNode;
if (!p) return NodeFilter.FILTER_REJECT;
var tag = p.tagName;
if (['SCRIPT', 'STYLE', 'CODE', 'PRE', 'TEXTAREA'].indexOf(tag) !== -1) {
return NodeFilter.FILTER_REJECT;
}
if (tag === 'INPUT' || p.isContentEditable) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
}
}
);
var curr;
while ((curr = walker.nextNode())) {
curr.nodeValue = converter(curr.nodeValue);
}
}
convertElement(document.body);
document.title = converter(document.title);
document.querySelectorAll('input[placeholder]').forEach(function(el) {
el.placeholder = converter(el.placeholder);
});
// 4. 监听 Moodle 动态加载内容(折叠章节、侧边抽屉、AJAX 弹窗)
var debounceTimer = null;
var pendingNodes = [];
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(m) {
m.addedNodes.forEach(function(n) {
if (n.nodeType === Node.ELEMENT_NODE) {
pendingNodes.push(n);
}
});
});
clearTimeout(debounceTimer);
debounceTimer = setTimeout(function() {
while (pendingNodes.length > 0) {
convertElement(pendingNodes.shift());
}
}, 60);
});
observer.observe(document.body, { childList: true, subtree: true });
// 5. 表单提交前将输入框内的繁体转回简体
document.addEventListener('submit', function(e) {
e.target.querySelectorAll('input[type="text"], textarea').forEach(function(input) {
if (input.value) {
input.value = reverseConverter(input.value);
}
});
}, true);
}).catch(function(err) {
console.error('[OpenCC] Load error:', err);
});
})();
</script>
- 点击页面底部的「保存更改(Save changes)」。
- 顺带提一个小坑:如果你是用浏览器自动化工具帮你在后台点保存,千万别直接写
document.querySelector('button[type="submit"]').click(),因为 Moodle 顶栏导航条里的全局搜索框按钮也是button[type="submit"],直接点会跳去全站搜索页;务必限定选择器为form#adminsettings button[type="submit"]。
- 顺带提一个小坑:如果你是用浏览器自动化工具帮你在后台点保存,千万别直接写
Step 3:切换双语验证效果
保存后打开任意一门课程,在右上角语言菜单切换简繁进行对比:
| 检查项 | 简体环境(?lang=zh_cn) | 繁体环境(?lang=zh_tw) |
|---|---|---|
| 课程与章节标题 | 第一单元:为什么要学习…… | 第一單元:為什麼要學習…… |
| 资源类型标签 | 课程指南(文件) | 課程指南(檔案) |
| 视听活动名称 | 视频 / 音频 | 影片 / 音訊 |
| 常用术语 | 伙伴项目 / 量身定制 | 夥伴專案 / 量身定製 |
| 控制台报错 | 0(脚本提前退出,不发请求) | 0(无 RequireJS 冲突报错) |
得益于 from: 'cn', to: 'twp' 地域词典模式,不仅字形完成了转换,「文件/檔案」「视频/影片」「音频/音訊」这类两岸日常用词差异也一并自动对齐。
5. 小结与适用边界
这套「官方语言包 + Additional HTML 注入 opencc-js」的做法,适合绝大多数以简体中文为主、需要低成本兼顾港澳台学员阅读体验的 Moodle 站点:
- 何时最好用:全站课程以图文网页、视频外链、客观测验题(单选、多选、判断、配对、下拉选词填空)为主,希望零数据库改动、5 分钟内上线生效。
- 何时不要硬套:
- 如果课件文字压在图片或扫描版 PDF 里,前端 DOM 替换无法生效。
- 如果你的测验里包含大量需要手敲文字的填空题(
shortanswer或内嵌SHORTANSWER的cloze),虽然上面脚本第 5 步加入了捕获阶段submit事件自动繁转简,但本次我只验证了页面渲染,还没在真实考试交卷和 Moodle AJAX 自动保存草稿的链路中做过端到端测验,正式开考前建议先拿测试账号跑通一次交卷判分,或者直接在题库标准答案里补上繁体同义词。
