464 lines
17 KiB
Dart
464 lines
17 KiB
Dart
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 = <int, List<ModelCatalogItem>>{};
|
||
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<String> 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<ModelCatalogItem> 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<ModelCatalogItem> items,
|
||
required ModelCatalogItem target,
|
||
required bool downloading,
|
||
required bool hasError,
|
||
required bool tActive,
|
||
required bool tDownloaded,
|
||
required List<ModelCatalogItem> 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,
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|