Files
Aq-Accounting-Flutter/lib/pages/assistant/ai_config_page.dart
T
2026-09-28 11:57:54 +08:00

589 lines
20 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void> 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<String>(
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<void> _openEditor(
BuildContext context,
WidgetRef ref,
AiConfigItem? existing,
) async {
final saved = await showModalBottomSheet<bool>(
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<void> _confirmDelete(
BuildContext context,
WidgetRef ref,
AiConfigItem item,
int customCount,
) async {
final ok = await showDialog<bool>(
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<void> _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)),
),
),
],
),
);
}
}