fix: seed production defaults and harden onboarding

This commit is contained in:
2026-08-22 23:18:27 +08:00
parent acf6356ba6
commit c6f98b5445
5 changed files with 182 additions and 62 deletions
@@ -31,37 +31,42 @@ class _OnboardingPageState extends State<OnboardingPage> {
}
Future<void> _loadConfig() async {
if (mounted) setState(() => _loaded = false);
final results = await Future.wait([_loadAvatars(), _loadPersonas()]);
if (!mounted) return;
final avatars = results[0] as List<AvatarItem>;
final personas = results[1] as List<PersonaItem>;
setState(() {
_avatars = avatars;
_personas = personas;
if (!avatars.any((item) => item.key == _avatar) && avatars.isNotEmpty) {
_avatar = avatars.first.key;
}
if (!personas.any((item) => item.key == _persona) && personas.isNotEmpty) {
_persona = personas.first.key;
}
_loaded = true;
});
}
Future<List<AvatarItem>> _loadAvatars() async {
try {
final avatars = await PublicConfigApi.avatars();
final personas = await PublicConfigApi.personas();
if (mounted)
setState(() {
_avatars = avatars;
_personas = personas;
if (avatars.isNotEmpty) _avatar = avatars.first.key;
if (personas.isNotEmpty) _persona = personas.first.key;
_loaded = true;
});
return await PublicConfigApi.avatars();
} catch (_) {
// API 失败用硬编码兜底
if (mounted) setState(() => _loaded = true);
return const [];
}
}
// 兜底数据(API 不可用时)
static const _fallbackAvatars = [
('cat', '小账喵', AppIcons.cat),
('dog', '阿福汪', AppIcons.dog),
('robot', '账小智', AppIcons.robot),
];
static const _fallbackPersonas = [
('sassy_cat', '毒舌猫娘', '乱花钱会被无情吐槽'),
('gentle', '温柔小暖', '永远鼓励,温柔提醒'),
('strict', '严格管家', '理性专业,数据说话'),
('meme', '沙雕损友', '玩梗高手,快乐记账'),
];
Future<List<PersonaItem>> _loadPersonas() async {
try {
return await PublicConfigApi.personas();
} catch (_) {
return const [];
}
}
Future<void> _finish() async {
if (!_hasValidCatalogSelection) return;
setState(() => _saving = true);
try {
await AuthApi.completeOnboarding(
@@ -81,6 +86,10 @@ class _OnboardingPageState extends State<OnboardingPage> {
}
}
bool get _hasValidCatalogSelection =>
_avatars.any((item) => item.key == _avatar) &&
_personas.any((item) => item.key == _persona);
@override
Widget build(BuildContext context) {
if (!_loaded)
@@ -170,9 +179,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
}
Widget _CompanionStep() {
// 优先 API 数据,回退硬编码
final avatars = _avatars.isNotEmpty
? _avatars
final avatars = _avatars
.map(
(a) => (
a.key,
@@ -184,11 +191,10 @@ class _OnboardingPageState extends State<OnboardingPage> {
: AppIcons.cat,
),
)
.toList()
: _fallbackAvatars;
final personas = _personas.isNotEmpty
? _personas.map((p) => (p.key, p.name, p.description)).toList()
: _fallbackPersonas;
.toList();
final personas = _personas
.map((p) => (p.key, p.name, p.description))
.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -226,6 +232,35 @@ class _OnboardingPageState extends State<OnboardingPage> {
style: TextStyle(fontSize: 12, color: context.jz.text2),
),
SizedBox(height: 14),
if (avatars.isEmpty || personas.isEmpty)
Expanded(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AppIcons.icon(AppIcons.cloud, size: 28, color: context.jz.text3),
const SizedBox(height: 12),
Text(
'AI 伙伴配置暂不可用',
style: TextStyle(
color: context.jz.text,
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text('请检查网络后重试', style: TextStyle(color: context.jz.text2, fontSize: 12)),
const SizedBox(height: 12),
TextButton.icon(
onPressed: _loadConfig,
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('重新加载'),
),
],
),
),
)
else ...[
SizedBox(
height: 96,
child: Row(
@@ -304,7 +339,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
curve: Curves.easeOut,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: on ? context.jz.aiBackground : Colors.white,
color: on ? context.jz.aiBackground : context.jz.card,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: on ? AppTheme.ai : context.jz.line,
@@ -339,11 +374,12 @@ class _OnboardingPageState extends State<OnboardingPage> {
}).toList(),
),
),
],
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.ai),
onPressed: _saving ? null : _finish,
onPressed: _saving || !_hasValidCatalogSelection ? null : _finish,
child: _saving
? SizedBox(
height: 20,
@@ -39,15 +39,17 @@ class GuestMergeService {
: (existingLedger['id'] as num).toInt();
final categoryMap = <int, int>{};
final available = <Map<String, dynamic>>[];
for (final type in ['expense', 'income']) {
for (final type in ['expense', 'income']) {
final response = await _dio.get(
'/api/categories',
queryParameters: {'type': type},
);
available.addAll(
(response.data as List).map((item) => item as Map<String, dynamic>),
);
}
);
}
await _ensureFallbackCategory(available, 'expense');
await _ensureFallbackCategory(available, 'income');
for (final value in snapshot['categories'] as List<dynamic>? ?? const []) {
final category = value as Map<String, dynamic>;
@@ -90,9 +92,10 @@ class GuestMergeService {
item['name'] == transaction['categoryName'],
);
if (exact.isNotEmpty) return (exact.first['id'] as num).toInt();
final fallback = available.firstWhere(
(item) => item['type'] == categoryType && item['name'] == '其他',
);
final fallback = available.firstWhere(
(item) => item['type'] == categoryType && item['name'] == '其他',
orElse: () => throw StateError('$categoryType 分类缺少“其他”,无法导入本机账单'),
);
return (fallback['id'] as num).toInt();
}
@@ -145,13 +148,47 @@ class GuestMergeService {
await CurrentLedgerStore.instance.ensureLoaded(force: true);
await CurrentLedgerStore.instance.select(ledgerId);
return GuestMergeResult(
ledgerId: ledgerId,
transactionCount: transactionCount,
);
}
static int? _findMappedDefault(
return GuestMergeResult(
ledgerId: ledgerId,
transactionCount: transactionCount,
);
}
static Future<void> _ensureFallbackCategory(
List<Map<String, dynamic>> available,
String type,
) async {
bool hasFallback() => available.any(
(item) => item['type'] == type && item['name'] == '其他',
);
if (hasFallback()) return;
try {
final response = await _dio.post(
'/api/categories',
data: {
'name': '其他',
'iconKey': 'tag',
'colorKey': type == 'income' ? 'lime' : 'graphite',
'type': type,
},
);
available.add(response.data as Map<String, dynamic>);
} catch (_) {
// Another request may have created it between the list and create calls.
final response = await _dio.get(
'/api/categories',
queryParameters: {'type': type},
);
available.removeWhere((item) => item['type'] == type);
available.addAll(
(response.data as List).map((item) => item as Map<String, dynamic>),
);
if (!hasFallback()) rethrow;
}
}
static int? _findMappedDefault(
List<Map<String, dynamic>> available,
Map<String, dynamic> snapshot,
int oldCategoryId,