PyREUser3 0.1.0__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,178 @@
1
+ """本地 Web UI 的 HTTP 请求处理。
2
+
3
+ 通过工厂函数 :func:`make_handler` 把服务配置、任务仓库和转换桥接器绑定到一个
4
+ ``BaseHTTPRequestHandler`` 子类上,处理单页应用首页、任务查询 API、路径选择与
5
+ 导出任务提交等请求。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from http.server import BaseHTTPRequestHandler
12
+ from typing import Any
13
+ from urllib.parse import urlparse
14
+
15
+ from .jobs import JobStore
16
+ from .page import INDEX_HTML
17
+ from .picker import pick_path
18
+ from .runners import ConversionRunners
19
+ from .settings import WebSettings
20
+
21
+
22
+ def make_handler(
23
+ settings: WebSettings,
24
+ jobs: JobStore,
25
+ runners: ConversionRunners,
26
+ ) -> type[BaseHTTPRequestHandler]:
27
+ """创建绑定当前服务状态的请求处理类。
28
+
29
+ 参数:
30
+ settings (WebSettings): 服务运行配置。
31
+ jobs (JobStore): 后台任务仓库。
32
+ runners (ConversionRunners): 把 Web 表单参数桥接到核心转换器的执行器。
33
+
34
+ 返回:
35
+ type[BaseHTTPRequestHandler]: 一个已闭包捕获上述依赖的请求处理类,供
36
+ ``ThreadingHTTPServer`` 实例化。
37
+ """
38
+
39
+ class WebHandler(BaseHTTPRequestHandler):
40
+ """处理静态首页和本地 JSON API。"""
41
+
42
+ def log_message(self, format: str, *args: Any) -> None:
43
+ """输出简洁的请求日志。
44
+
45
+ 参数:
46
+ format (str): 标准库格式字符串。
47
+ *args (Any): 与 ``format`` 对应的参数。
48
+
49
+ 返回:
50
+ None: 直接打印到标准输出。
51
+ """
52
+ print(f"{self.address_string()} - {format % args}")
53
+
54
+ def do_GET(self) -> None:
55
+ """处理首页、任务列表和任务详情的 GET 请求。
56
+
57
+ 返回:
58
+ None: 通过写入响应体返回结果;未匹配路径返回 404。
59
+ """
60
+ path = urlparse(self.path).path
61
+ if path == "/":
62
+ # 首页是单页应用,所有前端资源都内嵌在模板里。
63
+ self._send_html(INDEX_HTML)
64
+ return
65
+ if path == "/api/jobs":
66
+ # 任务列表不包含完整日志,避免轮询时响应体过大。
67
+ self._handle_jobs()
68
+ return
69
+ if path.startswith("/api/jobs/"):
70
+ # 任务详情包含日志,用于右侧日志面板刷新。
71
+ self._handle_job(path.rsplit("/", 1)[-1])
72
+ return
73
+ self._send_json(404, {"error": "未找到请求路径"})
74
+
75
+ def do_POST(self) -> None:
76
+ """处理路径选择和导出任务提交的 POST 请求。
77
+
78
+ 返回:
79
+ None: 通过写入响应体返回结果;请求异常时返回 400,未匹配返回 404。
80
+ """
81
+ path = urlparse(self.path).path
82
+ try:
83
+ payload = self._read_json()
84
+ if path == "/api/pick-path":
85
+ # 只有用户主动点击选择按钮时才打开本机文件/目录对话框。
86
+ self._send_json(200, pick_path(payload))
87
+ return
88
+ if path == "/api/export":
89
+ # 只提交任务,实际转换由后台线程执行。
90
+ job = jobs.start("export", payload, runners.run_export)
91
+ self._send_json(202, {"jobId": job.id})
92
+ return
93
+ self._send_json(404, {"error": "未找到请求路径"})
94
+ except Exception as exc:
95
+ # 请求体解析失败或参数结构异常时直接返回 400。
96
+ self._send_json(400, {"error": f"{exc.__class__.__name__}: {exc}"})
97
+
98
+ def _read_json(self) -> dict[str, Any]:
99
+ """读取并校验 JSON 请求体。
100
+
101
+ 返回:
102
+ dict[str, Any]: 解析后的请求体对象;无请求体时返回空字典。
103
+
104
+ 异常:
105
+ ValueError: 当请求体不是 JSON 对象时抛出。
106
+ """
107
+ length = int(self.headers.get("Content-Length", "0") or "0")
108
+ raw = self.rfile.read(length) if length else b"{}"
109
+ if not raw:
110
+ return {}
111
+ data = json.loads(raw.decode("utf-8"))
112
+ if not isinstance(data, dict):
113
+ raise ValueError("请求体必须是 JSON 对象")
114
+ return data
115
+
116
+ def _handle_jobs(self) -> None:
117
+ """返回当前任务列表和 Web 根目录。
118
+
119
+ 返回:
120
+ None: 以 JSON 响应输出任务列表(不含日志)和根目录。
121
+ """
122
+ payload = {
123
+ "jobs": [
124
+ jobs.serialize(job, include_logs=False) for job in jobs.list_jobs()
125
+ ],
126
+ "rootDir": str(settings.root_dir),
127
+ }
128
+ self._send_json(200, payload)
129
+
130
+ def _handle_job(self, job_id: str) -> None:
131
+ """返回单个任务详情。
132
+
133
+ 参数:
134
+ job_id (str): 任务唯一标识。
135
+
136
+ 返回:
137
+ None: 以 JSON 响应输出含日志的任务详情;不存在时返回 404。
138
+ """
139
+ job = jobs.get(job_id)
140
+ if job is None:
141
+ self._send_json(404, {"error": "任务不存在"})
142
+ return
143
+ self._send_json(200, {"job": jobs.serialize(job, include_logs=True)})
144
+
145
+ def _send_html(self, html: str) -> None:
146
+ """发送 HTML 响应。
147
+
148
+ 参数:
149
+ html (str): 要返回的 HTML 文本。
150
+
151
+ 返回:
152
+ None: 写出状态行、响应头和正文。
153
+ """
154
+ data = html.encode("utf-8")
155
+ self.send_response(200)
156
+ self.send_header("Content-Type", "text/html; charset=utf-8")
157
+ self.send_header("Content-Length", str(len(data)))
158
+ self.end_headers()
159
+ self.wfile.write(data)
160
+
161
+ def _send_json(self, status: int, payload: dict[str, Any]) -> None:
162
+ """发送 JSON 响应。
163
+
164
+ 参数:
165
+ status (int): HTTP 状态码。
166
+ payload (dict[str, Any]): 要序列化为 JSON 的响应体。
167
+
168
+ 返回:
169
+ None: 写出状态行、响应头和正文。
170
+ """
171
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
172
+ self.send_response(status)
173
+ self.send_header("Content-Type", "application/json; charset=utf-8")
174
+ self.send_header("Content-Length", str(len(data)))
175
+ self.end_headers()
176
+ self.wfile.write(data)
177
+
178
+ return WebHandler
pyreuser3/web/jobs.py ADDED
@@ -0,0 +1,243 @@
1
+ """本地 Web UI 的后台任务管理。
2
+
3
+ 转换任务可能耗时较长,因此放到后台线程执行。本模块提供线程安全的内存任务
4
+ 仓库 :class:`JobStore`:负责创建任务、启动工作线程、记录日志与状态、序列化给
5
+ 前端,并在超出上限时清理旧任务。任务记录仅存于内存,进程退出即丢失。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import threading
11
+ import time
12
+ import uuid
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, Callable
15
+
16
+
17
+ @dataclass
18
+ class Job:
19
+ """浏览器提交的一次导出任务。
20
+
21
+ 属性:
22
+ id (str): 任务唯一标识(短十六进制串)。
23
+ kind (str): 任务类型(如 ``"export"``)。
24
+ status (str): 任务状态:``queued`` / ``running`` / ``done`` / ``failed``。
25
+ created_at (float): 创建时间戳(秒)。
26
+ updated_at (float): 最近更新时间戳(秒)。
27
+ logs (list[str]): 带时间前缀的日志行列表。
28
+ result (dict[str, Any] | None): 成功时的结果数据。
29
+ error (str | None): 失败时的错误描述。
30
+ """
31
+
32
+ id: str
33
+ kind: str
34
+ status: str = "queued"
35
+ created_at: float = field(default_factory=time.time)
36
+ updated_at: float = field(default_factory=time.time)
37
+ logs: list[str] = field(default_factory=list)
38
+ result: dict[str, Any] | None = None
39
+ error: str | None = None
40
+
41
+
42
+ # 任务执行函数签名:接收 (载荷, 日志回调),返回结果字典。
43
+ Runner = Callable[[dict[str, Any], Callable[[str], None]], dict[str, Any]]
44
+
45
+
46
+ class JobStore:
47
+ """线程安全的内存任务仓库。
48
+
49
+ 使用一把互斥锁保护任务字典,对外暴露的读取方法均返回任务克隆,避免调用方
50
+ 在锁外读取时碰到并发修改。
51
+ """
52
+
53
+ def __init__(self, max_jobs: int = 50) -> None:
54
+ """初始化任务仓库。
55
+
56
+ 参数:
57
+ max_jobs (int): 内存中保留的最大任务数量,超出时清理最旧任务。
58
+
59
+ 返回:
60
+ None: 构造函数,仅初始化内部状态。
61
+ """
62
+ # Web UI 只用于本地临时操作,任务记录无需持久化到磁盘。
63
+ self.max_jobs = max_jobs
64
+ self._jobs: dict[str, Job] = {}
65
+ self._lock = threading.Lock()
66
+
67
+ def start(self, kind: str, payload: dict[str, Any], runner: Runner) -> Job:
68
+ """创建后台任务并立即启动线程。
69
+
70
+ 参数:
71
+ kind (str): 任务类型标识。
72
+ payload (dict[str, Any]): 传给 ``runner`` 的请求载荷。
73
+ runner (Runner): 实际执行任务的可调用对象。
74
+
75
+ 返回:
76
+ Job: 新建的任务对象(其状态会由后台线程异步更新)。
77
+ """
78
+ # 任务 ID 不需要可预测,只需要足够短、方便在前端显示。
79
+ job = Job(id=uuid.uuid4().hex[:12], kind=kind)
80
+ with self._lock:
81
+ self._jobs[job.id] = job
82
+ self._cleanup_locked()
83
+
84
+ # 转换任务可能耗时很长,因此必须放到后台线程中执行。
85
+ thread = threading.Thread(
86
+ target=self._run_worker,
87
+ args=(job, payload, runner),
88
+ daemon=True,
89
+ )
90
+ thread.start()
91
+ return job
92
+
93
+ def list_jobs(self) -> list[Job]:
94
+ """按创建时间倒序返回任务快照。
95
+
96
+ 返回:
97
+ list[Job]: 任务克隆列表,最新创建的排在最前。
98
+ """
99
+ with self._lock:
100
+ # 返回克隆对象,避免 HTTP 层在锁外读取时碰到并发修改。
101
+ return sorted(
102
+ (self._clone(job) for job in self._jobs.values()),
103
+ key=lambda job: job.created_at,
104
+ reverse=True,
105
+ )
106
+
107
+ def get(self, job_id: str) -> Job | None:
108
+ """按任务 ID 返回单个任务快照。
109
+
110
+ 参数:
111
+ job_id (str): 任务唯一标识。
112
+
113
+ 返回:
114
+ Job | None: 任务克隆;不存在时返回 ``None``。
115
+ """
116
+ with self._lock:
117
+ job = self._jobs.get(job_id)
118
+ return self._clone(job) if job is not None else None
119
+
120
+ def serialize(self, job: Job, include_logs: bool = False) -> dict[str, Any]:
121
+ """把任务对象转换成可 JSON 序列化的字典。
122
+
123
+ 参数:
124
+ job (Job): 待序列化的任务对象。
125
+ include_logs (bool): 是否包含日志列表(任务列表接口通常不含日志以减小体积)。
126
+
127
+ 返回:
128
+ dict[str, Any]: 使用驼峰字段名的任务字典,便于前端直接消费。
129
+ """
130
+ # 前端使用驼峰字段名,因此这里统一完成字段名转换。
131
+ data: dict[str, Any] = {
132
+ "id": job.id,
133
+ "kind": job.kind,
134
+ "status": job.status,
135
+ "createdAt": job.created_at,
136
+ "updatedAt": job.updated_at,
137
+ "result": job.result,
138
+ "error": job.error,
139
+ }
140
+ if include_logs:
141
+ data["logs"] = list(job.logs)
142
+ return data
143
+
144
+ def _run_worker(self, job: Job, payload: dict[str, Any], runner: Runner) -> None:
145
+ """在后台线程中执行具体任务。
146
+
147
+ 参数:
148
+ job (Job): 当前任务对象。
149
+ payload (dict[str, Any]): 传给 ``runner`` 的请求载荷。
150
+ runner (Runner): 实际执行任务的可调用对象。
151
+
152
+ 返回:
153
+ None: 通过更新任务状态/日志反馈执行结果。
154
+ """
155
+ self._set_job(job.id, status="running")
156
+ self._log(job.id, "任务已启动。")
157
+ try:
158
+ # runner 接收一个日志回调,便于转换流程把阶段性信息写回任务。
159
+ result = runner(payload, lambda message: self._log(job.id, message))
160
+ except Exception as exc:
161
+ # 单个任务失败只更新任务状态,不影响 HTTP 服务和其他任务。
162
+ error = f"{exc.__class__.__name__}: {exc}"
163
+ self._set_job(job.id, status="failed", error=error)
164
+ self._log(job.id, f"任务失败:{error}")
165
+ return
166
+ self._set_job(job.id, status="done", result=result)
167
+ self._log(job.id, "任务完成。")
168
+
169
+ def _set_job(self, job_id: str, **changes: Any) -> None:
170
+ """在锁内更新任务字段。
171
+
172
+ 参数:
173
+ job_id (str): 任务唯一标识。
174
+ **changes (Any): 要写入任务对象的字段名到新值的映射。
175
+
176
+ 返回:
177
+ None: 原地更新任务并刷新 ``updated_at``;任务不存在时直接返回。
178
+ """
179
+ with self._lock:
180
+ job = self._jobs.get(job_id)
181
+ if job is None:
182
+ return
183
+ for key, value in changes.items():
184
+ setattr(job, key, value)
185
+ job.updated_at = time.time()
186
+
187
+ def _log(self, job_id: str, message: str) -> None:
188
+ """在锁内追加一条任务日志。
189
+
190
+ 参数:
191
+ job_id (str): 任务唯一标识。
192
+ message (str): 日志文本(会自动加上 ``[时:分:秒]`` 前缀)。
193
+
194
+ 返回:
195
+ None: 原地追加日志并刷新 ``updated_at``;任务不存在时直接返回。
196
+ """
197
+ with self._lock:
198
+ job = self._jobs.get(job_id)
199
+ if job is None:
200
+ return
201
+ job.logs.append(f"[{time.strftime('%H:%M:%S')}] {message}")
202
+ job.updated_at = time.time()
203
+
204
+ def _cleanup_locked(self) -> None:
205
+ """在锁内移除超出保留上限的旧任务。
206
+
207
+ 调用方必须已持有 ``self._lock``。
208
+
209
+ 返回:
210
+ None: 原地裁剪任务字典,仅保留最新的 ``max_jobs`` 个任务。
211
+ """
212
+ if len(self._jobs) <= self.max_jobs:
213
+ return
214
+ ordered = sorted(
215
+ self._jobs.values(),
216
+ key=lambda job: job.created_at,
217
+ reverse=True,
218
+ )
219
+ keep = {job.id for job in ordered[: self.max_jobs]}
220
+ for job_id in list(self._jobs):
221
+ if job_id not in keep:
222
+ self._jobs.pop(job_id, None)
223
+
224
+ @staticmethod
225
+ def _clone(job: Job) -> Job:
226
+ """复制任务对象,隔离调用方和内部存储。
227
+
228
+ 参数:
229
+ job (Job): 源任务对象。
230
+
231
+ 返回:
232
+ Job: 字段值相同、但日志列表为独立副本的新任务对象。
233
+ """
234
+ return Job(
235
+ id=job.id,
236
+ kind=job.kind,
237
+ status=job.status,
238
+ created_at=job.created_at,
239
+ updated_at=job.updated_at,
240
+ logs=list(job.logs),
241
+ result=job.result,
242
+ error=job.error,
243
+ )
pyreuser3/web/page.py ADDED
@@ -0,0 +1,221 @@
1
+ """本地 Vue Web UI 使用的 HTML、CSS 和 TypeScript 模板。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ STYLE_CSS = r"""
6
+ :root{--bg:#f7f7f4;--panel:#fff;--text:#1c1d1f;--muted:#697078;--line:#d8ddd6;--accent:#176f6b;--red:#b0332e;--green:#227343;--blue:#315f9b}
7
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:"Segoe UI","Microsoft YaHei",Arial,sans-serif;font-size:14px;letter-spacing:0}
8
+ button,input,textarea{font:inherit}.topbar{border-bottom:1px solid var(--line);background:#fffefb}.topbar-inner{max-width:1180px;margin:0 auto;padding:16px 20px;display:flex;align-items:center;justify-content:space-between;gap:16px}
9
+ h1{margin:0;font-size:20px;line-height:1.2}.sub{margin:4px 0 0;color:var(--muted);line-height:1.4}.pill{border:1px solid var(--line);border-radius:999px;padding:6px 11px;background:#f3f7f1;color:#10514e;white-space:nowrap}
10
+ main{max-width:1180px;margin:0 auto;padding:18px 20px 24px;display:grid;grid-template-columns:minmax(0,1fr) minmax(340px,.82fr);gap:18px}.panel{background:var(--panel);border:1px solid var(--line);border-radius:8px;min-width:0}
11
+ .head{padding:12px 14px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;gap:12px}.head h2{margin:0;font-size:15px}.form{padding:16px;display:grid;gap:14px}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.inline{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.field{display:grid;gap:6px;min-width:0}.wide{grid-column:1/-1}
12
+ label{color:#32363a;font-size:13px;font-weight:650}input,textarea{width:100%;border:1px solid #cfd7cf;border-radius:7px;background:#fff;color:var(--text);padding:9px 10px;outline:none;min-width:0}input[readonly]{background:#f7f8f5;color:#303437}input:focus,textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(23,111,107,.14)}textarea{min-height:88px;resize:vertical;line-height:1.45}
13
+ .path-row{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:8px}.actions{display:flex;align-items:center;justify-content:space-between;gap:12px;padding-top:4px}.primary{border:1px solid #10514e;background:var(--accent);color:#fff;border-radius:7px;padding:10px 15px;min-width:112px;cursor:pointer;font-weight:700}.secondary{border:1px solid var(--line);background:#fbfcfa;color:#263433;border-radius:7px;padding:9px 11px;white-space:nowrap;cursor:pointer}.primary:disabled,.secondary:disabled{cursor:not-allowed;opacity:.62}.notice{color:var(--red);line-height:1.45;word-break:break-word}
14
+ .jobs{max-height:244px;overflow:auto}.job{width:100%;border:0;border-bottom:1px solid var(--line);background:transparent;padding:11px 14px;display:grid;grid-template-columns:1fr auto;gap:8px;text-align:left;cursor:pointer}.job:hover,.job.active{background:#f2f7f2}.job-title{font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.time{margin-top:3px;color:var(--muted);font-size:12px}
15
+ .badge{align-self:start;border-radius:999px;padding:4px 8px;font-size:12px;border:1px solid var(--line);white-space:nowrap}.queued,.running{color:var(--blue);background:#eef4fb;border-color:#cbd9ee}.done{color:var(--green);background:#eef8f1;border-color:#c8e2cf}.failed{color:var(--red);background:#fff0ef;border-color:#e8cbc8}
16
+ .detail{padding:14px;display:grid;gap:12px;min-height:360px}.metrics{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.metric{border:1px solid var(--line);border-radius:7px;padding:10px;background:#fbfcfa}.metric span{display:block;color:var(--muted);font-size:12px;margin-bottom:5px}.metric strong{font-size:18px}
17
+ pre{margin:0;padding:12px;border:1px solid var(--line);border-radius:7px;background:#1f2424;color:#f4f6ee;min-height:180px;max-height:420px;overflow:auto;line-height:1.5;white-space:pre-wrap;word-break:break-word}.empty,.muted{color:var(--muted)}
18
+ @media(max-width:980px){main{grid-template-columns:1fr}}@media(max-width:680px){.topbar-inner,.actions{align-items:stretch;flex-direction:column}.grid,.inline,.metrics,.path-row{grid-template-columns:1fr}main{padding:12px}.primary,.secondary{width:100%}}
19
+ """
20
+
21
+ APP_TS = r"""
22
+ declare const Vue:any;
23
+ type JobStatus="queued"|"running"|"done"|"failed";
24
+ interface Job{id:string;kind:string;status:JobStatus;createdAt:number;updatedAt:number;logs?:string[];result?:Record<string,any>|null;error?:string|null}
25
+ interface JobsResponse{jobs:Job[];rootDir:string}
26
+ const{createApp,computed,nextTick,onMounted,reactive,ref,watch}=Vue;
27
+
28
+ async function requestJson<T>(url:string,init?:RequestInit):Promise<T>{
29
+ const response=await fetch(url,init);
30
+ const body=await response.json().catch(()=>({}));
31
+ if(!response.ok)throw new Error(body.error||response.statusText);
32
+ return body as T;
33
+ }
34
+
35
+ function collectTotals(result:Record<string,any>|null|undefined){
36
+ const item=result&&result.user3?result.user3:{};
37
+ return{total:Number(item.total||0),success:Number(item.success||0),failed:Number(item.failed||0)};
38
+ }
39
+
40
+ createApp({
41
+ setup(){
42
+ const activeJobId=ref<string|null>(null);
43
+ const busy=ref(false);
44
+ const connectionLabel=ref("已连接");
45
+ const jobs=ref<Job[]>([]);
46
+ const logBox=ref<HTMLElement|null>(null);
47
+ const logRenderState=reactive({jobId:null as string|null,text:""});
48
+ const notice=ref("");
49
+ const rootDir=ref("");
50
+ const exportForm=reactive({
51
+ inputDir:"",schemaPath:"",outputDir:"",
52
+ il2cppDumpPath:"",treeDepth:"auto",excludeRegexes:"",
53
+ userMagic:"0x00525355",rszMagic:"0x005A5352"
54
+ });
55
+
56
+ const activeJob=computed<Job|null>(()=>jobs.value.find(job=>job.id===activeJobId.value)||null);
57
+ const activeTotals=computed(()=>collectTotals(activeJob.value?.result));
58
+ function jobLogText(job:Job|null){
59
+ if(!job)return "选择或启动一个任务。";
60
+ const lines=job.logs?[...job.logs]:[];
61
+ if(job.error)lines.push(`[错误] ${job.error}`);
62
+ if(job.result)lines.push(JSON.stringify(job.result,null,2));
63
+ return lines.join("\n")||"任务已提交,等待日志。";
64
+ }
65
+ function isLogNearBottom(element:HTMLElement){
66
+ return element.scrollHeight-element.scrollTop-element.clientHeight<24;
67
+ }
68
+ function scrollLogToBottom(element:HTMLElement){
69
+ window.requestAnimationFrame(()=>{element.scrollTop=element.scrollHeight});
70
+ }
71
+ function renderActiveLog(){
72
+ const element=logBox.value;
73
+ if(!element)return;
74
+ const job=activeJob.value;
75
+ const text=jobLogText(job);
76
+ const jobId=job?.id||null;
77
+ const previous=logRenderState.jobId===jobId?logRenderState.text:"";
78
+ const switchedJob=logRenderState.jobId!==jobId;
79
+ const shouldFollow=switchedJob||isLogNearBottom(element);
80
+
81
+ if(switchedJob||!previous||!text.startsWith(previous)){
82
+ element.textContent=text;
83
+ }else if(text.length>previous.length){
84
+ element.insertAdjacentText("beforeend",text.slice(previous.length));
85
+ }else{
86
+ return;
87
+ }
88
+
89
+ logRenderState.jobId=jobId;
90
+ logRenderState.text=text;
91
+ if(shouldFollow)scrollLogToBottom(element);
92
+ }
93
+
94
+ function mergeJob(job:Job){
95
+ const index=jobs.value.findIndex(item=>item.id===job.id);
96
+ if(index>=0)jobs.value[index]={...jobs.value[index],...job};
97
+ else jobs.value.unshift(job);
98
+ }
99
+ function mergeJobList(incoming:Job[]){
100
+ jobs.value=incoming.map(job=>{
101
+ const current=jobs.value.find(item=>item.id===job.id);
102
+ return current?{...current,...job,logs:job.logs||current.logs}:job;
103
+ });
104
+ }
105
+ async function refreshActive(){
106
+ if(!activeJobId.value)return;
107
+ try{mergeJob((await requestJson<{job:Job}>(`/api/jobs/${activeJobId.value}`)).job)}catch{}
108
+ }
109
+ async function refreshJobs(){
110
+ try{
111
+ const data=await requestJson<JobsResponse>("/api/jobs");
112
+ rootDir.value=data.rootDir;
113
+ mergeJobList(data.jobs);
114
+ if(!activeJobId.value&&jobs.value.length)activeJobId.value=jobs.value[0].id;
115
+ await refreshActive();
116
+ connectionLabel.value="已连接";
117
+ }catch{connectionLabel.value="连接中断"}
118
+ }
119
+ async function submitExport(){
120
+ notice.value="";busy.value=true;
121
+ try{
122
+ const data=await requestJson<{jobId:string}>("/api/export",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({...exportForm})});
123
+ activeJobId.value=data.jobId;
124
+ await refreshActive();
125
+ }catch(error){notice.value=error instanceof Error?error.message:String(error)}
126
+ finally{busy.value=false}
127
+ }
128
+ async function pickPath(form:Record<string,any>,key:string,kind:"file"|"directory",title:string,filetypes?:string[][]){
129
+ notice.value="";
130
+ try{
131
+ const data=await requestJson<{path:string}>("/api/pick-path",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({kind,title,filetypes:filetypes||[]})});
132
+ if(data.path)form[key]=data.path;
133
+ }catch(error){notice.value=error instanceof Error?error.message:String(error)}
134
+ }
135
+ function selectJob(jobId:string){activeJobId.value=jobId;refreshActive()}
136
+ function jobName(job:Job){return `导出 #${job.id}`}
137
+ function statusLabel(status:JobStatus){return {queued:"排队",running:"运行中",done:"完成",failed:"失败"}[status]||status}
138
+ function formatTime(value:number){return value?new Date(value*1000).toLocaleString():""}
139
+
140
+ watch(activeJob,()=>{nextTick(renderActiveLog)},{deep:true,immediate:true});
141
+ onMounted(()=>{renderActiveLog();refreshJobs();window.setInterval(refreshJobs,1200)});
142
+ return{activeJob,activeJobId,activeTotals,busy,connectionLabel,exportForm,formatTime,jobName,jobs,logBox,notice,pickPath,rootDir,selectJob,statusLabel,submitExport};
143
+ }
144
+ }).mount("#app");
145
+ """
146
+
147
+ INDEX_HTML = (
148
+ r"""<!doctype html>
149
+ <html lang="zh-CN">
150
+ <head>
151
+ <meta charset="utf-8">
152
+ <meta name="viewport" content="width=device-width, initial-scale=1">
153
+ <title>RE User3 JSON Web</title>
154
+ <style>"""
155
+ + STYLE_CSS
156
+ + r"""</style>
157
+ </head>
158
+ <body>
159
+ <div id="app">
160
+ <header class="topbar"><div class="topbar-inner">
161
+ <div><h1>RE User3 JSON Web</h1><p class="sub">仅支持 .user.3 解包导出,所有路径请通过选择按钮指定。</p></div>
162
+ <div class="pill">{{ connectionLabel }}</div>
163
+ </div></header>
164
+
165
+ <main>
166
+ <section class="panel">
167
+ <div class="head"><h2>导出 .user.3 为 JSON</h2><span class="muted">解包任务</span></div>
168
+ <form class="form" @submit.prevent="submitExport">
169
+ <div class="grid">
170
+ <div class="field wide"><label>输入目录或 .user.3 文件</label><div class="path-row"><input v-model="exportForm.inputDir" readonly placeholder="请选择目录或 .user.3 文件"><button class="secondary" type="button" @click="pickPath(exportForm,'inputDir','directory','选择包含 .user.3 的目录')">选择目录</button><button class="secondary" type="button" @click="pickPath(exportForm,'inputDir','file','选择 .user.3 文件',[['user3 文件','*.user.3'],['所有文件','*.*']])">选择文件</button></div></div>
171
+ <div class="field wide"><label>RE_RSZ 模板 JSON</label><div class="path-row"><input v-model="exportForm.schemaPath" readonly placeholder="请选择 RE_RSZ 模板 JSON"><button class="secondary" type="button" @click="pickPath(exportForm,'schemaPath','file','选择 RE_RSZ 模板 JSON',[['JSON 文件','*.json'],['所有文件','*.*']])">选择文件</button></div></div>
172
+ <div class="field wide"><label>JSON 输出目录</label><div class="path-row"><input v-model="exportForm.outputDir" readonly placeholder="请选择 JSON 输出目录"><button class="secondary" type="button" @click="pickPath(exportForm,'outputDir','directory','选择 JSON 输出目录')">选择目录</button></div></div>
173
+ <div class="field wide"><label>il2cpp_dump.json</label><div class="path-row"><input v-model="exportForm.il2cppDumpPath" readonly placeholder="请选择 il2cpp_dump.json"><button class="secondary" type="button" @click="pickPath(exportForm,'il2cppDumpPath','file','选择 il2cpp_dump.json',[['JSON 文件','*.json'],['所有文件','*.*']])">选择文件</button></div></div>
174
+ </div>
175
+ <div class="inline">
176
+ <div class="field"><label>树深度</label><input v-model="exportForm.treeDepth" autocomplete="off"></div>
177
+ <div class="field"><label>USR magic</label><input v-model="exportForm.userMagic" autocomplete="off"></div>
178
+ <div class="field"><label>RSZ magic</label><input v-model="exportForm.rszMagic" autocomplete="off"></div>
179
+ </div>
180
+ <div class="field"><label>排除正则</label><textarea v-model="exportForm.excludeRegexes" spellcheck="false"></textarea></div>
181
+ <div class="actions"><button class="primary" type="submit" :disabled="busy">开始导出</button><div class="notice">{{ notice }}</div></div>
182
+ </form>
183
+ </section>
184
+
185
+ <aside class="panel">
186
+ <div class="head"><h2>任务</h2><span class="muted">{{ jobs.length }} 个</span></div>
187
+ <div v-if="jobs.length" class="jobs">
188
+ <button v-for="job in jobs" :key="job.id" class="job" :class="{active:activeJobId===job.id}" @click="selectJob(job.id)">
189
+ <span><span class="job-title">{{ jobName(job) }}</span><span class="time">{{ formatTime(job.updatedAt) }}</span></span>
190
+ <span class="badge" :class="job.status">{{ statusLabel(job.status) }}</span>
191
+ </button>
192
+ </div>
193
+ <div v-else class="empty">暂无任务。</div>
194
+ <div class="detail">
195
+ <div class="metrics">
196
+ <div class="metric"><span>总数</span><strong>{{ activeTotals.total }}</strong></div>
197
+ <div class="metric"><span>成功</span><strong>{{ activeTotals.success }}</strong></div>
198
+ <div class="metric"><span>失败</span><strong>{{ activeTotals.failed }}</strong></div>
199
+ </div>
200
+ <pre ref="logBox">选择或启动一个任务。</pre>
201
+ </div>
202
+ </aside>
203
+ </main>
204
+ </div>
205
+ <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
206
+ <script src="https://unpkg.com/typescript@5/lib/typescript.js"></script>
207
+ <script id="app-ts" type="text/typescript">"""
208
+ + APP_TS
209
+ + r"""</script>
210
+ <script>
211
+ (function(){
212
+ if(!window.Vue||!window.ts){document.body.innerHTML="<div style='padding:24px;font-family:Segoe UI,Microsoft YaHei,sans-serif'>Vue 或 TypeScript CDN 加载失败。</div>";return}
213
+ var source=document.getElementById("app-ts").textContent;
214
+ var output=window.ts.transpile(source,{target:window.ts.ScriptTarget.ES2020,module:window.ts.ModuleKind.None});
215
+ var script=document.createElement("script");script.textContent=output;document.body.appendChild(script);
216
+ })();
217
+ </script>
218
+ </body>
219
+ </html>
220
+ """
221
+ )