fix: restore KaTeX assets and deployment checks
CI / test (pull_request) Canceled after 7s
PR合并自动部署 / release-check (pull_request) Successful in 1m40s
PR合并自动部署 / deploy (pull_request) Successful in 14s

This commit is contained in:
2026-08-09 03:47:17 +08:00
parent feb8845ec7
commit 3e47ba787d
68 changed files with 121 additions and 11 deletions
+10
View File
@@ -60,6 +60,16 @@ jobs:
- name: ASGI import check - name: ASGI import check
working-directory: backend working-directory: backend
run: ../.venv-ci/bin/python -c "from config.asgi import application; print(type(application).__name__)" run: ../.venv-ci/bin/python -c "from config.asgi import application; print(type(application).__name__)"
- name: Production static assets check
working-directory: backend
env:
DJANGO_DEBUG: "false"
DJANGO_USE_HTTPS: "false"
DJANGO_SECRET_KEY: ci-static-assets-check
DJANGO_ALLOWED_HOSTS: localhost
DATABASE_URL: mysql://root:ci-root-password@mysql:3306/hulumath
REDIS_URL: redis://127.0.0.1:6379/0
run: ../.venv-ci/bin/python manage.py collectstatic --noinput --clear
- name: SQLite tests and coverage - name: SQLite tests and coverage
run: | run: |
.venv-ci/bin/python -m pytest -q \ .venv-ci/bin/python -m pytest -q \
+11
View File
@@ -66,6 +66,17 @@ jobs:
working-directory: backend working-directory: backend
run: ../.venv-release/bin/python -c "from config.asgi import application; print(type(application).__name__)" run: ../.venv-release/bin/python -c "from config.asgi import application; print(type(application).__name__)"
- name: 生产静态资源完整性检查
working-directory: backend
env:
DJANGO_DEBUG: "false"
DJANGO_USE_HTTPS: "false"
DJANGO_SECRET_KEY: release-static-assets-check
DJANGO_ALLOWED_HOSTS: localhost
DATABASE_URL: mysql://root:ci-root-password@mysql:3306/hulumath
REDIS_URL: redis://127.0.0.1:6379/0
run: ../.venv-release/bin/python manage.py collectstatic --noinput --clear
- name: MySQL 发布迁移检查 - name: MySQL 发布迁移检查
env: env:
DATABASE_URL: mysql://root:ci-root-password@mysql:3306/hulumath DATABASE_URL: mysql://root:ci-root-password@mysql:3306/hulumath
+38
View File
@@ -1,6 +1,9 @@
import re
from pathlib import Path from pathlib import Path
from django.conf import settings from django.conf import settings
from django.core.management import call_command
from django.test import override_settings
STATIC_ROOT = Path(settings.BASE_DIR) / "static" STATIC_ROOT = Path(settings.BASE_DIR) / "static"
@@ -71,6 +74,41 @@ def test_toolbox_and_games_资源入口与移动端触控样式存在():
assert "@media (max-width: 700px)" in styles assert "@media (max-width: 700px)" in styles
def test_latex_默认源码可渲染且_katex_静态资源完整():
template = (settings.BASE_DIR / "templates" / "index.html").read_text(encoding="utf-8")
app = (STATIC_ROOT / "js" / "app.js").read_text(encoding="utf-8")
katex_root = STATIC_ROOT / "vendor" / "katex"
katex_css = (katex_root / "katex.min.css").read_text(encoding="utf-8")
font_paths = set(re.findall(r"url\(fonts/([^)]+)\)", katex_css))
assert (
'<textarea id="latex-source" spellcheck="false">'
r"\int_{0}^{1} x^2\,dx = \frac{1}{3}</textarea>"
) in template
assert r"\\int_{0}^{1}" not in template
assert "function normalizeLatexSource(source)" in app
assert "globalThis.katex.render(normalized, target" in app
assert "throwOnError: true" in app
assert len(font_paths) == 60
assert all((katex_root / "fonts" / path).is_file() for path in font_paths)
assert (katex_root / "LICENSE.txt").is_file()
def test_production_manifest_可处理全部静态资源(tmp_path):
storage = "whitenoise.storage.CompressedManifestStaticFilesStorage"
with override_settings(
STATIC_ROOT=tmp_path,
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {"BACKEND": storage},
},
):
call_command("collectstatic", interactive=False, clear=True, verbosity=0)
assert (tmp_path / "staticfiles.json").is_file()
assert (tmp_path / "vendor" / "katex" / "katex.min.css").is_file()
def test_realtime_match_联机码与_websocket_前端资源存在(): def test_realtime_match_联机码与_websocket_前端资源存在():
template = (settings.BASE_DIR / "templates" / "index.html").read_text(encoding="utf-8") template = (settings.BASE_DIR / "templates" / "index.html").read_text(encoding="utf-8")
realtime = (STATIC_ROOT / "js" / "realtime.js").read_text(encoding="utf-8") realtime = (STATIC_ROOT / "js" / "realtime.js").read_text(encoding="utf-8")
+3 -1
View File
@@ -161,7 +161,9 @@ button { color: inherit; }
.editor-pane label, .preview-pane > span { display: block; color: #9eaaa2; font-size: 10px; letter-spacing: .16em; text-transform: uppercase; } .editor-pane label, .preview-pane > span { display: block; color: #9eaaa2; font-size: 10px; letter-spacing: .16em; text-transform: uppercase; }
.editor-pane textarea { width: 100%; height: 330px; margin-top: 22px; resize: none; border: 0; outline: 0; background: transparent; color: #dcefa1; font: 16px/1.8 "SFMono-Regular", Consolas, monospace; } .editor-pane textarea { width: 100%; height: 330px; margin-top: 22px; resize: none; border: 0; outline: 0; background: transparent; color: #dcefa1; font: 16px/1.8 "SFMono-Regular", Consolas, monospace; }
.preview-pane { display: flex; flex-direction: column; } .preview-pane { display: flex; flex-direction: column; }
#latex-preview { margin: auto; max-width: 100%; overflow-wrap: anywhere; font: 28px/1.6 Georgia, serif; } #latex-preview { margin: auto; max-width: 100%; overflow-x: auto; overflow-y: hidden; padding: 18px 4px; font-size: 28px; line-height: 1.6; }
#latex-preview .katex-display { margin: 0; overflow-x: auto; overflow-y: hidden; }
#latex-preview.latex-preview-error { color: #b84136; font: 14px/1.7 system-ui, sans-serif; white-space: normal; }
.preview-pane button { align-self: flex-end; } .preview-pane button { align-self: flex-end; }
.profile-panel { padding: 35px; } .profile-panel { padding: 35px; }
.metric-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; } .metric-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; }
+34 -6
View File
@@ -1060,15 +1060,40 @@ function switchTool(tool) {
window.HuluToolbox?.activate(tool); window.HuluToolbox?.activate(tool);
} }
function normalizeLatexSource(source) {
let normalized = String(source || "").trim();
if (normalized.startsWith("$$") && normalized.endsWith("$$")) {
normalized = normalized.slice(2, -2).trim();
} else if (normalized.startsWith("\\[") && normalized.endsWith("\\]")) {
normalized = normalized.slice(2, -2).trim();
} else if (normalized.startsWith("$") && normalized.endsWith("$")) {
normalized = normalized.slice(1, -1).trim();
}
return normalized.replace(/\\\\(?=[A-Za-z,;:!])/g, "\\");
}
function renderLatexPreview(source) { function renderLatexPreview(source) {
const target = $("#latex-preview"); const target = $("#latex-preview");
target.replaceChildren(); target.replaceChildren();
if (!source.trim()) return; target.classList.remove("latex-preview-error");
if (typeof katex === "undefined") { target.textContent = source; return; } const normalized = normalizeLatexSource(source);
if (!normalized) return;
if (typeof globalThis.katex?.render !== "function") {
target.classList.add("latex-preview-error");
target.textContent = "公式渲染组件加载失败,请刷新页面后重试。";
return;
}
try { try {
katex.render(source, target, { throwOnError: false, displayMode: true }); globalThis.katex.render(normalized, target, {
} catch { displayMode: true,
target.textContent = source; throwOnError: true,
trust: false,
maxExpand: 500,
maxSize: 20,
});
} catch (error) {
target.classList.add("latex-preview-error");
target.textContent = `公式语法错误:${error.message}`;
} }
} }
@@ -1077,7 +1102,10 @@ async function saveFormula() {
try { try {
await api("latex/documents/", { await api("latex/documents/", {
method: "POST", method: "POST",
body: { title: `公式 ${new Date().toLocaleString()}`, source: $("#latex-source").value }, body: {
title: `公式 ${new Date().toLocaleString()}`,
source: normalizeLatexSource($("#latex-source").value),
},
}); });
showToast("公式及首个版本已保存"); showToast("公式及首个版本已保存");
} catch (error) { } catch (error) {
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2013-2020 Khan Academy and other contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -328,8 +328,8 @@
<div class="tool-workspace" id="tool-latex"> <div class="tool-workspace" id="tool-latex">
<div class="workspace-heading"><div><span class="kicker">LATEX LAB</span><h2>数学表达实验室</h2></div><p>源码、实时预览与版本保存。</p></div> <div class="workspace-heading"><div><span class="kicker">LATEX LAB</span><h2>数学表达实验室</h2></div><p>源码、实时预览与版本保存。</p></div>
<div class="editor-shell"> <div class="editor-shell">
<div class="editor-pane"><label for="latex-source">LaTeX 源码</label><textarea id="latex-source" spellcheck="false">\\int_{0}^{1} x^2\\,dx = \\frac{1}{3}</textarea></div> <div class="editor-pane"><label for="latex-source">LaTeX 源码</label><textarea id="latex-source" spellcheck="false">\int_{0}^{1} x^2\,dx = \frac{1}{3}</textarea></div>
<div class="preview-pane"><span>实时预览</span><div id="latex-preview">\int_{0}^{1} x^2\,dx = \frac{1}{3}</div><button id="save-formula" class="primary-button">保存到我的公式</button></div> <div class="preview-pane"><span>实时预览</span><div id="latex-preview" role="status" aria-live="polite"></div><button id="save-formula" class="primary-button">保存到我的公式</button></div>
</div> </div>
</div> </div>
</section> </section>
+2 -2
View File
@@ -228,7 +228,7 @@ test -s "$BACKUP_FILE" || fail "数据库备份为空: $BACKUP_FILE"
find "$BACKUP_DIR" -type f -name 'hulumath_*.sql.gz' -mtime +14 -delete find "$BACKUP_DIR" -type f -name 'hulumath_*.sql.gz' -mtime +14 -delete
log "数据库备份完成: $BACKUP_FILE" log "数据库备份完成: $BACKUP_FILE"
log "执行 Django 发布检查、迁移和静态资源收集" log "执行 Django 发布检查、静态资源收集和迁移"
if [ -d "$PROJECT_DIR/backend/staticfiles" ]; then if [ -d "$PROJECT_DIR/backend/staticfiles" ]; then
$SUDO chown -R "$DEPLOY_OWNER" "$PROJECT_DIR/backend/staticfiles" $SUDO chown -R "$DEPLOY_OWNER" "$PROJECT_DIR/backend/staticfiles"
fi fi
@@ -240,8 +240,8 @@ else
"$VENV_DIR/bin/python" manage.py check --deploy --fail-level ERROR --database default "$VENV_DIR/bin/python" manage.py check --deploy --fail-level ERROR --database default
fi fi
"$VENV_DIR/bin/python" manage.py check_mysql "$VENV_DIR/bin/python" manage.py check_mysql
"$VENV_DIR/bin/python" manage.py migrate --noinput
"$VENV_DIR/bin/python" manage.py collectstatic --noinput "$VENV_DIR/bin/python" manage.py collectstatic --noinput
"$VENV_DIR/bin/python" manage.py migrate --noinput
cd "$PROJECT_DIR" cd "$PROJECT_DIR"
log "安装或更新 systemd 服务" log "安装或更新 systemd 服务"