import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../api/ai_api.dart'; import '../../../core/network/api_exception.dart'; import '../../../models/ai_config.dart'; import '../../../providers/ai_config_provider.dart'; import '../../../utils/toast.dart'; import '../../../widgets/state_views.dart'; /// AI 模型配置管理:列出现有配置、切换、增删改。 /// /// 设计上的三个要点: /// 1. **「默认」那一项是虚拟的** —— 值来自服务器 `.env`,不能编辑/删除。 /// 它就是「什么都不选」的状态,选中后请求里 configId 传 null。 /// 2. **列表不打勾谁在用**,打勾的是 [selectedAiConfigProvider] 里存的那个。 /// 选中状态存在手机本地,不改服务端 —— 否则网页端和手机会互相打架。 /// 3. **api key 只显示掩码**。编辑时留空 = 不改动。 Future openAiConfigPage(BuildContext context) { return Navigator.of(context).push( MaterialPageRoute(builder: (_) => const AiConfigPage()), ); } class AiConfigPage extends ConsumerWidget { const AiConfigPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final configs = ref.watch(aiConfigsProvider); final selectedId = ref.watch(selectedAiConfigProvider); return Scaffold( appBar: AppBar( title: const Text('AI 模型'), actions: [ IconButton( tooltip: '新增配置', icon: const Icon(Icons.add), onPressed: () => _openEditor(context, ref, null), ), ], ), body: configs.when( loading: () => const LoadingView(tip: '正在加载配置...'), error: (e, _) => ErrorView( message: e is ApiException ? e.message : '配置加载失败', onRetry: () => ref.invalidate(aiConfigsProvider), ), data: (list) { final custom = list.where((c) => c.editable).length; return ListView( padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), children: [ _introCard(), const SizedBox(height: 14), for (final item in list) ...[ _configTile(context, ref, item, selectedId, custom), const SizedBox(height: 10), ], if (custom == 0) _emptyHint(), ], ); }, ), ); } Widget _introCard() { return Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: const Color(0xFFFFF3E6), borderRadius: BorderRadius.circular(12), ), child: const Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '换一个模型', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037), ), ), SizedBox(height: 5), Text( '可以配多套(不同厂商、不同模型),点一下就能切换。\n' '「默认」用的是服务器 .env 里那套,不需要在这里填。', style: TextStyle(fontSize: 12, height: 1.6, color: Color(0xFF8D6E63)), ), ], ), ); } Widget _emptyHint() { return Padding( padding: const EdgeInsets.only(top: 4), child: Container( width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), ), child: const Text( '还没有自己的配置。点右上角「+」加一套 ——\n' '比如 DeepSeek、通义、智谱,填上各自的 base url、api key 和模型名。', textAlign: TextAlign.center, style: TextStyle(fontSize: 12.5, height: 1.7, color: Color(0xFFBCAAA4)), ), ), ); } Widget _configTile( BuildContext context, WidgetRef ref, AiConfigItem item, int? selectedId, int customCount, ) { // 默认项的 selectionKey 是 -1,不能用 id 比较 final active = selectedId == null ? item.isDefault : item.selectionKey == selectedId; return Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(14), border: Border.all( color: active ? const Color(0xFFC8956E) : const Color(0xFFF0E6D8), width: active ? 1.5 : 1, ), ), child: InkWell( borderRadius: BorderRadius.circular(14), onTap: () { ref.read(selectedAiConfigProvider.notifier).select(item.id); showToast(context, '已切换到「${item.name}」', icon: 'check'); }, child: Padding( padding: const EdgeInsets.fromLTRB(14, 12, 8, 12), child: Row( children: [ Icon( active ? Icons.radio_button_checked : Icons.radio_button_unchecked, size: 19, color: active ? const Color(0xFFC8956E) : const Color(0xFFD7CCC8), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Flexible( child: Text( item.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 14.5, fontWeight: FontWeight.w600, color: Color(0xFF5D4037), ), ), ), if (item.isDefault) ...[ const SizedBox(width: 6), _tag('默认', const Color(0xFF8D6E63)), ], if (!item.ready) ...[ const SizedBox(width: 6), _tag('不可用', const Color(0xFFE53935)), ], ], ), const SizedBox(height: 4), Text( item.model.isEmpty ? '未填模型名' : item.model, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63)), ), const SizedBox(height: 2), Text( item.baseUrl.isEmpty ? '未填 base url' : item.baseUrl, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11, color: Color(0xFFBCAAA4)), ), if (item.apiKeyMasked.isNotEmpty) ...[ const SizedBox(height: 2), Text( 'key: ${item.apiKeyMasked}', style: const TextStyle(fontSize: 11, color: Color(0xFFBCAAA4)), ), ], ], ), ), // 默认项不给编辑/删除的入口 —— 它的值来自服务器 .env if (item.editable) PopupMenuButton( icon: const Icon(Icons.more_vert, size: 19, color: Color(0xFFBCAAA4)), onSelected: (v) { if (v == 'edit') { _openEditor(context, ref, item); } else { _confirmDelete(context, ref, item, customCount); } }, itemBuilder: (_) => const [ PopupMenuItem(value: 'edit', child: Text('编辑')), PopupMenuItem(value: 'delete', child: Text('删除')), ], ), ], ), ), ), ); } Widget _tag(String text, Color color) { return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), decoration: BoxDecoration( color: color.withValues(alpha: 0.11), borderRadius: BorderRadius.circular(6), ), child: Text(text, style: TextStyle(fontSize: 10.5, color: color)), ); } // ---------------- 增删改 ---------------- Future _openEditor( BuildContext context, WidgetRef ref, AiConfigItem? existing, ) async { final saved = await showModalBottomSheet( context: context, backgroundColor: Colors.white, isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), builder: (_) => _AiConfigEditor(existing: existing), ); if (saved == true && context.mounted) { ref.invalidate(aiConfigsProvider); showToast(context, existing == null ? '已添加' : '已保存', icon: 'check'); } } Future _confirmDelete( BuildContext context, WidgetRef ref, AiConfigItem item, int customCount, ) async { final ok = await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('删除配置?', style: TextStyle(fontSize: 16)), content: Text( '「${item.name}」会被删掉。\n' '${customCount == 1 ? '删掉之后会自动回到「默认」(服务器 .env 那套)。' : ''}', style: const TextStyle(fontSize: 13, height: 1.6), ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('取消', style: TextStyle(color: Color(0xFF8D6E63))), ), TextButton( onPressed: () => Navigator.of(dialogContext).pop(true), child: const Text('删除', style: TextStyle(color: Color(0xFFE53935))), ), ], ), ); if (ok != true || !context.mounted) return; try { await ref.read(aiApiProvider).deleteConfig(item.id!); if (!context.mounted) return; // 删掉的正好是当前选中的 → 退回默认,否则界面会显示一个不存在的名字 if (ref.read(selectedAiConfigProvider) == item.id) { ref.read(selectedAiConfigProvider.notifier).select(null); } ref.invalidate(aiConfigsProvider); showToast(context, '已删除', icon: 'check'); } on ApiException catch (e) { if (context.mounted) showToast(context, e.message, error: true); } catch (_) { if (context.mounted) showToast(context, '删除失败', error: true); } } } /// 新增 / 编辑配置的表单 class _AiConfigEditor extends ConsumerStatefulWidget { const _AiConfigEditor({this.existing}); /// null = 新增 final AiConfigItem? existing; @override ConsumerState<_AiConfigEditor> createState() => _AiConfigEditorState(); } class _AiConfigEditorState extends ConsumerState<_AiConfigEditor> { late final TextEditingController _name; late final TextEditingController _baseUrl; late final TextEditingController _apiKey; late final TextEditingController _model; bool _saving = false; bool get _isEdit => widget.existing != null; @override void initState() { super.initState(); final e = widget.existing; _name = TextEditingController(text: e?.name ?? ''); _baseUrl = TextEditingController(text: e?.baseUrl ?? ''); // key 不回显(拿到的只是掩码),编辑时留空 = 不改动 _apiKey = TextEditingController(); _model = TextEditingController(text: e?.model ?? ''); } @override void dispose() { _name.dispose(); _baseUrl.dispose(); _apiKey.dispose(); _model.dispose(); super.dispose(); } Future _save() async { final baseUrl = _baseUrl.text.trim(); final model = _model.text.trim(); final apiKey = _apiKey.text.trim(); if (baseUrl.isEmpty) { showToast(context, 'base url 不能为空', error: true); return; } if (model.isEmpty) { showToast(context, '模型名称不能为空', error: true); return; } // 新增时 key 必填;编辑时留空表示不改 if (!_isEdit && apiKey.isEmpty) { showToast(context, 'api key 不能为空', error: true); return; } setState(() => _saving = true); try { final api = ref.read(aiApiProvider); if (_isEdit) { await api.updateConfig( id: widget.existing!.id!, name: _name.text.trim(), baseUrl: baseUrl, model: model, apiKey: apiKey, ); } else { await api.createConfig( name: _name.text.trim(), baseUrl: baseUrl, apiKey: apiKey, model: model, ); } if (!mounted) return; Navigator.of(context).pop(true); } on ApiException catch (e) { if (!mounted) return; showToast(context, e.message, error: true); } catch (_) { if (!mounted) return; showToast(context, '保存失败', error: true); } finally { if (mounted) setState(() => _saving = false); } } @override Widget build(BuildContext context) { return Padding( padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), child: SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(20, 16, 20, 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Center( child: Text( _isEdit ? '编辑配置' : '新增配置', style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF5D4037), ), ), ), const SizedBox(height: 18), _field( label: '名称', controller: _name, hint: '留空则用模型名,比如:DeepSeek 便宜', ), _field( label: 'Base URL', controller: _baseUrl, hint: 'https://api.deepseek.com/v1', helper: '结尾不要带 /chat/completions', ), _field( label: 'API Key', controller: _apiKey, hint: _isEdit && widget.existing!.apiKeyMasked.isNotEmpty ? '留空表示不改动(当前 ${widget.existing!.apiKeyMasked})' : 'sk-...', helper: _isEdit ? '留空 = 保持原来的 key' : '只存在你的服务器上,App 拿不到完整值', obscure: true, ), _field( label: '模型名称', controller: _model, hint: 'deepseek-chat / qwen-plus / glm-4 …', ), const SizedBox(height: 6), _presetHint(), const SizedBox(height: 18), Row( children: [ Expanded( child: SizedBox( height: 46, child: OutlinedButton( onPressed: _saving ? null : () => Navigator.of(context).pop(), style: OutlinedButton.styleFrom( foregroundColor: const Color(0xFFC8956E), side: const BorderSide(color: Color(0xFFC8956E)), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(23), ), ), child: const Text('取消'), ), ), ), const SizedBox(width: 12), Expanded( child: SizedBox( height: 46, child: FilledButton( onPressed: _saving ? null : _save, style: FilledButton.styleFrom( backgroundColor: const Color(0xFFC8956E), foregroundColor: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(23), ), ), child: _saving ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ) : const Text('保存'), ), ), ), ], ), ], ), ), ), ); } /// 常用厂商的地址提示。不自动填充 —— 填错了比不填更难查。 Widget _presetHint() { return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: const Color(0xFFF7F4F1), borderRadius: BorderRadius.circular(10), ), child: const Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '常用地址(供参考)', style: TextStyle(fontSize: 11.5, color: Color(0xFF8D6E63)), ), SizedBox(height: 6), Text( 'DeepSeek https://api.deepseek.com/v1\n' '通义 https://dashscope.aliyuncs.com/compatible-mode/v1\n' '智谱 https://open.bigmodel.cn/api/paas/v4\n' 'OpenAI https://api.openai.com/v1', style: TextStyle( fontSize: 10.5, height: 1.8, color: Color(0xFFBCAAA4), fontFamily: 'monospace', ), ), ], ), ); } Widget _field({ required String label, required TextEditingController controller, required String hint, String? helper, bool obscure = false, }) { return Padding( padding: const EdgeInsets.only(bottom: 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: const TextStyle(fontSize: 13, color: Color(0xFF8D6E63))), const SizedBox(height: 6), Container( padding: const EdgeInsets.symmetric(horizontal: 14), decoration: BoxDecoration( color: const Color(0xFFF7F4F1), borderRadius: BorderRadius.circular(10), ), child: TextField( controller: controller, obscureText: obscure, autocorrect: false, enableSuggestions: false, style: const TextStyle(fontSize: 14, color: Color(0xFF5D4037)), decoration: InputDecoration( border: InputBorder.none, isDense: true, contentPadding: const EdgeInsets.symmetric(vertical: 13), hintText: hint, hintStyle: const TextStyle(fontSize: 13.5, color: Color(0xFFD7CCC8)), ), ), ), if (helper != null) Padding( padding: const EdgeInsets.only(top: 4, left: 2), child: Text( helper, style: const TextStyle(fontSize: 11, color: Color(0xFFBCAAA4)), ), ), ], ), ); } }