3df85fdb04
Includes legal document generation workflows, prompt governance runtime wiring, legal change impact scans, operation KPI dashboards, invoice files, sales follow-up events, mobile review polish, deployment automation, and updated project docs.
392 lines
14 KiB
Dart
392 lines
14 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../../../shared/widgets/app_scaffold_page.dart';
|
|
import '../../../shared/widgets/app_section_card.dart';
|
|
import '../application/contract_generation_controller.dart';
|
|
import '../domain/contract_models.dart';
|
|
import '../domain/contract_review_context.dart';
|
|
import 'generated_contract_result_screen.dart';
|
|
|
|
class ContractBuilderScreen extends StatefulWidget {
|
|
const ContractBuilderScreen({
|
|
super.key,
|
|
required this.template,
|
|
this.reviewContext,
|
|
this.initialVariableValues,
|
|
this.initialExportFormat = 'docx',
|
|
});
|
|
|
|
final ContractTemplateItem template;
|
|
final ContractReviewContext? reviewContext;
|
|
final Map<String, String>? initialVariableValues;
|
|
final String initialExportFormat;
|
|
|
|
@override
|
|
State<ContractBuilderScreen> createState() => _ContractBuilderScreenState();
|
|
}
|
|
|
|
class _ContractBuilderScreenState extends State<ContractBuilderScreen> {
|
|
final ContractGenerationController _controller =
|
|
ContractGenerationController();
|
|
final Map<String, TextEditingController> _fieldControllers =
|
|
<String, TextEditingController>{};
|
|
late String _exportFormat;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_exportFormat = widget.initialExportFormat;
|
|
for (final variable in widget.template.variables) {
|
|
_fieldControllers[variable.name] = TextEditingController(
|
|
text: _buildInitialValue(variable),
|
|
);
|
|
}
|
|
}
|
|
|
|
String _buildInitialValue(ContractTemplateVariable variable) {
|
|
final restoredValue = widget.initialVariableValues?[variable.name];
|
|
if (restoredValue != null && restoredValue.trim().isNotEmpty) {
|
|
return restoredValue;
|
|
}
|
|
final reviewContextValue = buildReviewContextVariableValue(
|
|
variable.name,
|
|
variable.label,
|
|
variable.description,
|
|
widget.reviewContext,
|
|
);
|
|
if (reviewContextValue.isNotEmpty) {
|
|
return reviewContextValue;
|
|
}
|
|
return variable.defaultValue;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
for (final controller in _fieldControllers.values) {
|
|
controller.dispose();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
Map<String, String> get _variableValues {
|
|
return _fieldControllers.map(
|
|
(key, value) => MapEntry(key, value.text.trim()),
|
|
);
|
|
}
|
|
|
|
String? _validateRequiredFields() {
|
|
for (final variable in widget.template.variables) {
|
|
if (variable.required) {
|
|
final value = _fieldControllers[variable.name]?.text.trim() ?? '';
|
|
if (value.isEmpty) {
|
|
return '请先填写“${variable.label}”。';
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
Future<void> _preview() async {
|
|
final validationError = _validateRequiredFields();
|
|
if (validationError != null) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(validationError)),
|
|
);
|
|
return;
|
|
}
|
|
|
|
await _controller.loadPreview(
|
|
templateId: widget.template.id,
|
|
variableValues: _variableValues,
|
|
);
|
|
final error = _controller.errorMessage.value;
|
|
if (!mounted || error == null || error.isEmpty) {
|
|
return;
|
|
}
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(error)),
|
|
);
|
|
}
|
|
|
|
Future<void> _generate() async {
|
|
final validationError = _validateRequiredFields();
|
|
if (validationError != null) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(validationError)),
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
final contract = await _controller.generate(
|
|
templateId: widget.template.id,
|
|
variableValues: _variableValues,
|
|
exportFormat: _exportFormat,
|
|
);
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
await Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => GeneratedContractResultScreen(contract: contract),
|
|
),
|
|
);
|
|
} on ContractGenerationException catch (error) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(error.message)),
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final template = widget.template;
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(template.name),
|
|
),
|
|
body: ListView(
|
|
padding: EdgeInsets.zero,
|
|
children: [
|
|
AppScaffoldPage(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
AppSectionCard(
|
|
title: '模板信息',
|
|
subtitle: '先确认模板说明,再填写变量并预览生成结果。',
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_InfoLine(label: '合同类型', value: template.contractType),
|
|
if (template.description.isNotEmpty) ...[
|
|
const SizedBox(height: 12),
|
|
_InfoLine(label: '模板说明', value: template.description),
|
|
],
|
|
if (template.previewExcerpt.isNotEmpty) ...[
|
|
const SizedBox(height: 12),
|
|
_InfoLine(
|
|
label: '预览摘要', value: template.previewExcerpt),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
if (widget.reviewContext != null) ...[
|
|
const SizedBox(height: 16),
|
|
AppSectionCard(
|
|
title: '审查上下文已带入',
|
|
subtitle: '系统已尝试把审查意见、风险项和建议动作预填到匹配变量中,你可以继续编辑后再生成。',
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_InfoLine(
|
|
label: '审查任务',
|
|
value: widget.reviewContext!.reviewTitle ?? '-'),
|
|
const SizedBox(height: 12),
|
|
_InfoLine(
|
|
label: '合同类型',
|
|
value: widget.reviewContext!.contractType ?? '-'),
|
|
const SizedBox(height: 12),
|
|
_InfoLine(
|
|
label: '总体风险',
|
|
value: widget.reviewContext!.overallRisk ?? '-'),
|
|
if (widget.reviewContext!.reviewOpinion != null &&
|
|
widget
|
|
.reviewContext!.reviewOpinion!.isNotEmpty) ...[
|
|
const SizedBox(height: 12),
|
|
_InfoLine(
|
|
label: '审查意见',
|
|
value: widget.reviewContext!.reviewOpinion!),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 16),
|
|
AppSectionCard(
|
|
title: '变量填写',
|
|
subtitle: '优先填写必填变量,确认导出格式后再预览和生成。',
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
for (final variable in template.variables) ...[
|
|
TextField(
|
|
controller: _fieldControllers[variable.name],
|
|
minLines: variable.description.length > 24 ? 2 : 1,
|
|
maxLines: variable.description.length > 24 ? 3 : 1,
|
|
decoration: InputDecoration(
|
|
labelText: variable.required
|
|
? '${variable.label} *'
|
|
: variable.label,
|
|
hintText: variable.description.isNotEmpty
|
|
? variable.description
|
|
: null,
|
|
),
|
|
),
|
|
if (variable != template.variables.last)
|
|
const SizedBox(height: 12),
|
|
],
|
|
const SizedBox(height: 16),
|
|
DropdownButtonFormField<String>(
|
|
initialValue: _exportFormat,
|
|
decoration: const InputDecoration(
|
|
labelText: '导出格式',
|
|
),
|
|
items: const <DropdownMenuItem<String>>[
|
|
DropdownMenuItem<String>(
|
|
value: 'docx', child: Text('Word (.docx)')),
|
|
DropdownMenuItem<String>(
|
|
value: 'pdf', child: Text('PDF (.pdf)')),
|
|
DropdownMenuItem<String>(
|
|
value: 'html', child: Text('HTML (.html)')),
|
|
DropdownMenuItem<String>(
|
|
value: 'txt', child: Text('文本 (.txt)')),
|
|
],
|
|
onChanged: (value) {
|
|
if (value == null) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_exportFormat = value;
|
|
});
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
ValueListenableBuilder<ContractPreviewItem?>(
|
|
valueListenable: _controller.preview,
|
|
builder: (context, preview, _) {
|
|
if (preview == null) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
return Column(
|
|
children: [
|
|
AppSectionCard(
|
|
title: '合同预览',
|
|
subtitle: '这里只是生成前预览,不会写入正式合同记录。',
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (preview.variableSummary.isNotEmpty) ...[
|
|
for (final item in preview.variableSummary) ...[
|
|
_InfoLine(
|
|
label: item.label,
|
|
value: item.valuePreview.isEmpty
|
|
? '(空)'
|
|
: item.valuePreview,
|
|
),
|
|
if (item != preview.variableSummary.last)
|
|
const SizedBox(height: 12),
|
|
],
|
|
const SizedBox(height: 16),
|
|
],
|
|
Text(preview.previewContent),
|
|
const SizedBox(height: 16),
|
|
_InfoLine(
|
|
label: '当前导出格式',
|
|
value: _exportFormat.toUpperCase()),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
ValueListenableBuilder<String?>(
|
|
valueListenable: _controller.errorMessage,
|
|
builder: (context, message, _) {
|
|
if (message == null || message.isEmpty) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 16),
|
|
child: Text(
|
|
message,
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.error),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: ValueListenableBuilder<bool>(
|
|
valueListenable: _controller.isPreviewing,
|
|
builder: (context, isPreviewing, _) {
|
|
return OutlinedButton.icon(
|
|
onPressed: isPreviewing ? null : _preview,
|
|
icon: isPreviewing
|
|
? const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.visibility_outlined),
|
|
label: Text(isPreviewing ? '预览中...' : '预览合同'),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: ValueListenableBuilder<bool>(
|
|
valueListenable: _controller.isGenerating,
|
|
builder: (context, isGenerating, _) {
|
|
return FilledButton.icon(
|
|
onPressed: isGenerating ? null : _generate,
|
|
icon: isGenerating
|
|
? const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.auto_awesome),
|
|
label: Text(isGenerating ? '生成中...' : '生成合同'),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _InfoLine extends StatelessWidget {
|
|
const _InfoLine({
|
|
required this.label,
|
|
required this.value,
|
|
});
|
|
|
|
final String label;
|
|
final String value;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(label, style: Theme.of(context).textTheme.labelLarge),
|
|
const SizedBox(height: 4),
|
|
Text(value.isEmpty ? '-' : value),
|
|
],
|
|
);
|
|
}
|
|
}
|