From 25e4fbb0532da999760298907d6f8b6353d92fb8 Mon Sep 17 00:00:00 2001
From: butubb <1422726308@qq.com>
Date: Sun, 13 Sep 2026 16:29:38 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BA=94=E7=94=A8=E5=88=97=E8=A1=A8?=
=?UTF-8?q?=E6=8C=89=E5=8C=85=E5=90=8D=E5=90=88=E5=B9=B6=20+=20=E5=89=AA?=
=?UTF-8?q?=E8=B4=B4=E6=9D=BF/=E5=AE=89=E8=A3=85/=E7=89=88=E6=9C=AC?=
=?UTF-8?q?=E7=AE=A1=E7=90=86=E6=98=BE=E7=A4=BA=E8=AE=BE=E5=A4=87=E5=90=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
两件事都是"列表看不清楚"的问题:
1) **同一个应用传多个版本会并排显示多行**(v1.2 / v1.3 各占一行),看着像重复。
现在按 **package_name 合并成一个应用一行**,显示最新版:
- `apk_manager.list_latest()`:版本按 version_code 排序取最新,其余版本放进
`versions` 字段;解析不出包名的 APK 各自独立成行(不硬合并)
- 管理页版本列显示 `1.3(另有 1 个旧版本)`,多一个「清理旧版本」按钮
- 「删除」语义改为**删掉这个应用的所有版本**(行=应用),另给
`?scope=older` 只清理旧版本;老的单条删除行为保留(不带 scope)
- **设备端商店也走同一个列表** —— 手机上不会再看到同一个 App 的 v1.2/v1.3 两行
2) **设备只显示地址**(`192.168.20.100:5555`),同型号多台时分不清是哪一台。
三处补齐设备名(平台给设备起的名字,如 A08),统一「名称 · 地址」:
- 剪贴板注入的设备勾选列表(`/api/adb/devices` 经 `_merged_device_list` 带 name)
- 应用管理的安装弹窗(`/api/apks/install/devices` 带 name)
- 应用版本管理表格(`/api/tools/appver` 结果里带 name)
- 顺带:安装进度表里的「设备」列原来显示**型号**(而且是 STF 时代残留的空 dict),
改为显示设备名
没名字的设备(不在池里)自动回退只显示地址,不显示空占位
验证:上传同包名 v1.1/v1.2/v1.3 三个版本 → 列表只出现一行(v1.3,标注共 4 个版本)、
设备端 bootstrap 也只剩 2 个应用;`scope=older` 只留最新、`scope=package` 整包删除;
三个面板在浏览器里渲染无 JS 报错,devText 有名字/没名字两种形态都正确。
---
core/apk_manager.py | 94 +++++++++++++++++++++++++++++++++++++++--
static/admin/apps.js | 34 +++++++++++----
static/admin/tools.js | 10 +++--
web/apks_api.py | 33 +++++++++++----
web/common.py | 14 +++++-
web/device_agent_api.py | 4 +-
web/tools_api.py | 3 ++
7 files changed, 165 insertions(+), 27 deletions(-)
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)})