diff --git a/core/apk_manager.py b/core/apk_manager.py index 6778eed..330cd60 100644 --- a/core/apk_manager.py +++ b/core/apk_manager.py @@ -127,7 +127,7 @@ class ApkManager: # ================== 列表 ================== def list_all(self): - """列出所有 APK。""" + """列出所有 APK(**每个上传版本一行**,内部用)。""" try: with self._db(): rows = ApkRow.query.order_by(ApkRow.upload_time.desc()).all() @@ -136,6 +136,42 @@ class ApkManager: _log.error(f"列出 APK 失败: {e}") return [] + def list_latest(self): + """按**包名**合并后的列表:一个应用一行,只显示最新版本。 + + 为什么:同一个应用传过多个版本(v1.2 / v1.3)时,列表里会并排出现两行—— + 用户看到的是"同一个应用怎么重复了"。这里合并成一行(最新版), + 其余版本的信息放在 `versions` 里,界面可以提示"另有 N 个旧版本"并按需清理。 + + 管理页和设备端商店都用这个(设备上选应用时同样不该出现两行同一个 App)。 + 解析不出包名的 APK 各自独立成行,不做合并(避免把无关文件混在一起)。 + """ + rows = self.list_all() + groups = {} + for r in rows: + pkg = (r.get("package_name") or "").strip() + key = pkg if pkg else ("__single__" + str(r.get("id"))) + groups.setdefault(key, []).append(r) + + def sort_key(x): + return (x.get("version_code") or 0, x.get("upload_time") or "") + + out = [] + for items in groups.values(): + items.sort(key=sort_key, reverse=True) + top = dict(items[0]) + top["version_count"] = len(items) + top["versions"] = [{ + "id": i.get("id"), + "version_name": i.get("version_name") or "", + "version_code": i.get("version_code") or 0, + "size": i.get("size") or 0, + "upload_time": i.get("upload_time") or "", + } for i in items] + out.append(top) + out.sort(key=lambda x: x.get("upload_time") or "", reverse=True) + return out + # ================== 删除 ================== def delete(self, apk_id): """删除 APK 文件和数据库记录。""" @@ -161,6 +197,47 @@ class ApkManager: _log.error(f"删除 APK 失败: {e}") return False, str(e) + def delete_package(self, apk_id, keep_latest=False): + """按**包名**删除:一个应用的所有版本一起处理。 + + keep_latest=True → 只删旧版本、保留最新那版("清理旧版本") + keep_latest=False → 整个应用删掉(所有版本) + 没有包名的 APK 退回单条删除。 + """ + try: + with self._db(): + row = ApkRow.query.get(apk_id) + if not row: + return False, "APK 不存在" + pkg = (row.package_name or "").strip() + if not pkg: + return self.delete(apk_id) + + items = ApkRow.query.filter_by(package_name=pkg).all() + items.sort(key=lambda r: ((r.version_code or 0), + (r.upload_time or "")), reverse=True) + targets = items[1:] if keep_latest else items + if not targets: + return True, "没有需要清理的旧版本" + name = row.display_name or pkg + for r in targets: + p = os.path.join(APK_DIR, r.filename) + if os.path.exists(p): + try: + os.remove(p) + except Exception as e: + _log.warning(f"删除 APK 文件失败: {e}") + db.session.delete(r) + db.session.commit() + _log.info(f"已删除 {name} 的 {len(targets)} 个版本" + f"({'保留最新' if keep_latest else '整个应用'})") + if keep_latest: + return True, f"已清理 {name} 的 {len(targets)} 个旧版本(保留最新)" + return True, f"已删除 {name}(共 {len(targets)} 个版本)" + except Exception as e: + _log.error(f"按包名删除失败: {e}") + return False, str(e) + # ================== 批量安装 ================== def install(self, apk_id, serials): """批量安装 APK 到指定设备(后台线程执行)。 @@ -198,13 +275,22 @@ class ApkManager: # 获取 worker 状态(判断设备是否运行中) worker_status = {w["serial"]: w for w in get_all_worker_status()} + # 设备名(平台给设备起的名字,如 A08)—— 进度表里显示这个而不是型号: + # 8 台同型号时型号根本区分不了,名字才行 + pool_names = {} + try: + from core import device_pool + for d in device_pool.list_configured(enabled_only=False): + if d.get("serial"): + pool_names[d["serial"]] = d.get("name") or "" + except Exception as e: + _log.warning(f"读取设备名失败(进度表将显示地址): {e}") + # 构造安装任务 items = {} for s in serials: - dev = all_devices.get(s, {}) - model = dev.get("model") or dev.get("manufacturer") or s items[s] = { - "name": model, + "name": pool_names.get(s) or s, "status": "pending", "msg": "", } diff --git a/static/admin/apps.js b/static/admin/apps.js index f1d8ec3..87396b5 100644 --- a/static/admin/apps.js +++ b/static/admin/apps.js @@ -29,15 +29,22 @@ async function loadApks(){ await fillAppsDeviceList(); } function renderApkRow(a){ + // 一行 = 一个应用(列表已按包名合并,显示最新版);旧版本给个数提示 + 清理入口 + const n=a.version_count||1; + const verCell=(a.version_name?esc(a.version_name):'-')+ + (n>1?' (另有 '+(n-1)+' 个旧版本)':''); + const clean=n>1?' ':''; + const title=n>1?('共 '+n+' 个版本:'+ (a.versions||[]).map(v=>v.version_name||'?').join(' / ')):''; return ''+ ''+esc(a.display_name||'(未命名)')+''+ ''+esc(a.package_name||'-')+''+ - ''+(a.version_name?esc(a.version_name):'-')+''+ + ''+verCell+''+ ''+fmtSize(a.size)+''+ ''+esc(a.upload_time)+''+ ''+ ' '+ - ''+ + clean+ + ''+ ''+ ''; } @@ -50,7 +57,7 @@ async function fillAppsDeviceList(){ const cur=sel.value; const devices=(sr.devices||[]).filter(d=>d.present); sel.innerHTML=''+devices.map(d=> - '' + '' ).join(''); if(cur)sel.value=cur; } @@ -140,9 +147,20 @@ async function uploadApk(input){ input.value=''; } -async function deleteApk(id){ - if(!confirm('确认删除此APK文件?'))return; - const r=await apiDelete('/api/apks/'+id); +async function deleteApk(id, versionCount){ + const n=versionCount||1; + const msg = n>1 + ? ('确认删除这个应用?\n\n它一共有 '+n+' 个版本,会**全部**删掉(想只留最新版请用「清理旧版本」)。') + : '确认删除此APK文件?'; + if(!confirm(msg))return; + // 一行 = 一个应用 → 删除即删掉该包的所有版本 + const r=await apiDelete('/api/apks/'+id+'?scope=package'); + handleResult(r,loadApks); +} + +async function cleanOldVersions(id, name, oldCount){ + if(!confirm('清理「'+name+'」的 '+oldCount+' 个旧版本?\n\n只保留最新版,旧版本文件会被删除。'))return; + const r=await apiDelete('/api/apks/'+id+'?scope=older'); handleResult(r,loadApks); } @@ -168,8 +186,8 @@ async function openInstallModal(apkId){ online.map(d=>{ const isUsb=d.source==='usb'; const st=''+(isUsb?'本地USB':(d.source==='pool'?'设备池':'adb'))+''; - // serial 唯一,显示 IP + 型号便于区分同型号设备 - const label=(d.serial||'').replace(/:5555$/,'')+' · '+(d.model||(isUsb?'USB 有线设备':'设备')); + // 一律「名称 · 地址」——同型号多台时靠名称区分;没有名称的设备(不在池里)只显示地址 + const label=devText(d.name, d.serial); return ''; }).join('')+ ''; diff --git a/static/admin/tools.js b/static/admin/tools.js index f1adb81..133a8a9 100644 --- a/static/admin/tools.js +++ b/static/admin/tools.js @@ -35,7 +35,7 @@ async function loadToolsDevices(force){ const stCls = d.state==='device' ? 'label-success' : 'label-warning'; return ''; }).join(''); status.textContent = '共 '+_clipDevices.length+' 台设备'; @@ -252,12 +252,14 @@ async function queryAppVers(){ } } function renderAppVerRow(x){ - if(x.error)return ''+esc(x.serial)+''+ + // 设备列显示「名称 · 地址」(名称来自设备池;没名字就只显示地址) + const dev=''+esc(devText(x.name,x.serial))+''; + if(x.error)return ''+dev+ '查询失败'+esc(x.error)+''; - if(x.installed)return ''+esc(x.serial)+''+ + if(x.installed)return ''+dev+ '已安装'+ ''+esc(x.version_name||'')+''+esc(x.version_code||'')+''; - return ''+esc(x.serial)+''+ + return ''+dev+ '未安装--'; } diff --git a/web/apks_api.py b/web/apks_api.py index a754445..e3589d9 100644 --- a/web/apks_api.py +++ b/web/apks_api.py @@ -17,8 +17,12 @@ bp = Blueprint("apks", __name__) @bp.route("/api/apks") @login_required def api_apks_list(): - """列出所有已上传的 APK。""" - return jsonify({"ok": True, "apks": context.apk_mgr.list_all()}) + """列出已上传的 APK —— **按包名合并,一个应用一行(最新版本)**。 + + 同一个应用传过 v1.2 / v1.3 时不再并排显示两行;每行带 versions + (全部版本)与 version_count,界面可提示"另有 N 个旧版本"并按需清理。 + """ + return jsonify({"ok": True, "apks": context.apk_mgr.list_latest()}) @bp.route("/api/apks/upload", methods=["POST"]) @perm_required(PERM_APKS) @@ -36,8 +40,19 @@ def api_apks_upload(): @bp.route("/api/apks/", methods=["DELETE"]) @perm_required(PERM_APKS) def api_apks_delete(apk_id): - """删除 APK 文件和记录。""" - ok, msg = context.apk_mgr.delete(apk_id) + """删除 APK。 + + 默认只删这一个版本(列表里一行 = 一个应用,所以界面通常传 scope): + ?scope=package → 删掉**这个应用的所有版本** + ?scope=older → 只清理**旧版本**,保留最新那版 + 不带 scope 时保持老行为(单条删除),给脚本/外部调用留兼容。 + """ + scope = (request.args.get("scope") or "").strip() + if scope in ("package", "older"): + ok, msg = context.apk_mgr.delete_package( + apk_id, keep_latest=(scope == "older")) + else: + ok, msg = context.apk_mgr.delete(apk_id) if ok: return jsonify({"ok": True, "msg": msg}) return jsonify({"ok": False, "error": msg}), 400 @@ -52,9 +67,11 @@ def api_apks_install_devices(): devices, seen = [], set() try: online = set(device_pool.list_online()) - for serial in device_pool.list_configured(): - if serial in online: - devices.append({"serial": serial, "model": "", "source": "pool"}) + for d in device_pool.list_configured(enabled_only=False): + serial = d.get("serial") + if serial in online and serial not in seen: + devices.append({"serial": serial, "model": "", + "name": d.get("name") or "", "source": "pool"}) seen.add(serial) except Exception: pass @@ -63,7 +80,7 @@ def api_apks_install_devices(): parts = line.split() if len(parts) >= 2 and parts[0] and parts[0] not in seen: devices.append({"serial": parts[0], "model": "", - "source": "usb" if ":" not in parts[0] else "adb"}) + "name": "", "source": "usb" if ":" not in parts[0] else "adb"}) seen.add(parts[0]) return jsonify({"ok": True, "devices": devices}) diff --git a/web/common.py b/web/common.py index 241f8b2..25e4abb 100644 --- a/web/common.py +++ b/web/common.py @@ -7,10 +7,12 @@ _log = get_logger("web") def _merged_device_list(): - """本地 adb(含 USB)+ 设备池的合并列表([{serial, state}])。 + """本地 adb(含 USB)+ 设备池的合并列表([{serial, name, state}])。 供维护终端/剪贴板注入/应用版本管理等设备选择场景共用。 状态:device/offline=本机 adb 实际状态;pool=设备池已配置但本机未连接。 + name 来自设备池(平台给设备起的名字,如 A08)—— 界面上统一按「名称 · 地址」 + 显示,同型号多台时才分得清是哪一台;池里没有的设备 name 为空,界面回退显示地址。 """ states = {} out = _adb("devices") @@ -18,13 +20,21 @@ def _merged_device_list(): parts = line.split() if len(parts) >= 2 and parts[0] and not parts[0].startswith("*"): states[parts[0]] = parts[1] + names = {} + try: + for d in device_pool.list_configured(enabled_only=False): + if d.get("serial"): + names[d["serial"]] = d.get("name") or "" + except Exception: + pass try: for serial in device_pool.list_configured(): if serial not in states: states[serial] = "pool" except Exception: pass - return [{"serial": s, "state": st} for s, st in sorted(states.items())] + return [{"serial": s, "name": names.get(s, ""), "state": st} + for s, st in sorted(states.items())] def _device_screen_state(serial): diff --git a/web/device_agent_api.py b/web/device_agent_api.py index 82c0b0b..10b9132 100644 --- a/web/device_agent_api.py +++ b/web/device_agent_api.py @@ -110,7 +110,9 @@ def api_dev_agent_bootstrap(): from web import context apks = [] try: - for a in context.apk_mgr.list_all(): + # 用「按包名合并」的列表:同一个应用只出现一行(最新版), + # 设备上不会看到同一个 App 的 v1.2 / v1.3 两行 + for a in context.apk_mgr.list_latest(): apks.append({ "id": a.get("id", ""), "display_name": a.get("display_name") or a.get("filename") or "", diff --git a/web/tools_api.py b/web/tools_api.py index e61fb6d..0758f99 100644 --- a/web/tools_api.py +++ b/web/tools_api.py @@ -156,6 +156,9 @@ def api_tools_appver(): results[d["serial"]] = fut.result() except Exception as e: results[d["serial"]] = {"installed": False, "error": str(e)} + # 结果里带上设备名:界面按「名称 · 地址」显示,同型号多台才分得清 + if isinstance(results.get(d["serial"]), dict): + results[d["serial"]]["name"] = d.get("name") or "" fail = sum(1 for v in results.values() if v.get("error")) return jsonify({"ok": True, "results": results, "fail": fail, "total": len(results)})