- 新增 Pagination 通用组件:首页/上一页/页码/下一页/末页导航 - PickModal 和 SwapModal 集成分页功能,每页20项 - 搜索/筛选切换自动回到第1页 - 现代化CSS样式:hover浮起动效、主色高亮、移动端适配 - 新增 dashboard、admin 管理模板 - 菜系(cuisine)模型和迁移 - 构建脚本 build.py 支持 PyInstaller 打包 - 前端资源重新构建
297 lines
8.8 KiB
Python
297 lines
8.8 KiB
Python
#!/usr/bin/env python
|
|
"""
|
|
Desktop launcher: run school meal planner inside a native window.
|
|
Starts Django backend (background thread), then opens a pywebview window.
|
|
"""
|
|
import os
|
|
import sys
|
|
import socket
|
|
import threading
|
|
import time
|
|
import traceback
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Logging
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_app_dir = None
|
|
|
|
|
|
def _get_app_dir():
|
|
global _app_dir
|
|
if _app_dir is None:
|
|
if getattr(sys, "frozen", False):
|
|
_app_dir = os.path.dirname(sys.executable)
|
|
else:
|
|
_app_dir = os.path.dirname(os.path.abspath(__file__))
|
|
return _app_dir
|
|
|
|
|
|
def _get_log_dir():
|
|
"""Return a writable directory for log files.
|
|
In frozen mode, prefer %LOCALAPPDATA% to avoid write errors in Program Files."""
|
|
if getattr(sys, "frozen", False):
|
|
local = os.environ.get("LOCALAPPDATA", "")
|
|
if local:
|
|
d = os.path.join(local, "学校订餐菜单生成器")
|
|
try:
|
|
os.makedirs(d, exist_ok=True)
|
|
# quick write test
|
|
t = os.path.join(d, ".write_test")
|
|
with open(t, "w") as f:
|
|
f.write("")
|
|
os.remove(t)
|
|
return d
|
|
except Exception:
|
|
pass
|
|
return _get_app_dir()
|
|
|
|
|
|
def _log(msg):
|
|
try:
|
|
path = os.path.join(_get_log_dir(), "desktop.log")
|
|
with open(path, "a", encoding="utf-8") as f:
|
|
f.write(msg + "\n")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _excepthook(exc_type, exc_value, exc_tb):
|
|
_log("FATAL: " + "".join(traceback.format_exception(exc_type, exc_value, exc_tb)))
|
|
|
|
|
|
sys.excepthook = _excepthook
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fix for console=False mode: ensure stdout/stderr are never None
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _ensure_stdio():
|
|
"""Redirect stdout/stderr to log file if they are None (console=False)."""
|
|
if sys.stdout is None or sys.stderr is None:
|
|
log_path = os.path.join(_get_log_dir(), "django.log")
|
|
try:
|
|
log_file = open(log_path, "a", encoding="utf-8", buffering=1)
|
|
except Exception:
|
|
log_file = open(os.path.join(_get_app_dir(), "django.log"), "a", encoding="utf-8", buffering=1)
|
|
if sys.stdout is None:
|
|
sys.stdout = log_file
|
|
if sys.stderr is None:
|
|
sys.stderr = log_file
|
|
|
|
|
|
_ensure_stdio()
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Environment
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _frozen_dir():
|
|
if getattr(sys, "frozen", False):
|
|
return sys._MEIPASS
|
|
return os.path.dirname(os.path.abspath(__file__))
|
|
|
|
|
|
def setup_environment():
|
|
base = _frozen_dir()
|
|
data_dir = _get_log_dir()
|
|
sys.path.insert(0, base)
|
|
backend_dir = os.path.join(base, "backend")
|
|
if os.path.isdir(backend_dir) and backend_dir not in sys.path:
|
|
sys.path.insert(0, backend_dir)
|
|
|
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
|
os.environ["DJANGO_DEBUG"] = "False"
|
|
os.environ["DJANGO_SECRET_KEY"] = "exe-local-secret-key-do-not-use-in-production"
|
|
os.environ["DJANGO_ALLOWED_HOSTS"] = "127.0.0.1,localhost"
|
|
os.environ["DJANGO_DB_PATH"] = os.path.join(data_dir, "db.sqlite3")
|
|
os.environ["DJANGO_STATIC_URL"] = "/static/"
|
|
os.environ["EXE_MODE"] = "1"
|
|
|
|
static_fe = os.path.join(base, "backend", "static", "frontend")
|
|
if not os.path.isdir(static_fe):
|
|
static_fe = os.path.join(base, "static", "frontend")
|
|
os.environ["FRONTEND_DIR"] = static_fe
|
|
|
|
|
|
def _ensure_database():
|
|
"""Copy the bundled db.sqlite3 to the data directory if missing or outdated.
|
|
|
|
In the frozen EXE, migrations cannot be discovered from the PyInstaller
|
|
data directory, so we ship a pre-migrated db.sqlite3 and copy it on first
|
|
run. If the user's existing database has the wrong schema (e.g. upgraded
|
|
from an older EXE), replace it with the bundled one.
|
|
"""
|
|
if not getattr(sys, "frozen", False):
|
|
return
|
|
|
|
data_dir = _get_log_dir()
|
|
target_db = os.path.join(data_dir, "db.sqlite3")
|
|
bundled_db = os.path.join(_get_app_dir(), "db.sqlite3")
|
|
|
|
if not os.path.isfile(bundled_db):
|
|
return
|
|
|
|
need_copy = False
|
|
if not os.path.isfile(target_db):
|
|
need_copy = True
|
|
else:
|
|
import sqlite3
|
|
try:
|
|
conn = sqlite3.connect(target_db)
|
|
cols = [r[1] for r in conn.execute("PRAGMA table_info(meals_dish)").fetchall()]
|
|
conn.close()
|
|
if "cuisine_id" not in cols:
|
|
need_copy = True
|
|
except Exception:
|
|
need_copy = True
|
|
|
|
if need_copy:
|
|
os.makedirs(data_dir, exist_ok=True)
|
|
import shutil
|
|
shutil.copy2(bundled_db, target_db)
|
|
_log(f"Copied bundled db.sqlite3 to {target_db}")
|
|
|
|
|
|
def init_database():
|
|
_ensure_database()
|
|
|
|
import django
|
|
django.setup()
|
|
from django.core.management import call_command
|
|
call_command("migrate", "--run-syncdb", verbosity=0)
|
|
|
|
from meals.models import Dish
|
|
if Dish.objects.count() == 0:
|
|
try:
|
|
call_command("seed_dishes", verbosity=0)
|
|
except Exception:
|
|
pass
|
|
|
|
from django.contrib.auth import get_user_model
|
|
User = get_user_model()
|
|
if not User.objects.filter(username="admin").exists():
|
|
User.objects.create_superuser("admin", "admin@example.com", "admin123")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Django server
|
|
# ---------------------------------------------------------------------------
|
|
|
|
PORT = 8000
|
|
|
|
|
|
def _port_in_use(port):
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
return s.connect_ex(("127.0.0.1", port)) == 0
|
|
|
|
|
|
def start_django_server():
|
|
try:
|
|
_log("start_django_server: calling django.setup()")
|
|
import django
|
|
django.setup()
|
|
_log("start_django_server: calling runserver")
|
|
from django.core.management import call_command
|
|
call_command("runserver", f"127.0.0.1:{PORT}", "--noreload", verbosity=0)
|
|
_log("start_django_server: runserver returned")
|
|
except Exception:
|
|
_log("start_django_server CRASHED:\n" + traceback.format_exc())
|
|
|
|
|
|
def wait_for_server(timeout=30):
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
if _port_in_use(PORT):
|
|
return True
|
|
time.sleep(0.3)
|
|
return False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main
|
|
# ---------------------------------------------------------------------------
|
|
|
|
WINDOW_TITLE = "学校订餐菜单生成器"
|
|
WINDOW_WIDTH = 1280
|
|
WINDOW_HEIGHT = 800
|
|
|
|
|
|
def _get_icon_path():
|
|
"""Return the path to the application icon file."""
|
|
return os.path.join(_get_app_dir(), "app.ico")
|
|
|
|
|
|
def main():
|
|
_log("=== desktop.py main() starting ===")
|
|
|
|
setup_environment()
|
|
_log("Environment setup done")
|
|
|
|
init_database()
|
|
_log("Database init done")
|
|
|
|
server_thread = threading.Thread(target=start_django_server, daemon=True)
|
|
server_thread.start()
|
|
_log("Django thread started, waiting for port 8000...")
|
|
|
|
if not wait_for_server():
|
|
_log("ERROR: Django server startup timed out!")
|
|
sys.exit(1)
|
|
|
|
_log("Django server ready, opening webview...")
|
|
|
|
import webview
|
|
|
|
url = f"http://127.0.0.1:{PORT}"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# JS API: opens new native windows from the index page
|
|
# ---------------------------------------------------------------------------
|
|
_child_windows = []
|
|
|
|
class JsApi:
|
|
"""Exposed to JavaScript as window.pywebview.api."""
|
|
|
|
def open_page(self, path, title=None, width=1280, height=800):
|
|
"""Open a new pywebview window for the given path."""
|
|
full_url = f"http://127.0.0.1:{PORT}{path}"
|
|
win_title = title or WINDOW_TITLE
|
|
w = webview.create_window(
|
|
win_title,
|
|
full_url,
|
|
width=int(width),
|
|
height=int(height),
|
|
min_size=(900, 600),
|
|
resizable=True,
|
|
text_select=True,
|
|
)
|
|
_child_windows.append(w)
|
|
return True
|
|
|
|
js_api = JsApi()
|
|
|
|
window = webview.create_window(
|
|
WINDOW_TITLE,
|
|
url,
|
|
width=WINDOW_WIDTH,
|
|
height=WINDOW_HEIGHT,
|
|
min_size=(900, 600),
|
|
resizable=True,
|
|
text_select=True,
|
|
js_api=js_api,
|
|
)
|
|
|
|
_log("Starting webview event loop...")
|
|
webview.start(debug=False)
|
|
_log("Webview closed.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main()
|
|
except Exception:
|
|
_log("UNHANDLED: " + traceback.format_exc())
|
|
raise
|