Files
Chunyu d7553520ce feat: 现代化翻页组件 + 完整项目更新
- 新增 Pagination 通用组件:首页/上一页/页码/下一页/末页导航
- PickModal 和 SwapModal 集成分页功能,每页20项
- 搜索/筛选切换自动回到第1页
- 现代化CSS样式:hover浮起动效、主色高亮、移动端适配
- 新增 dashboard、admin 管理模板
- 菜系(cuisine)模型和迁移
- 构建脚本 build.py 支持 PyInstaller 打包
- 前端资源重新构建
2026-08-28 13:44:21 +08:00

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