feat: 现代化翻页组件 + 完整项目更新
- 新增 Pagination 通用组件:首页/上一页/页码/下一页/末页导航 - PickModal 和 SwapModal 集成分页功能,每页20项 - 搜索/筛选切换自动回到第1页 - 现代化CSS样式:hover浮起动效、主色高亮、移动端适配 - 新增 dashboard、admin 管理模板 - 菜系(cuisine)模型和迁移 - 构建脚本 build.py 支持 PyInstaller 打包 - 前端资源重新构建
This commit is contained in:
+296
@@ -0,0 +1,296 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user