输入任意标题、正文关键词或分类开始搜索

按 Esc 关闭,按 Enter 查看完整页面

/ 程序员

不改数据库,用 OpenCC 给 Moodle 加上全站繁体中文转换(含 RequireJS 避坑)

如果你使用 Moodle 做了一套简体中文 AI 课程,既不想忽略掉繁体中文用户,又不想再单独维护一套繁体中文课程,可以通过官方语言包加 OpenCC 前端注入实现免改库全站繁简转换。

1. 起因与结论先行

如果你使用 Moodle 做了一套简体中文 AI 课程,但是又不想忽略掉繁体中文的用户,同时也不想再单独维护一套繁体中文的课程,可以试一下这个办法。

需求很明确:不能改动现有的简体数据库,不能把课程和题库复制成两套维护,也尽量不要去重新编译 Docker 容器或折腾 PHP 后端插件,但要让切换到繁体中文的学员看到全繁体的界面、课件正文与测验题目。

今天我把整套链路在 Moodle 线上环境跑通了。最终验证可行的方案分为两层:

  1. 系统界面层:直接在 Moodle 后台安装官方的 zh_tw(正体中文)语言包,接管导航栏、按钮和系统提示。
  2. 课程与题库内容层:通过 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 后台安装繁体语言包

  1. 用管理员账号登录 Moodle,进入 网站管理(Site administration)→ 语言(Language)→ 语言包(Language packs)。
  2. 在右侧可用列表中找到 「繁體中文 (zh_tw)」(如果需要兼顾香港学员,可一并安装 zh_hk),点击安装。
  3. 安装完成后,网站顶栏会出现语言切换菜单。

Step 2:在「额外 HTML」注入转换脚本

  1. 进入 网站管理(Site administration)→ 外观(Appearance)→ 额外 HTML(Additional HTML),或者直接访问后台路径: /admin/settings.php?section=additionalhtml
  2. 向下滚动找到第三个输入框:「在 BODY 闭合标签前(Before BODY is closed)」(对应表单字段 #id_s__additionalhtmlfooter)。
  3. 注意保留该输入框内原有的代码(例如很多站点在这里放了 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>
  1. 点击页面底部的「保存更改(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 分钟内上线生效。
  • 何时不要硬套:
    1. 如果课件文字压在图片或扫描版 PDF 里,前端 DOM 替换无法生效。
    2. 如果你的测验里包含大量需要手敲文字的填空题(shortanswer 或内嵌 SHORTANSWER 的 cloze),虽然上面脚本第 5 步加入了捕获阶段 submit 事件自动繁转简,但本次我只验证了页面渲染,还没在真实考试交卷和 Moodle AJAX 自动保存草稿的链路中做过端到端测验,正式开考前建议先拿测试账号跑通一次交卷判分,或者直接在题库标准答案里补上繁体同义词。