Mztj67_#M}W?l>kYSliK<%xAp;0j{!}J0!o7b
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
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..6d285b9
--- /dev/null
+++ b/ios/Runner/Info.plist
@@ -0,0 +1,84 @@
+
+
+
+
+ CADisableMinimumFrameDurationOnPhone
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ 鑫潼智电运营管理
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ smart_grid_iot_manage_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
+
+ NSCameraUsageDescription
+ 用于拍摄勘测现场照片与拜访取证照片
+ NSPhotoLibraryUsageDescription
+ 用于选择勘测现场照片与拜访取证照片
+ NSLocationWhenInUseUsageDescription
+ 用于拜访签到时记录当前位置
+
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+
+ NSLocalNetworkUsageDescription
+ 用于访问局域网内的运营管理后端服务
+
+
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..3678156
--- /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 SmartGridApp extends ConsumerWidget {
+ const SmartGridApp({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final router = ref.watch(routerProvider);
+
+ return MaterialApp.router(
+ title: '鑫潼智电运营管理',
+ 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,
+ ],
+ // 对应 App.vue 中挂在 `page` 上的全局字体设置:15px / 行高 1.6 / on-surface。
+ builder: (context, child) => DefaultTextStyle(
+ style: appDefaultTextStyle,
+ child: child ?? const SizedBox.shrink(),
+ ),
+ );
+ }
+}
\ No newline at end of file
diff --git a/lib/app/router.dart b/lib/app/router.dart
new file mode 100644
index 0000000..1255bad
--- /dev/null
+++ b/lib/app/router.dart
@@ -0,0 +1,109 @@
+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/customer/presentation/customer_detail_page.dart';
+import '../features/customer/presentation/customer_edit_page.dart';
+import '../features/project/presentation/project_detail_page.dart';
+import '../features/survey/presentation/survey_form_page.dart';
+import '../features/survey/presentation/survey_list_page.dart';
+import '../features/visit/presentation/visit_batch_page.dart';
+import '../features/visit/presentation/visit_detail_page.dart';
+import '../features/visit/presentation/visit_execute_page.dart';
+import '../features/visit/presentation/visit_form_page.dart';
+import '../features/visit/presentation/visit_list_page.dart';
+import 'tabs_page.dart';
+
+/// 会话语义变化时驱动 go_router 重新执行 `redirect`。
+class _SessionRefresh extends ChangeNotifier {
+ void bump() => notifyListeners();
+}
+
+/// 路由表,对应 uni-app 版 `pages.json` 的 12 个页面。
+///
+/// 除登录页与 Tab 容器页外,其余均为二级页,统一使用主题里的
+/// `CupertinoPageTransitionsBuilder`(新页面从右侧滑入),
+/// 与源项目 `animationType: slide-in-right` 的表现一致。
+final routerProvider = Provider((ref) {
+ final refresh = _SessionRefresh();
+ ref.listen(sessionProvider, (_, _) => refresh.bump());
+ ref.onDispose(refresh.dispose);
+
+ int? queryId(GoRouterState state) =>
+ int.tryParse(state.uri.queryParameters['id'] ?? '');
+
+ return GoRouter(
+ navigatorKey: AppKeys.rootNavigator,
+ initialLocation: '/login',
+ refreshListenable: refresh,
+ redirect: (context, state) {
+ final loggedIn = ref.read(sessionProvider).isLoggedIn;
+ final atLogin = state.matchedLocation == '/login';
+ if (!loggedIn) return atLogin ? null : '/login';
+ if (atLogin) return '/tabs';
+ return null;
+ },
+ routes: [
+ GoRoute(path: '/login', builder: (context, state) => const LoginPage()),
+ GoRoute(path: '/tabs', builder: (context, state) => const TabsPage()),
+
+ // 客户模块
+ GoRoute(
+ path: '/customer/detail/:id',
+ builder: (context, state) =>
+ CustomerDetailPage(id: int.parse(state.pathParameters['id']!)),
+ ),
+ GoRoute(
+ path: '/customer/edit',
+ builder: (context, state) => CustomerEditPage(id: queryId(state)),
+ ),
+
+ // 拜访模块
+ GoRoute(
+ path: '/visit',
+ builder: (context, state) => const VisitListPage(),
+ ),
+ GoRoute(
+ path: '/visit/detail/:id',
+ builder: (context, state) =>
+ VisitDetailPage(id: int.parse(state.pathParameters['id']!)),
+ ),
+ GoRoute(
+ path: '/visit/form',
+ builder: (context, state) => VisitFormPage(id: queryId(state)),
+ ),
+ GoRoute(
+ path: '/visit/execute/:id',
+ builder: (context, state) =>
+ VisitExecutePage(id: int.parse(state.pathParameters['id']!)),
+ ),
+ GoRoute(
+ path: '/visit/batch',
+ builder: (context, state) => const VisitBatchPage(),
+ ),
+
+ // 勘测模块
+ GoRoute(
+ path: '/survey',
+ builder: (context, state) => const SurveyListPage(),
+ ),
+ GoRoute(
+ path: '/survey/form',
+ builder: (context, state) => SurveyFormPage(
+ id: queryId(state),
+ customerId: int.tryParse(state.uri.queryParameters['customerId'] ?? ''),
+ ),
+ ),
+
+ // 项目模块(按后端 project-plans 真实字段重做)
+ GoRoute(
+ path: '/project/detail/:id',
+ builder: (context, state) =>
+ ProjectDetailPage(id: int.parse(state.pathParameters['id']!)),
+ ),
+ ],
+ );
+});
\ No newline at end of file
diff --git a/lib/app/tab_index.dart b/lib/app/tab_index.dart
new file mode 100644
index 0000000..3ea9466
--- /dev/null
+++ b/lib/app/tab_index.dart
@@ -0,0 +1,31 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+/// 底部导航的四个 Tab,对应 `components/XBottomNav.vue` 的 4 个入口。
+enum AppTab { workbench, customer, project, mine }
+
+extension AppTabKey on AppTab {
+ /// 与 `XBottomNav.items` 中的 key 一致。
+ String get key => switch (this) {
+ AppTab.workbench => 'workbench',
+ AppTab.customer => 'customer',
+ AppTab.project => 'project',
+ AppTab.mine => 'mine',
+ };
+}
+
+/// 当前 Tab。对应 uni-app 版 `utils/tabs.ts` 的 `currentTab` ref + `switchTab`。
+class TabIndexController extends Notifier {
+ @override
+ AppTab build() => AppTab.workbench;
+
+ void select(AppTab tab) => state = tab;
+
+ /// 按 `XBottomNav` 回传的字符串 key 切换。
+ void selectByKey(String key) {
+ state = AppTab.values.firstWhere((t) => t.key == key, orElse: () => state);
+ }
+}
+
+final tabIndexProvider = NotifierProvider(
+ TabIndexController.new,
+);
\ No newline at end of file
diff --git a/lib/app/tabs_page.dart b/lib/app/tabs_page.dart
new file mode 100644
index 0000000..fc429ff
--- /dev/null
+++ b/lib/app/tabs_page.dart
@@ -0,0 +1,52 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../core/theme/app_colors.dart';
+import '../features/customer/presentation/customer_tab.dart';
+import '../features/mine/presentation/mine_tab.dart';
+import '../features/project/presentation/project_tab.dart';
+import '../features/workbench/presentation/workbench_tab.dart';
+import '../shared/widgets/x_bottom_nav.dart';
+import 'tab_index.dart';
+
+/// 4 个 Tab 的常驻容器,对应 `pages/tabs/index.vue`。
+///
+/// 源项目用 `v-show` 切换,不销毁重建、保留滚动位置与内部状态;
+/// 这里用 [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 [
+ WorkbenchTab(),
+ CustomerTab(),
+ ProjectTab(),
+ MineTab(),
+ ],
+ ),
+ ),
+ Positioned(
+ left: 0,
+ right: 0,
+ bottom: 0,
+ child: XBottomNav(
+ current: current.key,
+ onChanged: (key) =>
+ ref.read(tabIndexProvider.notifier).selectByKey(key),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
\ No newline at end of file
diff --git a/lib/core/app_keys.dart b/lib/core/app_keys.dart
new file mode 100644
index 0000000..d53bc27
--- /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();
+}
\ No newline at end of file
diff --git a/lib/core/env.dart b/lib/core/env.dart
new file mode 100644
index 0000000..88fe981
--- /dev/null
+++ b/lib/core/env.dart
@@ -0,0 +1,10 @@
+/// 运行期配置。
+///
+/// 与 uni-app 版 `utils/request.ts` 的地址策略一致:App 端直连后端。
+/// 真机联调时用 `--dart-define=API_BASE_URL=http://<局域网IP>:7001/api` 覆盖默认值。
+abstract final class Env {
+ static const String apiBaseUrl = String.fromEnvironment(
+ 'API_BASE_URL',
+ defaultValue: 'http://192.168.3.29:7001/api',
+ );
+}
\ No newline at end of file
diff --git a/lib/core/network/api_client.dart b/lib/core/network/api_client.dart
new file mode 100644
index 0000000..5d63a71
--- /dev/null
+++ b/lib/core/network/api_client.dart
@@ -0,0 +1,242 @@
+import 'dart:async';
+import 'dart:convert';
+
+import 'package:dio/dio.dart';
+
+import '../env.dart';
+import '../storage/token_storage.dart';
+import 'api_response.dart';
+
+/// 图片上传结果,对应 uni-app 版 `api/upload.ts` 的 `UploadResult`。
+class UploadResult {
+ const UploadResult({
+ required this.key,
+ required this.url,
+ required this.name,
+ required this.size,
+ });
+
+ /// 对象存储 key,用于持久化到业务表
+ final String key;
+
+ /// 预签名访问 URL,用于即时预览
+ final String url;
+ final String name;
+ final int size;
+
+ factory UploadResult.fromJson(Map json) => UploadResult(
+ key: json['key'] as String? ?? '',
+ url: json['url'] as String? ?? '',
+ name: json['name'] as String? ?? '',
+ size: (json['size'] as num?)?.toInt() ?? 0,
+ );
+}
+
+/// 统一请求封装,等价于 uni-app 版 `utils/request.ts`:
+/// - 自动注入 `Authorization: Bearer `
+/// - 解析 `{ code, message, data }`,非 200 也照常返回给调用方判断
+/// - 401 且提示「过期」时刷新 token 并重试一次,其余 401 直接登出
+class ApiClient {
+ ApiClient({required TokenStorage storage, Dio? dio})
+ : _storage = storage,
+ _dio =
+ dio ??
+ Dio(
+ BaseOptions(
+ baseUrl: Env.apiBaseUrl,
+ 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;
+
+ Dio get raw => _dio;
+
+ // ---- 对外请求方法 ----
+
+ 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);
+
+ Future> delete(
+ String url, {
+ T Function(dynamic data)? parse,
+ bool needAuth = true,
+ }) => _send('DELETE', url, parse: parse, needAuth: needAuth);
+
+ /// 上传现场照片到对象存储(multipart/form-data,字段名 `file`)。
+ /// 严格依赖对象存储:失败直接抛异常,由调用方阻断保存并提示用户。
+ Future uploadImage(String filePath) async {
+ try {
+ final form = FormData.fromMap({
+ 'file': await MultipartFile.fromFile(filePath),
+ });
+ final res = await _dio.post(
+ '/upload/image',
+ data: form,
+ options: Options(headers: _authHeader()),
+ );
+ final json = _asMap(res.data);
+ final code = (json['code'] as num?)?.toInt();
+ final data = json['data'];
+ if (code == 200 && data is Map) {
+ return UploadResult.fromJson(data.cast());
+ }
+ throw ApiException(
+ (json['message'] as String?) ?? '图片上传失败',
+ code: code,
+ );
+ } on ApiException {
+ rethrow;
+ } catch (_) {
+ throw const ApiException('图片上传失败:网络异常');
+ }
+ }
+
+ void dispose() => _unauthorizedController.close();
+
+ // ---- 内部实现 ----
+
+ Future> _send(
+ String method,
+ String url, {
+ Map? query,
+ Object? body,
+ T Function(dynamic data)? parse,
+ required bool needAuth,
+ bool allowRetry = true,
+ }) 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 json = _asMap(res.data);
+ final response = ApiResponse.fromJson(json, parse);
+
+ if (response.code == 401) {
+ final expired = response.message.contains('过期');
+ if (expired && allowRetry) {
+ final refreshed = await _refreshToken();
+ if (refreshed) {
+ return _send(
+ method,
+ url,
+ query: query,
+ body: body,
+ parse: parse,
+ needAuth: needAuth,
+ allowRetry: false,
+ );
+ }
+ }
+ _unauthorizedController.add(null);
+ throw const ApiException('登录已失效,请重新登录', code: 401);
+ }
+
+ return response;
+ }
+
+ Map _authHeader() {
+ final token = _storage.accessToken;
+ return token.isEmpty ? const {} : {'Authorization': 'Bearer $token'};
+ }
+
+ Future? _refreshing;
+
+ /// 刷新 accessToken;并发调用共用同一个请求,避免刷新风暴。
+ Future _refreshToken() {
+ return _refreshing ??= _doRefresh().whenComplete(() => _refreshing = null);
+ }
+
+ Future _doRefresh() async {
+ final refreshToken = _storage.refreshToken;
+ if (refreshToken.isEmpty) return false;
+ try {
+ final res = await _dio.post(
+ '/auth/refresh',
+ data: {'refreshToken': refreshToken},
+ );
+ final json = _asMap(res.data);
+ final data = json['data'];
+ if ((json['code'] as num?)?.toInt() == 200 && data is Map) {
+ final token = data['accessToken'] as String?;
+ if (token != null && token.isNotEmpty) {
+ await _storage.setAccessToken(token);
+ return true;
+ }
+ }
+ } catch (_) {
+ // 刷新失败按会话失效处理
+ }
+ return false;
+ }
+
+ 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;
+ }
+}
\ No newline at end of file
diff --git a/lib/core/network/api_response.dart b/lib/core/network/api_response.dart
new file mode 100644
index 0000000..a770b54
--- /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;
+}
\ No newline at end of file
diff --git a/lib/core/providers.dart b/lib/core/providers.dart
new file mode 100644
index 0000000..3c06152
--- /dev/null
+++ b/lib/core/providers.dart
@@ -0,0 +1,15 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import 'network/api_client.dart';
+import 'storage/token_storage.dart';
+
+/// 在 `main()` 中读取 SharedPreferences 后通过 override 注入。
+final tokenStorageProvider = Provider(
+ (ref) => throw UnimplementedError('tokenStorageProvider 必须在 ProviderScope 中 override'),
+);
+
+final apiClientProvider = Provider((ref) {
+ final client = ApiClient(storage: ref.watch(tokenStorageProvider));
+ ref.onDispose(client.dispose);
+ return client;
+});
\ No newline at end of file
diff --git a/lib/core/storage/token_storage.dart b/lib/core/storage/token_storage.dart
new file mode 100644
index 0000000..1630b97
--- /dev/null
+++ b/lib/core/storage/token_storage.dart
@@ -0,0 +1,49 @@
+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 _kRefreshToken = 'refreshToken';
+ static const _kUserInfo = 'userInfo';
+
+ static Future open() async =>
+ TokenStorage(await SharedPreferences.getInstance());
+
+ String get accessToken => _prefs.getString(_kAccessToken) ?? '';
+
+ String get refreshToken => _prefs.getString(_kRefreshToken) ?? '';
+
+ 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 setRefreshToken(String token) =>
+ _prefs.setString(_kRefreshToken, token);
+
+ Future setUserInfo(Map info) =>
+ _prefs.setString(_kUserInfo, jsonEncode(info));
+
+ Future clearSession() async {
+ await _prefs.remove(_kAccessToken);
+ await _prefs.remove(_kRefreshToken);
+ await _prefs.remove(_kUserInfo);
+ }
+
+ bool get hasSession => accessToken.isNotEmpty;
+}
\ No newline at end of file
diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart
new file mode 100644
index 0000000..f4663da
--- /dev/null
+++ b/lib/core/theme/app_colors.dart
@@ -0,0 +1,103 @@
+import 'package:flutter/material.dart';
+
+/// 设计令牌 - 色彩。
+/// 逐项对应 uni-app 版 `src/styles/variables.scss` 中的 `:root` CSS 变量。
+abstract final class AppColors {
+ // Primary
+ static const primary = Color(0xFF0054CD);
+ static const primaryContainer = Color(0xFF136BFB);
+ static const onPrimary = Color(0xFFFFFFFF);
+ static const primaryFixed = Color(0xFFDAE2FF);
+ static const primaryFixedDim = Color(0xFFB2C5FF);
+ static const onPrimaryFixed = Color(0xFF001847);
+ static const onPrimaryFixedVariant = Color(0xFF0040A1);
+
+ // Secondary
+ static const secondary = Color(0xFF712AE2);
+ static const secondaryContainer = Color(0xFF8A4CFC);
+ static const onSecondary = Color(0xFFFFFFFF);
+ static const secondaryFixed = Color(0xFFEADDFF);
+ static const secondaryFixedDim = Color(0xFFD2BBFF);
+ static const onSecondaryFixed = Color(0xFF25005A);
+ static const onSecondaryFixedVariant = Color(0xFF5A00C6);
+
+ // Tertiary
+ static const tertiary = Color(0xFF00685F);
+ static const tertiaryContainer = Color(0xFF008378);
+ static const onTertiary = Color(0xFFFFFFFF);
+ static const tertiaryFixed = Color(0xFF89F5E7);
+ static const tertiaryFixedDim = Color(0xFF6BD8CB);
+ static const onTertiaryFixed = Color(0xFF00201D);
+ static const onTertiaryFixedVariant = Color(0xFF005049);
+
+ // 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(0xFFFFFFFF);
+ static const surfaceBright = Color(0xFFF8F9FF);
+ static const surfaceDim = Color(0xFFCCDBF1);
+ static const surfaceContainer = Color(0xFFE5EFFF);
+ static const surfaceContainerLowest = Color(0xFFFFFFFF);
+ static const surfaceContainerLow = Color(0xFFEEF4FF);
+ static const surfaceContainerHigh = Color(0xFFDBE9FF);
+ static const surfaceContainerHighest = Color(0xFFD5E4FA);
+ static const surfaceVariant = Color(0xFFD5E4FA);
+
+ // Background
+ static const bgPage = Color(0xFFEEF3F8);
+ static const background = Color(0xFFF8F9FF);
+
+ // Text
+ static const onSurface = Color(0xFF0D1C2C);
+ static const onSurfaceVariant = Color(0xFF424655);
+ static const textMuted = Color(0xFF5B6B7E);
+ static const textPlaceholder = Color(0xFF9AA3B2);
+
+ // Outline
+ static const outline = Color(0xFF727787);
+ static const outlineVariant = Color(0xFFC2C6D8);
+ static const borderRule = Color(0xFFDBE4EE);
+
+ // Status
+ static const statusWarn = Color(0xFFE0852B);
+ static const statusError = Color(0xFFDC2626);
+
+ // Inverse
+ static const inverseSurface = Color(0xFF233142);
+ static const inverseOnSurface = Color(0xFFE9F1FF);
+ static const inversePrimary = Color(0xFFB2C5FF);
+
+ // Accent
+ static const accentSubtle = Color(0x141D6FFF);
+
+ // 登录页品牌图标渐变
+ static const brandGradientStart = Color(0xFF1D6FFF);
+ static const brandGradientEnd = Color(0xFF2563EB);
+}
+
+/// chip 语义样式:背景色 + 前景色,对应全局样式 `.chip-*` 系列。
+enum ChipTone { primary, secondary, tertiary, warn, error, muted }
+
+extension ChipToneStyle on ChipTone {
+ Color get foreground => switch (this) {
+ ChipTone.primary => AppColors.primary,
+ ChipTone.secondary => AppColors.secondary,
+ ChipTone.tertiary => AppColors.tertiary,
+ ChipTone.warn => AppColors.statusWarn,
+ ChipTone.error => AppColors.statusError,
+ ChipTone.muted => AppColors.onSurfaceVariant,
+ };
+
+ Color get background => switch (this) {
+ ChipTone.primary => AppColors.accentSubtle,
+ ChipTone.secondary => const Color(0x14712AE2),
+ ChipTone.tertiary => const Color(0x1400685F),
+ ChipTone.warn => const Color(0x14E0852B),
+ ChipTone.error => const Color(0x14DC2626),
+ ChipTone.muted => AppColors.surfaceVariant,
+ };
+}
\ No newline at end of file
diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart
new file mode 100644
index 0000000..b2b1512
--- /dev/null
+++ b/lib/core/theme/app_dimens.dart
@@ -0,0 +1,32 @@
+import 'package:flutter/painting.dart';
+
+/// 设计令牌 - 间距与圆角。对应 `variables.scss` 的 `--spacing-*` / `--radius-*`。
+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;
+ static const double gutter = 16;
+}
+
+abstract final class AppRadius {
+ static const double sm = 4;
+ static const double regular = 8;
+ static const double lg = 12;
+ static const double xl = 24;
+ static const double full = 9999;
+}
+
+abstract final class AppShadows {
+ /// 全局 `.card` 阴影:0 4px 14px rgba(0,0,0,0.07)
+ static const card = [
+ BoxShadow(color: Color(0x12000000), blurRadius: 14, offset: Offset(0, 4)),
+ ];
+
+ /// 底部导航条:0 8px 24px rgba(0,0,0,0.10)
+ static const bottomNav = [
+ BoxShadow(color: Color(0x1A000000), blurRadius: 24, offset: Offset(0, 8)),
+ ];
+}
\ No newline at end of file
diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart
new file mode 100644
index 0000000..280f9df
--- /dev/null
+++ b/lib/core/theme/app_theme.dart
@@ -0,0 +1,71 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+
+import 'app_colors.dart';
+
+abstract final class AppTheme {
+ /// 全平台统一使用 iOS 风格「新页面从右侧滑入」转场,与 uni-app 版
+ /// pages.json 中 `animationType: slide-in-right` 的表现对齐。
+ 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.surface,
+ 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: 1,
+ 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: 20,
+ height: 26 / 20,
+ fontWeight: FontWeight.w700,
+ color: AppColors.onSurface,
+ ),
+ ),
+ splashFactory: InkSparkle.splashFactory,
+ tooltipTheme: const TooltipThemeData(waitDuration: Duration(milliseconds: 400)),
+ );
+ }
+}
\ No newline at end of file
diff --git a/lib/core/theme/app_typography.dart b/lib/core/theme/app_typography.dart
new file mode 100644
index 0000000..af3d786
--- /dev/null
+++ b/lib/core/theme/app_typography.dart
@@ -0,0 +1,93 @@
+import 'package:flutter/material.dart';
+
+import 'app_colors.dart';
+
+/// 设计令牌 - 字号 / 行高 / 字重。
+/// 逐项对应 uni-app 版 `src/styles/variables.scss` 中的 `$font-*` 变量,
+/// 行高统一换算成 Flutter 的 `height`(行高 ÷ 字号)。
+abstract final class AppTypography {
+ static const _sansFallback = [
+ 'PingFang SC',
+ 'Microsoft YaHei',
+ 'Noto Sans CJK SC',
+ ];
+
+ static const _monoFallback = [
+ 'JetBrains Mono',
+ 'SF Mono',
+ 'Menlo',
+ 'Consolas',
+ ];
+
+ /// 12 / 14 / w700
+ static const captionSm = TextStyle(
+ fontSize: 12,
+ height: 14 / 12,
+ fontWeight: FontWeight.w700,
+ fontFamilyFallback: _sansFallback,
+ );
+
+ /// 17 / 24 / w700
+ static const titleSm = TextStyle(
+ fontSize: 17,
+ height: 24 / 17,
+ fontWeight: FontWeight.w700,
+ fontFamilyFallback: _sansFallback,
+ );
+
+ /// 15 / 24 / w400
+ static const bodyMd = TextStyle(
+ fontSize: 15,
+ height: 24 / 15,
+ fontWeight: FontWeight.w400,
+ fontFamilyFallback: _sansFallback,
+ );
+
+ /// 13 / 16 / w700
+ static const labelBold = TextStyle(
+ fontSize: 13,
+ height: 16 / 13,
+ fontWeight: FontWeight.w700,
+ fontFamilyFallback: _sansFallback,
+ );
+
+ /// 12 / 16 / w500,等宽数字场景(编号、金额、时间)
+ static const dataMono = TextStyle(
+ fontSize: 12,
+ height: 16 / 12,
+ fontWeight: FontWeight.w500,
+ fontFamilyFallback: _monoFallback,
+ );
+
+ /// 23 / 28 / w700
+ static const headlineMd = TextStyle(
+ fontSize: 23,
+ height: 28 / 23,
+ fontWeight: FontWeight.w700,
+ fontFamilyFallback: _sansFallback,
+ );
+
+ /// 20 / 26 / w700
+ static const headlineMdMobile = TextStyle(
+ fontSize: 20,
+ height: 26 / 20,
+ fontWeight: FontWeight.w700,
+ fontFamilyFallback: _sansFallback,
+ );
+
+ /// 32 / 40 / w700
+ static const displayLg = TextStyle(
+ fontSize: 32,
+ height: 40 / 32,
+ fontWeight: FontWeight.w700,
+ fontFamilyFallback: _sansFallback,
+ );
+}
+
+/// 全局默认文字样式,对应 App.vue 里 `page` 上的字体设置。
+TextStyle get appDefaultTextStyle => const TextStyle(
+ fontSize: 15,
+ height: 1.6,
+ color: AppColors.onSurface,
+ fontFamilyFallback: AppTypography._sansFallback,
+);
\ No newline at end of file
diff --git a/lib/core/utils/format_util.dart b/lib/core/utils/format_util.dart
new file mode 100644
index 0000000..d223e6a
--- /dev/null
+++ b/lib/core/utils/format_util.dart
@@ -0,0 +1,46 @@
+/// 日期格式化工具,对应 uni-app 版 `utils/format.ts`(拜访模块多页面复用)。
+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 formatDateLabel(Object? v) {
+ final d = parseDate(v);
+ if (d == null) return '-';
+ final now = DateTime.now();
+ final a = DateTime(d.year, d.month, d.day);
+ final b = DateTime(now.year, now.month, now.day);
+ final diff = a.difference(b).inDays;
+ if (diff == 0) return '今日';
+ if (diff == 1) return '明日';
+ if (diff == -1) return '昨日';
+ return formatDate(d);
+}
+
+/// 今天的 `YYYY-MM-DD`
+String todayStr() => formatDate(DateTime.now());
+
+/// 当前时刻的 `YYYY-MM-DD HH:mm`
+String nowDateTimeStr() => formatDateTime(DateTime.now());
\ No newline at end of file
diff --git a/lib/core/utils/id_util.dart b/lib/core/utils/id_util.dart
new file mode 100644
index 0000000..a93260e
--- /dev/null
+++ b/lib/core/utils/id_util.dart
@@ -0,0 +1,17 @@
+/// 后端主键为 BIGINT,经 JSON 序列化后是字符串(如 `"1"`)。
+/// 统一归一化为 int,避免与表单/路由里的 int 型 id 做比较时失效。
+/// 对应 uni-app 版 `utils/request.ts` 的 `toNumId`。
+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;
\ No newline at end of file
diff --git a/lib/features/auth/application/session_controller.dart b/lib/features/auth/application/session_controller.dart
new file mode 100644
index 0000000..1010e89
--- /dev/null
+++ b/lib/features/auth/application/session_controller.dart
@@ -0,0 +1,95 @@
+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.user, required this.isLoggedIn});
+
+ final UserInfo? user;
+ final bool isLoggedIn;
+
+ SessionState copyWith({UserInfo? user, bool? isLoggedIn, bool clearUser = false}) =>
+ SessionState(
+ user: clearUser ? null : (user ?? this.user),
+ isLoggedIn: isLoggedIn ?? this.isLoggedIn,
+ );
+}
+
+/// 登录态,对应 uni-app 版 `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(
+ user: cached == null ? null : UserInfo.fromJson(cached),
+ isLoggedIn: _storage.hasSession,
+ );
+ }
+
+ Future login({
+ required String username,
+ required String password,
+ }) async {
+ final res = await ref
+ .read(authApiProvider)
+ .login(username: username, password: password);
+ if (!res.isOk || res.data == null) return false;
+
+ final data = res.data!;
+ await _storage.setAccessToken(data.accessToken);
+ if (data.refreshToken.isNotEmpty) {
+ await _storage.setRefreshToken(data.refreshToken);
+ }
+ final user = UserInfo(
+ id: data.userInfo.id,
+ username: data.userInfo.username,
+ realName: data.userInfo.realName,
+ roles: data.roles,
+ );
+ await _storage.setUserInfo(user.toJson());
+ state = SessionState(user: user, isLoggedIn: true);
+ return true;
+ }
+
+ Future fetchUserInfo() async {
+ try {
+ final res = await ref.read(authApiProvider).currentUser();
+ if (res.isOk && res.data != null) {
+ await _storage.setUserInfo(res.data!.toJson());
+ state = SessionState(user: res.data, isLoggedIn: true);
+ return true;
+ }
+ } catch (_) {
+ // 拉取失败保持原状态
+ }
+ return false;
+ }
+
+ Future logout() async {
+ await _storage.clearSession();
+ state = const SessionState(isLoggedIn: false, user: null);
+ }
+
+ /// 清空本地会话但不触发网络请求(被拦截器判定会话失效时调用)。
+ Future clearLocal() => logout();
+}
+
+final sessionProvider = NotifierProvider(
+ SessionController.new,
+);
\ No newline at end of file
diff --git a/lib/features/auth/data/auth_api.dart b/lib/features/auth/data/auth_api.dart
new file mode 100644
index 0000000..b5a7573
--- /dev/null
+++ b/lib/features/auth/data/auth_api.dart
@@ -0,0 +1,27 @@
+import '../../../core/network/api_client.dart';
+import '../../../core/network/api_response.dart';
+import 'auth_models.dart';
+
+/// 认证相关接口,对应 uni-app 版 `api/auth.ts`。
+class AuthApi {
+ AuthApi(this._client);
+
+ final ApiClient _client;
+
+ Future> login({
+ required String username,
+ required String password,
+ }) => _client.post(
+ '/auth/login',
+ body: {'username': username, 'password': password},
+ parse: (d) => LoginResult.fromJson((d as Map).cast()),
+ needAuth: false,
+ );
+
+ Future> currentUser() => _client.get(
+ '/auth/currentUser',
+ parse: (d) => UserInfo.fromJson((d as Map).cast()),
+ );
+
+ Future> logout() => _client.post('/auth/logout');
+}
\ No newline at end of file
diff --git a/lib/features/auth/data/auth_models.dart b/lib/features/auth/data/auth_models.dart
new file mode 100644
index 0000000..af083fe
--- /dev/null
+++ b/lib/features/auth/data/auth_models.dart
@@ -0,0 +1,80 @@
+import '../../../core/utils/id_util.dart';
+
+/// 登录态用户信息,对应 `types/auth.ts` 的 `UserInfo`。
+class UserInfo {
+ const UserInfo({
+ required this.id,
+ required this.username,
+ required this.realName,
+ required this.roles,
+ });
+
+ final int id;
+ final String username;
+ final String realName;
+ final List roles;
+
+ String get displayName => realName.isNotEmpty ? realName : username;
+
+ /// 头像占位文字:取显示名首个字符(按 Unicode 码点截取,兼容中文)。
+ String get avatarText {
+ if (displayName.isEmpty) return '?';
+ return String.fromCharCode(displayName.runes.first);
+ }
+
+ bool get isAdmin => roles.contains('ADMIN');
+
+ factory UserInfo.fromJson(Map json) => UserInfo(
+ id: toIntOr(json['id'], 0),
+ username: json['username'] as String? ?? '',
+ realName: json['realName'] as String? ?? '',
+ roles: (json['roles'] as List?)?.map((e) => e.toString()).toList() ?? const [],
+ );
+
+ Map toJson() => {
+ 'id': id,
+ 'username': username,
+ 'realName': realName,
+ 'roles': roles,
+ };
+}
+
+/// 登录接口返回体,对应 `types/auth.ts` 的 `LoginResponse`。
+class LoginResult {
+ const LoginResult({
+ required this.accessToken,
+ required this.refreshToken,
+ required this.userInfo,
+ required this.roles,
+ });
+
+ final String accessToken;
+ final String refreshToken;
+ final UserInfo userInfo;
+ final List roles;
+
+ factory LoginResult.fromJson(Map json) => LoginResult(
+ accessToken: json['accessToken'] as String? ?? '',
+ refreshToken: json['refreshToken'] as String? ?? '',
+ userInfo: UserInfo.fromJson(
+ (json['userInfo'] as Map?)?.cast() ?? const {},
+ ),
+ roles: (json['roles'] as List?)?.map((e) => e.toString()).toList() ?? const [],
+ );
+}
+
+/// 后端角色编码 → 中文名称映射。
+const roleLabelMap = {
+ 'ADMIN': '运营管理员',
+ 'SALESMAN': '业务员',
+ 'WORKER': '共享电工',
+ 'CUSTOMER': '客户',
+};
+
+/// 将角色编码数组映射为可读文本(多个角色以 / 分隔)。
+String roleLabel(List? roles) {
+ if (roles != null && roles.isNotEmpty) {
+ return roles.map((r) => roleLabelMap[r] ?? r).join(' / ');
+ }
+ return '运营管理员';
+}
\ No newline at end of file
diff --git a/lib/features/auth/presentation/login_page.dart b/lib/features/auth/presentation/login_page.dart
new file mode 100644
index 0000000..3351a12
--- /dev/null
+++ b/lib/features/auth/presentation/login_page.dart
@@ -0,0 +1,279 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+
+import '../../../core/theme/app_colors.dart';
+import '../../../core/theme/app_dimens.dart';
+import '../../../core/theme/app_typography.dart';
+import '../../../shared/widgets/x_card.dart';
+import '../../../shared/widgets/x_common.dart';
+import '../../../shared/widgets/x_icon.dart';
+import '../application/session_controller.dart';
+
+/// 登录页,对应 `pages/login/index.vue`。
+class LoginPage extends ConsumerStatefulWidget {
+ const LoginPage({super.key});
+
+ @override
+ ConsumerState createState() => _LoginPageState();
+}
+
+class _LoginPageState extends ConsumerState {
+ final _username = TextEditingController();
+ final _password = TextEditingController();
+
+ bool _loading = false;
+ String _errorMsg = '';
+
+ @override
+ void dispose() {
+ _username.dispose();
+ _password.dispose();
+ super.dispose();
+ }
+
+ Future _handleLogin() async {
+ final username = _username.text.trim();
+ final password = _password.text;
+
+ if (username.isEmpty || password.isEmpty) {
+ setState(() => _errorMsg = '请输入账号和密码');
+ return;
+ }
+
+ setState(() {
+ _loading = true;
+ _errorMsg = '';
+ });
+
+ try {
+ final ok = await ref
+ .read(sessionProvider.notifier)
+ .login(username: username, password: password);
+ if (!mounted) return;
+ if (ok) {
+ context.go('/tabs');
+ } else {
+ setState(() => _errorMsg = '登录失败,请检查账号密码');
+ }
+ } catch (e) {
+ if (!mounted) return;
+ setState(() => _errorMsg = e.toString());
+ } finally {
+ if (mounted) setState(() => _loading = false);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ backgroundColor: AppColors.bgPage,
+ body: Stack(
+ children: [
+ // 源页面右上/左下的两处柔光渐变
+ const Positioned(
+ right: -80,
+ top: -80,
+ child: _SoftBlob(color: Color(0x0F0054CD), size: 280),
+ ),
+ const Positioned(
+ left: -80,
+ bottom: -80,
+ child: _SoftBlob(color: Color(0x0D712AE2), size: 280),
+ ),
+ LayoutBuilder(
+ builder: (context, constraints) => SingleChildScrollView(
+ padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 40),
+ child: ConstrainedBox(
+ constraints: BoxConstraints(
+ minHeight: (constraints.maxHeight - 80).clamp(0, double.infinity),
+ ),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ _buildBrand(),
+ const SizedBox(height: 40),
+ _buildForm(),
+ const SizedBox(height: 40),
+ Text(
+ '© 鑫潼智电 · 运营管理',
+ style: const TextStyle(
+ fontSize: 12,
+ color: AppColors.textMuted,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildBrand() {
+ return Column(
+ children: [
+ Container(
+ width: 64,
+ height: 64,
+ decoration: BoxDecoration(
+ gradient: const LinearGradient(
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ colors: [AppColors.brandGradientStart, AppColors.brandGradientEnd],
+ ),
+ borderRadius: BorderRadius.circular(20),
+ boxShadow: const [
+ BoxShadow(
+ color: Color(0x401D6FFF),
+ blurRadius: 20,
+ offset: Offset(0, 8),
+ ),
+ ],
+ ),
+ alignment: Alignment.center,
+ child: const XIcon('bolt', size: 34, color: Colors.white),
+ ),
+ const SizedBox(height: 16),
+ Text(
+ '鑫潼智电',
+ style: AppTypography.headlineMd.copyWith(
+ fontSize: 26,
+ letterSpacing: 2,
+ ),
+ ),
+ const SizedBox(height: 4),
+ const Text(
+ '鑫潼智电运营管理平台',
+ style: TextStyle(fontSize: 14, color: AppColors.textMuted),
+ ),
+ ],
+ );
+ }
+
+ Widget _buildForm() {
+ return XCard(
+ padding: const EdgeInsets.all(24),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ _buildField(
+ label: '账号',
+ icon: 'person',
+ controller: _username,
+ hintText: '请输入账号',
+ obscure: false,
+ ),
+ const SizedBox(height: 18),
+ _buildField(
+ label: '密码',
+ icon: 'more',
+ controller: _password,
+ hintText: '请输入密码',
+ obscure: true,
+ ),
+ const SizedBox(height: 8),
+ XPrimaryButton(
+ label: '登 录',
+ loading: _loading,
+ onPressed: _handleLogin,
+ ),
+ if (_errorMsg.isNotEmpty)
+ Padding(
+ padding: const EdgeInsets.only(top: 14),
+ child: Text(
+ _errorMsg,
+ textAlign: TextAlign.center,
+ style: const TextStyle(
+ fontSize: 13,
+ color: AppColors.statusError,
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildField({
+ required String label,
+ required String icon,
+ required TextEditingController controller,
+ required String hintText,
+ required bool obscure,
+ }) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ label,
+ style: AppTypography.labelBold.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.borderRule),
+ borderRadius: BorderRadius.circular(AppRadius.lg),
+ ),
+ child: Row(
+ children: [
+ XIcon(icon, size: 20, color: AppColors.textMuted),
+ const SizedBox(width: 10),
+ Expanded(
+ child: TextField(
+ controller: controller,
+ obscureText: obscure,
+ 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,
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+/// 登录页背景的柔光圆斑(对应源页面两处 radial-gradient)。
+class _SoftBlob extends StatelessWidget {
+ const _SoftBlob({required this.color, required this.size});
+
+ final Color color;
+ final double size;
+
+ @override
+ Widget build(BuildContext context) {
+ return IgnorePointer(
+ child: Container(
+ width: size,
+ height: size,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ gradient: RadialGradient(
+ colors: [color, color.withValues(alpha: 0)],
+ ),
+ ),
+ ),
+ );
+ }
+}
\ No newline at end of file
diff --git a/lib/features/customer/data/customer_api.dart b/lib/features/customer/data/customer_api.dart
new file mode 100644
index 0000000..98f1457
--- /dev/null
+++ b/lib/features/customer/data/customer_api.dart
@@ -0,0 +1,77 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../../../core/network/api_client.dart';
+import '../../../core/network/api_response.dart';
+import '../../../core/providers.dart';
+import 'customer_models.dart';
+
+final customerApiProvider = Provider(
+ (ref) => CustomerApi(ref.watch(apiClientProvider)),
+);
+
+/// 客户档案接口,对应 uni-app 版 `api/customer.ts`。
+class CustomerApi {
+ CustomerApi(this._client);
+
+ final ApiClient _client;
+
+ Future> list({
+ int page = 1,
+ int pageSize = 100,
+ String? keyword,
+ int? status,
+ int? salesmanId,
+ }) => _client.get(
+ '/customers',
+ query: {
+ 'page': page,
+ 'pageSize': pageSize,
+ 'keyword': keyword,
+ 'status': status,
+ 'salesmanId': salesmanId,
+ },
+ parse: (d) {
+ final m = (d as Map).cast();
+ return CustomerPage(
+ list: (m['list'] as List? ?? [])
+ .map((e) => Customer.fromJson((e as Map).cast()))
+ .toList(),
+ total: (m['total'] as num?)?.toInt() ?? 0,
+ page: (m['page'] as num?)?.toInt() ?? 1,
+ pageSize: (m['pageSize'] as num?)?.toInt() ?? pageSize,
+ );
+ },
+ );
+
+ Future> detail(int id) => _client.get(
+ '/customers/$id',
+ parse: (d) => Customer.fromJson((d as Map).cast()),
+ );
+
+ Future> create(Map body) =>
+ _client.post(
+ '/customers',
+ body: body,
+ parse: (d) => Customer.fromJson((d as Map).cast()),
+ );
+
+ Future> update(int id, Map body) =>
+ _client.put(
+ '/customers/$id',
+ body: body,
+ parse: (d) => Customer.fromJson((d as Map).cast()),
+ );
+
+ Future> delete(int id) => _client.delete('/customers/$id');
+
+ Future> updateStatus(int id, int status) =>
+ _client.put('/customers/$id/status', body: {'status': status});
+
+ Future>> options() =>
+ _client.get>(
+ '/customers/options/all',
+ parse: (d) => (d as List)
+ .map((e) => CustomerOption.fromJson((e as Map).cast()))
+ .toList(),
+ );
+}
\ No newline at end of file
diff --git a/lib/features/customer/data/customer_models.dart b/lib/features/customer/data/customer_models.dart
new file mode 100644
index 0000000..0a411c5
--- /dev/null
+++ b/lib/features/customer/data/customer_models.dart
@@ -0,0 +1,143 @@
+import 'package:flutter/painting.dart';
+
+import '../../../core/theme/app_colors.dart';
+import '../../../core/utils/id_util.dart';
+
+/// 客户档案,与后端 `/api/customers` 契约对齐(`types/customer.ts`)。
+class Customer {
+ const Customer({
+ required this.id,
+ required this.customerNo,
+ required this.customerName,
+ required this.province,
+ required this.city,
+ required this.district,
+ required this.address,
+ required this.contactPerson,
+ required this.contactPhone,
+ required this.postalCode,
+ required this.fax,
+ required this.customerLevel,
+ required this.status,
+ this.salesmanId,
+ this.createdAt,
+ this.updatedAt,
+ });
+
+ final int id;
+ final String customerNo;
+ final String customerName;
+ final String province;
+ final String city;
+ final String district;
+ final String address;
+ final String contactPerson;
+ final String contactPhone;
+ final String postalCode;
+ final String fax;
+ final String customerLevel;
+ final int status;
+ final int? salesmanId;
+ final String? createdAt;
+ final String? updatedAt;
+
+ factory Customer.fromJson(Map json) => Customer(
+ id: toIntOr(json['id'], 0),
+ customerNo: json['customerNo'] as String? ?? '',
+ customerName: json['customerName'] as String? ?? '',
+ province: json['province'] as String? ?? '',
+ city: json['city'] as String? ?? '',
+ district: json['district'] as String? ?? '',
+ address: json['address'] as String? ?? '',
+ contactPerson: json['contactPerson'] as String? ?? '',
+ contactPhone: json['contactPhone'] as String? ?? '',
+ postalCode: json['postalCode'] as String? ?? '',
+ fax: json['fax'] as String? ?? '',
+ customerLevel: json['customerLevel'] as String? ?? '',
+ status: toIntOr(json['status'], 1),
+ salesmanId: toIntOrNull(json['salesmanId']),
+ createdAt: json['created_at'] as String?,
+ updatedAt: json['updated_at'] as String?,
+ );
+
+ Map toJson() => {
+ 'id': id,
+ 'customerNo': customerNo,
+ 'customerName': customerName,
+ 'province': province,
+ 'city': city,
+ 'district': district,
+ 'address': address,
+ 'contactPerson': contactPerson,
+ 'contactPhone': contactPhone,
+ 'postalCode': postalCode,
+ 'fax': fax,
+ 'customerLevel': customerLevel,
+ 'status': status,
+ if (salesmanId != null) 'salesmanId': salesmanId,
+ };
+
+ /// 列表卡片右侧的「地区」展示:省市拼接,缺失时提示未填写。
+ String get locationText {
+ final text = '$province$city';
+ return text.isEmpty ? '未填写地区' : text;
+ }
+}
+
+/// 客户下拉选项(拜访/客户关系选择用)。
+class CustomerOption {
+ const CustomerOption({
+ required this.id,
+ required this.customerNo,
+ required this.customerName,
+ required this.status,
+ });
+
+ final int id;
+ final String customerNo;
+ final String customerName;
+ final int status;
+
+ factory CustomerOption.fromJson(Map json) => CustomerOption(
+ id: toIntOr(json['id'], 0),
+ customerNo: json['customerNo'] as String? ?? '',
+ customerName: json['customerName'] as String? ?? '',
+ status: toIntOr(json['status'], 1),
+ );
+}
+
+class CustomerPage {
+ const CustomerPage({
+ required this.list,
+ required this.total,
+ required this.page,
+ required this.pageSize,
+ });
+
+ final List list;
+ final int total;
+ final int page;
+ final int pageSize;
+}
+
+/// 客户状态:1 意向 2 签约 3 运维中 4 终止
+const customerStatusMap = {
+ 1: (text: '意向', tone: ChipTone.warn),
+ 2: (text: '签约', tone: ChipTone.primary),
+ 3: (text: '运维中', tone: ChipTone.tertiary),
+ 4: (text: '终止', tone: ChipTone.error),
+};
+
+String customerStatusText(int status) => customerStatusMap[status]?.text ?? '未知';
+
+ChipTone customerStatusTone(int status) =>
+ customerStatusMap[status]?.tone ?? ChipTone.muted;
+
+/// 列表卡片左侧色条颜色。
+Color customerStatusBarColor(int status) => switch (status) {
+ 1 => AppColors.statusWarn,
+ 2 => AppColors.primary,
+ 3 => AppColors.tertiaryContainer,
+ 4 => AppColors.statusError,
+ _ => AppColors.borderRule,
+};
\ No newline at end of file
diff --git a/lib/features/customer/data/user_api.dart b/lib/features/customer/data/user_api.dart
new file mode 100644
index 0000000..591c1df
--- /dev/null
+++ b/lib/features/customer/data/user_api.dart
@@ -0,0 +1,99 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../../../core/network/api_client.dart';
+import '../../../core/network/api_response.dart';
+import '../../../core/providers.dart';
+import '../../../core/utils/id_util.dart';
+
+final userApiProvider = Provider(
+ (ref) => UserApi(ref.watch(apiClientProvider)),
+);
+
+class SysRole {
+ const SysRole({required this.id, required this.roleCode, required this.roleName});
+
+ final int id;
+ final String roleCode;
+ final String roleName;
+
+ factory SysRole.fromJson(Map