742 lines
26 KiB
Dart
742 lines
26 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
|
|
import '../../../api/ai_api.dart';
|
|
import '../../../core/network/api_exception.dart';
|
|
import '../../../models/ai_persona.dart';
|
|
import '../../../providers/ai_persona_provider.dart';
|
|
import '../../../utils/toast.dart';
|
|
import '../../../widgets/avatar.dart';
|
|
import '../../../widgets/state_views.dart';
|
|
|
|
/// 人设列表页(不含 Scaffold/AppBar —— 它作为「AI 设置」页的一个 Tab 嵌进去)
|
|
///
|
|
/// 与模型配置页 [AiConfigPage] 是**平行的两套**:一个管模型,一个管人设。
|
|
/// 分成两个 Tab 而不是在顶栏加两个胶囊,是因为顶栏已经挤了五块东西,
|
|
/// 320 宽的小屏塞不下第二个胶囊。
|
|
///
|
|
/// 三个要点:
|
|
/// 1. **「默认」是虚拟项** —— 内容是服务端代码里的常量,不能编辑/删除。
|
|
/// 2. **预填而不是空框** —— 新增时把内置默认文本填进去,用户改一改就用,
|
|
/// 而不是面对一片空白不知道写什么。
|
|
/// 3. **提示词里不含日期** —— 那是运行时事实,服务端每次对话自动追加。
|
|
class AiPersonaTab extends ConsumerWidget {
|
|
const AiPersonaTab({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final personas = ref.watch(aiPersonasProvider);
|
|
|
|
return Scaffold(
|
|
backgroundColor: const Color(0xFFFAF8F5),
|
|
floatingActionButton: FloatingActionButton(
|
|
onPressed: () => _openEditor(context, ref, null),
|
|
backgroundColor: const Color(0xFFC8956E),
|
|
foregroundColor: Colors.white,
|
|
tooltip: '新增人设',
|
|
child: const Icon(Icons.add),
|
|
),
|
|
body: personas.when(
|
|
loading: () => const LoadingView(tip: '正在加载人设...'),
|
|
error: (e, _) => ErrorView(
|
|
message: e is ApiException ? e.message : '人设加载失败',
|
|
onRetry: () => ref.invalidate(aiPersonasProvider),
|
|
),
|
|
data: (list) {
|
|
final custom = list.where((p) => p.editable).length;
|
|
return ListView(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 88),
|
|
children: [
|
|
_introCard(),
|
|
const SizedBox(height: 14),
|
|
for (final item in list) ...[
|
|
_personaTile(context, ref, item, 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'
|
|
'一段对话选定之后不会中途换人,想换就开个新对话。\n'
|
|
'提示词里不用写日期,服务端每次会自动补上今天几号、「这个月」指哪个月。',
|
|
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'
|
|
'想加一个就点右下角「+」—— 会预填一份能用的,改一改就行。\n'
|
|
'加完之后,新建对话时就能选它。',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(fontSize: 12.5, height: 1.7, color: Color(0xFFBCAAA4)),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 一条人设卡片。
|
|
///
|
|
/// **点整行不再是「选用」** —— 用哪个人设是新建对话时决定的,
|
|
/// 这里只负责管理(改名、改提示词、换头像、删除)。
|
|
/// 之前这里有个单选圈 + 点击切换,会让人误以为「在这儿选了就生效」,
|
|
/// 而实际说话的人跟着会话走,两者不是一回事。
|
|
Widget _personaTile(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
AiPersonaItem item,
|
|
int customCount,
|
|
) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(color: const Color(0xFFF0E6D8)),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(14, 12, 8, 12),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// 人设头像。没设过就是默认小人图标
|
|
Avatar(
|
|
assetPath: item.avatar,
|
|
size: 38,
|
|
fallbackIcon: 'assistant',
|
|
),
|
|
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.promptLength > 0) ...[
|
|
const SizedBox(width: 6),
|
|
_tag('${item.promptLength} 字', const Color(0xFFBCAAA4)),
|
|
],
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
// 提示词预览:两行封顶,别把列表撑散
|
|
Text(
|
|
item.preview.isEmpty ? '(空提示词)' : item.preview,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontSize: 11.5,
|
|
height: 1.6,
|
|
color: Color(0xFFBCAAA4),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// 默认项不给编辑/删除的入口 —— 它的内容是服务端代码里内置的
|
|
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,
|
|
AiPersonaItem? 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: (_) => _PersonaEditor(existing: existing),
|
|
);
|
|
if (saved == true && context.mounted) {
|
|
ref.invalidate(aiPersonasProvider);
|
|
showToast(context, existing == null ? '已添加' : '已保存', icon: 'check');
|
|
}
|
|
}
|
|
|
|
Future<void> _confirmDelete(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
AiPersonaItem 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 ? '删掉之后会自动回到「默认」那套。' : ''}',
|
|
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).deletePersona(item.id!);
|
|
if (!context.mounted) return;
|
|
|
|
// 删掉的正好是「新对话预选的那条」→ 忘掉它,别让下次新对话
|
|
// 预选一个已经不存在的 id(服务端会兜底退回默认,但界面上会显得莫名其妙)
|
|
if (ref.read(preferredPersonaProvider) == item.id) {
|
|
ref.read(preferredPersonaProvider.notifier).forget();
|
|
}
|
|
ref.invalidate(aiPersonasProvider);
|
|
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 _PersonaEditor extends ConsumerStatefulWidget {
|
|
const _PersonaEditor({this.existing});
|
|
|
|
/// null = 新增
|
|
final AiPersonaItem? existing;
|
|
|
|
@override
|
|
ConsumerState<_PersonaEditor> createState() => _PersonaEditorState();
|
|
}
|
|
|
|
class _PersonaEditorState extends ConsumerState<_PersonaEditor> {
|
|
late final TextEditingController _name;
|
|
late final TextEditingController _prompt;
|
|
|
|
bool _saving = false;
|
|
|
|
/// 当前头像相对路径。null = 没有头像。
|
|
///
|
|
/// 三种状态要分清(和后端语义对齐):
|
|
/// · 进来时的原值 → 不传,保持不动
|
|
/// · 用户选了新图 → 传新路径
|
|
/// · 用户点了「移除头像」→ 传 clearAvatar=true
|
|
String? _avatar;
|
|
|
|
/// 是否显式要求清空头像
|
|
bool _clearAvatar = false;
|
|
|
|
/// 上传中(转圈 + 禁止重复点)
|
|
bool _uploading = false;
|
|
|
|
bool get _isEdit => widget.existing != null;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final e = widget.existing;
|
|
_name = TextEditingController(text: e?.name ?? '');
|
|
// 新增时预填内置默认文本 —— 面对的不是空白框,是一份能跑的。
|
|
// 拿不到(列表还没加载出来)就先空着,用户手动点「恢复默认」也能填
|
|
_prompt = TextEditingController(
|
|
text: e?.systemPrompt ?? ref.read(defaultPersonaPromptProvider) ?? '',
|
|
);
|
|
_avatar = e?.avatar;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_name.dispose();
|
|
_prompt.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
/// 从相册或相机选一张头像。
|
|
///
|
|
/// 这里**传了压缩参数**(和水印那个「要原图」的场景不同)——
|
|
/// 头像显示尺寸才 40px,传几 MB 的原图纯属浪费流量和服务器空间。
|
|
Future<void> _pickAvatar(ImageSource source) async {
|
|
if (_uploading) return;
|
|
try {
|
|
final picked = await ImagePicker().pickImage(
|
|
source: source,
|
|
imageQuality: 80,
|
|
maxWidth: 800,
|
|
);
|
|
if (picked == null || !mounted) return;
|
|
|
|
setState(() => _uploading = true);
|
|
final path = await ref.read(aiApiProvider).uploadPersonaAvatar(picked.path);
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
_avatar = path;
|
|
_clearAvatar = false;
|
|
});
|
|
showToast(context, '头像已上传,记得保存', icon: 'check');
|
|
} on ApiException catch (e) {
|
|
if (mounted) showToast(context, e.message, error: true);
|
|
} catch (e) {
|
|
if (mounted) showToast(context, '上传失败:$e', error: true);
|
|
} finally {
|
|
if (mounted) setState(() => _uploading = false);
|
|
}
|
|
}
|
|
|
|
/// 底部选「拍照 / 相册」
|
|
Future<void> _showAvatarPicker() async {
|
|
final source = await showModalBottomSheet<ImageSource>(
|
|
context: context,
|
|
backgroundColor: Colors.white,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
builder: (sheetContext) => SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ListTile(
|
|
leading: const Icon(Icons.photo_camera_outlined, color: Color(0xFFC8956E)),
|
|
title: const Text('拍一张', style: TextStyle(fontSize: 14.5)),
|
|
onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera),
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.photo_library_outlined, color: Color(0xFFC8956E)),
|
|
title: const Text('从相册选', style: TextStyle(fontSize: 14.5)),
|
|
onTap: () => Navigator.of(sheetContext).pop(ImageSource.gallery),
|
|
),
|
|
if ((_avatar?.isNotEmpty ?? false))
|
|
ListTile(
|
|
leading: const Icon(Icons.delete_outline, color: Color(0xFFE53935)),
|
|
title: const Text('移除头像',
|
|
style: TextStyle(fontSize: 14.5, color: Color(0xFFE53935))),
|
|
onTap: () {
|
|
Navigator.of(sheetContext).pop();
|
|
setState(() {
|
|
_avatar = null;
|
|
_clearAvatar = true;
|
|
});
|
|
},
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
if (source == null || !mounted) return;
|
|
await _pickAvatar(source);
|
|
}
|
|
|
|
Future<void> _save() async {
|
|
final prompt = _prompt.text.trim();
|
|
if (prompt.isEmpty) {
|
|
showToast(context, '提示词不能为空', error: true);
|
|
return;
|
|
}
|
|
|
|
setState(() => _saving = true);
|
|
try {
|
|
final api = ref.read(aiApiProvider);
|
|
// 只有「选了新图」才传 avatar;没动就不传(后端保持原值)
|
|
final newAvatar = _clearAvatar ? null : _avatar;
|
|
if (_isEdit) {
|
|
await api.updatePersona(
|
|
id: widget.existing!.id!,
|
|
name: _name.text.trim(),
|
|
systemPrompt: prompt,
|
|
avatar: newAvatar,
|
|
clearAvatar: _clearAvatar,
|
|
);
|
|
} else {
|
|
await api.createPersona(
|
|
name: _name.text.trim(),
|
|
systemPrompt: prompt,
|
|
avatar: newAvatar,
|
|
);
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// 头像选择区:一个圆形头像 + 下面一行小字。
|
|
///
|
|
/// 点它弹「拍照 / 相册 / 移除」的选择。已上传时头像立即预览
|
|
/// (用的是刚传回来的相对路径,本地也能看到)。
|
|
Widget _avatarPicker() {
|
|
return GestureDetector(
|
|
onTap: _uploading ? null : _showAvatarPicker,
|
|
child: Column(
|
|
children: [
|
|
Stack(
|
|
children: [
|
|
Avatar(
|
|
assetPath: _avatar,
|
|
size: 76,
|
|
fallbackIcon: 'assistant',
|
|
),
|
|
// 悬一个小相机角标,暗示「可以点」
|
|
Positioned(
|
|
right: 0,
|
|
bottom: 0,
|
|
child: Container(
|
|
width: 24,
|
|
height: 24,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: const Color(0xFFC8956E),
|
|
border: Border.all(color: Colors.white, width: 2),
|
|
),
|
|
child: const Icon(Icons.camera_alt,
|
|
size: 12, color: Colors.white),
|
|
),
|
|
),
|
|
if (_uploading)
|
|
Positioned.fill(
|
|
child: Container(
|
|
decoration: const BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: Colors.black26,
|
|
),
|
|
child: const Center(
|
|
child: SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
_uploading ? '上传中…' : '点头像换个形象',
|
|
style: const TextStyle(fontSize: 12, color: Color(0xFFBCAAA4)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _restoreDefault() {
|
|
final def = ref.read(defaultPersonaPromptProvider);
|
|
if (def == null || def.isEmpty) {
|
|
showToast(context, '默认人设还没加载出来,稍后再试', error: true);
|
|
return;
|
|
}
|
|
setState(() => _prompt.text = def);
|
|
showToast(context, '已填入默认人设,记得保存');
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// 是否提示「没提到工具」——只提示,不阻止保存
|
|
final lacksToolHint = _prompt.text.trim().isNotEmpty &&
|
|
!_prompt.text.contains('工具');
|
|
|
|
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),
|
|
|
|
// 头像放最上面 —— 它是「这个人长什么样」的第一印象
|
|
Center(child: _avatarPicker()),
|
|
const SizedBox(height: 18),
|
|
|
|
_field(
|
|
label: '名称',
|
|
controller: _name,
|
|
hint: '留空则取提示词开头几个字,比如:花花',
|
|
maxLines: 1,
|
|
),
|
|
_field(
|
|
label: '提示词',
|
|
controller: _prompt,
|
|
hint: '比如:你是一个说话简洁的记账助手…',
|
|
helper: '人设 + 运行规则都写这里。日期由服务端自动补,不用写。',
|
|
maxLines: null,
|
|
minLines: 12,
|
|
),
|
|
|
|
if (lacksToolHint) ...[
|
|
const SizedBox(height: 2),
|
|
_warnBox(),
|
|
],
|
|
|
|
const SizedBox(height: 6),
|
|
// 「恢复默认」放左边,和右边的取消/保存隔开 —— 避免误点
|
|
Row(
|
|
children: [
|
|
TextButton.icon(
|
|
onPressed: _saving ? null : _restoreDefault,
|
|
icon: const Icon(Icons.settings_backup_restore, size: 16),
|
|
label: const Text('恢复默认', style: TextStyle(fontSize: 12.5)),
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: const Color(0xFF8D6E63),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 10),
|
|
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 _warnBox() {
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(11),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFFFF8E1),
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(color: const Color(0xFFFFE082)),
|
|
),
|
|
child: const Text(
|
|
'提示:没提到「工具」。助手可能会凭印象编造金额和数字 ——\n'
|
|
'建议保留「必须用工具查询,不要编造数字」这类要求。',
|
|
style: TextStyle(fontSize: 11.5, height: 1.65, color: Color(0xFF8A6D1F)),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _field({
|
|
required String label,
|
|
required TextEditingController controller,
|
|
required String hint,
|
|
String? helper,
|
|
int? maxLines = 1,
|
|
int? minLines,
|
|
}) {
|
|
// 单行 vs 多行:maxLines 为 null 或 >1 都算多行
|
|
final multiline = maxLines == null || maxLines != 1;
|
|
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,
|
|
maxLines: maxLines,
|
|
minLines: minLines,
|
|
// 多行时让文字从顶部开始,否则会垂直居中显得很怪
|
|
textAlignVertical:
|
|
multiline ? TextAlignVertical.top : TextAlignVertical.center,
|
|
keyboardType: multiline ? TextInputType.multiline : TextInputType.text,
|
|
autocorrect: false,
|
|
enableSuggestions: false,
|
|
// 边打字边刷新 —— 上面那个软提示要根据内容变
|
|
onChanged: multiline ? (_) => setState(() {}) : null,
|
|
style: TextStyle(
|
|
fontSize: multiline ? 13 : 14,
|
|
height: multiline ? 1.75 : null,
|
|
color: const Color(0xFF5D4037),
|
|
),
|
|
decoration: InputDecoration(
|
|
border: InputBorder.none,
|
|
isDense: true,
|
|
contentPadding: EdgeInsets.symmetric(
|
|
vertical: multiline ? 12 : 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)),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|