소스 검색

Add resumable asset task table

bang 2 주 전
부모
커밋
8bf53eaac1
4개의 변경된 파일327개의 추가작업 그리고 54개의 파일을 삭제
  1. 2 2
      exporter.py
  2. 32 10
      pipeline.py
  3. 193 2
      server.py
  4. 100 40
      web/index.html

+ 2 - 2
exporter.py

@@ -613,8 +613,8 @@ def export(game, out_root, log=print):
         ]
         if boss_id not in lib_char_ids or missing_files:
             raise RuntimeError(
-                f"当前资源库缺少关主大魔王资源:{boss_id}。请重新生成图片资源,"
-                "生成成功后才会包含 idle/watch/coin_throw/taunt/stomp/explode 等动作。"
+                f"当前资源库缺少关主大魔王资源:{boss_id}。请先在角色库任务卡片里补生成该资源,"
+                "成功后才会包含 idle/watch/coin_throw/taunt/stomp/explode 等动作。"
             )
 
     pack = os.path.join(base, "cocos-pack")

+ 32 - 10
pipeline.py

@@ -14,7 +14,7 @@ import baidu_segment
 HERE = os.path.dirname(os.path.abspath(__file__))
 
 
-def run(manifest, out_root, creds=None, log=print):
+def run(manifest, out_root, creds=None, log=print, merge_existing=False):
     """manifest: dict; out_root: 输出根目录; creds: {provider,api_key,base_url,model,size}
     返回 (library_dict, base_out)。"""
     creds = creds or {}
@@ -33,6 +33,26 @@ def run(manifest, out_root, creds=None, log=print):
         "ui": [],
         "ui_art": [],
     }
+    if merge_existing:
+        library_path = os.path.join(base_out, "library.json")
+        if os.path.isfile(library_path):
+            with open(library_path, encoding="utf-8") as f:
+                existing = json.load(f)
+            for key in ("characters", "vfx", "ui", "ui_art"):
+                library[key] = existing.get(key, [])
+            library["slot_config"] = manifest.get("slot_config") or existing.get("slot_config", {})
+
+    def upsert(section, item):
+        item_id = item.get("id")
+        if not item_id:
+            library[section].append(item)
+            return
+        rows = library.setdefault(section, [])
+        for idx, old in enumerate(rows):
+            if old.get("id") == item_id:
+                rows[idx] = item
+                return
+        rows.append(item)
     total_steps = len(manifest.get("characters", [])) + len(manifest.get("ui_art", [])) + len(manifest.get("vfx", [])) + (1 if manifest.get("ui", []) else 0)
     done_steps = 0
 
@@ -99,6 +119,8 @@ def run(manifest, out_root, creds=None, log=print):
         boss_id = required_boss_id()
         return bool(boss_id and (c.get("role") == "boss" or c.get("id") == boss_id))
 
+    boss_required_in_this_run = any(is_required_boss(c) for c in manifest.get("characters", []))
+
     required_failures = []
 
     # ---- A. 角色(Spine)----
@@ -138,7 +160,7 @@ def run(manifest, out_root, creds=None, log=print):
                 spine_builder.build_character(cid, img, chars_out, anims)
                 w, h = spine_builder.trim_to_content(img).size
                 files = [f"characters/{cid}.json", f"characters/{cid}.atlas", f"characters/{cid}.png"]
-            library["characters"].append({
+            upsert("characters", {
                 "id": cid,
                 "png": f"characters/{cid}.png",
                 "w": w, "h": h,
@@ -156,7 +178,7 @@ def run(manifest, out_root, creds=None, log=print):
             progress(f"{cid}")
 
     boss_id = required_boss_id()
-    if boss_id and not any(c.get("id") == boss_id for c in library["characters"]):
+    if boss_id and boss_required_in_this_run and not any(c.get("id") == boss_id for c in library["characters"]):
         detail = ";".join(required_failures) if required_failures else "生成结果中没有关主资源"
         raise RuntimeError(
             f"关主大魔王资源缺失:{boss_id}。已开启关主玩法,必须生成 boss 拆件和动作后才能继续。原因:{detail}"
@@ -179,9 +201,9 @@ def run(manifest, out_root, creds=None, log=print):
             img = generate_checked(aid, full_prompt, a.get("size", creds.get("size", "1024x1024")), transparent)
             os.makedirs(ui_art_out, exist_ok=True)
             img.save(os.path.join(ui_art_out, f"{aid}.png"))
-            library["ui_art"].append({"id": aid, "file": f"ui_art/{aid}.png",
-                                      "w": img.width, "h": img.height,
-                                      "transparent": transparent})
+            upsert("ui_art", {"id": aid, "file": f"ui_art/{aid}.png",
+                              "w": img.width, "h": img.height,
+                              "transparent": transparent})
             log(f"✅ [{aid}] UI 美术完成")
             progress(f"{aid}")
         except Exception as e:
@@ -195,8 +217,8 @@ def run(manifest, out_root, creds=None, log=print):
             path = particle_builder.build_particle(
                 vid, v.get("template", "burst"), v.get("color", [255, 255, 255]), vfx_out)
             cfg = json.load(open(path, encoding="utf-8"))
-            library["vfx"].append({"id": vid, "template": v.get("template"),
-                                   "file": f"vfx/{vid}.particle.json", "config": cfg})
+            upsert("vfx", {"id": vid, "template": v.get("template"),
+                           "file": f"vfx/{vid}.particle.json", "config": cfg})
             log(f"✨ [{vid}] 粒子配置完成")
             progress(f"{vid}")
         except Exception as e:
@@ -210,8 +232,8 @@ def run(manifest, out_root, creds=None, log=print):
         try:
             tween_builder.build_tweens(used, ui_out)
             for u in ui:
-                library["ui"].append({"id": u.get("id"), "preset": u.get("preset"),
-                                      "params": u.get("params", {})})
+                upsert("ui", {"id": u.get("id"), "preset": u.get("preset"),
+                              "params": u.get("params", {})})
             log(f"🎛  TweenPresets.ts 完成 ({used})")
             progress("TweenPresets")
         except Exception as e:

+ 193 - 2
server.py

@@ -95,6 +95,150 @@ def _run_generate_job(job_id, manifest, creds):
         _set_job(job_id, status="error", ok=False, error=str(e), updatedAt=time.time())
 
 
+def _load_library(game):
+    library_path = os.path.join(OUT_ROOT, game, "library.json")
+    if not os.path.isfile(library_path):
+        return {"game": game, "characters": [], "vfx": [], "ui": [], "ui_art": []}
+    with open(library_path, encoding="utf-8") as f:
+        return json.load(f)
+
+
+def _manifest_from_library(lib):
+    if lib.get("slot_config"):
+        return slot_workflow.complete_manifest({"slot_config": lib["slot_config"]})
+    return {
+        "game": lib.get("game") or "game",
+        "style": "",
+        "characters": [],
+        "ui_art": [],
+        "vfx": [],
+        "ui": [],
+    }
+
+
+TASK_KIND_MAP = {
+    "characters": "characters",
+    "character": "characters",
+    "ui_art": "ui_art",
+    "art": "ui_art",
+    "vfx": "vfx",
+    "ui": "ui",
+}
+
+
+def _zh_name(kind, item, slot_config=None):
+    item_id = item.get("id", "")
+    role = item.get("role", "")
+    known = {
+        "wild": "百搭符号",
+        "scatter": "免费旋转触发符号",
+        "coin_cash": "现金金币符号",
+        "collect": "收集符号",
+        "bg_main": "主背景",
+        "cover": "封面图",
+        "logo": "游戏 Logo",
+        "reel_frame": "卷轴框",
+        "btn_spin": "旋转按钮",
+        "btn_round": "通用圆按钮",
+        "hud_pill": "HUD 信息条",
+        "boss_explosion": "大魔王裂开爆炸特效",
+    }
+    if role == "boss" or item_id == (slot_config or {}).get("boss", {}).get("id"):
+        return (slot_config or {}).get("boss", {}).get("title") or "大魔王关主"
+    if item_id in known:
+        return known[item_id]
+    if item_id.startswith("jelly_"):
+        return "果冻角色 " + item_id.replace("jelly_", "")
+    if kind == "vfx":
+        return "粒子特效 " + item_id
+    if kind == "ui":
+        return "界面动效 " + item_id
+    return "素材 " + item_id
+
+
+def _task_use(kind, item):
+    item_id = item.get("id", "")
+    role = item.get("role", "")
+    if role == "boss":
+        return "关主角色。待机时 watch/charge,玩家赢时撒币并裂开爆炸,玩家输时举剑踩踏耀武扬威。"
+    if kind == "characters":
+        return "卷轴符号或可动画角色,用于 slot 盘面和中奖反馈。"
+    if kind == "ui_art":
+        if item_id == "bg_main":
+            return "游戏主场景背景。"
+        if item_id == "cover":
+            return "封面、分享图或入口展示。"
+        return "界面美术元素,用于 Cocos 原型的背景、按钮、框体或 HUD。"
+    if kind == "vfx":
+        return "粒子特效配置,用于中奖、金币雨、爆炸或强调反馈。"
+    return "UI tween 动效预设,用于按钮、弹窗、数字滚动等界面反馈。"
+
+
+def _task_prompt(kind, item):
+    if kind == "characters" and item.get("type") == "spine_parts":
+        parts = item.get("parts") or []
+        part_text = "\n".join([f"- {p.get('id')}: {p.get('prompt', '')}" for p in parts])
+        return (item.get("prompt", "") + ("\n拆件:\n" + part_text if part_text else "")).strip()
+    return item.get("prompt") or item.get("template") or item.get("preset") or ""
+
+
+def _build_tasks(lib):
+    manifest = _manifest_from_library(lib)
+    slot_config = manifest.get("slot_config", {})
+    existing = {
+        "characters": {x.get("id"): x for x in lib.get("characters", [])},
+        "ui_art": {x.get("id"): x for x in lib.get("ui_art", [])},
+        "vfx": {x.get("id"): x for x in lib.get("vfx", [])},
+        "ui": {x.get("id"): x for x in lib.get("ui", [])},
+    }
+    tasks = {k: [] for k in ("characters", "ui_art", "vfx", "ui")}
+    for kind in tasks:
+        for item in manifest.get(kind, []):
+            item_id = item.get("id")
+            asset = existing[kind].get(item_id)
+            tasks[kind].append({
+                "kind": kind,
+                "id": item_id,
+                "englishName": item_id,
+                "chineseName": _zh_name(kind, item, slot_config),
+                "use": _task_use(kind, item),
+                "prompt": _task_prompt(kind, item),
+                "status": "done" if asset else "missing",
+                "asset": asset,
+                "assetType": item.get("type") or kind,
+                "animations": item.get("animations", []),
+                "transparent": item.get("transparent"),
+                "size": item.get("size"),
+            })
+    return tasks
+
+
+def _filter_manifest_tasks(manifest, kind, task_ids):
+    wanted = set(task_ids)
+    filtered = copy_manifest = json.loads(json.dumps(manifest, ensure_ascii=False))
+    for key in ("characters", "ui_art", "vfx", "ui"):
+        filtered[key] = [x for x in manifest.get(key, []) if key == kind and x.get("id") in wanted]
+    return filtered
+
+
+def _run_retry_job(job_id, game, kind, task_ids, creds):
+    _set_job(job_id, status="running", logs=[], game=game, startedAt=time.time(), updatedAt=time.time())
+    try:
+        lib = _load_library(game)
+        manifest = _manifest_from_library(lib)
+        partial = _filter_manifest_tasks(manifest, kind, task_ids)
+        labels = ", ".join(task_ids)
+        _append_job_log(job_id, f"补生成任务:{kind} / {labels}")
+        new_lib, _ = pipeline.run(partial, OUT_ROOT, creds=creds,
+                                  log=lambda m: _append_job_log(job_id, m),
+                                  merge_existing=True)
+        _set_job(job_id, status="done", ok=True, game=new_lib["game"], updatedAt=time.time())
+    except Exception as e:
+        traceback.print_exc()
+        _append_job_log(job_id, f"❌ 补生成失败: {e}")
+        _set_job(job_id, status="error", ok=False, error=str(e), updatedAt=time.time())
+
+
 def _split_lines(value):
     if isinstance(value, list):
         return [str(x).strip() for x in value if str(x).strip()]
@@ -367,15 +511,22 @@ class Handler(BaseHTTPRequestHandler):
             game = (qs.get("game", [None])[0]) or (games[-1] if games else None)
             if not game:
                 return self._send(200, {"game": None, "games": games,
-                                        "characters": [], "vfx": [], "ui": []})
+                                        "characters": [], "vfx": [], "ui": [], "tasks": {}})
             library_path = os.path.join(OUT_ROOT, game, "library.json")
             if not os.path.isfile(library_path):
                 return self._send(200, {"game": game, "games": games, "assetBase": f"/assets/{game}/",
-                                        "characters": [], "vfx": [], "ui": []})
+                                        "characters": [], "vfx": [], "ui": [], "tasks": {}})
             with open(library_path, encoding="utf-8") as f:
                 lib = json.load(f)
             lib["games"] = games
             lib["assetBase"] = f"/assets/{game}/"
+            lib["tasks"] = _build_tasks(lib)
+            all_tasks = [t for rows in lib["tasks"].values() for t in rows]
+            lib["taskSummary"] = {
+                "total": len(all_tasks),
+                "done": sum(1 for t in all_tasks if t.get("status") == "done"),
+                "missing": sum(1 for t in all_tasks if t.get("status") != "done"),
+            }
             return self._send(200, lib)
         if path.startswith("/assets/"):
             rel = unquote(path[len("/assets/"):])
@@ -402,6 +553,8 @@ class Handler(BaseHTTPRequestHandler):
             return self._post_export()
         if route == "/api/open-folder":
             return self._post_open_folder()
+        if route == "/api/retry-task":
+            return self._post_retry_task()
         if route == "/api/delete":
             return self._post_delete()
         if route == "/api/slot-workflow":
@@ -508,6 +661,44 @@ class Handler(BaseHTTPRequestHandler):
             return self._send(500, {"ok": False, "error": f"打开素材目录失败: {e}"})
         return self._send(200, {"ok": True, "game": game, "path": target})
 
+    def _post_retry_task(self):
+        try:
+            data = self._read_json_body()
+        except Exception as e:
+            return self._send(400, {"ok": False, "error": f"请求体不是合法 JSON: {e}"})
+        game = (data.get("game") or "").strip()
+        if not game or game not in list_games():
+            return self._send(400, {"ok": False, "error": f"无效的 game: {game!r}"})
+        kind = TASK_KIND_MAP.get((data.get("kind") or "").strip())
+        if not kind:
+            return self._send(400, {"ok": False, "error": "无效的任务类型"})
+        task_ids = data.get("ids") or data.get("taskIds") or [data.get("id")]
+        if isinstance(task_ids, str):
+            task_ids = [task_ids]
+        task_ids = [str(x).strip() for x in task_ids if str(x or "").strip()]
+        if not task_ids:
+            return self._send(400, {"ok": False, "error": "没有选择要重试的任务"})
+
+        lib = _load_library(game)
+        tasks = _build_tasks(lib).get(kind, [])
+        valid_ids = {t["id"] for t in tasks}
+        bad = [x for x in task_ids if x not in valid_ids]
+        if bad:
+            return self._send(400, {"ok": False, "error": f"任务不存在: {', '.join(bad)}"})
+        creds = {
+            "provider": data.get("provider", "OpenAI 兼容接口"),
+            "api_key": (data.get("api_key") or DEFAULT_API_KEY).strip(),
+            "base_url": (data.get("base_url") or DEFAULT_BASE_URL).strip(),
+            "model": (data.get("model") or DEFAULT_IMAGE_MODEL).strip(),
+            "size": data.get("size", "1024x1024"),
+        }
+        job_id = uuid.uuid4().hex
+        with JOBS_LOCK:
+            JOBS[job_id] = {"status": "queued", "ok": None, "logs": ["补生成任务已创建,等待开始…"],
+                            "game": game, "createdAt": time.time(), "updatedAt": time.time()}
+        threading.Thread(target=_run_retry_job, args=(job_id, game, kind, task_ids, creds), daemon=True).start()
+        return self._send(200, {"ok": True, "jobId": job_id, "game": game})
+
     def _post_delete(self):
         try:
             data = self._read_json_body()

+ 100 - 40
web/index.html

@@ -52,6 +52,15 @@
   .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:16px}
   .card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px;
         display:flex;flex-direction:column;gap:10px}
+  .card.missing{border-style:dashed;background:#241a3d}
+  .task-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}
+  .task-status{border-radius:20px;padding:2px 9px;font-size:11px;white-space:nowrap;border:1px solid var(--line)}
+  .task-status.done{color:#96ffce;background:#10291f}
+  .task-status.missing{color:#ffd0df;background:#3d1830}
+  .task-prompt{max-height:94px;overflow:auto;background:#160f29;border:1px solid var(--line);
+        border-radius:8px;padding:8px;color:#cfc4ec;font-size:11px;line-height:1.45;white-space:pre-wrap}
+  .placeholder{width:82%;height:82%;border:1px dashed #6b5c92;border-radius:12px;display:flex;
+        align-items:center;justify-content:center;text-align:center;color:var(--muted);font-size:13px;padding:14px}
   .stage{height:200px;border-radius:10px;background:
         repeating-conic-gradient(#241a3d 0 25%, #2c2150 0 50%) 0/22px 22px;
         display:flex;align-items:flex-end;justify-content:center;overflow:hidden;position:relative}
@@ -222,6 +231,8 @@
 const $ = s => document.querySelector(s);
 let LIB = {characters:[],vfx:[],ui:[]}, ASSET="", TAB="chars";
 const animTargets = []; // {el, anim, start}
+const esc = s => String(s ?? '').replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
+const tasksFor = kind => ((LIB.tasks&&LIB.tasks[kind]) || []);
 
 // ---------- 拉取默认 manifest & 资源库 ----------
 async function loadManifest(){
@@ -239,11 +250,14 @@ async function loadLibrary(game){
      o.value=g;o.textContent=g+(pending&&g===lib.game?'(待生成)':''); if(g===lib.game)o.selected=true; sel.appendChild(o); });
   if(!games.length){ const o=document.createElement('option');o.textContent='(暂无)';sel.appendChild(o); }
   if(pending) opMsg(`已载入 ${lib.game} 的 manifest,但还没有图片资源;点“开始生成”后才会进入资源库。`, false);
+  if(lib.taskSummary && !pending){
+    opMsg(`任务 ${lib.taskSummary.done}/${lib.taskSummary.total} 已完成,缺失 ${lib.taskSummary.missing} 个。`, lib.taskSummary.missing===0);
+  }
   const boss = lib.slot_config && lib.slot_config.boss;
   if(boss && boss.enabled){
     const bossId = boss.id || 'boss_demon_lord';
     const hasBoss = (lib.characters||[]).some(c=>c.id===bossId);
-    if(!hasBoss) opMsg(`当前资源库缺少关主大魔王 ${bossId},请重新生成图片资源;旧库不会有大魔王动作。`, false);
+    if(!hasBoss) opMsg(`当前资源库缺少关主大魔王 ${bossId},在角色库任务卡片点“补生成 / 重试”即可继续。`, false);
   }
   render();
 }
@@ -284,58 +298,73 @@ function render(){
 }
 
 function renderChars(v){
-  const list=LIB.characters||[];
-  if(!list.length){ v.innerHTML='<div class="empty">还没有角色。填 key 在生成面板点「开始」即可生成。</div>'; return; }
+  const list=tasksFor('characters');
+  if(!list.length){ v.innerHTML='<div class="empty">还没有角色任务。先生成 manifest。</div>'; return; }
   const grid=document.createElement('div'); grid.className='grid';
-  list.forEach(c=>{
-    const card=document.createElement('div'); card.className='card';
-    const anims=Object.keys(c.animations||{});
+  list.forEach(t=>{
+    const c=t.asset||{};
+    const done=t.status==='done' && c.png;
+    const animMap=c.animations||{};
+    const anims=Object.keys(animMap);
+    const card=document.createElement('div'); card.className='card '+(done?'':'missing');
     card.innerHTML=`
-      <div class="stage"><img src="${ASSET+c.png}" alt="${c.id}"></div>
-      <div class="name">${c.id}</div>
-      <div class="row">
-        <select>${anims.map(a=>`<option>${a}</option>`).join('')}</select>
-      </div>
-      <div class="meta">${c.w}×${c.h}px · ${(c.files||[]).length} 个文件<br>
-        <span class="pill">spine</span> 动画: ${anims.join(', ')||'idle'}</div>`;
-    const img=card.querySelector('img'), selA=card.querySelector('select');
-    const tgt={el:img, anim:c.animations[anims[0]], start:performance.now()};
-    animTargets.push(tgt);
-    selA.onchange=()=>{ tgt.anim=c.animations[selA.value]; tgt.start=performance.now(); };
+      <div class="stage">${done?`<img src="${ASSET+c.png}" alt="${esc(t.id)}">`:`<div class="placeholder">待生成<br>${esc(t.chineseName)}</div>`}</div>
+      <div class="task-head"><div><div class="name">${esc(t.chineseName)}</div><div class="meta">${esc(t.englishName)}</div></div>
+        <span class="task-status ${done?'done':'missing'}">${done?'已生成':'缺失'}</span></div>
+      ${done&&anims.length?`<div class="row"><select>${anims.map(a=>`<option>${esc(a)}</option>`).join('')}</select></div>`:''}
+      <div class="meta"><span class="pill">${esc(t.assetType||'spine')}</span> ${esc(t.use)}<br>
+        动作: ${esc((done?anims:t.animations||[]).join(', ')||'idle')}</div>
+      <div class="task-prompt">${esc(t.prompt||'')}</div>
+      ${done?'':`<button class="ghost retry-btn" data-kind="characters" data-id="${esc(t.id)}">补生成 / 重试</button>`}`;
+    if(done&&anims.length){
+      const img=card.querySelector('img'), selA=card.querySelector('select');
+      const tgt={el:img, anim:animMap[anims[0]], start:performance.now()};
+      animTargets.push(tgt);
+      selA.onchange=()=>{ tgt.anim=animMap[selA.value]; tgt.start=performance.now(); };
+    }
     grid.appendChild(card);
   });
   v.appendChild(grid);
 }
 
 function renderArt(v){
-  const list=LIB.ui_art||[];
-  if(!list.length){ v.innerHTML='<div class="empty">还没有 UI 美术。用工作流生成 manifest 后点「开始生成」。</div>'; return; }
+  const list=tasksFor('ui_art');
+  if(!list.length){ v.innerHTML='<div class="empty">还没有 UI 美术任务。用工作流生成 manifest 后点「开始生成」。</div>'; return; }
   const grid=document.createElement('div'); grid.className='grid';
-  list.forEach(a=>{
-    const card=document.createElement('div'); card.className='card';
+  list.forEach(t=>{
+    const a=t.asset||{};
+    const done=t.status==='done' && a.file;
+    const card=document.createElement('div'); card.className='card '+(done?'':'missing');
     card.innerHTML=`
-      <div class="stage"><img class="art-img" src="${ASSET+a.file}" alt="${a.id}"></div>
-      <div class="name">${a.id}</div>
-      <div class="meta">${a.w}×${a.h}px · ${a.transparent?'透明素材':'整图背景'}<br>
-        <span class="pill">ui_art</span> ${a.file}</div>`;
+      <div class="stage">${done?`<img class="art-img" src="${ASSET+a.file}" alt="${esc(t.id)}">`:`<div class="placeholder">待生成<br>${esc(t.chineseName)}</div>`}</div>
+      <div class="task-head"><div><div class="name">${esc(t.chineseName)}</div><div class="meta">${esc(t.englishName)}</div></div>
+        <span class="task-status ${done?'done':'missing'}">${done?'已生成':'缺失'}</span></div>
+      <div class="meta">${done?`${a.w}×${a.h}px · ${a.transparent?'透明素材':'整图背景'}`:`${esc(t.size||'')} · ${t.transparent?'透明素材':'整图背景'}`}<br>
+        <span class="pill">ui_art</span> ${esc(t.use)}</div>
+      <div class="task-prompt">${esc(t.prompt||'')}</div>
+      ${done?'':`<button class="ghost retry-btn" data-kind="ui_art" data-id="${esc(t.id)}">补生成 / 重试</button>`}`;
     grid.appendChild(card);
   });
   v.appendChild(grid);
 }
 
 function renderVfx(v){
-  const list=LIB.vfx||[];
+  const list=tasksFor('vfx');
   if(!list.length){ v.innerHTML='<div class="empty">还没有特效。</div>'; return; }
   const grid=document.createElement('div'); grid.className='grid';
-  list.forEach(x=>{
-    const card=document.createElement('div'); card.className='card';
+  list.forEach(t=>{
+    const x=t.asset||{};
+    const done=t.status==='done' && x.config;
+    const card=document.createElement('div'); card.className='card '+(done?'':'missing');
     card.innerHTML=`
-      <div class="stage"><canvas></canvas></div>
-      <div class="name">${x.id}</div>
-      <div class="meta"><span class="pill">particle</span> 模板: ${x.template} ·
-        发射率 ${x.config.emissionRate}/s · 寿命 ${x.config.life}s</div>`;
+      <div class="stage">${done?'<canvas></canvas>':`<div class="placeholder">待生成<br>${esc(t.chineseName)}</div>`}</div>
+      <div class="task-head"><div><div class="name">${esc(t.chineseName)}</div><div class="meta">${esc(t.englishName)}</div></div>
+        <span class="task-status ${done?'done':'missing'}">${done?'已生成':'缺失'}</span></div>
+      <div class="meta"><span class="pill">particle</span> ${done?`模板: ${esc(x.template)} · 发射率 ${x.config.emissionRate}/s · 寿命 ${x.config.life}s`:esc(t.use)}</div>
+      <div class="task-prompt">${esc(t.prompt||'')}</div>
+      ${done?'':`<button class="ghost retry-btn" data-kind="vfx" data-id="${esc(t.id)}">补生成 / 重试</button>`}`;
     grid.appendChild(card);
-    startParticle(card.querySelector('canvas'), x.config);
+    if(done) startParticle(card.querySelector('canvas'), x.config);
   });
   v.appendChild(grid);
 }
@@ -405,15 +434,20 @@ const TWEEN_DEMO={
     el._pi=setInterval(()=>{animate(0.6,'sineInOut',t=>el.style.transform=`scale(${on?1+0.06*t:1.06-0.06*t})`);on=!on;},620);},
 };
 function renderUi(v){
-  const list=LIB.ui||[];
+  const list=tasksFor('ui');
   if(!list.length){ v.innerHTML='<div class="empty">还没有 UI 动效。已生成的会编译进 <code>ui/TweenPresets.ts</code>。</div>'; return; }
   const grid=document.createElement('div'); grid.className='grid';
-  list.forEach(u=>{
-    const card=document.createElement('div'); card.className='card';
-    card.innerHTML=`<div class="stage"><div class="demo-box">${u.preset==='number_roll'?'0':'UI'}</div></div>
-      <div class="name">${u.id}</div>
-      <div class="meta"><span class="pill">tween</span> 预设: ${u.preset}</div>
-      <button class="ghost">▶ 播放</button>`;
+  list.forEach(t=>{
+    const u=t.asset||{};
+    const done=t.status==='done';
+    const preset=done?u.preset:t.prompt;
+    const card=document.createElement('div'); card.className='card '+(done?'':'missing');
+    card.innerHTML=`<div class="stage">${done?`<div class="demo-box">${preset==='number_roll'?'0':'UI'}</div>`:`<div class="placeholder">待生成<br>${esc(t.chineseName)}</div>`}</div>
+      <div class="task-head"><div><div class="name">${esc(t.chineseName)}</div><div class="meta">${esc(t.englishName)}</div></div>
+        <span class="task-status ${done?'done':'missing'}">${done?'已生成':'缺失'}</span></div>
+      <div class="meta"><span class="pill">tween</span> 预设: ${esc(preset)}<br>${esc(t.use)}</div>
+      ${done?'<button class="ghost">▶ 播放</button>':`<button class="ghost retry-btn" data-kind="ui" data-id="${esc(t.id)}">补生成 / 重试</button>`}`;
+    if(!done){ grid.appendChild(card); return; }
     const box=card.querySelector('.demo-box');
     card.querySelector('button').onclick=()=>{
       box.style.opacity=1;box.style.transform='';
@@ -429,6 +463,32 @@ document.querySelectorAll('.tabs button').forEach(b=>b.onclick=()=>{
   document.querySelectorAll('.tabs button').forEach(x=>x.classList.remove('active'));
   b.classList.add('active'); TAB=b.dataset.tab; render();
 });
+
+$('#view').addEventListener('click', async e=>{
+  const btn=e.target.closest('.retry-btn');
+  if(!btn) return;
+  const game=$('#gameSel').value;
+  if(!game||game==='(暂无)'){ opMsg('没有可重试的资源库',false); return; }
+  btn.disabled=true;
+  const log=$('#log'); log.style.display='block'; log.textContent=`补生成 ${btn.dataset.kind}/${btn.dataset.id} 任务创建中…`;
+  try{
+    const r=await fetch('/api/retry-task',{method:'POST',headers:{'Content-Type':'application/json'},
+      body:JSON.stringify({
+        game, kind:btn.dataset.kind, id:btn.dataset.id,
+        provider:$('#provider').value, api_key:$('#apiKey').value,
+        base_url:$('#baseUrl').value, model:$('#model').value, size:$('#size').value })});
+    const d=await r.json();
+    if(!d.ok || !d.jobId){
+      log.textContent='❌ '+(d.error||'补生成失败');
+      btn.disabled=false;
+      return;
+    }
+    await pollJob(d.jobId, log, btn);
+  }catch(err){
+    log.textContent='请求失败: '+err;
+    btn.disabled=false;
+  }
+});
 $('#gameSel').onchange=e=>loadLibrary(e.target.value);
 function currentManifestGame(){
   try{ return JSON.parse($('#manifest').value||'{}').game || ''; }catch(e){ return ''; }