import 'package:flutter/material.dart'; import '../models/model_manager.dart'; /// 档位展示名(档位码 s/n 仅内部记账,不对用户展示) String _variantLabel(String v) => v == kVariantN ? '高性能' : '高精度'; /// 设置弹层「模型清单」区块:顶部「识别档位」分段控件(高性能/高精度,默认 /// 高性能,持久化本地)选择**目标档位**——只记录偏好,不直接切换运行中的 /// 模型,决定卡片主按钮面向哪一档(2026-09-11 回归单按钮制:卡内不再同列 /// 两档按钮)。多物种综合识别 App 端下线(2026-09-11):目录中 combined 条目 /// 直接忽略、不展示,服务端/管理端综合训练功能保持不变。下方 2 列封面网格, /// 同一数据集合并一张卡(s/n 两档内部记账,各自下载独立进度),卡片只对 /// **目标档**给一个主按钮: /// - 任一档下载中 → 逐档进度条 + 取消(中止全部进行中的下载); /// - 任一档失败 → 错误提示 + 重试(只补下未成功的档); /// - 目标档已激活 →「使用中」点击取消使用; /// - 目标档已下载未激活 →「使用」直接启用(同数据集另一档在运行会自动停用); /// - 目标档未下载 →「下载」只取回目标档(双档分别下载,伴档不随下;另一档 /// 正在运行而缺目标档时,下载完自动切换过去)。 /// 卡片不展示版本号与档位状态行(2026-09-10 删「高精度/高性能 + 版本」灰字行, /// 版本仅作内部记账),档位状态看主按钮。新版本由用户在卡片上手动重新 /// 下载(使用中重下会原地生效,2026-09-09 自动更新/待办横幅已退场)。 class ModelCatalogSection extends StatelessWidget { final ModelManager manager; const ModelCatalogSection({super.key, required this.manager}); @override Widget build(BuildContext context) { return ListenableBuilder( listenable: manager, builder: (context, _) { // 目录按数据集分组:同一数据集 s/n 合成一张卡;组内 s(高精度)前 n 后。 // 综合(combined,datasetId=0)条目 App 端忽略(2026-09-11 多物种下线) final byDataset = >{}; for (final c in manager.catalog) { if (c.isCombined) continue; byDataset.putIfAbsent(c.datasetId, () => []).add(c); } final groups = byDataset.values.toList() ..sort((a, b) => a.first.datasetId.compareTo(b.first.datasetId)); const order = {kVariantS: 0, kVariantN: 1}; for (final g in groups) { g.sort((a, b) => (order[a.variant] ?? 9).compareTo(order[b.variant] ?? 9)); } return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( children: [ const Text( '识别档位', style: TextStyle(color: Colors.white70, fontSize: 14), ), const Spacer(), _SegmentSwitch( selected: manager.mode, // 高性能在左、高精度在右(档位码仅内部记账) options: const [ (value: kVariantN, label: '高性能'), (value: kVariantS, label: '高精度'), ], onSelect: (v) => manager.setMode(v), ), ], ), const SizedBox(height: 6), const Text( '卡片操作面向所选档位;同一数据集一次只运行一档,切换会自动停用另一档', style: TextStyle(color: Colors.white38, fontSize: 11), ), const SizedBox(height: 12), Row( children: [ const Text( '模型清单', style: TextStyle( color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold, ), ), const Spacer(), TextButton.icon( onPressed: () => manager.refresh(), icon: const Icon(Icons.refresh, size: 16), label: const Text('刷新'), style: TextButton.styleFrom( foregroundColor: Colors.white70, visualDensity: VisualDensity.compact, ), ), ], ), const SizedBox(height: 4), if (manager.error != null) Padding( padding: const EdgeInsets.only(bottom: 8), child: Text( manager.error!, style: const TextStyle(color: Colors.orange, fontSize: 12), ), ), if (groups.isEmpty) const Text( '暂无已发布模型', style: TextStyle(color: Colors.white54, fontSize: 13), ) else GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, mainAxisSpacing: 12, crossAxisSpacing: 12, childAspectRatio: 0.72, ), itemCount: groups.length, itemBuilder: (context, i) => _SpeciesCard(items: groups[i], manager: manager), ), ], ); }, ); } } /// 通用分段开关:白底圆角外框,选中段绿色高亮;[options] = (值, 展示名) 列表, /// 点某段回调 [onSelect](识别档位行使用) class _SegmentSwitch extends StatelessWidget { final String selected; final List<({String value, String label})> options; final ValueChanged onSelect; const _SegmentSwitch({ required this.selected, required this.options, required this.onSelect, }); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(2), decoration: BoxDecoration( color: Colors.white12, borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ for (var i = 0; i < options.length; i++) ...[ if (i > 0) const SizedBox(width: 2), _seg(options[i]), ], ], ), ); } Widget _seg(({String value, String label}) o) { final sel = selected == o.value; return InkWell( borderRadius: BorderRadius.circular(6), onTap: () => onSelect(o.value), child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: sel ? Colors.greenAccent : Colors.transparent, borderRadius: BorderRadius.circular(6), ), child: Text( o.label, style: TextStyle( color: sel ? Colors.black : Colors.white70, fontSize: 12, fontWeight: sel ? FontWeight.bold : FontWeight.normal, ), ), ), ); } } /// 单物种卡片:同数据集 s/n 两档合并展示但**只给目标档一个主按钮**;下载/激活 /// 状态按 (数据集, 档位) 独立记账,同物种同时至多一个档位被激活(目标档为准) class _SpeciesCard extends StatelessWidget { final List items; final ModelManager manager; const _SpeciesCard({required this.items, required this.manager}); @override Widget build(BuildContext context) { // 目标条目:当前目标档优先;目录缺目标档(存量单档物种)时取 s 档 final target = items.firstWhere( (i) => i.variant == manager.mode, orElse: () => items.first, ); final tActive = manager.isActive(target.datasetId, target.variant); final tDownloaded = manager.isDownloaded(target.datasetId, target.variant); // 同物种另一档正在使用的条目(至多一个——每数据集单档激活约束) final activeOther = items .where((i) => i.variant != target.variant && manager.isActive(i.datasetId, i.variant)) .toList(); // 任一档进行中/失败标志 final downloading = items.any((i) => manager.progressOf(i.datasetId, i.variant) != null); final hasError = items.any((i) => manager.errorOf(i.datasetId, i.variant) != null); final thumb = ClipRRect( borderRadius: BorderRadius.circular(8), child: AspectRatio( aspectRatio: 4 / 3, child: Stack( fit: StackFit.expand, children: [ Image.network( '${manager.baseUrl}${target.coverUrl}', fit: BoxFit.cover, loadingBuilder: (context, child, chunk) => chunk == null ? child : Container( color: Colors.white12, child: const Center( child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ), ), ), errorBuilder: (context, error, stack) => Container( color: Colors.white12, child: const Icon( Icons.image_not_supported_outlined, color: Colors.white38, ), ), ), ], ), ), ); return Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.white10, borderRadius: BorderRadius.circular(10), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(child: Center(child: thumb)), const SizedBox(height: 6), Row( children: [ Expanded( child: Text( target.datasetName, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600, ), ), ), ], ), const SizedBox(height: 6), _actionArea( items: items, target: target, downloading: downloading, hasError: hasError, tActive: tActive, tDownloaded: tDownloaded, activeOther: activeOther, ), ], ), ); } /// 动作区:进度/错误优先级最高;其次按目标档的下载/激活状态给唯一主按钮 Widget _actionArea({ required List items, required ModelCatalogItem target, required bool downloading, required bool hasError, required bool tActive, required bool tDownloaded, required List activeOther, }) { if (downloading) { return Column( mainAxisSize: MainAxisSize.min, children: [ for (final i in items) if (manager.progressOf(i.datasetId, i.variant) != null) _progressRow(item: i), Align( alignment: Alignment.centerRight, child: TextButton( onPressed: () { for (final i in items) { if (manager.progressOf(i.datasetId, i.variant) != null) { manager.cancelDownload(i.datasetId, i.variant); } } }, style: TextButton.styleFrom( foregroundColor: Colors.white54, visualDensity: VisualDensity.compact, padding: const EdgeInsets.symmetric(horizontal: 8), minimumSize: const Size(0, 24), tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), child: const Text('取消', style: TextStyle(fontSize: 11)), ), ), ], ); } if (hasError) { return Column( mainAxisSize: MainAxisSize.min, children: [ for (final i in items) if (manager.errorOf(i.datasetId, i.variant) != null) Text( '${_variantLabel(i.variant)}下载失败', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: Colors.redAccent, fontSize: 10), ), Align( alignment: Alignment.centerRight, child: TextButton( onPressed: () { for (final i in items) { if (manager.errorOf(i.datasetId, i.variant) != null) { manager.downloadModel(i); } } }, style: TextButton.styleFrom( foregroundColor: Colors.redAccent, visualDensity: VisualDensity.compact, padding: const EdgeInsets.symmetric(horizontal: 8), minimumSize: const Size(0, 24), tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), child: const Text('重试', style: TextStyle(fontSize: 11)), ), ), ], ); } final Widget mainBtn; if (tActive) { // 目标档使用中:点击取消使用 mainBtn = SizedBox( height: 30, child: OutlinedButton( onPressed: () => manager.setActive(target.datasetId, target.variant, false), style: OutlinedButton.styleFrom( foregroundColor: Colors.greenAccent, side: const BorderSide(color: Colors.greenAccent), ), child: const Text('使用中', style: TextStyle(fontSize: 12)), ), ); } else if (tDownloaded) { // 已下载未激活(目标档):点「使用」直接启用——同物种另一档在使用会 // 自动停用(每数据集至多一档运行) mainBtn = SizedBox( height: 30, child: FilledButton( onPressed: () => manager.setActive(target.datasetId, target.variant, true), style: FilledButton.styleFrom( backgroundColor: Colors.greenAccent, foregroundColor: Colors.black, visualDensity: VisualDensity.compact, ), child: const Text('使用', style: TextStyle(fontSize: 12)), ), ); } else { // 目标档未下载:只下载目标档(双档分别下载——伴档不随下,缺失档由 // 切档后按钮补下);另一档在使用时补下目标档后自动切换过去 final use = activeOther.isNotEmpty; mainBtn = SizedBox( height: 30, child: FilledButton( onPressed: () async { if (!manager.isDownloaded(target.datasetId, target.variant)) { if (use) { final ok = await manager.downloadModel(target); if (!ok) return; // 下载失败/取消:错误分支展示,保持现状 } else { manager.downloadModel(target); return; } } await manager.setActive(target.datasetId, target.variant, true); }, style: FilledButton.styleFrom( backgroundColor: Colors.greenAccent, foregroundColor: Colors.black, visualDensity: VisualDensity.compact, ), child: const Text('下载', style: TextStyle(fontSize: 12)), ), ); } return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [mainBtn], ); } Widget _progressRow({required ModelCatalogItem item}) { final p = manager.progressOf(item.datasetId, item.variant) ?? 0.0; final label = _variantLabel(item.variant); return Column( mainAxisSize: MainAxisSize.min, children: [ Row( children: [ Text( '$label ${(p * 100).toStringAsFixed(0)}%', style: const TextStyle(color: Colors.white70, fontSize: 10), ), ], ), LinearProgressIndicator( value: p, backgroundColor: Colors.white12, color: Colors.greenAccent, ), ], ); } }