/// 上传中心 widget 测试(UI-03 页面 14/15)。 /// /// 打桩面:RecordingEngine(记录 retryNow/cancel,不出队)+ 内存 TaskStore /// 种子 + FakeAdapter 相册列表 + 临时目录准备器(准备失败重试换源)。 /// 覆盖:页头(Uploads 标题 + History 入口)、摘要卡(范围标注 + 四组 /// 任务数,突出已完成)、主段筛选(All/Active/Needs attention)、次级 /// 筛选菜单(相册/时间/已完成)、任务行渲染(状态徽章/状态专属文案/ /// 错误文案/相册名)、按状态动作(重试/重新选择/取消/移除记录 + 确认 /// 弹窗)、异常视图(仅失败行 + 说明行 + 摘要全量口径 + 专用空态)、 /// 批量重试条口径(仅可重试失败)、写通知防抖实时刷新、行级进度条 /// (分数 + 百分比文案 + 未订阅行不重建)。 library; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:photo_live/core/api_client.dart'; import 'package:photo_live/core/device_id.dart'; import 'package:photo_live/core/session_store.dart'; import 'package:photo_live/features/uploads/enqueue.dart'; import 'package:photo_live/features/uploads/preparer.dart'; import 'package:photo_live/features/uploads/providers.dart'; import 'package:photo_live/features/uploads/queue_controller.dart'; import 'package:photo_live/features/uploads/task_state.dart'; import 'package:photo_live/features/uploads/task_store.dart'; import 'package:photo_live/features/uploads/upload_engine.dart'; import 'package:photo_live/l10n/app_localizations.dart'; import 'package:photo_live/pages/queue_tab.dart'; import '../helpers/fakes.dart'; /// JPEG 魔数(准备器换源重试用真实临时文件)。 const List kJpgBytes = [ 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, ]; class _FixedDeviceIdStore implements DeviceIdStore { @override Future read() async => '0123456789abcdef0123456789abcdef'; @override Future write(String value) async {} } /// 引擎替身:记录 retryNow/cancel,不出队(动作流断言点;真实出队语义 /// 由 upload_engine_test 覆盖)。 class RecordingEngine extends UploadEngine { RecordingEngine( super.store, super.api, super.deviceId, { required Dio objectDio, }) : super(objectDio: objectDio); final List<(String, String)> retryCalls = <(String, String)>[]; final List<(String, String)> cancelCalls = <(String, String)>[]; @override Future retryNow(String accountId, String taskId) async => retryCalls.add((accountId, taskId)); @override Future cancel(String accountId, String taskId) async { cancelCalls.add((accountId, taskId)); return true; } @override void notifyWorkAvailable() {} } void main() { late FakeSessionStore session; late TaskStore store; late RecordingEngine engine; late ApiClient api; late Directory tmp; late List pickedPaths; setUp(() async { session = FakeSessionStore() ..session = Session( token: 'tok-1', expiresAt: DateTime.utc(2099, 1, 1), account: 'alice', ); store = TaskStore.memory(); api = ApiClient( sessionStore: session, dio: Dio(BaseOptions(baseUrl: 'http://test.local')) ..httpClientAdapter = FakeAdapter((o) { if (o.method == 'GET' && o.path == '/v1/albums') { return ( 200, cursorPageBody(>[ albumJson(id: 'alb-1'), albumJson( id: 'alb-2', translations: >[ { 'locale': 'en', 'title': 'Wedding Day', 'description': null, 'venue': null, 'address': null, }, ], ), ]), ); } throw StateError('unexpected ${o.method} ${o.path}'); }), ); engine = RecordingEngine( store, api, DeviceId(store: _FixedDeviceIdStore()), objectDio: Dio() ..httpClientAdapter = FakeAdapter((_) => throw StateError('替身引擎不应发起传输')), ); tmp = await Directory.systemTemp.createTemp('queue-tab-test-'); pickedPaths = []; }); tearDown(() async { await store.close(); await tmp.delete(recursive: true); }); Future pumpQueue(WidgetTester tester) async { // 加高视口:摘要卡 + 筛选栏 + 批量条压缩列表区,默认 800x600 下 // 第二行落在懒构建区外(ListView.builder 不构建不可见项)。 tester.view.physicalSize = const Size(800, 1600); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); await tester.pumpWidget( ProviderScope( overrides: [ ...pageTestOverrides(session: session, taskStore: store, client: api), uploadEngineProvider.overrideWith((ref) => engine), uploadPreparerProvider.overrideWith( (ref) => UploadPreparer( store, baseDirProvider: () async => tmp, ), ), singleImagePickerProvider .overrideWith((ref) => () async => pickedPaths.removeAt(0)), ], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, // History 入口跳转断言用占位路由。 routes: { '/server-history': (context) => const Scaffold(body: Center(child: Text('SERVER_HISTORY_MARK'))), }, home: const QueueTab(), ), ), ); // 不用 pumpAndSettle:uploading 行的不确定态进度条永远动画(帧不断)。 await tester.pump(const Duration(milliseconds: 300)); await tester.pump(const Duration(milliseconds: 300)); } /// 种子任务(真实迁移链到目标态;attempt/lastError 供失败文案断言)。 Future seed( String filename, { String albumId = 'alb-1', UploadTaskState state = UploadTaskState.queued, DateTime? createdAt, String? lastError, int? attempt, }) async { final id = 'task-$filename'; var task = await store.create( UploadTaskDraft( id: id, accountId: 'alice', albumId: albumId, clientTaskId: 'ct-$filename', sourceFilePath: '${tmp.path}/$filename', filename: filename, bytes: 16, contentType: 'image/jpeg', ), now: createdAt, ); Future to(UploadTaskState s, {String? error, int? tries}) => store.updateState( task.id, s, patch: UploadTaskPatch(lastError: error, attempt: tries), now: createdAt, ); switch (state) { case UploadTaskState.preparing: break; case UploadTaskState.queued: case UploadTaskState.preparationFailed: case UploadTaskState.cancelled: task = await to(state, error: lastError); case UploadTaskState.waitingNetwork: task = await to(UploadTaskState.queued); task = await to(state, error: lastError); case UploadTaskState.uploading: case UploadTaskState.retryableFailed: task = await to(UploadTaskState.queued); task = await to(UploadTaskState.uploading); if (state == UploadTaskState.retryableFailed) { task = await to(state, error: lastError, tries: attempt); } case UploadTaskState.uploadedConfirming: case UploadTaskState.completed: task = await to(UploadTaskState.queued); task = await to(UploadTaskState.uploading); task = await to(UploadTaskState.uploadedConfirming); if (state == UploadTaskState.completed) { task = await to(state); } } return task; } /// 行级锚定:任务行 Padding 带 ValueKey('queue-task-{task.id}')。 Finder rowOf(String filename) => find.byKey(ValueKey('queue-task-task-$filename')); /// 等防抖重查落定(写通知 → 200ms Timer → 异步查询;不 pumpAndSettle—— /// uploading 行的不确定态进度条动画不会停)。 Future settleDebounce(WidgetTester tester) async { await tester.pump(kQueueRefreshDebounce); await tester.pump(const Duration(milliseconds: 120)); } /// 打开次级筛选菜单(相册 × 时间 × 已完成)。 Future openFilterSheet(WidgetTester tester) async { await tester.tap(find.byIcon(Icons.tune)); await tester.pumpAndSettle(); } /// 触发一个会走真实文件 IO 的按钮:在 runAsync(真实事件循环)内直接 /// 调用其回调——widget 测试的 fake-async 区里 dart:io 完成回调不会被 /// pump 冲刷,入队链路必须整体跑在真实区;返回后 pump 刷新徽章防抖。 Future pressButtonRealIo(WidgetTester tester, Finder button) async { final widget = button.evaluate().single.widget; final VoidCallback onPressed; if (widget is IconButton) { onPressed = widget.onPressed!; } else if (widget is TextButton) { onPressed = widget.onPressed!; } else { throw StateError('unsupported button type: $widget'); } await tester.runAsync(() async { onPressed(); await Future.delayed(const Duration(milliseconds: 400)); }); await tester.pump(kQueueRefreshDebounce); await tester.pump(const Duration(milliseconds: 60)); } testWidgets('页头:Uploads 标题 + History 入口跳服务端历史页', (tester) async { await pumpQueue(tester); expect(find.text('Uploads'), findsOneWidget); expect(find.text('History'), findsOneWidget); await tester.tap(find.text('History')); await tester.pumpAndSettle(); expect(find.text('SERVER_HISTORY_MARK'), findsOneWidget, reason: 'History 入口 push /server-history(与本机队列数据源区分)'); }); testWidgets('摘要卡:范围标注(时间·本设备)+ 四组任务数(突出已完成)', (tester) async { await seed('done1.jpg', state: UploadTaskState.completed); await seed('done2.jpg', state: UploadTaskState.completed); await seed('up.jpg', state: UploadTaskState.uploading); await seed('q1.jpg'); await seed('q2.jpg'); await seed( 'bad.jpg', state: UploadTaskState.retryableFailed, lastError: 'NETWORK_ERROR', ); await pumpQueue(tester); // 范围标注:未选相册 → 时间范围 · 本设备(跨设备记录在服务端历史页)。 expect(find.text('Any time · This device'), findsOneWidget); // 四组计数:摘要标签小写、主段大写——find.text 区分大小写不冲突。 void expectSummary(String label, String value) { final row = find.ancestor( of: find.text(label), matching: find.byType(Row), ); expect( find.descendant(of: row, matching: find.text(value)), findsOneWidget, reason: '摘要「$label」= $value', ); } expectSummary('completed', '2'); expectSummary('uploading', '1'); expectSummary('waiting', '2'); expectSummary('need attention', '1'); }); testWidgets('各状态任务行:徽章文案 + 相册名(缓存)+ 缩略图占位', (tester) async { await seed('prep.jpg', state: UploadTaskState.preparing); await seed('ok.jpg', state: UploadTaskState.completed); await seed('net.jpg', state: UploadTaskState.waitingNetwork); await seed('alb2.jpg', albumId: 'alb-2', state: UploadTaskState.uploading); await pumpQueue(tester); // 状态徽章(en locale;断言锚定在行内——主段文案与部分徽章同名)。 for (final (filename, badge) in [ ('prep.jpg', 'Preparing photo'), ('ok.jpg', 'Completed'), ('net.jpg', 'Waiting for network'), ('alb2.jpg', 'Uploading'), ]) { expect( find.descendant(of: rowOf(filename), matching: find.text(badge)), findsOneWidget, reason: '$filename 行徽章/状态文案 $badge', ); } // 文件名。 expect(find.text('ok.jpg'), findsOneWidget); // 相册名经 albums 缓存展示(en 主标题;3 条 alb-1 + 1 条 alb-2)。 expect(find.text('Opening Gala'), findsNWidgets(3)); expect(find.text('Wedding Day'), findsOneWidget); // 缩略图:每行一个 Image.file(源缺失走 errorBuilder 占位;fake-async // 下真实 IO 不完成,仅断言 Image 挂载)。 expect(find.byType(Image), findsNWidgets(4)); }); testWidgets('失败任务:本地化错误文案 + 尝试次数', (tester) async { await seed( 'big.jpg', state: UploadTaskState.preparationFailed, lastError: 'PREP_TOO_LARGE', ); await seed( 'flaky.jpg', state: UploadTaskState.retryableFailed, lastError: 'NETWORK_ERROR', attempt: 2, ); await pumpQueue(tester); // §4.4:失败行显示本地化原因(独立错误行),状态行附尝试次数。 expect(find.text('Photo exceeds the 50MB limit'), findsOneWidget); expect(find.text('Network error. Check your connection and retry'), findsOneWidget); expect( find.descendant( of: rowOf('flaky.jpg'), matching: find.text('Upload failed'), ), findsOneWidget, ); expect( find.descendant( of: rowOf('flaky.jpg'), matching: find.text('2 attempt(s)'), ), findsOneWidget, ); }); testWidgets('等待网络行:徽章 + 自动恢复提示 + 保留手动重试', (tester) async { await seed( 'net.jpg', state: UploadTaskState.waitingNetwork, lastError: 'NETWORK_ERROR', ); await pumpQueue(tester); expect( find.descendant( of: rowOf('net.jpg'), matching: find.text('Waiting for network'), ), findsOneWidget, ); // §4.4:等待网络附自动恢复说明(用户无需手动干预)。 expect( find.descendant( of: rowOf('net.jpg'), matching: find.text('Resumes automatically when the network recovers'), ), findsOneWidget, ); // §4.4「保留已有重试」:等待网络行仍有刷新入口。 expect( find.descendant(of: rowOf('net.jpg'), matching: find.byIcon(Icons.refresh)), findsOneWidget, ); }); testWidgets('主段筛选 Needs attention → 仅失败行 + 说明行 + 摘要仍全量(页面 15)', (tester) async { await seed('bad.jpg', state: UploadTaskState.preparationFailed); await seed('good.jpg', state: UploadTaskState.completed); await pumpQueue(tester); await tester.tap(find.text('Needs attention')); await settleDebounce(tester); // 页面 15:异常视图只列失败任务,附「正在显示需关注任务」说明行。 expect(find.text('bad.jpg'), findsOneWidget); expect(find.text('good.jpg'), findsNothing); expect( find.text('Showing tasks that need your attention'), findsOneWidget, ); // 摘要不随状态筛选变口径:completed 1 / need attention 1 仍可见。 expect(find.text('Any time · This device'), findsOneWidget); void expectSummary(String label, String value) { final row = find.ancestor( of: find.text(label), matching: find.byType(Row), ); expect(find.descendant(of: row, matching: find.text(value)), findsOneWidget); } expectSummary('completed', '1'); expectSummary('need attention', '1'); }); testWidgets('异常视图空态:无失败任务 → 专用空文案(页面 15)', (tester) async { await seed('good.jpg', state: UploadTaskState.completed); await pumpQueue(tester); await tester.tap(find.text('Needs attention')); await settleDebounce(tester); expect(find.text('No tasks need attention'), findsOneWidget); expect(find.text('good.jpg'), findsNothing, reason: '非失败任务不显示'); }); testWidgets('主段筛选 Active → 活任务(含等待网络)', (tester) async { await seed('live.jpg'); await seed('net.jpg', state: UploadTaskState.waitingNetwork); await seed('done.jpg', state: UploadTaskState.completed); await seed('bad.jpg', state: UploadTaskState.preparationFailed); await pumpQueue(tester); await tester.tap(find.text('Active')); await settleDebounce(tester); // 排队 + 等待网络属「进行中」(自动恢复范畴);终态与需关注排除。 expect(find.text('live.jpg'), findsOneWidget); expect(find.text('net.jpg'), findsOneWidget); expect(find.text('done.jpg'), findsNothing); expect(find.text('bad.jpg'), findsNothing); }); testWidgets('次级筛选菜单:Completed → 仅已完成行(主三段不亮)', (tester) async { await seed('done.jpg', state: UploadTaskState.completed); await seed('gone.jpg', state: UploadTaskState.cancelled); await seed('q.jpg'); await pumpQueue(tester); await openFilterSheet(tester); // 行内徽章同名「Completed」——锚定筛选面板的 ListTile。 await tester.tap(find.widgetWithText(ListTile, 'Completed')); await tester.pumpAndSettle(); await settleDebounce(tester); // §4.4:已完成组不含 cancelled(取消仅在「全部」可见)。 expect(find.text('done.jpg'), findsOneWidget); expect(find.text('gone.jpg'), findsNothing); expect(find.text('q.jpg'), findsNothing); }); testWidgets('次级筛选菜单:Today → 昨日任务排除', (tester) async { final now = DateTime.now(); await seed('fresh.jpg', createdAt: now); await seed( 'old.jpg', createdAt: now.subtract(const Duration(days: 40)), ); await pumpQueue(tester); expect(find.text('old.jpg'), findsOneWidget, reason: '默认全部时间'); await openFilterSheet(tester); await tester.tap(find.widgetWithText(RadioListTile, 'Today')); await tester.pumpAndSettle(); await settleDebounce(tester); expect(find.text('fresh.jpg'), findsOneWidget); expect(find.text('old.jpg'), findsNothing); }); testWidgets('次级筛选菜单:相册 alb-2 → 仅该相册行 + 摘要范围带相册名', (tester) async { await seed('one.jpg', albumId: 'alb-1'); await seed('two.jpg', albumId: 'alb-2'); await pumpQueue(tester); await openFilterSheet(tester); // 任务行副标题同名相册名——锚定筛选面板的单选行。 await tester.tap( find.widgetWithText(RadioListTile, 'Wedding Day')); await tester.pumpAndSettle(); await settleDebounce(tester); expect(find.text('two.jpg'), findsOneWidget); expect(find.text('one.jpg'), findsNothing); // 摘要范围标注随筛选更新:相册名 · 时间 · 本设备。 expect(find.text('Wedding Day · Any time · This device'), findsOneWidget); }); testWidgets('按状态动作可见性:queued 取消 / completed 移除记录 / uploading 无', (tester) async { await seed('q.jpg', state: UploadTaskState.queued); await seed('done.jpg', state: UploadTaskState.completed); await seed('up.jpg', state: UploadTaskState.uploading); await pumpQueue(tester); // queued 行 → 取消图标;completed 行 → 移除记录(弱化文字按钮); // uploading 行无动作(传输中不打断)。 expect( find.descendant(of: rowOf('q.jpg'), matching: find.byIcon(Icons.close)), findsOneWidget, reason: 'queued 行有取消按钮', ); expect( find.descendant( of: rowOf('done.jpg'), matching: find.text('Remove record'), ), findsOneWidget, reason: 'completed 行有移除记录按钮', ); expect( find.descendant(of: rowOf('up.jpg'), matching: find.byType(IconButton)) .evaluate(), isEmpty, reason: 'uploading 行无图标动作(传输中不打断)', ); expect( find.descendant(of: rowOf('up.jpg'), matching: find.byType(TextButton)) .evaluate(), isEmpty, reason: 'uploading 行无文字动作', ); }); testWidgets('取消流:queued 行点取消 → engine.cancel(账号, 任务)', (tester) async { final task = await seed('q.jpg'); await pumpQueue(tester); await tester.tap(find.descendant( of: rowOf('q.jpg'), matching: find.byIcon(Icons.close), )); await tester.pumpAndSettle(); expect(engine.cancelCalls, [('alice', task.id)]); }); testWidgets('重试流:retryableFailed 行 → engine.retryNow', (tester) async { final task = await seed( 'flaky.jpg', state: UploadTaskState.retryableFailed, lastError: 'INTERNAL_ERROR', ); await pumpQueue(tester); await tester.tap(find.descendant( of: rowOf('flaky.jpg'), matching: find.byIcon(Icons.refresh), )); await tester.pumpAndSettle(); expect(engine.retryCalls, [('alice', task.id)]); }); testWidgets('准备失败:重新选图(非普通重试)→ 换源重新入队为 queued', (tester) async { final task = await seed( 'broken.jpg', state: UploadTaskState.preparationFailed, lastError: 'PREP_SOURCE_UNREADABLE', ); // 替身选图器返回真实 JPEG 临时文件。 final picked = File('${tmp.path}/repicked.jpg') ..writeAsBytesSync(kJpgBytes); pickedPaths.add(picked.path); await pumpQueue(tester); // §4.4:准备失败行是「重新选择」(image_search 图标),不是刷新重试。 expect( find.descendant( of: rowOf('broken.jpg'), matching: find.text('File unavailable'), ), findsOneWidget, ); final selectAgain = find.descendant( of: rowOf('broken.jpg'), matching: find.widgetWithText(TextButton, 'Select again'), ); // 换源复制是真实文件 IO——runAsync 放行真实事件循环完成。 await pressButtonRealIo(tester, selectAgain); await settleDebounce(tester); final updated = await store.get('alice', task.id); expect(updated?.state, UploadTaskState.queued); expect(updated?.lastError, isNull); // 重试换源 = 新源复制进同一受管路径(路径不变、内容替换)。 expect(updated?.sourceFilePath, task.sourceFilePath); expect(File(task.sourceFilePath).readAsBytesSync(), kJpgBytes, reason: '受管副本内容已替换为重选文件'); }); testWidgets('移除记录:确认弹窗明确不删云端照片;确认后行消失 + 库内清除', (tester) async { final task = await seed('done.jpg', state: UploadTaskState.completed); await pumpQueue(tester); await tester.tap(find.descendant( of: rowOf('done.jpg'), matching: find.text('Remove record'), )); await tester.pumpAndSettle(); // §4.4:确认弹窗明确「仅清本机记录,不删云端照片」。 expect(find.text('Remove this record?'), findsOneWidget); expect( find.textContaining('Photos already uploaded are not deleted'), findsOneWidget, ); // 取消 → 记录保留。 await tester.tap(find.text('Cancel')); await tester.pumpAndSettle(); expect(find.text('done.jpg'), findsOneWidget); // 再删 → 确认 → 行消失。 await tester.tap(find.descendant( of: rowOf('done.jpg'), matching: find.text('Remove record'), )); await tester.pumpAndSettle(); await tester.tap(find.text('Remove')); await settleDebounce(tester); expect(find.text('done.jpg'), findsNothing); expect(await store.get('alice', task.id), isNull); }); testWidgets('批量重试条:口径 = 可重试失败才重试(等待网络/准备失败不混入)', (tester) async { final auto1 = await seed( 'f1.jpg', state: UploadTaskState.retryableFailed, lastError: 'INTERNAL_ERROR', ); await seed( 'f2.jpg', state: UploadTaskState.waitingNetwork, lastError: 'NETWORK_ERROR', ); await seed( 'f3.jpg', state: UploadTaskState.preparationFailed, lastError: 'PREP_COPY_FAILED', ); await pumpQueue(tester); // 批量条出现:标题计数与摘要「需关注」同口径(准备失败 + 上传失败), // 按钮文案「重试可恢复项」+ 范围说明(页面 15)。 expect(find.text('Retry eligible'), findsOneWidget); expect(find.text('Needs attention · 2'), findsOneWidget); expect(find.textContaining('recover on their own'), findsOneWidget); await tester.tap(find.text('Retry eligible')); await tester.pumpAndSettle(); // §4.4:一键重试仅作用于 retryableFailed——等待网络自动恢复、准备 // 失败需换源重选,均不进入批量重试。 expect(engine.retryCalls, [('alice', auto1.id)]); expect( engine.retryCalls.map((c) => c.$2), isNot(contains('task-f3.jpg')), reason: '准备失败需换源重选,不进批量重试', ); expect( engine.retryCalls.map((c) => c.$2), isNot(contains('task-f2.jpg')), reason: '等待网络自动恢复,不进批量重试', ); }); testWidgets('无失败任务 → 无批量条', (tester) async { await seed('q.jpg'); await pumpQueue(tester); expect(find.text('Retry eligible'), findsNothing); }); testWidgets('实时刷新:库内新增任务 → 防抖后列表出现(写通知驱动)', (tester) async { await seed('first.jpg'); await pumpQueue(tester); expect(find.text('second.jpg'), findsNothing); // 模拟连拍页入队(不经 UI 直接写库)。 await seed('second.jpg'); await settleDebounce(tester); expect(find.text('second.jpg'), findsOneWidget); }); testWidgets('行级进度:进度事件只刷新订阅行,未订阅行不整行重建', (tester) async { final task1 = await seed('up1.jpg', state: UploadTaskState.uploading); await seed('up2.jpg', state: UploadTaskState.uploading); await pumpQueue(tester); final context = tester.element(find.byType(QueueTab)); final hub = ProviderScope.containerOf(context) .read(uploadProgressHubProvider); LinearProgressIndicator indicatorOf(String filename) => find .descendant( of: rowOf(filename), matching: find.byType(LinearProgressIndicator), ) .evaluate() .single .widget as LinearProgressIndicator; expect(indicatorOf('up1.jpg').value, isNull, reason: '无事件时不确定态'); // 快照另一行的 widget 实例——行级订阅下它不应随 up1 的进度重建。 final otherBefore = indicatorOf('up2.jpg'); hub.emit(task1.id, 50, 100); await tester.pump(); expect(indicatorOf('up1.jpg').value, 0.5, reason: '进度事件行级刷新为 50%'); expect( find.descendant( of: rowOf('up1.jpg'), matching: find.text('Uploading 50%'), ), findsOneWidget, ); expect( identical(indicatorOf('up2.jpg'), otherBefore), isTrue, reason: '未收到进度事件的行不重建(行级进度,不整页刷新)', ); }); }