Browse Source

fix #19407 优化界面

chengc 1 week ago
parent
commit
25652474ef

+ 1 - 0
assets/i18n/en.json

@@ -42,6 +42,7 @@
     "edit": "Edit",
     "add": "Add",
     "close": "Close",
+    "copy": "Copy",
     "retry": "Retry",
     "confirmAdd": "Confirm Add",
     "confirmEdit": "Confirm Edit",

+ 1 - 0
assets/i18n/zh_CN.json

@@ -39,6 +39,7 @@
     "edit": "编辑",
     "add": "添加",
     "close": "关闭",
+    "copy": "复制",
     "retry": "重试",
     "confirmAdd": "确认添加",
     "confirmEdit": "确认修改",

+ 1 - 0
assets/i18n/zh_TW.json

@@ -42,6 +42,7 @@
     "edit": "編輯",
     "add": "添加",
     "close": "關閉",
+    "copy": "複製",
     "retry": "重試",
     "confirmAdd": "確認添加",
     "confirmEdit": "確認修改",

+ 27 - 39
lib/features/expense/expense_detail_page.dart

@@ -245,16 +245,23 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
         children: [
           Icon(icon, size: 18, color: color),
           const SizedBox(width: 4),
-          Text(text, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: color)),
+          Text(
+            text,
+            style: TextStyle(
+              fontSize: 13,
+              fontWeight: FontWeight.w600,
+              color: color,
+            ),
+          ),
         ],
       ),
     );
-    final canTap = approvalStatus != null && approvalStatus.isNotEmpty && approvalText.isNotEmpty;
+    final canTap =
+        approvalStatus != null &&
+        approvalStatus.isNotEmpty &&
+        approvalText.isNotEmpty;
     if (canTap) {
-      return GestureDetector(
-        onTap: () => _showAuditTrail('BX'),
-        child: tag,
-      );
+      return GestureDetector(onTap: () => _showAuditTrail('BX'), child: tag);
     }
     return tag;
   }
@@ -306,34 +313,13 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
           showArrow: false,
         ),
         const SizedBox(height: 16),
-        SizedBox(
-          height: 24,
-          child: Row(
-            crossAxisAlignment: CrossAxisAlignment.center,
-            children: [
-              Text(
-                l10n.get('expenseReason'),
-                style: TextStyle(
-                  fontSize: AppFontSizes.subtitle,
-                  color: colors.textSecondary,
-                ),
-              ),
-              const SizedBox(width: 8),
-              Expanded(
-                child: Text(
-                  expense.purpose.isNotEmpty ? expense.purpose : '-',
-                  textAlign: TextAlign.end,
-                  maxLines: 2,
-                  overflow: TextOverflow.ellipsis,
-                  style: TextStyle(
-                    fontSize: AppFontSizes.subtitle,
-                    fontWeight: FontWeight.w500,
-                    color: colors.textPrimary,
-                  ),
-                ),
-              ),
-            ],
-          ),
+        FormFieldRow(
+          label: l10n.get('expenseReason'),
+          value: expense.purpose.isNotEmpty ? expense.purpose : '-',
+          readOnly: true,
+          showArrow: false,
+          bold: true,
+          showMoreOnOverflow: true,
         ),
         const SizedBox(height: 16),
         FormFieldRow(
@@ -631,7 +617,9 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
                 style: TextStyle(
                   fontSize: AppFontSizes.subtitle,
                   fontWeight: FontWeight.w700,
-                  color: totalApproved > 0 ? colors.success : colors.textPrimary,
+                  color: totalApproved > 0
+                      ? colors.success
+                      : colors.textPrimary,
                 ),
               ),
             ],
@@ -790,10 +778,10 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
             const SizedBox(width: 8),
             GestureDetector(
               onTap: () => AttachmentDownloadHelper.downloadAndSave(
-                    context,
-                    a,
-                    ref.read(expenseApiProvider).downloadAttachment,
-                  ),
+                context,
+                a,
+                ref.read(expenseApiProvider).downloadAttachment,
+              ),
               child: Icon(
                 Icons.download_outlined,
                 size: 22,

+ 23 - 20
lib/features/expense_apply/expense_apply_create_page.dart

@@ -134,8 +134,9 @@ class _ExpenseApplyCreatePageState
       setState(() {
         _departments = results[0] as List<DepartmentItem>;
         _acctTree = _convertAcctTree(results[1]);
-        _currentUserAsEmployee =
-            employeeCheck.isNotEmpty ? employeeCheck.first : null;
+        _currentUserAsEmployee = employeeCheck.isNotEmpty
+            ? employeeCheck.first
+            : null;
         _refDataLoading = false;
         _autoSelectDept();
       });
@@ -479,21 +480,23 @@ class _ExpenseApplyCreatePageState
             ],
           ),
         ),
-        const Spacer(),
-        Row(
-          mainAxisSize: MainAxisSize.min,
-          children: Urgency.values.asMap().entries.map((e) {
-            final sel = _urgency == e.value.value;
-            final isCritical = e.value.value == Urgency.critical.value;
-            final isUrgent = e.value.value == Urgency.urgent.value;
-            final activeColor = isCritical
-                ? colors.danger
-                : isUrgent
-                ? colors.warning
-                : colors.primary;
-            return Padding(
-              padding: EdgeInsets.only(left: e.key > 0 ? 18 : 0),
-              child: GestureDetector(
+        const SizedBox(width: 8),
+        Expanded(
+          child: Wrap(
+            alignment: WrapAlignment.end,
+            crossAxisAlignment: WrapCrossAlignment.center,
+            spacing: 18,
+            runSpacing: 8,
+            children: Urgency.values.asMap().entries.map((e) {
+              final sel = _urgency == e.value.value;
+              final isCritical = e.value.value == Urgency.critical.value;
+              final isUrgent = e.value.value == Urgency.urgent.value;
+              final activeColor = isCritical
+                  ? colors.danger
+                  : isUrgent
+                  ? colors.warning
+                  : colors.primary;
+              return GestureDetector(
                 behavior: HitTestBehavior.opaque,
                 onTap: () => setState(() => _urgency = e.value.value),
                 child: Row(
@@ -532,9 +535,9 @@ class _ExpenseApplyCreatePageState
                     ),
                   ],
                 ),
-              ),
-            );
-          }).toList(),
+              );
+            }).toList(),
+          ),
         ),
       ],
     );

+ 36 - 25
lib/features/expense_apply/expense_apply_detail_page.dart

@@ -266,16 +266,23 @@ class _ExpenseApplyDetailPageState
         children: [
           Icon(icon, size: 18, color: color),
           const SizedBox(width: 4),
-          Text(text, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: color)),
+          Text(
+            text,
+            style: TextStyle(
+              fontSize: 13,
+              fontWeight: FontWeight.w600,
+              color: color,
+            ),
+          ),
         ],
       ),
     );
-    final canTap = approvalStatus != null && approvalStatus.isNotEmpty && approvalText.isNotEmpty;
+    final canTap =
+        approvalStatus != null &&
+        approvalStatus.isNotEmpty &&
+        approvalText.isNotEmpty;
     if (canTap) {
-      return GestureDetector(
-        onTap: () => _showAuditTrail('AE'),
-        child: tag,
-      );
+      return GestureDetector(onTap: () => _showAuditTrail('AE'), child: tag);
     }
     return tag;
   }
@@ -318,21 +325,18 @@ class _ExpenseApplyDetailPageState
           showArrow: false,
         ),
         const SizedBox(height: 16),
-        SizedBox(
-          height: 24,
-          child: Row(
-            mainAxisAlignment: MainAxisAlignment.spaceBetween,
-            children: [
-              Text(
-                l10n.get('emergencyLevel'),
-                style: TextStyle(
-                  fontSize: AppFontSizes.subtitle,
-                  color: colors.textSecondary,
-                ),
+        Row(
+          mainAxisAlignment: MainAxisAlignment.spaceBetween,
+          children: [
+            Text(
+              l10n.get('emergencyLevel'),
+              style: TextStyle(
+                fontSize: AppFontSizes.subtitle,
+                color: colors.textSecondary,
               ),
-              _buildUrgencyChip(app.urgency, l10n, colors),
-            ],
-          ),
+            ),
+            _buildUrgencyChip(app.urgency, l10n, colors),
+          ],
         ),
         const SizedBox(height: 16),
         FormFieldRow(
@@ -341,6 +345,7 @@ class _ExpenseApplyDetailPageState
           readOnly: true,
           showArrow: false,
           bold: true,
+          showMoreOnOverflow: true,
         ),
         const SizedBox(height: 16),
         FormFieldRow(
@@ -349,6 +354,7 @@ class _ExpenseApplyDetailPageState
           readOnly: true,
           showArrow: false,
           bold: false,
+          showMoreOnOverflow: true,
         ),
       ],
     );
@@ -528,7 +534,12 @@ class _ExpenseApplyDetailPageState
                 ),
               ),
               Text(
-                formatAmount(app.details.fold<double>(0, (sum, d) => sum + d.estimatedAmount)),
+                formatAmount(
+                  app.details.fold<double>(
+                    0,
+                    (sum, d) => sum + d.estimatedAmount,
+                  ),
+                ),
                 style: TextStyle(
                   fontSize: AppFontSizes.subtitle,
                   fontWeight: FontWeight.w700,
@@ -631,10 +642,10 @@ class _ExpenseApplyDetailPageState
             const SizedBox(width: 8),
             GestureDetector(
               onTap: () => AttachmentDownloadHelper.downloadAndSave(
-                    context,
-                    a,
-                    ref.read(expenseApplyApiProvider).downloadAttachment,
-                  ),
+                context,
+                a,
+                ref.read(expenseApplyApiProvider).downloadAttachment,
+              ),
               child: Icon(
                 Icons.download_outlined,
                 size: 22,

+ 44 - 47
lib/features/expense_apply/expense_apply_edit_page.dart

@@ -1,4 +1,4 @@
-import 'dart:async';
+import 'dart:async';
 
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
@@ -105,9 +105,7 @@ class _ExpenseApplyEditPageState extends ConsumerState<ExpenseApplyEditPage> {
     }
     try {
       final api = ref.read(expenseApplyApiProvider);
-      final results = await Future.wait([
-        api.getAcctSubjects(),
-      ]);
+      final results = await Future.wait([api.getAcctSubjects()]);
       if (!mounted) return;
       setState(() {
         _acctTree = _convertAcctTree(results[0]);
@@ -173,25 +171,27 @@ class _ExpenseApplyEditPageState extends ConsumerState<ExpenseApplyEditPage> {
             endDateStr =
                 '${e.year}-${e.month.toString().padLeft(2, '0')}-${e.day.toString().padLeft(2, '0')}';
           }
-          _details.add(_DetailItem(
-            id: _detailIdCounter++,
-            category: d.expenseCategory,
-            categoryName: d.categoryName,
-            acctSubjectId: d.acctSubjectId,
-            acctSubjectName: d.acctSubjectName,
-            purpose: d.purpose,
-            projectId: d.projectId,
-            projectName: d.projectName,
-            costDeptId: d.costDeptId,
-            costDeptName: d.costDeptName,
-            startDate: startDateStr,
-            endDate: endDateStr,
-            estimatedAmount: d.estimatedAmount,
-            remark: d.remark,
-            preItm: d.preItm,
-            sqMan: d.sqMan,
-            sqManName: d.sqName,
-          ));
+          _details.add(
+            _DetailItem(
+              id: _detailIdCounter++,
+              category: d.expenseCategory,
+              categoryName: d.categoryName,
+              acctSubjectId: d.acctSubjectId,
+              acctSubjectName: d.acctSubjectName,
+              purpose: d.purpose,
+              projectId: d.projectId,
+              projectName: d.projectName,
+              costDeptId: d.costDeptId,
+              costDeptName: d.costDeptName,
+              startDate: startDateStr,
+              endDate: endDateStr,
+              estimatedAmount: d.estimatedAmount,
+              remark: d.remark,
+              preItm: d.preItm,
+              sqMan: d.sqMan,
+              sqManName: d.sqName,
+            ),
+          );
         }
 
         _loadingBill = false;
@@ -400,21 +400,23 @@ class _ExpenseApplyEditPageState extends ConsumerState<ExpenseApplyEditPage> {
             ],
           ),
         ),
-        const Spacer(),
-        Row(
-          mainAxisSize: MainAxisSize.min,
-          children: Urgency.values.asMap().entries.map((e) {
-            final sel = _urgency == e.value.value;
-            final isCritical = e.value.value == Urgency.critical.value;
-            final isUrgent = e.value.value == Urgency.urgent.value;
-            final activeColor = isCritical
-                ? colors.danger
-                : isUrgent
-                    ? colors.warning
-                    : colors.primary;
-            return Padding(
-              padding: EdgeInsets.only(left: e.key > 0 ? 18 : 0),
-              child: GestureDetector(
+        const SizedBox(width: 8),
+        Expanded(
+          child: Wrap(
+            alignment: WrapAlignment.end,
+            crossAxisAlignment: WrapCrossAlignment.center,
+            spacing: 18,
+            runSpacing: 8,
+            children: Urgency.values.asMap().entries.map((e) {
+              final sel = _urgency == e.value.value;
+              final isCritical = e.value.value == Urgency.critical.value;
+              final isUrgent = e.value.value == Urgency.urgent.value;
+              final activeColor = isCritical
+                  ? colors.danger
+                  : isUrgent
+                  ? colors.warning
+                  : colors.primary;
+              return GestureDetector(
                 behavior: HitTestBehavior.opaque,
                 onTap: () => setState(() => _urgency = e.value.value),
                 child: Row(
@@ -453,9 +455,9 @@ class _ExpenseApplyEditPageState extends ConsumerState<ExpenseApplyEditPage> {
                     ),
                   ],
                 ),
-              ),
-            );
-          }).toList(),
+              );
+            }).toList(),
+          ),
         ),
       ],
     );
@@ -727,8 +729,6 @@ class _ExpenseApplyEditPageState extends ConsumerState<ExpenseApplyEditPage> {
     }).toList();
   }
 
-
-
   // ═══ 3. 底部操作栏 ═══
   Widget _buildBottomBar(AppLocalizations l10n) {
     return ActionBar(
@@ -749,10 +749,7 @@ class _ExpenseApplyEditPageState extends ConsumerState<ExpenseApplyEditPage> {
           await api.submit(data);
           if (mounted) {
             LoadingDialog.hide(context);
-            TDToast.showSuccess(
-              l10n.get('submitSuccess'),
-              context: context,
-            );
+            TDToast.showSuccess(l10n.get('submitSuccess'), context: context);
             WidgetsBinding.instance.addPostFrameCallback((_) {
               if (mounted) GoRouter.of(context).pop(true);
             });

+ 120 - 93
lib/features/report/expense_apply_detail_report_page.dart

@@ -42,9 +42,11 @@ class _ExpenseApplyDetailReportPageState
     super.initState();
     final now = DateTime.now();
     final twelveMonthsAgo = DateTime(now.year, now.month - 11, 1);
-    _startCtrl.text = '${twelveMonthsAgo.year}-${twelveMonthsAgo.month.toString().padLeft(2, '0')}-01';
+    _startCtrl.text =
+        '${twelveMonthsAgo.year}-${twelveMonthsAgo.month.toString().padLeft(2, '0')}-01';
     final lastDay = DateTime(now.year, now.month + 1, 0).day;
-    _endCtrl.text = '${now.year}-${now.month.toString().padLeft(2, '0')}-${lastDay.toString().padLeft(2, '0')}';
+    _endCtrl.text =
+        '${now.year}-${now.month.toString().padLeft(2, '0')}-${lastDay.toString().padLeft(2, '0')}';
     _loadData();
     _scrollCtrl.addListener(_onScroll);
   }
@@ -142,8 +144,12 @@ class _ExpenseApplyDetailReportPageState
   }
 
   void _onScroll() {
-    if (_scrollCtrl.position.pixels < _scrollCtrl.position.maxScrollExtent - 200) return;
-    if (_detailTotal > 0 && _details.length < _detailTotal && !_detailLoadingMore) {
+    if (_scrollCtrl.position.pixels <
+        _scrollCtrl.position.maxScrollExtent - 200)
+      return;
+    if (_detailTotal > 0 &&
+        _details.length < _detailTotal &&
+        !_detailLoadingMore) {
       _loadMoreDetails();
     }
   }
@@ -480,7 +486,10 @@ class _ExpenseApplyDetailReportPageState
                 Container(
                   width: 8,
                   height: 8,
-                  decoration: BoxDecoration(color: colors.amountPrimary, shape: BoxShape.circle),
+                  decoration: BoxDecoration(
+                    color: colors.amountPrimary,
+                    shape: BoxShape.circle,
+                  ),
                 ),
                 const SizedBox(width: 4),
                 Text(
@@ -528,7 +537,12 @@ class _ExpenseApplyDetailReportPageState
     if (amountData.isEmpty) {
       return SizedBox(
         height: 200,
-        child: Center(child: Text(AppLocalizations.of(context).get('noData'), style: TextStyle(fontSize: 14, color: colors.textPlaceholder))),
+        child: Center(
+          child: Text(
+            AppLocalizations.of(context).get('noData'),
+            style: TextStyle(fontSize: 14, color: colors.textPlaceholder),
+          ),
+        ),
       );
     }
 
@@ -536,98 +550,105 @@ class _ExpenseApplyDetailReportPageState
     final maxVal = amountData.reduce((a, b) => a > b ? a : b);
     final maxY = (maxVal > 0 ? maxVal * 1.2 : 100).toDouble();
 
-    return LineChart(
-      LineChartData(
-        gridData: FlGridData(
-          show: true,
-          drawVerticalLine: false,
-          horizontalInterval: maxY / 4,
-          getDrawingHorizontalLine: (v) =>
-              FlLine(color: colors.border, strokeWidth: 0.5),
-        ),
-        titlesData: FlTitlesData(
-          leftTitles: AxisTitles(
-            sideTitles: SideTitles(
-              showTitles: true,
-              reservedSize: 80,
-              interval: maxY / 4,
-              getTitlesWidget: (v, meta) => Text(
-                formatAmount(v.toDouble()),
-                style: TextStyle(fontSize: 10, color: colors.textPlaceholder),
+    return Padding(
+      padding: const EdgeInsets.only(top: 8),
+      child: LineChart(
+        LineChartData(
+          gridData: FlGridData(
+            show: true,
+            drawVerticalLine: false,
+            horizontalInterval: maxY / 4,
+            getDrawingHorizontalLine: (v) =>
+                FlLine(color: colors.border, strokeWidth: 0.5),
+          ),
+          titlesData: FlTitlesData(
+            leftTitles: AxisTitles(
+              sideTitles: SideTitles(
+                showTitles: true,
+                reservedSize: 80,
+                interval: maxY / 4,
+                getTitlesWidget: (v, meta) => Text(
+                  formatAmount(v.toDouble()),
+                  style: TextStyle(fontSize: 10, color: colors.textPlaceholder),
+                ),
               ),
             ),
-          ),
-          bottomTitles: AxisTitles(
-            sideTitles: SideTitles(
-              showTitles: true,
-              interval: 1,
-              reservedSize: 26,
-              getTitlesWidget: (v, meta) {
-                final i = v.toInt();
-                if (i < 0 || i >= monthLabels.length) return const SizedBox();
-                return Padding(
-                  padding: const EdgeInsets.only(top: 4),
-                  child: Text(
-                    monthLabels[i],
-                    style: TextStyle(
-                      fontSize: 9,
-                      color: colors.textPlaceholder,
+            bottomTitles: AxisTitles(
+              sideTitles: SideTitles(
+                showTitles: true,
+                interval: 1,
+                reservedSize: 26,
+                getTitlesWidget: (v, meta) {
+                  final i = v.toInt();
+                  if (i < 0 || i >= monthLabels.length) return const SizedBox();
+                  return Padding(
+                    padding: const EdgeInsets.only(top: 4),
+                    child: Text(
+                      monthLabels[i],
+                      style: TextStyle(
+                        fontSize: 9,
+                        color: colors.textPlaceholder,
+                      ),
                     ),
-                  ),
-                );
-              },
+                  );
+                },
+              ),
             ),
-          ),
-          topTitles: const AxisTitles(
-            sideTitles: SideTitles(showTitles: false),
-          ),
-          rightTitles: const AxisTitles(
-            sideTitles: SideTitles(showTitles: false),
-          ),
-        ),
-        borderData: FlBorderData(show: false),
-        minY: 0,
-        maxY: maxY,
-        lineTouchData: LineTouchData(
-          enabled: true,
-          touchTooltipData: LineTouchTooltipData(
-            getTooltipItems: (spots) => spots.map((s) {
-              return LineTooltipItem(
-                '${monthLabels[s.spotIndex]}\n${formatAmount(s.y)}',
-                const TextStyle(
-                  color: Colors.white,
-                  fontSize: 12,
-                  fontWeight: FontWeight.w500,
-                ),
-              );
-            }).toList(),
-          ),
-        ),
-        lineBarsData: [
-          LineChartBarData(
-            spots: List.generate(
-              amountData.length,
-              (i) => FlSpot(i.toDouble(), amountData[i]),
+            topTitles: const AxisTitles(
+              sideTitles: SideTitles(showTitles: false),
             ),
-            isCurved: true,
-            preventCurveOverShooting: true,
-            color: colors.amountPrimary,
-            barWidth: 2.5,
-            dotData: FlDotData(
-              show: true,
-              getDotPainter: (spot, percent, barData, index) =>
-                  FlDotCirclePainter(
-                    radius: 3,
-                    color: colors.amountPrimary,
-                    strokeWidth: 0,
-                  ),
+            rightTitles: const AxisTitles(
+              sideTitles: SideTitles(showTitles: false),
             ),
-            belowBarData: BarAreaData(
-              show: true,
-              color: colors.amountPrimary.withValues(alpha: 0.08),
+          ),
+          borderData: FlBorderData(show: false),
+          minY: 0,
+          maxY: maxY,
+          lineTouchData: LineTouchData(
+            enabled: true,
+            touchTooltipData: LineTouchTooltipData(
+              fitInsideHorizontally: true,
+              fitInsideVertically: true,
+              getTooltipColor: (_) =>
+                  colors.textPrimary.withValues(alpha: 0.85),
+              getTooltipItems: (spots) => spots.map((s) {
+                return LineTooltipItem(
+                  '${monthLabels[s.spotIndex]}\n${formatAmount(s.y)}',
+                  const TextStyle(
+                    color: Colors.white,
+                    fontSize: 12,
+                    fontWeight: FontWeight.w500,
+                  ),
+                );
+              }).toList(),
             ),
           ),
-        ],
+          lineBarsData: [
+            LineChartBarData(
+              spots: List.generate(
+                amountData.length,
+                (i) => FlSpot(i.toDouble(), amountData[i]),
+              ),
+              isCurved: true,
+              preventCurveOverShooting: true,
+              color: colors.amountPrimary,
+              barWidth: 2.5,
+              dotData: FlDotData(
+                show: true,
+                getDotPainter: (spot, percent, barData, index) =>
+                    FlDotCirclePainter(
+                      radius: 3,
+                      color: colors.amountPrimary,
+                      strokeWidth: 0,
+                    ),
+              ),
+              belowBarData: BarAreaData(
+                show: true,
+                color: colors.amountPrimary.withValues(alpha: 0.08),
+              ),
+            ),
+          ],
+        ),
       ),
     );
   }
@@ -661,7 +682,11 @@ class _ExpenseApplyDetailReportPageState
             child: Column(
               mainAxisSize: MainAxisSize.min,
               children: [
-                Icon(Icons.inbox_outlined, size: 48, color: colors.textPlaceholder),
+                Icon(
+                  Icons.inbox_outlined,
+                  size: 48,
+                  color: colors.textPlaceholder,
+                ),
                 const SizedBox(height: 12),
                 Text(
                   l10n.get('noDetailData'),
@@ -791,7 +816,10 @@ class _ExpenseApplyDetailReportPageState
                   ),
                   if (d.isTransferred)
                     Container(
-                      padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
+                      padding: const EdgeInsets.symmetric(
+                        horizontal: 6,
+                        vertical: 1,
+                      ),
                       decoration: BoxDecoration(
                         color: colors.primary.withValues(alpha: 0.1),
                         borderRadius: BorderRadius.circular(4),
@@ -824,5 +852,4 @@ class _ExpenseApplyDetailReportPageState
       ],
     );
   }
-
 }

+ 302 - 156
lib/features/report/expense_detail_report_page.dart

@@ -44,9 +44,11 @@ class _ExpenseDetailReportPageState
     super.initState();
     final now = DateTime.now();
     final elevenMonthsAgo = DateTime(now.year, now.month - 11, 1);
-    _startCtrl.text = '${elevenMonthsAgo.year}-${elevenMonthsAgo.month.toString().padLeft(2, '0')}-01';
+    _startCtrl.text =
+        '${elevenMonthsAgo.year}-${elevenMonthsAgo.month.toString().padLeft(2, '0')}-01';
     final lastDay = DateTime(now.year, now.month + 1, 0).day;
-    _endCtrl.text = '${now.year}-${now.month.toString().padLeft(2, '0')}-${lastDay.toString().padLeft(2, '0')}';
+    _endCtrl.text =
+        '${now.year}-${now.month.toString().padLeft(2, '0')}-${lastDay.toString().padLeft(2, '0')}';
     _scrollCtrl.addListener(_onScroll);
     WidgetsBinding.instance.addPostFrameCallback((_) => _loadData());
   }
@@ -145,8 +147,12 @@ class _ExpenseDetailReportPageState
   }
 
   void _onScroll() {
-    if (_scrollCtrl.position.pixels < _scrollCtrl.position.maxScrollExtent - 200) return;
-    if (_detailTotal > 0 && _details.length < _detailTotal && !_detailLoadingMore) {
+    if (_scrollCtrl.position.pixels <
+        _scrollCtrl.position.maxScrollExtent - 200)
+      return;
+    if (_detailTotal > 0 &&
+        _details.length < _detailTotal &&
+        !_detailLoadingMore) {
       _loadMoreDetails();
     }
   }
@@ -160,7 +166,11 @@ class _ExpenseDetailReportPageState
         startDate: _startCtrl.text.isNotEmpty ? _startCtrl.text : null,
         endDate: _endCtrl.text.isNotEmpty ? _endCtrl.text : null,
       );
-      if (mounted) setState(() { _subordinateData = data; _subordinateLoading = false; });
+      if (mounted)
+        setState(() {
+          _subordinateData = data;
+          _subordinateLoading = false;
+        });
     } catch (_) {
       if (mounted) setState(() => _subordinateLoading = false);
     }
@@ -500,13 +510,22 @@ class _ExpenseDetailReportPageState
       monthlyMap[item.month] = item;
     }
 
-    final amountData = monthKeys.map((m) => monthlyMap[m]?.amount ?? 0.0).toList();
-    final approvedData = monthKeys.map((m) => monthlyMap[m]?.approvedAmount ?? 0.0).toList();
+    final amountData = monthKeys
+        .map((m) => monthlyMap[m]?.amount ?? 0.0)
+        .toList();
+    final approvedData = monthKeys
+        .map((m) => monthlyMap[m]?.approvedAmount ?? 0.0)
+        .toList();
 
     if (amountData.isEmpty) {
       return SizedBox(
         height: 200,
-        child: Center(child: Text(AppLocalizations.of(context).get('noData'), style: TextStyle(fontSize: 14, color: colors.textPlaceholder))),
+        child: Center(
+          child: Text(
+            AppLocalizations.of(context).get('noData'),
+            style: TextStyle(fontSize: 14, color: colors.textPlaceholder),
+          ),
+        ),
       );
     }
 
@@ -514,118 +533,128 @@ class _ExpenseDetailReportPageState
     final maxVal = allValues.reduce((a, b) => a > b ? a : b);
     final maxY = (maxVal > 0 ? maxVal * 1.2 : 100).toDouble();
 
-    return LineChart(
-      LineChartData(
-        gridData: FlGridData(
-          show: true,
-          drawVerticalLine: false,
-          horizontalInterval: maxY / 4,
-          getDrawingHorizontalLine: (v) =>
-              FlLine(color: colors.border, strokeWidth: 0.5),
-        ),
-        titlesData: FlTitlesData(
-          leftTitles: AxisTitles(
-            sideTitles: SideTitles(
-              showTitles: true,
-              reservedSize: 80,
-              interval: maxY / 4,
-              getTitlesWidget: (v, meta) => Text(
-                formatAmount(v.toDouble()),
-                style: TextStyle(fontSize: 10, color: colors.textPlaceholder),
-              ),
-            ),
-          ),
-          bottomTitles: AxisTitles(
-            sideTitles: SideTitles(
-              showTitles: true,
-              interval: 1,
-              reservedSize: 26,
-              getTitlesWidget: (v, meta) {
-                final i = v.toInt();
-                if (i < 0 || i >= monthLabels.length) return const SizedBox();
-                return Padding(
-                  padding: const EdgeInsets.only(top: 4),
-                  child: Text(
-                    monthLabels[i],
-                    style: TextStyle(fontSize: 9, color: colors.textPlaceholder),
-                  ),
-                );
-              },
-            ),
-          ),
-          topTitles: const AxisTitles(
-            sideTitles: SideTitles(showTitles: false),
-          ),
-          rightTitles: const AxisTitles(
-            sideTitles: SideTitles(showTitles: false),
+    return Padding(
+      padding: const EdgeInsets.only(top: 8),
+      child: LineChart(
+        LineChartData(
+          gridData: FlGridData(
+            show: true,
+            drawVerticalLine: false,
+            horizontalInterval: maxY / 4,
+            getDrawingHorizontalLine: (v) =>
+                FlLine(color: colors.border, strokeWidth: 0.5),
           ),
-        ),
-        borderData: FlBorderData(show: false),
-        minY: 0,
-        maxY: maxY,
-        lineTouchData: LineTouchData(
-          enabled: true,
-          touchTooltipData: LineTouchTooltipData(
-            getTooltipItems: (spots) => spots.map((s) {
-              return LineTooltipItem(
-                '${monthLabels[s.spotIndex]}\n${formatAmount(s.y)}',
-                const TextStyle(
-                  color: Colors.white,
-                  fontSize: 12,
-                  fontWeight: FontWeight.w500,
+          titlesData: FlTitlesData(
+            leftTitles: AxisTitles(
+              sideTitles: SideTitles(
+                showTitles: true,
+                reservedSize: 80,
+                interval: maxY / 4,
+                getTitlesWidget: (v, meta) => Text(
+                  formatAmount(v.toDouble()),
+                  style: TextStyle(fontSize: 10, color: colors.textPlaceholder),
                 ),
-              );
-            }).toList(),
-          ),
-        ),
-        lineBarsData: [
-          LineChartBarData(
-            spots: List.generate(
-              amountData.length,
-              (i) => FlSpot(i.toDouble(), amountData[i]),
+              ),
             ),
-            isCurved: true,
-            preventCurveOverShooting: true,
-            color: colors.amountPrimary,
-            barWidth: 2.5,
-            dotData: FlDotData(
-              show: true,
-              getDotPainter: (spot, percent, barData, index) =>
-                  FlDotCirclePainter(
-                    radius: 3,
-                    color: colors.amountPrimary,
-                    strokeWidth: 0,
-                  ),
+            bottomTitles: AxisTitles(
+              sideTitles: SideTitles(
+                showTitles: true,
+                interval: 1,
+                reservedSize: 26,
+                getTitlesWidget: (v, meta) {
+                  final i = v.toInt();
+                  if (i < 0 || i >= monthLabels.length) return const SizedBox();
+                  return Padding(
+                    padding: const EdgeInsets.only(top: 4),
+                    child: Text(
+                      monthLabels[i],
+                      style: TextStyle(
+                        fontSize: 9,
+                        color: colors.textPlaceholder,
+                      ),
+                    ),
+                  );
+                },
+              ),
             ),
-            belowBarData: BarAreaData(
-              show: true,
-              color: colors.amountPrimary.withValues(alpha: 0.08),
+            topTitles: const AxisTitles(
+              sideTitles: SideTitles(showTitles: false),
             ),
-          ),
-          LineChartBarData(
-            spots: List.generate(
-              approvedData.length,
-              (i) => FlSpot(i.toDouble(), approvedData[i]),
+            rightTitles: const AxisTitles(
+              sideTitles: SideTitles(showTitles: false),
             ),
-            isCurved: true,
-            preventCurveOverShooting: true,
-            color: colors.success,
-            barWidth: 2.5,
-            dotData: FlDotData(
-              show: true,
-              getDotPainter: (spot, percent, barData, index) =>
-                  FlDotCirclePainter(
-                    radius: 3,
-                    color: colors.success,
-                    strokeWidth: 0,
+          ),
+          borderData: FlBorderData(show: false),
+          minY: 0,
+          maxY: maxY,
+          lineTouchData: LineTouchData(
+            enabled: true,
+            touchTooltipData: LineTouchTooltipData(
+              fitInsideHorizontally: true,
+              fitInsideVertically: true,
+              getTooltipColor: (_) =>
+                  colors.textPrimary.withValues(alpha: 0.85),
+              getTooltipItems: (spots) => spots.map((s) {
+                return LineTooltipItem(
+                  '${monthLabels[s.spotIndex]}\n${formatAmount(s.y)}',
+                  const TextStyle(
+                    color: Colors.white,
+                    fontSize: 12,
+                    fontWeight: FontWeight.w500,
                   ),
-            ),
-            belowBarData: BarAreaData(
-              show: true,
-              color: colors.success.withValues(alpha: 0.08),
+                );
+              }).toList(),
             ),
           ),
-        ],
+          lineBarsData: [
+            LineChartBarData(
+              spots: List.generate(
+                amountData.length,
+                (i) => FlSpot(i.toDouble(), amountData[i]),
+              ),
+              isCurved: true,
+              preventCurveOverShooting: true,
+              color: colors.amountPrimary,
+              barWidth: 2.5,
+              dotData: FlDotData(
+                show: true,
+                getDotPainter: (spot, percent, barData, index) =>
+                    FlDotCirclePainter(
+                      radius: 3,
+                      color: colors.amountPrimary,
+                      strokeWidth: 0,
+                    ),
+              ),
+              belowBarData: BarAreaData(
+                show: true,
+                color: colors.amountPrimary.withValues(alpha: 0.08),
+              ),
+            ),
+            LineChartBarData(
+              spots: List.generate(
+                approvedData.length,
+                (i) => FlSpot(i.toDouble(), approvedData[i]),
+              ),
+              isCurved: true,
+              preventCurveOverShooting: true,
+              color: colors.success,
+              barWidth: 2.5,
+              dotData: FlDotData(
+                show: true,
+                getDotPainter: (spot, percent, barData, index) =>
+                    FlDotCirclePainter(
+                      radius: 3,
+                      color: colors.success,
+                      strokeWidth: 0,
+                    ),
+              ),
+              belowBarData: BarAreaData(
+                show: true,
+                color: colors.success.withValues(alpha: 0.08),
+              ),
+            ),
+          ],
+        ),
       ),
     );
   }
@@ -637,7 +666,12 @@ class _ExpenseDetailReportPageState
     if (_subordinateLoading && _subordinateData.isEmpty) {
       return const Padding(
         padding: EdgeInsets.only(top: 16),
-        child: Center(child: TDLoading(size: TDLoadingSize.small, icon: TDLoadingIcon.activity)),
+        child: Center(
+          child: TDLoading(
+            size: TDLoadingSize.small,
+            icon: TDLoadingIcon.activity,
+          ),
+        ),
       );
     }
     if (_subordinateData.isEmpty) return const SizedBox.shrink();
@@ -650,14 +684,24 @@ class _ExpenseDetailReportPageState
         decoration: BoxDecoration(
           color: colors.bgCard,
           borderRadius: BorderRadius.circular(8),
-          boxShadow: const [BoxShadow(color: Color(0x08000000), blurRadius: 4, offset: Offset(0, 1))],
+          boxShadow: const [
+            BoxShadow(
+              color: Color(0x08000000),
+              blurRadius: 4,
+              offset: Offset(0, 1),
+            ),
+          ],
         ),
         child: Column(
           crossAxisAlignment: CrossAxisAlignment.start,
           children: [
             Text(
               l10n.get('chartTitle8'),
-              style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.textPrimary),
+              style: TextStyle(
+                fontSize: 14,
+                fontWeight: FontWeight.w600,
+                color: colors.textPrimary,
+              ),
             ),
             const SizedBox(height: 16),
             SizedBox(
@@ -665,51 +709,143 @@ class _ExpenseDetailReportPageState
               child: SingleChildScrollView(
                 scrollDirection: Axis.horizontal,
                 child: SizedBox(
-                  width: (_subordinateData.length * 60.0).clamp(MediaQuery.of(context).size.width - 56, double.infinity),
-                  child: BarChart(
-                BarChartData(
-                  alignment: _subordinateData.length == 1 ? BarChartAlignment.center : BarChartAlignment.spaceAround,
-                  maxY: (_subordinateData.map((e) => e.amount).reduce((a, b) => a > b ? a : b) * 1.2).clamp(1, double.infinity),
-                  barGroups: _subordinateData.asMap().entries.map((e) {
-                    final barColors = [colors.chart1, colors.chart2, colors.chart3, colors.chart4, colors.chart5, colors.primary, colors.success, colors.warning, colors.infoText, colors.danger];
-                    return BarChartGroupData(
-                      x: e.key,
-                      barRods: [BarChartRodData(toY: e.value.amount, color: barColors[e.key % barColors.length], width: _subordinateData.length == 1 ? 40 : 20, borderRadius: const BorderRadius.vertical(top: Radius.circular(4)))],
-                    );
-                  }).toList(),
-                  titlesData: FlTitlesData(
-                    leftTitles: AxisTitles(
-                      sideTitles: SideTitles(showTitles: true, reservedSize: 80, interval: (_subordinateData.map((e) => e.amount).reduce((a, b) => a > b ? a : b) * 1.2).clamp(1, double.infinity) / 4, getTitlesWidget: (v, _) => Text(formatAmount(v), style: TextStyle(fontSize: 10, color: colors.textPlaceholder))),
-                    ),
-                    bottomTitles: AxisTitles(
-                      sideTitles: SideTitles(
-                        showTitles: true,
-                        reservedSize: 40,
-                        getTitlesWidget: (v, _) {
-                          final i = v.toInt();
-                          if (i < 0 || i >= _subordinateData.length) return const SizedBox();
-                          return Padding(
-                            padding: const EdgeInsets.only(top: 4),
-                            child: Text(
-                              _subordinateData[i].usrName.length > 4 ? '${_subordinateData[i].usrName.substring(0, 4)}…' : _subordinateData[i].usrName,
-                              style: TextStyle(fontSize: 9, color: colors.textPlaceholder),
-                              maxLines: 2,
-                              overflow: TextOverflow.ellipsis,
-                            ),
+                  width: (_subordinateData.length * 60.0).clamp(
+                    MediaQuery.of(context).size.width - 56,
+                    double.infinity,
+                  ),
+                  child: Padding(
+                    padding: const EdgeInsets.only(top: 8),
+                    child: BarChart(
+                      BarChartData(
+                        alignment: _subordinateData.length == 1
+                            ? BarChartAlignment.center
+                            : BarChartAlignment.spaceAround,
+                        maxY:
+                            (_subordinateData
+                                        .map((e) => e.amount)
+                                        .reduce((a, b) => a > b ? a : b) *
+                                    1.2)
+                                .clamp(1, double.infinity),
+                        barTouchData: BarTouchData(
+                          enabled: true,
+                          touchTooltipData: BarTouchTooltipData(
+                            fitInsideHorizontally: true,
+                            fitInsideVertically: true,
+                            getTooltipColor: (_) =>
+                                colors.textPrimary.withValues(alpha: 0.85),
+                            getTooltipItem: (group, groupIndex, rod, rodIndex) {
+                              final item = _subordinateData[group.x];
+                              return BarTooltipItem(
+                                '${item.usrName}\n${formatAmount(rod.toY)}',
+                                const TextStyle(
+                                  color: Colors.white,
+                                  fontSize: 12,
+                                  fontWeight: FontWeight.w500,
+                                ),
+                              );
+                            },
+                          ),
+                        ),
+                        barGroups: _subordinateData.asMap().entries.map((e) {
+                          final barColors = [
+                            colors.chart1,
+                            colors.chart2,
+                            colors.chart3,
+                            colors.chart4,
+                            colors.chart5,
+                            colors.primary,
+                            colors.success,
+                            colors.warning,
+                            colors.infoText,
+                            colors.danger,
+                          ];
+                          return BarChartGroupData(
+                            x: e.key,
+                            barRods: [
+                              BarChartRodData(
+                                toY: e.value.amount,
+                                color: barColors[e.key % barColors.length],
+                                width: _subordinateData.length == 1 ? 40 : 20,
+                                borderRadius: const BorderRadius.vertical(
+                                  top: Radius.circular(4),
+                                ),
+                              ),
+                            ],
                           );
-                        },
+                        }).toList(),
+                        titlesData: FlTitlesData(
+                          leftTitles: AxisTitles(
+                            sideTitles: SideTitles(
+                              showTitles: true,
+                              reservedSize: 80,
+                              interval:
+                                  (_subordinateData
+                                              .map((e) => e.amount)
+                                              .reduce((a, b) => a > b ? a : b) *
+                                          1.2)
+                                      .clamp(1, double.infinity) /
+                                  4,
+                              getTitlesWidget: (v, _) => Text(
+                                formatAmount(v),
+                                style: TextStyle(
+                                  fontSize: 10,
+                                  color: colors.textPlaceholder,
+                                ),
+                              ),
+                            ),
+                          ),
+                          bottomTitles: AxisTitles(
+                            sideTitles: SideTitles(
+                              showTitles: true,
+                              reservedSize: 40,
+                              getTitlesWidget: (v, _) {
+                                final i = v.toInt();
+                                if (i < 0 || i >= _subordinateData.length)
+                                  return const SizedBox();
+                                return Padding(
+                                  padding: const EdgeInsets.only(top: 4),
+                                  child: Text(
+                                    _subordinateData[i].usrName.length > 4
+                                        ? '${_subordinateData[i].usrName.substring(0, 4)}…'
+                                        : _subordinateData[i].usrName,
+                                    style: TextStyle(
+                                      fontSize: 9,
+                                      color: colors.textPlaceholder,
+                                    ),
+                                    maxLines: 2,
+                                    overflow: TextOverflow.ellipsis,
+                                  ),
+                                );
+                              },
+                            ),
+                          ),
+                          topTitles: const AxisTitles(
+                            sideTitles: SideTitles(showTitles: false),
+                          ),
+                          rightTitles: const AxisTitles(
+                            sideTitles: SideTitles(showTitles: false),
+                          ),
+                        ),
+                        borderData: FlBorderData(show: false),
+                        gridData: FlGridData(
+                          show: true,
+                          drawVerticalLine: false,
+                          horizontalInterval:
+                              (_subordinateData
+                                          .map((e) => e.amount)
+                                          .reduce((a, b) => a > b ? a : b) *
+                                      1.2)
+                                  .clamp(1, double.infinity) /
+                              5,
+                          getDrawingHorizontalLine: (v) =>
+                              FlLine(color: colors.border, strokeWidth: 0.5),
+                        ),
                       ),
                     ),
-                    topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
-                    rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
                   ),
-                  borderData: FlBorderData(show: false),
-                  gridData: FlGridData(show: true, drawVerticalLine: false, horizontalInterval: (_subordinateData.map((e) => e.amount).reduce((a, b) => a > b ? a : b) * 1.2).clamp(1, double.infinity) / 5, getDrawingHorizontalLine: (v) => FlLine(color: colors.border, strokeWidth: 0.5)),
                 ),
               ),
             ),
-            ),
-            ),
           ],
         ),
       ),
@@ -828,7 +964,11 @@ class _ExpenseDetailReportPageState
             child: Column(
               mainAxisSize: MainAxisSize.min,
               children: [
-                Icon(Icons.inbox_outlined, size: 48, color: colors.textPlaceholder),
+                Icon(
+                  Icons.inbox_outlined,
+                  size: 48,
+                  color: colors.textPlaceholder,
+                ),
                 const SizedBox(height: 12),
                 Text(
                   l10n.get('noDetailData'),
@@ -981,7 +1121,10 @@ class _ExpenseDetailReportPageState
                 Align(
                   alignment: Alignment.centerRight,
                   child: Container(
-                    padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
+                    padding: const EdgeInsets.symmetric(
+                      horizontal: 6,
+                      vertical: 1,
+                    ),
                     decoration: BoxDecoration(
                       color: colors.primary.withValues(alpha: 0.1),
                       borderRadius: BorderRadius.circular(4),
@@ -989,7 +1132,11 @@ class _ExpenseDetailReportPageState
                     ),
                     child: Text(
                       l10n.get('statusTransferred'),
-                      style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: colors.primary),
+                      style: TextStyle(
+                        fontSize: 11,
+                        fontWeight: FontWeight.w500,
+                        color: colors.primary,
+                      ),
                     ),
                   ),
                 ),
@@ -1001,5 +1148,4 @@ class _ExpenseDetailReportPageState
       ],
     );
   }
-
 }

+ 54 - 15
lib/shared/widgets/attachment_picker.dart

@@ -160,7 +160,10 @@ class _AttachmentPickerState extends State<AttachmentPicker> {
               InkWell(
                 onTap: () => Navigator.pop(ctx, 'image'),
                 child: Padding(
-                  padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
+                  padding: const EdgeInsets.symmetric(
+                    horizontal: 16,
+                    vertical: 16,
+                  ),
                   child: Row(
                     children: [
                       Container(
@@ -170,12 +173,19 @@ class _AttachmentPickerState extends State<AttachmentPicker> {
                           color: colors.primaryLight,
                           borderRadius: BorderRadius.circular(12),
                         ),
-                        child: Icon(Icons.image_outlined, color: colors.primary, size: 24),
+                        child: Icon(
+                          Icons.image_outlined,
+                          color: colors.primary,
+                          size: 24,
+                        ),
                       ),
                       const SizedBox(width: 16),
                       Text(
                         l10n.get('pickImage'),
-                        style: TextStyle(fontSize: 16, color: colors.textPrimary),
+                        style: TextStyle(
+                          fontSize: 16,
+                          color: colors.textPrimary,
+                        ),
                       ),
                     ],
                   ),
@@ -186,7 +196,10 @@ class _AttachmentPickerState extends State<AttachmentPicker> {
               InkWell(
                 onTap: () => Navigator.pop(ctx, 'file'),
                 child: Padding(
-                  padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
+                  padding: const EdgeInsets.symmetric(
+                    horizontal: 16,
+                    vertical: 16,
+                  ),
                   child: Row(
                     children: [
                       Container(
@@ -196,12 +209,19 @@ class _AttachmentPickerState extends State<AttachmentPicker> {
                           color: colors.primaryLight,
                           borderRadius: BorderRadius.circular(12),
                         ),
-                        child: Icon(Icons.description_outlined, color: colors.primary, size: 24),
+                        child: Icon(
+                          Icons.description_outlined,
+                          color: colors.primary,
+                          size: 24,
+                        ),
                       ),
                       const SizedBox(width: 16),
                       Text(
                         l10n.get('pickFile'),
-                        style: TextStyle(fontSize: 16, color: colors.textPrimary),
+                        style: TextStyle(
+                          fontSize: 16,
+                          color: colors.textPrimary,
+                        ),
                       ),
                     ],
                   ),
@@ -248,14 +268,18 @@ class _AttachmentPickerState extends State<AttachmentPicker> {
     if (available <= 0) return;
     final result = await FilePicker.pickFiles(
       type: FileType.custom,
-      allowedExtensions: widget.allowedExtensions ??
+      allowedExtensions:
+          widget.allowedExtensions ??
           const ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt'],
       allowMultiple: true,
     );
     if (!mounted || result == null || result.files.isEmpty) return;
     if (result.files.length > available) {
       final l10n = AppLocalizations.of(context);
-      TDToast.showText(l10n.getString('fileLimitHint', args: {'limit': '$available'}), context: context);
+      TDToast.showText(
+        l10n.getString('fileLimitHint', args: {'limit': '$available'}),
+        context: context,
+      );
     }
     for (final pf in result.files) {
       if (widget.controller.isFull) break;
@@ -271,14 +295,24 @@ class _AttachmentPickerState extends State<AttachmentPicker> {
     final l10n = AppLocalizations.of(context);
     final sizeMB = file.sizeMB;
 
-    if (file.isImage && widget.maxImageSizeMB != null && sizeMB > widget.maxImageSizeMB!) {
-      final reason = l10n.getString('imageSizeLimit', args: {'max': widget.maxImageSizeMB!.toStringAsFixed(0)});
+    if (file.isImage &&
+        widget.maxImageSizeMB != null &&
+        sizeMB > widget.maxImageSizeMB!) {
+      final reason = l10n.getString(
+        'imageSizeLimit',
+        args: {'max': widget.maxImageSizeMB!.toStringAsFixed(0)},
+      );
       widget.onFileRejected?.call(file, reason);
       if (mounted) TDToast.showText(reason, context: context);
       return true;
     }
-    if (!file.isImage && widget.maxFileSizeMB != null && sizeMB > widget.maxFileSizeMB!) {
-      final reason = l10n.getString('fileSizeLimit', args: {'max': widget.maxFileSizeMB!.toStringAsFixed(0)});
+    if (!file.isImage &&
+        widget.maxFileSizeMB != null &&
+        sizeMB > widget.maxFileSizeMB!) {
+      final reason = l10n.getString(
+        'fileSizeLimit',
+        args: {'max': widget.maxFileSizeMB!.toStringAsFixed(0)},
+      );
       widget.onFileRejected?.call(file, reason);
       if (mounted) TDToast.showText(reason, context: context);
       return true;
@@ -383,7 +417,11 @@ class _AttachmentPickerState extends State<AttachmentPicker> {
     return _buildDocTile(file, colors, size);
   }
 
-  Widget _buildDocTile(AttachmentFile file, AppColorsExtension colors, double size) {
+  Widget _buildDocTile(
+    AttachmentFile file,
+    AppColorsExtension colors,
+    double size,
+  ) {
     return SizedBox(
       width: size,
       child: Column(
@@ -407,7 +445,9 @@ class _AttachmentPickerState extends State<AttachmentPicker> {
           const SizedBox(height: 4),
           SizedBox(
             width: size,
-            height: 16,
+            height:
+                MediaQuery.textScalerOf(context).scale(AppFontSizes.caption) *
+                1.4,
             child: Marquee(
               text: file.name,
               style: TextStyle(
@@ -448,4 +488,3 @@ class _AttachmentPickerState extends State<AttachmentPicker> {
     }
   }
 }
-

+ 147 - 32
lib/shared/widgets/form_field_row.dart

@@ -1,5 +1,7 @@
-import '../../core/theme/app_colors.dart';
 import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:tdesign_flutter/tdesign_flutter.dart';
+import '../../core/theme/app_colors.dart';
 import '../../core/theme/app_colors_extension.dart';
 import '../../core/i18n/app_localizations.dart';
 
@@ -16,6 +18,7 @@ class FormFieldRow extends StatelessWidget {
   final VoidCallback? onTap;
   final VoidCallback? onClear;
   final bool bold;
+  final bool showMoreOnOverflow;
 
   const FormFieldRow({
     super.key,
@@ -28,6 +31,7 @@ class FormFieldRow extends StatelessWidget {
     this.onTap,
     this.onClear,
     this.bold = false,
+    this.showMoreOnOverflow = true,
   });
 
   @override
@@ -42,40 +46,128 @@ class FormFieldRow extends StatelessWidget {
         child: Row(
           mainAxisAlignment: MainAxisAlignment.spaceBetween,
           children: [
-            Text.rich(
-              TextSpan(children: [
-                TextSpan(text: label, style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textSecondary)),
-                if (required) TextSpan(text: ' *', style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.danger)),
-              ]),
-            ),
-            const SizedBox(width: 12),
-            Expanded(
-              child: Row(
-                mainAxisSize: MainAxisSize.min,
-                children: [
-                  Expanded(
-                    child: Text(
-                      hasValue ? value! : (hint ?? AppLocalizations.of(context).get('pleaseSelectOrFill')),
-                      maxLines: 1,
-                      softWrap: false,
-                      overflow: TextOverflow.ellipsis,
-                      textAlign: TextAlign.end,
-                      style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: bold ? FontWeight.w600 : FontWeight.normal, color: hasValue ? colors.textPrimary : colors.textPlaceholder),
+            Flexible(
+              flex: 2,
+              child: Text.rich(
+                TextSpan(
+                  children: [
+                    TextSpan(
+                      text: label,
+                      style: TextStyle(
+                        fontSize: AppFontSizes.subtitle,
+                        color: colors.textSecondary,
+                      ),
                     ),
-                  ),
-                  if (hasValue && onClear != null)
-                    GestureDetector(
-                      onTap: onClear,
-                      child: const Padding(
-                        padding: EdgeInsets.only(left: 4),
-                        child: Icon(Icons.close, size: 16, color: Color(0xFFBBBBBB)),
+                    if (required)
+                      TextSpan(
+                        text: ' *',
+                        style: TextStyle(
+                          fontSize: AppFontSizes.subtitle,
+                          color: colors.danger,
+                        ),
                       ),
-                    )
-                  else if (showArrow && !readOnly) ...[
-                    const SizedBox(width: 4),
-                    Icon(Icons.chevron_right, size: 14, color: colors.textPlaceholder),
                   ],
-                ],
+                ),
+                maxLines: 1,
+                overflow: TextOverflow.ellipsis,
+              ),
+            ),
+            const SizedBox(width: 16),
+            Expanded(
+              flex: 3,
+              child: LayoutBuilder(
+                builder: (context, constraints) {
+                  final textStyle = TextStyle(
+                    fontSize: AppFontSizes.subtitle,
+                    fontWeight: bold ? FontWeight.w600 : FontWeight.normal,
+                    color: hasValue
+                        ? colors.textPrimary
+                        : colors.textPlaceholder,
+                  );
+                  final displayText = hasValue
+                      ? value!
+                      : (hint ??
+                            AppLocalizations.of(
+                              context,
+                            ).get('pleaseSelectOrFill'));
+
+                  // 测量文字宽度(需传入 textScaler,否则按 1.0 倍测量,
+                  // 与 Text 组件的实际渲染宽度不一致)
+                  final tp = TextPainter(
+                    text: TextSpan(text: displayText, style: textStyle),
+                    textDirection: TextDirection.ltr,
+                    textScaler: MediaQuery.textScalerOf(context),
+                    maxLines: 1,
+                  )..layout();
+
+                  // 预先计算图标占位(close/chevron 固定出现,iconSpace > 0)
+                  // 更多图标是条件出现的,iconSpace 保持 0,不预扣宽度
+                  double iconSpace = 0;
+                  if (hasValue && onClear != null) {
+                    iconSpace = 20;
+                  } else if (showArrow && !readOnly) {
+                    iconSpace = 18;
+                  }
+
+                  final textOverflows =
+                      tp.width > (constraints.maxWidth - iconSpace);
+
+                  return Row(
+                    children: [
+                      Expanded(
+                        child: Text(
+                          displayText,
+                          maxLines: 1,
+                          softWrap: false,
+                          overflow:
+                              (hasValue &&
+                                  readOnly &&
+                                  showMoreOnOverflow &&
+                                  textOverflows)
+                              ? TextOverflow.fade
+                              : TextOverflow.ellipsis,
+                          textAlign: TextAlign.end,
+                          style: textStyle,
+                        ),
+                      ),
+                      if (hasValue && onClear != null)
+                        GestureDetector(
+                          onTap: onClear,
+                          child: const Padding(
+                            padding: EdgeInsets.only(left: 4),
+                            child: Icon(
+                              Icons.close,
+                              size: 16,
+                              color: Color(0xFFBBBBBB),
+                            ),
+                          ),
+                        )
+                      else if (hasValue &&
+                          readOnly &&
+                          showMoreOnOverflow &&
+                          textOverflows)
+                        GestureDetector(
+                          onTap: () => _showFullValue(context, displayText),
+                          child: const Padding(
+                            padding: EdgeInsets.only(left: 4),
+                            child: Icon(
+                              Icons.more_horiz,
+                              size: 16,
+                              color: Color(0xFFBBBBBB),
+                            ),
+                          ),
+                        )
+                      else if (showArrow && !readOnly) ...[
+                        const SizedBox(width: 4),
+                        Icon(
+                          Icons.chevron_right,
+                          size: 14,
+                          color: colors.textPlaceholder,
+                        ),
+                      ],
+                    ],
+                  );
+                },
               ),
             ),
           ],
@@ -83,4 +175,27 @@ class FormFieldRow extends StatelessWidget {
       ),
     );
   }
+
+  void _showFullValue(BuildContext context, String text) {
+    final l10n = AppLocalizations.of(context);
+    showDialog(
+      context: context,
+      builder: (ctx) => TDAlertDialog(
+        title: label,
+        content: text,
+        buttonStyle: TDDialogButtonStyle.text,
+        rightBtn: TDDialogButtonOptions(
+          title: l10n.get('close'),
+          action: () => Navigator.pop(ctx),
+        ),
+        leftBtn: TDDialogButtonOptions(
+          title: l10n.get('copy'),
+          action: () {
+            Clipboard.setData(ClipboardData(text: text));
+            Navigator.pop(ctx);
+          },
+        ),
+      ),
+    );
+  }
 }