Mztj67_#M}W?l>kYSliK<%xAp;0j{!}J0!o7b
zE>q9${Lb$D&h7k=+4=!ek^n+`0zq>LL1O?lVyea53S5x`Nqqo2YyeuIrQrJj9XjOp
z{;T5qbj3}&1vg1VK~#9!?b~^C5-}JC@Pyrv-6dSEqJqT}#j9#dJ@GzT@B8}x
zU&J@bBI>f6w6en+CeI)3^kC*U?}X%OD8$Fd$H&LV$H&LV$H&LV#|K5~mLYf|VqzOc
zkc7qL~0sOYuM{tG`rYEDV{DWY`Z8&)kW*hc2VkBuY+^Yx&92j&StN}Wp=LD
zxoGxXw6f&8sB^u})h@b@z0RBeD`K7RMR9deyL(ZJu#39Z>rT)^>v}Khq8U-IbIvT>
z?4pV9qGj=2)TNH3d)=De<+^w;>S7m_eFKTvzeaBeir45xY!^m!FmxnljbSS_3o=g(
z->^wC9%qkR{kbGnW8MfFew_o9h3(r55Is`L$8KI@d+*%{=Nx+FXJ98L0PjFIu;rGnnfY
zn1R5Qnp<{Jq0M1vX=X&F8gtLmcWv$1*M@4ZfF^9``()#hGTeKeP`1!iED
ztNE(TN}M5}3Bbc*d=FIv`DNv&@|C6yYj{sSqUj5oo$#*0$7pu|Dd2TLI>t5%I
zIa4Dvr(iayb+5x=j*Vum9&irk)xV1`t509lnPO0%skL8_1c#Xbamh(2@f?4yUI
zhhuT5<#8RJhGz4%b$`PJwKPAudsm|at?u;*hGgnA
zU1;9gnxVBC)wA(BsB`AW54N{|qmikJR*%x0c`{LGsSfa|NK61pYH(r-UQ4_JXd!Rsz)=k
zL{GMc5{h138)fF5CzHEDM>+FqY)$pdN3}Ml+riTgJOLN0F*Vh?{9ESR{SVVg>*>=#
zix;VJHPtvFFCRY$Ks*F;VX~%*r9F)W`PmPE9F!(&s#x07n2<}?S{(ygpXgX-&B&OM
zONY&BRQ(#%0%jeQs?oJ4P!p*R98>qCy5p8w>_gpuh39NcOlp)(wOoz0sY-Qz55eB~
z7OC-fKBaD1sE3$l-6QgBJO!n?QOTza`!S_YK
z_v-lm^7{VO^8Q@M_^8F)09Ki6%=s?2_5eupee(w1FB%aqSweusQ-T+CH0Xt{`
zFjMvW{@C&TB)k25()nh~_yJ9coBRL(0oO@HK~z}7?bm5j;y@69;bvlHb2tf!$ReA~x{22wTq550
z?f?Hnw(;m3ip30;QzdV~7pi!wyMYhDtXW#cO7T>|f=bdFhu+F!zMZ2UFj;GUKX7tI
z;hv3{q~!*pMj75WP_c}>6)IWvg5_yyg<9Op()eD1hWC19M@?_9_MHec{Z8n3FaF{8
z;u`Mw0ly(uE>*CgQYv{be6ab2LWhlaH1^iLIM{olnag$78^Fd}%dR7;JECQ+hmk|o
z!u2&!3MqPfP5ChDSkFSH8F2WVOEf0(E_M(JL17G}Y+fg0_IuW%WQ
zG(mG&u?|->YSdk0;8rc{yw2@2Z&GA}z{Wb91Ooz9VhA{b2DYE7RmG
zjL}?eq#iX%3#k;JWMx_{^2nNax`xPhByFiDX+a7uTGU|otOvIAUy|dEKkXOm-`aWS
z27pUzD{a)Ct<6p{{3)+lq@i`t@%>-wT4r?*S}k)58e09WZYP0{{R3FC5Sl00039P)t-s|Ns9~
z#rP?<_5oL$Q^olD{r_0T`27C={r>*`|Nj71npVa5OTzc(_WfbW_({R{p56NV{r*M2
z_xt?)2V0#0NsfV0u>{42ctGP(8vQj-Btk1n|O0ZD=YLwd&R{Ko41Gr9H=
zY@z@@bOAMB5Ltl$E>bJJ{>JP30ZxkmI%?eW{k`b?Wy<&gOo;dS`~CR$Vwb@XWtR|N
zi~t=w02?-0&j0TD{>bb6sNwsK*!p?V`RMQUl(*DVjk-9Cx+-z1KXab|Ka2oXhX5f%
z`$|e!000AhNklrxs)5QTeTVRiEmz~MKK1WAjCw(c-JK6eox;2O)?`?
zTG`AHia671e^vgmp!llKp|=5sVHk#C7=~epA~VAf-~%aPC=%Qw01h8mnSZ|p?hz91
z7p83F3%LVu9;S$tSI$C^%^yud1dfTM_6p2|+5Ejp$bd`GDvbR|xit>i!ZD&F>@CJrPmu*UjD&?DfZs=$@e3FQA(vNiU+$A*%a}
z?`XcG2jDxJ_ZQ#Md`H{4Lpf6QBDp81_KWZ6Tk#yCy1)32zO#3<7>b`eT7UyYH1eGz
z;O(rH$=QR*L%%ZcBpc=eGua?N55nD^K(8<#gl2+pN_j~b2MHs4#mcLmv%DkspS-3<
zpI1F=^9siI0s-;IN_IrA;5xm~3?3!StX}pUv0vkxMaqm+zxrg7X7(I&*N~&dEd0kD
z-FRV|g=|QuUsuh>-xCI}vD2imzYIOIdcCVV=$Bz@*u0+Bs<|L^)32nN*=wu3n%Ynw
z@1|eLG>!8ruU1pFXUfb`j>(=Gy~?Rn4QJ-c3%3T|(Frd!bI`9u&zAnyFYTqlG#&J7
zAkD(jpw|oZLNiA>;>hgp1KX7-wxC~31II47gc
zHcehD6Uxlf%+M^^uN5Wc*G%^;>D5qT{>=uxUhX%WJu^Z*(_Wq9y}npFO{Hhb>s6<9
zNi0pHXWFaVZnb)1+RS&F)xOv6&aeILcI)`k#0YE+?e)5r7J#c`3Z7x!LpTc01dx
zrdC3{Z;joZ^KN&))zB_i)I9fWedoN>Zl-6_Iz+^G&*ak2jpF07*qoM6N<$f;w%0(f|Me
literal 0
HcmV?d00001
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..0467bf12aa4d28f374bb26596605a46dcbb3e7c8
GIT binary patch
literal 1418
zcmV;51$Fv~P)q
zKfU)WzW*n(@|xWGCA9ScMt*e9`2kdxPQ&&>|-UCa7_51w+
zLUsW@ZzZSW0y$)Hp~e9%PvP|a03ks1`~K?q{u;6NC8*{AOqIUq{CL&;p56Lf$oQGq
z^={4hPQv)y=I|4n+?>7Fim=dxt1
z2H+Dm+1+fh+IF>G0SjJMkQQre1x4|G*Z==(Ot&kCnUrL4I(rf(ucITwmuHf^hXiJT
zkdTm&kdTm&kdTm&kdP`esgWG0BcWCVkVZ&2dUwN`cgM8QJb`Z7Z~e<&Yj2(}>Tmf`
zm1{eLgw!b{bXkjWbF%dTkTZEJWyWOb##Lfw4EK2}<0d6%>AGS{po>WCOy&f$Tay_>
z?NBlkpo@s-O;0V%Y_Xa-G#_O08q5LR*~F%&)}{}r&L%Sbs8AS4t7Y0NEx*{soY=0MZExqA5XHQkqi#4gW3
zqODM^iyZl;dvf)-bOXtOru(s)Uc7~BFx{w-FK;2{`VA?(g&@3z&bfLFyctOH!cVsF
z7IL=fo-qBndRUm;kAdXR4e6>k-z|21AaN%ubeVrHl*<|s&Ax@W-t?LR(P-24A5=>a
z*R9#QvjzF8n%@1Nw@?CG@6(%>+-0ASK~jEmCV|&a*7-GKT72W<(TbSjf)&Eme6nGE
z>Gkj4Sq&2e+-G%|+NM8OOm5zVl9{Z8Dd8A5z3y8mZ=4Bv4%>as_{9cN#bm~;h>62(
zdqY93Zy}v&c4n($Vv!UybR8ocs7#zbfX1IY-*w~)p}XyZ-SFC~4w>BvMVr`dFbelV{lLL0bx7@*ZZdebr3`sP;?
zVImji)kG)(6Juv0lz@q`F!k1FE;CQ(D0iG$wchPbKZQELlsZ#~rt8#90Y_Xh&3U-<
z{s<&cCV_1`^TD^ia9!*mQDq&
zn2{r`j};V|uV%_wsP!zB?m%;FeaRe+X47K0e+KE!8C{gAWF8)lCd1u1%~|M!XNRvw
zvtqy3iz0WSpWdhn6$hP8PaRBmp)q`#PCA`Vd#Tc$@f1tAcM>f_I@bC)hkI9|o(Iqv
zo}Piadq!j76}004RBio<`)70k^`K1NK)q>w?p^C6J2ZC!+UppiK6&y3Kmbv&O!oYF
z34$0Z;QO!JOY#!`qyGH<3Pd}Pt@q*A0V=3SVtWKRR8d8Z&@)3qLPA19LPA19LPEUC
YUoZo%k(ykuW&i*H07*qoM6N<$f+CH{y8r+H
literal 0
HcmV?d00001
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
new file mode 100644
index 0000000..0bedcf2
--- /dev/null
+++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage.png",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage@3x.png",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
new file mode 100644
index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838
GIT binary patch
literal 68
zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J
Q1PU{Fy85}Sb4q9e0B4a5jsO4v
literal 0
HcmV?d00001
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838
GIT binary patch
literal 68
zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J
Q1PU{Fy85}Sb4q9e0B4a5jsO4v
literal 0
HcmV?d00001
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
new file mode 100644
index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838
GIT binary patch
literal 68
zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J
Q1PU{Fy85}Sb4q9e0B4a5jsO4v
literal 0
HcmV?d00001
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
new file mode 100644
index 0000000..89c2725
--- /dev/null
+++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
@@ -0,0 +1,5 @@
+# Launch Screen Assets
+
+You can customize the launch screen with your own desired assets by replacing the image files in this directory.
+
+You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
\ No newline at end of file
diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard
new file mode 100644
index 0000000..f2e259c
--- /dev/null
+++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard
new file mode 100644
index 0000000..f3c2851
--- /dev/null
+++ b/ios/Runner/Base.lproj/Main.storyboard
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
new file mode 100644
index 0000000..52ab127
--- /dev/null
+++ b/ios/Runner/Info.plist
@@ -0,0 +1,70 @@
+
+
+
+
+ CADisableMinimumFrameDurationOnPhone
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Smart Grid Iot App Flutter
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ smart_grid_iot_app_flutter
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(FLUTTER_BUILD_NAME)
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ $(FLUTTER_BUILD_NUMBER)
+ LSRequiresIPhoneOS
+
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+ UISceneConfigurations
+
+ UIWindowSceneSessionRoleApplication
+
+
+ UISceneClassName
+ UIWindowScene
+ UISceneConfigurationName
+ flutter
+ UISceneDelegateClassName
+ $(PRODUCT_MODULE_NAME).SceneDelegate
+ UISceneStoryboardFile
+ Main
+
+
+
+
+ UIApplicationSupportsIndirectInputEvents
+
+ UILaunchStoryboardName
+ LaunchScreen
+ UIMainStoryboardFile
+ Main
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+
+
diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h
new file mode 100644
index 0000000..308a2a5
--- /dev/null
+++ b/ios/Runner/Runner-Bridging-Header.h
@@ -0,0 +1 @@
+#import "GeneratedPluginRegistrant.h"
diff --git a/ios/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift
new file mode 100644
index 0000000..b9ce8ea
--- /dev/null
+++ b/ios/Runner/SceneDelegate.swift
@@ -0,0 +1,6 @@
+import Flutter
+import UIKit
+
+class SceneDelegate: FlutterSceneDelegate {
+
+}
diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift
new file mode 100644
index 0000000..86a7c3b
--- /dev/null
+++ b/ios/RunnerTests/RunnerTests.swift
@@ -0,0 +1,12 @@
+import Flutter
+import UIKit
+import XCTest
+
+class RunnerTests: XCTestCase {
+
+ func testExample() {
+ // If you add code to the Runner application, consider adding tests here.
+ // See https://developer.apple.com/documentation/xctest for more information about using XCTest.
+ }
+
+}
diff --git a/lib/app/app.dart b/lib/app/app.dart
new file mode 100644
index 0000000..06723f0
--- /dev/null
+++ b/lib/app/app.dart
@@ -0,0 +1,37 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_localizations/flutter_localizations.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../core/app_keys.dart';
+import '../core/theme/app_theme.dart';
+import '../core/theme/app_typography.dart';
+import 'router.dart';
+
+class VoltLinkApp extends ConsumerWidget {
+ const VoltLinkApp({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final router = ref.watch(routerProvider);
+
+ return MaterialApp.router(
+ title: 'VoltLink',
+ debugShowCheckedModeBanner: false,
+ theme: AppTheme.build(),
+ routerConfig: router,
+ scaffoldMessengerKey: AppKeys.scaffoldMessenger,
+ locale: const Locale('zh', 'CN'),
+ supportedLocales: const [Locale('zh', 'CN'), Locale('en', 'US')],
+ localizationsDelegates: const [
+ GlobalMaterialLocalizations.delegate,
+ GlobalWidgetsLocalizations.delegate,
+ GlobalCupertinoLocalizations.delegate,
+ ],
+ // 对应源 `page` 上的全局字体设置。
+ builder: (context, child) => DefaultTextStyle(
+ style: appDefaultTextStyle,
+ child: child ?? const SizedBox.shrink(),
+ ),
+ );
+ }
+}
diff --git a/lib/app/router.dart b/lib/app/router.dart
new file mode 100644
index 0000000..d034b84
--- /dev/null
+++ b/lib/app/router.dart
@@ -0,0 +1,63 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+
+import '../core/app_keys.dart';
+import '../features/auth/application/session_controller.dart';
+import '../features/auth/presentation/login_page.dart';
+import '../features/auth/presentation/register_page.dart';
+import '../features/notice/presentation/notice_center_page.dart';
+import '../features/order/presentation/order_detail_page.dart';
+import 'tabs_page.dart';
+
+/// 会话语义变化时驱动 go_router 重新执行 `redirect`。
+class _SessionRefresh extends ChangeNotifier {
+ void bump() => notifyListeners();
+}
+
+/// 路由表,对应源 `pages.json` 的 5 个页面。
+///
+/// 除登录/注册外,其余页面统一使用主题里的 `CupertinoPageTransitionsBuilder`
+/// (新页面从右侧滑入),与源 `PageTransition.vue` 的表现一致。
+final routerProvider = Provider((ref) {
+ final refresh = _SessionRefresh();
+ ref.listen(sessionProvider, (_, _) => refresh.bump());
+ ref.onDispose(refresh.dispose);
+
+ return GoRouter(
+ navigatorKey: AppKeys.rootNavigator,
+ initialLocation: '/login',
+ refreshListenable: refresh,
+ redirect: (context, state) {
+ final loggedIn = ref.read(sessionProvider).isLoggedIn;
+ final atAuth =
+ state.matchedLocation == '/login' || state.matchedLocation == '/register';
+ if (!loggedIn) return atAuth ? null : '/login';
+ if (atAuth) return '/tabs';
+ return null;
+ },
+ routes: [
+ GoRoute(path: '/login', builder: (context, state) => const LoginPage()),
+ GoRoute(
+ path: '/register',
+ builder: (context, state) => const RegisterPage(),
+ ),
+ GoRoute(path: '/tabs', builder: (context, state) => const TabsPage()),
+
+ // 消息中心:顶栏铃铛入口
+ GoRoute(
+ path: '/notice',
+ builder: (context, state) => const NoticeCenterPage(),
+ ),
+
+ // 订单详情:source 为 repair|install|maintenance|survey
+ GoRoute(
+ path: '/order/:source/:id',
+ builder: (context, state) => OrderDetailPage(
+ source: state.pathParameters['source']!,
+ id: int.parse(state.pathParameters['id']!),
+ ),
+ ),
+ ],
+ );
+});
diff --git a/lib/app/tab_index.dart b/lib/app/tab_index.dart
new file mode 100644
index 0000000..948438a
--- /dev/null
+++ b/lib/app/tab_index.dart
@@ -0,0 +1,30 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+/// 底部导航的三个 Tab,对应 `components/CustomTabBar.vue` 的 3 个入口。
+enum AppTab { order, earnings, profile }
+
+extension AppTabKey on AppTab {
+ /// 与 `XBottomNav.items` 中的 key 一致。
+ String get key => switch (this) {
+ AppTab.order => 'order',
+ AppTab.earnings => 'earnings',
+ AppTab.profile => 'profile',
+ };
+}
+
+/// 当前 Tab。源里每个页面各自挂 `CustomTabBar` 并用 `redirectTo` 切换,
+/// Flutter 端改为单容器 + [IndexedStack],避免页面重建、保留各自滚动位置。
+class TabIndexController extends Notifier {
+ @override
+ AppTab build() => AppTab.order;
+
+ void select(AppTab tab) => state = tab;
+
+ void selectByKey(String key) {
+ state = AppTab.values.firstWhere((t) => t.key == key, orElse: () => state);
+ }
+}
+
+final tabIndexProvider = NotifierProvider(
+ TabIndexController.new,
+);
diff --git a/lib/app/tabs_page.dart b/lib/app/tabs_page.dart
new file mode 100644
index 0000000..4a9bc0f
--- /dev/null
+++ b/lib/app/tabs_page.dart
@@ -0,0 +1,44 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../core/theme/app_colors.dart';
+import '../features/earnings/presentation/earnings_tab.dart';
+import '../features/order/presentation/order_tab.dart';
+import '../features/profile/presentation/profile_tab.dart';
+import '../shared/widgets/x_bottom_nav.dart';
+import 'tab_index.dart';
+
+/// 3 个 Tab 的常驻容器。源项目每个页面各自挂 `CustomTabBar`,
+/// 这里统一收口到容器层,用 [IndexedStack] 保留各页内部状态。
+class TabsPage extends ConsumerWidget {
+ const TabsPage({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final current = ref.watch(tabIndexProvider);
+
+ return Scaffold(
+ backgroundColor: AppColors.bgPage,
+ body: Stack(
+ children: [
+ Positioned.fill(
+ child: IndexedStack(
+ index: current.index,
+ children: const [OrderTab(), EarningsTab(), ProfileTab()],
+ ),
+ ),
+ Positioned(
+ left: 0,
+ right: 0,
+ bottom: 0,
+ child: XBottomNav(
+ current: current.key,
+ onChanged: (key) =>
+ ref.read(tabIndexProvider.notifier).selectByKey(key),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/core/app_keys.dart b/lib/core/app_keys.dart
new file mode 100644
index 0000000..b7c62b4
--- /dev/null
+++ b/lib/core/app_keys.dart
@@ -0,0 +1,7 @@
+import 'package:flutter/material.dart';
+
+/// 全局 Key:供非 Widget 层(拦截器、工具函数)访问 Navigator / ScaffoldMessenger。
+abstract final class AppKeys {
+ static final rootNavigator = GlobalKey();
+ static final scaffoldMessenger = GlobalKey();
+}
diff --git a/lib/core/env.dart b/lib/core/env.dart
new file mode 100644
index 0000000..1cfc91c
--- /dev/null
+++ b/lib/core/env.dart
@@ -0,0 +1,35 @@
+/// 运行期配置。
+///
+/// 与 uni-app 版 `utils/request.ts` 的地址策略一致:App 端直连后端。
+/// 真机联调时用 `--dart-define=API_BASE_URL=http://<局域网IP>:7001/api` 覆盖默认值。
+///
+/// 登录页「服务端设置」(仅调试版可见)保存的 IP+端口优先级更高,
+/// 解析顺序见 [resolveBaseUrl]。
+abstract final class Env {
+ /// 编译期默认地址,作为兜底方案。
+ static const String apiBaseUrl = String.fromEnvironment(
+ 'API_BASE_URL',
+ defaultValue: 'http://192.168.3.29:7001/api',
+ );
+
+ static final Uri _default = Uri.parse(apiBaseUrl);
+
+ /// 默认主机,取自 [apiBaseUrl]。
+ static String get defaultHost => _default.host;
+
+ /// 默认端口,取自 [apiBaseUrl],未显式指定时回落 7007。
+ static int get defaultPort => _default.hasPort ? _default.port : 7001;
+
+ /// 由 IP + 端口拼接 Base URL;协议与路径沿用 [apiBaseUrl],
+ /// 这样 `--dart-define` 传入自定义路径时也能保持一致。
+ static String buildBaseUrl(String host, String port) {
+ final h = host.trim();
+ final p = port.trim();
+ final resolvedPort = p.isEmpty ? '$defaultPort' : p;
+ return '${_default.scheme}://$h:$resolvedPort${_default.path}';
+ }
+
+ /// 当前生效的 Base URL:登录页保存值优先,其次编译期默认值。
+ static String resolveBaseUrl({String host = '', String port = ''}) =>
+ host.trim().isEmpty ? apiBaseUrl : buildBaseUrl(host, port);
+}
diff --git a/lib/core/network/api_client.dart b/lib/core/network/api_client.dart
new file mode 100644
index 0000000..18bce5f
--- /dev/null
+++ b/lib/core/network/api_client.dart
@@ -0,0 +1,133 @@
+import 'dart:async';
+import 'dart:convert';
+
+import 'package:dio/dio.dart';
+
+import '../storage/token_storage.dart';
+import 'api_response.dart';
+
+/// 统一请求封装,等价于 uni-app 版 `utils/request.ts`:
+/// - 自动注入 `Authorization: Bearer `
+/// - 解析 `{ code, message, data }`,非 200 也照常返回给调用方判断
+/// - 401 直接判定会话失效并广播给会话层,由会话层清空本地登录态回到登录页。
+///
+/// 注意:电工端后端未提供 refresh 接口(与管理端 `auth.controller` 不同),
+/// 因此这里不做静默续期,过期即重新登录。
+class ApiClient {
+ ApiClient({required TokenStorage storage, required String baseUrl, Dio? dio})
+ : _storage = storage,
+ _dio =
+ dio ??
+ Dio(
+ BaseOptions(
+ baseUrl: baseUrl,
+ connectTimeout: const Duration(seconds: 15),
+ receiveTimeout: const Duration(seconds: 30),
+ sendTimeout: const Duration(seconds: 30),
+ // 自行判断业务 code,不因 HTTP 状态码抛异常
+ validateStatus: (_) => true,
+ ),
+ );
+
+ final TokenStorage _storage;
+ final Dio _dio;
+
+ final _unauthorizedController = StreamController.broadcast();
+
+ /// 会话失效事件:401 时触发,由会话层订阅后跳转登录页。
+ Stream get onUnauthorized => _unauthorizedController.stream;
+
+ Future> get(
+ String url, {
+ Map? query,
+ T Function(dynamic data)? parse,
+ bool needAuth = true,
+ }) => _send(
+ 'GET',
+ url,
+ query: _compact(query),
+ parse: parse,
+ needAuth: needAuth,
+ );
+
+ Future> post(
+ String url, {
+ Object? body,
+ T Function(dynamic data)? parse,
+ bool needAuth = true,
+ }) => _send('POST', url, body: body, parse: parse, needAuth: needAuth);
+
+ Future> put(
+ String url, {
+ Object? body,
+ T Function(dynamic data)? parse,
+ bool needAuth = true,
+ }) => _send('PUT', url, body: body, parse: parse, needAuth: needAuth);
+
+ void dispose() => _unauthorizedController.close();
+
+ // ---- 内部实现 ----
+
+ Future> _send(
+ String method,
+ String url, {
+ Map? query,
+ Object? body,
+ T Function(dynamic data)? parse,
+ required bool needAuth,
+ }) async {
+ late Response res;
+ try {
+ res = await _dio.request(
+ url,
+ data: body,
+ queryParameters: query,
+ options: Options(
+ method: method,
+ headers: needAuth ? _authHeader() : null,
+ ),
+ );
+ } on DioException catch (e) {
+ throw ApiException(e.message ?? '网络请求失败');
+ }
+
+ final response = ApiResponse.fromJson(_asMap(res.data), parse);
+
+ if (response.code == 401) {
+ _unauthorizedController.add(null);
+ throw const ApiException('登录已失效,请重新登录', code: 401);
+ }
+
+ return response;
+ }
+
+ Map _authHeader() {
+ final token = _storage.accessToken;
+ return token.isEmpty ? const {} : {'Authorization': 'Bearer $token'};
+ }
+
+ static Map _asMap(dynamic data) {
+ if (data is Map) return data.cast();
+ if (data is String && data.isNotEmpty) {
+ try {
+ final decoded = jsonDecode(data);
+ if (decoded is Map) return decoded.cast();
+ } catch (_) {}
+ }
+ return const {'code': -1, 'message': '响应解析失败'};
+ }
+
+ /// 去掉 null / 空串查询条件。
+ /// uni-app 会把 undefined 序列化成空串(如 `status=`),后端对空串查询条件会 500,
+ /// 因此这里同样只在有值时才拼接参数。
+ static Map? _compact(Map? src) {
+ if (src == null || src.isEmpty) return null;
+ final out = {};
+ src.forEach((k, v) {
+ if (v == null) return;
+ if (v is String && v.isEmpty) return;
+ out[k] = v;
+ });
+ return out.isEmpty ? null : out;
+ }
+}
diff --git a/lib/core/network/api_response.dart b/lib/core/network/api_response.dart
new file mode 100644
index 0000000..bdc56b6
--- /dev/null
+++ b/lib/core/network/api_response.dart
@@ -0,0 +1,33 @@
+/// 后端统一响应体:`{ code, message, data }`。
+class ApiResponse {
+ const ApiResponse({required this.code, required this.message, this.data});
+
+ final int code;
+ final String message;
+ final T? data;
+
+ bool get isOk => code == 200;
+
+ factory ApiResponse.fromJson(
+ Map json,
+ T Function(dynamic data)? parse,
+ ) {
+ final raw = json['data'];
+ return ApiResponse(
+ code: (json['code'] as num?)?.toInt() ?? -1,
+ message: (json['message'] as String?) ?? '',
+ data: (raw == null || parse == null) ? null : parse(raw),
+ );
+ }
+}
+
+/// 网络/协议层异常。调用方捕获后用 `message` 提示用户。
+class ApiException implements Exception {
+ const ApiException(this.message, {this.code});
+
+ final String message;
+ final int? code;
+
+ @override
+ String toString() => message;
+}
diff --git a/lib/core/providers.dart b/lib/core/providers.dart
new file mode 100644
index 0000000..b724698
--- /dev/null
+++ b/lib/core/providers.dart
@@ -0,0 +1,110 @@
+import 'dart:async';
+
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../features/auth/application/session_controller.dart';
+import '../features/notice/data/notice_api.dart';
+import 'env.dart';
+import 'network/api_client.dart';
+import 'network/api_response.dart';
+import 'storage/token_storage.dart';
+
+/// 在 `main()` 中读取 SharedPreferences 后通过 override 注入。
+final tokenStorageProvider = Provider(
+ (ref) => throw UnimplementedError('tokenStorageProvider 必须在 ProviderScope 中 override'),
+);
+
+/// 登录页「服务端设置」保存的主机与端口;空串表示未覆盖,走编译期默认值。
+typedef ServerAddress = ({String host, String port});
+
+/// 服务端地址状态。登录页保存/重置后通知 [apiClientProvider] 重建客户端,
+/// 因此无需重启 App 即可切换后端。
+class ServerAddressNotifier extends Notifier {
+ @override
+ ServerAddress build() {
+ final storage = ref.watch(tokenStorageProvider);
+ return (host: storage.serverHost, port: storage.serverPort);
+ }
+
+ Future save(String host, String port) async {
+ await ref.read(tokenStorageProvider).setServerAddress(host, port);
+ state = (host: host, port: port);
+ }
+
+ Future reset() async {
+ await ref.read(tokenStorageProvider).clearServerAddress();
+ state = (host: '', port: '');
+ }
+}
+
+final serverAddressProvider =
+ NotifierProvider(
+ ServerAddressNotifier.new,
+ );
+
+/// 当前生效的 API Base URL。
+final apiBaseUrlProvider = Provider(
+ (ref) => Env.resolveBaseUrl(
+ host: ref.watch(serverAddressProvider).host,
+ port: ref.watch(serverAddressProvider).port,
+ ),
+);
+
+final apiClientProvider = Provider((ref) {
+ final client = ApiClient(
+ storage: ref.watch(tokenStorageProvider),
+ baseUrl: ref.watch(apiBaseUrlProvider),
+ );
+ ref.onDispose(client.dispose);
+ return client;
+});
+
+/// 站内消息中心接口。
+final noticeApiProvider = Provider(
+ (ref) => NoticeApi(ref.watch(apiClientProvider)),
+);
+
+/// 未读消息数。登录态下每 60s 轮询一次,登出立即停止并归零;
+/// 三处顶栏铃铛与消息中心页共用,标记已读后调用 [noticeUnreadCountProvider]
+/// 的 `refresh()` 立即刷新。
+final noticeUnreadCountProvider =
+ NotifierProvider(
+ NoticeUnreadCountNotifier.new,
+ );
+
+class NoticeUnreadCountNotifier extends Notifier {
+ static const _interval = Duration(seconds: 60);
+
+ Timer? _timer;
+
+ @override
+ int build() {
+ // 登录态变化时重建:先停掉上一轮轮询,避免重复计时器。
+ _stop();
+
+ final loggedIn = ref.watch(sessionProvider.select((s) => s.isLoggedIn));
+ if (!loggedIn) return 0;
+
+ ref.onDispose(_stop);
+ _timer = Timer.periodic(_interval, (_) => unawaited(refresh()));
+ unawaited(refresh());
+ return 0;
+ }
+
+ /// 立即拉取一次未读数(进入消息中心 / 单条或全部已读后调用)。
+ Future refresh() async {
+ try {
+ final res = await ref.read(noticeApiProvider).unreadCount();
+ // 请求期间若已登出,丢弃结果,避免残留红点。
+ if (!ref.read(sessionProvider).isLoggedIn) return;
+ if (res.isOk && res.data != null) state = res.data!;
+ } on ApiException {
+ // 轮询失败保持上一次的值,不打断 UI。
+ }
+ }
+
+ void _stop() {
+ _timer?.cancel();
+ _timer = null;
+ }
+}
diff --git a/lib/core/storage/token_storage.dart b/lib/core/storage/token_storage.dart
new file mode 100644
index 0000000..3f89f7d
--- /dev/null
+++ b/lib/core/storage/token_storage.dart
@@ -0,0 +1,59 @@
+import 'dart:convert';
+
+import 'package:shared_preferences/shared_preferences.dart';
+
+/// 本地持久化。键名与 uni-app 版 `uni.getStorageSync` 使用的键保持一致,
+/// 便于两端在真机上迁移/对拍时理解同一份数据。
+class TokenStorage {
+ TokenStorage(this._prefs);
+
+ final SharedPreferences _prefs;
+
+ static const _kAccessToken = 'accessToken';
+ static const _kUserInfo = 'userInfo';
+ static const _kServerHost = 'serverHost';
+ static const _kServerPort = 'serverPort';
+
+ static Future open() async =>
+ TokenStorage(await SharedPreferences.getInstance());
+
+ String get accessToken => _prefs.getString(_kAccessToken) ?? '';
+
+ Map? get userInfo {
+ final raw = _prefs.getString(_kUserInfo);
+ if (raw == null || raw.isEmpty) return null;
+ try {
+ return jsonDecode(raw) as Map;
+ } catch (_) {
+ return null;
+ }
+ }
+
+ Future setAccessToken(String token) =>
+ _prefs.setString(_kAccessToken, token);
+
+ Future setUserInfo(Map info) =>
+ _prefs.setString(_kUserInfo, jsonEncode(info));
+
+ /// 登录页「服务端设置」保存的 IP 与端口,空串表示未覆盖、走编译期默认值。
+ String get serverHost => _prefs.getString(_kServerHost) ?? '';
+
+ String get serverPort => _prefs.getString(_kServerPort) ?? '';
+
+ Future setServerAddress(String host, String port) async {
+ await _prefs.setString(_kServerHost, host);
+ await _prefs.setString(_kServerPort, port);
+ }
+
+ Future clearServerAddress() async {
+ await _prefs.remove(_kServerHost);
+ await _prefs.remove(_kServerPort);
+ }
+
+ Future clearSession() async {
+ await _prefs.remove(_kAccessToken);
+ await _prefs.remove(_kUserInfo);
+ }
+
+ bool get hasSession => accessToken.isNotEmpty;
+}
diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart
new file mode 100644
index 0000000..78badd1
--- /dev/null
+++ b/lib/core/theme/app_colors.dart
@@ -0,0 +1,116 @@
+import 'package:flutter/material.dart';
+
+/// 设计令牌 - 色彩。
+/// 逐项对应 uni-app 版 `src/uni.scss` 中的 `$color-*` 变量。
+abstract final class AppColors {
+ // Primary
+ /// 品牌主色(源 `$primary-container`)。源页面绝大多数蓝色元素用它。
+ static const primary = Color(0xFF0052FF);
+
+ /// 深一档的品牌色(源 `$primary`),用于余额卡渐变起始色。
+ static const primaryDeep = Color(0xFF003EC7);
+ static const onPrimary = Color(0xFFFFFFFF);
+ static const onPrimaryContainer = Color(0xFFDFE3FF);
+ static const primaryFixed = Color(0xFFDDE1FF);
+ static const primaryFixedDim = Color(0xFFB7C4FF);
+ static const onPrimaryFixed = Color(0xFF001452);
+ static const onPrimaryFixedVariant = Color(0xFF0038B6);
+
+ // Secondary
+ static const secondary = Color(0xFF4C4ACA);
+ static const secondaryContainer = Color(0xFF6664E4);
+ static const onSecondary = Color(0xFFFFFFFF);
+ static const onSecondaryContainer = Color(0xFFFFFBFF);
+ static const secondaryFixed = Color(0xFFE2DFFF);
+ static const secondaryFixedDim = Color(0xFFC2C1FF);
+ static const onSecondaryFixed = Color(0xFF0C006A);
+ static const onSecondaryFixedVariant = Color(0xFF3631B4);
+
+ // Tertiary
+ static const tertiary = Color(0xFF952200);
+ static const tertiaryContainer = Color(0xFFBF3003);
+ static const onTertiary = Color(0xFFFFFFFF);
+ static const onTertiaryContainer = Color(0xFFFFDDD5);
+ static const tertiaryFixed = Color(0xFFFFDBD2);
+ static const tertiaryFixedDim = Color(0xFFFFB4A1);
+ static const onTertiaryFixed = Color(0xFF3C0800);
+ static const onTertiaryFixedVariant = Color(0xFF891E00);
+
+ // Error
+ static const error = Color(0xFFBA1A1A);
+ static const errorContainer = Color(0xFFFFDAD6);
+ static const onError = Color(0xFFFFFFFF);
+ static const onErrorContainer = Color(0xFF93000A);
+
+ // Surface
+ static const surface = Color(0xFFFAF9FE);
+ static const surfaceBright = Color(0xFFFAF9FE);
+ static const surfaceDim = Color(0xFFDAD9DF);
+ static const surfaceContainerLowest = Color(0xFFFFFFFF);
+ static const surfaceContainerLow = Color(0xFFF4F3F8);
+ static const surfaceContainer = Color(0xFFEEEDF3);
+ static const surfaceContainerHigh = Color(0xFFE9E7ED);
+ static const surfaceContainerHighest = Color(0xFFE3E2E7);
+ static const surfaceVariant = Color(0xFFE3E2E7);
+
+ // Background
+ static const bgPage = Color(0xFFFAF9FE);
+ static const background = Color(0xFFFAF9FE);
+
+ // Text
+ static const onSurface = Color(0xFF1A1B1F);
+ static const onSurfaceVariant = Color(0xFF434656);
+ static const textMuted = Color(0xFF737688);
+ static const textPlaceholder = Color(0xFFC3C5D9);
+
+ // Outline
+ static const outline = Color(0xFF737688);
+ static const outlineVariant = Color(0xFFC3C5D9);
+ static const borderRule = Color(0xFFC3C5D9);
+
+ /// `rgba($outline-variant, 0.2)` —— 各页面 `.ios-card` / 分隔线用的浅描边。
+ static const borderSoft = Color(0x33C3C5D9);
+
+ /// `.ios-blur` / `.custom-tab-bar` 的毛玻璃底色 rgba(250,249,254,0.85)
+ static const headerGlass = Color(0xD9FAF9FE);
+
+ // Inverse
+ static const inverseSurface = Color(0xFF2F3034);
+ static const inverseOnSurface = Color(0xFFF1F0F5);
+ static const inversePrimary = Color(0xFFB7C4FF);
+
+ // Accent
+ static const accentSubtle = Color(0x140052FF);
+
+ // Status
+ static const success = Color(0xFF34C759);
+ static const star = Color(0xFFFF9500);
+ static const statusWarn = Color(0xFFE0852B);
+ static const statusError = Color(0xFFDC2626);
+
+ // 底部导航毛玻璃底:rgba(250,249,254,0.95)
+ static const navGlass = Color(0xF2FAF9FE);
+}
+
+/// chip 语义样式:背景色 + 前景色,对应各页面 `.chip-*` / 徽章配色。
+enum ChipTone { primary, secondary, urgent, warning, success, muted }
+
+extension ChipToneStyle on ChipTone {
+ Color get foreground => switch (this) {
+ ChipTone.primary => AppColors.primary,
+ ChipTone.secondary => AppColors.secondary,
+ ChipTone.urgent => AppColors.tertiaryContainer,
+ ChipTone.warning => AppColors.statusWarn,
+ ChipTone.success => AppColors.success,
+ ChipTone.muted => AppColors.onSurfaceVariant,
+ };
+
+ Color get background => switch (this) {
+ ChipTone.primary => AppColors.accentSubtle,
+ ChipTone.secondary => const Color(0x144C4ACA),
+ ChipTone.urgent => const Color(0x14BF3003),
+ ChipTone.warning => const Color(0x14E0852B),
+ ChipTone.success => const Color(0x1434C759),
+ ChipTone.muted => AppColors.surfaceContainer,
+ };
+}
diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart
new file mode 100644
index 0000000..2b14c05
--- /dev/null
+++ b/lib/core/theme/app_dimens.dart
@@ -0,0 +1,66 @@
+import 'package:flutter/painting.dart';
+
+/// 设计令牌 - 间距与圆角。对应 `uni.scss` 的 `$spacing-*` / `$rounded-*`。
+abstract final class AppSpacing {
+ static const double base = 4;
+ static const double xs = 8;
+ static const double sm = 12;
+ static const double md = 16;
+ static const double lg = 24;
+ static const double xl = 40;
+
+ /// `$spacing-margin-page` / `$spacing-gutter-grid` = 1rem
+ static const double gutter = 16;
+}
+
+abstract final class AppRadius {
+ /// `$rounded-sm` = 0.25rem
+ static const double sm = 4;
+
+ /// `$rounded-md` = 0.5rem
+ static const double regular = 8;
+
+ /// `$rounded-lg` = 0.75rem
+ static const double lg = 12;
+
+ /// `$rounded-xl` = 1rem
+ static const double xl = 16;
+
+ /// 订单区 / 底部弹窗的 24px 大圆角
+ static const double xxl = 24;
+
+ /// `$rounded-full`
+ static const double full = 9999;
+}
+
+abstract final class AppShadows {
+ /// 全局 `.ios-card`:0 1px 3px rgba(0,0,0,0.04)
+ static const card = [
+ BoxShadow(color: Color(0x0A000000), blurRadius: 3, offset: Offset(0, 1)),
+ ];
+
+ /// 订单卡片:0 2px 8px rgba(0,0,0,0.04)
+ static const orderCard = [
+ BoxShadow(color: Color(0x0A000000), blurRadius: 8, offset: Offset(0, 2)),
+ ];
+
+ /// 顶栏下边框阴影:0 -1px 3px rgba(0,0,0,0.05)
+ static const topBar = [
+ BoxShadow(color: Color(0x0D000000), blurRadius: 3, offset: Offset(0, 1)),
+ ];
+
+ /// `@mixin ios-shadow`:0 2px 8px rgba(0,0,0,0.04)
+ static const soft = [
+ BoxShadow(color: Color(0x0A000000), blurRadius: 8, offset: Offset(0, 2)),
+ ];
+
+ /// 底部导航条:向上投影
+ static const bottomNav = [
+ BoxShadow(color: Color(0x14000000), blurRadius: 24, offset: Offset(0, -8)),
+ ];
+
+ /// 品牌主按钮发光:0 8px 20px rgba(0,82,255,0.28)
+ static const primaryButton = [
+ BoxShadow(color: Color(0x470052FF), blurRadius: 20, offset: Offset(0, 8)),
+ ];
+}
diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart
new file mode 100644
index 0000000..35ebb64
--- /dev/null
+++ b/lib/core/theme/app_theme.dart
@@ -0,0 +1,97 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+
+import 'app_colors.dart';
+
+abstract final class AppTheme {
+ /// 全平台统一使用 iOS 风格「新页面从右侧滑入」转场,与 uni-app 版
+ /// `components/PageTransition.vue` 的表现对齐。
+ static const _transitions = PageTransitionsTheme(
+ builders: {
+ TargetPlatform.android: CupertinoPageTransitionsBuilder(),
+ TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
+ TargetPlatform.macOS: CupertinoPageTransitionsBuilder(),
+ TargetPlatform.windows: CupertinoPageTransitionsBuilder(),
+ TargetPlatform.linux: CupertinoPageTransitionsBuilder(),
+ TargetPlatform.fuchsia: CupertinoPageTransitionsBuilder(),
+ },
+ );
+
+ static ThemeData build() {
+ final base = ThemeData.light(useMaterial3: true);
+
+ return base.copyWith(
+ scaffoldBackgroundColor: AppColors.bgPage,
+ pageTransitionsTheme: _transitions,
+ colorScheme: base.colorScheme.copyWith(
+ primary: AppColors.primary,
+ onPrimary: AppColors.onPrimary,
+ secondary: AppColors.secondary,
+ onSecondary: AppColors.onSecondary,
+ tertiary: AppColors.tertiary,
+ onTertiary: AppColors.onTertiary,
+ error: AppColors.error,
+ onError: AppColors.onError,
+ surface: AppColors.surfaceContainerLowest,
+ onSurface: AppColors.onSurface,
+ outline: AppColors.outline,
+ outlineVariant: AppColors.outlineVariant,
+ ),
+ textTheme: base.textTheme.apply(
+ bodyColor: AppColors.onSurface,
+ displayColor: AppColors.onSurface,
+ ),
+ dividerTheme: const DividerThemeData(
+ color: AppColors.borderRule,
+ thickness: 0.5,
+ space: 1,
+ ),
+ appBarTheme: const AppBarTheme(
+ backgroundColor: AppColors.surface,
+ foregroundColor: AppColors.onSurface,
+ elevation: 0,
+ scrolledUnderElevation: 0,
+ centerTitle: true,
+ systemOverlayStyle: SystemUiOverlayStyle(
+ statusBarColor: Colors.transparent,
+ statusBarIconBrightness: Brightness.dark,
+ statusBarBrightness: Brightness.light,
+ ),
+ titleTextStyle: TextStyle(
+ fontSize: 17,
+ height: 24 / 17,
+ fontWeight: FontWeight.w600,
+ color: AppColors.onSurface,
+ ),
+ ),
+ inputDecorationTheme: InputDecorationTheme(
+ filled: true,
+ fillColor: AppColors.surfaceContainerLow,
+ hintStyle: const TextStyle(color: AppColors.textPlaceholder, fontSize: 17),
+ contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ borderSide: BorderSide.none,
+ ),
+ enabledBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ borderSide: BorderSide.none,
+ ),
+ focusedBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ borderSide: const BorderSide(color: AppColors.primary, width: 1.5),
+ ),
+ errorBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ borderSide: const BorderSide(color: AppColors.error),
+ ),
+ focusedErrorBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ borderSide: const BorderSide(color: AppColors.error, width: 1.5),
+ ),
+ ),
+ splashFactory: InkSparkle.splashFactory,
+ tooltipTheme: const TooltipThemeData(waitDuration: Duration(milliseconds: 400)),
+ );
+ }
+}
diff --git a/lib/core/theme/app_typography.dart b/lib/core/theme/app_typography.dart
new file mode 100644
index 0000000..31f6119
--- /dev/null
+++ b/lib/core/theme/app_typography.dart
@@ -0,0 +1,114 @@
+import 'package:flutter/material.dart';
+
+import 'app_colors.dart';
+
+/// 设计令牌 - 字号 / 行高 / 字重。
+/// 对应 `uni.scss` 的 `$font-*` 与 `global.scss` 的 `.font-*` 工具类,
+/// 行高统一换算成 Flutter 的 `height`(行高 ÷ 字号)。
+abstract final class AppTypography {
+ static const _sansFallback = [
+ 'Inter',
+ 'PingFang SC',
+ 'Microsoft YaHei',
+ 'Noto Sans CJK SC',
+ ];
+
+ static const _monoFallback = [
+ 'JetBrains Mono',
+ 'SF Mono',
+ 'Menlo',
+ 'Consolas',
+ ];
+
+ /// 12 / 16 / w500 / ls 0.02em — `.font-label-caps`
+ static const labelCaps = TextStyle(
+ fontSize: 12,
+ height: 16 / 12,
+ fontWeight: FontWeight.w500,
+ letterSpacing: 0.24,
+ fontFamilyFallback: _sansFallback,
+ );
+
+ /// 13 / 18 / w500 — 卡片内的次要信息行
+ static const bodySm = TextStyle(
+ fontSize: 13,
+ height: 18 / 13,
+ fontWeight: FontWeight.w500,
+ fontFamilyFallback: _sansFallback,
+ );
+
+ /// 15 / 20 / w400 — `.font-body-sm`
+ static const bodyMd = TextStyle(
+ fontSize: 15,
+ height: 20 / 15,
+ fontWeight: FontWeight.w400,
+ fontFamilyFallback: _sansFallback,
+ );
+
+ /// 17 / 22 / w400 / ls -0.01em — `.font-body-lg`
+ static const bodyLg = TextStyle(
+ fontSize: 17,
+ height: 22 / 17,
+ fontWeight: FontWeight.w400,
+ letterSpacing: -0.17,
+ fontFamilyFallback: _sansFallback,
+ );
+
+ /// 20 / 25 / w600 / ls -0.01em — `.font-headline-md`
+ static const headlineMd = TextStyle(
+ fontSize: 20,
+ height: 25 / 20,
+ fontWeight: FontWeight.w600,
+ letterSpacing: -0.2,
+ fontFamilyFallback: _sansFallback,
+ );
+
+ /// 22 / 28 / w700 — 「我的」页用户名
+ static const titleLg = TextStyle(
+ fontSize: 22,
+ height: 28 / 22,
+ fontWeight: FontWeight.w700,
+ fontFamilyFallback: _sansFallback,
+ );
+
+ /// 28 / 34 / w700 / ls -0.02em — `.font-nav-large-title-mobile`
+ static const navLargeTitle = TextStyle(
+ fontSize: 28,
+ height: 34 / 28,
+ fontWeight: FontWeight.w700,
+ letterSpacing: -0.56,
+ fontFamilyFallback: _sansFallback,
+ );
+
+ /// 32 / 40 / w700 — 登录页品牌字
+ static const displayMd = TextStyle(
+ fontSize: 32,
+ height: 40 / 32,
+ fontWeight: FontWeight.w700,
+ fontFamilyFallback: _sansFallback,
+ );
+
+ /// 40 / 48 / w700 — 收益页总余额
+ static const displayLg = TextStyle(
+ fontSize: 40,
+ height: 48 / 40,
+ fontWeight: FontWeight.w700,
+ fontFamilyFallback: _sansFallback,
+ );
+
+ /// 金额 / 编号等需要等宽对齐的场景
+ static const dataMono = TextStyle(
+ fontSize: 14,
+ height: 18 / 14,
+ fontWeight: FontWeight.w600,
+ fontFamilyFallback: _monoFallback,
+ );
+}
+
+/// 全局默认文字样式,对应 `global.scss` 里 `page` 上的字体设置。
+TextStyle get appDefaultTextStyle => const TextStyle(
+ fontSize: 15,
+ height: 1.4,
+ color: AppColors.onSurface,
+ fontFamilyFallback: AppTypography._sansFallback,
+);
diff --git a/lib/core/utils/format_util.dart b/lib/core/utils/format_util.dart
new file mode 100644
index 0000000..d2f4c02
--- /dev/null
+++ b/lib/core/utils/format_util.dart
@@ -0,0 +1,45 @@
+/// 日期格式化工具。
+String _pad(int n) => n.toString().padLeft(2, '0');
+
+/// 统一转 DateTime:兼容 `YYYY-MM-DD`、ISO 字符串与时间戳。
+DateTime? parseDate(Object? v) {
+ if (v == null) return null;
+ if (v is DateTime) return v;
+ if (v is num) return DateTime.fromMillisecondsSinceEpoch(v.toInt());
+ final s = v.toString();
+ if (s.isEmpty) return null;
+ return DateTime.tryParse(s) ?? DateTime.tryParse(s.replaceAll('-', '/'));
+}
+
+/// `YYYY-MM-DD`
+String formatDate(Object? v) {
+ final d = parseDate(v);
+ if (d == null) return '-';
+ return '${d.year}-${_pad(d.month)}-${_pad(d.day)}';
+}
+
+/// `YYYY-MM-DD HH:mm`
+String formatDateTime(Object? v) {
+ final d = parseDate(v);
+ if (d == null) return '-';
+ return '${formatDate(d)} ${_pad(d.hour)}:${_pad(d.minute)}';
+}
+
+/// 今天的 `YYYY-MM-DD`
+String todayStr() => formatDate(DateTime.now());
+
+/// 金额格式化:千分位 + 两位小数,对应源页面 `formatNumber`。
+String formatMoney(num? value) {
+ final v = (value ?? 0).toDouble();
+ final fixed = v.toStringAsFixed(2);
+ final parts = fixed.split('.');
+ final intPart = parts[0];
+ final negative = intPart.startsWith('-');
+ final digits = negative ? intPart.substring(1) : intPart;
+ final buf = StringBuffer();
+ for (var i = 0; i < digits.length; i++) {
+ if (i > 0 && (digits.length - i) % 3 == 0) buf.write(',');
+ buf.write(digits[i]);
+ }
+ return '${negative ? '-' : ''}$buf.${parts[1]}';
+}
diff --git a/lib/core/utils/id_util.dart b/lib/core/utils/id_util.dart
new file mode 100644
index 0000000..0e461fa
--- /dev/null
+++ b/lib/core/utils/id_util.dart
@@ -0,0 +1,22 @@
+/// 后端主键为 BIGINT,经 JSON 序列化后是字符串(如 `"1"`)。
+/// 统一归一化为 int,避免与表单/路由里的 int 型 id 做比较时失效。
+Object? toNumId(Object? v) {
+ if (v == null || v == '') return v;
+ if (v is int) return v;
+ if (v is num) return v.toInt();
+ final n = int.tryParse(v.toString());
+ return n ?? v;
+}
+
+int? toIntOrNull(Object? v) {
+ final n = toNumId(v);
+ return n is int ? n : null;
+}
+
+int toIntOr(Object? v, int fallback) => toIntOrNull(v) ?? fallback;
+
+double toDoubleOr(Object? v, double fallback) {
+ if (v == null) return fallback;
+ if (v is num) return v.toDouble();
+ return double.tryParse(v.toString()) ?? fallback;
+}
diff --git a/lib/features/auth/application/session_controller.dart b/lib/features/auth/application/session_controller.dart
new file mode 100644
index 0000000..f854537
--- /dev/null
+++ b/lib/features/auth/application/session_controller.dart
@@ -0,0 +1,100 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../../../core/providers.dart';
+import '../../../core/storage/token_storage.dart';
+import '../data/auth_api.dart';
+import '../data/auth_models.dart';
+
+final authApiProvider = Provider(
+ (ref) => AuthApi(ref.watch(apiClientProvider)),
+);
+
+class SessionState {
+ const SessionState({this.worker, required this.isLoggedIn});
+
+ final WorkerInfo? worker;
+ final bool isLoggedIn;
+
+ SessionState copyWith({WorkerInfo? worker, bool? isLoggedIn}) =>
+ SessionState(
+ worker: worker ?? this.worker,
+ isLoggedIn: isLoggedIn ?? this.isLoggedIn,
+ );
+}
+
+/// 登录态。对应源 `store/user.ts` 的登录/登出与本地缓存恢复。
+class SessionController extends Notifier {
+ TokenStorage get _storage => ref.read(tokenStorageProvider);
+
+ @override
+ SessionState build() {
+ // 拦截器判定会话失效(401)时,清空本地会话并回到登录页。
+ final sub = ref
+ .watch(apiClientProvider)
+ .onUnauthorized
+ .listen((_) => clearLocal());
+ ref.onDispose(sub.cancel);
+
+ final cached = _storage.userInfo;
+ return SessionState(
+ worker: cached == null ? null : WorkerInfo.fromJson(cached),
+ isLoggedIn: _storage.hasSession,
+ );
+ }
+
+ /// 登录成功后写入 token 与电工信息。
+ Future login({
+ required String phone,
+ required String password,
+ }) async {
+ final res = await ref
+ .read(authApiProvider)
+ .login(phone: phone, password: password);
+ if (!res.isOk || res.data == null) {
+ throw AuthException(res.message.isEmpty ? '登录失败' : res.message);
+ }
+
+ final data = res.data!;
+ await _storage.setAccessToken(data.token);
+ await _storage.setUserInfo(data.workerInfo.toJson());
+ state = SessionState(worker: data.workerInfo, isLoggedIn: true);
+ }
+
+ /// 从后端刷新电工信息(「我的」页进入时调用)。
+ Future refreshProfile() async {
+ try {
+ final res = await ref.read(authApiProvider).profile();
+ if (res.isOk && res.data != null) {
+ final info = res.data!.basic;
+ await _storage.setUserInfo(info.toJson());
+ state = SessionState(worker: info, isLoggedIn: true);
+ return info;
+ }
+ } catch (_) {
+ // 拉取失败保持原状态
+ }
+ return null;
+ }
+
+ Future logout() async {
+ await _storage.clearSession();
+ state = const SessionState(isLoggedIn: false, worker: null);
+ }
+
+ /// 清空本地会话但不跳转(被拦截器判定会话失效时调用)。
+ Future clearLocal() => logout();
+}
+
+final sessionProvider = NotifierProvider(
+ SessionController.new,
+);
+
+/// 会话层业务异常,携带后端返回的可读 message。
+class AuthException implements Exception {
+ const AuthException(this.message);
+
+ final String message;
+
+ @override
+ String toString() => message;
+}
diff --git a/lib/features/auth/data/auth_api.dart b/lib/features/auth/data/auth_api.dart
new file mode 100644
index 0000000..1b25cdd
--- /dev/null
+++ b/lib/features/auth/data/auth_api.dart
@@ -0,0 +1,58 @@
+import '../../../core/network/api_client.dart';
+import '../../../core/network/api_response.dart';
+import 'auth_models.dart';
+
+/// 认证与个人资料接口,对应后端 `/api/worker-app/auth/*` 与 `/profile`。
+class AuthApi {
+ AuthApi(this._client);
+
+ final ApiClient _client;
+
+ Future> login({
+ required String phone,
+ required String password,
+ }) => _client.post(
+ '/worker-app/auth/login',
+ body: {'phone': phone, 'password': password},
+ parse: (d) => LoginResult.fromJson((d as Map).cast()),
+ needAuth: false,
+ );
+
+ Future> register({
+ required String workerName,
+ required String phone,
+ required String password,
+ String? idCard,
+ String? workerTitle,
+ }) => _client.post(
+ '/worker-app/auth/register',
+ body: {
+ 'workerName': workerName,
+ 'phone': phone,
+ 'password': password,
+ if (idCard != null && idCard.isNotEmpty) 'idCard': idCard,
+ if (workerTitle != null && workerTitle.isNotEmpty) 'workerTitle': workerTitle,
+ },
+ parse: (d) => WorkerInfo.fromJson((d as Map).cast()),
+ needAuth: false,
+ );
+
+ Future> profile() => _client.get(
+ '/worker-app/profile',
+ parse: (d) => WorkerProfile.fromJson((d as Map).cast()),
+ );
+
+ Future> updateProfile({
+ String? workerName,
+ String? idCard,
+ String? workerTitle,
+ }) => _client.put(
+ '/worker-app/profile',
+ body: {
+ 'workerName': ?workerName,
+ 'idCard': ?idCard,
+ 'workerTitle': ?workerTitle,
+ },
+ parse: (d) => WorkerInfo.fromJson((d as Map).cast()),
+ );
+}
diff --git a/lib/features/auth/data/auth_models.dart b/lib/features/auth/data/auth_models.dart
new file mode 100644
index 0000000..b3fb08a
--- /dev/null
+++ b/lib/features/auth/data/auth_models.dart
@@ -0,0 +1,151 @@
+import '../../../core/utils/id_util.dart';
+
+/// 电工基础信息,对应后端 `WorkerAppService.basicInfo()`。
+class WorkerInfo {
+ const WorkerInfo({
+ required this.id,
+ required this.workerName,
+ required this.phone,
+ required this.workerTitle,
+ required this.auditStatus,
+ required this.status,
+ required this.rating,
+ required this.completedCount,
+ });
+
+ final int id;
+ final String workerName;
+ final String phone;
+ final String workerTitle;
+
+ /// 审核状态:0 待审核 / 1 已通过 / 2 已驳回
+ final int auditStatus;
+
+ /// 在职状态:0 离职 / 1 在职
+ final int status;
+
+ final double rating;
+ final int completedCount;
+
+ String get displayName => workerName.isNotEmpty ? workerName : phone;
+
+ /// 头像占位文字:取显示名首个字符(按 Unicode 码点截取,兼容中文)。
+ String get avatarText =>
+ displayName.isEmpty ? '电' : String.fromCharCode(displayName.runes.first);
+
+ bool get isApproved => auditStatus == 1;
+
+ String get auditStatusText => switch (auditStatus) {
+ 0 => '待审核',
+ 1 => '已通过',
+ 2 => '已驳回',
+ _ => '未知',
+ };
+
+ factory WorkerInfo.fromJson(Map json) => WorkerInfo(
+ id: toIntOr(json['id'], 0),
+ workerName: json['workerName'] as String? ?? '',
+ phone: json['phone'] as String? ?? '',
+ workerTitle: json['workerTitle'] as String? ?? '',
+ auditStatus: toIntOr(json['auditStatus'], 0),
+ status: toIntOr(json['status'], 0),
+ rating: toDoubleOr(json['rating'], 5),
+ completedCount: toIntOr(json['completedCount'], 0),
+ );
+
+ Map toJson() => {
+ 'id': id,
+ 'workerName': workerName,
+ 'phone': phone,
+ 'workerTitle': workerTitle,
+ 'auditStatus': auditStatus,
+ 'status': status,
+ 'rating': rating,
+ 'completedCount': completedCount,
+ };
+}
+
+/// 登录返回体。
+class LoginResult {
+ const LoginResult({required this.token, required this.workerInfo});
+
+ final String token;
+ final WorkerInfo workerInfo;
+
+ factory LoginResult.fromJson(Map json) => LoginResult(
+ token: json['token'] as String? ?? '',
+ workerInfo: WorkerInfo.fromJson(
+ (json['workerInfo'] as Map?)?.cast() ?? const {},
+ ),
+ );
+}
+
+/// 电工资质证照,对应 `worker_cert` 表。
+class CertInfo {
+ const CertInfo({
+ required this.id,
+ required this.certType,
+ required this.certNo,
+ required this.issueAuthority,
+ required this.issueDate,
+ required this.expireDate,
+ required this.certImageUrl,
+ required this.status,
+ });
+
+ final int id;
+ final String certType;
+ final String certNo;
+ final String issueAuthority;
+ final String issueDate;
+ final String expireDate;
+ final String certImageUrl;
+
+ /// 有效期状态:0 正常 / 1 即将到期 / 2 已过期
+ final int status;
+
+ factory CertInfo.fromJson(Map json) => CertInfo(
+ id: toIntOr(json['id'], 0),
+ certType: json['certType'] as String? ?? '',
+ certNo: json['certNo'] as String? ?? '',
+ issueAuthority: json['issueAuthority'] as String? ?? '',
+ issueDate: json['issueDate'] as String? ?? '',
+ expireDate: json['expireDate'] as String? ?? '',
+ certImageUrl: json['certImageUrl'] as String? ?? '',
+ status: toIntOr(json['status'], 0),
+ );
+}
+
+/// 个人资料,对应 `GET /api/worker-app/profile`。
+class WorkerProfile {
+ const WorkerProfile({
+ required this.basic,
+ required this.idCard,
+ required this.auditRemark,
+ required this.certStatus,
+ required this.certs,
+ });
+
+ final WorkerInfo basic;
+ final String idCard;
+ final String auditRemark;
+
+ /// 资质汇总状态:none / valid / expiring / expired
+ final String certStatus;
+ final List certs;
+
+ /// 「我的」页证书区取首张证书展示(源页面只展示一张「电工职业资格证」)。
+ CertInfo? get primaryCert => certs.isEmpty ? null : certs.first;
+
+ factory WorkerProfile.fromJson(Map json) => WorkerProfile(
+ basic: WorkerInfo.fromJson(json),
+ idCard: json['idCard'] as String? ?? '',
+ auditRemark: json['auditRemark'] as String? ?? '',
+ certStatus: json['certStatus'] as String? ?? 'none',
+ certs:
+ (json['certs'] as List?)
+ ?.map((e) => CertInfo.fromJson((e as Map).cast()))
+ .toList() ??
+ const [],
+ );
+}
diff --git a/lib/features/auth/presentation/login_page.dart b/lib/features/auth/presentation/login_page.dart
new file mode 100644
index 0000000..1ace9a8
--- /dev/null
+++ b/lib/features/auth/presentation/login_page.dart
@@ -0,0 +1,717 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+
+import '../../../core/env.dart';
+import '../../../core/providers.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_common.dart';
+import '../../../shared/widgets/x_icon.dart';
+import '../application/session_controller.dart';
+
+/// 是否在 release 包里也显示「服务端设置」入口。
+///
+/// 默认关闭,正式包不暴露改地址的口子;打内部测试包时用
+/// `--dart-define=ENABLE_SERVER_SETTINGS=true` 打开。
+const _serverSettingsEnabled = bool.fromEnvironment('ENABLE_SERVER_SETTINGS');
+
+/// 登录页,对应源 `pages/login/index.vue`。
+///
+/// **与源的差异(已与用户确认)**:源为「手机号 + 验证码」,后端只支持
+/// 「手机号 + 密码」,故此处改为手机号 + 密码,并新增注册入口。
+class LoginPage extends ConsumerStatefulWidget {
+ const LoginPage({super.key});
+
+ @override
+ ConsumerState createState() => _LoginPageState();
+}
+
+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();
+ _password.dispose();
+ 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(() {});
+ return;
+ }
+
+ setState(() {
+ _loading = true;
+ _error = '';
+ });
+ try {
+ await ref
+ .read(sessionProvider.notifier)
+ .login(phone: _phone.text.trim(), password: _password.text);
+ if (!mounted) return;
+ context.go('/tabs');
+ } on AuthException 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: 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,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Future _openServerSettings() => showModalBottomSheet(
+ context: context,
+ isScrollControlled: true,
+ backgroundColor: AppColors.surface,
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)),
+ ),
+ builder: (_) => const _ServerSettingsSheet(),
+ );
+
+ // ---- 各区块 ----
+
+ 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),
+ ),
+ Padding(
+ padding: const EdgeInsets.only(bottom: 8),
+ child: Text(
+ 'VoltLink',
+ style: AppTypography.displayMd.copyWith(
+ color: AppColors.onSurface,
+ ),
+ ),
+ ),
+ const Text(
+ '欢迎回到 VoltLink',
+ style: TextStyle(fontSize: 16, 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() {
+ return Container(
+ height: 56,
+ decoration: const BoxDecoration(
+ border: Border(bottom: BorderSide(color: AppColors.surfaceContainerHighest)),
+ ),
+ child: Row(
+ 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),
+ ),
+ ],
+ ),
+ ),
+ 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('请输入手机号码'),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ /// 密码行:源为「验证码 + 获取验证码」,后端改为密码,右侧按钮换成明文切换。
+ 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 _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,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ 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 _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 SizedBox(width: 12),
+ 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 在登录时获取我的相关信息。'),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ 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 与端口,
+/// 协议与路径沿用 [Env.apiBaseUrl] 的编译期配置。
+class _ServerSettingsSheet extends ConsumerStatefulWidget {
+ const _ServerSettingsSheet();
+
+ @override
+ ConsumerState<_ServerSettingsSheet> createState() =>
+ _ServerSettingsSheetState();
+}
+
+class _ServerSettingsSheetState extends ConsumerState<_ServerSettingsSheet> {
+ late final TextEditingController _host;
+ late final TextEditingController _port;
+ String _error = '';
+
+ @override
+ void initState() {
+ super.initState();
+ final saved = ref.read(serverAddressProvider);
+ _host = TextEditingController(
+ text: saved.host.isEmpty ? Env.defaultHost : saved.host,
+ );
+ _port = TextEditingController(
+ text: saved.port.isEmpty ? '${Env.defaultPort}' : saved.port,
+ );
+ }
+
+ @override
+ void dispose() {
+ _host.dispose();
+ _port.dispose();
+ super.dispose();
+ }
+
+ String get _preview {
+ final host = _host.text.trim();
+ return Env.buildBaseUrl(
+ host.isEmpty ? Env.defaultHost : host,
+ _port.text.trim(),
+ );
+ }
+
+ Future _save() async {
+ final host = _host.text.trim();
+ final port = _port.text.trim();
+
+ if (host.isEmpty) {
+ setState(() => _error = '请输入服务端 IP');
+ return;
+ }
+ if (host.contains('://') || host.contains('/') || host.contains(':')) {
+ setState(() => _error = '只填 IP 或域名,不要带 http:// 和端口');
+ return;
+ }
+ final parsedPort = int.tryParse(port);
+ if (parsedPort == null || parsedPort < 1 || parsedPort > 65535) {
+ setState(() => _error = '端口需为 1 - 65535 的整数');
+ return;
+ }
+
+ // 保存后 serverAddressProvider 变化会重建 ApiClient,立即生效
+ await ref.read(serverAddressProvider.notifier).save(host, port);
+ if (!mounted) return;
+ Navigator.of(context).pop();
+ showAppToast('已切换到 ${Env.buildBaseUrl(host, port)}', success: true);
+ }
+
+ Future _reset() async {
+ await ref.read(serverAddressProvider.notifier).reset();
+ if (!mounted) return;
+ Navigator.of(context).pop();
+ showAppToast('已恢复默认地址', success: true);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.fromLTRB(24, 20, 24, 28),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Text(
+ '服务端设置',
+ style: AppTypography.headlineMd.copyWith(
+ color: AppColors.onSurface,
+ ),
+ ),
+ const SizedBox(height: 6),
+ const Text(
+ '保存后立即生效,无需重启 App。',
+ style: TextStyle(fontSize: 13, color: AppColors.textMuted),
+ ),
+ const SizedBox(height: 20),
+ _buildField(
+ label: '服务端 IP',
+ controller: _host,
+ hintText: '如 192.168.3.29',
+ keyboardType: TextInputType.url,
+ ),
+ const SizedBox(height: 16),
+ _buildField(
+ label: '端口',
+ controller: _port,
+ hintText: '如 7001',
+ keyboardType: TextInputType.number,
+ ),
+ const SizedBox(height: 14),
+ // 保存前实时预览拼接结果,避免地址拼错
+ ListenableBuilder(
+ listenable: Listenable.merge([_host, _port]),
+ builder: (context, _) => Text(
+ '生效地址:$_preview',
+ style: AppTypography.dataMono.copyWith(
+ color: AppColors.textMuted,
+ ),
+ ),
+ ),
+ if (_error.isNotEmpty)
+ Padding(
+ padding: const EdgeInsets.only(top: 12),
+ child: Text(
+ _error,
+ style: const TextStyle(fontSize: 13, color: AppColors.error),
+ ),
+ ),
+ const SizedBox(height: 24),
+ Row(
+ children: [
+ Expanded(
+ child: XPrimaryButton(
+ label: '恢复默认',
+ onPressed: _reset,
+ background: AppColors.surfaceContainerHigh,
+ foreground: AppColors.onSurfaceVariant,
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: XPrimaryButton(label: '保存', onPressed: _save),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildField({
+ required String label,
+ required TextEditingController controller,
+ required String hintText,
+ required TextInputType keyboardType,
+ }) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ label,
+ style: AppTypography.bodySm.copyWith(
+ color: AppColors.onSurfaceVariant,
+ ),
+ ),
+ 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,
+ keyboardType: keyboardType,
+ autocorrect: false,
+ style: const TextStyle(fontSize: 15, color: AppColors.onSurface),
+ cursorColor: AppColors.primary,
+ decoration: InputDecoration(
+ isDense: true,
+ border: InputBorder.none,
+ hintText: hintText,
+ hintStyle: const TextStyle(
+ fontSize: 15,
+ color: AppColors.textPlaceholder,
+ ),
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/features/auth/presentation/register_page.dart b/lib/features/auth/presentation/register_page.dart
new file mode 100644
index 0000000..1e5aaf5
--- /dev/null
+++ b/lib/features/auth/presentation/register_page.dart
@@ -0,0 +1,252 @@
+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';
+
+/// 注册页。
+///
+/// 源工程登录页只有「手机号 + 验证码」,没有注册页;后端 `/auth/register`
+/// 支持手机号 + 密码开户,因此这里补一个与登录页同一套视觉语言的注册表单。
+class RegisterPage extends ConsumerStatefulWidget {
+ const RegisterPage({super.key});
+
+ @override
+ ConsumerState createState() => _RegisterPageState();
+}
+
+class _RegisterPageState extends ConsumerState {
+ final _name = TextEditingController();
+ final _phone = TextEditingController();
+ final _password = TextEditingController();
+ final _idCard = TextEditingController();
+ final _title = TextEditingController();
+
+ bool _loading = false;
+ String _error = '';
+
+ @override
+ void initState() {
+ super.initState();
+ for (final c in [_name, _phone, _password]) {
+ c.addListener(_onChanged);
+ }
+ }
+
+ @override
+ void dispose() {
+ _name.dispose();
+ _phone.dispose();
+ _password.dispose();
+ _idCard.dispose();
+ _title.dispose();
+ super.dispose();
+ }
+
+ void _onChanged() => setState(() {});
+
+ bool get _canSubmit =>
+ _name.text.trim().isNotEmpty &&
+ _phone.text.length == 11 &&
+ _password.text.length >= 6;
+
+ Future