python-script-launcher 0.0.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,99 @@
1
+ """
2
+ Python App Launcher — 桌面客户端版
3
+ 作为模块引入时: from python_script_launcher import start_desktop
4
+ 直接运行时: python client.py
5
+ """
6
+ import sys, os, threading, argparse
7
+ from pathlib import Path
8
+
9
+
10
+ def start_desktop(
11
+ root: str = None,
12
+ port: int = 8765,
13
+ title: str = "Python App Launcher",
14
+ width: int = 1200,
15
+ height: int = 800,
16
+ debug: bool = False,
17
+ ):
18
+ import webview
19
+ from app import create_app, scan_scripts, find_python, is_port_used
20
+
21
+ project_root = Path(root).resolve() if root else _find_root()
22
+
23
+ if is_port_used(port):
24
+ print(f"[!] Port {port} already in use")
25
+ return
26
+
27
+ app = create_app(root=project_root)
28
+
29
+ found = scan_scripts(project_root)
30
+ print(f"\n{'='*50}")
31
+ print(f" {title} - Desktop")
32
+ print(f"{'='*50}")
33
+ print(f" Project: {project_root}")
34
+ print(f" Scripts: {len(found)}")
35
+ print(f" Port: {port}")
36
+ print(f"{'='*50}\n")
37
+
38
+ def run_server():
39
+ import uvicorn
40
+ uvicorn.run(app, host="127.0.0.1", port=port, log_level="error")
41
+
42
+ server_thread = threading.Thread(target=run_server, daemon=True)
43
+ server_thread.start()
44
+
45
+ import time; time.sleep(1)
46
+
47
+ class Api:
48
+ def select_folder(self):
49
+ w = webview.windows[0]
50
+ r = w.create_file_dialog(webview.FOLDER_DIALOG)
51
+ return r[0] if r else None
52
+
53
+ def open_folder(self, path=None):
54
+ import subprocess
55
+ target = str(Path(path) if path else project_root)
56
+ if sys.platform == "win32":
57
+ subprocess.Popen(["explorer", target])
58
+ elif sys.platform == "darwin":
59
+ subprocess.Popen(["open", target])
60
+ else:
61
+ subprocess.Popen(["xdg-open", target])
62
+
63
+ window = webview.create_window(
64
+ title=title,
65
+ url=f"http://localhost:{port}",
66
+ width=width,
67
+ height=height,
68
+ min_size=(800, 600),
69
+ text_select=True,
70
+ background_color="#ffffff",
71
+ js_api=Api(),
72
+ )
73
+
74
+ webview.start(debug=debug)
75
+ os._exit(0)
76
+
77
+
78
+ def _find_root():
79
+ if getattr(sys, "frozen", False):
80
+ return Path(sys._MEIPASS)
81
+ p = Path(__file__).parent.resolve()
82
+ return p.parent if p.name == "scripts" else p
83
+
84
+
85
+ if __name__ == "__main__":
86
+ if getattr(sys, "frozen", False):
87
+ import ctypes
88
+ mutex = ctypes.windll.kernel32.CreateMutexW(None, False, "PythonAppLauncher_Desktop")
89
+ if ctypes.windll.kernel32.GetLastError() == 183:
90
+ print("Another instance is already running.")
91
+ sys.exit(0)
92
+
93
+ parser = argparse.ArgumentParser(description="Python App Launcher Desktop")
94
+ parser.add_argument("--root", "-r", type=str, default=None)
95
+ parser.add_argument("--port", "-p", type=int, default=8765)
96
+ parser.add_argument("--debug", action="store_true")
97
+ args, _ = parser.parse_known_args()
98
+
99
+ start_desktop(root=args.root, port=args.port, debug=args.debug)
@@ -0,0 +1,189 @@
1
+ """
2
+ Python App Launcher - packager.
3
+
4
+ Usage from the repo root:
5
+ python make_exe.py [--web] [--client] [--both] [--onefile] [--clean]
6
+
7
+ Design:
8
+ - Default mode is --onedir (fast startup, one exe per <name>/ folder).
9
+ - --onefile packs a single-file exe (easier to ship, slower cold start).
10
+ - --clean drops build/<name> cache to force a full rebuild.
11
+ - No import scanning: every third-party package in the current environment
12
+ is passed to PyInstaller via `--collect-all` so the resulting exe is
13
+ self-contained. Useful for shipping arbitrary user scripts.
14
+
15
+ Callable API:
16
+ build_exe(entry, name, *, project=None, dist=None, static=None,
17
+ noconsole=False, onefile=False, clean=False)
18
+ """
19
+ import argparse
20
+ import os
21
+ import shutil
22
+ import subprocess
23
+ import sys
24
+ from importlib.metadata import distributions
25
+ from pathlib import Path
26
+
27
+ PROJECT = Path.cwd()
28
+ DIST = PROJECT / "dist"
29
+ BUILD = PROJECT / "build"
30
+ STATIC = PROJECT / "static"
31
+
32
+ # Packages that should NOT be shipped inside the exe (build tooling & self).
33
+ SKIP_PACKAGES = {
34
+ "pyinstaller", "pyinstaller-hooks-contrib", "altgraph", "pefile",
35
+ "pywin32-ctypes", "setuptools", "pip", "wheel", "uv", "build",
36
+ "pyproject-hooks", "packaging",
37
+ "python-script-launcher",
38
+ }
39
+
40
+
41
+ def _norm(name: str) -> str:
42
+ return (name or "").strip().lower().replace("_", "-")
43
+
44
+
45
+ def get_installed_packages():
46
+ """Return sorted top-level import names of third-party packages installed
47
+ in the current interpreter environment.
48
+ """
49
+ names = set()
50
+ skip = {_norm(x) for x in SKIP_PACKAGES}
51
+ for dist in distributions():
52
+ pkg_name = (dist.metadata["Name"] or "").strip()
53
+ if not pkg_name or _norm(pkg_name) in skip:
54
+ continue
55
+ top_level = dist.read_text("top_level.txt")
56
+ if top_level:
57
+ for line in top_level.splitlines():
58
+ mod = line.strip()
59
+ if mod and not mod.startswith("_"):
60
+ names.add(mod)
61
+ else:
62
+ names.add(pkg_name.replace("-", "_"))
63
+ return sorted(names)
64
+
65
+
66
+ def _clean_target(name: str, dist_dir: Path, build_dir: Path, project: Path,
67
+ drop_cache: bool = False):
68
+ targets = [dist_dir / f"{name}.exe", dist_dir / name, project / f"{name}.spec"]
69
+ if drop_cache:
70
+ targets.append(build_dir / name)
71
+ for p in targets:
72
+ if p.is_file():
73
+ p.unlink()
74
+ elif p.is_dir():
75
+ shutil.rmtree(p, ignore_errors=True)
76
+
77
+
78
+ def build_exe(entry, name, *, project=None, dist=None, static=None,
79
+ noconsole=False, onefile=False, clean=False):
80
+ """Build a single PyInstaller exe.
81
+
82
+ Parameters
83
+ ----------
84
+ entry: str | Path
85
+ Path to the entry .py file. Relative paths resolve against `project`.
86
+ name: str
87
+ Application name (produces <name>.exe or <name>/<name>.exe).
88
+ project: Path | None
89
+ Project root (defaults to the current working directory). Also used
90
+ for build/ and spec placement.
91
+ dist: Path | None
92
+ Output directory (defaults to <project>/dist).
93
+ static: Path | None
94
+ If given and exists, is packaged as `--add-data <static>:static`.
95
+ If None, defaults to <project>/static.
96
+ noconsole, onefile, clean:
97
+ PyInstaller options (`--noconsole`, `--onefile`, `--clean`).
98
+ """
99
+ project = Path(project).resolve() if project else PROJECT
100
+ dist_dir = Path(dist).resolve() if dist else project / "dist"
101
+ build_dir = project / "build"
102
+ static_dir = Path(static).resolve() if static else project / "static"
103
+
104
+ mode = "onefile" if onefile else "onedir"
105
+ print(f"=== build {name} ({mode}) ===\n")
106
+ icon = project / "icon.ico"
107
+ icon_arg = str(icon) if icon.exists() else None
108
+
109
+ _clean_target(name, dist_dir, build_dir, project, drop_cache=clean)
110
+
111
+ cmd = [
112
+ sys.executable, "-m", "PyInstaller",
113
+ "--noconfirm",
114
+ "--onefile" if onefile else "--onedir",
115
+ "--name", name,
116
+ "--distpath", str(dist_dir),
117
+ "--workpath", str(build_dir),
118
+ "--specpath", str(project),
119
+ ]
120
+ if clean:
121
+ cmd.append("--clean")
122
+ if noconsole:
123
+ cmd.append("--noconsole")
124
+
125
+ if static_dir.exists():
126
+ cmd += ["--add-data", f"{static_dir}{os.pathsep}static"]
127
+ scripts_dir = project / "scripts"
128
+ if scripts_dir.exists():
129
+ cmd += ["--add-data", f"{scripts_dir}{os.pathsep}scripts"]
130
+
131
+ # Make `tasks` and the compatibility `python_script_launcher` shim
132
+ # importable from user scripts inside the frozen exe. `--paths` lets
133
+ # PyInstaller resolve them at build time; hidden-imports pull them into
134
+ # the frozen PYZ.
135
+ cmd += ["--paths", str(project)]
136
+ if (project / "tasks.py").exists():
137
+ cmd += ["--hidden-import", "tasks"]
138
+ if (project / "python_script_launcher" / "__init__.py").exists():
139
+ cmd += ["--hidden-import", "python_script_launcher"]
140
+
141
+ for pkg in get_installed_packages():
142
+ cmd += ["--collect-all", pkg]
143
+
144
+ if icon_arg:
145
+ cmd += ["--icon", icon_arg]
146
+
147
+ entry_path = Path(entry)
148
+ if not entry_path.is_absolute():
149
+ entry_path = (project / entry_path).resolve()
150
+ cmd.append(str(entry_path))
151
+
152
+ print(f" entry: {entry_path}")
153
+ subprocess.run(cmd, check=True, cwd=str(project))
154
+ out = f"{dist_dir}/{name}.exe" if onefile else f"{dist_dir}/{name}/{name}.exe"
155
+ print(f" [OK] {name} -> {out}\n")
156
+
157
+
158
+ def main():
159
+ parser = argparse.ArgumentParser(description="Python App Launcher packager")
160
+ parser.add_argument("--web", action="store_true", help="build web exe (app.py)")
161
+ parser.add_argument("--client", action="store_true", help="build desktop exe (client.py)")
162
+ parser.add_argument("--both", action="store_true", help="build both web and desktop")
163
+ parser.add_argument("--onefile", action="store_true", help="single-file exe (slower start)")
164
+ parser.add_argument("--clean", action="store_true", help="drop build/ cache")
165
+ args = parser.parse_args()
166
+
167
+ do_web = args.web or args.both or not (args.web or args.client)
168
+ do_client = args.client or args.both or not (args.web or args.client)
169
+
170
+ pkgs = get_installed_packages()
171
+ kind = "onefile (single file, slower)" if args.onefile else "onedir (folder, faster)"
172
+ print(f"\n Python App Launcher packager")
173
+ print(f" mode: {kind}")
174
+ print(f" packages to bundle ({len(pkgs)}): {', '.join(pkgs)}\n")
175
+
176
+ if do_web:
177
+ build_exe("app.py", "PythonAppLauncher", noconsole=False,
178
+ onefile=args.onefile, clean=args.clean)
179
+ if do_client and (PROJECT / "client.py").exists():
180
+ build_exe("client.py", "PythonAppDesktop", noconsole=True,
181
+ onefile=args.onefile, clean=args.clean)
182
+
183
+ print("=" * 40)
184
+ print(f" All done. Output: {DIST}")
185
+ print("=" * 40)
186
+
187
+
188
+ if __name__ == "__main__":
189
+ main()
@@ -0,0 +1,34 @@
1
+ """示例:文件统计 — 演示文件路径参数"""
2
+ import sys
3
+ from pathlib import Path
4
+
5
+
6
+ def main(filepath: str):
7
+ """统计指定文件的行数、单词数和字节数"""
8
+ path = Path(filepath)
9
+ if not path.exists():
10
+ print(f"❌ 文件不存在: {filepath}")
11
+ return
12
+ content = path.read_text(encoding="utf-8", errors="replace")
13
+ lines = content.splitlines()
14
+ words = content.split()
15
+ print(f"📄 {path.name}")
16
+ print(f" 大小: {path.stat().st_size:,} 字节")
17
+ print(f" 行数: {len(lines):,}")
18
+ print(f" 单词: {len(words):,}")
19
+
20
+
21
+ if __name__ == "__main__":
22
+ if len(sys.argv) < 2:
23
+ print("用法: python file_stats.py --filepath <文件路径>")
24
+ sys.exit(1)
25
+ import argparse
26
+
27
+ parser = argparse.ArgumentParser()
28
+ parser.add_argument("--filepath", nargs="+", required=True)
29
+ args = parser.parse_args()
30
+
31
+ # args.filepath 现在是列表:['uploads/abc.json', 'uploads/def.json']
32
+ for fpath in args.filepath:
33
+ main(fpath)
34
+
@@ -0,0 +1,17 @@
1
+ """示例:Hello World — 基础参数演示"""
2
+ import sys
3
+
4
+
5
+ def main(name: str, greeting: str = "你好"):
6
+ """一个简单的问候脚本,演示必填参数和可选参数"""
7
+ print(f"👋 {greeting},{name}!")
8
+ print(f"Python {sys.version.split()[0]}")
9
+
10
+
11
+ if __name__ == "__main__":
12
+ import argparse
13
+ p = argparse.ArgumentParser()
14
+ p.add_argument("--name", required=True)
15
+ p.add_argument("--greeting", default="你好")
16
+ a = p.parse_args()
17
+ main(a.name, a.greeting)
@@ -0,0 +1,24 @@
1
+ """多任务示例: 用 @task 注册多个可运行函数, 无需 __main__ guard."""
2
+ from tasks import task
3
+
4
+
5
+ @task
6
+ def greet(name: str, greeting: str = "hello"):
7
+ """Say hi to someone."""
8
+ print(f"{greeting}, {name}!")
9
+
10
+
11
+ @task(name="square-sum", desc="Sum of squares up to n")
12
+ def square_sum(n: int = 4):
13
+ total = sum(i * i for i in range(1, n + 1))
14
+ print(f"squares(1..{n}) = {total}")
15
+
16
+
17
+ @task
18
+ def multiline(times: int = 3):
19
+ """Emit multiple lines to test SSE streaming."""
20
+ import time
21
+ for i in range(times):
22
+ print(f"line {i}", flush=True)
23
+ time.sleep(0.05)
24
+ print("done")
@@ -0,0 +1,24 @@
1
+ """最简单的模块测试"""
2
+ import sys
3
+
4
+
5
+ def main():
6
+ print("=== Module Test ===")
7
+ print(f"Python: {sys.version}")
8
+ print(f"Executable: {sys.executable}")
9
+ print()
10
+
11
+ modules = ["json", "os", "pathlib", "requests", "rich", "pandas", "numpy"]
12
+ for mod in modules:
13
+ try:
14
+ m = __import__(mod)
15
+ v = getattr(m, "__version__", "ok")
16
+ print(f" [OK] {mod} {v}")
17
+ except ImportError:
18
+ print(f" [MISS] {mod}")
19
+ except Exception as e:
20
+ print(f" [ERR] {mod}: {e}")
21
+
22
+
23
+ if __name__ == "__main__":
24
+ main()
@@ -0,0 +1,147 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Python App Launcher</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ html, body { height: 100%; overflow: hidden; }
10
+ body {
11
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
12
+ background: #f5f5f5;
13
+ color: #202124;
14
+ display: flex;
15
+ flex-direction: column;
16
+ }
17
+ .titlebar { height: 48px; background: #fff; border-bottom: 1px solid #e0e0e0; display: flex; align-items: center; padding: 0 20px; gap: 16px; flex-shrink: 0; }
18
+ .titlebar .logo { font-size: 18px; font-weight: 700; color: #1a73e8; display: flex; align-items: center; gap: 8px; }
19
+ .titlebar .logo span { font-size: 22px; }
20
+ .titlebar .path-tag { font-size: 11px; color: #5f6368; background: #f1f3f4; padding: 4px 12px; border-radius: 20px; max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
21
+ .titlebar .spacer { flex: 1; }
22
+ .titlebar .toolbar-btn { padding: 6px 14px; border: 1px solid #dadce0; border-radius: 6px; background: #fff; color: #5f6368; font-size: 13px; cursor: pointer; font-family: inherit; }
23
+ .titlebar .toolbar-btn:hover { background: #f1f3f4; color: #202124; }
24
+ .main-layout { flex: 1; display: flex; overflow: hidden; }
25
+ .sidebar { width: 260px; background: #fff; border-right: 1px solid #e0e0e0; display: flex; flex-direction: column; flex-shrink: 0; }
26
+ .sidebar-header { padding: 14px 16px 10px; border-bottom: 1px solid #f1f3f4; }
27
+ .sidebar-header h2 { font-size: 12px; font-weight: 600; color: #5f6368; text-transform: uppercase; letter-spacing: 0.5px; }
28
+ .search-box { margin-top: 8px; }
29
+ .search-box input { width: 100%; padding: 7px 10px; border: 1px solid #dadce0; border-radius: 6px; font-size: 13px; outline: none; background: #f8f9fa; color: #202124; font-family: inherit; }
30
+ .search-box input:focus { border-color: #1a73e8; background: #fff; box-shadow: 0 0 0 2px rgba(26,115,232,0.12); }
31
+ .script-list { flex: 1; overflow-y: auto; padding: 6px 8px; }
32
+ .script-item { padding: 10px 12px; border-radius: 8px; cursor: pointer; margin-bottom: 2px; }
33
+ .script-item:hover { background: #f1f3f4; }
34
+ .script-item.active { background: #e8f0fe; border-left: 3px solid #1a73e8; padding-left: 9px; }
35
+ .script-item .sname { font-size: 13px; font-weight: 600; color: #202124; }
36
+ .script-item .sdesc { font-size: 11px; color: #80868b; margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
37
+ .script-item .spath { font-size: 10px; color: #bdc1c6; margin-top: 2px; font-family: Consolas, monospace; }
38
+ .script-item.active .sname { color: #1a73e8; }
39
+ .script-count { padding: 8px 16px; border-top: 1px solid #f1f3f4; font-size: 11px; color: #80868b; flex-shrink: 0; }
40
+ .content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
41
+ .config-panel { background: #fff; border-bottom: 1px solid #e0e0e0; padding: 20px 24px; overflow-y: auto; flex-shrink: 0; max-height: 45vh; }
42
+ .config-panel .panel-title { font-size: 15px; font-weight: 600; color: #202124; margin-bottom: 4px; }
43
+ .config-panel .panel-desc { font-size: 12px; color: #80868b; margin-bottom: 16px; line-height: 1.5; }
44
+ .form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; margin-bottom: 16px; }
45
+ .form-group { display: flex; flex-direction: column; gap: 4px; }
46
+ .form-group label { font-size: 12px; font-weight: 500; color: #5f6368; display: flex; align-items: center; gap: 4px; }
47
+ .form-group label .req { color: #d93025; font-weight: 600; }
48
+ .form-group label .opt { color: #bdc1c6; font-size: 10px; }
49
+ .form-group label .hint { font-size: 10px; color: #bdc1c6; font-weight: 400; margin-left: auto; }
50
+ .form-group input { padding: 8px 12px; border: 1px solid #dadce0; border-radius: 6px; font-size: 13px; color: #202124; background: #fff; outline: none; font-family: inherit; }
51
+ .form-group input:focus { border-color: #1a73e8; box-shadow: 0 0 0 2px rgba(26,115,232,0.12); }
52
+ .upload-row { display: flex; align-items: flex-end; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
53
+ .upload-drop { flex: 1; min-width: 200px; border: 2px dashed #dadce0; border-radius: 8px; padding: 14px 16px; display: flex; align-items: center; gap: 10px; cursor: pointer; font-size: 13px; color: #80868b; }
54
+ .upload-drop:hover { border-color: #1a73e8; background: #f0f7ff; color: #1a73e8; }
55
+ .file-chips { display: flex; gap: 6px; flex-wrap: wrap; }
56
+ .file-chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; background: #f1f3f4; border: 1px solid #e0e0e0; border-radius: 16px; font-size: 11px; color: #5f6368; }
57
+ .file-chip .remove { width: 16px; height: 16px; border-radius: 50%; background: #e8eaed; color: #80868b; border: none; cursor: pointer; font-size: 10px; display: flex; align-items: center; justify-content: center; padding: 0; line-height: 1; }
58
+ .file-chip .remove:hover { background: #d93025; color: #fff; }
59
+ .action-row { display: flex; align-items: center; gap: 12px; }
60
+ .run-btn { padding: 9px 28px; background: #1a73e8; border: none; border-radius: 6px; color: #fff; font-size: 14px; font-weight: 500; cursor: pointer; font-family: inherit; display: flex; align-items: center; gap: 6px; }
61
+ .run-btn:hover { background: #1557b0; }
62
+ .run-btn:disabled { opacity: 0.5; cursor: not-allowed; }
63
+ .run-btn.running { background: #ea4335; }
64
+ .run-btn.running:hover { background: #c5221f; }
65
+ .action-hint { font-size: 11px; color: #bdc1c6; }
66
+ .terminal-panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; background: #1e1e1e; }
67
+ .terminal-header { display: flex; align-items: center; padding: 8px 16px; background: #252526; border-bottom: 1px solid #333; gap: 12px; flex-shrink: 0; }
68
+ .terminal-header .tab { font-size: 12px; color: #ccc; padding: 4px 10px; border-radius: 4px; background: #333; display: flex; align-items: center; gap: 6px; }
69
+ .terminal-header .tab.active { background: #1e1e1e; color: #fff; }
70
+ .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
71
+ .dot.idle { background: #80868b; }
72
+ .dot.running { background: #fbbc04; animation: pulse 1s infinite; }
73
+ .dot.done { background: #34a853; }
74
+ .dot.error { background: #ea4335; }
75
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
76
+ .terminal-header .status-text { font-size: 11px; color: #80868b; margin-left: auto; }
77
+ .terminal-header .clear-btn { padding: 3px 8px; border: 1px solid #555; border-radius: 4px; background: transparent; color: #80868b; font-size: 11px; cursor: pointer; font-family: inherit; }
78
+ .terminal-header .clear-btn:hover { background: #333; color: #ccc; }
79
+ .terminal-output { flex: 1; overflow-y: auto; padding: 12px 16px; font-family: "Cascadia Code","Fira Code",Consolas,monospace; font-size: 13px; line-height: 1.65; color: #d4d4d4; white-space: pre-wrap; word-break: break-all; }
80
+ .t-out { color: #d4d4d4; }
81
+ .t-err { color: #f48771; }
82
+ .t-info { color: #6a9955; }
83
+ .empty-center { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #80868b; gap: 8px; }
84
+ .empty-center .eicon { font-size: 40px; opacity: 0.5; }
85
+ .empty-center .etext { font-size: 14px; }
86
+ .empty-center .esub { font-size: 12px; color: #bdc1c6; }
87
+ ::-webkit-scrollbar { width: 6px; }
88
+ ::-webkit-scrollbar-track { background: transparent; }
89
+ ::-webkit-scrollbar-thumb { background: #dadce0; border-radius: 3px; }
90
+ .terminal-output::-webkit-scrollbar-thumb { background: #424242; }
91
+ </style>
92
+ </head>
93
+ <body>
94
+ <div class="titlebar">
95
+ <div class="logo"><span>&#x1F40D;</span> Python App Launcher</div>
96
+ <div class="path-tag" id="project-root">加载中...</div>
97
+ <div class="spacer"></div>
98
+ <button class="toolbar-btn" onclick="refreshAll()">&#x21BB; 刷新</button>
99
+ </div>
100
+ <div class="main-layout">
101
+ <div class="sidebar">
102
+ <div class="sidebar-header"><h2>脚本</h2>
103
+ <div class="search-box"><input type="text" id="search" placeholder="搜索脚本..." oninput="filterScripts()"></div>
104
+ </div>
105
+ <div class="script-list" id="script-list"></div>
106
+ <div class="script-count" id="script-count">0 个脚本</div>
107
+ </div>
108
+ <div class="content">
109
+ <div class="config-panel" id="config-panel">
110
+ <div class="empty-center">
111
+ <div class="eicon">&#x1F4C1;</div>
112
+ <div class="etext">选择左侧脚本开始配置</div>
113
+ <div class="esub">参数和文件上传在这里设置</div>
114
+ </div>
115
+ </div>
116
+ <div class="terminal-panel">
117
+ <div class="terminal-header">
118
+ <div class="tab active"><span class="dot idle" id="status-dot"></span> 输出</div>
119
+ <span class="status-text" id="status-text">就绪</span>
120
+ <button class="clear-btn" onclick="clearOutput()">清空</button>
121
+ </div>
122
+ <div class="terminal-output" id="terminal-output"><span class="t-info">等待执行...</span>
123
+ </div>
124
+ </div>
125
+ </div>
126
+ </div>
127
+ <input type="file" id="file-input" multiple style="display:none">
128
+ <script>
129
+ var allScripts=[],selectedScript=null,uploadedFiles=[],abortCtrl=null;
130
+ function loadScripts(){fetch('/api/scripts').then(function(r){return r.json()}).then(function(d){allScripts=d.scripts;document.getElementById('project-root').textContent=d.root;renderScriptList()})}
131
+ function renderScriptList(){var q=document.getElementById('search').value.toLowerCase();var f=allScripts.filter(function(s){return!q||s.name.toLowerCase().indexOf(q)>=0||s.docstring.toLowerCase().indexOf(q)>=0});var el=document.getElementById('script-list');if(!f.length){el.innerHTML='<div style="padding:40px;text-align:center;color:#80868b">未找到脚本</div>';document.getElementById('script-count').textContent='0 个脚本';return}var h='';for(var i=0;i<f.length;i++){var s=f[i],c=selectedScript&&selectedScript.name===s.name?' active':'',d=s.docstring.split('\n')[0];h+='<div class="script-item'+c+'" onclick="selectScript(\''+s.name+'\')"><div class="sname">'+s.name+'</div><div class="sdesc">'+d+'</div><div class="spath">'+s.path+'</div></div>'}el.innerHTML=h;document.getElementById('script-count').textContent=f.length+' 个脚本'}
132
+ function filterScripts(){renderScriptList()}
133
+ function selectScript(name){for(var i=0;i<allScripts.length;i++){if(allScripts[i].name===name){selectedScript=allScripts[i];break}}renderScriptList();renderConfig()}
134
+ function renderConfig(){var s=selectedScript;if(!s)return;var p=document.getElementById('config-panel');var h='<div class="panel-title">&#x2699;&#xFE0F; '+s.name+'</div><div class="panel-desc">'+s.docstring+'</div>';if(s.params.length){h+='<div class="form-grid">';for(var i=0;i<s.params.length;i++){var p2=s.params[i],r=p2.required?'<span class="req">*</span>':'<span class="opt">可选</span>',v=p2.default!==null?' value="'+p2.default+'"':'',ph=p2.default!==null?String(p2.default):p2.type;h+='<div class="form-group"><label>'+p2.name+' '+r+'<span class="hint">'+p2.type+'</span></label><input type="text" id="param-'+p2.name+'"'+v+' placeholder="'+ph+'"></div>'}h+='</div>'}h+='<div class="upload-row"><div class="upload-drop" onclick="document.getElementById(\'file-input\').click()"><span>&#x1F4CE;</span> 点击上传文件</div><div class="file-chips" id="file-chips"></div></div>';h+='<div class="action-row"><button class="run-btn" id="run-btn" onclick="runScript()">&#x25B6; 运行</button><span class="action-hint">Ctrl+C 取消运行</span></div>';p.innerHTML=h;renderFileChips()}
135
+ document.getElementById('file-input').addEventListener('change',function(e){for(var i=0;i<e.target.files.length;i++){(function(file){var fd=new FormData();fd.append('file',file);fetch('/api/upload',{method:'POST',body:fd}).then(function(r){return r.json()}).then(function(d){uploadedFiles.push(d);renderFileChips()})})(e.target.files[i])}e.target.value=''});
136
+ function renderFileChips(){var el=document.getElementById('file-chips');if(!el)return;var h='';for(var i=0;i<uploadedFiles.length;i++){h+='<span class="file-chip">'+uploadedFiles[i].filename+' <button class="remove" onclick="removeFile('+i+')">&#x00D7;</button></span>'}el.innerHTML=h}
137
+ function removeFile(i){uploadedFiles.splice(i,1);renderFileChips()}
138
+ function runScript(){if(!selectedScript)return;var btn=document.getElementById('run-btn'),dot=document.getElementById('status-dot'),stxt=document.getElementById('status-text'),out=document.getElementById('terminal-output');var args={};for(var i=0;i<selectedScript.params.length;i++){var p=selectedScript.params[i],el=document.getElementById('param-'+p.name);if(el&&el.value)args[p.name]=el.value}var fd=new FormData();fd.append('script',selectedScript.name);fd.append('args_json',JSON.stringify(args));fd.append('file_ids',uploadedFiles.map(function(f){return f.file_id}).join(','));btn.disabled=true;btn.className='run-btn running';btn.innerHTML='&#x25A0; 停止';out.innerHTML='';dot.className='dot running';stxt.textContent='运行中...';abortCtrl=new AbortController();fetch('/api/run',{method:'POST',body:fd,signal:abortCtrl.signal}).then(function(res){var reader=res.body.getReader(),decoder=new TextDecoder(),buf='';function read(){return reader.read().then(function(r){if(r.done){finishRun();return}buf+=decoder.decode(r.value,{stream:true});var lines=buf.split('\n');buf=lines.pop();for(var i=0;i<lines.length;i++){var line=lines[i];if(line.indexOf('data: ')!==0)continue;var data=line.slice(6);if(data.indexOf('__EXIT__:')===0){var code=parseInt(data.split(':')[1]);dot.className=code===0?'dot done':'dot error';stxt.textContent=code===0?'完成 (exit 0)':'失败 (exit '+code+')'}else{appendLine(data)}}return read()})}return read()}).catch(function(e){if(e.name!=='AbortError'){appendLine('ERROR: '+e.message,'err');dot.className='dot error';stxt.textContent='错误'}finishRun()})}
139
+ function finishRun(){var btn=document.getElementById('run-btn');btn.disabled=false;btn.className='run-btn';btn.innerHTML='&#x25B6; 运行'}
140
+ function appendLine(text,forceType){var el=document.getElementById('terminal-output'),span=document.createElement('span'),t=forceType||'out';if(!forceType&&text.indexOf('[ERR] ')===0){t='err';text=text.slice(6)}else if(!forceType&&text.indexOf('[OUT] ')===0){t='out';text=text.slice(6)}span.className='t-'+t;span.textContent=text;el.appendChild(span);el.appendChild(document.createTextNode('\n'));el.scrollTop=el.scrollHeight}
141
+ function clearOutput(){document.getElementById('terminal-output').innerHTML=''}
142
+ function refreshAll(){loadScripts();if(selectedScript)renderConfig()}
143
+ document.addEventListener('keydown',function(e){if(e.ctrlKey&&e.key==='c'&&abortCtrl)abortCtrl.abort()});
144
+ loadScripts();
145
+ </script>
146
+ </body>
147
+ </html>