- 新增 Pagination 通用组件:首页/上一页/页码/下一页/末页导航 - PickModal 和 SwapModal 集成分页功能,每页20项 - 搜索/筛选切换自动回到第1页 - 现代化CSS样式:hover浮起动效、主色高亮、移动端适配 - 新增 dashboard、admin 管理模板 - 菜系(cuisine)模型和迁移 - 构建脚本 build.py 支持 PyInstaller 打包 - 前端资源重新构建
286 lines
9.4 KiB
Python
286 lines
9.4 KiB
Python
#!/usr/bin/env python
|
|
"""
|
|
Build script: compile frontend, then package everything into a distributable
|
|
folder via PyInstaller. Optionally build a Windows installer via Inno Setup.
|
|
|
|
Usage:
|
|
python build.py # full build (windowed desktop EXE)
|
|
python build.py --clean # clean previous build artifacts first
|
|
python build.py --installer # build EXE then compile installer
|
|
"""
|
|
import argparse
|
|
import glob
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from PyInstaller.utils.hooks import collect_submodules
|
|
|
|
ROOT = os.path.dirname(os.path.abspath(__file__))
|
|
BACKEND = os.path.join(ROOT, "backend")
|
|
FRONTEND = os.path.join(ROOT, "frontend")
|
|
DIST = os.path.join(ROOT, "dist")
|
|
BUILD = os.path.join(ROOT, "build")
|
|
INSTALLER_DIR = os.path.join(ROOT, "installer")
|
|
ISS_SCRIPT = os.path.join(ROOT, "school-meal-setup.iss")
|
|
FRONTEND_BUILD = os.path.join(FRONTEND, "dist")
|
|
STATIC_FE = os.path.join(BACKEND, "static", "frontend")
|
|
SPEC_FILE = os.path.join(ROOT, "school-meal.spec")
|
|
|
|
|
|
def run(cmd, cwd=None, shell=False):
|
|
print(f" > {cmd if isinstance(cmd, str) else ' '.join(cmd)}")
|
|
result = subprocess.run(cmd, cwd=cwd, shell=shell)
|
|
if result.returncode != 0:
|
|
print(f"ERROR: command failed with exit code {result.returncode}")
|
|
sys.exit(1)
|
|
|
|
|
|
def clean():
|
|
print("[clean] Removing previous build artifacts...")
|
|
for d in [DIST, BUILD, STATIC_FE]:
|
|
if os.path.isdir(d):
|
|
shutil.rmtree(d)
|
|
for f in glob.glob(os.path.join(ROOT, "*.spec")):
|
|
os.remove(f)
|
|
|
|
|
|
def build_frontend():
|
|
print("\n[1/3] Building React frontend...")
|
|
run("npm run build", cwd=FRONTEND, shell=True)
|
|
if not os.path.isdir(FRONTEND_BUILD):
|
|
print("ERROR: frontend/dist not found after build")
|
|
sys.exit(1)
|
|
print(" Copying to backend/static/frontend/")
|
|
if os.path.isdir(STATIC_FE):
|
|
shutil.rmtree(STATIC_FE)
|
|
shutil.copytree(FRONTEND_BUILD, STATIC_FE)
|
|
|
|
|
|
venv_site = (
|
|
os.path.join(ROOT, ".venv", "Lib", "site-packages")
|
|
if os.path.isdir(os.path.join(ROOT, ".venv"))
|
|
else ""
|
|
)
|
|
|
|
|
|
def create_spec():
|
|
"""Generate the PyInstaller .spec file dynamically."""
|
|
print("\n[2/3] Creating PyInstaller spec...")
|
|
|
|
# Collect Jazzmin admin theme static files
|
|
jazzmin_static = os.path.join(venv_site, "jazzmin", "static")
|
|
datas_extra = []
|
|
if os.path.isdir(jazzmin_static):
|
|
for root, dirs, files in os.walk(jazzmin_static):
|
|
rel = os.path.relpath(root, jazzmin_static)
|
|
for f in files:
|
|
src = os.path.join(root, f)
|
|
dst = os.path.join("static", rel) if rel != "." else "static"
|
|
datas_extra.append((src, dst))
|
|
|
|
datas = [
|
|
(os.path.join(BACKEND, "static"), "backend/static"),
|
|
(os.path.join(BACKEND, "templates"), "backend/templates"),
|
|
(os.path.join(BACKEND, "meals", "migrations"), "backend/meals/migrations"),
|
|
(os.path.join(BACKEND, "config"), "backend/config"),
|
|
(os.path.join(BACKEND, "meals", "management"), "backend/meals/management"),
|
|
] + datas_extra
|
|
|
|
# Hidden imports
|
|
hidden = []
|
|
for pkg in ["rest_framework", "corsheaders", "jazzmin", "rich"]:
|
|
try:
|
|
hidden.extend(collect_submodules(pkg))
|
|
except Exception:
|
|
pass
|
|
hidden += [
|
|
"meals.views_dashboard", "meals", "meals.apps", "meals.models",
|
|
"meals.admin", "meals.urls", "meals.views", "meals.views_spa",
|
|
"meals.serializers", "meals.management", "meals.management.commands",
|
|
"meals.management.commands.seed_dishes",
|
|
"meals.management.commands.import_dishes",
|
|
"config", "config.settings", "config.wsgi", "config.asgi", "config.urls",
|
|
"django.contrib.admin", "django.contrib.auth",
|
|
"django.contrib.contenttypes", "django.contrib.sessions",
|
|
"django.contrib.messages", "django.contrib.staticfiles",
|
|
"pypinyin", "openpyxl",
|
|
"tkinter", "tkinter.messagebox", "tkinter.constants",
|
|
"webview", "webview.window", "webview.platforms.edgechromium",
|
|
"pythonnet", "clr_loader", "cffi", "bottle",
|
|
]
|
|
seen = set()
|
|
unique_hidden = [h for h in hidden if not (h in seen or seen.add(h))]
|
|
|
|
# Build spec content using list of lines to avoid f-string quoting issues
|
|
desktop_py = os.path.join(ROOT, "desktop.py").replace("\\", "/")
|
|
root_path = ROOT.replace("\\", "/")
|
|
backend_path = BACKEND.replace("\\", "/")
|
|
icon_path = os.path.join(ROOT, "app.ico").replace("\\", "/")
|
|
|
|
lines = [
|
|
"# -*- mode: python ; coding: utf-8 -*-",
|
|
"# Auto-generated by build.py",
|
|
"block_cipher = None",
|
|
"",
|
|
"a = Analysis(",
|
|
f" [r'{desktop_py}'],",
|
|
f" pathex=[r'{root_path}', r'{backend_path}'],",
|
|
" binaries=[],",
|
|
" datas=[",
|
|
]
|
|
for src, dst in datas:
|
|
lines.append(f" (r'{src}', r'{dst}'),")
|
|
lines += [
|
|
" ],",
|
|
" hiddenimports=[",
|
|
]
|
|
for h in unique_hidden:
|
|
lines.append(f" '{h}',")
|
|
lines += [
|
|
" ],",
|
|
" hookspath=[],",
|
|
" hooksconfig={},",
|
|
" runtime_hooks=[],",
|
|
" excludes=[],",
|
|
" win_no_prefer_redirects=False,",
|
|
" win_private_assemblies=False,",
|
|
" cipher=block_cipher,",
|
|
" noarchive=False,",
|
|
")",
|
|
"",
|
|
"pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)",
|
|
"",
|
|
"exe = EXE(",
|
|
" pyz,",
|
|
" a.scripts,",
|
|
" [],",
|
|
" exclude_binaries=True,",
|
|
" name='school-meal',",
|
|
" debug=False,",
|
|
" bootloader_ignore_signals=False,",
|
|
" strip=False,",
|
|
" upx=True,",
|
|
" console=False,",
|
|
" disable_windowed_traceback=False,",
|
|
" target_arch=None,",
|
|
" codesign_identity=None,",
|
|
" entitlements_file=None,",
|
|
f" icon=r'{icon_path}',",
|
|
")",
|
|
"",
|
|
"coll = COLLECT(",
|
|
" exe,",
|
|
" a.binaries,",
|
|
" a.zipfiles,",
|
|
" a.datas,",
|
|
" strip=False,",
|
|
" upx=True,",
|
|
" upx_exclude=[],",
|
|
" name='school-meal',",
|
|
")",
|
|
"",
|
|
]
|
|
|
|
with open(SPEC_FILE, "w", encoding="utf-8") as f:
|
|
f.write("\n".join(lines))
|
|
print(f" Spec written to {SPEC_FILE}")
|
|
|
|
|
|
def run_pyinstaller():
|
|
print("\n[3/3] Running PyInstaller...")
|
|
run([sys.executable, "-m", "PyInstaller", "--clean", "--noconfirm", SPEC_FILE])
|
|
|
|
out_dir = os.path.join(DIST, "school-meal")
|
|
bat_path = os.path.join(out_dir, "school-meal.bat")
|
|
with open(bat_path, "w", encoding="utf-8") as f:
|
|
f.write("@echo off\n")
|
|
f.write("chcp 65001 >nul\n")
|
|
f.write("cd /d %~dp0\n")
|
|
f.write('echo Starting school meal planner...\n')
|
|
f.write('start "" school-meal.exe\n')
|
|
|
|
size_mb = sum(
|
|
os.path.getsize(os.path.join(dp, fn))
|
|
for dp, _, fnames in os.walk(out_dir)
|
|
for fn in fnames
|
|
) / (1024 * 1024)
|
|
print(f"\nBuild complete! Output: {out_dir}")
|
|
print(f"Total size: {size_mb:.1f} MB")
|
|
|
|
|
|
def find_iscc():
|
|
"""Locate the Inno Setup command-line compiler."""
|
|
candidates = [
|
|
os.path.join(os.environ.get("ProgramFiles(x86)", ""), "Inno Setup 6", "ISCC.exe"),
|
|
os.path.join(os.environ.get("ProgramFiles", ""), "Inno Setup 6", "ISCC.exe"),
|
|
os.path.join(os.environ.get("ProgramFiles(x86)", ""), "Inno Setup 5", "ISCC.exe"),
|
|
os.path.join(os.environ.get("ProgramFiles", ""), "Inno Setup 5", "ISCC.exe"),
|
|
]
|
|
for path in candidates:
|
|
if os.path.isfile(path):
|
|
return path
|
|
from shutil import which
|
|
path = which("iscc")
|
|
if path:
|
|
return path
|
|
return None
|
|
|
|
|
|
def build_installer():
|
|
"""Compile the Inno Setup .iss script to produce an installer .exe."""
|
|
print("\n[installer] Building Windows installer...")
|
|
|
|
if not os.path.isfile(ISS_SCRIPT):
|
|
print(f"ERROR: {ISS_SCRIPT} not found. Cannot build installer.")
|
|
sys.exit(1)
|
|
|
|
iscc = find_iscc()
|
|
if not iscc:
|
|
print("ERROR: Inno Setup compiler (ISCC.exe) not found.")
|
|
print("Please install Inno Setup 6 from https://jrsoftware.org/isdl.php")
|
|
sys.exit(1)
|
|
|
|
print(f" Using: {iscc}")
|
|
run([iscc, ISS_SCRIPT])
|
|
|
|
if os.path.isdir(INSTALLER_DIR):
|
|
size_mb = sum(
|
|
os.path.getsize(os.path.join(dp, fn))
|
|
for dp, _, fnames in os.walk(INSTALLER_DIR)
|
|
for fn in fnames
|
|
) / (1024 * 1024)
|
|
print(f"\nInstaller built! Output: {INSTALLER_DIR}")
|
|
print(f"Installer size: {size_mb:.1f} MB")
|
|
else:
|
|
print("ERROR: Installer output directory not created.")
|
|
sys.exit(1)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Build school-meal package")
|
|
parser.add_argument("--clean", action="store_true", help="Clean previous build artifacts")
|
|
parser.add_argument("--installer", action="store_true", help="Also build Windows installer after EXE build")
|
|
args = parser.parse_args()
|
|
|
|
if args.clean:
|
|
clean()
|
|
print("Clean done. Exiting.\n")
|
|
return
|
|
|
|
os.chdir(ROOT)
|
|
build_frontend()
|
|
create_spec()
|
|
run_pyinstaller()
|
|
|
|
if args.installer:
|
|
build_installer()
|
|
print("\nDone! Installer at installer/学校订餐菜单生成器-安装包.exe")
|
|
else:
|
|
print("\nDone! Run dist/school-meal/school-meal.exe to launch the desktop app.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
|