This commit is contained in:
2026-09-03 17:50:23 +08:00
parent 7bfafc7be3
commit 77d3aad6fc
45 changed files with 1398 additions and 1088 deletions
+315 -204
View File
@@ -2,15 +2,24 @@ import 'package:flutter/material.dart';
import '../models/model_manager.dart';
/// 设置弹层「模型清单」区块:顶部「识别模式」分段控件(s 高识别 / n 高性能,
/// 默认 s,持久化本地;切换即热加载该档位已激活模型),下方 2 列封面缩略图网格。
/// 双档位:每数据集至多两张卡片(s/n 各一),卡片带档位角标;
/// 卡片状态(下载进度/激活)按 (数据集, 档位) 独立记账。
/// 档位展示名(档位码 s/n 仅内部记账,不对用户展示)
String _variantLabel(String v) => v == kVariantN ? '高性能' : '高精度';
/// 设置弹层「模型清单」区块:顶部「识别模式」分段控件(高性能 / 高精度,
/// 默认高精度,持久化本地;2026-09-03 高性能移左位)选择**目标档位**——
/// 只记录偏好,不直接切换运行中的模型,而是决定卡片按钮面向哪个档;下方
/// 2 列封面缩略图网格,同一物种(datasetId)合并一张卡(s/n 两档内部记账,
/// 各自下载独立进度)。
///
/// 卡片状态机(档位 == 当前识别档位时与原单档行为一致):
/// - 未下载 →「使用/下载」:下载完成自动激活(当前档立即使用,非当前档备好待切);
/// - 已下载未激活 →「使用」直接激活;已激活且当前档 →「已使用」点击取消;
/// - 已激活但属非当前档 →「已备好」(已下载就绪,点击切换到该档立即生效)。
/// 同一物种一次只运行一个档位:启用某档会自动停用同物种另一档(不同物种可用
/// 不同档位并行识别)。物种卡片主按钮(面向目标档,2026-09-03 简化文案——
/// 一律「使用/使用中/下载」,不再叫「改用X」,不显示当前运行档提示):
/// - 任一档下载中 → 逐档进度条 + 取消(中止全部进行中的下载);
/// - 任一档失败 → 错误提示 + 重试(只补下未成功的档);
/// - 目标档已激活 →「使用中」点击取消使用;
/// - 目标档已下载未激活 →「使用」直接启用(另一档在运行会被自动停用);
/// - 目标档未下载 →「下载」取回缺失档(目标档落地自动启用、伴档备好;
/// 另一档在使用时补下目标档后自动切换过去)。
class ModelCatalogSection extends StatelessWidget {
final ModelManager manager;
@@ -21,44 +30,57 @@ class ModelCatalogSection extends StatelessWidget {
return ListenableBuilder(
listenable: manager,
builder: (context, _) {
// 目录行按 (数据集, 档位) 稳定排序:同数据集 s 前 n 后相邻展示
final items = [...manager.catalog]..sort((a, b) {
if (a.datasetId != b.datasetId) {
return a.datasetId.compareTo(b.datasetId);
}
return a.variant.compareTo(b.variant);
});
// 目录按数据集分组:同一物种 s/n 合成一张卡;组内 s(高精度)前 n 后
final byDataset = <int, List<ModelCatalogItem>>{};
for (final c in manager.catalog) {
byDataset.putIfAbsent(c.datasetId, () => []).add(c);
}
final groups = byDataset.values.toList()
..sort((a, b) => a.first.datasetId.compareTo(b.first.datasetId));
final 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 Text(
'识别模式',
style: TextStyle(color: Colors.white70, fontSize: 14),
),
const Spacer(),
_ModeToggle(manager: manager),
],
),
const SizedBox(height: 2),
const Text('切换即加载该档位已激活模型(激活状态跨档保留)',
style: TextStyle(color: Colors.white38, fontSize: 11)),
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 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),
foregroundColor: Colors.white70,
visualDensity: VisualDensity.compact,
),
),
],
),
@@ -66,27 +88,29 @@ class ModelCatalogSection extends StatelessWidget {
if (manager.error != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(manager.error!,
style: const TextStyle(
color: Colors.orange, fontSize: 12)),
child: Text(
manager.error!,
style: const TextStyle(color: Colors.orange, fontSize: 12),
),
),
if (items.isEmpty)
const Text('暂无已发布模型',
style: TextStyle(color: Colors.white54, fontSize: 13))
if (groups.isEmpty)
const Text(
'暂无已发布模型',
style: TextStyle(color: Colors.white54, fontSize: 13),
)
else
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.72,
),
itemCount: items.length,
itemCount: groups.length,
itemBuilder: (context, i) =>
_ModelCard(item: items[i], manager: manager),
_SpeciesCard(items: groups[i], manager: manager),
),
],
);
@@ -95,12 +119,16 @@ class ModelCatalogSection extends StatelessWidget {
}
}
/// 档位分段控件:s 高识别 / n 高性能
/// 目标档位分段控件:高性能 / 高精度(2026-09-03 调换两档显示位置,
/// 高性能在左、高精度在右;档位码 s/n 仅内部使用,不对用户展示)
class _ModeToggle extends StatelessWidget {
final ModelManager manager;
const _ModeToggle({required this.manager});
/// 展示顺序(与内部档位常量解耦)
static const List<String> _displayOrder = [kVariantN, kVariantS];
@override
Widget build(BuildContext context) {
return Container(
@@ -112,9 +140,9 @@ class _ModeToggle extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (final v in const [kVariantS, kVariantN]) ...[
if (v != kVariantS) const SizedBox(width: 2),
_seg(v),
for (var i = 0; i < _displayOrder.length; i++) ...[
if (i > 0) const SizedBox(width: 2),
_seg(_displayOrder[i]),
],
],
),
@@ -123,7 +151,6 @@ class _ModeToggle extends StatelessWidget {
Widget _seg(String v) {
final selected = manager.mode == v;
final label = v == kVariantS ? 's 高识别' : 'n 高性能';
return InkWell(
borderRadius: BorderRadius.circular(6),
onTap: () => manager.setMode(v),
@@ -134,7 +161,7 @@ class _ModeToggle extends StatelessWidget {
borderRadius: BorderRadius.circular(6),
),
child: Text(
label,
_variantLabel(v),
style: TextStyle(
color: selected ? Colors.black : Colors.white70,
fontSize: 12,
@@ -146,22 +173,35 @@ class _ModeToggle extends StatelessWidget {
}
}
class _ModelCard extends StatelessWidget {
final ModelCatalogItem item;
/// 单物种卡片:同数据集 s/n 两档合并;下载/激活状态按 (数据集, 档位) 独立记账,
/// 同物种同时至多一个档位被激活(激活以目标档为准)
class _SpeciesCard extends StatelessWidget {
final List<ModelCatalogItem> items;
final ModelManager manager;
const _ModelCard({required this.item, required this.manager});
const _SpeciesCard({required this.items, required this.manager});
@override
Widget build(BuildContext context) {
final variant = item.variant;
final sameMode = manager.mode == variant;
final active = manager.isActive(item.datasetId, variant);
final downloaded = manager.isDownloaded(item.datasetId, variant);
final progress = manager.progressOf(item.datasetId, variant);
final error = manager.errorOf(item.datasetId, variant);
final accent =
variant == kVariantS ? Colors.greenAccent : Colors.orangeAccent;
// 目标条目:当前目标档优先;目录缺目标档(存量单档物种)时取 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),
@@ -171,41 +211,25 @@ class _ModelCard extends StatelessWidget {
fit: StackFit.expand,
children: [
Image.network(
'${manager.baseUrl}${item.coverUrl}',
'${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)))),
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),
),
),
// 档位角标:s 高识别(绿)/ n 高性能(橙)
Positioned(
top: 4,
left: 4,
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: accent,
borderRadius: BorderRadius.circular(4),
),
child: Text(
variant == kVariantS ? 's' : 'n',
style: const TextStyle(
color: Colors.black87,
fontSize: 10,
fontWeight: FontWeight.bold),
child: const Icon(
Icons.image_not_supported_outlined,
color: Colors.white38,
),
),
),
@@ -214,110 +238,6 @@ class _ModelCard extends StatelessWidget {
),
);
Widget action;
if (progress != null) {
action = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
LinearProgressIndicator(
value: progress,
backgroundColor: Colors.white12,
color: Colors.greenAccent),
const SizedBox(height: 2),
Row(
children: [
Expanded(
child: Text(
'${(progress * 100).toStringAsFixed(0)}%',
textAlign: TextAlign.center,
style:
const TextStyle(color: Colors.white70, fontSize: 11),
),
),
TextButton(
onPressed: () =>
manager.cancelDownload(item.datasetId, 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)),
),
],
),
],
);
} else if (error != null) {
action = Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(error,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.redAccent, fontSize: 10)),
TextButton(
onPressed: () => manager.downloadModel(item),
child: const Text('重试', style: TextStyle(fontSize: 12)),
),
],
);
} else if (active && downloaded) {
if (sameMode) {
// 当前档已激活:点击取消
action = SizedBox(
height: 30,
child: OutlinedButton(
onPressed: () =>
manager.setActive(item.datasetId, variant, false),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.greenAccent,
side: const BorderSide(color: Colors.greenAccent)),
child: const Text('已使用', style: TextStyle(fontSize: 12)),
),
);
} else {
// 非当前档已备好(激活保留):点击切换到该档立即生效
action = SizedBox(
height: 30,
child: OutlinedButton(
onPressed: () => manager.setMode(variant),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.amberAccent,
side: const BorderSide(color: Colors.amberAccent)),
child: const Text('已备好', style: TextStyle(fontSize: 12)),
),
);
}
} else if (downloaded) {
action = SizedBox(
height: 30,
child: FilledButton(
onPressed: () => manager.setActive(item.datasetId, variant, true),
style: FilledButton.styleFrom(
backgroundColor: Colors.greenAccent,
foregroundColor: Colors.black,
visualDensity: VisualDensity.compact),
child: const Text('使用', style: TextStyle(fontSize: 12)),
),
);
} else {
action = SizedBox(
height: 30,
child: FilledButton(
onPressed: () => manager.downloadModel(item),
style: FilledButton.styleFrom(
backgroundColor: Colors.greenAccent,
foregroundColor: Colors.black,
visualDensity: VisualDensity.compact),
child: Text(sameMode ? '使用' : '下载', style: const TextStyle(fontSize: 12)),
),
);
}
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
@@ -332,35 +252,226 @@ class _ModelCard extends StatelessWidget {
Row(
children: [
Expanded(
child: Text(item.datasetName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600)),
),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.25),
borderRadius: BorderRadius.circular(4),
),
child: Text(
variant == kVariantS ? '高识别' : '高性能',
style: TextStyle(color: accent, fontSize: 9),
target.datasetName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 2),
Text('v${item.version}',
style: const TextStyle(color: Colors.white38, fontSize: 10)),
// 双档行:高精度 / 高性能各自版本号;使用中绿色高亮、已下载次之、缺失置灰
Text.rich(
TextSpan(
children: [
for (final i in items) ...[
if (i != items.first) const TextSpan(text: ' · '),
TextSpan(
text: '${_variantLabel(i.variant)} ${i.version}',
style: TextStyle(
fontSize: 9,
color: manager.isActive(i.datasetId, i.variant)
? Colors.greenAccent
: manager.isDownloaded(i.datasetId, i.variant)
? Colors.white70
: Colors.white38,
fontWeight: manager.isActive(i.datasetId, i.variant)
? FontWeight.bold
: FontWeight.normal,
),
),
],
],
),
),
const SizedBox(height: 6),
action,
_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) {
// 已下载未激活(目标档):点「使用」直接启用——同物种另一档在使用会被
// 自动停用(每数据集至多一档运行),统一文案不再叫「改用X」(2026-09-03
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 (!use) {
for (final i in items) {
if (!manager.isDownloaded(i.datasetId, i.variant)) {
manager.downloadModel(i);
}
}
return;
}
if (!manager.isDownloaded(target.datasetId, target.variant)) {
final ok = await manager.downloadModel(target);
if (!ok) 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,
),
],
);
}
}