feat: 新增勘察明细管理与修改密码功能
This commit is contained in:
1 parent
56830e94ec
commit
774403785c
12 files changed
+1553
-18
No files matched your search
+5
-2
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../core/app_keys.dart';
|
||||
import '../features/auth/application/session_controller.dart';
|
||||
import '../features/auth/presentation/change_password_page.dart';
|
||||
import '../features/auth/presentation/login_page.dart';
|
||||
import '../features/auth/presentation/register_page.dart';
|
||||
import '../features/notice/presentation/notice_center_page.dart';
|
||||
@@ -38,9 +39,11 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
},
|
||||
routes: [
|
||||
GoRoute(path: '/login', builder: (context, state) => const LoginPage()),
|
||||
GoRoute(path: '/register', builder: (context, state) => const RegisterPage()),
|
||||
// 修改登录密码:「我的」页入口
|
||||
GoRoute(
|
||||
path: '/register',
|
||||
builder: (context, state) => const RegisterPage(),
|
||||
path: '/change-password',
|
||||
builder: (context, state) => const ChangePasswordPage(),
|
||||
),
|
||||
GoRoute(path: '/tabs', builder: (context, state) => const TabsPage()),
|
||||
|
||||
|
||||
@@ -6,6 +6,31 @@ import 'package:dio/dio.dart';
|
||||
import '../storage/token_storage.dart';
|
||||
import 'api_response.dart';
|
||||
|
||||
/// 图片上传结果,与后端 `POST /api/upload/image` 的 `data` 对齐。
|
||||
class UploadResult {
|
||||
const UploadResult({
|
||||
required this.key,
|
||||
required this.url,
|
||||
required this.name,
|
||||
required this.size,
|
||||
});
|
||||
|
||||
/// 对象存储 key,持久化到业务表。
|
||||
final String key;
|
||||
|
||||
/// 预签名访问 URL,用于即时预览。
|
||||
final String url;
|
||||
final String name;
|
||||
final int size;
|
||||
|
||||
factory UploadResult.fromJson(Map<String, dynamic> json) => UploadResult(
|
||||
key: json['key'] as String? ?? '',
|
||||
url: json['url'] as String? ?? '',
|
||||
name: json['name'] as String? ?? '',
|
||||
size: (json['size'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// 统一请求封装,等价于 uni-app 版 `utils/request.ts`:
|
||||
/// - 自动注入 `Authorization: Bearer <token>`
|
||||
/// - 解析 `{ code, message, data }`,非 200 也照常返回给调用方判断
|
||||
@@ -64,6 +89,36 @@ class ApiClient {
|
||||
bool needAuth = true,
|
||||
}) => _send<T>('PUT', url, body: body, parse: parse, needAuth: needAuth);
|
||||
|
||||
/// 上传现场照片到对象存储(multipart/form-data,字段名 `file`)。
|
||||
/// 走电工通道 `POST /api/worker-app/upload/image`(电工 token 无法访问管理端 `/api/upload`)。
|
||||
/// 依赖对象存储:失败直接抛异常,由调用方阻断保存并提示用户。
|
||||
Future<UploadResult> uploadImage(String filePath) async {
|
||||
try {
|
||||
final form = FormData.fromMap({
|
||||
'file': await MultipartFile.fromFile(filePath),
|
||||
});
|
||||
final res = await _dio.post<dynamic>(
|
||||
'/worker-app/upload/image',
|
||||
data: form,
|
||||
options: Options(headers: _authHeader()),
|
||||
);
|
||||
final json = _asMap(res.data);
|
||||
final code = (json['code'] as num?)?.toInt();
|
||||
final data = json['data'];
|
||||
if (code == 200 && data is Map) {
|
||||
return UploadResult.fromJson(data.cast<String, dynamic>());
|
||||
}
|
||||
throw ApiException(
|
||||
(json['message'] as String?) ?? '图片上传失败',
|
||||
code: code,
|
||||
);
|
||||
} on ApiException {
|
||||
rethrow;
|
||||
} catch (_) {
|
||||
throw const ApiException('图片上传失败:网络异常');
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() => _unauthorizedController.close();
|
||||
|
||||
// ---- 内部实现 ----
|
||||
|
||||
@@ -55,4 +55,12 @@ class AuthApi {
|
||||
},
|
||||
parse: (d) => WorkerInfo.fromJson((d as Map).cast<String, dynamic>()),
|
||||
);
|
||||
|
||||
/// 修改登录密码。后端 `/worker-app/profile` 收到 `password` 即更新密码哈希。
|
||||
Future<ApiResponse<WorkerInfo>> changePassword(String password) =>
|
||||
_client.put<WorkerInfo>(
|
||||
'/worker-app/profile',
|
||||
body: {'password': password},
|
||||
parse: (d) => WorkerInfo.fromJson((d as Map).cast<String, dynamic>()),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/network/api_response.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../shared/widgets/app_feedback.dart';
|
||||
import '../../../shared/widgets/x_card.dart';
|
||||
import '../../../shared/widgets/x_common.dart';
|
||||
import '../../../shared/widgets/x_top_bar.dart';
|
||||
import '../application/session_controller.dart';
|
||||
|
||||
/// 修改登录密码页。
|
||||
///
|
||||
/// 后端 `/worker-app/profile` 传 `password` 即更新密码哈希,
|
||||
/// 因此本页只校验新密码的长度与两次输入一致,不要求原密码。
|
||||
class ChangePasswordPage extends ConsumerStatefulWidget {
|
||||
const ChangePasswordPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ChangePasswordPage> createState() => _ChangePasswordPageState();
|
||||
}
|
||||
|
||||
class _ChangePasswordPageState extends ConsumerState<ChangePasswordPage> {
|
||||
final _password = TextEditingController();
|
||||
final _confirm = TextEditingController();
|
||||
|
||||
bool _loading = false;
|
||||
String _error = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_password.addListener(_onChanged);
|
||||
_confirm.addListener(_onChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_password.dispose();
|
||||
_confirm.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onChanged() => setState(() {});
|
||||
|
||||
bool get _canSubmit =>
|
||||
_password.text.length >= 6 && _password.text == _confirm.text;
|
||||
|
||||
Future<void> _handleSubmit() async {
|
||||
if (!_canSubmit) {
|
||||
setState(() => _error = '新密码至少 6 位,且两次输入需一致');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = '';
|
||||
});
|
||||
try {
|
||||
final res = await ref
|
||||
.read(authApiProvider)
|
||||
.changePassword(_password.text);
|
||||
if (!mounted) return;
|
||||
if (!res.isOk) {
|
||||
setState(() => _error = res.message.isEmpty ? '修改失败' : res.message);
|
||||
return;
|
||||
}
|
||||
showAppToast('密码修改成功', success: true);
|
||||
context.pop();
|
||||
} on ApiException catch (e) {
|
||||
if (mounted) setState(() => _error = e.message);
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _error = '网络异常,请稍后重试');
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: XPageShell(
|
||||
topBar: XTopBar.nav(title: '修改密码', onBack: () => context.pop()),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 40),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
XCard(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_Field(
|
||||
label: '新密码',
|
||||
hint: '至少 6 位',
|
||||
controller: _password,
|
||||
required: true,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_Field(
|
||||
label: '确认新密码',
|
||||
hint: '请再次输入新密码',
|
||||
controller: _confirm,
|
||||
required: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
XPrimaryButton(
|
||||
label: '确认修改',
|
||||
height: 56,
|
||||
radius: AppRadius.xl,
|
||||
loading: _loading,
|
||||
onPressed: _canSubmit ? _handleSubmit : null,
|
||||
shadows: AppShadows.primaryButton,
|
||||
),
|
||||
if (_error.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 14),
|
||||
child: Text(
|
||||
_error,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 13, color: AppColors.error),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'如忘记密码,可联系平台管理员重置为手机号后 6 位。',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13, color: AppColors.textMuted),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 表单输入项,与注册页保持同一套视觉语言。
|
||||
class _Field extends StatelessWidget {
|
||||
const _Field({
|
||||
required this.label,
|
||||
required this.hint,
|
||||
required this.controller,
|
||||
this.required = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String hint;
|
||||
final TextEditingController controller;
|
||||
final bool required;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: AppTypography.bodySm.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (required)
|
||||
const Text(
|
||||
' *',
|
||||
style: TextStyle(fontSize: 13, color: AppColors.error),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
height: 48,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceBright,
|
||||
border: Border.all(color: AppColors.borderSoft),
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
obscureText: true,
|
||||
style: const TextStyle(fontSize: 15, color: AppColors.onSurface),
|
||||
cursorColor: AppColors.primary,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
counterText: '',
|
||||
hintText: hint,
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppColors.textPlaceholder,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,16 @@ class _NoticeCenterPageState extends ConsumerState<NoticeCenterPage> {
|
||||
|
||||
Future<void> _loadMore() => _load();
|
||||
|
||||
/// 点击消息:先标记已读;勘察指派消息(`bizType == "survey_order"`)
|
||||
/// 带 `bizId` 时跳转到对应勘察订单详情,其余保持仅标记已读。
|
||||
Future<void> _onNoticeTap(Notice notice) async {
|
||||
if (!notice.isRead) await _markRead(notice);
|
||||
final bizId = notice.bizId;
|
||||
if (notice.bizType == 'survey_order' && bizId != null && mounted) {
|
||||
context.push('/order/survey/$bizId');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _markRead(Notice notice) async {
|
||||
if (notice.isRead) return;
|
||||
try {
|
||||
@@ -231,7 +241,7 @@ class _NoticeCenterPageState extends ConsumerState<NoticeCenterPage> {
|
||||
final notice = _items[index];
|
||||
return _NoticeTile(
|
||||
notice: notice,
|
||||
onTap: notice.isRead ? null : () => _markRead(notice),
|
||||
onTap: () => _onNoticeTap(notice),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -281,7 +291,7 @@ class _NoticeCenterPageState extends ConsumerState<NoticeCenterPage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 单条消息卡片:未读有蓝底高亮 + 红点,点击标记已读。
|
||||
/// 单条消息卡片:未读有蓝底高亮 + 红点,点击标记已读并按业务类型跳转。
|
||||
class _NoticeTile extends StatelessWidget {
|
||||
const _NoticeTile({required this.notice, this.onTap});
|
||||
|
||||
|
||||
@@ -33,6 +33,19 @@ class OrderApi {
|
||||
Future<ApiResponse<void>> grab(String source, int id) =>
|
||||
_client.post<void>('/worker-app/orders/$source/$id/grab');
|
||||
|
||||
/// 保存勘察明细(全量替换)。仅本人已接单且状态为「现场勘察中」的勘察单可写,
|
||||
/// 成功返回服务端最新明细(含预签名 `photoUrls`)。
|
||||
Future<ApiResponse<List<SurveyDetail>>> saveSurveyDetails(
|
||||
int id,
|
||||
List<SurveyDetail> details,
|
||||
) => _client.put<List<SurveyDetail>>(
|
||||
'/worker-app/orders/survey/$id/details',
|
||||
body: {'details': details.map((d) => d.toPayload()).toList()},
|
||||
parse: (d) => (d as List? ?? const [])
|
||||
.map((e) => SurveyDetail.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
/// 完成订单。非本人订单后端返回 403。
|
||||
Future<ApiResponse<void>> complete(String source, int id) =>
|
||||
_client.put<void>('/worker-app/orders/$source/$id/complete');
|
||||
|
||||
@@ -22,6 +22,8 @@ class Order {
|
||||
required this.laborFee,
|
||||
required this.materialFee,
|
||||
required this.estimatedDuration,
|
||||
this.details = const [],
|
||||
this.topology,
|
||||
});
|
||||
|
||||
/// `repair-12`
|
||||
@@ -52,6 +54,18 @@ class Order {
|
||||
final double materialFee;
|
||||
final String estimatedDuration;
|
||||
|
||||
/// 勘察明细(仅 `source == "survey"` 的详情接口返回)。
|
||||
final List<SurveyDetail> details;
|
||||
|
||||
/// 客户空间拓扑(仅 `source == "survey"` 的详情接口返回)。
|
||||
final CustomerTopology? topology;
|
||||
|
||||
/// 是否为现场勘察单。
|
||||
bool get isSurvey => source == 'survey';
|
||||
|
||||
/// 是否处于「现场勘察中(1)」,仅该状态可维护勘察明细。
|
||||
bool get surveyEditable => source == 'survey' && status == 1;
|
||||
|
||||
/// 来源中文名,用于收益明细副标题等场景。
|
||||
String get sourceName => switch (source) {
|
||||
'repair' => '家庭电路维修',
|
||||
@@ -109,5 +123,164 @@ class Order {
|
||||
laborFee: toDoubleOr(json['laborFee'], 0),
|
||||
materialFee: toDoubleOr(json['materialFee'], 0),
|
||||
estimatedDuration: json['estimatedDuration'] as String? ?? '',
|
||||
details: (json['details'] as List? ?? const [])
|
||||
.map((e) => SurveyDetail.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(),
|
||||
topology: json['topology'] is Map
|
||||
? CustomerTopology.fromJson(
|
||||
(json['topology'] as Map).cast<String, dynamic>(),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// 勘察明细:客户空间拓扑中的「建筑 + 楼层 + 房间(选填) + 现场照片 + 备注」。
|
||||
class SurveyDetail {
|
||||
const SurveyDetail({
|
||||
this.id = 0,
|
||||
required this.buildingId,
|
||||
required this.buildingName,
|
||||
required this.floorNo,
|
||||
this.roomId,
|
||||
this.roomName = '',
|
||||
this.photos = const [],
|
||||
this.photoUrls = const [],
|
||||
this.remark = '',
|
||||
});
|
||||
|
||||
/// 后端主键,新增行传 0(服务端忽略)。
|
||||
final int id;
|
||||
final int buildingId;
|
||||
final String buildingName;
|
||||
|
||||
/// 楼层:地上 `"1"`、地下 `"-1"`。
|
||||
final String floorNo;
|
||||
|
||||
final int? roomId;
|
||||
final String roomName;
|
||||
|
||||
/// 对象存储 key,提交时传数组。
|
||||
final List<String> photos;
|
||||
|
||||
/// 预签名访问 URL,仅用于预览。
|
||||
final List<String> photoUrls;
|
||||
final String remark;
|
||||
|
||||
/// 楼层展示文案:正数 `N层`、负数 `负N层`,无值返回空串。
|
||||
String get floorLabel {
|
||||
final n = int.tryParse(floorNo);
|
||||
if (n == null || n == 0) return '';
|
||||
return n > 0 ? '$n层' : '负${-n}层';
|
||||
}
|
||||
|
||||
factory SurveyDetail.fromJson(Map<String, dynamic> json) => SurveyDetail(
|
||||
id: toIntOr(json['id'], 0),
|
||||
buildingId: toIntOr(json['buildingId'], 0),
|
||||
buildingName: json['buildingName'] as String? ?? '',
|
||||
floorNo: json['floorNo']?.toString() ?? '',
|
||||
roomId: toIntOrNull(json['roomId']),
|
||||
roomName: json['roomName'] as String? ?? '',
|
||||
photos:
|
||||
(json['photos'] as List?)?.map((e) => e.toString()).toList() ??
|
||||
const [],
|
||||
photoUrls:
|
||||
(json['photoUrls'] as List?)?.map((e) => e.toString()).toList() ??
|
||||
const [],
|
||||
remark: json['remark'] as String? ?? '',
|
||||
);
|
||||
|
||||
/// 提交给后端的字段(全量替换数组的元素)。
|
||||
Map<String, dynamic> toPayload() => {
|
||||
'buildingId': buildingId,
|
||||
'buildingName': buildingName,
|
||||
'floorNo': floorNo,
|
||||
'roomId': roomId,
|
||||
'roomName': roomName,
|
||||
'photos': photos,
|
||||
'remark': remark,
|
||||
};
|
||||
}
|
||||
|
||||
/// 拓扑建筑:`floorCount` 地上层数、`floorDownCount` 地下层数。
|
||||
class TopologyBuilding {
|
||||
const TopologyBuilding({
|
||||
required this.id,
|
||||
required this.buildingName,
|
||||
required this.floorCount,
|
||||
required this.floorDownCount,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String buildingName;
|
||||
final int floorCount;
|
||||
final int floorDownCount;
|
||||
|
||||
factory TopologyBuilding.fromJson(Map<String, dynamic> json) =>
|
||||
TopologyBuilding(
|
||||
id: toIntOr(json['id'], 0),
|
||||
buildingName: json['buildingName'] as String? ?? '',
|
||||
floorCount: toIntOr(json['floorCount'], 0),
|
||||
floorDownCount: toIntOr(json['floorDownCount'], 0),
|
||||
);
|
||||
|
||||
/// 生成楼层选项:value 地上 `"1"`/地下 `"-1"`,label `"1层"`/`"负1层"`。
|
||||
List<({String value, String label})> get floorOptions => [
|
||||
for (var i = 1; i <= floorCount; i++) (value: '$i', label: '$i层'),
|
||||
for (var i = 1; i <= floorDownCount; i++) (value: '-$i', label: '负$i层'),
|
||||
];
|
||||
}
|
||||
|
||||
/// 拓扑房间:按 `buildingId` + `floorNo` 归属。
|
||||
class TopologyRoom {
|
||||
const TopologyRoom({
|
||||
required this.id,
|
||||
required this.roomName,
|
||||
required this.buildingId,
|
||||
required this.floorNo,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String roomName;
|
||||
final int buildingId;
|
||||
final String floorNo;
|
||||
|
||||
factory TopologyRoom.fromJson(Map<String, dynamic> json) => TopologyRoom(
|
||||
id: toIntOr(json['id'], 0),
|
||||
roomName: json['roomName'] as String? ?? '',
|
||||
buildingId: toIntOr(json['buildingId'], 0),
|
||||
floorNo: json['floorNo']?.toString() ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// 客户空间拓扑:建筑 + 房间。
|
||||
class CustomerTopology {
|
||||
const CustomerTopology({this.buildings = const [], this.rooms = const []});
|
||||
|
||||
final List<TopologyBuilding> buildings;
|
||||
final List<TopologyRoom> rooms;
|
||||
|
||||
factory CustomerTopology.fromJson(Map<String, dynamic> json) =>
|
||||
CustomerTopology(
|
||||
buildings: (json['buildings'] as List? ?? const [])
|
||||
.map(
|
||||
(e) => TopologyBuilding.fromJson((e as Map).cast<String, dynamic>()),
|
||||
)
|
||||
.toList(),
|
||||
rooms: (json['rooms'] as List? ?? const [])
|
||||
.map((e) => TopologyRoom.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
/// 按建筑 + 楼层过滤房间,用于明细编辑时的房间下拉。
|
||||
List<TopologyRoom> roomsOf(int buildingId, String floorNo) => rooms
|
||||
.where((r) => r.buildingId == buildingId && r.floorNo == floorNo)
|
||||
.toList();
|
||||
|
||||
/// 按 id 查建筑。
|
||||
TopologyBuilding? buildingOf(int id) {
|
||||
for (final b in buildings) {
|
||||
if (b.id == id) return b;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import '../../../core/network/api_response.dart';
|
||||
import '../../../core/providers.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
@@ -98,7 +101,10 @@ class OrderDetailPage extends ConsumerWidget {
|
||||
const SizedBox(height: 12),
|
||||
_CustomerCard(order: order),
|
||||
const SizedBox(height: 16),
|
||||
_PhotosSection(photos: order.photos),
|
||||
if (order.isSurvey)
|
||||
_SurveyDetailsSection(order: order)
|
||||
else
|
||||
_PhotosSection(photos: order.photos),
|
||||
const SizedBox(height: 16),
|
||||
_FinancialCard(order: order),
|
||||
const SizedBox(height: 16),
|
||||
@@ -737,3 +743,939 @@ class _DotPatternPainter extends CustomPainter {
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
/// 打开单张照片大图预览。
|
||||
void _previewPhoto(BuildContext context, String url) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierColor: Colors.black,
|
||||
builder: (_) => Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
insetPadding: EdgeInsets.zero,
|
||||
child: GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Center(child: Image.network(url, fit: BoxFit.contain)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 勘察明细区块:展示客户空间拓扑与明细列表;「现场勘察中」时可增删改并保存。
|
||||
class _SurveyDetailsSection extends ConsumerStatefulWidget {
|
||||
const _SurveyDetailsSection({required this.order});
|
||||
|
||||
final Order order;
|
||||
|
||||
@override
|
||||
ConsumerState<_SurveyDetailsSection> createState() =>
|
||||
_SurveyDetailsSectionState();
|
||||
}
|
||||
|
||||
class _SurveyDetailsSectionState extends ConsumerState<_SurveyDetailsSection> {
|
||||
List<SurveyDetail> _rows = const [];
|
||||
bool _dirty = false;
|
||||
bool _saving = false;
|
||||
|
||||
Order get _order => widget.order;
|
||||
bool get _editable => _order.surveyEditable;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_rows = List.of(_order.details);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _SurveyDetailsSection oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// 详情重新加载后同步服务端明细;本地有未保存改动时不覆盖。
|
||||
if (!_dirty && !identical(oldWidget.order.details, _order.details)) {
|
||||
_rows = List.of(_order.details);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _editRow([int? index]) async {
|
||||
final topology = _order.topology;
|
||||
if (topology == null || topology.buildings.isEmpty) {
|
||||
showAppToast('该客户暂无空间拓扑,无法维护勘察明细');
|
||||
return;
|
||||
}
|
||||
final result = await showModalBottomSheet<SurveyDetail>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: AppColors.surfaceContainerLowest,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)),
|
||||
),
|
||||
builder: (_) => _DetailEditorSheet(
|
||||
topology: topology,
|
||||
initial: index == null ? null : _rows[index],
|
||||
),
|
||||
);
|
||||
if (result == null || !mounted) return;
|
||||
setState(() {
|
||||
if (index == null) {
|
||||
_rows = [..._rows, result];
|
||||
} else {
|
||||
_rows = [..._rows]..[index] = result;
|
||||
}
|
||||
_dirty = true;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _confirmRemove(int index) async {
|
||||
final ok = await showAppConfirm(
|
||||
title: '删除明细',
|
||||
content: '确定删除该条勘察明细吗?',
|
||||
destructive: true,
|
||||
);
|
||||
if (!ok || !mounted) return;
|
||||
setState(() {
|
||||
_rows = [..._rows]..removeAt(index);
|
||||
_dirty = true;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (_saving) return;
|
||||
// 建筑、楼层为必填要素,缺失的行提交前过滤。
|
||||
final cleaned = _rows
|
||||
.where((d) => d.buildingId != 0 && d.floorNo.isNotEmpty)
|
||||
.toList();
|
||||
if (cleaned.length != _rows.length) {
|
||||
showAppToast('缺少建筑或楼层的明细行已忽略');
|
||||
}
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
final res = await ref
|
||||
.read(orderApiProvider)
|
||||
.saveSurveyDetails(_order.intId, cleaned);
|
||||
if (!mounted) return;
|
||||
if (res.isOk && res.data != null) {
|
||||
setState(() {
|
||||
_rows = List.of(res.data!);
|
||||
_dirty = false;
|
||||
});
|
||||
showAppToast('勘察明细已保存', success: true);
|
||||
} else {
|
||||
showAppToast(res.message.isEmpty ? '保存失败' : res.message);
|
||||
}
|
||||
} on ApiException catch (e) {
|
||||
if (mounted) showAppToast(e.message);
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final topology = _order.topology;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_header(),
|
||||
if (topology != null && topology.buildings.isNotEmpty) ...[
|
||||
_TopologyCard(topology: topology),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if (_rows.isEmpty)
|
||||
XCard(
|
||||
padding: const EdgeInsets.symmetric(vertical: 28),
|
||||
child: const XPlaceholder(
|
||||
text: '暂无勘察明细',
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
)
|
||||
else
|
||||
for (var i = 0; i < _rows.length; i++) ...[
|
||||
if (i > 0) const SizedBox(height: 12),
|
||||
_DetailRowCard(
|
||||
detail: _rows[i],
|
||||
editable: _editable,
|
||||
onEdit: () => _editRow(i),
|
||||
onDelete: () => _confirmRemove(i),
|
||||
),
|
||||
],
|
||||
if (_editable) ...[
|
||||
const SizedBox(height: 16),
|
||||
XPrimaryButton(
|
||||
label: '保存勘察明细',
|
||||
loading: _saving,
|
||||
onPressed: _dirty ? _save : null,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _header() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'勘察明细',
|
||||
style: AppTypography.bodySm.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textMuted,
|
||||
letterSpacing: 0.26,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (_editable)
|
||||
GestureDetector(
|
||||
onTap: () => _editRow(),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accentSubtle,
|
||||
borderRadius: BorderRadius.circular(AppRadius.regular),
|
||||
),
|
||||
child: Text(
|
||||
'+ 新增明细',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 客户空间拓扑摘要:列出各建筑及其地上/地下层数。
|
||||
class _TopologyCard extends StatelessWidget {
|
||||
const _TopologyCard({required this.topology});
|
||||
|
||||
final CustomerTopology topology;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return XCard(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
'客户空间',
|
||||
style: AppTypography.bodySm.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textMuted,
|
||||
letterSpacing: 0.26,
|
||||
),
|
||||
),
|
||||
),
|
||||
for (var i = 0; i < topology.buildings.length; i++) ...[
|
||||
if (i > 0) const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const XIcon('location', size: 18, color: AppColors.primary),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
topology.buildings[i].buildingName,
|
||||
style: AppTypography.bodyMd.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_floorSummary(topology.buildings[i]),
|
||||
style: AppTypography.labelCaps.copyWith(
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _floorSummary(TopologyBuilding b) {
|
||||
final parts = <String>[];
|
||||
if (b.floorCount > 0) parts.add('地上 ${b.floorCount} 层');
|
||||
if (b.floorDownCount > 0) parts.add('地下 ${b.floorDownCount} 层');
|
||||
return parts.isEmpty ? '暂无楼层' : parts.join(' · ');
|
||||
}
|
||||
}
|
||||
|
||||
/// 单条勘察明细的只读卡片。
|
||||
class _DetailRowCard extends StatelessWidget {
|
||||
const _DetailRowCard({
|
||||
required this.detail,
|
||||
required this.editable,
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
final SurveyDetail detail;
|
||||
final bool editable;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final building = detail.buildingName.isEmpty
|
||||
? '未知建筑'
|
||||
: detail.buildingName;
|
||||
final title = detail.floorLabel.isEmpty
|
||||
? building
|
||||
: '$building · ${detail.floorLabel}';
|
||||
|
||||
return XCard(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: AppTypography.bodyLg.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (editable) ...[
|
||||
const SizedBox(width: 8),
|
||||
_TextAction(
|
||||
label: '编辑',
|
||||
color: AppColors.primary,
|
||||
onTap: onEdit,
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
_TextAction(
|
||||
label: '删除',
|
||||
color: AppColors.statusError,
|
||||
onTap: onDelete,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (detail.roomName.isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
_kv('房间', detail.roomName),
|
||||
],
|
||||
if (detail.remark.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
_kv('备注', detail.remark),
|
||||
],
|
||||
if (detail.photoUrls.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 92,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: detail.photoUrls.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 10),
|
||||
itemBuilder: (context, i) => GestureDetector(
|
||||
onTap: () => _previewPhoto(context, detail.photoUrls[i]),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
child: Image.network(
|
||||
detail.photoUrls[i],
|
||||
width: 92,
|
||||
height: 92,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => Container(
|
||||
width: 92,
|
||||
height: 92,
|
||||
color: AppColors.surfaceContainerHigh,
|
||||
child: const XIcon(
|
||||
'camera',
|
||||
size: 24,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _kv(String label, String value) => Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 48,
|
||||
child: Text(
|
||||
label,
|
||||
style: AppTypography.labelCaps.copyWith(color: AppColors.textMuted),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: AppTypography.bodyMd.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 文字操作(编辑 / 删除)。
|
||||
class _TextAction extends StatelessWidget {
|
||||
const _TextAction({
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 2),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: color),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 明细编辑弹层:建筑 / 楼层(必填)、房间(选填)、现场照片、备注。
|
||||
class _DetailEditorSheet extends ConsumerStatefulWidget {
|
||||
const _DetailEditorSheet({required this.topology, this.initial});
|
||||
|
||||
final CustomerTopology topology;
|
||||
final SurveyDetail? initial;
|
||||
|
||||
@override
|
||||
ConsumerState<_DetailEditorSheet> createState() => _DetailEditorSheetState();
|
||||
}
|
||||
|
||||
class _DetailEditorSheetState extends ConsumerState<_DetailEditorSheet> {
|
||||
late final TextEditingController _remark;
|
||||
late List<({String key, String url})> _photos;
|
||||
|
||||
int? _buildingId;
|
||||
String _floorNo = '';
|
||||
int? _roomId;
|
||||
String _roomName = '';
|
||||
bool _uploading = false;
|
||||
|
||||
CustomerTopology get _topology => widget.topology;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final initial = widget.initial;
|
||||
_buildingId = (initial?.buildingId ?? 0) == 0 ? null : initial!.buildingId;
|
||||
_floorNo = initial?.floorNo ?? '';
|
||||
_roomId = initial?.roomId;
|
||||
_roomName = initial?.roomName ?? '';
|
||||
_remark = TextEditingController(text: initial?.remark ?? '');
|
||||
final keys = initial?.photos ?? const <String>[];
|
||||
final urls = initial?.photoUrls ?? const <String>[];
|
||||
_photos = [
|
||||
for (var i = 0; i < keys.length; i++)
|
||||
(key: keys[i], url: i < urls.length ? urls[i] : ''),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_remark.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
TopologyBuilding? get _building =>
|
||||
_buildingId == null ? null : _topology.buildingOf(_buildingId!);
|
||||
|
||||
Future<void> _pickChoice(
|
||||
List<String> labels,
|
||||
int current,
|
||||
ValueChanged<int> onSelected,
|
||||
) async {
|
||||
if (labels.isEmpty) return;
|
||||
final index = await showModalBottomSheet<int>(
|
||||
context: context,
|
||||
backgroundColor: AppColors.surfaceContainerLowest,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)),
|
||||
),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(ctx).size.height * 0.6,
|
||||
),
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
for (var i = 0; i < labels.length; i++)
|
||||
ListTile(
|
||||
title: Text(
|
||||
labels[i],
|
||||
style: AppTypography.bodyMd.copyWith(
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
trailing: i == current
|
||||
? const XIcon('check', size: 18, color: AppColors.primary)
|
||||
: null,
|
||||
onTap: () => Navigator.of(ctx).pop(i),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (index != null) onSelected(index);
|
||||
}
|
||||
|
||||
void _pickBuilding() {
|
||||
final buildings = _topology.buildings;
|
||||
final current = buildings.indexWhere((b) => b.id == _buildingId);
|
||||
_pickChoice(buildings.map((b) => b.buildingName).toList(), current, (i) {
|
||||
final b = buildings[i];
|
||||
setState(() {
|
||||
_buildingId = b.id;
|
||||
// 建筑变更后楼层/房间重新选择
|
||||
_floorNo = '';
|
||||
_roomId = null;
|
||||
_roomName = '';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _pickFloor() {
|
||||
final options = _building?.floorOptions ?? const [];
|
||||
final current = options.indexWhere((o) => o.value == _floorNo);
|
||||
_pickChoice(options.map((o) => o.label).toList(), current, (i) {
|
||||
setState(() {
|
||||
_floorNo = options[i].value;
|
||||
_roomId = null;
|
||||
_roomName = '';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _pickRoom() {
|
||||
final building = _building;
|
||||
if (building == null || _floorNo.isEmpty) return;
|
||||
final rooms = _topology.roomsOf(building.id, _floorNo);
|
||||
if (rooms.isEmpty) return;
|
||||
final labels = ['不选择房间', ...rooms.map((r) => r.roomName)];
|
||||
final current = rooms.indexWhere((r) => r.id == _roomId) + 1;
|
||||
_pickChoice(labels, current, (i) {
|
||||
setState(() {
|
||||
if (i == 0) {
|
||||
_roomId = null;
|
||||
_roomName = '';
|
||||
} else {
|
||||
_roomId = rooms[i - 1].id;
|
||||
_roomName = rooms[i - 1].roomName;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _addPhoto() async {
|
||||
if (_uploading) return;
|
||||
final source = await showModalBottomSheet<ImageSource>(
|
||||
context: context,
|
||||
backgroundColor: AppColors.surfaceContainerLowest,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)),
|
||||
),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text(
|
||||
'拍照',
|
||||
style: AppTypography.bodyMd.copyWith(color: AppColors.onSurface),
|
||||
),
|
||||
onTap: () => Navigator.of(ctx).pop(ImageSource.camera),
|
||||
),
|
||||
ListTile(
|
||||
title: Text(
|
||||
'从相册选择',
|
||||
style: AppTypography.bodyMd.copyWith(color: AppColors.onSurface),
|
||||
),
|
||||
onTap: () => Navigator.of(ctx).pop(ImageSource.gallery),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (source == null || !mounted) return;
|
||||
|
||||
final file = await ImagePicker().pickImage(source: source, imageQuality: 80);
|
||||
if (file == null || !mounted) return;
|
||||
|
||||
setState(() => _uploading = true);
|
||||
try {
|
||||
final up = await ref.read(apiClientProvider).uploadImage(file.path);
|
||||
if (!mounted) return;
|
||||
setState(() => _photos = [..._photos, (key: up.key, url: up.url)]);
|
||||
} catch (e) {
|
||||
final message = e is ApiException && e.message.isNotEmpty
|
||||
? e.message
|
||||
: '照片上传失败';
|
||||
if (mounted) showAppToast(message);
|
||||
} finally {
|
||||
if (mounted) setState(() => _uploading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _confirm() {
|
||||
final building = _building;
|
||||
if (building == null) {
|
||||
showAppToast('请选择建筑');
|
||||
return;
|
||||
}
|
||||
if (_floorNo.isEmpty) {
|
||||
showAppToast('请选择楼层');
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop(
|
||||
SurveyDetail(
|
||||
id: widget.initial?.id ?? 0,
|
||||
buildingId: building.id,
|
||||
buildingName: building.buildingName,
|
||||
floorNo: _floorNo,
|
||||
roomId: _roomId,
|
||||
roomName: _roomName,
|
||||
photos: _photos
|
||||
.map((p) => p.key)
|
||||
.where((k) => k.isNotEmpty)
|
||||
.toList(),
|
||||
photoUrls: _photos.map((p) => p.url).toList(),
|
||||
remark: _remark.text.trim(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final building = _building;
|
||||
final floorOptions = building?.floorOptions ?? const [];
|
||||
final floorLabel = floorOptions
|
||||
.where((o) => o.value == _floorNo)
|
||||
.map((o) => o.label)
|
||||
.firstOrNull;
|
||||
final rooms = building == null || _floorNo.isEmpty
|
||||
? const <TopologyRoom>[]
|
||||
: _topology.roomsOf(building.id, _floorNo);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
widget.initial == null ? '新增勘察明细' : '编辑勘察明细',
|
||||
style: AppTypography.headlineMd.copyWith(
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_field(
|
||||
label: '建筑',
|
||||
required: true,
|
||||
child: _pickerBox(
|
||||
building?.buildingName ?? '',
|
||||
'请选择建筑',
|
||||
_pickBuilding,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_field(
|
||||
label: '楼层',
|
||||
required: true,
|
||||
child: _pickerBox(
|
||||
floorLabel ?? '',
|
||||
building == null
|
||||
? '请先选择建筑'
|
||||
: (floorOptions.isEmpty ? '暂无可选楼层' : '请选择楼层'),
|
||||
building == null || floorOptions.isEmpty ? null : _pickFloor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_field(
|
||||
label: '房间',
|
||||
child: _pickerBox(
|
||||
_roomName,
|
||||
_floorNo.isEmpty
|
||||
? '请先选择楼层'
|
||||
: (rooms.isEmpty ? '暂无房间' : '选择房间(选填)'),
|
||||
rooms.isEmpty ? null : _pickRoom,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_field(
|
||||
label: '现场照片',
|
||||
child: _photoGrid(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_field(
|
||||
label: '备注',
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: _fieldDecoration,
|
||||
child: TextField(
|
||||
controller: _remark,
|
||||
maxLines: 4,
|
||||
minLines: 3,
|
||||
style: AppTypography.bodyMd.copyWith(
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
cursorColor: AppColors.primary,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
hintText: '补充说明(选填)',
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppColors.textPlaceholder,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: XPrimaryButton(
|
||||
label: '取消',
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
background: AppColors.surface,
|
||||
foreground: AppColors.primary,
|
||||
height: 48,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: XPrimaryButton(
|
||||
label: '确定',
|
||||
onPressed: _uploading ? null : _confirm,
|
||||
height: 48,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _field({
|
||||
required String label,
|
||||
required Widget child,
|
||||
bool required = false,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: AppTypography.bodySm.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (required)
|
||||
Text(
|
||||
' *',
|
||||
style: AppTypography.bodySm.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.statusError,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
child,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _photoGrid() {
|
||||
return SizedBox(
|
||||
height: 84,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
for (var i = 0; i < _photos.length; i++) ...[
|
||||
Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
child: _photos[i].url.isNotEmpty
|
||||
? Image.network(
|
||||
_photos[i].url,
|
||||
width: 84,
|
||||
height: 84,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => Container(
|
||||
width: 84,
|
||||
height: 84,
|
||||
color: AppColors.surfaceContainerHigh,
|
||||
child: const XIcon(
|
||||
'camera',
|
||||
size: 22,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 84,
|
||||
height: 84,
|
||||
color: AppColors.surfaceContainerHigh,
|
||||
child: const XIcon(
|
||||
'camera',
|
||||
size: 22,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() {
|
||||
_photos = [..._photos]..removeAt(i);
|
||||
}),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0x8C0D1C2C),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const XIcon(
|
||||
'close',
|
||||
size: 12,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
GestureDetector(
|
||||
onTap: _uploading ? null : _addPhoto,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
width: 84,
|
||||
height: 84,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceBright,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(color: AppColors.borderRule),
|
||||
),
|
||||
child: _uploading
|
||||
? const Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation(AppColors.primary),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: const [
|
||||
XIcon('camera', size: 24, color: AppColors.primary),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'添加',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _pickerBox(String value, String placeholder, VoidCallback? onTap) {
|
||||
final empty = value.isEmpty;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: 44,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: _fieldDecoration,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
empty ? placeholder : value,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppTypography.bodyMd.copyWith(
|
||||
color: empty ? AppColors.textPlaceholder : AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (onTap != null)
|
||||
const XIcon('forward', size: 16, color: AppColors.outline),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
BoxDecoration get _fieldDecoration => BoxDecoration(
|
||||
color: AppColors.surfaceBright,
|
||||
border: Border.all(color: AppColors.borderRule),
|
||||
borderRadius: BorderRadius.circular(AppRadius.regular),
|
||||
);
|
||||
}
|
||||
@@ -68,7 +68,7 @@ class ProfileTab extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
_profileCard(worker, profile?.primaryCert),
|
||||
_menuSection(),
|
||||
_menuSection(context),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: _logoutButton(ref),
|
||||
@@ -323,25 +323,29 @@ class ProfileTab extends ConsumerWidget {
|
||||
],
|
||||
);
|
||||
|
||||
/// 两组 `ios-card` 菜单,行点击仅提示(同源实现)。
|
||||
Widget _menuSection() {
|
||||
/// 两组 `ios-card` 菜单,除「修改密码」跳转外其余行点击仅提示(同源实现)。
|
||||
Widget _menuSection(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||
child: Column(
|
||||
children: [
|
||||
_menuGroup(const [
|
||||
('order', '我的订单'),
|
||||
('earnings', '收益记录'),
|
||||
('notify', '账户设置'),
|
||||
_menuGroup(context, const [
|
||||
('order', '我的订单', null),
|
||||
('earnings', '收益记录', null),
|
||||
('settings', '修改密码', '/change-password'),
|
||||
('notify', '账户设置', null),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
_menuGroup(const [('star', '帮助中心'), ('shandian', '关于我们')]),
|
||||
_menuGroup(context, const [
|
||||
('star', '帮助中心', null),
|
||||
('shandian', '关于我们', null),
|
||||
]),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _menuGroup(List<(String, String)> items) {
|
||||
Widget _menuGroup(BuildContext context, List<(String, String, String?)> items) {
|
||||
return XCard(
|
||||
radius: AppRadius.lg,
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
@@ -354,16 +358,16 @@ class ProfileTab extends ConsumerWidget {
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
color: AppColors.borderSoft,
|
||||
),
|
||||
_menuRow(items[i].$1, items[i].$2),
|
||||
_menuRow(context, items[i].$1, items[i].$2, items[i].$3),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _menuRow(String icon, String label) {
|
||||
Widget _menuRow(BuildContext context, String icon, String label, String? route) {
|
||||
return GestureDetector(
|
||||
onTap: () => showAppToast(label),
|
||||
onTap: () => route == null ? showAppToast(label) : context.push(route),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
|
||||
Reference in new issue
Block a user