feat: 应用列表按包名合并 + 剪贴板/安装/版本管理显示设备名

两件事都是"列表看不清楚"的问题:

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 有名字/没名字两种形态都正确。
This commit is contained in:
2026-09-13 16:29:38 +08:00
parent fa07ed0804
commit 25e4fbb053
7 changed files with 165 additions and 27 deletions
+90 -4
View File
@@ -127,7 +127,7 @@ class ApkManager:
# ================== 列表 ================== # ================== 列表 ==================
def list_all(self): def list_all(self):
"""列出所有 APK。""" """列出所有 APK(**每个上传版本一行**,内部用)。"""
try: try:
with self._db(): with self._db():
rows = ApkRow.query.order_by(ApkRow.upload_time.desc()).all() rows = ApkRow.query.order_by(ApkRow.upload_time.desc()).all()
@@ -136,6 +136,42 @@ class ApkManager:
_log.error(f"列出 APK 失败: {e}") _log.error(f"列出 APK 失败: {e}")
return [] 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): def delete(self, apk_id):
"""删除 APK 文件和数据库记录。""" """删除 APK 文件和数据库记录。"""
@@ -161,6 +197,47 @@ class ApkManager:
_log.error(f"删除 APK 失败: {e}") _log.error(f"删除 APK 失败: {e}")
return False, str(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): def install(self, apk_id, serials):
"""批量安装 APK 到指定设备(后台线程执行)。 """批量安装 APK 到指定设备(后台线程执行)。
@@ -198,13 +275,22 @@ class ApkManager:
# 获取 worker 状态(判断设备是否运行中) # 获取 worker 状态(判断设备是否运行中)
worker_status = {w["serial"]: w for w in get_all_worker_status()} 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 = {} items = {}
for s in serials: for s in serials:
dev = all_devices.get(s, {})
model = dev.get("model") or dev.get("manufacturer") or s
items[s] = { items[s] = {
"name": model, "name": pool_names.get(s) or s,
"status": "pending", "status": "pending",
"msg": "", "msg": "",
} }
+26 -8
View File
@@ -29,15 +29,22 @@ async function loadApks(){
await fillAppsDeviceList(); await fillAppsDeviceList();
} }
function renderApkRow(a){ function renderApkRow(a){
// 一行 = 一个应用(列表已按包名合并,显示最新版);旧版本给个数提示 + 清理入口
const n=a.version_count||1;
const verCell=(a.version_name?esc(a.version_name):'-')+
(n>1?' <span style="font-size:11px;color:#94a3b8">(另有 '+(n-1)+' 个旧版本)</span>':'');
const clean=n>1?'<button class="btn btn-xs" onclick="cleanOldVersions(\''+a.id+'\',\''+esc(a.display_name||'')+'\','+(n-1)+')" title="只保留最新版,删掉旧版本">清理旧版本</button> ':'';
const title=n>1?('共 '+n+' 个版本:'+ (a.versions||[]).map(v=>v.version_name||'?').join(' / ')):'';
return '<tr>'+ return '<tr>'+
'<td><strong>'+esc(a.display_name||'(未命名)')+'</strong></td>'+ '<td><strong>'+esc(a.display_name||'(未命名)')+'</strong></td>'+
'<td style="font-family:monospace;font-size:12px;color:#64748b">'+esc(a.package_name||'-')+'</td>'+ '<td style="font-family:monospace;font-size:12px;color:#64748b">'+esc(a.package_name||'-')+'</td>'+
'<td>'+(a.version_name?esc(a.version_name):'-')+'</td>'+ '<td title="'+esc(title)+'">'+verCell+'</td>'+
'<td>'+fmtSize(a.size)+'</td>'+ '<td>'+fmtSize(a.size)+'</td>'+
'<td style="font-size:12px;color:#94a3b8">'+esc(a.upload_time)+'</td>'+ '<td style="font-size:12px;color:#94a3b8">'+esc(a.upload_time)+'</td>'+
'<td>'+ '<td>'+
'<button class="btn btn-primary btn-xs" onclick="openInstallModal(\''+a.id+'\')">安装</button> '+ '<button class="btn btn-primary btn-xs" onclick="openInstallModal(\''+a.id+'\')">安装</button> '+
'<button class="btn btn-danger btn-xs" onclick="deleteApk(\''+a.id+'\')">删除</button>'+ clean+
'<button class="btn btn-danger btn-xs" onclick="deleteApk(\''+a.id+'\','+n+')">删除</button>'+
'</td>'+ '</td>'+
'</tr>'; '</tr>';
} }
@@ -50,7 +57,7 @@ async function fillAppsDeviceList(){
const cur=sel.value; const cur=sel.value;
const devices=(sr.devices||[]).filter(d=>d.present); const devices=(sr.devices||[]).filter(d=>d.present);
sel.innerHTML='<option value="">选择设备...</option>'+devices.map(d=> sel.innerHTML='<option value="">选择设备...</option>'+devices.map(d=>
'<option value="'+esc(d.serial)+'">'+esc((d.serial||'').replace(/:5555$/,'')+' ('+(d.device_name||d.model||'未知设备')+')')+'</option>' '<option value="'+esc(d.serial)+'">'+esc(devText(d.device_name||d.name, d.serial))+'</option>'
).join(''); ).join('');
if(cur)sel.value=cur; if(cur)sel.value=cur;
} }
@@ -140,9 +147,20 @@ async function uploadApk(input){
input.value=''; input.value='';
} }
async function deleteApk(id){ async function deleteApk(id, versionCount){
if(!confirm('确认删除此APK文件?'))return; const n=versionCount||1;
const r=await apiDelete('/api/apks/'+id); 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); handleResult(r,loadApks);
} }
@@ -168,8 +186,8 @@ async function openInstallModal(apkId){
online.map(d=>{ online.map(d=>{
const isUsb=d.source==='usb'; const isUsb=d.source==='usb';
const st='<span class="label label-'+(isUsb?'primary':'default')+'" style="margin-left:6px">'+(isUsb?'本地USB':(d.source==='pool'?'设备池':'adb'))+'</span>'; const st='<span class="label label-'+(isUsb?'primary':'default')+'" style="margin-left:6px">'+(isUsb?'本地USB':(d.source==='pool'?'设备池':'adb'))+'</span>';
// serial 唯一,显示 IP + 型号便于区分同型号设备 // 一律「名称 · 地址」——同型号多台时靠名称区分;没有名称的设备(不在池里)只显示地址
const label=(d.serial||'').replace(/:5555$/,'')+' · '+(d.model||(isUsb?'USB 有线设备':'设备')); const label=devText(d.name, d.serial);
return '<label><input type="checkbox" value="'+esc(d.serial)+'" checked>'+esc(label)+st+'</label>'; return '<label><input type="checkbox" value="'+esc(d.serial)+'" checked>'+esc(label)+st+'</label>';
}).join('')+ }).join('')+
'</div>'; '</div>';
+6 -4
View File
@@ -35,7 +35,7 @@ async function loadToolsDevices(force){
const stCls = d.state==='device' ? 'label-success' : 'label-warning'; const stCls = d.state==='device' ? 'label-success' : 'label-warning';
return '<label data-serial="'+esc(d.serial)+'">' return '<label data-serial="'+esc(d.serial)+'">'
+'<input type="checkbox" value="'+esc(d.serial)+'"'+( _clipSelected.has(d.serial)?' checked':'')+' onchange="toggleClipDevice(this)">' +'<input type="checkbox" value="'+esc(d.serial)+'"'+( _clipSelected.has(d.serial)?' checked':'')+' onchange="toggleClipDevice(this)">'
+'<span style="font-family:monospace;flex:1;overflow:hidden;text-overflow:ellipsis">'+esc(d.serial)+'</span>' +'<span style="font-family:monospace;flex:1;overflow:hidden;text-overflow:ellipsis">'+esc(devText(d.name,d.serial))+'</span>'
+'<span class="label '+stCls+'">'+st+'</span></label>'; +'<span class="label '+stCls+'">'+st+'</span></label>';
}).join(''); }).join('');
status.textContent = '共 '+_clipDevices.length+' 台设备'; status.textContent = '共 '+_clipDevices.length+' 台设备';
@@ -252,12 +252,14 @@ async function queryAppVers(){
} }
} }
function renderAppVerRow(x){ function renderAppVerRow(x){
if(x.error)return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'+ // 设备列显示「名称 · 地址」(名称来自设备池;没名字就只显示地址)
const dev='<td style="font-family:monospace">'+esc(devText(x.name,x.serial))+'</td>';
if(x.error)return '<tr>'+dev+
'<td><span class="label label-warning">查询失败</span></td><td colspan="2">'+esc(x.error)+'</td></tr>'; '<td><span class="label label-warning">查询失败</span></td><td colspan="2">'+esc(x.error)+'</td></tr>';
if(x.installed)return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'+ if(x.installed)return '<tr>'+dev+
'<td><span class="label label-success">已安装</span></td>'+ '<td><span class="label label-success">已安装</span></td>'+
'<td>'+esc(x.version_name||'')+'</td><td style="font-family:monospace">'+esc(x.version_code||'')+'</td></tr>'; '<td>'+esc(x.version_name||'')+'</td><td style="font-family:monospace">'+esc(x.version_code||'')+'</td></tr>';
return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'+ return '<tr>'+dev+
'<td><span class="label label-default">未安装</span></td><td>-</td><td>-</td></tr>'; '<td><span class="label label-default">未安装</span></td><td>-</td><td>-</td></tr>';
} }
+24 -7
View File
@@ -17,8 +17,12 @@ bp = Blueprint("apks", __name__)
@bp.route("/api/apks") @bp.route("/api/apks")
@login_required @login_required
def api_apks_list(): def api_apks_list():
"""列出所有已上传的 APK。""" """列出已上传的 APK —— **按包名合并,一个应用一行(最新版本)**。
return jsonify({"ok": True, "apks": context.apk_mgr.list_all()})
同一个应用传过 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"]) @bp.route("/api/apks/upload", methods=["POST"])
@perm_required(PERM_APKS) @perm_required(PERM_APKS)
@@ -36,7 +40,18 @@ def api_apks_upload():
@bp.route("/api/apks/<apk_id>", methods=["DELETE"]) @bp.route("/api/apks/<apk_id>", methods=["DELETE"])
@perm_required(PERM_APKS) @perm_required(PERM_APKS)
def api_apks_delete(apk_id): def api_apks_delete(apk_id):
"""删除 APK 文件和记录。""" """删除 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) ok, msg = context.apk_mgr.delete(apk_id)
if ok: if ok:
return jsonify({"ok": True, "msg": msg}) return jsonify({"ok": True, "msg": msg})
@@ -52,9 +67,11 @@ def api_apks_install_devices():
devices, seen = [], set() devices, seen = [], set()
try: try:
online = set(device_pool.list_online()) online = set(device_pool.list_online())
for serial in device_pool.list_configured(): for d in device_pool.list_configured(enabled_only=False):
if serial in online: serial = d.get("serial")
devices.append({"serial": serial, "model": "", "source": "pool"}) if serial in online and serial not in seen:
devices.append({"serial": serial, "model": "",
"name": d.get("name") or "", "source": "pool"})
seen.add(serial) seen.add(serial)
except Exception: except Exception:
pass pass
@@ -63,7 +80,7 @@ def api_apks_install_devices():
parts = line.split() parts = line.split()
if len(parts) >= 2 and parts[0] and parts[0] not in seen: if len(parts) >= 2 and parts[0] and parts[0] not in seen:
devices.append({"serial": parts[0], "model": "", 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]) seen.add(parts[0])
return jsonify({"ok": True, "devices": devices}) return jsonify({"ok": True, "devices": devices})
+12 -2
View File
@@ -7,10 +7,12 @@ _log = get_logger("web")
def _merged_device_list(): def _merged_device_list():
"""本地 adb(含 USB)+ 设备池的合并列表([{serial, state}])。 """本地 adb(含 USB)+ 设备池的合并列表([{serial, name, state}])。
供维护终端/剪贴板注入/应用版本管理等设备选择场景共用。 供维护终端/剪贴板注入/应用版本管理等设备选择场景共用。
状态:device/offline=本机 adb 实际状态;pool=设备池已配置但本机未连接。 状态:device/offline=本机 adb 实际状态;pool=设备池已配置但本机未连接。
name 来自设备池(平台给设备起的名字,如 A08)—— 界面上统一按「名称 · 地址」
显示,同型号多台时才分得清是哪一台;池里没有的设备 name 为空,界面回退显示地址。
""" """
states = {} states = {}
out = _adb("devices") out = _adb("devices")
@@ -18,13 +20,21 @@ def _merged_device_list():
parts = line.split() parts = line.split()
if len(parts) >= 2 and parts[0] and not parts[0].startswith("*"): if len(parts) >= 2 and parts[0] and not parts[0].startswith("*"):
states[parts[0]] = parts[1] 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: try:
for serial in device_pool.list_configured(): for serial in device_pool.list_configured():
if serial not in states: if serial not in states:
states[serial] = "pool" states[serial] = "pool"
except Exception: except Exception:
pass 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): def _device_screen_state(serial):
+3 -1
View File
@@ -110,7 +110,9 @@ def api_dev_agent_bootstrap():
from web import context from web import context
apks = [] apks = []
try: try:
for a in context.apk_mgr.list_all(): # 用「按包名合并」的列表:同一个应用只出现一行(最新版),
# 设备上不会看到同一个 App 的 v1.2 / v1.3 两行
for a in context.apk_mgr.list_latest():
apks.append({ apks.append({
"id": a.get("id", ""), "id": a.get("id", ""),
"display_name": a.get("display_name") or a.get("filename") or "", "display_name": a.get("display_name") or a.get("filename") or "",
+3
View File
@@ -156,6 +156,9 @@ def api_tools_appver():
results[d["serial"]] = fut.result() results[d["serial"]] = fut.result()
except Exception as e: except Exception as e:
results[d["serial"]] = {"installed": False, "error": str(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")) fail = sum(1 for v in results.values() if v.get("error"))
return jsonify({"ok": True, "results": results, "fail": fail, return jsonify({"ok": True, "results": results, "fail": fail,
"total": len(results)}) "total": len(results)})