From 411ec8be23bedd422332e9d0ba1370c701d1494f Mon Sep 17 00:00:00 2001 From: andyesfly <35127884@qq.com> Date: Wed, 21 Oct 2026 11:58:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=AE=A2=E5=8D=95?= =?UTF-8?q?=E6=8B=A8=E5=8F=B7=E5=8A=9F=E8=83=BD=EF=BC=8C=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E8=AE=A2=E5=8D=95=E9=A1=B5=E4=B8=8E=E7=99=BB=E5=BD=95=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 10 +- ios/Runner/Info.plist | 6 + lib/core/env.dart | 8 +- .../auth/presentation/login_page.dart | 658 ++++++++---------- lib/features/order/data/order_models.dart | 28 + .../order/presentation/order_detail_page.dart | 44 +- .../order/presentation/order_tab.dart | 538 +++++++++++--- pubspec.lock | 64 ++ pubspec.yaml | 1 + 9 files changed, 875 insertions(+), 482 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4d14762..9c9398e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,11 @@ + + + android:icon="@mipmap/ic_launcher" + android:usesCleartextTraffic="true"> + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 8f03d66..443847e 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,6 +2,12 @@ + NSAppTransportSecurity + + + NSAllowsArbitraryLoads + + CADisableMinimumFrameDurationOnPhone NSCameraUsageDescription diff --git a/lib/core/env.dart b/lib/core/env.dart index 1cfc91c..f9d8ef5 100644 --- a/lib/core/env.dart +++ b/lib/core/env.dart @@ -7,9 +7,13 @@ /// 解析顺序见 [resolveBaseUrl]。 abstract final class Env { /// 编译期默认地址,作为兜底方案。 + /// + /// 默认值指向当前开发机后端(本机实测 7001 可用)。局域网 IP 变化后, + /// 登录页右上角「服务端设置」可直接改,或启动时用 + /// `--dart-define=API_BASE_URL=http://:7001/api` 覆盖。 static const String apiBaseUrl = String.fromEnvironment( 'API_BASE_URL', - defaultValue: 'http://192.168.3.29:7001/api', + defaultValue: 'http://192.168.21.184:7001/api', ); static final Uri _default = Uri.parse(apiBaseUrl); @@ -17,7 +21,7 @@ abstract final class Env { /// 默认主机,取自 [apiBaseUrl]。 static String get defaultHost => _default.host; - /// 默认端口,取自 [apiBaseUrl],未显式指定时回落 7007。 + /// 默认端口,取自 [apiBaseUrl],未显式指定时回落 7001。 static int get defaultPort => _default.hasPort ? _default.port : 7001; /// 由 IP + 端口拼接 Base URL;协议与路径沿用 [apiBaseUrl], diff --git a/lib/features/auth/presentation/login_page.dart b/lib/features/auth/presentation/login_page.dart index 1ace9a8..913bee0 100644 --- a/lib/features/auth/presentation/login_page.dart +++ b/lib/features/auth/presentation/login_page.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../core/env.dart'; +import '../../../core/network/api_response.dart'; import '../../../core/providers.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; @@ -20,10 +21,11 @@ import '../application/session_controller.dart'; /// `--dart-define=ENABLE_SERVER_SETTINGS=true` 打开。 const _serverSettingsEnabled = bool.fromEnvironment('ENABLE_SERVER_SETTINGS'); -/// 登录页,对应源 `pages/login/index.vue`。 +/// 登录页:手机号 + 密码。 /// -/// **与源的差异(已与用户确认)**:源为「手机号 + 验证码」,后端只支持 -/// 「手机号 + 密码」,故此处改为手机号 + 密码,并新增注册入口。 +/// 后端 `/worker-app/auth/login` 只支持手机号 + 密码(源 uni-app 的 +/// 「手机号 + 验证码」已按后端能力调整)。页面只保留登录必需元素, +/// 注册入口作为次要操作放在卡片底部。 class LoginPage extends ConsumerStatefulWidget { const LoginPage({super.key}); @@ -35,18 +37,10 @@ class _LoginPageState extends ConsumerState { final _phone = TextEditingController(); final _password = TextEditingController(); - bool _agree = false; bool _obscure = true; bool _loading = false; String _error = ''; - @override - void initState() { - super.initState(); - _phone.addListener(_onChanged); - _password.addListener(_onChanged); - } - @override void dispose() { _phone.dispose(); @@ -54,18 +48,31 @@ class _LoginPageState extends ConsumerState { super.dispose(); } - void _onChanged() => setState(() {}); - - bool get _canLogin => - _phone.text.length == 11 && _password.text.length >= 6 && _agree; - Future _handleLogin() async { - if (!_canLogin) { - if (!_agree) _error = '请先阅读并同意服务协议与隐私政策'; - setState(() {}); + if (_loading) return; + + final phone = _phone.text.trim(); + final password = _password.text; + + // 按钮始终可点,校验失败给出明确提示,避免「点了没反应」。 + if (phone.isEmpty) { + setState(() => _error = '请输入手机号'); + return; + } + if (phone.length != 11) { + setState(() => _error = '请输入 11 位手机号'); + return; + } + if (password.isEmpty) { + setState(() => _error = '请输入密码'); + return; + } + if (password.length < 6) { + setState(() => _error = '密码至少 6 位'); return; } + FocusScope.of(context).unfocus(); setState(() { _loading = true; _error = ''; @@ -73,13 +80,22 @@ class _LoginPageState extends ConsumerState { try { await ref .read(sessionProvider.notifier) - .login(phone: _phone.text.trim(), password: _password.text); + .login(phone: phone, password: password); if (!mounted) return; context.go('/tabs'); } on AuthException catch (e) { + // 后端返回的业务错误(如手机号或密码错误) if (mounted) setState(() => _error = e.message); + } on ApiException catch (e) { + if (!mounted) return; + final base = ref.read(apiBaseUrlProvider); + setState( + () => _error = kDebugMode + ? '无法连接服务端:${e.message}\n$base' + : '网络异常,请检查网络后重试', + ); } catch (_) { - if (mounted) setState(() => _error = '网络异常,请检查服务端地址'); + if (mounted) setState(() => _error = '网络异常,请检查网络后重试'); } finally { if (mounted) setState(() => _loading = false); } @@ -88,53 +104,57 @@ class _LoginPageState extends ConsumerState { @override Widget build(BuildContext context) { return Scaffold( - body: Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0xFFF0F4FF), Color(0xFFFAF9FE)], - ), - ), - child: Stack( - children: [ - Positioned.fill( - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(24, 80, 24, 60), - child: Column( - children: [ - _buildLogoSection(), - _buildFormSection(), - _buildOtherLogin(), - _buildAgreement(), - const SizedBox(height: 24), - _buildRegisterEntry(), - ], - ), - ), - ), - // 测试用「服务端设置」入口:调试版常显,release 需显式开关 - if (kDebugMode || _serverSettingsEnabled) - Positioned( - top: 0, - right: 0, - child: SafeArea( - child: GestureDetector( - onTap: _openServerSettings, - behavior: HitTestBehavior.opaque, - child: const Padding( - padding: EdgeInsets.all(12), - child: XIcon( - 'settings', - size: 22, - color: AppColors.textMuted, + backgroundColor: AppColors.bgPage, + body: Stack( + children: [ + Positioned.fill( + child: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 32, 24, 32), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 64, + ), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildBrand(), + const SizedBox(height: 28), + _buildFormCard(), + const SizedBox(height: 20), + _buildAgreementHint(), + ], + ), ), ), ), ), ), - ], - ), + ), + ), + // 测试用「服务端设置」入口:调试版常显,release 需显式开关 + if (kDebugMode || _serverSettingsEnabled) + Positioned( + top: 0, + right: 0, + child: SafeArea( + child: IconButton( + onPressed: _openServerSettings, + tooltip: '服务端设置', + icon: const XIcon( + 'settings', + size: 20, + color: AppColors.textMuted, + ), + ), + ), + ), + ], ), ); } @@ -151,331 +171,175 @@ class _LoginPageState extends ConsumerState { // ---- 各区块 ---- - Widget _buildLogoSection() { - return Padding( - padding: const EdgeInsets.only(bottom: 48), - child: Column( - children: [ - // .logo 96×96 / #0052ff / radius 20 / 0 8px 24px rgba(0,82,255,.3) - Container( - width: 96, - height: 96, - margin: const EdgeInsets.only(bottom: 24), - decoration: BoxDecoration( - color: AppColors.primary, - borderRadius: BorderRadius.circular(20), - boxShadow: const [ - BoxShadow( - color: Color(0x4D0052FF), - blurRadius: 24, - offset: Offset(0, 8), - ), - ], - ), - alignment: Alignment.center, - child: const XIcon('shandian', size: 48, color: Colors.white), + Widget _buildBrand() { + return Column( + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(18), + boxShadow: AppShadows.primaryButton, ), - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - 'VoltLink', - style: AppTypography.displayMd.copyWith( - color: AppColors.onSurface, - ), - ), + alignment: Alignment.center, + child: const XIcon('shandian', size: 34, color: Colors.white), + ), + const SizedBox(height: 16), + Text( + 'VoltLink', + style: AppTypography.navLargeTitle.copyWith( + color: AppColors.onSurface, ), - const Text( - '欢迎回到 VoltLink', - style: TextStyle(fontSize: 16, color: AppColors.textMuted), - ), - ], - ), + ), + const SizedBox(height: 6), + const Text( + '电网作业电工端', + style: TextStyle(fontSize: 14, color: AppColors.textMuted), + ), + ], ); } - Widget _buildFormSection() { - return Padding( - padding: const EdgeInsets.only(bottom: 48), - child: Column( - children: [ - // .input-group:白底 / radius 16 / padding 1px - Container( - decoration: BoxDecoration( - color: AppColors.surfaceContainerLowest, - borderRadius: BorderRadius.circular(AppRadius.xl), - ), - child: Column( - children: [ - _buildPhoneRow(), - _buildPasswordRow(), - ], - ), - ), - const SizedBox(height: 24), - _buildLoginButton(), - if (_error.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 14), - child: Text( - _error, - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 13, color: AppColors.error), - ), - ), - ], - ), - ); - } - - /// 手机号行:+86 国家码 + 分隔线 + 输入框。 - Widget _buildPhoneRow() { + Widget _buildFormCard() { return Container( - height: 56, - decoration: const BoxDecoration( - border: Border(bottom: BorderSide(color: AppColors.surfaceContainerHighest)), + padding: const EdgeInsets.fromLTRB(20, 24, 20, 20), + decoration: BoxDecoration( + color: AppColors.surfaceContainerLowest, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: AppColors.borderSoft), + boxShadow: AppShadows.soft, ), - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Row( - children: [ - Text( - '+86', - style: AppTypography.bodyLg.copyWith( - fontWeight: FontWeight.w600, - color: AppColors.onSurface, - ), - ), - const SizedBox(width: 4), - const Text( - '▼', - style: TextStyle(fontSize: 12, color: AppColors.textMuted), - ), - ], + Text( + '登录', + style: AppTypography.headlineMd.copyWith( + color: AppColors.onSurface, ), ), - Container(width: 1, height: 32, color: AppColors.surfaceContainerHighest), - Expanded( - child: TextField( - controller: _phone, - keyboardType: TextInputType.phone, - maxLength: 11, - style: const TextStyle(fontSize: 17, color: AppColors.onSurface), - cursorColor: AppColors.primary, - decoration: _inputDecoration('请输入手机号码'), + const SizedBox(height: 4), + const Text( + '使用注册手机号和密码登录', + style: TextStyle(fontSize: 13, color: AppColors.textMuted), + ), + const SizedBox(height: 20), + _buildLabel('手机号'), + const SizedBox(height: 8), + TextField( + controller: _phone, + keyboardType: TextInputType.phone, + textInputAction: TextInputAction.next, + maxLength: 11, + style: const TextStyle(fontSize: 16, color: AppColors.onSurface), + cursorColor: AppColors.primary, + decoration: _fieldDecoration( + hint: '请输入手机号', + icon: Icons.phone_iphone_rounded, ), ), + const SizedBox(height: 16), + _buildLabel('密码'), + const SizedBox(height: 8), + TextField( + controller: _password, + obscureText: _obscure, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _handleLogin(), + style: const TextStyle(fontSize: 16, color: AppColors.onSurface), + cursorColor: AppColors.primary, + decoration: _fieldDecoration( + hint: '请输入密码(至少 6 位)', + icon: Icons.lock_outline_rounded, + suffix: IconButton( + onPressed: () => setState(() => _obscure = !_obscure), + tooltip: _obscure ? '显示密码' : '隐藏密码', + icon: Icon( + _obscure + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + size: 20, + color: AppColors.textMuted, + ), + ), + ), + ), + if (_error.isNotEmpty) ...[ + const SizedBox(height: 14), + _buildError(), + ], + const SizedBox(height: 20), + XPrimaryButton( + label: '登录', + onPressed: _handleLogin, + loading: _loading, + height: 52, + radius: AppRadius.lg, + shadows: AppShadows.primaryButton, + ), + const SizedBox(height: 16), + _buildRegisterEntry(), ], ), ); } - /// 密码行:源为「验证码 + 获取验证码」,后端改为密码,右侧按钮换成明文切换。 - Widget _buildPasswordRow() { - return SizedBox( - height: 56, - child: Row( - children: [ - Expanded( - child: TextField( - controller: _password, - obscureText: _obscure, - style: const TextStyle(fontSize: 17, color: AppColors.onSurface), - cursorColor: AppColors.primary, - decoration: _inputDecoration('请输入密码(至少 6 位)'), - ), - ), - GestureDetector( - onTap: () => setState(() => _obscure = !_obscure), - behavior: HitTestBehavior.opaque, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 12, - ), - child: Text( - _obscure ? '显示' : '隐藏', - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w500, - color: AppColors.primary, - ), - ), - ), - ), - ], - ), - ); - } - - InputDecoration _inputDecoration(String hint) => InputDecoration( - isDense: true, - border: InputBorder.none, - counterText: '', - contentPadding: const EdgeInsets.symmetric(horizontal: 20), - hintText: hint, - hintStyle: const TextStyle(fontSize: 17, color: AppColors.textPlaceholder), + Widget _buildLabel(String text) => Text( + text, + style: AppTypography.bodySm.copyWith(color: AppColors.onSurfaceVariant), ); - Widget _buildLoginButton() { - final enabled = _canLogin && !_loading; - return GestureDetector( - onTap: enabled ? _handleLogin : null, - behavior: HitTestBehavior.opaque, - child: AnimatedContainer( - duration: const Duration(milliseconds: 120), - height: 56, - decoration: BoxDecoration( - color: enabled ? AppColors.primary : AppColors.textPlaceholder, - borderRadius: BorderRadius.circular(AppRadius.xl), - boxShadow: enabled - ? const [ - BoxShadow( - color: Color(0x4D0052FF), - blurRadius: 20, - offset: Offset(0, 8), - ), - ] - : null, - ), - alignment: Alignment.center, - child: _loading - ? const SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ) - : Row( - mainAxisSize: MainAxisSize.min, - children: const [ - Text( - '立即登录', - style: TextStyle( - fontSize: 17, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - SizedBox(width: 8), - Text( - '→', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w300, - color: Colors.white, - ), - ), - ], - ), + InputDecoration _fieldDecoration({ + required String hint, + required IconData icon, + Widget? suffix, + }) { + final border = OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + borderSide: BorderSide.none, + ); + return InputDecoration( + hintText: hint, + hintStyle: const TextStyle(fontSize: 15, color: AppColors.textPlaceholder), + prefixIcon: Icon(icon, size: 20, color: AppColors.textMuted), + prefixIconConstraints: const BoxConstraints(minWidth: 46, minHeight: 46), + suffixIcon: suffix, + filled: true, + fillColor: AppColors.surfaceContainerLow, + counterText: '', + contentPadding: const EdgeInsets.symmetric(vertical: 16), + border: border, + enabledBorder: border, + focusedBorder: border.copyWith( + borderSide: const BorderSide(color: AppColors.primary, width: 1.4), ), ); } - Widget _buildOtherLogin() { - return Padding( - padding: const EdgeInsets.only(bottom: 48), - child: Column( - children: [ - const Padding( - padding: EdgeInsets.only(bottom: 24), - child: Row( - children: [ - Expanded( - child: SizedBox( - height: 1, - child: ColoredBox(color: AppColors.surfaceContainerHighest), - ), - ), - Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: Text( - '微信快捷登录', - style: TextStyle(fontSize: 13, color: AppColors.textMuted), - ), - ), - Expanded( - child: SizedBox( - height: 1, - child: ColoredBox(color: AppColors.surfaceContainerHighest), - ), - ), - ], - ), - ), - GestureDetector( - onTap: () => showAppToast('微信登录暂未开放'), - behavior: HitTestBehavior.opaque, - child: Container( - width: 56, - height: 56, - decoration: const BoxDecoration( - color: Color(0xFF07C160), - shape: BoxShape.circle, - ), - alignment: Alignment.center, - child: const XIcon('weixin', size: 32, color: Colors.white), - ), - ), - ], + Widget _buildError() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: AppColors.errorContainer, + borderRadius: BorderRadius.circular(AppRadius.regular + 2), ), - ); - } - - Widget _buildAgreement() { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - GestureDetector( - onTap: () => setState(() => _agree = !_agree), - behavior: HitTestBehavior.opaque, - child: Container( - width: 20, - height: 20, - margin: const EdgeInsets.only(top: 1), - decoration: BoxDecoration( - color: _agree ? AppColors.primary : Colors.transparent, - border: Border.all( - color: _agree ? AppColors.primary : AppColors.textPlaceholder, - width: 2, - ), - borderRadius: BorderRadius.circular(6), - ), - alignment: Alignment.center, - child: _agree - ? const Text( - '✓', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Colors.white, - height: 1, - ), - ) - : null, - ), + const Icon( + Icons.error_outline_rounded, + size: 16, + color: AppColors.error, ), - const SizedBox(width: 12), + const SizedBox(width: 8), Expanded( - child: RichText( - text: TextSpan( - style: const TextStyle( - fontSize: 13, - height: 1.6, - color: AppColors.textMuted, - ), - children: [ - const TextSpan(text: '我已阅读并同意'), - _link('《VoltLink服务协议》'), - const TextSpan(text: '与'), - _link('《隐私政策》'), - const TextSpan(text: ',并授权 VoltLink 在登录时获取我的相关信息。'), - ], + child: Text( + _error, + style: const TextStyle( + fontSize: 13, + height: 1.4, + color: AppColors.onErrorContainer, ), ), ), @@ -484,39 +348,59 @@ class _LoginPageState extends ConsumerState { ); } + Widget _buildRegisterEntry() { + return Center( + child: GestureDetector( + onTap: () => context.push('/register'), + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Text.rich( + TextSpan( + style: const TextStyle(fontSize: 14, color: AppColors.textMuted), + children: const [ + TextSpan(text: '还没有账号?'), + TextSpan( + text: '立即注册', + style: TextStyle( + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + ), + ], + ), + ), + ), + ), + ); + } + + /// 极简版不再放协议勾选框:登录即视为同意,文案置底。 + Widget _buildAgreementHint() { + return Text.rich( + TextSpan( + style: const TextStyle( + fontSize: 12, + height: 1.6, + color: AppColors.textMuted, + ), + children: [ + const TextSpan(text: '登录即表示你已阅读并同意'), + _link('《服务协议》'), + const TextSpan(text: '与'), + _link('《隐私政策》'), + ], + ), + textAlign: TextAlign.center, + ); + } + TextSpan _link(String text) => TextSpan( text: text, style: const TextStyle(color: AppColors.primary), recognizer: TapGestureRecognizer() ..onTap = () => showAppToast('$text 暂未开放'), ); - - Widget _buildRegisterEntry() { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text( - '还没有账号?', - style: TextStyle(fontSize: 14, color: AppColors.textMuted), - ), - GestureDetector( - onTap: () => context.push('/register'), - behavior: HitTestBehavior.opaque, - child: const Padding( - padding: EdgeInsets.symmetric(vertical: 6, horizontal: 4), - child: Text( - '立即注册', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppColors.primary, - ), - ), - ), - ), - ], - ); - } } /// 调试版「服务端设置」弹窗:只让改 IP 与端口, @@ -618,7 +502,7 @@ class _ServerSettingsSheetState extends ConsumerState<_ServerSettingsSheet> { _buildField( label: '服务端 IP', controller: _host, - hintText: '如 192.168.3.29', + hintText: '如 192.168.21.184', keyboardType: TextInputType.url, ), const SizedBox(height: 16), diff --git a/lib/features/order/data/order_models.dart b/lib/features/order/data/order_models.dart index 96129d2..44b2183 100644 --- a/lib/features/order/data/order_models.dart +++ b/lib/features/order/data/order_models.dart @@ -66,6 +66,34 @@ class Order { /// 是否处于「现场勘察中(1)」,仅该状态可维护勘察明细。 bool get surveyEditable => source == 'survey' && status == 1; + /// 是否已完成:报修(2)、安装/维保(1)、勘察已归档(3)。 + bool get isCompleted => switch (source) { + 'repair' => status == 2, + 'install' || 'maintenance' => status == 1, + 'survey' => status == 3, + _ => false, + }; + + /// 当前状态文案,用于「进行中 / 已完成」列表的状态标签。 + String get statusText => switch (source) { + 'survey' => switch (status) { + 0 => '待指派', + 1 => '现场勘察中', + 2 => '待技术复核', + 3 => '已归档', + 5 => '已取消', + _ => '进行中', + }, + 'repair' => switch (status) { + 0 => '待处理', + 1 => '处理中', + 2 => '已完成', + _ => '进行中', + }, + 'install' || 'maintenance' => status == 1 ? '已完成' : '待上门', + _ => isCompleted ? '已完成' : '进行中', + }; + /// 来源中文名,用于收益明细副标题等场景。 String get sourceName => switch (source) { 'repair' => '家庭电路维修', diff --git a/lib/features/order/presentation/order_detail_page.dart b/lib/features/order/presentation/order_detail_page.dart index d2eca75..02cbf34 100644 --- a/lib/features/order/presentation/order_detail_page.dart +++ b/lib/features/order/presentation/order_detail_page.dart @@ -2,6 +2,7 @@ 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 'package:url_launcher/url_launcher.dart'; import '../../../core/network/api_response.dart'; import '../../../core/providers.dart'; @@ -33,9 +34,14 @@ class OrderDetailPage extends ConsumerWidget { final String source; final int id; + /// 地图占位暂时隐藏(代码保留,后续接入真实地图时改回 true)。 + static const _showMap = false; + @override Widget build(BuildContext context, WidgetRef ref) { final detail = ref.watch(orderDetailProvider((source: source, id: id))); + final order = detail.value; + final showBottomAction = order != null && !order.isCompleted; return Scaffold( body: XPageShell( @@ -70,15 +76,16 @@ class OrderDetailPage extends ConsumerWidget { ), }, ), - // 底部固定操作栏,对应源 `.bottom-action` - Positioned( - left: 0, - right: 0, - bottom: 0, - child: _BottomAction( - onStart: () => showAppToast('已开始出发', success: true), + // 已完成订单不再展示出发操作 + if (showBottomAction) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: _BottomAction( + onStart: () => showAppToast('已开始出发', success: true), + ), ), - ), ], ), ), @@ -87,11 +94,11 @@ class OrderDetailPage extends ConsumerWidget { Widget _buildContent(BuildContext context, Order order) { return SingleChildScrollView( - padding: const EdgeInsets.only(bottom: 120), + padding: EdgeInsets.only(bottom: order.isCompleted ? 24 : 120), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _MapSection(address: order.address), + if (_showMap) _MapSection(address: order.address), Padding( padding: const EdgeInsets.all(16), child: Column( @@ -392,7 +399,7 @@ class _CustomerCard extends StatelessWidget { ), const SizedBox(width: 12), GestureDetector( - onTap: () => showAppToast('拨打电话'), + onTap: () => _callPhone(order.customerPhone), behavior: HitTestBehavior.opaque, child: Container( width: 40, @@ -744,6 +751,21 @@ class _DotPatternPainter extends CustomPainter { bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } +/// 调起系统拨号盘。 +Future _callPhone(String raw) async { + final phone = raw.replaceAll(RegExp(r'[^0-9+]'), ''); + if (phone.isEmpty) { + showAppToast('该客户暂无联系方式'); + return; + } + try { + final ok = await launchUrl(Uri(scheme: 'tel', path: phone)); + if (!ok) showAppToast('无法调起拨号'); + } catch (_) { + showAppToast('无法调起拨号'); + } +} + /// 打开单张照片大图预览。 void _previewPhoto(BuildContext context, String url) { showDialog( diff --git a/lib/features/order/presentation/order_tab.dart b/lib/features/order/presentation/order_tab.dart index 48d5e13..ddfd7c6 100644 --- a/lib/features/order/presentation/order_tab.dart +++ b/lib/features/order/presentation/order_tab.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../../../core/network/api_response.dart'; import '../../../core/providers.dart'; @@ -20,10 +21,10 @@ import '../data/order_models.dart'; String _money(num v) => v == v.roundToDouble() ? v.toStringAsFixed(0) : formatMoney(v); -/// 订单 Tab(待接订单),对应源 `pages/order/index.vue`。 +/// 订单 Tab:顶部三档切换「进行中 / 待接单 / 已完成」。 /// -/// 顶栏与地图区固定,只有下方订单列表滚动;沿用源 `.orders-section` -/// 24px 顶部圆角「上移压住地图」的观感(把地图高度相应减 24,视觉等价)。 +/// 「进行中」「已完成」来自 `myOrders`,待接单来自订单池;地图区域暂时隐藏, +/// `_showMap` 置 false 保留占位实现,后续接入真实地图时改回 true 即可。 class OrderTab extends ConsumerStatefulWidget { const OrderTab({super.key}); @@ -32,10 +33,34 @@ class OrderTab extends ConsumerStatefulWidget { } class _OrderTabState extends ConsumerState { + /// 地图占位暂时隐藏(代码保留)。 + static const _showMap = false; + + static const _segments = ['进行中', '待接单', '已完成']; + + /// 待接单的类型筛选,null 表示全部。 + static const _typeFilters = <(String?, String)>[ + (null, '全部'), + ('repair', '报修'), + ('install', '安装'), + ('maintenance', '维保'), + ('survey', '勘察'), + ]; + + /// 0 进行中 / 1 待接单 / 2 已完成。 + int _segment = 0; + String? _typeFilter; + /// 本会话内已接单的订单 id(接单后列表会刷新移除,这里用于渲染「已接单」态)。 final _grabbed = {}; String? _grabbing; + /// 只订阅当前分段的列表,切换时旧的 autoDispose 实例自动释放。 + AsyncValue> _watchActive() => switch (_segment) { + 1 => ref.watch(orderPoolProvider), + _ => ref.watch(myOrdersProvider(_segment == 0 ? 'pending' : 'completed')), + }; + Future _handleGrab(Order order) async { if (_grabbing != null || _grabbed.contains(order.id)) return; setState(() => _grabbing = order.id); @@ -45,9 +70,14 @@ class _OrderTabState extends ConsumerState { .grab(order.source, order.intId); if (!mounted) return; if (res.isOk) { - setState(() => _grabbed.add(order.id)); + setState(() { + _grabbed.add(order.id); + // 接单成功后订单转入「进行中」,顺手切到该分段,避免用户还要自己找 + _segment = 0; + }); showAppToast('接单成功', success: true); ref.invalidate(orderPoolProvider); + ref.invalidate(myOrdersProvider); } else { showAppToast(res.message.isEmpty ? '接单失败' : res.message); } @@ -58,9 +88,41 @@ class _OrderTabState extends ConsumerState { } } + Future _refresh() async { + ref.invalidate(orderPoolProvider); + ref.invalidate(myOrdersProvider); + try { + switch (_segment) { + case 1: + await ref.read(orderPoolProvider.future); + case 0: + await ref.read(myOrdersProvider('pending').future); + default: + await ref.read(myOrdersProvider('completed').future); + } + } catch (_) { + // 失败由列表的 hasError 分支展示,这里只负责结束下拉动画 + } + } + + /// 联系客户:调起系统拨号盘。 + Future _callCustomer(Order order) async { + final phone = order.customerPhone.replaceAll(RegExp(r'[^0-9+]'), ''); + if (phone.isEmpty) { + showAppToast('该客户暂无联系方式'); + return; + } + try { + final ok = await launchUrl(Uri(scheme: 'tel', path: phone)); + if (!ok && mounted) showAppToast('无法调起拨号'); + } catch (_) { + if (mounted) showAppToast('无法调起拨号'); + } + } + @override Widget build(BuildContext context) { - final pool = ref.watch(orderPoolProvider); + final active = _watchActive(); final unread = ref.watch(noticeUnreadCountProvider); return XPageShell( @@ -72,93 +134,176 @@ class _OrderTabState extends ConsumerState { trailingBadge: unread, onTrailingTap: () => context.push('/notice'), ), - child: RefreshIndicator( - color: AppColors.primary, - onRefresh: () async { - ref.invalidate(orderPoolProvider); - await ref.read(orderPoolProvider.future); - }, - child: ListView( - padding: EdgeInsets.zero, - physics: const AlwaysScrollableScrollPhysics(), - children: [ - // 源地图高 320,被下方 24px 圆角区块遮住底部 → 这里直接取 296 - SizedBox(height: 296, child: const _MapPlaceholder()), - Container( - decoration: const BoxDecoration( - color: AppColors.bgPage, - borderRadius: BorderRadius.vertical( - top: Radius.circular(AppRadius.xxl), - ), - ), - padding: const EdgeInsets.fromLTRB(16, 24, 16, 120), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + child: Column( + children: [ + _buildSegments(), + Expanded( + child: RefreshIndicator( + color: AppColors.primary, + onRefresh: _refresh, + child: ListView( + padding: EdgeInsets.zero, + physics: const AlwaysScrollableScrollPhysics(), children: [ - _buildSectionHeader(pool), - const SizedBox(height: 16), - ..._buildBody(pool), + // 源地图高 320,被下方 24px 圆角区块遮住底部 → 这里直接取 296 + if (_showMap) + const SizedBox(height: 296, child: _MapPlaceholder()), + Container( + decoration: BoxDecoration( + color: AppColors.bgPage, + borderRadius: _showMap + ? const BorderRadius.vertical( + top: Radius.circular(AppRadius.xxl), + ) + : BorderRadius.zero, + ), + padding: const EdgeInsets.fromLTRB(16, 20, 16, 120), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildSectionHeader(active), + if (_segment == 1) ...[ + const SizedBox(height: 12), + _buildTypeFilter(), + ], + const SizedBox(height: 16), + ..._buildBody(active), + ], + ), + ), ], ), ), + ), + ], + ), + ); + } + + /// 顶部三档分段控件。 + Widget _buildSegments() { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: AppColors.surfaceContainer, + borderRadius: BorderRadius.circular(AppRadius.full), + ), + child: Row( + children: [ + for (var i = 0; i < _segments.length; i++) + Expanded( + child: GestureDetector( + onTap: () => setState(() => _segment = i), + behavior: HitTestBehavior.opaque, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + height: 36, + alignment: Alignment.center, + decoration: BoxDecoration( + color: _segment == i + ? AppColors.surfaceContainerLowest + : Colors.transparent, + borderRadius: BorderRadius.circular(AppRadius.full), + boxShadow: _segment == i + ? const [ + BoxShadow( + color: Color(0x14000000), + blurRadius: 6, + offset: Offset(0, 2), + ), + ] + : null, + ), + child: Text( + _segments[i], + style: TextStyle( + fontSize: 14, + fontWeight: _segment == i + ? FontWeight.w700 + : FontWeight.w500, + color: _segment == i + ? AppColors.onSurface + : AppColors.onSurfaceVariant, + ), + ), + ), + ), + ), ], ), ), ); } - Widget _buildSectionHeader(AsyncValue> pool) { - final count = pool.value?.length; - return Row( + Widget _buildSectionHeader(AsyncValue> active) { + final count = active.value?.length; + final (title, loadingText, unit) = switch (_segment) { + 0 => ('进行中派单', '正在获取进行中的派单…', ' 个进行中'), + 1 => ('待接订单', '正在获取附近订单…', ' 个待接订单'), + _ => ('已完成订单', '正在获取已完成订单…', ' 个已完成'), + }; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '待接订单', - style: AppTypography.navLargeTitle.copyWith( - color: AppColors.onSurface, - ), - ), - const SizedBox(height: 4), - Text( - count == null ? '正在获取附近订单…' : '附近有 $count 个订单', - style: AppTypography.bodyMd.copyWith( - color: AppColors.onSurfaceVariant, - ), - ), - ], + Text( + title, + style: AppTypography.navLargeTitle.copyWith( + color: AppColors.onSurface, ), ), - GestureDetector( - onTap: () => showAppToast('筛选功能'), - behavior: HitTestBehavior.opaque, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - const Text( - '筛选', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w500, - color: AppColors.primary, - ), - ), - const SizedBox(width: 6), - const XIcon('filter', size: 18, color: AppColors.primary), - ], - ), + const SizedBox(height: 4), + Text( + count == null ? loadingText : '共 $count$unit', + style: AppTypography.bodyMd.copyWith( + color: AppColors.onSurfaceVariant, ), ), ], ); } - List _buildBody(AsyncValue> pool) { - if (pool.isLoading && !pool.hasValue) { + /// 待接单的类型筛选。 + Widget _buildTypeFilter() { + return SizedBox( + height: 34, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: _typeFilters.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (context, i) { + final (value, label) = _typeFilters[i]; + final selected = _typeFilter == value; + return GestureDetector( + onTap: () => setState(() => _typeFilter = value), + behavior: HitTestBehavior.opaque, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14), + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected + ? AppColors.primary + : AppColors.surfaceContainerLow, + borderRadius: BorderRadius.circular(AppRadius.full), + ), + child: Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: selected ? Colors.white : AppColors.onSurfaceVariant, + ), + ), + ), + ); + }, + ), + ); + } + + List _buildBody(AsyncValue> active) { + if (active.isLoading && !active.hasValue) { return const [ Padding( padding: EdgeInsets.symmetric(vertical: 60), @@ -175,31 +320,62 @@ class _OrderTabState extends ConsumerState { ), ]; } - if (pool.hasError && !pool.hasValue) { + if (active.hasError && !active.hasValue) { return [ - XPlaceholder(text: '订单加载失败\n${pool.error}'), + XPlaceholder(text: '订单加载失败\n${active.error}'), Center( child: TextButton( - onPressed: () => ref.invalidate(orderPoolProvider), + onPressed: () { + ref.invalidate(orderPoolProvider); + ref.invalidate(myOrdersProvider); + }, child: const Text('点击重试'), ), ), ]; } - final orders = pool.value ?? const []; + final orders = active.value ?? const []; + if (_segment == 1) { + final filtered = _typeFilter == null + ? orders + : orders.where((o) => o.source == _typeFilter).toList(); + if (filtered.isEmpty) { + return [ + XPlaceholder( + text: _typeFilter == null ? '附近暂无待接订单' : '该类型暂无待接订单', + ), + ]; + } + return [ + for (final order in filtered) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: _OrderCard( + order: order, + grabbing: _grabbing == order.id, + grabbed: _grabbed.contains(order.id), + onGrab: () => _handleGrab(order), + onTap: () => context.push('/order/${order.source}/${order.intId}'), + ), + ), + ]; + } + if (orders.isEmpty) { - return const [XPlaceholder(text: '附近暂无待接订单')]; + return [ + XPlaceholder( + text: _segment == 0 ? '暂无进行中的派单' : '暂无已完成订单', + ), + ]; } return [ for (final order in orders) Padding( padding: const EdgeInsets.only(bottom: 16), - child: _OrderCard( + child: _MyOrderCard( order: order, - grabbing: _grabbing == order.id, - grabbed: _grabbed.contains(order.id), - onGrab: () => _handleGrab(order), + onCall: () => _callCustomer(order), onTap: () => context.push('/order/${order.source}/${order.intId}'), ), ), @@ -410,6 +586,206 @@ class _OrderCard extends StatelessWidget { ); } +/// 进行中 / 已完成订单卡片:状态标签 + 联系客户 + 查看详情。 +class _MyOrderCard extends StatelessWidget { + const _MyOrderCard({ + required this.order, + required this.onCall, + required this.onTap, + }); + + final Order order; + final VoidCallback onCall; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: AppColors.surfaceContainerLowest, + borderRadius: BorderRadius.circular(AppRadius.xl), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppRadius.xl), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: AppColors.borderSoft), + borderRadius: BorderRadius.circular(AppRadius.xl), + boxShadow: AppShadows.orderCard, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Row( + children: [ + Text( + '#${order.displayNo}', + style: AppTypography.labelCaps.copyWith( + color: AppColors.textMuted, + ), + ), + const SizedBox(width: 12), + XTypeChip(type: order.type, text: order.listTypeText), + ], + ), + ), + const SizedBox(width: 8), + _statusChip(), + ], + ), + const SizedBox(height: 12), + Text( + order.title, + style: AppTypography.headlineMd.copyWith( + color: AppColors.onSurface, + ), + ), + const SizedBox(height: 10), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(top: 2), + child: XIcon( + 'location', + size: 16, + color: AppColors.onSurfaceVariant, + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + order.address.isEmpty ? '地址待确认' : order.address, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: AppTypography.bodyMd.copyWith( + color: AppColors.onSurfaceVariant, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + const XIcon( + 'clock', + size: 16, + color: AppColors.onSurfaceVariant, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + order.schedule, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTypography.bodyMd.copyWith( + color: AppColors.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: 12), + Text( + '¥${_money(order.amount)}', + style: AppTypography.bodyLg.copyWith( + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _actionButton( + label: '联系客户', + icon: 'call', + filled: false, + onTap: onCall, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _actionButton( + label: '查看详情', + icon: 'forward', + filled: true, + onTap: onTap, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _statusChip() { + final completed = order.isCompleted; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: completed ? AppColors.surfaceContainer : AppColors.accentSubtle, + borderRadius: BorderRadius.circular(AppRadius.full), + ), + child: Text( + order.statusText, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: completed ? AppColors.onSurfaceVariant : AppColors.primary, + ), + ), + ); + } + + Widget _actionButton({ + required String label, + required String icon, + required bool filled, + required VoidCallback onTap, + }) { + final foreground = filled ? Colors.white : AppColors.onSurface; + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Container( + height: 46, + alignment: Alignment.center, + decoration: BoxDecoration( + color: filled ? AppColors.primary : AppColors.surfaceContainerLow, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: filled + ? null + : Border.all(color: const Color(0x80C3C5D9)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + XIcon(icon, size: 18, color: foreground), + const SizedBox(width: 8), + Text( + label, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: foreground, + ), + ), + ], + ), + ), + ); + } +} + /// 接单中的旋转刷新图标。 class _SpinningRefresh extends StatefulWidget { const _SpinningRefresh(); diff --git a/pubspec.lock b/pubspec.lock index fa9f166..f858dfb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -738,6 +738,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572" + url: "https://pub.dev" + source: hosted + version: "6.3.29" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" uuid: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index f3f7a01..f4e4189 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,6 +20,7 @@ dependencies: shared_preferences: ^2.5.5 flutter_svg: ^2.3.0 image_picker: ^1.2.3 + url_launcher: ^6.3.2 dev_dependencies: flutter_test: