Author SHA1 Message Date
Jacky 92c456c7d1 fix: smoke test Hulumath over public TLS
CI / test (pull_request) Successful in 6m7s
PR合并自动部署 / release-check (pull_request) Successful in 3s
PR合并自动部署 / deploy (pull_request) Failing after 19s
2026-09-29 01:29:55 +08:00
Jacky 9a5ce6e4af Merge pull request 'fix: decouple edge HSTS from backend health' (#44) from fix/edge-hsts-header-probe into main 2026-09-29 01:13:59 +08:00
Jacky 8825f6c639 fix: decouple edge HSTS from backend health
CI / test (pull_request) Successful in 6m47s
PR合并自动部署 / release-check (pull_request) Successful in 4s
PR合并自动部署 / deploy (pull_request) Failing after 21s
2026-09-29 01:05:24 +08:00
Jacky d87edf0530 Merge pull request 'fix: use certificate host for deployment health checks' (#43) from fix/deploy-health-host into main 2026-09-29 01:00:40 +08:00
Jacky c89b82c6f4 fix: use certificate host for deployment health checks
CI / test (pull_request) Successful in 6m43s
PR合并自动部署 / release-check (pull_request) Successful in 3s
PR合并自动部署 / deploy (pull_request) Failing after 11s
2026-09-29 00:51:01 +08:00
Jacky f7536d3a3f Merge pull request 'fix: verify edge-owned HSTS during deployment' (#42) from fix/edge-hsts-deploy-check into main 2026-09-29 00:47:49 +08:00
Jacky 6d8ed567b0 fix: verify edge-owned HSTS during deployment
CI / test (pull_request) Successful in 6m38s
PR合并自动部署 / release-check (pull_request) Successful in 3s
PR合并自动部署 / deploy (pull_request) Failing after 11s
2026-09-29 00:40:00 +08:00
Jacky 136db1d47e Merge pull request 'fix: use HTTPS for production repository fetches' (#41) from fix/deploy-https-remote into main 2026-09-29 00:35:40 +08:00
Jacky c609cd0d02 fix: use HTTPS for production repository fetches
CI / test (pull_request) Successful in 6m42s
PR合并自动部署 / release-check (pull_request) Successful in 3s
PR合并自动部署 / deploy (pull_request) Failing after 13s
2026-09-29 00:27:54 +08:00
Jacky 848db95623 Merge pull request 'ci: authenticate production checkout' (#40) from fix/deploy-authenticated-checkout into main 2026-09-29 00:25:20 +08:00
Jacky 3d61deefd3 ci: authenticate production checkout
CI / test (pull_request) Successful in 6m54s
PR合并自动部署 / release-check (pull_request) Successful in 3s
PR合并自动部署 / deploy (pull_request) Failing after 3s
2026-09-29 00:16:33 +08:00
Jacky 881cee958d Merge pull request 'docs: add Universal Context capsule' (#39) from chore/universal-context-capsule into main 2026-09-28 23:47:27 +08:00
Jacky 719ce60b51 style: align context check with Ruff
CI / test (pull_request) Successful in 5m53s
PR合并自动部署 / release-check (pull_request) Failing after 7s
PR合并自动部署 / deploy (pull_request) Skipped
2026-09-28 23:20:42 +08:00
Jacky 49fd8598e7 ci: use authenticated Gitea checkout
CI / test (pull_request) Failing after 4m13s
2026-09-28 22:58:29 +08:00
Jacky 6588e128bd docs: add universal context capsule
CI / test (pull_request) Failing after 6s
2026-09-28 22:15:02 +08:00
Jacky 8ac22a4c52 Merge pull request 'feat: upgrade toolbox for v1.2.1' (#38) from feat/v1.2.1-toolbox into main
Reviewed-on: http://117.72.28.96:8765/Jacky/Hulumath-Web/pulls/38
2026-08-10 02:06:40 +08:00
Jacky 8e94f053b1 feat: upgrade toolbox for v1.2.1
CI / test (pull_request) Canceled after 52s
PR合并自动部署 / release-check (pull_request) Successful in 13s
PR合并自动部署 / deploy (pull_request) Successful in 13s
2026-08-10 02:05:21 +08:00
24 changed files with 1438 additions and 343 deletions
+3 -1
View File
@@ -2,7 +2,7 @@
# 包含 #、空格等特殊字符的值请用单引号包裹。
DJANGO_SETTINGS_MODULE=config.settings
APP_VERSION=1.2.0
APP_VERSION=1.2.1
DJANGO_SECRET_KEY='replace-with-at-least-50-random-characters'
DJANGO_DEBUG=false
DJANGO_USE_HTTPS=true
@@ -17,6 +17,8 @@ API_ANON_RATE=120/minute
API_USER_RATE=600/minute
CALCULATOR_RATE=30/minute
SECURE_HSTS_SECONDS=31536000
# Set true only when the HTTPS reverse proxy emits a non-zero HSTS max-age.
DJANGO_HSTS_AT_EDGE=false
# 仅供部署脚本直接访问 127.0.0.1:8000 时设置 Host 头。
DEPLOY_HEALTH_HOST=math.example.com
+8 -2
View File
@@ -26,13 +26,19 @@ jobs:
steps:
- name: Checkout repository
env:
REPOSITORY_URL: http://117.72.28.96:8765/Jacky/Hulumath-Web.git
CHECKOUT_TOKEN: ${{ github.token }}
REVISION: ${{ gitea.sha }}
run: |
git clone "$REPOSITORY_URL" .
set -euo pipefail
git init .
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git -c http.extraHeader="Authorization: token ${CHECKOUT_TOKEN}" \
fetch --depth=1 origin "$REVISION"
git checkout --detach "$REVISION"
- name: Show Python version
run: python3 --version
- name: Validate repository Capsule
run: python3 scripts/check_context.py
- name: Install MySQL build dependencies
run: |
sudo apt-get update
+14 -10
View File
@@ -19,8 +19,14 @@ jobs:
steps:
- name: 检出 main
env:
REPOSITORY_URL: http://117.72.28.96:8765/Jacky/Hulumath-Web.git
run: git clone --branch main --single-branch "$REPOSITORY_URL" .
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
git init .
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git -c http.extraHeader="Authorization: token ${CHECKOUT_TOKEN}" \
fetch --depth=1 origin main
git checkout --detach FETCH_HEAD
- name: 检查发布文件和脚本语法
run: |
@@ -63,7 +69,7 @@ jobs:
set -Eeuo pipefail
PROJECT="/www/wwwroot/Hulumath-Web"
REPOSITORY_URL="http://117.72.28.96:8765/Jacky/Hulumath-Web.git"
REPOSITORY_URL="https://git.jacky0987.xyz/Jacky/Hulumath-Web.git"
PYTHON_BIN="/www/server/pyporject_evn/versions/3.12.13/bin/python3"
exec 9>/tmp/hulumath-production-deploy.lock
@@ -94,11 +100,13 @@ jobs:
chmod +x scripts/deploy_production.sh
DJANGO_HSTS_AT_EDGE=true \
PROJECT_DIR="$PROJECT" \
PYTHON_BIN="$PYTHON_BIN" \
PREVIOUS_REVISION="$PREVIOUS_REVISION" \
DEPLOY_SMOKE_BASE_URL="http://127.0.0.1:4321" \
DEPLOY_SMOKE_WS_URL="ws://127.0.0.1:4321/ws/health/" \
DEPLOY_HEALTH_HOST="math.jacky0987.xyz" \
DEPLOY_SMOKE_BASE_URL="https://math.jacky0987.xyz" \
DEPLOY_SMOKE_WS_URL="wss://math.jacky0987.xyz/ws/health/" \
bash scripts/deploy_production.sh
echo "========================================"
@@ -108,13 +116,9 @@ jobs:
EOF
- name: 从 Runner 验证 Nginx 健康状态
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_PUBLIC_HOST: 117.72.28.96
run: |
curl --fail --silent --show-error \
--retry 5 \
--retry-delay 2 \
--header "Host: $DEPLOY_PUBLIC_HOST" \
"http://$DEPLOY_HOST:4321/health/" \
"https://math.jacky0987.xyz/health/" \
| grep -q '"status": "ok"'
+22
View File
@@ -0,0 +1,22 @@
# Hulumath AI Working Agreement
Read `context/boot.md` and `context/capsule.json` before changing the product.
Then select the smallest relevant product, architecture or deployment
document under `docs/`.
## Source Boundaries
- Hulumath owns its Django application, mathematical content, product rules
and release documentation.
- JLearn may link to Hulumath but does not own its users or runtime data.
- JOps owns shared edge, deployment observation and platform authentication
facts.
- Secrets, production databases, uploads and user data remain outside Git.
## Change Protocol
1. Preserve server-side authorization, scoring and anti-cheat checks.
2. Keep API and WebSocket routes backward compatible or version them.
3. Update product or deployment documentation with behavioral changes.
4. Run `make context-check`, `make check` and `make test` before committing.
5. Treat checked-in release notes as source history, not live runtime proof.
+5 -2
View File
@@ -1,4 +1,4 @@
.PHONY: install migrate seed local-admin run run-asgi test check
.PHONY: install migrate seed local-admin run run-asgi test check context-check
install:
python3 -m venv .venv
@@ -23,7 +23,10 @@ run-asgi:
test:
.venv/bin/python -m pytest -q
check:
context-check:
python3 scripts/check_context.py
check: context-check
.venv/bin/ruff check backend scripts
cd backend && ../.venv/bin/python manage.py check
cd backend && ../.venv/bin/python manage.py makemigrations --check --dry-run
+2 -2
View File
@@ -2,8 +2,8 @@
面向全年龄数学兴趣用户的“数学人生宇宙”。当前仓库包含可运行的 Django 模块化单体、响应式 Web 客户端、运营后台、内容种子、实时比赛基础设施和生产部署配置。
当前发布版本:**v1.2.0**。变更与迁移说明见
[v1.2.0 Release Notes](docs/RELEASE_NOTES_V1.2.0.md)。
当前发布版本:**v1.2.1**。变更与迁移说明见
[v1.2.1 Release Notes](docs/RELEASE_NOTES_V1.2.1.md)。
## 参与贡献
@@ -0,0 +1,23 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def test_edge_hsts_ownership_is_explicit_and_verified():
settings = (ROOT / "backend/config/settings.py").read_text(encoding="utf-8")
deploy = (ROOT / "scripts/deploy_production.sh").read_text(encoding="utf-8")
workflow = (ROOT / ".gitea/workflows/deploy.yml").read_text(encoding="utf-8")
example = (ROOT / ".env.production.example").read_text(encoding="utf-8")
assert 'os.getenv("DJANGO_HSTS_AT_EDGE", "false")' in settings
assert '["security.W004"]' in settings
assert "DJANGO_HSTS_AT_EDGE=true" in workflow
assert 'DEPLOY_HEALTH_HOST="math.jacky0987.xyz"' in workflow
assert 'DEPLOY_SMOKE_BASE_URL="https://math.jacky0987.xyz"' in workflow
assert 'DEPLOY_SMOKE_WS_URL="wss://math.jacky0987.xyz/ws/health/"' in workflow
assert '"https://math.jacky0987.xyz/health/"' in workflow
assert "127.0.0.1:4321" not in workflow
assert "curl --silent --show-error --head" in deploy
assert "curl --fail --silent --show-error --head" not in deploy
assert "strict-transport-security:.*max-age=[1-9][0-9]*" in deploy
assert "DJANGO_HSTS_AT_EDGE=false" in example
+45
View File
@@ -81,6 +81,51 @@ def test_toolbox_and_games_资源入口与移动端触控样式存在():
assert "@media (max-width: 700px)" in styles
def test_v121_工具箱分类与响应式函数探索器资源完整():
template = (settings.BASE_DIR / "templates" / "index.html").read_text(
encoding="utf-8"
)
app = (STATIC_ROOT / "js" / "app.js").read_text(encoding="utf-8")
toolbox = (STATIC_ROOT / "js" / "toolbox.js").read_text(encoding="utf-8")
graph = (STATIC_ROOT / "js" / "graph.js").read_text(encoding="utf-8")
styles = (STATIC_ROOT / "css" / "app.css").read_text(encoding="utf-8")
assert template.count("js/graph.js") == 1
assert 'data-tool="mental"' not in template
assert 'class="tool-groups"' in template
assert 'data-calc-category="calculus"' in template
assert 'value="solve_system"' in template
assert 'value="matrix_eigenvalues"' in template
assert 'id="graph-function-list"' in template
assert 'id="graph-x-min"' in template
assert 'id="graph-y-max"' in template
assert 'id="graph-auto-fit"' in template
assert 'id="graph-expression"' not in template
assert 'id="graph-range"' not in template
assert "function setCalculatorCategory(" in toolbox
assert "window.HuluGraph?.init()" in toolbox
assert "drawGraph" not in toolbox
assert 'tool === "mental"' not in toolbox
assert "drawGraph" not in app
assert 'tool === "mental"' not in app
assert "function addFunction(" in graph
assert "function drawAxes(" in graph
assert "function niceStep(" in graph
assert "window.devicePixelRatio" in graph
assert '"pointerdown"' in graph
assert '"wheel"' in graph
assert "ResizeObserver" in graph
assert "最多同时绘制 8 条曲线" in graph
assert ".tool-groups" in styles
assert ".calc-category-tabs" in styles
assert ".graph-function-row" in styles
assert ".graph-stage" in styles
assert "aspect-ratio: 16 / 10" in styles
assert "touch-action: none" 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")
+7 -1
View File
@@ -6,7 +6,7 @@ from django.core.exceptions import ImproperlyConfigured
BASE_DIR = Path(__file__).resolve().parent.parent
PROJECT_ROOT = BASE_DIR.parent
APP_VERSION = os.getenv("APP_VERSION", "1.2.0")
APP_VERSION = os.getenv("APP_VERSION", "1.2.1")
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-only-change-before-production")
DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true"
@@ -189,6 +189,12 @@ SECURE_HSTS_SECONDS = int(
)
SECURE_HSTS_INCLUDE_SUBDOMAINS = USE_HTTPS
SECURE_HSTS_PRELOAD = USE_HTTPS
HSTS_AT_EDGE = os.getenv("DJANGO_HSTS_AT_EDGE", "false").lower() == "true"
SILENCED_SYSTEM_CHECKS = (
["security.W004"]
if USE_HTTPS and HSTS_AT_EDGE and SECURE_HSTS_SECONDS == 0
else []
)
LOGGING = {
"version": 1,
+36 -7
View File
@@ -185,11 +185,18 @@ button { color: inherit; }
.match-history-item.result-loss { border-left-color: #c05245; }.match-history-item.result-draw { border-left-color: #9a8455; }
.match-history-item > div, .match-history-item b, .match-history-item span, .match-history-item strong { display: block; }
.match-history-item > div:last-child { text-align: right; }.match-history-item span { margin-top: 5px; color: var(--muted); font-size: 11px; }.match-history-item strong { color: var(--green); }
.tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; margin-bottom: 22px; }
.tool-groups { display: grid; grid-template-columns: 1.15fr 1fr .78fr; gap: 14px; margin-bottom: 24px; align-items: stretch; }
.tool-group { min-width: 0; padding: 18px; border: 1px solid var(--line); border-radius: 20px; background: rgba(255,255,252,.52); }
.tool-group > header { display: flex; align-items: center; gap: 12px; min-height: 52px; margin-bottom: 14px; }
.tool-group > header > span { display: grid; place-items: center; width: 35px; height: 35px; flex: 0 0 35px; border-radius: 11px; background: var(--ink); color: var(--lime); font: 11px Georgia, serif; }
.tool-group > header b, .tool-group > header small { display: block; }.tool-group > header b { font: 18px Georgia, serif; }.tool-group > header small { margin-top: 4px; color: var(--muted); font-size: 10px; }
.tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-bottom: 22px; }
.tool-group .tool-grid { height: calc(100% - 66px); margin-bottom: 0; }
.tool-card {
min-height: 165px; border: 1px solid var(--line); border-radius: 18px; padding: 22px;
background: rgba(255,255,252,.7); text-align: left; cursor: pointer; transition: .2s ease;
}
.tool-group .tool-card { min-height: 148px; padding: 18px; }
.tool-card:hover, .tool-card.active { transform: translateY(-3px); border-color: rgba(25,101,72,.48); box-shadow: 0 16px 35px rgba(38,48,40,.08); }
.tool-card.active { background: var(--ink); color: white; }
.tool-card > span { display: grid; place-items: center; width: 52px; height: 52px; border-radius: 15px; background: rgba(25,101,72,.08); color: var(--green); font: 700 17px Georgia, serif; }
@@ -201,26 +208,48 @@ button { color: inherit; }
.workspace-heading { display: flex; justify-content: space-between; gap: 30px; align-items: end; margin-bottom: 25px; }
.workspace-heading h2 { margin: 7px 0 0; font: 31px Georgia, serif; }.workspace-heading p { max-width: 480px; color: var(--muted); font-size: 13px; }
.calculator-shell { max-width: 980px; margin: auto; }
.advanced-calculator { max-width: 1120px; }
.calc-category-tabs { display: flex; gap: 5px; margin-bottom: 16px; padding: 5px; border-radius: 13px; background: #e9ede7; overflow-x: auto; scrollbar-width: thin; }
.calc-category-tabs button { flex: 1 0 max-content; border: 0; border-radius: 9px; padding: 10px 14px; background: transparent; color: var(--muted); cursor: pointer; font-size: 12px; font-weight: 650; }
.calc-category-tabs button.active { background: var(--ink); color: white; box-shadow: 0 5px 14px rgba(23,33,27,.13); }
.calculator-display { min-height: 145px; border-radius: 18px; padding: 25px; background: var(--ink); color: white; display: flex; flex-direction: column; justify-content: space-between; text-align: right; }
.calculator-display small { color: #9eaaa2; }.calculator-display output { color: var(--lime); font: 48px/1 Georgia, serif; overflow-wrap: anywhere; }
.formula-input, .search-input { width: 100%; border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; background: white; outline-color: var(--green); }
.calculator-shell > .formula-input { margin: 14px 0; font: 18px "SFMono-Regular", Consolas, monospace; }
.calculator-actions { display: flex; flex-wrap: wrap; gap: 8px; }.calculator-actions button { border: 1px solid var(--line); border-radius: 10px; padding: 11px 15px; background: white; cursor: pointer; }.calculator-actions .primary-button { margin-left: auto; color: white; background: var(--green); border: 0; }
.calculator-controls { display: grid; grid-template-columns: repeat(4, minmax(120px, 1fr)); gap: 10px; margin-bottom: 14px; }.calculator-controls label, .calculator-expression-label { display: grid; gap: 7px; color: var(--muted); font-size: 11px; }.calculator-controls select, .calculator-controls input { width: 100%; border: 1px solid var(--line); border-radius: 10px; padding: 11px; background: white; }
.advanced-calculator .calculator-controls > label:first-child { grid-column: span 2; }
.calculator-controls [hidden] { display: none !important; }
.calculator-expression-label textarea { min-height: 92px; margin: 0 0 12px; resize: vertical; font: 17px/1.6 "SFMono-Regular", Consolas, monospace; }
.calc-hint { margin: -4px 0 14px; color: var(--muted); font-size: 11px; line-height: 1.65; }
.calculator-result { margin-top: 17px; }.calc-result-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 10px; }.calc-result-grid > div { min-width: 0; padding: 14px; border: 1px solid var(--line); border-radius: 12px; background: white; }.calc-result-grid span { display: block; color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .12em; }.calc-result-grid code { display: block; margin-top: 8px; overflow-wrap: anywhere; color: var(--green); }
.calc-steps { margin: 13px 0 0; padding: 14px 14px 14px 34px; border-radius: 12px; background: #eef1eb; color: var(--muted); font-size: 12px; line-height: 1.8; }.calc-steps:empty { display: none; }
.search-input { width: min(370px, 100%); }
.symbol-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
.symbol-card { border: 1px solid var(--line); border-radius: 15px; padding: 18px; background: white; cursor: pointer; text-align: left; }
.symbol-card strong { display: block; color: var(--green); font: 29px Georgia, serif; }.symbol-card b { display: block; margin-top: 10px; }.symbol-card small { display: block; margin-top: 5px; color: var(--muted); }.symbol-card code { display: inline-block; margin-top: 12px; padding: 4px 7px; border-radius: 6px; background: #f0f1eb; color: #405048; }
.graph-shell { display: grid; grid-template-columns: 245px minmax(0, 1fr); gap: 20px; }
.graph-controls { display: flex; flex-direction: column; gap: 17px; }.graph-controls label { display: grid; gap: 8px; color: var(--muted); font-size: 12px; }.graph-controls p { color: #b84136; font-size: 12px; }.graph-controls textarea { min-height: 100px; resize: vertical; font: 13px/1.6 "SFMono-Regular", Consolas, monospace; }
.graph-shell { display: grid; grid-template-columns: minmax(300px, 360px) minmax(0, 1fr); gap: 18px; align-items: start; }
.graph-controls { display: flex; min-width: 0; flex-direction: column; gap: 14px; padding: 18px; border: 1px solid var(--line); border-radius: 17px; background: rgba(247,248,243,.86); }.graph-controls label { display: grid; gap: 8px; color: var(--muted); font-size: 12px; }.graph-controls > p { min-height: 17px; margin: 0; color: #b84136; font-size: 12px; }
.function-syntax-help { border: 1px solid var(--line); border-radius: 11px; padding: 12px 14px; background: #f5f7f1; }.function-syntax-help summary { color: var(--green); font-weight: 700; cursor: pointer; }.function-syntax-help p { margin: 9px 0 0; color: var(--muted); line-height: 1.7; }.function-syntax-help code { color: var(--ink); }
.check-row { display: flex !important; grid-template-columns: auto 1fr; align-items: center; gap: 8px !important; }.check-row input { margin: 0; }
.graph-analysis { padding: 11px; border-radius: 10px; background: #eef1eb; color: var(--muted); font-size: 11px; line-height: 1.6; }
#graph-canvas { width: 100%; height: auto; border: 1px solid var(--line); border-radius: 17px; background: #fbfbf7; }
.graph-functions-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; }.graph-functions-heading b { font-size: 13px; }.graph-functions-heading button, .graph-actions button { border: 1px solid var(--line); border-radius: 9px; padding: 8px 10px; background: white; color: var(--green); cursor: pointer; font-size: 11px; font-weight: 650; }
.graph-function-list { display: grid; gap: 8px; }
.graph-function-row { display: grid; grid-template-columns: 18px 29px auto minmax(70px, 1fr) 29px; gap: 7px; align-items: center; min-width: 0; }
.graph-function-row > input[type="checkbox"] { width: 16px; height: 16px; margin: 0; accent-color: var(--green); }
.graph-function-row > input[type="color"] { width: 29px; height: 29px; padding: 2px; border: 1px solid var(--line); border-radius: 8px; background: white; cursor: pointer; }
.graph-function-row > span { color: var(--muted); font: 11px "SFMono-Regular", Consolas, monospace; white-space: nowrap; }
.graph-function-row .formula-input { min-width: 0; padding: 9px 10px; border-radius: 9px; font: 12px "SFMono-Regular", Consolas, monospace; }
.graph-function-row > button { width: 29px; height: 29px; border: 1px solid var(--line); border-radius: 8px; background: white; color: #a5443b; cursor: pointer; font-size: 18px; line-height: 1; }.graph-function-row > button:disabled { color: #b8beb9; cursor: not-allowed; }
.graph-parameter-control { display: grid; gap: 8px; padding-top: 3px; }.graph-parameter-control label { display: flex; justify-content: space-between; }.graph-parameter-control output { color: var(--green); font-weight: 700; }
.graph-viewport-controls { border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 12px 0; }.graph-viewport-controls summary { color: var(--ink); font-size: 12px; font-weight: 700; cursor: pointer; }.graph-viewport-controls > .check-row { margin-top: 12px; }
.graph-range-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 11px; }.graph-range-grid label { gap: 5px; font-size: 10px; }.graph-range-grid input { min-width: 0; width: 100%; border: 1px solid var(--line); border-radius: 8px; padding: 8px; background: white; color: var(--ink); }
.graph-option-grid { display: grid; grid-template-columns: 1fr; gap: 8px; }
.graph-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }.graph-actions .primary-button { grid-column: 1 / -1; border-color: var(--green); background: var(--green); color: white; }
.graph-stage { position: relative; min-width: 0; padding: 10px; border: 1px solid var(--line); border-radius: 19px; background: rgba(255,255,252,.72); overflow: hidden; }
.graph-legend { min-height: 29px; display: flex; flex-wrap: wrap; gap: 6px; padding: 0 3px 9px; }.graph-legend span { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; padding: 5px 8px; border-radius: 99px; background: #eef1eb; color: var(--muted); font: 10px "SFMono-Regular", Consolas, monospace; overflow-wrap: anywhere; }.graph-legend i { width: 8px; height: 8px; flex: 0 0 8px; border-radius: 50%; }
.graph-analysis { margin-top: 9px; padding: 11px; border-radius: 10px; background: #eef1eb; color: var(--muted); font-size: 11px; line-height: 1.6; }
.graph-stage-hint { padding: 8px 4px 0; color: #8b958e; font-size: 10px; text-align: center; }
#graph-canvas { display: block; width: 100%; height: auto; aspect-ratio: 16 / 10; border: 1px solid var(--line); border-radius: 14px; background: #fbfbf7; cursor: grab; touch-action: none; user-select: none; }#graph-canvas.dragging { cursor: grabbing; }
.drawing-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 14px; }.drawing-toolbar button, .drawing-toolbar label { border: 1px solid var(--line); border-radius: 9px; padding: 9px 12px; background: white; color: var(--muted); cursor: pointer; }.drawing-toolbar button.active { border-color: var(--ink); background: var(--ink); color: white; }.drawing-toolbar .primary-button { margin-left: auto; border: 0; background: var(--green); color: white; }.drawing-toolbar label { display: flex; align-items: center; gap: 7px; font-size: 11px; }.drawing-toolbar input[type=color] { width: 28px; height: 24px; padding: 0; border: 0; background: transparent; }
.toolbar-text-input { flex: 1 1 190px; min-width: 150px; border: 1px solid var(--line); border-radius: 9px; padding: 10px 12px; background: white; }.drawing-toolbar .file-tool input { display: none; }
.canvas-stage { width: 100%; overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: white; box-shadow: inset 0 0 0 1px rgba(255,255,255,.6); }.canvas-stage canvas { display: block; width: 100%; height: auto; touch-action: none; cursor: crosshair; }.geometry-stage { background: #fbfbf7; }.canvas-hint { color: var(--muted); font-size: 11px; text-align: center; }
@@ -296,7 +325,7 @@ dialog::backdrop { background: rgba(17,25,20,.55); backdrop-filter: blur(5px); }
.app-shell { grid-template-columns: 80px 1fr; }.sidebar { padding: 25px 13px; }.brand span:last-child, .nav-item:not(.active) span, .nav-item { font-size: 0; }
.nav-item { text-align: center; }.nav-item span { width: auto; font-size: 11px !important; }.sidebar-foot { display: none; }
.hero-grid { grid-template-columns: 1fr; }.daily-card { min-height: 350px; }.spirit-grid { grid-template-columns: 1fr 1fr; }.content-grid { grid-template-columns: 1fr 1fr; }
.tool-grid { grid-template-columns: repeat(3, 1fr); }.symbol-grid { grid-template-columns: repeat(3, 1fr); }.video-grid { grid-template-columns: 1fr 1fr; }.calculator-controls { grid-template-columns: repeat(2, 1fr); }
.tool-groups, .graph-shell { grid-template-columns: 1fr; }.tool-group .tool-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.tool-grid { grid-template-columns: repeat(3, 1fr); }.symbol-grid { grid-template-columns: repeat(3, 1fr); }.video-grid { grid-template-columns: 1fr 1fr; }.calculator-controls { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 700px) {
.app-shell { display: block; }.sidebar { position: fixed; top: auto; bottom: 0; width: 100%; height: 68px; z-index: 10; border: 0; border-top: 1px solid var(--line); padding: 8px; }
@@ -310,7 +339,7 @@ dialog::backdrop { background: rgba(17,25,20,.55); backdrop-filter: blur(5px); }
.board-online-panel { grid-template-columns: 1fr; }.board-online-panel > div, .board-online-panel form { display: grid; grid-template-columns: 1fr; }.board-online-panel p, .board-online-panel > strong { grid-column: 1; }
.story-experience { padding: 14px 14px 40px; }.story-experience-header { align-items: flex-start; }.story-experience-header .ghost-button { width: auto; padding: 9px 11px; font-size: 10px; }.story-chapter-bar, .story-experience-layout { grid-template-columns: 1fr; }.story-scene-panel { min-height: 0; padding: 25px 20px; }.story-scene-panel .scene { min-height: 120px; }.story-mark-panel { position: static; }.story-domain-counts { grid-template-columns: repeat(2, 1fr); }
.wechat-entry { align-items: flex-start; flex-direction: column; padding: 24px; }.wechat-entry-actions { width: 100%; display: grid; grid-template-columns: 1fr 1fr; }.wechat-entry-actions > * { justify-content: center; text-align: center; }
.tool-card { min-height: 125px; }.workspace-heading, .map-heading { display: block; }.workspace-heading p { margin-top: 12px; }.tool-workspace { padding: 16px; }.calculator-controls { grid-template-columns: 1fr 1fr; }.calculator-display { min-height: 120px; padding: 18px; }.calculator-display output { font-size: 34px; }.calc-examples .primary-button { width: 100%; margin-left: 0; }.drawing-toolbar { align-items: stretch; }.drawing-toolbar .primary-button { width: 100%; margin-left: 0; }.canvas-stage canvas { width: 100%; min-width: 0; }
.tool-group { padding: 14px; }.tool-group .tool-grid { grid-template-columns: 1fr; }.tool-card { min-height: 125px; }.workspace-heading, .map-heading { display: block; }.workspace-heading p { margin-top: 12px; }.tool-workspace { padding: 16px; }.calculator-controls { grid-template-columns: 1fr 1fr; }.advanced-calculator .calculator-controls > label:first-child { grid-column: 1 / -1; }.calculator-display { min-height: 120px; padding: 18px; }.calculator-display output { font-size: 34px; }.calc-examples .primary-button { width: 100%; margin-left: 0; }.graph-controls { padding: 14px; }.graph-function-row { grid-template-columns: 17px 27px auto minmax(60px, 1fr) 27px; gap: 5px; }.graph-function-row > input[type="color"], .graph-function-row > button { width: 27px; height: 27px; }.graph-range-grid { grid-template-columns: 1fr 1fr; }#graph-canvas { aspect-ratio: 4 / 3; }.drawing-toolbar { align-items: stretch; }.drawing-toolbar .primary-button { width: 100%; margin-left: 0; }.canvas-stage canvas { width: 100%; min-width: 0; }
.math-games-section { margin-top: 45px; }.game-card { min-height: 260px; padding: 21px; }.game-card-controls { align-items: stretch; }.game-card-controls .primary-button { flex: 1; }.twenty-four-numbers { gap: 7px; }.twenty-four-numbers button { border-radius: 13px; font-size: 28px; }.sudoku-board input { font-size: clamp(13px, 4.5vw, 20px); }.sudoku-actions { display: grid; grid-template-columns: 1fr 1fr; }.game-keypad { gap: 5px; }
.challenge-panel { grid-template-columns: 1fr; padding: 20px; }.challenge-actions form { grid-template-columns: 1fr; }.challenge-actions .dark-button { width: 100%; }.challenge-code { width: 100%; padding: 14px 10px; font-size: 27px; }.realtime-status-line { display: grid; }.realtime-progress-panel { grid-template-columns: 1fr 1fr; }
.map-stage { height: 480px; transform: scale(.92); }.ability-node { width: 108px; height: 94px; }.node-vision { left: calc(50% - 54px); }.node-humanities { left: 0; top: 100px; }.node-connection { right: 0; top: 100px; }.node-detection { left: 2%; bottom: 30px; }.node-modeling { right: 2%; bottom: 30px; }.pet-node { top: 190px; }
-75
View File
@@ -1109,18 +1109,6 @@ function evaluateExpression(source, variables = {}) {
return value;
}
function runCalculator() {
const input = $("#calc-input").value;
try {
const result = evaluateExpression(input);
$("#calc-history").textContent = input;
$("#calc-output").textContent = Number(result.toPrecision(12)).toString();
} catch (error) {
$("#calc-history").textContent = error.message;
$("#calc-output").textContent = "错误";
}
}
function renderSymbols(query = "") {
const keyword = query.trim().toLowerCase();
const matches = MATH_SYMBOLS.filter((symbol) =>
@@ -1152,70 +1140,7 @@ function renderSymbols(query = "") {
);
}
function drawGraph() {
const canvas = $("#graph-canvas");
const context = canvas.getContext("2d");
const expression = $("#graph-expression").value;
const range = Number($("#graph-range").value);
const width = canvas.width;
const height = canvas.height;
$("#graph-range-label").textContent = `−${range} 到 ${range}`;
$("#graph-error").textContent = "";
context.clearRect(0, 0, width, height);
context.fillStyle = "#fbfbf7";
context.fillRect(0, 0, width, height);
const toX = (x) => ((x + range) / (range * 2)) * width;
const toY = (y) => height / 2 - (y / range) * (height / 2);
context.strokeStyle = "#e4e5df";
context.lineWidth = 1;
for (let value = -range; value <= range; value += 1) {
context.beginPath();
context.moveTo(toX(value), 0);
context.lineTo(toX(value), height);
context.stroke();
context.beginPath();
context.moveTo(0, toY(value));
context.lineTo(width, toY(value));
context.stroke();
}
context.strokeStyle = "#718078";
context.lineWidth = 2;
context.beginPath();
context.moveTo(0, height / 2);
context.lineTo(width, height / 2);
context.moveTo(width / 2, 0);
context.lineTo(width / 2, height);
context.stroke();
context.strokeStyle = "#196548";
context.lineWidth = 3;
context.beginPath();
let drawing = false;
try {
for (let pixel = 0; pixel <= width; pixel += 2) {
const x = (pixel / width) * range * 2 - range;
const y = evaluateExpression(expression, { x });
const screenY = toY(y);
if (screenY < -height * 2 || screenY > height * 3) {
drawing = false;
continue;
}
if (!drawing) context.moveTo(pixel, screenY);
else context.lineTo(pixel, screenY);
drawing = true;
}
context.stroke();
} catch (error) {
$("#graph-error").textContent = error.message;
}
}
function switchTool(tool) {
if (tool === "mental") {
navigate("contest");
return;
}
$$(".tool-card").forEach((card) => {
card.classList.toggle("active", card.dataset.tool === tool);
});
+525
View File
@@ -0,0 +1,525 @@
(function () {
const $graph = (selector, root = document) => root.querySelector(selector);
const COLORS = [
"#196548",
"#d86f45",
"#5d73e8",
"#8667b5",
"#c44f83",
"#198b9a",
"#b07b24",
"#3d4852",
];
const graph = {
functions: [],
nextId: 1,
viewport: { xMin: -10, xMax: 10, yMin: -6, yMax: 6 },
dragging: null,
frame: null,
initialized: false,
observer: null,
};
function schedule() {
if (graph.frame) window.cancelAnimationFrame(graph.frame);
graph.frame = window.requestAnimationFrame(() => {
graph.frame = null;
draw();
});
}
function activeFunctions() {
return graph.functions.filter(
(item) => item.visible && item.expression.trim(),
);
}
function addFunction(expression = "", color) {
if (graph.functions.length >= 8) {
showToast("最多同时绘制 8 条曲线");
return;
}
graph.functions.push({
id: graph.nextId,
expression,
color: color || COLORS[(graph.nextId - 1) % COLORS.length],
visible: true,
});
graph.nextId += 1;
renderFunctionList();
schedule();
}
function renderFunctionList() {
const root = $graph("#graph-function-list");
root.replaceChildren(
...graph.functions.map((item, index) => {
const row = document.createElement("div");
row.className = "graph-function-row";
const visible = document.createElement("input");
visible.type = "checkbox";
visible.checked = item.visible;
visible.setAttribute("aria-label", `显示曲线 ${index + 1}`);
visible.addEventListener("change", () => {
item.visible = visible.checked;
schedule();
});
const color = document.createElement("input");
color.type = "color";
color.value = item.color;
color.setAttribute("aria-label", `曲线 ${index + 1} 颜色`);
color.addEventListener("input", () => {
item.color = color.value;
schedule();
});
const prefix = document.createElement("span");
prefix.textContent = `f${index + 1}(x)`;
const input = document.createElement("input");
input.className = "formula-input";
input.value = item.expression;
input.placeholder = "例如 sin(x)";
input.setAttribute("aria-label", `函数 ${index + 1}`);
input.addEventListener("input", () => {
item.expression = input.value;
schedule();
});
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = "×";
remove.title = "删除曲线";
remove.disabled = graph.functions.length === 1;
remove.addEventListener("click", () => {
graph.functions = graph.functions.filter(
(candidate) => candidate.id !== item.id,
);
renderFunctionList();
schedule();
});
row.append(visible, color, prefix, input, remove);
return row;
}),
);
}
function readViewport() {
const viewport = {
xMin: Number($graph("#graph-x-min").value),
xMax: Number($graph("#graph-x-max").value),
yMin: Number($graph("#graph-y-min").value),
yMax: Number($graph("#graph-y-max").value),
};
if (
!Object.values(viewport).every(Number.isFinite) ||
viewport.xMin >= viewport.xMax ||
viewport.yMin >= viewport.yMax ||
viewport.xMax - viewport.xMin > 1_000_000 ||
viewport.yMax - viewport.yMin > 1_000_000
) {
throw new Error("坐标范围必须有限、最小值小于最大值,跨度不超过 1,000,000");
}
return viewport;
}
function writeViewport(viewport = graph.viewport) {
$graph("#graph-x-min").value = Number(viewport.xMin.toPrecision(8));
$graph("#graph-x-max").value = Number(viewport.xMax.toPrecision(8));
$graph("#graph-y-min").value = Number(viewport.yMin.toPrecision(8));
$graph("#graph-y-max").value = Number(viewport.yMax.toPrecision(8));
}
function sampleFunction(item, viewport, count) {
const parameter = Number($graph("#graph-parameter").value);
const samples = [];
let lastError = null;
for (let index = 0; index <= count; index += 1) {
const x =
viewport.xMin +
(index / count) * (viewport.xMax - viewport.xMin);
let y = Number.NaN;
try {
y = evaluateExpression(item.expression, { x, a: parameter });
} catch (error) {
lastError = error;
}
samples.push({ x, y: Number.isFinite(y) ? y : Number.NaN });
}
if (!samples.some((point) => Number.isFinite(point.y))) {
throw lastError || new Error(`函数 ${item.expression} 在当前视窗没有有效值`);
}
return samples;
}
function autoY(functions, viewport) {
const values = functions
.flatMap((item) => sampleFunction(item, viewport, 500))
.map((item) => item.y)
.filter(Number.isFinite)
.sort((left, right) => left - right);
if (!values.length) return { ...viewport, yMin: -6, yMax: 6 };
const low = values[Math.floor(values.length * 0.02)];
const high = values[Math.min(values.length - 1, Math.ceil(values.length * 0.98))];
const span = Math.max(high - low, Math.abs(high) * 0.1, 2);
return {
...viewport,
yMin: low - span * 0.12,
yMax: high + span * 0.12,
};
}
function niceStep(span, target = 9) {
const rough = span / target;
const power = 10 ** Math.floor(Math.log10(rough));
const fraction = rough / power;
return (fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 5 ? 5 : 10) * power;
}
function tickLabel(value, step) {
if (Math.abs(value) < step * 0.001) return "0";
if (Math.abs(value) >= 10000 || Math.abs(value) < 0.001) {
return value.toExponential(1);
}
return value.toFixed(Math.min(6, Math.max(0, -Math.floor(Math.log10(step)))));
}
function canvasSize() {
const canvas = $graph("#graph-canvas");
const rect = canvas.getBoundingClientRect();
const ratio = Math.min(window.devicePixelRatio || 1, 2);
const width = Math.max(320, Math.round(rect.width));
const height = Math.max(320, Math.round(rect.height));
if (
canvas.width !== Math.round(width * ratio) ||
canvas.height !== Math.round(height * ratio)
) {
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
}
const context = canvas.getContext("2d");
context.setTransform(ratio, 0, 0, ratio, 0, 0);
return { context, width, height };
}
function drawGrid(context, viewport, width, height, toX, toY) {
context.fillStyle = "#fcfcf8";
context.fillRect(0, 0, width, height);
if (!$graph("#graph-grid").checked) return;
const xStep = niceStep(viewport.xMax - viewport.xMin);
const yStep = niceStep(viewport.yMax - viewport.yMin);
context.font = "11px system-ui, sans-serif";
context.lineWidth = 1;
context.strokeStyle = "#e1e6e0";
context.fillStyle = "#6d796f";
context.textAlign = "center";
context.textBaseline = "top";
for (
let value = Math.ceil(viewport.xMin / xStep) * xStep;
value <= viewport.xMax + xStep * 0.001;
value += xStep
) {
const x = toX(value);
context.beginPath();
context.moveTo(x, 0);
context.lineTo(x, height);
context.stroke();
const labelY =
viewport.yMin <= 0 && viewport.yMax >= 0
? Math.min(height - 18, Math.max(4, toY(0) + 6))
: height - 18;
context.fillText(tickLabel(value, xStep), x, labelY);
}
context.textAlign = "left";
context.textBaseline = "middle";
for (
let value = Math.ceil(viewport.yMin / yStep) * yStep;
value <= viewport.yMax + yStep * 0.001;
value += yStep
) {
const y = toY(value);
context.beginPath();
context.moveTo(0, y);
context.lineTo(width, y);
context.stroke();
const labelX =
viewport.xMin <= 0 && viewport.xMax >= 0
? Math.min(width - 50, Math.max(6, toX(0) + 7))
: 7;
context.fillText(tickLabel(value, yStep), labelX, y);
}
}
function drawAxes(context, viewport, width, height, toX, toY) {
context.strokeStyle = "#263b30";
context.fillStyle = "#263b30";
context.lineWidth = 2;
context.beginPath();
if (viewport.yMin <= 0 && viewport.yMax >= 0) {
const y = toY(0);
context.moveTo(0, y);
context.lineTo(width, y);
context.moveTo(width - 9, y - 5);
context.lineTo(width, y);
context.lineTo(width - 9, y + 5);
}
if (viewport.xMin <= 0 && viewport.xMax >= 0) {
const x = toX(0);
context.moveTo(x, height);
context.lineTo(x, 0);
context.moveTo(x - 5, 9);
context.lineTo(x, 0);
context.lineTo(x + 5, 9);
}
context.stroke();
context.font = "700 12px system-ui, sans-serif";
context.fillText("x", width - 16, Math.min(height - 16, Math.max(14, toY(0) - 15)));
context.fillText("y", Math.min(width - 18, Math.max(10, toX(0) + 10)), 14);
}
function drawPath(context, samples, color, viewport, toX, toY) {
const ySpan = viewport.yMax - viewport.yMin;
context.strokeStyle = color;
context.lineWidth = 2.6;
context.lineJoin = "round";
context.beginPath();
let drawing = false;
let previous;
samples.forEach((item) => {
const discontinuity =
previous &&
Number.isFinite(previous.y) &&
Number.isFinite(item.y) &&
Math.abs(item.y - previous.y) > ySpan * 3;
if (
!Number.isFinite(item.y) ||
item.y < viewport.yMin - ySpan ||
item.y > viewport.yMax + ySpan ||
discontinuity
) {
drawing = false;
} else if (!drawing) {
context.moveTo(toX(item.x), toY(item.y));
drawing = true;
} else {
context.lineTo(toX(item.x), toY(item.y));
}
previous = item;
});
context.stroke();
}
function analyze(samples, xSpan) {
const roots = [];
let extrema = 0;
for (let index = 1; index < samples.length; index += 1) {
const before = samples[index - 1];
const current = samples[index];
if (!Number.isFinite(before.y) || !Number.isFinite(current.y)) continue;
if (before.y === 0 || before.y * current.y < 0) {
const root = (before.x + current.x) / 2;
if (!roots.length || Math.abs(root - roots.at(-1)) > xSpan / 300) {
roots.push(root);
}
}
if (index > 1) {
const older = samples[index - 2];
if (!Number.isFinite(older.y)) continue;
if ((before.y - older.y) * (current.y - before.y) < 0) extrema += 1;
}
}
return { roots: roots.slice(0, 8), extrema };
}
function renderLegend(functions) {
$graph("#graph-legend").replaceChildren(
...functions.map((item) => {
const badge = document.createElement("span");
const dot = document.createElement("i");
dot.style.background = item.color;
badge.append(dot, document.createTextNode(item.expression));
return badge;
}),
);
}
function draw() {
const canvas = $graph("#graph-canvas");
if (!canvas) return;
const functions = activeFunctions();
const { context, width, height } = canvasSize();
$graph("#graph-error").textContent = "";
$graph("#graph-parameter-label").textContent =
Number($graph("#graph-parameter").value).toFixed(1);
try {
let viewport = readViewport();
if ($graph("#graph-auto-y").checked && functions.length) {
viewport = autoY(functions, viewport);
$graph("#graph-y-min").value = Number(viewport.yMin.toPrecision(7));
$graph("#graph-y-max").value = Number(viewport.yMax.toPrecision(7));
}
graph.viewport = viewport;
const toX = (x) =>
((x - viewport.xMin) / (viewport.xMax - viewport.xMin)) * width;
const toY = (y) =>
height - ((y - viewport.yMin) / (viewport.yMax - viewport.yMin)) * height;
drawGrid(context, viewport, width, height, toX, toY);
drawAxes(context, viewport, width, height, toX, toY);
if (!functions.length) {
$graph("#graph-error").textContent = "请至少输入并启用一个函数";
renderLegend([]);
return;
}
const count = Math.max(500, Math.min(1600, Math.round(width * 1.5)));
const sampled = functions.map((item) => ({
item,
samples: sampleFunction(item, viewport, count),
}));
const first = sampled[0].samples;
if ($graph("#graph-integral").checked) {
context.fillStyle = `${functions[0].color}22`;
context.beginPath();
context.moveTo(toX(viewport.xMin), toY(0));
first.forEach((point) => {
if (Number.isFinite(point.y)) context.lineTo(toX(point.x), toY(point.y));
});
context.lineTo(toX(viewport.xMax), toY(0));
context.closePath();
context.fill();
}
sampled.forEach(({ item, samples }) => {
drawPath(context, samples, item.color, viewport, toX, toY);
});
if ($graph("#graph-derivative").checked) {
const derivative = first.slice(1, -1).map((point, index) => {
const before = first[index];
const after = first[index + 2];
return { x: point.x, y: (after.y - before.y) / (after.x - before.x) };
});
context.setLineDash([8, 6]);
drawPath(context, derivative, "#111827", viewport, toX, toY);
context.setLineDash([]);
}
const analysis = analyze(first, viewport.xMax - viewport.xMin);
const rootText = analysis.roots.length
? analysis.roots.map((item) => item.toPrecision(4)).join("、")
: "当前视窗未发现";
$graph("#graph-analysis").textContent =
`第一条曲线:近似零点 ${rootText};检测到 ${analysis.extrema} 个极值转折。`;
renderLegend(functions);
} catch (error) {
context.clearRect(0, 0, width, height);
$graph("#graph-error").textContent = error.message;
}
}
function setViewport(viewport, disableAutoY = true) {
graph.viewport = viewport;
if (disableAutoY) $graph("#graph-auto-y").checked = false;
writeViewport(viewport);
schedule();
}
function resetViewport() {
$graph("#graph-auto-y").checked = true;
setViewport({ xMin: -10, xMax: 10, yMin: -6, yMax: 6 }, false);
}
function autoFit() {
$graph("#graph-auto-y").checked = true;
schedule();
}
function bindCanvasNavigation() {
const canvas = $graph("#graph-canvas");
canvas.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
graph.dragging = {
x: event.clientX,
y: event.clientY,
viewport: { ...graph.viewport },
};
canvas.setPointerCapture(event.pointerId);
canvas.classList.add("dragging");
});
canvas.addEventListener("pointermove", (event) => {
if (!graph.dragging) return;
const rect = canvas.getBoundingClientRect();
const start = graph.dragging;
const dx =
((event.clientX - start.x) / rect.width) *
(start.viewport.xMax - start.viewport.xMin);
const dy =
((event.clientY - start.y) / rect.height) *
(start.viewport.yMax - start.viewport.yMin);
setViewport({
xMin: start.viewport.xMin - dx,
xMax: start.viewport.xMax - dx,
yMin: start.viewport.yMin + dy,
yMax: start.viewport.yMax + dy,
});
});
const stopDragging = () => {
graph.dragging = null;
canvas.classList.remove("dragging");
};
canvas.addEventListener("pointerup", stopDragging);
canvas.addEventListener("pointercancel", stopDragging);
canvas.addEventListener(
"wheel",
(event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const viewport = graph.viewport;
const xRatio = (event.clientX - rect.left) / rect.width;
const yRatio = 1 - (event.clientY - rect.top) / rect.height;
const centerX = viewport.xMin + xRatio * (viewport.xMax - viewport.xMin);
const centerY = viewport.yMin + yRatio * (viewport.yMax - viewport.yMin);
const factor = event.deltaY > 0 ? 1.14 : 0.88;
setViewport({
xMin: centerX + (viewport.xMin - centerX) * factor,
xMax: centerX + (viewport.xMax - centerX) * factor,
yMin: centerY + (viewport.yMin - centerY) * factor,
yMax: centerY + (viewport.yMax - centerY) * factor,
});
},
{ passive: false },
);
canvas.addEventListener("dblclick", autoFit);
}
function init() {
if (graph.initialized) return;
graph.initialized = true;
addFunction("sin(x) + a*x/3", COLORS[0]);
addFunction("0.08*x^2 - 2", COLORS[1]);
$graph("#graph-add-function").addEventListener("click", () => addFunction());
$graph("#graph-run").addEventListener("click", draw);
$graph("#graph-auto-fit").addEventListener("click", autoFit);
$graph("#graph-reset-view").addEventListener("click", resetViewport);
[
"#graph-parameter",
"#graph-x-min",
"#graph-x-max",
"#graph-auto-y",
"#graph-grid",
"#graph-derivative",
"#graph-integral",
].forEach((selector) => {
$graph(selector).addEventListener("input", schedule);
});
["#graph-y-min", "#graph-y-max"].forEach((selector) => {
$graph(selector).addEventListener("input", () => {
$graph("#graph-auto-y").checked = false;
schedule();
});
});
bindCanvasNavigation();
if ("ResizeObserver" in window) {
graph.observer = new ResizeObserver(schedule);
graph.observer.observe($graph(".graph-stage"));
} else {
window.addEventListener("resize", schedule);
}
schedule();
}
window.HuluGraph = { init, draw, resize: schedule, addFunction };
})();
+123 -192
View File
@@ -5,25 +5,107 @@
const CALC_FIELDS = {
derivative: ["order"],
integral: ["bounds"],
limit: ["point"],
limit: ["point", "direction"],
series: ["point", "order"],
solve_system: ["variables"],
gradient: ["variables"],
hessian: ["variables"],
base: ["base"],
unit: ["unit"],
};
function formatCalculatorResult(result) {
if (Array.isArray(result)) {
return {
exact: result.map((item) => item.exact).join(", "),
decimal: result.map((item) => item.decimal).join(", "),
latex: result.map((item) => item.latex).join(", "),
};
const CALC_OPERATION_META = {
statistics: {
placeholder: "12, 15, 18, 21, 24",
hint: "使用逗号分隔数据,返回四分位数、方差、标准差、极差等统计量。",
},
base: {
placeholder: "FF",
hint: "输入不带前缀的整数,原进制与目标进制均支持 2 到 36。",
},
solve: {
placeholder: "x^2 - 5*x + 6 = 0",
hint: "可省略等号右侧;通过“变量”指定要求解的未知量。",
},
solve_system: {
placeholder: "x + y = 5; x - y = 1",
hint: "使用分号分隔方程,通过“变量列表”指定未知量,最多 4 个变量、6 个方程。",
},
polynomial_roots: {
placeholder: "x^5 - x + 1 = 0",
hint: "返回至多 12 次单变量多项式的全部高精度数值根,包括复根。",
},
series: {
placeholder: "exp(x) * cos(x)",
hint: "在指定点附近展开;“阶数 6”表示保留到 5 阶并显示余项。",
order: 6,
},
gradient: {
placeholder: "x^2*y + sin(y)",
hint: "变量列表示例:x,y。结果按该顺序组成梯度列向量。",
},
hessian: {
placeholder: "x^2 + x*y + y^2",
hint: "变量列表示例:x,y。结果为二阶偏导组成的 Hessian 矩阵。",
},
matrix_det: { placeholder: "1,2;3,4", hint: "逗号分列、分号分行,最多 36 个元素。" },
matrix_inverse: { placeholder: "1,2;3,4", hint: "逆矩阵要求方阵且行列式非零。" },
matrix_rref: { placeholder: "1,2,3;2,4,6", hint: "返回矩阵的行最简形。" },
matrix_transpose: { placeholder: "1,2,3;4,5,6", hint: "交换矩阵的行与列。" },
matrix_rank: { placeholder: "1,2,3;2,4,6", hint: "通过精确行变换计算矩阵秩。" },
matrix_nullspace: { placeholder: "1,2;2,4", hint: "返回齐次方程组对应零空间的一组基。" },
matrix_eigenvalues: { placeholder: "2,1;1,2", hint: "要求方阵,返回特征值及其代数重数。" },
};
function calculatorCategoryFor(operation) {
const option = [...$tool("#calc-operation").options].find(
(item) => item.value === operation,
);
return option?.parentElement?.dataset.calcCategoryOptions || "algebra";
}
function setCalculatorCategory(category, selectFirst = true) {
const select = $tool("#calc-operation");
$$tool("[data-calc-category-options]").forEach((group) => {
const active = group.dataset.calcCategoryOptions === category;
group.hidden = !active;
group.disabled = !active;
});
$$tool("[data-calc-category]").forEach((button) => {
button.classList.toggle(
"active",
button.dataset.calcCategory === category,
);
});
if (selectFirst) {
const group = $tool(
`[data-calc-category-options="${category}"]`,
);
if (group?.querySelector("option")) {
select.value = group.querySelector("option").value;
}
}
if (result && typeof result === "object" && "exact" in result) return result;
const entries = Object.entries(result || {});
updateCalculatorFields();
}
function resultText(value, field = "exact") {
if (Array.isArray(value)) {
return value.map((item) => resultText(item, field)).join("; ");
}
if (value && typeof value === "object") {
if ("exact" in value) return value[field] || value.exact;
return Object.entries(value)
.map(([key, item]) => `${key}: ${resultText(item, field)}`)
.join("; ");
}
return String(value ?? "");
}
function formatCalculatorResult(result) {
return {
exact: entries.map(([key, value]) => `${key}: ${value}`).join(" · "),
decimal: entries.map(([key, value]) => `${key}: ${value}`).join(" · "),
latex: entries.map(([key, value]) => `${key}=${value}`).join(", "),
exact: resultText(result, "exact"),
decimal: resultText(result, "decimal"),
latex: resultText(result, "latex"),
};
}
@@ -33,17 +115,13 @@
$$tool("[data-calc-field]").forEach((field) => {
field.hidden = !visible.has(field.dataset.calcField);
});
const input = $tool("#calc-input");
const placeholders = {
statistics: "12, 15, 18, 21, 24",
base: "FF",
matrix_det: "1,2;3,4",
matrix_inverse: "1,2;3,4",
matrix_rref: "1,2,3;2,4,6",
matrix_transpose: "1,2,3;4,5,6",
solve: "x^2 - 5*x + 6 = 0",
};
input.placeholder = placeholders[operation] || "例如:sqrt(2) + 1/3";
const meta = CALC_OPERATION_META[operation] || {};
$tool("#calc-input").placeholder = meta.placeholder || "例如:sqrt(2) + 1/3";
$tool("#calc-hint").textContent =
meta.hint || "支持精确常量、受限数学函数和变量;按 Command/Ctrl + Enter 运行。";
$tool("#calc-order").max = operation === "derivative" ? 5 : 12;
if (meta.order) $tool("#calc-order").value = meta.order;
else if (operation === "derivative") $tool("#calc-order").value = 1;
}
async function runCalculator() {
@@ -59,10 +137,12 @@
operation,
expression,
variable: $tool("#calc-variable").value,
variables: $tool("#calc-variables").value,
order: $tool("#calc-order").value,
lower: $tool("#calc-lower").value,
upper: $tool("#calc-upper").value,
point: $tool("#calc-point").value,
direction: $tool("#calc-direction").value,
from_base: $tool("#calc-from-base").value,
to_base: $tool("#calc-to-base").value,
from_unit: $tool("#calc-from-unit").value,
@@ -92,163 +172,6 @@
}
}
const GRAPH_COLORS = ["#196548", "#d86f45", "#5d73e8", "#8667b5"];
function drawFunctionPath(context, expression, variables, range, width, height, color) {
const toY = (value) => height / 2 - (value / range) * (height / 2);
context.strokeStyle = color;
context.lineWidth = 3;
context.beginPath();
let drawing = false;
const samples = [];
for (let pixel = 0; pixel <= width; pixel += 2) {
const x = (pixel / width) * range * 2 - range;
let y;
try {
y = evaluateExpression(expression, { ...variables, x });
} catch (error) {
if (pixel === 0) throw error;
drawing = false;
continue;
}
samples.push({ x, y });
const screenY = toY(y);
if (!Number.isFinite(screenY) || screenY < -height * 2 || screenY > height * 3) {
drawing = false;
continue;
}
if (!drawing) context.moveTo(pixel, screenY);
else context.lineTo(pixel, screenY);
drawing = true;
}
context.stroke();
return samples;
}
function graphAnalysis(samples) {
const roots = [];
let extrema = 0;
for (let index = 1; index < samples.length; index += 1) {
const before = samples[index - 1];
const current = samples[index];
if (before.y === 0 || before.y * current.y < 0) {
const root = (before.x + current.x) / 2;
if (!roots.length || Math.abs(root - roots.at(-1)) > 0.15) roots.push(root);
}
if (index > 1) {
const previousSlope = before.y - samples[index - 2].y;
const currentSlope = current.y - before.y;
if (previousSlope * currentSlope < 0) extrema += 1;
}
}
return { roots: roots.slice(0, 8), extrema };
}
function drawGraph() {
const canvas = $tool("#graph-canvas");
const context = canvas.getContext("2d");
const expressions = $tool("#graph-expression").value
.split("\n")
.map((item) => item.trim())
.filter(Boolean)
.slice(0, 4);
const range = Number($tool("#graph-range").value);
const parameter = Number($tool("#graph-parameter").value);
const width = canvas.width;
const height = canvas.height;
$tool("#graph-range-label").textContent = `−${range} 到 ${range}`;
$tool("#graph-parameter-label").textContent = `a = ${parameter}`;
$tool("#graph-error").textContent = "";
context.clearRect(0, 0, width, height);
context.fillStyle = "#fbfbf7";
context.fillRect(0, 0, width, height);
const toX = (x) => ((x + range) / (range * 2)) * width;
const toY = (y) => height / 2 - (y / range) * (height / 2);
context.strokeStyle = "#e4e5df";
context.lineWidth = 1;
for (let value = -range; value <= range; value += 1) {
context.beginPath();
context.moveTo(toX(value), 0);
context.lineTo(toX(value), height);
context.moveTo(0, toY(value));
context.lineTo(width, toY(value));
context.stroke();
}
context.strokeStyle = "#718078";
context.lineWidth = 2;
context.beginPath();
context.moveTo(0, height / 2);
context.lineTo(width, height / 2);
context.moveTo(width / 2, 0);
context.lineTo(width / 2, height);
context.stroke();
if (!expressions.length) {
$tool("#graph-error").textContent = "请至少输入一个函数";
return;
}
try {
const firstSamples = drawFunctionPath(
context,
expressions[0],
{ a: parameter },
range,
width,
height,
GRAPH_COLORS[0],
);
expressions.slice(1).forEach((expression, index) => {
drawFunctionPath(
context,
expression,
{ a: parameter },
range,
width,
height,
GRAPH_COLORS[index + 1],
);
});
if ($tool("#graph-integral").checked) {
context.fillStyle = "rgba(25, 101, 72, .13)";
context.beginPath();
context.moveTo(0, height / 2);
firstSamples.forEach((item) => context.lineTo(toX(item.x), toY(item.y)));
context.lineTo(width, height / 2);
context.closePath();
context.fill();
}
if ($tool("#graph-derivative").checked) {
const derivative = firstSamples.slice(1, -1).map((item, index) => {
const before = firstSamples[index];
const after = firstSamples[index + 2];
return { x: item.x, y: (after.y - before.y) / (after.x - before.x) };
});
context.strokeStyle = "#111827";
context.lineWidth = 2;
context.setLineDash([9, 7]);
context.beginPath();
derivative.forEach((item, index) => {
const x = toX(item.x);
const y = toY(item.y);
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.stroke();
context.setLineDash([]);
}
const analysis = graphAnalysis(firstSamples);
const rootText = analysis.roots.length
? analysis.roots.map((item) => item.toFixed(2)).join("、")
: "当前范围未发现";
$tool("#graph-analysis").textContent =
`第一条曲线:近似零点 ${rootText};检测到 ${analysis.extrema} 个极值转折。`;
} catch (error) {
$tool("#graph-error").textContent = error.message;
}
}
function canvasPoint(canvas, event) {
const rect = canvas.getBoundingClientRect();
return {
@@ -930,7 +853,17 @@
}
function init() {
$tool("#calc-operation").addEventListener("change", updateCalculatorFields);
$tool("#calc-operation").addEventListener("change", () => {
setCalculatorCategory(
calculatorCategoryFor($tool("#calc-operation").value),
false,
);
});
$$tool("[data-calc-category]").forEach((button) => {
button.addEventListener("click", () => {
setCalculatorCategory(button.dataset.calcCategory);
});
});
$tool("#calc-run").addEventListener("click", runCalculator);
$tool("#calc-input").addEventListener("keydown", (event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") runCalculator();
@@ -939,24 +872,22 @@
button.addEventListener("click", () => {
$tool("#calc-operation").value = button.dataset.calcOperation;
$tool("#calc-input").value = button.dataset.calcExample;
updateCalculatorFields();
setCalculatorCategory(
calculatorCategoryFor(button.dataset.calcOperation),
false,
);
runCalculator();
});
});
["#graph-run", "#graph-range", "#graph-parameter", "#graph-derivative", "#graph-integral"]
.forEach((selector) => $tool(selector).addEventListener("input", drawGraph));
$tool("#graph-expression").addEventListener("keydown", (event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") drawGraph();
});
initDrawingTools();
updateCalculatorFields();
drawGraph();
setCalculatorCategory("algebra", false);
window.HuluGraph?.init();
}
function activate(tool) {
if (tool === "graph") window.setTimeout(drawGraph, 30);
if (tool === "graph") window.setTimeout(() => window.HuluGraph?.resize(), 30);
if (tool === "whiteboard") window.setTimeout(() => geometry.draw(), 30);
}
window.HuluToolbox = { init, activate, runCalculator, drawGraph };
window.HuluToolbox = { init, activate, runCalculator };
})();
+106 -43
View File
@@ -214,46 +214,84 @@
</section>
<section class="view" id="view-toolbox">
<div class="page-title"><span class="kicker">MATHEMATICAL WORKBENCH</span><h1>工具箱</h1><p>计算、查询、绘图与表达,把想法直接变成可以继续工作的对象。</p></div>
<div class="tool-grid" id="tool-grid">
<button class="tool-card" data-tool="mental"><span>⚡</span><b>口算竞技</b><small>限时挑战你的心算速度</small></button>
<button class="tool-card active" data-tool="calculator"><span>123</span><b>数学计算工作台</b><small>代数、微积分、矩阵与统计</small></button>
<button class="tool-card" data-tool="symbols"><span>Σ</span><b>数学符号查询</b><small>含义、读法与 LaTeX 写法</small></button>
<button class="tool-card" data-tool="graph"><span>⌁</span><b>函数图形绘制</b><small>多函数、参数、导数与分析</small></button>
<button class="tool-card" data-tool="whiteboard"><span>✎△</span><b>数学画板</b><small>白板、几何、联机展示与你画我猜</small></button>
<button class="tool-card" data-tool="latex"><span>TeX</span><b>LaTeX Lab</b><small>编辑、预览、课程与公式库</small></button>
<div class="page-title"><span class="kicker">MATHEMATICAL WORKBENCH</span><h1>工具箱</h1><p>从符号计算、函数探索到数学表达与协作创作,按任务组织完整工作流。</p></div>
<div class="tool-groups" id="tool-grid">
<section class="tool-group">
<header><span>01</span><div><b>计算与探索</b><small>从表达式到结构与图像</small></div></header>
<div class="tool-grid">
<button class="tool-card active" data-tool="calculator"><span>CAS</span><b>高级计算工作台</b><small>代数、微积分、方程组、矩阵与数据分析</small></button>
<button class="tool-card" data-tool="graph"><span>ƒ(x)</span><b>函数探索器</b><small>响应式曲线、独立视窗、平移缩放与分析</small></button>
</div>
</section>
<section class="tool-group">
<header><span>02</span><div><b>知识与表达</b><small>查询、排版与规范表达</small></div></header>
<div class="tool-grid">
<button class="tool-card" data-tool="symbols"><span>Σ</span><b>数学符号查询</b><small>含义、读法与 LaTeX 写法</small></button>
<button class="tool-card" data-tool="latex"><span>TeX</span><b>LaTeX Lab</b><small>编辑、实时预览与版本保存</small></button>
</div>
</section>
<section class="tool-group">
<header><span>03</span><div><b>创作与协作</b><small>把思路画出来并实时分享</small></div></header>
<div class="tool-grid">
<button class="tool-card" data-tool="whiteboard"><span>✎△</span><b>数学画板</b><small>白板、几何、联机展示与你画我猜</small></button>
</div>
</section>
</div>
<div class="tool-workspace active" id="tool-calculator">
<div class="workspace-heading"><div><span class="kicker">COMPUTATION STUDIO</span><h2>数学计算工作台</h2></div><p>精确值、方程、微积分、矩阵、统计与进制转换由受限数学内核计算。</p></div>
<div class="workspace-heading"><div><span class="kicker">SYMBOLIC COMPUTATION STUDIO</span><h2>高级计算工作台</h2></div><p>受限 SymPy 内核提供精确符号计算、多元分析、线性代数和数据换算。</p></div>
<div class="calculator-shell advanced-calculator">
<div class="calc-category-tabs" aria-label="计算分类">
<button class="active" data-calc-category="algebra">代数与方程</button>
<button data-calc-category="calculus">微积分与多元分析</button>
<button data-calc-category="linear">线性代数</button>
<button data-calc-category="data">数据与换算</button>
</div>
<div class="calculator-controls">
<label>计算类型
<select id="calc-operation">
<option value="calculate">精确计算</option>
<option value="simplify">表达式化简</option>
<option value="expand">代数展开</option>
<option value="factor">因式分解</option>
<option value="solve">解方程</option>
<option value="derivative">求导</option>
<option value="integral">积分</option>
<option value="limit">极限</option>
<option value="matrix_det">矩阵行列式</option>
<option value="matrix_inverse">逆矩阵</option>
<option value="matrix_rref">矩阵行最简形</option>
<option value="matrix_transpose">矩阵转置</option>
<option value="statistics">描述统计</option>
<option value="base">进制转换</option>
<option value="unit">单位换算</option>
<optgroup label="代数与方程" data-calc-category-options="algebra">
<option value="calculate">精确计算</option>
<option value="simplify">表达式化简</option>
<option value="expand">代数展开</option>
<option value="factor">因式分解</option>
<option value="solve">符号解方程</option>
<option value="solve_system">解多元方程组</option>
<option value="polynomial_roots">多项式全部数值根</option>
</optgroup>
<optgroup label="微积分与多元分析" data-calc-category-options="calculus">
<option value="derivative">高阶导数</option>
<option value="integral">定积分 / 不定积分</option>
<option value="limit">单侧 / 双侧极限</option>
<option value="series">Taylor / Laurent 级数</option>
<option value="gradient">梯度</option>
<option value="hessian">Hessian 矩阵</option>
</optgroup>
<optgroup label="线性代数" data-calc-category-options="linear">
<option value="matrix_det">矩阵行列式</option>
<option value="matrix_inverse">逆矩阵</option>
<option value="matrix_rref">矩阵行最简形</option>
<option value="matrix_transpose">矩阵转置</option>
<option value="matrix_rank">矩阵秩</option>
<option value="matrix_nullspace">零空间基</option>
<option value="matrix_eigenvalues">特征值与重数</option>
</optgroup>
<optgroup label="数据与换算" data-calc-category-options="data">
<option value="statistics">增强描述统计</option>
<option value="base">进制转换</option>
<option value="unit">单位换算</option>
</optgroup>
</select>
</label>
<label>变量
<select id="calc-variable"><option>x</option><option>y</option><option>z</option><option>a</option><option>b</option><option>t</option><option>n</option></select>
</label>
<label class="calc-field" data-calc-field="order">阶数<input id="calc-order" type="number" min="1" max="5" value="1"></label>
<label class="calc-field" data-calc-field="variables">变量列表<input id="calc-variables" value="x,y" placeholder="例如 x,y"></label>
<label class="calc-field" data-calc-field="order">阶数<input id="calc-order" type="number" min="1" max="12" value="1"></label>
<label class="calc-field" data-calc-field="bounds">下限<input id="calc-lower" placeholder="可留空"></label>
<label class="calc-field" data-calc-field="bounds">上限<input id="calc-upper" placeholder="可留空"></label>
<label class="calc-field" data-calc-field="point">趋近值<input id="calc-point" value="0"></label>
<label class="calc-field" data-calc-field="direction">方向<select id="calc-direction"><option value="+-">双侧</option><option value="+">右极限</option><option value="-">左极限</option></select></label>
<label class="calc-field" data-calc-field="base">原进制<input id="calc-from-base" type="number" min="2" max="36" value="10"></label>
<label class="calc-field" data-calc-field="base">目标进制<input id="calc-to-base" type="number" min="2" max="36" value="2"></label>
<label class="calc-field" data-calc-field="unit">原单位<select id="calc-from-unit"><option>mm</option><option>cm</option><option selected>m</option><option>km</option><option>in</option><option>ft</option><option>g</option><option>kg</option><option>lb</option><option>s</option><option>min</option><option>h</option><option>deg</option><option>rad</option></select></label>
@@ -262,13 +300,13 @@
<label class="calculator-expression-label">表达式或数据
<textarea id="calc-input" class="formula-input" autocomplete="off" aria-label="计算表达式">sqrt(2) + 1/3</textarea>
</label>
<p id="calc-hint" class="calc-hint">支持精确常量、函数与变量;按 Command/Ctrl + Enter 运行。</p>
<div class="calculator-actions calc-examples">
<button data-calc-operation="solve" data-calc-example="x^2 - 5*x + 6 = 0">解方程</button>
<button data-calc-operation="derivative" data-calc-example="sin(x) + x^3">求导</button>
<button data-calc-operation="integral" data-calc-example="x^2">积分</button>
<button data-calc-operation="matrix_inverse" data-calc-example="1,2;3,4">逆矩阵</button>
<button data-calc-operation="statistics" data-calc-example="12,15,18,21,24">统计</button>
<button data-calc-operation="unit" data-calc-example="1.75">单位换算</button>
<button data-calc-operation="solve_system" data-calc-example="x + y = 5; x - y = 1">方程组</button>
<button data-calc-operation="series" data-calc-example="exp(x)*cos(x)">级数展开</button>
<button data-calc-operation="hessian" data-calc-example="x^2 + x*y + y^2">Hessian</button>
<button data-calc-operation="matrix_eigenvalues" data-calc-example="2,1;1,2">特征值</button>
<button data-calc-operation="statistics" data-calc-example="12,15,18,21,24">增强统计</button>
<button id="calc-run" class="primary-button">开始计算</button>
</div>
<div class="calculator-result" aria-live="polite">
@@ -288,23 +326,47 @@
</div>
<div class="tool-workspace" id="tool-graph">
<div class="workspace-heading"><div><span class="kicker">FUNCTION PLOTTER</span><h2>函数图形绘制</h2></div><p>每行一个函数;支持参数 a、数值导数、积分区域和曲线分析。</p></div>
<div class="graph-shell">
<div class="graph-controls">
<div class="workspace-heading"><div><span class="kicker">INTERACTIVE FUNCTION EXPLORER</span><h2>函数探索器</h2></div><p>每条曲线独立编辑;画布随容器缩放,支持平移、滚轮缩放、自动取景和清晰刻度。</p></div>
<div class="graph-shell advanced-graph-shell">
<aside class="graph-controls">
<details class="function-syntax-help" open>
<summary>函数书写规则</summary>
<p>每行一个表达式,直接写右侧:<code>sin(x)</code>、<code>x^2 - 2*x + 1</code>、<code>sqrt(abs(x))</code>。乘法必须写 <code>*</code>,幂可写 <code>^</code>,参数仅支持 <code>a</code>。</p>
<p>直接写右侧:<code>sin(x)</code>、<code>x^2 - 2*x + 1</code>、<code>sqrt(abs(x))</code>。乘法写 <code>*</code>,幂写 <code>^</code>,可使用参数 <code>a</code>。</p>
</details>
<label>函数列表<textarea id="graph-expression" class="formula-input">sin(x) + a*x/3&#10;0.08*x^2 - 2</textarea></label>
<label>X 范围<input id="graph-range" type="range" min="5" max="30" value="10"><span id="graph-range-label">−10 到 10</span></label>
<label>参数 a<input id="graph-parameter" type="range" min="-5" max="5" step="0.1" value="1"><span id="graph-parameter-label">a = 1</span></label>
<label class="check-row"><input id="graph-derivative" type="checkbox"> 绘制第一条函数的导数</label>
<label class="check-row"><input id="graph-integral" type="checkbox"> 填充第一条函数与 x 轴区域</label>
<button id="graph-run" class="primary-button">绘制函数</button>
<div class="graph-functions-heading"><b>函数列表</b><button id="graph-add-function" type="button">+ 添加曲线</button></div>
<div id="graph-function-list" class="graph-function-list"></div>
<div class="graph-parameter-control">
<label>参数 a <output id="graph-parameter-label">1.0</output></label>
<input id="graph-parameter" type="range" min="-10" max="10" step="0.1" value="1">
</div>
<details class="graph-viewport-controls" open>
<summary>坐标视窗</summary>
<div class="graph-range-grid">
<label>X 最小<input id="graph-x-min" type="number" step="any" value="-10"></label>
<label>X 最大<input id="graph-x-max" type="number" step="any" value="10"></label>
<label>Y 最小<input id="graph-y-min" type="number" step="any" value="-6"></label>
<label>Y 最大<input id="graph-y-max" type="number" step="any" value="6"></label>
</div>
<label class="check-row"><input id="graph-auto-y" type="checkbox" checked> 根据可见曲线自动计算 Y 范围</label>
</details>
<div class="graph-option-grid">
<label class="check-row"><input id="graph-grid" type="checkbox" checked> 网格与刻度</label>
<label class="check-row"><input id="graph-derivative" type="checkbox"> 第一条曲线导数</label>
<label class="check-row"><input id="graph-integral" type="checkbox"> 第一条曲线积分区域</label>
</div>
<div class="graph-actions">
<button id="graph-auto-fit" type="button">自动取景</button>
<button id="graph-reset-view" type="button">重置视窗</button>
<button id="graph-run" class="primary-button">重新绘制</button>
</div>
<p id="graph-error"></p>
</aside>
<section class="graph-stage">
<div id="graph-legend" class="graph-legend"></div>
<canvas id="graph-canvas" aria-label="可交互函数坐标图"></canvas>
<div class="graph-stage-hint">拖动画布平移 · 滚轮或触控板缩放 · 双击自动取景</div>
<div id="graph-analysis" class="graph-analysis"></div>
</div>
<canvas id="graph-canvas" width="900" height="480" aria-label="函数曲线图"></canvas>
</section>
</div>
</div>
@@ -478,6 +540,7 @@
<div class="toast" id="toast" role="status"></div>
<div class="csrf-token">{% csrf_token %}</div>
<script src="{% static 'vendor/katex/katex.min.js' %}" defer></script>
<script src="{% static 'js/graph.js' %}" defer></script>
<script src="{% static 'js/toolbox.js' %}" defer></script>
<script src="{% static 'js/games.js' %}" defer></script>
<script src="{% static 'js/realtime.js' %}" defer></script>
+136 -3
View File
@@ -1,13 +1,17 @@
import ast
import math
from statistics import mean, median, pstdev, pvariance
from statistics import mean, median, pstdev, pvariance, quantiles
import sympy as sp
from mpmath.libmp.libhyper import NoConvergence
from rest_framework.exceptions import ValidationError
MAX_EXPRESSION_LENGTH = 500
MAX_AST_NODES = 120
MAX_MATRIX_CELLS = 36
MAX_SERIES_ORDER = 12
MAX_SYSTEM_EQUATIONS = 6
MAX_POLYNOMIAL_DEGREE = 12
SYMBOLS = {name: sp.Symbol(name, real=True) for name in ("x", "y", "z", "a", "b", "t", "n")}
CONSTANTS = {"pi": sp.pi, "e": sp.E, "E": sp.E, "i": sp.I, "I": sp.I}
FUNCTIONS = {
@@ -188,6 +192,31 @@ def parse_number_list(source):
return values
def parse_variables(raw_variables, fallback="x"):
names = [
item.strip()
for item in str(raw_variables or fallback).split(",")
if item.strip()
]
if not names or len(names) > 4 or len(set(names)) != len(names):
raise ValidationError({"variables": "变量应为 1 到 4 个不重复名称"})
try:
return [SYMBOLS[name] for name in names]
except KeyError as exc:
raise ValidationError(
{"variables": "变量仅支持 x、y、z、a、b、t、n"}
) from exc
def parse_equation_system(source):
parts = [item.strip() for item in str(source or "").split(";") if item.strip()]
if not 1 <= len(parts) <= MAX_SYSTEM_EQUATIONS:
raise ValidationError(
{"expression": "方程组应使用分号分隔,最多支持 6 个方程"}
)
return [parse_equation(item) for item in parts]
def calculate(payload):
operation = str(payload.get("operation", "calculate"))
source = payload.get("expression", "")
@@ -198,14 +227,23 @@ def calculate(payload):
if operation == "statistics":
values = parse_number_list(source)
quartile_values = (
quantiles(values, n=4, method="inclusive")
if len(values) > 1
else [values[0], values[0], values[0]]
)
result = {
"count": len(values),
"sum": sum(values),
"mean": mean(values),
"median": median(values),
"variance": pvariance(values),
"standard_deviation": pstdev(values),
"minimum": min(values),
"q1": quartile_values[0],
"q3": quartile_values[2],
"maximum": max(values),
"range": max(values) - min(values),
}
return {
"operation": operation,
@@ -278,11 +316,43 @@ def calculate(payload):
elif operation == "matrix_transpose":
result = matrix.T
steps = ["读取矩阵", "交换行列"]
elif operation == "matrix_rank":
result = matrix.rank()
steps = ["读取矩阵", "执行行变换", "计算矩阵秩"]
elif operation == "matrix_nullspace":
result = matrix.nullspace()
steps = ["读取矩阵", "求解齐次线性方程组", "得到零空间基"]
elif operation == "matrix_eigenvalues":
if not matrix.is_square:
raise ValidationError({"expression": "特征值要求方阵"})
result = matrix.eigenvals()
steps = ["读取方阵", "构造特征多项式", "计算特征值及重数"]
else:
raise ValidationError({"operation": "不支持的矩阵操作"})
return {"operation": operation, "result": serialize_math(result), "steps": steps}
expression = parse_equation(source) if operation == "solve" else parse_expression(source)
if operation == "solve_system":
variables = parse_variables(payload.get("variables"), variable_name)
equations = parse_equation_system(source)
result = sp.solve(equations, variables, dict=True)
if len(result) > 50:
raise ValidationError({"expression": "方程组解的数量过多"})
return {
"operation": operation,
"result": serialize_math(result),
"steps": [
f"读取 {len(equations)} 个方程",
f"以 {', '.join(str(item) for item in variables)} 为未知量",
"联立消元并求解",
],
}
equation_operations = {"solve", "polynomial_roots"}
expression = (
parse_equation(source)
if operation in equation_operations
else parse_expression(source)
)
steps = ["解析受限数学表达式"]
if operation == "calculate":
result = sp.simplify(expression)
@@ -301,8 +371,39 @@ def calculate(payload):
if len(result) > 50:
raise ValidationError({"expression": "解的数量过多"})
steps.extend([f"以 {variable_name} 为未知量", "求解方程"])
elif operation == "polynomial_roots":
polynomial_expression = (
expression.lhs - expression.rhs
if isinstance(expression, sp.Equality)
else expression
)
if polynomial_expression.free_symbols - {variable}:
raise ValidationError({"expression": "数值求根仅支持所选变量"})
try:
polynomial = sp.Poly(polynomial_expression, variable)
except sp.PolynomialError as exc:
raise ValidationError({"expression": "请输入单变量多项式"}) from exc
if not 1 <= polynomial.degree() <= MAX_POLYNOMIAL_DEGREE:
raise ValidationError(
{"expression": "数值求根支持 1 到 12 次单变量多项式"}
)
try:
result = list(sp.nroots(polynomial, n=12, maxsteps=100))
except NoConvergence as exc:
raise ValidationError(
{"expression": "数值求根未收敛,请简化多项式后重试"}
) from exc
steps.extend(
[
f"构造 {polynomial.degree()} 次多项式",
"使用高精度数值方法计算全部根",
]
)
elif operation == "derivative":
order = int(payload.get("order", 1))
try:
order = int(payload.get("order", 1))
except (TypeError, ValueError) as exc:
raise ValidationError({"order": "导数阶数必须是整数"}) from exc
if not 1 <= order <= 5:
raise ValidationError({"order": "导数阶数必须在 1 到 5 之间"})
result = sp.diff(expression, variable, order)
@@ -328,6 +429,38 @@ def calculate(payload):
raise ValidationError({"direction": "极限方向必须为 +、- 或 +-"})
result = sp.limit(expression, variable, point, dir=direction)
steps.append(f"令 {variable_name} 趋近 {point}")
elif operation == "series":
point = parse_expression(payload.get("point", "0"))
try:
order = int(payload.get("order", 6))
except (TypeError, ValueError) as exc:
raise ValidationError({"order": "级数展开阶数必须是整数"}) from exc
if not 1 <= order <= MAX_SERIES_ORDER:
raise ValidationError(
{"order": f"级数展开阶数必须在 1 到 {MAX_SERIES_ORDER} 之间"}
)
result = sp.series(expression, variable, point, order)
steps.append(
f"在 {variable_name} = {point} 附近展开到 {order - 1} 阶"
)
elif operation == "gradient":
variables = parse_variables(payload.get("variables"), variable_name)
result = sp.Matrix([sp.diff(expression, item) for item in variables])
steps.extend(
[
f"选取变量 {', '.join(str(item) for item in variables)}",
"分别计算一阶偏导并组成梯度",
]
)
elif operation == "hessian":
variables = parse_variables(payload.get("variables"), variable_name)
result = sp.hessian(expression, variables)
steps.extend(
[
f"选取变量 {', '.join(str(item) for item in variables)}",
"计算全部二阶偏导并组成 Hessian 矩阵",
]
)
else:
raise ValidationError({"operation": "不支持的计算类型"})
return {"operation": operation, "result": serialize_math(result), "steps": steps}
+102
View File
@@ -49,6 +49,108 @@ def test_calculate_方程矩阵统计与进制():
assert combinations["result"]["exact"] == "120"
def test_calculate_高级微积分与多元分析():
series = calculate(
{
"operation": "series",
"expression": "exp(x)",
"variable": "x",
"point": "0",
"order": 5,
}
)
gradient = calculate(
{
"operation": "gradient",
"expression": "x^2*y + sin(y)",
"variables": "x,y",
}
)
hessian = calculate(
{
"operation": "hessian",
"expression": "x^2 + x*y + y^2",
"variables": "x,y",
}
)
roots = calculate(
{
"operation": "polynomial_roots",
"expression": "x^3 - 1 = 0",
"variable": "x",
}
)
assert "x**4/24" in series["result"]["exact"]
assert gradient["result"]["exact"] == "[[2*x*y], [x**2 + cos(y)]]"
assert hessian["result"]["exact"] == "[[2, 1], [1, 2]]"
assert len(roots["result"]) == 3
def test_calculate_线性代数与方程组():
system = calculate(
{
"operation": "solve_system",
"expression": "x + y = 5; x - y = 1",
"variables": "x,y",
}
)
rank = calculate(
{"operation": "matrix_rank", "expression": "1,2,3;2,4,6"}
)
nullspace = calculate(
{"operation": "matrix_nullspace", "expression": "1,2;2,4"}
)
eigenvalues = calculate(
{"operation": "matrix_eigenvalues", "expression": "2,0;0,3"}
)
assert system["result"][0]["x"]["exact"] == "3"
assert system["result"][0]["y"]["exact"] == "2"
assert rank["result"]["exact"] == "1"
assert nullspace["result"][0]["exact"] == "[[-2], [1]]"
assert set(eigenvalues["result"]) == {"2", "3"}
def test_calculate_统计包含四分位数与极差():
result = calculate(
{"operation": "statistics", "expression": "1,2,3,4,5"}
)["result"]
assert result["sum"] == 15
assert result["q1"] == 2
assert result["q3"] == 4
assert result["range"] == 4
@pytest.mark.parametrize(
("payload", "field"),
[
(
{
"operation": "series",
"expression": "exp(x)",
"order": "not-an-integer",
},
"order",
),
(
{
"operation": "polynomial_roots",
"expression": "x + y",
"variable": "x",
},
"expression",
),
],
)
def test_calculate_高级操作返回可读校验错误(payload, field):
with pytest.raises(ValidationError) as exc_info:
calculate(payload)
assert field in exc_info.value.detail
@pytest.mark.parametrize(
"source",
[
+31
View File
@@ -0,0 +1,31 @@
# Hulumath Context Boot
## Purpose
Hulumath is a public mathematics product built as a Django modular monolith.
It combines mathematical identity, stories, competitions, courses,
progression and constrained mathematical tools.
## Repository Map
```text
backend/ Django applications, APIs, templates and static assets
docs/ product, release and deployment documentation
scripts/ deployment and maintenance helpers
deploy/ production configuration templates
context/ bounded repository identity for Universal Context
```
## Read Order
```text
AGENTS.md
-> context/boot.md
-> context/capsule.json
-> README.md
-> relevant document under docs/
-> relevant Django application under backend/
```
Runtime credentials, databases, uploads and account data are never context
inputs. Current production claims require a fresh JOps observation.
+95
View File
@@ -0,0 +1,95 @@
{
"schema": 1,
"kind": "jcm.repository-capsule",
"repository": {
"id": "hulumath",
"name": "Hulumath",
"remote": "Jacky/Hulumath-Web",
"role": "public-mathematics-product",
"summary": "Owns the public Hulumath Django application, mathematical content, competitions, learning tools and product release evidence."
},
"domains": [
"platform",
"learning"
],
"context": {
"entrypoint": "AGENTS.md",
"read_order": [
"AGENTS.md",
"context/boot.md",
"context/capsule.json",
"context/manifest.json",
"README.md",
"docs/RELEASE_NOTES_V1.2.1.md",
"docs/DEPLOYMENT.md"
]
},
"ownership": {
"owns": [
"Hulumath product behavior and mathematical content",
"Django application, APIs and WebSocket contracts",
"Hulumath release and migration documentation"
],
"excludes": [
"JLearn accounts, sessions and learner data",
"Shared platform edge and runtime operations",
"Production secrets, databases and uploads"
]
},
"contracts": null,
"services": [
{
"id": "hulumath-web",
"name": "Hulumath",
"kind": "public-mathematics-application",
"summary": "Responsive mathematics product with identity, stories, contests, courses and tools.",
"public_urls": [
"https://math.jacky0987.xyz/"
],
"domains": [
"platform",
"learning"
]
}
],
"relationships": [
{
"target": "jlearn",
"type": "integrates",
"summary": "Receives a public link from JLearn but keeps independent accounts and data."
},
{
"target": "jops",
"type": "operates",
"summary": "Relies on JOps for shared edge and production runtime evidence."
}
],
"runtime": {
"authority": "none"
},
"current_state": {
"status": "released",
"summary": "The repository release baseline is v1.2.1; this source state does not claim current production health.",
"evidence": [
"README.md",
"docs/RELEASE_NOTES_V1.2.1.md",
"docs/DEPLOYMENT.md"
]
},
"facts": [
{
"id": "hulumath-platform-boundary",
"scope": "platform",
"summary": "Hulumath is an independently deployed product whose shared edge and runtime evidence belong to JOps.",
"authority": "canonical",
"evidence": "docs/DEPLOYMENT.md"
},
{
"id": "hulumath-learning-scope",
"scope": "learning",
"summary": "Hulumath combines mathematical identity, stories, competitions, courses, progression and constrained tools.",
"authority": "canonical",
"evidence": "README.md"
}
]
}
+35
View File
@@ -0,0 +1,35 @@
{
"schema_version": "2.0.0",
"bundle_type": "repo-local-ai",
"repository": {
"owner": "Jacky",
"name": "Hulumath-Web",
"role": "public-mathematics-product"
},
"state": "RELEASED",
"runtime_state": "UNVERIFIED",
"entrypoint": "AGENTS.md",
"read_order": [
"AGENTS.md",
"context/boot.md",
"context/capsule.json",
"context/manifest.json",
"README.md",
"docs/RELEASE_NOTES_V1.2.1.md",
"docs/DEPLOYMENT.md"
],
"contracts": {},
"dependencies": {
"strategy": "none"
},
"runtime": {
"freshness": "requires_jops_observation"
},
"artifacts": [
"context/boot.md",
"context/capsule.json",
"README.md",
"docs/RELEASE_NOTES_V1.2.1.md",
"docs/DEPLOYMENT.md"
]
}
+3 -3
View File
@@ -24,7 +24,7 @@ systemd: Gunicorn + UvicornWorker
约定:
- Ubuntu 22.04 或 24.04。
- Gitea 仓库:`http://117.72.28.96:8765/Jacky/Hulumath-Web.git`。
- Gitea 仓库:`https://git.jacky0987.xyz/Jacky/Hulumath-Web.git`。
- 项目目录:`/www/wwwroot/Hulumath-Web`。
- 应用监听:`127.0.0.1:8000`。
- MySQL 8 宿主机端口:`3307`。
@@ -206,7 +206,7 @@ mysql -h 127.0.0.1 -P 3307 \
```bash
git ls-remote \
http://117.72.28.96:8765/Jacky/Hulumath-Web.git \
https://git.jacky0987.xyz/Jacky/Hulumath-Web.git \
refs/heads/main
```
@@ -223,7 +223,7 @@ if [ -d Hulumath-Web ]; then
fi
git clone \
http://117.72.28.96:8765/Jacky/Hulumath-Web.git \
https://git.jacky0987.xyz/Jacky/Hulumath-Web.git \
Hulumath-Web
cd Hulumath-Web
git checkout main
+2 -2
View File
@@ -51,7 +51,7 @@ PR 质量门禁由 `.gitea/workflows/ci.yml` 执行:
自动部署前,服务器必须满足:
- Gitea 仓库为 `http://117.72.28.96:8765/Jacky/Hulumath-Web.git`。
- Gitea 仓库为 `https://git.jacky0987.xyz/Jacky/Hulumath-Web.git`。
- 项目目录为 `/www/wwwroot/Hulumath-Web`。
- Python 位于 `/www/server/pyporject_evn/versions/3.12.13/bin/python3` 或同目录 `python`。
- 已安装 MySQL 8 客户端命令 `mysqldump`。
@@ -76,7 +76,7 @@ chmod 600 .env.production
```bash
cd /www/wwwroot/Hulumath-Web
git remote set-url origin http://117.72.28.96:8765/Jacky/Hulumath-Web.git
git remote set-url origin https://git.jacky0987.xyz/Jacky/Hulumath-Web.git
git fetch origin main
PREVIOUS_REVISION="$(git rev-parse HEAD)"
git reset --hard origin/main
+58
View File
@@ -0,0 +1,58 @@
# Hulumath v1.2.1 Release Notes
发布日期:待功能分支合并并通过生产冒烟后填写。
## 版本主题
v1.2.1 聚焦数学工具箱:按实际任务重新分类入口,扩展符号计算能力,并将函数绘图升级为可交互、响应式的函数探索器。数学画板与 LaTeX Lab 保持 v1.2.0 的行为。
## 主要变化
### 工具分类
- 删除工具箱中的“口算竞技”入口;口算玩法仍归属比赛模块。
- 工具按“计算与探索”“知识与表达”“创作与协作”分组。
- 高级计算工作台按代数、微积分、线性代数、数据换算提供分类切换。
### 高级计算工作台
- 新增 Taylor/Laurent 级数、梯度、Hessian 矩阵。
- 新增多元方程组求解和 1 至 12 次多项式数值求根。
- 新增矩阵秩、零空间基、特征值及代数重数。
- 描述统计新增总和、四分位数和极差。
- 继续使用受限 AST 与 SymPy 内核,不执行用户输入代码。
### 函数探索器
- 动态增删最多 8 条曲线,每条曲线可独立显示、隐藏和设置颜色。
- X/Y 视窗独立输入,支持自动 Y 范围与重置视窗。
- Canvas 随容器响应式缩放,并按设备像素比生成清晰位图。
- 支持拖动平移、滚轮或触控板缩放、双击自动取景。
- 坐标轴增加刻度、数值标签和方向箭头。
- 保留参数 `a`、第一条曲线导数、积分区域、近似零点和极值分析。
- 支持 `sqrt(x)` 等只在部分视窗有定义的函数,不因单个采样点无效而中止绘图。
## 兼容与迁移
- 本版本没有数据库迁移。
- 画板 WebSocket、LaTeX 文档和已保存数据结构不变。
- 发布时仍须先执行 `collectstatic`,成功后再执行 `migrate`。
## 发布检查
```bash
make check
make test
cd backend && ../.venv/bin/python manage.py collectstatic --noinput
cd backend && ../.venv/bin/python manage.py migrate --noinput
```
生产部署后检查:
```text
/health/ version == 1.2.1
高级计算分类与示例可运行
函数列表可动态增删
桌面和手机端坐标轴、缩放与平移正常
数学画板与 LaTeX Lab 回归正常
```
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CAPSULE_FILE = ROOT / "context" / "capsule.json"
MANIFEST_FILE = ROOT / "context" / "manifest.json"
def fail(message):
raise SystemExit(f"context_check_error={message}")
try:
capsule = json.loads(CAPSULE_FILE.read_text(encoding="utf-8"))
manifest = json.loads(MANIFEST_FILE.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
fail(f"invalid_capsule:{error}")
if (
capsule.get("schema") != 1
or capsule.get("kind") != "jcm.repository-capsule"
or capsule.get("repository", {}).get("id") != "hulumath"
or capsule.get("repository", {}).get("remote") != "Jacky/Hulumath-Web"
):
fail("invalid_identity")
if (
manifest.get("schema_version") != "2.0.0"
or manifest.get("repository", {}).get("name") != "Hulumath-Web"
or "context/capsule.json" not in manifest.get("read_order", [])
):
fail("invalid_manifest")
paths = [
capsule.get("context", {}).get("entrypoint"),
*capsule.get("context", {}).get("read_order", []),
*capsule.get("current_state", {}).get("evidence", []),
*[fact.get("evidence") for fact in capsule.get("facts", [])],
]
for relative in paths:
if not relative or Path(relative).is_absolute() or ".." in Path(relative).parts:
fail(f"unsafe_path:{relative}")
if not (ROOT / relative).is_file():
fail(f"missing_path:{relative}")
print("context_check=ok")
+10
View File
@@ -234,6 +234,16 @@ if [ -d "$PROJECT_DIR/backend/staticfiles" ]; then
fi
cd "$PROJECT_DIR/backend"
if [ "${DJANGO_USE_HTTPS:-true}" = "true" ]; then
if [ "${DJANGO_HSTS_AT_EDGE:-false}" = "true" ]; then
curl --silent --show-error --head \
--noproxy '*' \
--resolve "${HEALTH_HOST}:443:127.0.0.1" \
--max-time 10 \
"https://${HEALTH_HOST}/" |
tr -d '\r' |
grep -Eiq '^strict-transport-security:.*max-age=[1-9][0-9]*'
log "边缘代理 HSTS 已验证"
fi
"$VENV_DIR/bin/python" manage.py check --deploy --fail-level WARNING --database default
else
log "临时 HTTP 模式:部署安全检查只阻断 ERROR;绑定域名后应启用 HTTPS"