commit f61b7bf90b14dc6b8ae434dd17a8d57bda233bdf
Author: andyesfly <35127884@qq.com>
Date: Fri Oct 2 17:21:53 2026 +0800
feat: 完成 Flutter 版智能电网 IoT 管理应用初始化搭建
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3820a95
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,45 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.build/
+.buildlog/
+.history
+.svn/
+.swiftpm/
+migrate_working_dir/
+
+# IntelliJ related
+*.iml
+*.ipr
+*.iws
+.idea/
+
+# The .vscode folder contains launch configuration and tasks you configure in
+# VS Code which you may wish to be included in version control, so this line
+# is commented out by default.
+#.vscode/
+
+# Flutter/Dart/Pub related
+**/doc/api/
+**/ios/Flutter/.last_build_id
+.dart_tool/
+.flutter-plugins-dependencies
+.pub-cache/
+.pub/
+/build/
+/coverage/
+
+# Symbolication related
+app.*.symbols
+
+# Obfuscation related
+app.*.map.json
+
+# Android Studio will place build artifacts here
+/android/app/debug
+/android/app/profile
+/android/app/release
diff --git a/.metadata b/.metadata
new file mode 100644
index 0000000..6738224
--- /dev/null
+++ b/.metadata
@@ -0,0 +1,33 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+ revision: "00b0c91f06209d9e4a41f71b7a512d6eb3b9c694"
+ channel: "stable"
+
+project_type: app
+
+# Tracks metadata for the flutter migrate command
+migration:
+ platforms:
+ - platform: root
+ create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
+ base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
+ - platform: android
+ create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
+ base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
+ - platform: ios
+ create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
+ base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
+
+ # User provided section
+
+ # List of Local paths (relative to this file) that should be
+ # ignored by the migrate tool.
+ #
+ # Files that are not part of the templates will be ignored by default.
+ unmanaged_files:
+ - 'lib/main.dart'
+ - 'ios/Runner.xcodeproj/project.pbxproj'
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..474ac2e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,17 @@
+# smart_grid_iot_manage_app_flutter
+
+A new Flutter project.
+
+## Getting Started
+
+This project is a starting point for a Flutter application.
+
+A few resources to get you started if this is your first Flutter project:
+
+- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
+- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
+- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
+
+For help getting started with Flutter development, view the
+[online documentation](https://docs.flutter.dev/), which offers tutorials,
+samples, guidance on mobile development, and a full API reference.
diff --git a/analysis_options.yaml b/analysis_options.yaml
new file mode 100644
index 0000000..0d29021
--- /dev/null
+++ b/analysis_options.yaml
@@ -0,0 +1,28 @@
+# This file configures the analyzer, which statically analyzes Dart code to
+# check for errors, warnings, and lints.
+#
+# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
+# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
+# invoked from the command line by running `flutter analyze`.
+
+# The following line activates a set of recommended lints for Flutter apps,
+# packages, and plugins designed to encourage good coding practices.
+include: package:flutter_lints/flutter.yaml
+
+linter:
+ # The lint rules applied to this project can be customized in the
+ # section below to disable rules from the `package:flutter_lints/flutter.yaml`
+ # included above or to enable additional rules. A list of all available lints
+ # and their documentation is published at https://dart.dev/lints.
+ #
+ # Instead of disabling a lint rule for the entire project in the
+ # section below, it can also be suppressed for a single line of code
+ # or a specific dart file by using the `// ignore: name_of_lint` and
+ # `// ignore_for_file: name_of_lint` syntax on the line or in the file
+ # producing the lint.
+ rules:
+ # avoid_print: false # Uncomment to disable the `avoid_print` rule
+ # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
+
+# Additional information about this file can be found at
+# https://dart.dev/guides/language/analysis-options
diff --git a/android/.gitignore b/android/.gitignore
new file mode 100644
index 0000000..be3943c
--- /dev/null
+++ b/android/.gitignore
@@ -0,0 +1,14 @@
+gradle-wrapper.jar
+/.gradle
+/captures/
+/gradlew
+/gradlew.bat
+/local.properties
+GeneratedPluginRegistrant.java
+.cxx/
+
+# Remember to never publicly share your keystore.
+# See https://flutter.dev/to/reference-keystore
+key.properties
+**/*.keystore
+**/*.jks
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
new file mode 100644
index 0000000..804cb7a
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -0,0 +1,44 @@
+plugins {
+ id("com.android.application")
+ id("kotlin-android")
+ // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
+ id("dev.flutter.flutter-gradle-plugin")
+}
+
+android {
+ namespace = "cn.xintong.smart_grid_iot_manage_app_flutter"
+ compileSdk = flutter.compileSdkVersion
+ ndkVersion = flutter.ndkVersion
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_17.toString()
+ }
+
+ defaultConfig {
+ // 与 uni-app 版 manifest.json 的 distribute.android.packagename 一致
+ applicationId = "cn.xintong.operations"
+ // You can update the following values to match your application needs.
+ // For more information, see: https://flutter.dev/to/review-gradle-config.
+ minSdk = flutter.minSdkVersion
+ targetSdk = flutter.targetSdkVersion
+ versionCode = flutter.versionCode
+ versionName = flutter.versionName
+ }
+
+ buildTypes {
+ release {
+ // TODO: Add your own signing config for the release build.
+ // Signing with the debug keys for now, so `flutter run --release` works.
+ signingConfig = signingConfigs.getByName("debug")
+ }
+ }
+}
+
+flutter {
+ source = "../.."
+}
diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..f7ceb2b
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/cn/xintong/smart_grid_iot_manage_app_flutter/MainActivity.kt b/android/app/src/main/kotlin/cn/xintong/smart_grid_iot_manage_app_flutter/MainActivity.kt
new file mode 100644
index 0000000..268692f
--- /dev/null
+++ b/android/app/src/main/kotlin/cn/xintong/smart_grid_iot_manage_app_flutter/MainActivity.kt
@@ -0,0 +1,5 @@
+package cn.xintong.smart_grid_iot_manage_app_flutter
+
+import io.flutter.embedding.android.FlutterActivity
+
+class MainActivity : FlutterActivity()
diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml
new file mode 100644
index 0000000..f74085f
--- /dev/null
+++ b/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml
new file mode 100644
index 0000000..304732f
--- /dev/null
+++ b/android/app/src/main/res/drawable/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..db77bb4
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..17987b7
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..09d4391
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..d5f1c8d
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..4d6372e
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml
new file mode 100644
index 0000000..06952be
--- /dev/null
+++ b/android/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..cb1ef88
--- /dev/null
+++ b/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/android/app/src/profile/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 0000000..d337529
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,25 @@
+allprojects {
+ repositories {
+ // 本机无法访问 maven.google.com / repo.maven.apache.org,仅使用国内镜像,避免长超时。
+ maven { url = uri("https://maven.aliyun.com/repository/google") }
+ maven { url = uri("https://maven.aliyun.com/repository/public") }
+ }
+}
+
+val newBuildDir: Directory =
+ rootProject.layout.buildDirectory
+ .dir("../../build")
+ .get()
+rootProject.layout.buildDirectory.value(newBuildDir)
+
+subprojects {
+ val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
+ project.layout.buildDirectory.value(newSubprojectBuildDir)
+}
+subprojects {
+ project.evaluationDependsOn(":app")
+}
+
+tasks.register("clean") {
+ delete(rootProject.layout.buildDirectory)
+}
diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html
new file mode 100644
index 0000000..4622f69
--- /dev/null
+++ b/android/build/reports/problems/problems-report.html
@@ -0,0 +1,663 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Gradle Configuration Cache
+
+
+
+
+
+
+ Loading...
+
+
+
+
+
+
+
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 0000000..38eff2a
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,6 @@
+org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
+android.useAndroidX=true
+# 源项目目录含中文(智能电网),AGP 默认会拒绝构建非 ASCII 路径,此处按官方提示放行。
+android.overridePathCheck=true
+# Kotlin 增量编译在非 ASCII 路径下会因路径转义导致 "different roots" 崩溃,关闭增量编译规避。
+kotlin.incremental=false
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..e4ef43f
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
new file mode 100644
index 0000000..2d55f39
--- /dev/null
+++ b/android/settings.gradle.kts
@@ -0,0 +1,27 @@
+pluginManagement {
+ val flutterSdkPath =
+ run {
+ val properties = java.util.Properties()
+ file("local.properties").inputStream().use { properties.load(it) }
+ val flutterSdkPath = properties.getProperty("flutter.sdk")
+ require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
+ flutterSdkPath
+ }
+
+ includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
+
+ repositories {
+ // 本机无法访问 maven.google.com / repo.maven.apache.org,仅使用国内镜像,避免长超时。
+ maven { url = uri("https://maven.aliyun.com/repository/google") }
+ maven { url = uri("https://maven.aliyun.com/repository/public") }
+ maven { url = uri("https://maven.aliyun.com/repository/gradle-plugin") }
+ }
+}
+
+plugins {
+ id("dev.flutter.flutter-plugin-loader") version "1.0.0"
+ id("com.android.application") version "8.11.1" apply false
+ id("org.jetbrains.kotlin.android") version "2.2.20" apply false
+}
+
+include(":app")
diff --git a/ios/.gitignore b/ios/.gitignore
new file mode 100644
index 0000000..7a7f987
--- /dev/null
+++ b/ios/.gitignore
@@ -0,0 +1,34 @@
+**/dgph
+*.mode1v3
+*.mode2v3
+*.moved-aside
+*.pbxuser
+*.perspectivev3
+**/*sync/
+.sconsign.dblite
+.tags*
+**/.vagrant/
+**/DerivedData/
+Icon?
+**/Pods/
+**/.symlinks/
+profile
+xcuserdata
+**/.generated/
+Flutter/App.framework
+Flutter/Flutter.framework
+Flutter/Flutter.podspec
+Flutter/Generated.xcconfig
+Flutter/ephemeral/
+Flutter/app.flx
+Flutter/app.zip
+Flutter/flutter_assets/
+Flutter/flutter_export_environment.sh
+ServiceDefinitions.json
+Runner/GeneratedPluginRegistrant.*
+
+# Exceptions to above rules.
+!default.mode1v3
+!default.mode2v3
+!default.pbxuser
+!default.perspectivev3
diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist
new file mode 100644
index 0000000..391a902
--- /dev/null
+++ b/ios/Flutter/AppFrameworkInfo.plist
@@ -0,0 +1,24 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ App
+ CFBundleIdentifier
+ io.flutter.flutter.app
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ App
+ CFBundlePackageType
+ FMWK
+ CFBundleShortVersionString
+ 1.0
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ 1.0
+
+
diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig
new file mode 100644
index 0000000..592ceee
--- /dev/null
+++ b/ios/Flutter/Debug.xcconfig
@@ -0,0 +1 @@
+#include "Generated.xcconfig"
diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig
new file mode 100644
index 0000000..592ceee
--- /dev/null
+++ b/ios/Flutter/Release.xcconfig
@@ -0,0 +1 @@
+#include "Generated.xcconfig"
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..49a1c86
--- /dev/null
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -0,0 +1,620 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 54;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
+ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
+ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
+ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
+ 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
+ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
+ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
+ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+ 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 97C146E61CF9000F007C117D /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 97C146ED1CF9000F007C117D;
+ remoteInfo = Runner;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ 9705A1C41CF9048500538489 /* Embed Frameworks */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 10;
+ files = (
+ );
+ name = "Embed Frameworks";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
+/* Begin PBXFileReference section */
+ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; };
+ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; };
+ 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; };
+ 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; };
+ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; };
+ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
+ 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; };
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; };
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; };
+ 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; };
+ 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; };
+ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; };
+ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 97C146EB1CF9000F007C117D /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 331C8082294A63A400263BE5 /* RunnerTests */ = {
+ isa = PBXGroup;
+ children = (
+ 331C807B294A618700263BE5 /* RunnerTests.swift */,
+ );
+ path = RunnerTests;
+ sourceTree = "";
+ };
+ 9740EEB11CF90186004384FC /* Flutter */ = {
+ isa = PBXGroup;
+ children = (
+ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */,
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
+ 9740EEB31CF90195004384FC /* Generated.xcconfig */,
+ );
+ name = Flutter;
+ sourceTree = "";
+ };
+ 97C146E51CF9000F007C117D = {
+ isa = PBXGroup;
+ children = (
+ 9740EEB11CF90186004384FC /* Flutter */,
+ 97C146F01CF9000F007C117D /* Runner */,
+ 97C146EF1CF9000F007C117D /* Products */,
+ 331C8082294A63A400263BE5 /* RunnerTests */,
+ );
+ sourceTree = "";
+ };
+ 97C146EF1CF9000F007C117D /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 97C146EE1CF9000F007C117D /* Runner.app */,
+ 331C8081294A63A400263BE5 /* RunnerTests.xctest */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 97C146F01CF9000F007C117D /* Runner */ = {
+ isa = PBXGroup;
+ children = (
+ 97C146FA1CF9000F007C117D /* Main.storyboard */,
+ 97C146FD1CF9000F007C117D /* Assets.xcassets */,
+ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
+ 97C147021CF9000F007C117D /* Info.plist */,
+ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
+ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
+ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
+ 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
+ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
+ );
+ path = Runner;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 331C8080294A63A400263BE5 /* RunnerTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
+ buildPhases = (
+ 331C807D294A63A400263BE5 /* Sources */,
+ 331C807F294A63A400263BE5 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 331C8086294A63A400263BE5 /* PBXTargetDependency */,
+ );
+ name = RunnerTests;
+ productName = RunnerTests;
+ productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+ 97C146ED1CF9000F007C117D /* Runner */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
+ buildPhases = (
+ 9740EEB61CF901F6004384FC /* Run Script */,
+ 97C146EA1CF9000F007C117D /* Sources */,
+ 97C146EB1CF9000F007C117D /* Frameworks */,
+ 97C146EC1CF9000F007C117D /* Resources */,
+ 9705A1C41CF9048500538489 /* Embed Frameworks */,
+ 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = Runner;
+ productName = Runner;
+ productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 97C146E61CF9000F007C117D /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = YES;
+ LastUpgradeCheck = 1510;
+ ORGANIZATIONNAME = "";
+ TargetAttributes = {
+ 331C8080294A63A400263BE5 = {
+ CreatedOnToolsVersion = 14.0;
+ TestTargetID = 97C146ED1CF9000F007C117D;
+ };
+ 97C146ED1CF9000F007C117D = {
+ CreatedOnToolsVersion = 7.3.1;
+ LastSwiftMigration = 1100;
+ };
+ };
+ };
+ buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
+ compatibilityVersion = "Xcode 9.3";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 97C146E51CF9000F007C117D;
+ productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 97C146ED1CF9000F007C117D /* Runner */,
+ 331C8080294A63A400263BE5 /* RunnerTests */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 331C807F294A63A400263BE5 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 97C146EC1CF9000F007C117D /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
+ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
+ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
+ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
+ );
+ name = "Thin Binary";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
+ };
+ 9740EEB61CF901F6004384FC /* Run Script */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ );
+ name = "Run Script";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 331C807D294A63A400263BE5 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 97C146EA1CF9000F007C117D /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
+ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
+ 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ 331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 97C146ED1CF9000F007C117D /* Runner */;
+ targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin PBXVariantGroup section */
+ 97C146FA1CF9000F007C117D /* Main.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 97C146FB1CF9000F007C117D /* Base */,
+ );
+ name = Main.storyboard;
+ sourceTree = "";
+ };
+ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 97C147001CF9000F007C117D /* Base */,
+ );
+ name = LaunchScreen.storyboard;
+ sourceTree = "";
+ };
+/* End PBXVariantGroup section */
+
+/* Begin XCBuildConfiguration section */
+ 249021D3217E4FDB00AE95B9 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Profile;
+ };
+ 249021D4217E4FDB00AE95B9 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = cn.xintong.operations;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Profile;
+ };
+ 331C8088294A63A400263BE5 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = cn.xintong.operations.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Debug;
+ };
+ 331C8089294A63A400263BE5 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = cn.xintong.operations.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Release;
+ };
+ 331C808A294A63A400263BE5 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = cn.xintong.operations.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Profile;
+ };
+ 97C147031CF9000F007C117D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ MTL_ENABLE_DEBUG_INFO = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 97C147041CF9000F007C117D /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 97C147061CF9000F007C117D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = cn.xintong.operations;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Debug;
+ };
+ 97C147071CF9000F007C117D /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = cn.xintong.operations;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 331C8088294A63A400263BE5 /* Debug */,
+ 331C8089294A63A400263BE5 /* Release */,
+ 331C808A294A63A400263BE5 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 97C147031CF9000F007C117D /* Debug */,
+ 97C147041CF9000F007C117D /* Release */,
+ 249021D3217E4FDB00AE95B9 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 97C147061CF9000F007C117D /* Debug */,
+ 97C147071CF9000F007C117D /* Release */,
+ 249021D4217E4FDB00AE95B9 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 97C146E61CF9000F007C117D /* Project object */;
+}
diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 0000000..f9b0d7c
--- /dev/null
+++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,8 @@
+
+
+
+
+ PreviewsEnabled
+
+
+
diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
new file mode 100644
index 0000000..e3773d4
--- /dev/null
+++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..1d526a1
--- /dev/null
+++ b/ios/Runner.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 0000000..f9b0d7c
--- /dev/null
+++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,8 @@
+
+
+
+
+ PreviewsEnabled
+
+
+
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
new file mode 100644
index 0000000..c30b367
--- /dev/null
+++ b/ios/Runner/AppDelegate.swift
@@ -0,0 +1,16 @@
+import Flutter
+import UIKit
+
+@main
+@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
+ override func application(
+ _ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
+ ) -> Bool {
+ return super.application(application, didFinishLaunchingWithOptions: launchOptions)
+ }
+
+ func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
+ GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
+ }
+}
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..d36b1fa
--- /dev/null
+++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,122 @@
+{
+ "images" : [
+ {
+ "size" : "20x20",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-20x20@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "20x20",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-20x20@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-29x29@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-29x29@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-29x29@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-40x40@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-40x40@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "60x60",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-60x60@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "60x60",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-60x60@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "20x20",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-20x20@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "20x20",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-20x20@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-29x29@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-29x29@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-40x40@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-40x40@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "76x76",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-76x76@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "76x76",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-76x76@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "83.5x83.5",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-83.5x83.5@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "1024x1024",
+ "idiom" : "ios-marketing",
+ "filename" : "Icon-App-1024x1024@1x.png",
+ "scale" : "1x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png
new file mode 100644
index 0000000..dc9ada4
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png
new file mode 100644
index 0000000..7353c41
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png
new file mode 100644
index 0000000..797d452
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png
new file mode 100644
index 0000000..6ed2d93
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png
new file mode 100644
index 0000000..4cd7b00
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png
new file mode 100644
index 0000000..fe73094
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png
new file mode 100644
index 0000000..321773c
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png
new file mode 100644
index 0000000..797d452
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png
new file mode 100644
index 0000000..502f463
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png
new file mode 100644
index 0000000..0ec3034
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png
new file mode 100644
index 0000000..0ec3034
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png
new file mode 100644
index 0000000..e9f5fea
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png
new file mode 100644
index 0000000..84ac32a
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png
new file mode 100644
index 0000000..8953cba
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ
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 0000000..0467bf1
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ
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 0000000..9da19ea
Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ
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 0000000..9da19ea
Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ
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 0000000..9da19ea
Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ
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 json) => SysRole(
+ id: toIntOr(json['id'], 0),
+ roleCode: json['roleCode'] as String? ?? '',
+ roleName: json['roleName'] as String? ?? '',
+ );
+}
+
+class SysUser {
+ const SysUser({
+ required this.id,
+ required this.username,
+ required this.realName,
+ this.phone,
+ this.email,
+ this.status,
+ });
+
+ final int id;
+ final String username;
+ final String realName;
+ final String? phone;
+ final String? email;
+ final int? status;
+
+ /// 下拉项展示文本:姓名 · 手机号
+ String get optionLabel {
+ final name = realName.isNotEmpty ? realName : username;
+ return phone == null || phone!.isEmpty ? name : '$name · $phone';
+ }
+
+ factory SysUser.fromJson(Map json) => SysUser(
+ id: toIntOr(json['id'], 0),
+ username: json['username'] as String? ?? '',
+ realName: json['realName'] as String? ?? '',
+ phone: json['phone'] as String?,
+ email: json['email'] as String?,
+ status: toIntOrNull(json['status']),
+ );
+}
+
+/// 用户/角色接口,对应 uni-app 版 `api/user.ts`。
+class UserApi {
+ UserApi(this._client);
+
+ final ApiClient _client;
+
+ Future>> roles() => _client.get>(
+ '/users/roles',
+ parse: (d) => (d as List)
+ .map((e) => SysRole.fromJson((e as Map).cast()))
+ .toList(),
+ );
+
+ Future>> list({
+ int page = 1,
+ int pageSize = 100,
+ int? status,
+ int? roleId,
+ }) => _client.get>(
+ '/users',
+ query: {'page': page, 'pageSize': pageSize, 'status': status, 'roleId': roleId},
+ parse: (d) {
+ final m = (d as Map).cast();
+ return (m['list'] as List? ?? [])
+ .map((e) => SysUser.fromJson((e as Map).cast()))
+ .toList();
+ },
+ );
+
+ /// 业务员下拉选项:先按 roleCode 解析 SALESMAN 角色 id,再取其启用状态的用户。
+ Future> salesmanOptions() async {
+ final roleRes = await roles();
+ final salesmanRole = (roleRes.data ?? [])
+ .where((r) => r.roleCode == 'SALESMAN')
+ .firstOrNull;
+ if (salesmanRole == null) return const [];
+
+ final userRes = await list(page: 1, pageSize: 100, status: 1, roleId: salesmanRole.id);
+ return userRes.data ?? const [];
+ }
+}
\ No newline at end of file
diff --git a/lib/features/customer/presentation/customer_detail_page.dart b/lib/features/customer/presentation/customer_detail_page.dart
new file mode 100644
index 0000000..650643e
--- /dev/null
+++ b/lib/features/customer/presentation/customer_detail_page.dart
@@ -0,0 +1,443 @@
+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_typography.dart';
+import '../../../core/utils/format_util.dart';
+import '../../../shared/widgets/app_feedback.dart';
+import '../../../shared/widgets/x_card.dart';
+import '../../../shared/widgets/x_chip.dart';
+import '../../../shared/widgets/x_common.dart';
+import '../../../shared/widgets/x_icon.dart';
+import '../../../shared/widgets/x_top_bar.dart';
+import '../../visit/data/visit_api.dart';
+import '../../visit/data/visit_models.dart';
+import '../data/customer_api.dart';
+import '../data/customer_models.dart';
+
+/// 信息卡里的等宽数值(联系电话 / 传真 / 邮编)。
+/// 源页面 `.info-value.font-data-mono`:字号 15 覆盖全局 12,字重 500、行高 16、等宽字体。
+const _monoFallback = ['JetBrains Mono', 'SF Mono', 'Menlo', 'Consolas'];
+const _infoMonoStyle = TextStyle(
+ fontSize: 15,
+ height: 16 / 15,
+ fontWeight: FontWeight.w500,
+ fontFamilyFallback: _monoFallback,
+);
+
+/// 客户详情页,对应 `pages/customer/detail.vue`。
+class CustomerDetailPage extends ConsumerStatefulWidget {
+ const CustomerDetailPage({super.key, required this.id});
+
+ final int id;
+
+ @override
+ ConsumerState createState() => _CustomerDetailPageState();
+}
+
+class _CustomerDetailPageState extends ConsumerState {
+ Customer? _customer;
+ bool _loading = true;
+ List _visits = const [];
+ int _visitTotal = 0;
+ bool _visitLoading = true;
+
+ @override
+ void initState() {
+ super.initState();
+ _load();
+ }
+
+ Future _load() async {
+ await Future.wait([_loadCustomer(), _loadVisits()]);
+ }
+
+ Future _loadCustomer() async {
+ setState(() => _loading = true);
+ try {
+ final res = await ref.read(customerApiProvider).detail(widget.id);
+ if (!mounted) return;
+ if (res.isOk && res.data != null) {
+ setState(() => _customer = res.data);
+ } else {
+ showAppToast(res.message.isEmpty ? '客户不存在' : res.message);
+ }
+ } catch (_) {
+ if (mounted) showAppToast('加载失败');
+ } finally {
+ if (mounted) setState(() => _loading = false);
+ }
+ }
+
+ Future _loadVisits() async {
+ setState(() => _visitLoading = true);
+ try {
+ final res = await ref
+ .read(visitApiProvider)
+ .list(customerId: widget.id, page: 1, pageSize: 5);
+ if (!mounted) return;
+ if (res.isOk && res.data != null) {
+ setState(() {
+ _visits = res.data!.list;
+ _visitTotal = res.data!.total;
+ });
+ }
+ } catch (_) {
+ // 拜访记录加载失败不影响客户详情展示
+ } finally {
+ if (mounted) setState(() => _visitLoading = false);
+ }
+ }
+
+ Future _goEdit() async {
+ await context.push('/customer/edit?id=${widget.id}');
+ if (!mounted) return;
+ _load();
+ }
+
+ Future _onDelete() async {
+ final confirmed = await showAppConfirm(
+ title: '删除客户',
+ content: '确定删除该客户吗?此操作不可恢复。',
+ destructive: true,
+ );
+ if (!confirmed) return;
+ try {
+ final res = await ref.read(customerApiProvider).delete(widget.id);
+ if (res.isOk) {
+ showAppToast('已删除', success: true);
+ if (mounted) context.pop();
+ } else {
+ showAppToast(res.message.isEmpty ? '删除失败' : res.message);
+ }
+ } catch (_) {
+ showAppToast('删除失败');
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return XPageShell(
+ topBar: XTopBar.nav(
+ title: '客户详情',
+ onBack: () => context.pop(),
+ trailing: GestureDetector(
+ onTap: _goEdit,
+ behavior: HitTestBehavior.opaque,
+ child: const XIcon('edit', size: 20, color: AppColors.primary),
+ ),
+ ),
+ child: _buildBody(),
+ );
+ }
+
+ Widget _buildBody() {
+ final c = _customer;
+ if (c == null) {
+ if (_loading) {
+ return Center(
+ child: Text(
+ '加载中...',
+ style: AppTypography.bodyMd.copyWith(color: AppColors.textMuted),
+ ),
+ );
+ }
+ return const SizedBox.shrink();
+ }
+
+ return SingleChildScrollView(
+ padding: const EdgeInsets.fromLTRB(16, 16, 16, 60),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ _buildHeaderCard(c),
+ _buildInfoCard(c),
+ _buildSectionHead(),
+ if (_visits.isNotEmpty) _buildVisitCard() else _buildEmptyBlock(),
+ XPrimaryButton(
+ label: '删除客户',
+ onPressed: _onDelete,
+ height: 46,
+ margin: const EdgeInsets.fromLTRB(16, 28, 16, 0),
+ background: AppColors.surface,
+ foreground: AppColors.statusError,
+ borderColor: AppColors.statusError,
+ fontSize: 15,
+ pill: false,
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildHeaderCard(Customer c) {
+ return XCard(
+ padding: const EdgeInsets.all(16),
+ margin: const EdgeInsets.only(bottom: 12),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Wrap(
+ spacing: 6,
+ runSpacing: 6,
+ crossAxisAlignment: WrapCrossAlignment.center,
+ children: [
+ Text(
+ c.customerName,
+ style: AppTypography.headlineMdMobile.copyWith(
+ color: AppColors.onSurface,
+ ),
+ ),
+ XChip(
+ customerStatusText(c.status),
+ tone: customerStatusTone(c.status),
+ ),
+ if (c.customerLevel.isNotEmpty)
+ XChip(c.customerLevel, tone: ChipTone.muted),
+ ],
+ ),
+ const SizedBox(height: 10),
+ Row(
+ children: [
+ Text(
+ '客户编号',
+ style: AppTypography.bodyMd.copyWith(
+ fontSize: 12,
+ color: AppColors.textMuted,
+ ),
+ ),
+ const SizedBox(width: 8),
+ Text(
+ c.customerNo,
+ style: AppTypography.dataMono.copyWith(
+ color: AppColors.onSurface,
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildInfoCard(Customer c) {
+ final region = [c.province, c.city, c.district]
+ .where((e) => e.isNotEmpty)
+ .toList();
+ final items = [
+ _infoItem('location', '地区', region.isEmpty ? '-' : region.join(' ')),
+ _infoItem('business', '详细地址', c.address.isEmpty ? '-' : c.address),
+ _infoItem('person', '联系人', c.contactPerson.isEmpty ? '-' : c.contactPerson),
+ _infoItem(
+ 'call',
+ '联系电话',
+ c.contactPhone.isEmpty ? '-' : c.contactPhone,
+ mono: true,
+ ),
+ _infoItem('category', '传真', c.fax.isEmpty ? '-' : c.fax, mono: true),
+ _infoItem('category', '邮编', c.postalCode.isEmpty ? '-' : c.postalCode, mono: true),
+ _infoItem('clock', '创建时间', formatDateTime(c.createdAt)),
+ ];
+
+ return XCard(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
+ child: Column(
+ children: [
+ for (var i = 0; i < items.length; i++) ...[
+ if (i > 0) Container(height: 1, color: AppColors.borderRule),
+ items[i],
+ ],
+ ],
+ ),
+ );
+ }
+
+ Widget _infoItem(String icon, String label, String value, {bool mono = false}) {
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 13),
+ child: Row(
+ children: [
+ XIcon(icon, size: 18, color: AppColors.textMuted),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ label,
+ style: AppTypography.bodyMd.copyWith(
+ fontSize: 12,
+ color: AppColors.textMuted,
+ ),
+ ),
+ Text(
+ value,
+ style: mono
+ ? _infoMonoStyle.copyWith(color: AppColors.onSurface)
+ : AppTypography.bodyMd.copyWith(
+ color: AppColors.onSurface,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildSectionHead() {
+ return Padding(
+ padding: const EdgeInsets.only(top: 22, bottom: 10),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Row(
+ children: [
+ const XIcon('schedule', size: 17, color: AppColors.primary),
+ const SizedBox(width: 6),
+ Text(
+ '拜访记录',
+ style: AppTypography.titleSm.copyWith(
+ fontSize: 16,
+ color: AppColors.onSurface,
+ ),
+ ),
+ ],
+ ),
+ GestureDetector(
+ onTap: () => context.push('/visit/form?customerId=${widget.id}'),
+ behavior: HitTestBehavior.opaque,
+ child: Row(
+ children: [
+ const XIcon('add', size: 15, color: AppColors.primary),
+ const SizedBox(width: 2),
+ Text(
+ '发起拜访',
+ style: AppTypography.labelBold.copyWith(
+ color: AppColors.primary,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildVisitCard() {
+ final items = [];
+ for (var i = 0; i < _visits.length; i++) {
+ if (i > 0) items.add(Container(height: 1, color: AppColors.borderRule));
+ items.add(_visitItem(_visits[i]));
+ }
+ if (_visitTotal > _visits.length) {
+ items.add(
+ Padding(
+ padding: const EdgeInsets.symmetric(vertical: 10),
+ child: Text(
+ '共 $_visitTotal 条拜访,仅显示最近 ${_visits.length} 条',
+ textAlign: TextAlign.center,
+ style: AppTypography.bodyMd.copyWith(
+ fontSize: 12,
+ color: AppColors.textMuted,
+ ),
+ ),
+ ),
+ );
+ }
+
+ return XCard(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
+ child: Column(children: items),
+ );
+ }
+
+ Widget _visitItem(VisitInfo v) {
+ return GestureDetector(
+ onTap: () => context.push('/visit/detail/${v.id}'),
+ behavior: HitTestBehavior.opaque,
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(10, 13, 0, 13),
+ child: Stack(
+ clipBehavior: Clip.none,
+ children: [
+ Positioned(
+ left: -10,
+ top: 1,
+ bottom: 1,
+ child: Container(
+ width: 3,
+ decoration: BoxDecoration(
+ color: visitStatusBarColor(v.status),
+ borderRadius: BorderRadius.circular(2),
+ ),
+ ),
+ ),
+ Row(
+ children: [
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Wrap(
+ spacing: 6,
+ runSpacing: 6,
+ crossAxisAlignment: WrapCrossAlignment.center,
+ children: [
+ Text(
+ (v.subject == null || v.subject!.isEmpty)
+ ? '客户拜访'
+ : v.subject!,
+ style: AppTypography.bodyMd.copyWith(
+ fontWeight: FontWeight.w700,
+ color: AppColors.onSurface,
+ ),
+ ),
+ XChip(
+ visitStatusText(v.status),
+ tone: visitStatusTone(v.status),
+ ),
+ ],
+ ),
+ const SizedBox(height: 4),
+ Text(
+ '${v.visitNo} · ${formatDate(v.planTime)}',
+ style: AppTypography.dataMono.copyWith(
+ color: AppColors.textMuted,
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(width: 10),
+ const XIcon('forward', size: 16, color: AppColors.textPlaceholder),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildEmptyBlock() {
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: AppColors.surfaceBright,
+ border: Border.all(color: AppColors.borderRule),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Text(
+ _visitLoading ? '加载中...' : '暂无拜访记录',
+ style: AppTypography.bodyMd.copyWith(
+ fontSize: 13,
+ color: AppColors.textMuted,
+ ),
+ ),
+ );
+ }
+}
\ No newline at end of file
diff --git a/lib/features/customer/presentation/customer_edit_page.dart b/lib/features/customer/presentation/customer_edit_page.dart
new file mode 100644
index 0000000..c04503c
--- /dev/null
+++ b/lib/features/customer/presentation/customer_edit_page.dart
@@ -0,0 +1,653 @@
+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_icon.dart';
+import '../../../shared/widgets/x_top_bar.dart';
+import '../../auth/application/session_controller.dart';
+import '../data/customer_api.dart';
+import '../data/customer_models.dart';
+import '../data/user_api.dart';
+
+/// 设施类型下拉项,照抄源页面 `levelOptions`。
+const _levelOptions = ['医院', '学校', '企业', '其他'];
+
+/// 状态下拉的可选值,照抄源页面 `statusOptions`。
+const _statusValues = [1, 2, 3, 4];
+
+/// 省市区三级选择器的内置示例数据(源页面用 uni 的 region picker)。
+const _regionData = >>{
+ '北京市': {
+ '北京市': ['东城区', '西城区', '朝阳区', '海淀区', '丰台区', '石景山区', '通州区'],
+ },
+ '上海市': {
+ '上海市': ['黄浦区', '徐汇区', '长宁区', '静安区', '普陀区', '浦东新区', '闵行区'],
+ },
+ '广东省': {
+ '广州市': ['越秀区', '天河区', '海珠区', '白云区', '番禺区'],
+ '深圳市': ['福田区', '南山区', '罗湖区', '宝安区', '龙岗区'],
+ '珠海市': ['香洲区', '金湾区', '斗门区'],
+ },
+ '浙江省': {
+ '杭州市': ['上城区', '拱墅区', '西湖区', '滨江区', '余杭区'],
+ '宁波市': ['海曙区', '江北区', '鄞州区', '北仑区'],
+ '温州市': ['鹿城区', '龙湾区', '瓯海区'],
+ },
+ '江苏省': {
+ '南京市': ['玄武区', '秦淮区', '鼓楼区', '江宁区'],
+ '苏州市': ['姑苏区', '吴中区', '相城区', '工业园区'],
+ '无锡市': ['梁溪区', '滨湖区', '新吴区'],
+ },
+ '四川省': {
+ '成都市': ['锦江区', '青羊区', '武侯区', '成华区', '高新区'],
+ '绵阳市': ['涪城区', '游仙区', '安州区'],
+ },
+};
+
+/// 客户编辑页(新增 / 编辑),对应 `pages/customer/edit.vue`。
+class CustomerEditPage extends ConsumerStatefulWidget {
+ const CustomerEditPage({super.key, this.id});
+
+ /// 为 null 表示新增。
+ final int? id;
+
+ @override
+ ConsumerState createState() => _CustomerEditPageState();
+}
+
+class _CustomerEditPageState extends ConsumerState {
+ final _customerName = TextEditingController();
+ final _customerNo = TextEditingController();
+ final _address = TextEditingController();
+ final _contactPerson = TextEditingController();
+ final _contactPhone = TextEditingController();
+ final _postalCode = TextEditingController();
+ final _fax = TextEditingController();
+
+ late final bool _isEdit = widget.id != null;
+ bool _isAdmin = false;
+ bool _saving = false;
+
+ String _province = '';
+ String _city = '';
+ String _district = '';
+ String _customerLevel = '';
+ int _status = 1;
+ List _salesmen = const [];
+ int _salesmanIndex = 0;
+
+ List get _salesmanLabels =>
+ ['未指定', ..._salesmen.map((s) => s.optionLabel)];
+
+ @override
+ void initState() {
+ super.initState();
+ _isAdmin = ref.read(sessionProvider).user?.isAdmin ?? false;
+ _load();
+ }
+
+ @override
+ void dispose() {
+ _customerName.dispose();
+ _customerNo.dispose();
+ _address.dispose();
+ _contactPerson.dispose();
+ _contactPhone.dispose();
+ _postalCode.dispose();
+ _fax.dispose();
+ super.dispose();
+ }
+
+ Future _load() async {
+ if (_isAdmin) {
+ try {
+ final list = await ref.read(userApiProvider).salesmanOptions();
+ if (mounted) setState(() => _salesmen = list);
+ } catch (_) {
+ if (mounted) setState(() => _salesmen = const []);
+ }
+ }
+
+ final id = widget.id;
+ if (id == null) return;
+ try {
+ final res = await ref.read(customerApiProvider).detail(id);
+ if (!mounted) return;
+ final d = res.data;
+ if (res.isOk && d != null) {
+ setState(() {
+ _customerName.text = d.customerName;
+ _customerNo.text = d.customerNo;
+ _province = d.province;
+ _city = d.city;
+ _district = d.district;
+ _address.text = d.address;
+ _contactPerson.text = d.contactPerson;
+ _contactPhone.text = d.contactPhone;
+ _postalCode.text = d.postalCode;
+ _fax.text = d.fax;
+ _customerLevel = d.customerLevel;
+ _status = d.status;
+ final si = _salesmen.indexWhere((s) => s.id == d.salesmanId);
+ _salesmanIndex = si >= 0 ? si + 1 : 0;
+ });
+ }
+ } catch (_) {
+ // 详情加载失败保持空表单
+ }
+ }
+
+ Future _onSave() async {
+ if (_customerName.text.trim().isEmpty) {
+ showAppToast('请输入客户名称');
+ return;
+ }
+ setState(() => _saving = true);
+
+ final payload = {
+ 'customerName': _customerName.text,
+ if (_isEdit) 'customerNo': _customerNo.text,
+ 'province': _province,
+ 'city': _city,
+ 'district': _district,
+ 'address': _address.text,
+ 'contactPerson': _contactPerson.text,
+ 'contactPhone': _contactPhone.text,
+ 'postalCode': _postalCode.text,
+ 'fax': _fax.text,
+ 'customerLevel': _customerLevel,
+ 'status': _status,
+ };
+ // ADMIN 可指定负责业务员;未指定时传 null 以清空。
+ if (_isAdmin) {
+ payload['salesmanId'] =
+ _salesmanIndex > 0 ? _salesmen[_salesmanIndex - 1].id : null;
+ }
+
+ try {
+ final api = ref.read(customerApiProvider);
+ final ApiResponse res;
+ if (_isEdit) {
+ res = await api.update(widget.id!, payload);
+ } else {
+ payload.remove('customerNo');
+ res = await api.create(payload);
+ }
+ if (res.isOk) {
+ showAppToast('保存成功', success: true);
+ if (mounted) context.pop();
+ } else {
+ showAppToast(res.message.isEmpty ? '保存失败' : res.message);
+ }
+ } catch (_) {
+ showAppToast('保存失败,请重试');
+ } finally {
+ if (mounted) setState(() => _saving = false);
+ }
+ }
+
+ String get _regionText => _province.isEmpty
+ ? '请选择省市区'
+ : [_province, _city, _district].where((e) => e.isNotEmpty).join(' ');
+
+ Future _pickRegion() async {
+ final res = await showModalBottomSheet<({String province, String city, String district})>(
+ context: context,
+ backgroundColor: Colors.transparent,
+ builder: (_) => const _RegionPickerSheet(),
+ );
+ if (res == null) return;
+ setState(() {
+ _province = res.province;
+ _city = res.city;
+ _district = res.district;
+ });
+ }
+
+ Future _pickLevel() async {
+ final index = await _pickIndex(
+ '选择设施类型',
+ _levelOptions,
+ selected: _levelOptions.indexOf(_customerLevel),
+ );
+ if (index == null) return;
+ setState(() => _customerLevel = _levelOptions[index]);
+ }
+
+ Future _pickStatus() async {
+ final index = await _pickIndex(
+ '选择状态',
+ _statusValues.map(customerStatusText).toList(),
+ selected: _statusValues.indexOf(_status),
+ );
+ if (index == null) return;
+ setState(() => _status = _statusValues[index]);
+ }
+
+ Future _pickSalesman() async {
+ final index = await _pickIndex(
+ '选择负责业务员',
+ _salesmanLabels,
+ selected: _salesmanIndex,
+ );
+ if (index == null) return;
+ setState(() => _salesmanIndex = index);
+ }
+
+ Future _pickIndex(
+ String title,
+ List options, {
+ int? selected,
+ }) {
+ return showModalBottomSheet(
+ context: context,
+ backgroundColor: AppColors.surface,
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)),
+ ),
+ builder: (ctx) => SafeArea(
+ top: false,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Padding(
+ padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
+ child: Row(
+ children: [
+ Expanded(
+ child: Text(
+ title,
+ style: AppTypography.titleSm.copyWith(fontSize: 16),
+ ),
+ ),
+ GestureDetector(
+ onTap: () => Navigator.of(ctx).pop(),
+ behavior: HitTestBehavior.opaque,
+ child: const XIcon('close', size: 20, color: AppColors.textMuted),
+ ),
+ ],
+ ),
+ ),
+ Container(height: 1, color: AppColors.borderRule),
+ Flexible(
+ child: ListView.builder(
+ shrinkWrap: true,
+ itemCount: options.length,
+ itemBuilder: (_, i) {
+ final sel = selected == i;
+ return InkWell(
+ onTap: () => Navigator.of(ctx).pop(i),
+ child: Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 16,
+ vertical: 14,
+ ),
+ decoration: const BoxDecoration(
+ border: Border(
+ bottom: BorderSide(color: AppColors.borderRule),
+ ),
+ ),
+ child: Row(
+ children: [
+ Expanded(
+ child: Text(
+ options[i],
+ style: AppTypography.bodyMd.copyWith(
+ color: sel
+ ? AppColors.primary
+ : AppColors.onSurface,
+ ),
+ ),
+ ),
+ if (sel)
+ const XIcon('check', size: 18, color: AppColors.primary),
+ ],
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return XPageShell(
+ topBar: XTopBar.nav(
+ title: _isEdit ? '编辑客户' : '新增客户',
+ onBack: () => context.pop(),
+ ),
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.fromLTRB(16, 16, 16, 60),
+ child: Column(
+ children: [
+ _buildForm(),
+ XPrimaryButton(
+ label: '保存',
+ loading: _saving,
+ onPressed: _onSave,
+ margin: const EdgeInsets.fromLTRB(16, 24, 16, 40),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildForm() {
+ final fields = [
+ _field(
+ '客户名称',
+ required: true,
+ control: _input(_customerName, '请输入客户名称'),
+ ),
+ _field(
+ '客户编号',
+ control: _input(_customerNo, '保存后自动生成', enabled: !_isEdit),
+ ),
+ _field(
+ '省市区',
+ control: _picker(
+ text: _regionText,
+ placeholder: _province.isEmpty,
+ onTap: _pickRegion,
+ ),
+ ),
+ _field('详细地址', control: _input(_address, '请输入详细地址')),
+ _field('联系人', control: _input(_contactPerson, '请输入联系人')),
+ _field(
+ '联系电话',
+ control: _input(_contactPhone, '请输入联系电话', number: true),
+ ),
+ _field('邮编', control: _input(_postalCode, '选填', number: true)),
+ _field('传真', control: _input(_fax, '选填')),
+ _field(
+ '设施类型',
+ control: _picker(
+ text: _customerLevel.isEmpty ? '请选择设施类型' : _customerLevel,
+ placeholder: _customerLevel.isEmpty,
+ onTap: _pickLevel,
+ ),
+ ),
+ _field(
+ '状态',
+ control: _picker(
+ text: customerStatusText(_status),
+ placeholder: false,
+ onTap: _pickStatus,
+ ),
+ ),
+ if (_isAdmin)
+ _field(
+ '负责业务员',
+ control: _picker(
+ text: _salesmanIndex > 0 ? _salesmanLabels[_salesmanIndex] : '未指定',
+ placeholder: _salesmanIndex <= 0,
+ onTap: _pickSalesman,
+ ),
+ ),
+ ];
+
+ return XCard(
+ padding: const EdgeInsets.fromLTRB(16, 6, 16, 16),
+ child: Column(
+ children: [
+ for (var i = 0; i < fields.length; i++) ...[
+ if (i > 0) Container(height: 1, color: AppColors.borderRule),
+ fields[i],
+ ],
+ ],
+ ),
+ );
+ }
+
+ Widget _field(String label, {bool required = false, required Widget control}) {
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 12),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text.rich(
+ TextSpan(
+ children: [
+ TextSpan(text: label),
+ if (required)
+ const TextSpan(
+ text: ' *',
+ style: TextStyle(color: AppColors.statusError),
+ ),
+ ],
+ ),
+ style: AppTypography.labelBold.copyWith(
+ color: AppColors.onSurfaceVariant,
+ ),
+ ),
+ const SizedBox(height: 8),
+ control,
+ ],
+ ),
+ );
+ }
+
+ Widget _input(
+ TextEditingController controller,
+ String hint, {
+ bool enabled = true,
+ bool number = false,
+ }) {
+ return Container(
+ height: 44,
+ padding: const EdgeInsets.symmetric(horizontal: 14),
+ decoration: BoxDecoration(
+ color: AppColors.surfaceBright,
+ border: Border.all(color: AppColors.borderRule),
+ borderRadius: BorderRadius.circular(10),
+ ),
+ child: Center(
+ child: TextField(
+ controller: controller,
+ enabled: enabled,
+ keyboardType: number ? TextInputType.number : TextInputType.text,
+ style: AppTypography.bodyMd.copyWith(color: AppColors.onSurface),
+ cursorColor: AppColors.primary,
+ decoration: InputDecoration(
+ isDense: true,
+ border: InputBorder.none,
+ hintText: hint,
+ hintStyle: AppTypography.bodyMd.copyWith(
+ color: AppColors.textPlaceholder,
+ ),
+ contentPadding: EdgeInsets.zero,
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _picker({
+ required String text,
+ required bool placeholder,
+ required VoidCallback onTap,
+ }) {
+ return GestureDetector(
+ onTap: onTap,
+ behavior: HitTestBehavior.opaque,
+ child: Container(
+ height: 44,
+ padding: const EdgeInsets.symmetric(horizontal: 14),
+ decoration: BoxDecoration(
+ color: AppColors.surfaceBright,
+ border: Border.all(color: AppColors.borderRule),
+ borderRadius: BorderRadius.circular(10),
+ ),
+ child: Row(
+ children: [
+ Expanded(
+ child: Text(
+ text,
+ style: AppTypography.bodyMd.copyWith(
+ color: placeholder
+ ? AppColors.textPlaceholder
+ : AppColors.onSurface,
+ ),
+ ),
+ ),
+ const XIcon('forward', size: 16, color: AppColors.outline),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+/// 省 / 市 / 区三级选择器(自建,替代 uni 的 region picker)。
+class _RegionPickerSheet extends StatefulWidget {
+ const _RegionPickerSheet();
+
+ @override
+ State<_RegionPickerSheet> createState() => _RegionPickerSheetState();
+}
+
+class _RegionPickerSheetState extends State<_RegionPickerSheet> {
+ int _level = 0;
+ String? _province;
+ String? _city;
+
+ void _back() {
+ setState(() {
+ if (_level == 2) {
+ _city = null;
+ } else {
+ _province = null;
+ }
+ _level--;
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final List items;
+ if (_level == 0) {
+ items = _regionData.keys.toList();
+ } else if (_level == 1) {
+ items = _regionData[_province]!.keys.toList();
+ } else {
+ items = _regionData[_province!]![_city!]!;
+ }
+ final crumbs = [_province, _city].whereType().join(' ');
+
+ return SafeArea(
+ top: false,
+ child: Container(
+ decoration: const BoxDecoration(
+ color: AppColors.surface,
+ borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)),
+ ),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Padding(
+ padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
+ child: Row(
+ children: [
+ if (_level > 0)
+ GestureDetector(
+ onTap: _back,
+ behavior: HitTestBehavior.opaque,
+ child: const XIcon('back', size: 20, color: AppColors.primary),
+ ),
+ const SizedBox(width: 6),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ '选择省市区',
+ style: AppTypography.titleSm.copyWith(fontSize: 16),
+ ),
+ if (crumbs.isNotEmpty)
+ Text(
+ crumbs,
+ style: AppTypography.bodyMd.copyWith(
+ fontSize: 12,
+ color: AppColors.textMuted,
+ ),
+ ),
+ ],
+ ),
+ ),
+ GestureDetector(
+ onTap: () => Navigator.of(context).pop(),
+ behavior: HitTestBehavior.opaque,
+ child: const XIcon('close', size: 20, color: AppColors.textMuted),
+ ),
+ ],
+ ),
+ ),
+ Container(height: 1, color: AppColors.borderRule),
+ Flexible(
+ child: ListView.builder(
+ shrinkWrap: true,
+ itemCount: items.length,
+ itemBuilder: (_, i) {
+ final name = items[i];
+ return InkWell(
+ onTap: () {
+ if (_level == 0) {
+ setState(() {
+ _province = name;
+ _level = 1;
+ });
+ } else if (_level == 1) {
+ setState(() {
+ _city = name;
+ _level = 2;
+ });
+ } else {
+ Navigator.of(context).pop(
+ (
+ province: _province!,
+ city: _city!,
+ district: name,
+ ),
+ );
+ }
+ },
+ child: Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 16,
+ vertical: 14,
+ ),
+ decoration: const BoxDecoration(
+ border: Border(
+ bottom: BorderSide(color: AppColors.borderRule),
+ ),
+ ),
+ child: Text(
+ name,
+ style: AppTypography.bodyMd.copyWith(
+ color: AppColors.onSurface,
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
\ No newline at end of file
diff --git a/lib/features/customer/presentation/customer_tab.dart b/lib/features/customer/presentation/customer_tab.dart
new file mode 100644
index 0000000..84f3851
--- /dev/null
+++ b/lib/features/customer/presentation/customer_tab.dart
@@ -0,0 +1,465 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+
+import '../../../app/tab_index.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_chip.dart';
+import '../../../shared/widgets/x_common.dart';
+import '../../../shared/widgets/x_icon.dart';
+import '../../../shared/widgets/x_top_bar.dart';
+import '../data/customer_api.dart';
+import '../data/customer_models.dart';
+
+/// 客户 Tab,对应 `components/tabs/CustomerTab.vue`。
+class CustomerTab extends ConsumerStatefulWidget {
+ const CustomerTab({super.key});
+
+ @override
+ ConsumerState createState() => _CustomerTabState();
+}
+
+class _CustomerTabState extends ConsumerState {
+ static const _statusOptions = <({String label, int? value})>[
+ (label: '全部', value: null),
+ (label: '意向', value: 1),
+ (label: '签约', value: 2),
+ (label: '运维中', value: 3),
+ (label: '终止', value: 4),
+ ];
+
+ static const _levelOptions = <({String label, String? value})>[
+ (label: '全部', value: null),
+ (label: '医院', value: '医院'),
+ (label: '学校', value: '学校'),
+ (label: '企业', value: '企业'),
+ ];
+
+ final _searchController = TextEditingController();
+
+ Timer? _searchTimer;
+ List _list = const [];
+ bool _loading = false;
+ int? _filterStatus;
+ String? _filterLevel;
+
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addPostFrameCallback((_) => _load());
+ }
+
+ @override
+ void dispose() {
+ _searchTimer?.cancel();
+ _searchController.dispose();
+ super.dispose();
+ }
+
+ Future _load() async {
+ setState(() => _loading = true);
+ try {
+ // 列表由后端按 keyword / status 过滤;设施类型后端未提供查询条件,前端过滤
+ final keyword = _searchController.text.trim();
+ final res = await ref
+ .read(customerApiProvider)
+ .list(
+ page: 1,
+ pageSize: 100,
+ keyword: keyword.isEmpty ? null : keyword,
+ status: _filterStatus,
+ );
+ if (!mounted) return;
+ if (res.isOk && res.data != null) {
+ setState(() => _list = res.data!.list);
+ }
+ } catch (_) {
+ showAppToast('加载客户失败');
+ } finally {
+ if (mounted) setState(() => _loading = false);
+ }
+ }
+
+ void _onSearchChanged(String _) {
+ _searchTimer?.cancel();
+ _searchTimer = Timer(const Duration(milliseconds: 350), _load);
+ }
+
+ void _onStatusChip(int? value) {
+ if (_filterStatus == value) return;
+ setState(() => _filterStatus = value);
+ _load();
+ }
+
+ String? _statusAction(int status) {
+ if (status == 1) return '跟进展';
+ if (status == 2) return '安排';
+ return null;
+ }
+
+ Future _goDetail(Customer c) async {
+ await context.push('/customer/detail/${c.id}');
+ if (mounted) _load();
+ }
+
+ Future _goEdit(Customer c) async {
+ await context.push('/customer/edit?id=${c.id}');
+ if (mounted) _load();
+ }
+
+ Future _goCreate() async {
+ await context.push('/customer/edit');
+ if (mounted) _load();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ ref.listen(tabIndexProvider, (previous, next) {
+ if (next == AppTab.customer) _load();
+ });
+
+ final visible = _filterLevel == null
+ ? _list
+ : _list.where((c) => c.customerLevel == _filterLevel).toList();
+
+ return Stack(
+ children: [
+ XPageShell(
+ topBar: XTopBar.brand(
+ title: '鑫潼智电',
+ trailingColor: AppColors.primary,
+ ),
+ child: Column(
+ children: [
+ _buildPageHead(),
+ _buildSearchBar(),
+ _buildFilters(),
+ Expanded(child: _buildList(visible)),
+ ],
+ ),
+ ),
+ Positioned(
+ right: 20,
+ bottom: 110,
+ child: GestureDetector(
+ onTap: _goCreate,
+ behavior: HitTestBehavior.opaque,
+ child: Container(
+ width: 52,
+ height: 52,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: AppColors.primary,
+ borderRadius: BorderRadius.circular(14),
+ boxShadow: const [
+ BoxShadow(
+ color: Color(0x4D0054CD),
+ blurRadius: 16,
+ offset: Offset(0, 6),
+ ),
+ ],
+ ),
+ child: const XIcon('add', size: 24, color: Colors.white),
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+
+ Widget _buildPageHead() {
+ return Padding(
+ padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text('客户', style: AppTypography.headlineMd),
+ Padding(
+ padding: const EdgeInsets.only(top: 2),
+ child: const Text(
+ '管理和跟踪您的运营客户组合',
+ style: TextStyle(fontSize: 14, color: AppColors.textMuted),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildSearchBar() {
+ return Padding(
+ padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
+ child: XSearchField(
+ controller: _searchController,
+ hintText: '按客户名称、联系人或电话搜索...',
+ onChanged: _onSearchChanged,
+ ),
+ );
+ }
+
+ Widget _buildFilters() {
+ return Padding(
+ padding: const EdgeInsets.only(top: 4),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ _buildFilterGroup(
+ '生命周期状态',
+ Row(
+ children: [
+ for (final option in _statusOptions)
+ Padding(
+ padding: const EdgeInsets.only(right: 8),
+ child: XFilterChip(
+ label: option.label,
+ selected: _filterStatus == option.value,
+ onTap: () => _onStatusChip(option.value),
+ ),
+ ),
+ ],
+ ),
+ ),
+ _buildFilterGroup(
+ '设施类型',
+ Row(
+ children: [
+ for (final option in _levelOptions)
+ Padding(
+ padding: const EdgeInsets.only(right: 8),
+ child: XFilterChip(
+ label: option.label,
+ selected: _filterLevel == option.value,
+ onTap: () => setState(() => _filterLevel = option.value),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildFilterGroup(String label, Widget chips) {
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 8),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(left: 16, right: 16, bottom: 6),
+ child: Text(
+ label,
+ style: AppTypography.captionSm.copyWith(
+ color: AppColors.textMuted,
+ letterSpacing: 1,
+ ),
+ ),
+ ),
+ SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: chips,
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildList(List visible) {
+ if (_loading && _list.isEmpty) {
+ return const XPlaceholder(text: '加载中...');
+ }
+ if (visible.isEmpty) {
+ return const XPlaceholder(text: '暂无客户');
+ }
+ return RefreshIndicator(
+ color: AppColors.primary,
+ onRefresh: _load,
+ child: ListView.builder(
+ padding: const EdgeInsets.fromLTRB(16, 12, 16, 140),
+ itemCount: visible.length,
+ itemBuilder: (context, index) => _customerCard(visible[index]),
+ ),
+ );
+ }
+
+ Widget _customerCard(Customer c) {
+ final action = _statusAction(c.status);
+
+ return XCard(
+ padding: EdgeInsets.zero,
+ margin: const EdgeInsets.only(bottom: 12),
+ onTap: () => _goDetail(c),
+ child: Stack(
+ children: [
+ // 左侧状态色条 / 右侧编号面板均贴边,圆角按卡片圆角裁出
+ Positioned(
+ left: 0,
+ top: 0,
+ bottom: 0,
+ width: 4,
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ color: customerStatusBarColor(c.status),
+ borderRadius: const BorderRadius.horizontal(
+ left: Radius.circular(AppRadius.lg),
+ ),
+ ),
+ ),
+ ),
+ const Positioned(
+ right: 0,
+ top: 0,
+ bottom: 0,
+ width: 86,
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ color: AppColors.surfaceBright,
+ borderRadius: BorderRadius.horizontal(
+ right: Radius.circular(AppRadius.lg),
+ ),
+ ),
+ ),
+ ),
+ Padding(
+ padding: const EdgeInsets.only(left: 4, right: 86),
+ child: Padding(
+ padding: const EdgeInsets.all(14),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Expanded(
+ child: Wrap(
+ spacing: 6,
+ runSpacing: 6,
+ crossAxisAlignment: WrapCrossAlignment.center,
+ children: [
+ Text(
+ c.customerName,
+ style: const TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w700,
+ color: AppColors.onSurface,
+ ),
+ ),
+ XChip(
+ customerStatusText(c.status),
+ tone: customerStatusTone(c.status),
+ ),
+ if (c.customerLevel.isNotEmpty)
+ XChip(c.customerLevel, tone: ChipTone.muted),
+ ],
+ ),
+ ),
+ const SizedBox(width: 6),
+ const XIcon('more', size: 20, color: AppColors.textMuted),
+ ],
+ ),
+ const SizedBox(height: 10),
+ Row(
+ children: [
+ const XIcon(
+ 'location',
+ size: 16,
+ color: AppColors.textMuted,
+ ),
+ const SizedBox(width: 4),
+ Text(
+ c.locationText,
+ style: const TextStyle(
+ fontSize: 14,
+ color: AppColors.textMuted,
+ ),
+ ),
+ const SizedBox(width: 16),
+ const XIcon('person', size: 16, color: AppColors.textMuted),
+ const SizedBox(width: 4),
+ Text(
+ c.contactPerson.isEmpty ? '-' : c.contactPerson,
+ style: const TextStyle(
+ fontSize: 14,
+ color: AppColors.textMuted,
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ Positioned(
+ right: 0,
+ top: 0,
+ bottom: 0,
+ width: 86,
+ child: Padding(
+ padding: const EdgeInsets.all(12),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ '客户编号',
+ style: TextStyle(fontSize: 11, color: AppColors.textMuted),
+ ),
+ const SizedBox(height: 6),
+ Text(
+ c.customerNo,
+ style: AppTypography.dataMono.copyWith(
+ fontSize: 11,
+ color: AppColors.onSurface,
+ ),
+ ),
+ if (action != null) ...[
+ const SizedBox(height: 6),
+ GestureDetector(
+ onTap: () => _goEdit(c),
+ behavior: HitTestBehavior.opaque,
+ child: Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 8,
+ vertical: 4,
+ ),
+ decoration: BoxDecoration(
+ color: const Color(0x1A712AE2),
+ borderRadius: BorderRadius.circular(AppRadius.regular),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const XIcon(
+ 'calendar',
+ size: 14,
+ color: AppColors.secondary,
+ ),
+ const SizedBox(width: 2),
+ Text(
+ action,
+ style: AppTypography.captionSm.copyWith(
+ color: AppColors.secondary,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
\ No newline at end of file
diff --git a/lib/features/dashboard/data/dashboard_api.dart b/lib/features/dashboard/data/dashboard_api.dart
new file mode 100644
index 0000000..9b96a56
--- /dev/null
+++ b/lib/features/dashboard/data/dashboard_api.dart
@@ -0,0 +1,22 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../../../core/network/api_client.dart';
+import '../../../core/network/api_response.dart';
+import '../../../core/providers.dart';
+import 'dashboard_models.dart';
+
+final dashboardApiProvider = Provider(
+ (ref) => DashboardApi(ref.watch(apiClientProvider)),
+);
+
+/// 工作台统计接口,对应 uni-app 版 `api/dashboard.ts`。
+class DashboardApi {
+ DashboardApi(this._client);
+
+ final ApiClient _client;
+
+ Future> stats() => _client.get(
+ '/dashboard/stats',
+ parse: (d) => DashboardStats.fromJson((d as Map).cast()),
+ );
+}
\ No newline at end of file
diff --git a/lib/features/dashboard/data/dashboard_models.dart b/lib/features/dashboard/data/dashboard_models.dart
new file mode 100644
index 0000000..ee5aa78
--- /dev/null
+++ b/lib/features/dashboard/data/dashboard_models.dart
@@ -0,0 +1,159 @@
+import '../../../core/utils/id_util.dart';
+
+/// 名称/数值二元组(勘察状态分布、模块统计、客户状态分布)。
+class NameValue {
+ const NameValue({required this.name, required this.value});
+
+ final String name;
+ final int value;
+
+ factory NameValue.fromJson(Map json) => NameValue(
+ name: json['name'] as String? ?? '',
+ value: (json['value'] as num?)?.toInt() ?? 0,
+ );
+}
+
+/// 近期勘察派单流水。
+class DashboardSurveyItem {
+ const DashboardSurveyItem({
+ required this.id,
+ required this.orderNo,
+ required this.customerName,
+ required this.locale,
+ required this.owner,
+ required this.status,
+ required this.time,
+ });
+
+ final int id;
+ final String orderNo;
+ final String customerName;
+ final String locale;
+ final String owner;
+
+ /// 0 待指派 / 1 现场勘察中 / 2 待技术复核 / 3 已归档 / 5 已取消
+ final int status;
+ final String time;
+
+ factory DashboardSurveyItem.fromJson(Map json) =>
+ DashboardSurveyItem(
+ id: toIntOr(json['id'], 0),
+ orderNo: json['orderNo'] as String? ?? '',
+ customerName: json['customerName'] as String? ?? '',
+ locale: json['locale'] as String? ?? '',
+ owner: json['owner'] as String? ?? '',
+ status: toIntOr(json['status'], 0),
+ time: json['time'] as String? ?? '',
+ );
+}
+
+/// 智电工程推进看板(contract 聚合)。
+class DashboardProjectItem {
+ const DashboardProjectItem({
+ required this.id,
+ required this.customerName,
+ required this.amount,
+ required this.stage,
+ required this.stageLabel,
+ required this.progress,
+ required this.owner,
+ required this.desc,
+ required this.orderNo,
+ });
+
+ final int id;
+ final String customerName;
+ final num amount;
+ final int stage;
+ final String stageLabel;
+ final int progress;
+ final String owner;
+ final String desc;
+ final String orderNo;
+
+ factory DashboardProjectItem.fromJson(Map json) =>
+ DashboardProjectItem(
+ id: toIntOr(json['id'], 0),
+ customerName: json['customerName'] as String? ?? '',
+ amount: (json['amount'] as num?) ?? 0,
+ stage: toIntOr(json['stage'], 0),
+ stageLabel: json['stageLabel'] as String? ?? '',
+ progress: toIntOr(json['progress'], 0),
+ owner: json['owner'] as String? ?? '',
+ desc: json['desc'] as String? ?? '',
+ orderNo: json['orderNo'] as String? ?? '',
+ );
+}
+
+/// `/api/dashboard/stats` 真实返回体(与后端 dashboard.service.ts 对齐)。
+class DashboardStats {
+ const DashboardStats({
+ required this.customerTotal,
+ required this.customerMonthlyNew,
+ required this.contractTotal,
+ required this.contractYearAmount,
+ required this.surveyPending,
+ required this.surveyTotal,
+ required this.visitMonthly,
+ required this.visitTotal,
+ required this.planTotal,
+ required this.acceptTotal,
+ required this.surveyByStatus,
+ required this.modules,
+ required this.trend,
+ required this.customerStatus,
+ required this.customerSignRate,
+ required this.surveyList,
+ required this.projects,
+ });
+
+ final int customerTotal;
+ final int customerMonthlyNew;
+ final int contractTotal;
+ final num contractYearAmount;
+ final int surveyPending;
+ final int surveyTotal;
+ final int visitMonthly;
+ final int visitTotal;
+ final int planTotal;
+ final int acceptTotal;
+ final List surveyByStatus;
+ final List modules;
+ final List customerStatus;
+ final num customerSignRate;
+ final List surveyList;
+ final List projects;
+
+ /// 趋势项字段为 `{ date, customers, contracts }`,App 端暂未使用,保留原始结构。
+ final List> trend;
+
+ factory DashboardStats.fromJson(Map json) => DashboardStats(
+ customerTotal: toIntOr(json['customerTotal'], 0),
+ customerMonthlyNew: toIntOr(json['customerMonthlyNew'], 0),
+ contractTotal: toIntOr(json['contractTotal'], 0),
+ contractYearAmount: (json['contractYearAmount'] as num?) ?? 0,
+ surveyPending: toIntOr(json['surveyPending'], 0),
+ surveyTotal: toIntOr(json['surveyTotal'], 0),
+ visitMonthly: toIntOr(json['visitMonthly'], 0),
+ visitTotal: toIntOr(json['visitTotal'], 0),
+ planTotal: toIntOr(json['planTotal'], 0),
+ acceptTotal: toIntOr(json['acceptTotal'], 0),
+ surveyByStatus: _nameValues(json['surveyByStatus']),
+ modules: _nameValues(json['modules']),
+ trend: (json['trend'] as List? ?? [])
+ .map((e) => (e as Map).cast())
+ .toList(),
+ customerStatus: _nameValues(json['customerStatus']),
+ customerSignRate: (json['customerSignRate'] as num?) ?? 0,
+ surveyList: (json['surveyList'] as List? ?? [])
+ .map((e) => DashboardSurveyItem.fromJson((e as Map).cast()))
+ .toList(),
+ projects: (json['projects'] as List? ?? [])
+ .map((e) => DashboardProjectItem.fromJson((e as Map).cast()))
+ .toList(),
+ );
+
+ static List _nameValues(dynamic raw) => (raw as List? ?? [])
+ .map((e) => NameValue.fromJson((e as Map).cast()))
+ .toList();
+}
\ No newline at end of file
diff --git a/lib/features/mine/presentation/mine_tab.dart b/lib/features/mine/presentation/mine_tab.dart
new file mode 100644
index 0000000..a432309
--- /dev/null
+++ b/lib/features/mine/presentation/mine_tab.dart
@@ -0,0 +1,345 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+
+import '../../../app/tab_index.dart';
+import '../../../core/theme/app_colors.dart';
+import '../../../shared/widgets/app_feedback.dart';
+import '../../../shared/widgets/x_card.dart';
+import '../../../shared/widgets/x_chip.dart';
+import '../../../shared/widgets/x_common.dart';
+import '../../../shared/widgets/x_icon.dart';
+import '../../../shared/widgets/x_top_bar.dart';
+import '../../auth/application/session_controller.dart';
+import '../../auth/data/auth_models.dart';
+import '../../customer/data/customer_api.dart';
+import '../../dashboard/data/dashboard_api.dart';
+
+/// 「我的」功能入口配置。
+typedef _MenuItem = ({
+ String label,
+ String icon,
+ Color background,
+ Color color,
+ VoidCallback onTap,
+});
+
+/// 我的 Tab,对应 `components/tabs/MineTab.vue`。
+class MineTab extends ConsumerStatefulWidget {
+ const MineTab({super.key});
+
+ @override
+ ConsumerState createState() => _MineTabState();
+}
+
+class _MineTabState extends ConsumerState {
+ int _customerCount = 0;
+ int _activeProjects = 0;
+ int _surveys = 0;
+
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addPostFrameCallback((_) => _load());
+ }
+
+ Future _load() async {
+ var customerCount = _customerCount;
+ var activeProjects = _activeProjects;
+ var surveys = _surveys;
+
+ // 客户总数:独立接口
+ try {
+ final res = await ref
+ .read(customerApiProvider)
+ .list(page: 1, pageSize: 1);
+ if (res.isOk) customerCount = res.data?.total ?? 0;
+ } catch (_) {
+ // 静默刷新:请求失败时保留已渲染的数据
+ }
+
+ // 进行中项目 + 勘测任务:一次 dashboard 请求
+ try {
+ final res = await ref.read(dashboardApiProvider).stats();
+ final dash = res.isOk ? res.data : null;
+ if (dash != null) {
+ activeProjects = dash.contractTotal;
+ surveys = dash.surveyTotal;
+ }
+ } catch (_) {
+ // 静默刷新:请求失败时保留已渲染的数据
+ }
+
+ if (!mounted) return;
+ setState(() {
+ _customerCount = customerCount;
+ _activeProjects = activeProjects;
+ _surveys = surveys;
+ });
+ }
+
+ Future _goSurvey() async {
+ await context.push('/survey');
+ if (mounted) _load();
+ }
+
+ Future _onLogout() async {
+ final confirmed = await showAppConfirm(
+ title: '退出登录',
+ content: '确定要退出当前账号吗?',
+ destructive: true,
+ );
+ if (!confirmed) return;
+ await ref.read(sessionProvider.notifier).logout();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ ref.listen(tabIndexProvider, (previous, next) {
+ if (next == AppTab.mine) _load();
+ });
+
+ final user = ref.watch(sessionProvider).user;
+ final displayName = user?.displayName ?? '陈伟';
+ final avatarText = user?.avatarText ?? '陈';
+ final roleText = user == null ? '高级销售工程师' : roleLabel(user.roles);
+
+ final menus = <_MenuItem>[
+ (
+ label: '客户管理',
+ icon: 'groups',
+ background: const Color(0x140054CD),
+ color: AppColors.primary,
+ onTap: () =>
+ ref.read(tabIndexProvider.notifier).select(AppTab.customer),
+ ),
+ (
+ label: '项目中心',
+ icon: 'calendar',
+ background: const Color(0x14712AE2),
+ color: AppColors.secondary,
+ onTap: () =>
+ ref.read(tabIndexProvider.notifier).select(AppTab.project),
+ ),
+ (
+ label: '勘测任务',
+ icon: 'camera',
+ background: const Color(0x1400685F),
+ color: AppColors.tertiary,
+ onTap: _goSurvey,
+ ),
+ (
+ label: '设置',
+ icon: 'more',
+ background: const Color(0x14E0852B),
+ color: AppColors.statusWarn,
+ onTap: () => showAppToast('设置功能即将上线'),
+ ),
+ ];
+
+ return XPageShell(
+ topBar: XTopBar.brand(title: '我的'),
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.fromLTRB(16, 16, 16, 130),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ _buildUserCard(avatarText, displayName, roleText),
+ _buildStats(),
+ XCard(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
+ child: Column(
+ children: [
+ for (var i = 0; i < menus.length; i++) ...[
+ if (i > 0)
+ const Divider(
+ height: 1,
+ thickness: 1,
+ color: AppColors.borderRule,
+ ),
+ _menuItem(menus[i]),
+ ],
+ ],
+ ),
+ ),
+ XPrimaryButton(
+ label: '退出登录',
+ onPressed: _onLogout,
+ height: 46,
+ margin: const EdgeInsets.only(top: 28, bottom: 40),
+ background: AppColors.surface,
+ foreground: AppColors.statusError,
+ borderColor: AppColors.statusError,
+ fontSize: 15,
+ pill: false,
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildUserCard(String avatarText, String displayName, String roleText) {
+ return XCard(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
+ margin: const EdgeInsets.only(bottom: 14),
+ child: Row(
+ children: [
+ Container(
+ width: 60,
+ height: 60,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: AppColors.surfaceContainerHigh,
+ shape: BoxShape.circle,
+ border: Border.all(color: AppColors.primaryFixedDim, width: 2),
+ ),
+ child: Text(
+ avatarText,
+ style: const TextStyle(
+ fontSize: 26,
+ fontWeight: FontWeight.w700,
+ color: AppColors.primary,
+ ),
+ ),
+ ),
+ const SizedBox(width: 14),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ displayName,
+ style: const TextStyle(
+ fontSize: 19,
+ fontWeight: FontWeight.w700,
+ color: AppColors.onSurface,
+ ),
+ ),
+ Padding(
+ padding: const EdgeInsets.only(top: 2),
+ child: Text(
+ roleText,
+ style: const TextStyle(
+ fontSize: 13,
+ color: AppColors.textMuted,
+ ),
+ ),
+ ),
+ const Padding(
+ padding: EdgeInsets.only(top: 8),
+ child: XChip(
+ '在线',
+ tone: ChipTone.tertiary,
+ leading: _OnlineDot(),
+ ),
+ ),
+ ],
+ ),
+ ),
+ const XIcon('forward', size: 20, color: AppColors.onSurfaceVariant),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildStats() {
+ return XCard(
+ padding: const EdgeInsets.symmetric(vertical: 20),
+ margin: const EdgeInsets.only(bottom: 14),
+ child: Row(
+ children: [
+ _statCell('$_customerCount', '我的客户'),
+ const _StatDivider(),
+ _statCell('$_activeProjects', '进行中项目'),
+ const _StatDivider(),
+ _statCell('$_surveys', '勘测任务'),
+ ],
+ ),
+ );
+ }
+
+ Widget _statCell(String value, String label) {
+ return Expanded(
+ child: Column(
+ children: [
+ Text(
+ value,
+ style: const TextStyle(
+ fontSize: 22,
+ fontWeight: FontWeight.w700,
+ color: AppColors.onSurface,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ label,
+ style: const TextStyle(fontSize: 12, color: AppColors.textMuted),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _menuItem(_MenuItem menu) {
+ return GestureDetector(
+ onTap: menu.onTap,
+ behavior: HitTestBehavior.opaque,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(vertical: 15),
+ child: Row(
+ children: [
+ Container(
+ width: 36,
+ height: 36,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: menu.background,
+ borderRadius: BorderRadius.circular(10),
+ ),
+ child: XIcon(menu.icon, size: 20, color: menu.color),
+ ),
+ const SizedBox(width: 14),
+ Expanded(
+ child: Text(
+ menu.label,
+ style: const TextStyle(
+ fontSize: 15,
+ color: AppColors.onSurface,
+ ),
+ ),
+ ),
+ const XIcon('forward', size: 18, color: AppColors.outline),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+/// 个人信息卡「在线」chip 左侧的状态圆点。
+class _OnlineDot extends StatelessWidget {
+ const _OnlineDot();
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ width: 7,
+ height: 7,
+ decoration: const BoxDecoration(
+ shape: BoxShape.circle,
+ color: AppColors.tertiary,
+ ),
+ );
+ }
+}
+
+/// 统计卡内的 1px 分隔线。
+class _StatDivider extends StatelessWidget {
+ const _StatDivider();
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(width: 1, height: 32, color: AppColors.borderRule);
+ }
+}
\ No newline at end of file
diff --git a/lib/features/project/data/project_api.dart b/lib/features/project/data/project_api.dart
new file mode 100644
index 0000000..a175833
--- /dev/null
+++ b/lib/features/project/data/project_api.dart
@@ -0,0 +1,73 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../../../core/network/api_client.dart';
+import '../../../core/network/api_response.dart';
+import '../../../core/providers.dart';
+import 'project_models.dart';
+
+final projectApiProvider = Provider(
+ (ref) => ProjectApi(ref.watch(apiClientProvider)),
+);
+
+/// 智电改造方案接口,对应后端 `/api/project-plans`。
+///
+/// 说明:uni-app 版项目模块使用本地 mock(`api/mock.ts`),字段与后端契约完全不一致;
+/// Flutter 版按用户确认改为直连后端真实接口。
+class ProjectApi {
+ ProjectApi(this._client);
+
+ final ApiClient _client;
+
+ Future> list({
+ int page = 1,
+ int pageSize = 100,
+ String? keyword,
+ int? customerId,
+ int? status,
+ String? registerStartDate,
+ String? registerEndDate,
+ }) => _client.get(
+ '/project-plans',
+ query: {
+ 'page': page,
+ 'pageSize': pageSize,
+ 'keyword': keyword,
+ 'customerId': customerId,
+ 'status': status,
+ 'registerStartDate': registerStartDate,
+ 'registerEndDate': registerEndDate,
+ },
+ parse: (d) {
+ final m = (d as Map).cast();
+ return ProjectPlanPage(
+ list: (m['list'] as List? ?? [])
+ .map((e) => ProjectPlan.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(
+ '/project-plans/$id',
+ parse: (d) => ProjectPlan.fromJson((d as Map).cast()),
+ );
+
+ Future> create(Map body) =>
+ _client.post(
+ '/project-plans',
+ body: body,
+ parse: (d) => ProjectPlan.fromJson((d as Map).cast()),
+ );
+
+ Future> update(int id, Map body) =>
+ _client.put(
+ '/project-plans/$id',
+ body: body,
+ parse: (d) => ProjectPlan.fromJson((d as Map).cast()),
+ );
+
+ Future> delete(int id) => _client.delete('/project-plans/$id');
+}
\ No newline at end of file
diff --git a/lib/features/project/data/project_models.dart b/lib/features/project/data/project_models.dart
new file mode 100644
index 0000000..e816a22
--- /dev/null
+++ b/lib/features/project/data/project_models.dart
@@ -0,0 +1,150 @@
+import 'package:flutter/painting.dart';
+
+import '../../../core/theme/app_colors.dart';
+import '../../../core/utils/id_util.dart';
+
+/// 方案状态:0 草稿 1 已确认。
+abstract final class ProjectPlanStatus {
+ static const draft = 0;
+ static const confirmed = 1;
+}
+
+const projectStatusMap = {
+ 0: (text: '草稿', tone: ChipTone.warn),
+ 1: (text: '已确认', tone: ChipTone.primary),
+};
+
+String projectStatusText(int? s) => projectStatusMap[s]?.text ?? '未知';
+
+ChipTone projectStatusTone(int? s) => projectStatusMap[s]?.tone ?? ChipTone.muted;
+
+/// 列表卡片左侧色条颜色。
+Color projectStatusBarColor(int? status) => switch (status) {
+ 0 => AppColors.statusWarn,
+ 1 => AppColors.primary,
+ _ => AppColors.borderRule,
+};
+
+/// 项目方案设备明细,对应后端 `project_plan_detail` 表。
+class ProjectPlanDetail {
+ const ProjectPlanDetail({
+ required this.id,
+ required this.planId,
+ required this.customerId,
+ required this.equipName,
+ required this.quantity,
+ this.equipId,
+ this.smartDeviceId,
+ this.remark,
+ });
+
+ final int id;
+ final int planId;
+ final int customerId;
+ final String equipName;
+ final num quantity;
+ final int? equipId;
+ final int? smartDeviceId;
+ final String? remark;
+
+ factory ProjectPlanDetail.fromJson(Map json) => ProjectPlanDetail(
+ id: toIntOr(json['id'], 0),
+ planId: toIntOr(json['planId'], 0),
+ customerId: toIntOr(json['customerId'], 0),
+ equipName: json['equipName'] as String? ?? '',
+ quantity: (json['quantity'] as num?) ?? 1,
+ equipId: toIntOrNull(json['equipId']),
+ smartDeviceId: toIntOrNull(json['smartDeviceId']),
+ remark: json['remark'] as String?,
+ );
+
+ Map toJson() => {
+ 'equipName': equipName,
+ 'quantity': quantity,
+ if (equipId != null) 'equipId': equipId,
+ if (smartDeviceId != null) 'smartDeviceId': smartDeviceId,
+ if (remark != null && remark!.isNotEmpty) 'remark': remark,
+ };
+
+ ProjectPlanDetail copyWith({String? equipName, num? quantity, String? remark}) =>
+ ProjectPlanDetail(
+ id: id,
+ planId: planId,
+ customerId: customerId,
+ equipName: equipName ?? this.equipName,
+ quantity: quantity ?? this.quantity,
+ equipId: equipId,
+ smartDeviceId: smartDeviceId,
+ remark: remark ?? this.remark,
+ );
+}
+
+/// 智电改造方案,与后端 `/api/project-plans` 契约对齐。
+class ProjectPlan {
+ const ProjectPlan({
+ required this.id,
+ required this.planNo,
+ required this.customerId,
+ required this.status,
+ this.registrar,
+ this.registerTime,
+ this.planDesc,
+ this.planFileUrl,
+ this.createdAt,
+ this.details = const [],
+ });
+
+ final int id;
+ final String planNo;
+ final int customerId;
+
+ /// 方案登记人(业务员姓名文本)
+ final String? registrar;
+
+ /// 方案登记时间(DATEONLY,`YYYY-MM-DD`)
+ final String? registerTime;
+ final String? planDesc;
+
+ /// 方案最终文件 URL
+ final String? planFileUrl;
+ final String? createdAt;
+
+ /// 设备明细,仅详情接口返回
+ final List details;
+ final int status;
+
+ factory ProjectPlan.fromJson(Map json) => ProjectPlan(
+ id: toIntOr(json['id'], 0),
+ planNo: json['planNo'] as String? ?? '',
+ customerId: toIntOr(json['customerId'], 0),
+ registrar: json['registrar'] as String?,
+ registerTime: json['registerTime'] as String?,
+ planDesc: json['planDesc'] as String?,
+ planFileUrl: json['planFileUrl'] as String?,
+ createdAt: json['created_at'] as String?,
+ details: (json['details'] as List? ?? [])
+ .map((e) => ProjectPlanDetail.fromJson((e as Map).cast()))
+ .toList(),
+ status: toIntOr(json['status'], 0),
+ );
+
+ /// 设备明细合计数量。
+ num get detailQuantityTotal =>
+ details.fold(0, (sum, d) => sum + d.quantity);
+
+ String get displayTitle => planNo.isNotEmpty ? planNo : '方案 #$id';
+}
+
+class ProjectPlanPage {
+ const ProjectPlanPage({
+ required this.list,
+ required this.total,
+ required this.page,
+ required this.pageSize,
+ });
+
+ final List list;
+ final int total;
+ final int page;
+ final int pageSize;
+}
\ No newline at end of file
diff --git a/lib/features/project/presentation/project_detail_page.dart b/lib/features/project/presentation/project_detail_page.dart
new file mode 100644
index 0000000..1801dd0
--- /dev/null
+++ b/lib/features/project/presentation/project_detail_page.dart
@@ -0,0 +1,338 @@
+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_typography.dart';
+import '../../../core/utils/format_util.dart';
+import '../../../shared/widgets/app_feedback.dart';
+import '../../../shared/widgets/x_card.dart';
+import '../../../shared/widgets/x_chip.dart';
+import '../../../shared/widgets/x_common.dart';
+import '../../../shared/widgets/x_icon.dart';
+import '../../../shared/widgets/x_top_bar.dart';
+import '../../customer/data/customer_api.dart';
+import '../data/project_api.dart';
+import '../data/project_models.dart';
+
+/// 项目详情页。
+///
+/// 源 `pages/project/detail.vue` 基于 mock 数据(团队 / 里程碑 / 设备概览等),
+/// 这里按用户确认改为直连后端 `/api/project-plans/:id`,保留信息卡 + 分组 + 明细列表的视觉语言。
+class ProjectDetailPage extends ConsumerStatefulWidget {
+ const ProjectDetailPage({super.key, required this.id});
+
+ final int id;
+
+ @override
+ ConsumerState createState() => _ProjectDetailPageState();
+}
+
+class _ProjectDetailPageState extends ConsumerState {
+ ProjectPlan? _plan;
+ String? _customerName;
+ bool _loading = true;
+
+ @override
+ void initState() {
+ super.initState();
+ _load();
+ }
+
+ Future _load() async {
+ setState(() => _loading = true);
+ try {
+ final res = await ref.read(projectApiProvider).detail(widget.id);
+ if (!mounted) return;
+ if (res.isOk && res.data != null) {
+ setState(() => _plan = res.data);
+ await _loadCustomer(res.data!.customerId);
+ } else {
+ showAppToast(res.message.isEmpty ? '方案不存在' : res.message);
+ }
+ } catch (_) {
+ if (mounted) showAppToast('加载失败');
+ } finally {
+ if (mounted) setState(() => _loading = false);
+ }
+ }
+
+ Future _loadCustomer(int customerId) async {
+ try {
+ final res = await ref.read(customerApiProvider).detail(customerId);
+ if (!mounted) return;
+ if (res.isOk && res.data != null) {
+ setState(() => _customerName = res.data!.customerName);
+ }
+ } catch (_) {
+ // 客户名称获取失败时降级为「客户 #id」
+ }
+ }
+
+ String _customerLabel(int customerId) {
+ final name = _customerName;
+ return (name == null || name.isEmpty) ? '客户 #$customerId' : name;
+ }
+
+ String _textOrDash(String? value) =>
+ (value == null || value.isEmpty) ? '-' : value;
+
+ String _formatQty(num quantity) => quantity == quantity.roundToDouble()
+ ? quantity.toInt().toString()
+ : quantity.toString();
+
+ @override
+ Widget build(BuildContext context) {
+ final planNo = _plan?.planNo;
+ return XPageShell(
+ topBar: XTopBar.nav(
+ title: (planNo == null || planNo.isEmpty) ? '方案详情' : planNo,
+ onBack: () => context.pop(),
+ ),
+ child: _buildBody(),
+ );
+ }
+
+ Widget _buildBody() {
+ final p = _plan;
+ if (p == null) {
+ return XPlaceholder(text: _loading ? '加载中...' : '方案不存在');
+ }
+
+ return SingleChildScrollView(
+ padding: const EdgeInsets.fromLTRB(16, 16, 16, 60),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ _buildHeaderCard(p),
+ _buildInfoCard(p),
+ _buildDetailSection(p),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildHeaderCard(ProjectPlan p) {
+ return XCard(
+ padding: const EdgeInsets.all(16),
+ margin: const EdgeInsets.only(bottom: 12),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Wrap(
+ spacing: 6,
+ runSpacing: 6,
+ crossAxisAlignment: WrapCrossAlignment.center,
+ children: [
+ Text(
+ _customerLabel(p.customerId),
+ style: AppTypography.headlineMdMobile.copyWith(
+ color: AppColors.onSurface,
+ ),
+ ),
+ XChip(
+ projectStatusText(p.status),
+ tone: projectStatusTone(p.status),
+ ),
+ ],
+ ),
+ const SizedBox(height: 10),
+ Row(
+ children: [
+ Text(
+ '方案单号',
+ style: AppTypography.bodyMd.copyWith(
+ fontSize: 12,
+ color: AppColors.textMuted,
+ ),
+ ),
+ const SizedBox(width: 8),
+ Text(
+ p.displayTitle,
+ style: AppTypography.dataMono.copyWith(
+ color: AppColors.onSurface,
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildInfoCard(ProjectPlan p) {
+ final items = [
+ _infoItem('person', '登记人', _textOrDash(p.registrar)),
+ _infoItem('calendar', '登记时间', formatDate(p.registerTime)),
+ _infoItem('clock', '创建时间', formatDateTime(p.createdAt)),
+ _infoItem('category', '方案说明', _textOrDash(p.planDesc)),
+ ];
+
+ return XCard(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
+ child: Column(
+ children: [
+ for (var i = 0; i < items.length; i++) ...[
+ if (i > 0) Container(height: 1, color: AppColors.borderRule),
+ items[i],
+ ],
+ if (p.planFileUrl != null && p.planFileUrl!.isNotEmpty) ...[
+ Container(height: 1, color: AppColors.borderRule),
+ _buildFileRow(p.planFileUrl!),
+ ],
+ ],
+ ),
+ );
+ }
+
+ Widget _infoItem(String icon, String label, String value) {
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 13),
+ child: Row(
+ children: [
+ XIcon(icon, size: 18, color: AppColors.textMuted),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ label,
+ style: AppTypography.bodyMd.copyWith(
+ fontSize: 12,
+ color: AppColors.textMuted,
+ ),
+ ),
+ Text(
+ value,
+ style: AppTypography.bodyMd.copyWith(
+ color: AppColors.onSurface,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ /// 方案文件:源页面用 uni 下载打开,Flutter 未引入 url_launcher,这里仅展示链接文本。
+ Widget _buildFileRow(String url) {
+ return GestureDetector(
+ onTap: () => showAppToast(url),
+ behavior: HitTestBehavior.opaque,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(vertical: 13),
+ child: Row(
+ children: [
+ const XIcon('domain', size: 18, color: AppColors.textMuted),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ '方案文件',
+ style: AppTypography.bodyMd.copyWith(
+ fontSize: 12,
+ color: AppColors.textMuted,
+ ),
+ ),
+ Text(
+ url,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: AppTypography.bodyMd.copyWith(
+ color: AppColors.primary,
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(width: 6),
+ const XIcon('forward', size: 16, color: AppColors.textPlaceholder),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildDetailSection(ProjectPlan p) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(top: 22, bottom: 10),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ const XSectionTitle('设备明细', icon: 'category', fontSize: 16),
+ Text(
+ '合计 ${_formatQty(p.detailQuantityTotal)}',
+ style: AppTypography.dataMono.copyWith(
+ color: AppColors.primary,
+ ),
+ ),
+ ],
+ ),
+ ),
+ if (p.details.isEmpty)
+ const XPlaceholder(text: '暂无设备明细', dashed: true)
+ else
+ _buildDetailCard(p.details),
+ ],
+ );
+ }
+
+ Widget _buildDetailCard(List details) {
+ final items = [];
+ for (var i = 0; i < details.length; i++) {
+ if (i > 0) items.add(Container(height: 1, color: AppColors.borderRule));
+ items.add(_detailItem(details[i]));
+ }
+ return XCard(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
+ child: Column(children: items),
+ );
+ }
+
+ Widget _detailItem(ProjectPlanDetail d) {
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 13),
+ child: Row(
+ children: [
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ d.equipName.isEmpty ? '未命名设备' : d.equipName,
+ style: AppTypography.bodyMd.copyWith(
+ fontWeight: FontWeight.w700,
+ color: AppColors.onSurface,
+ ),
+ ),
+ if (d.remark != null && d.remark!.isNotEmpty) ...[
+ const SizedBox(height: 2),
+ Text(
+ d.remark!,
+ style: AppTypography.bodyMd.copyWith(
+ fontSize: 12,
+ color: AppColors.textMuted,
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ const SizedBox(width: 12),
+ Text(
+ '×${_formatQty(d.quantity)}',
+ style: AppTypography.dataMono.copyWith(color: AppColors.primary),
+ ),
+ ],
+ ),
+ );
+ }
+}
\ No newline at end of file
diff --git a/lib/features/project/presentation/project_tab.dart b/lib/features/project/presentation/project_tab.dart
new file mode 100644
index 0000000..8e582b6
--- /dev/null
+++ b/lib/features/project/presentation/project_tab.dart
@@ -0,0 +1,330 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+
+import '../../../app/tab_index.dart';
+import '../../../core/theme/app_colors.dart';
+import '../../../core/theme/app_dimens.dart';
+import '../../../core/theme/app_typography.dart';
+import '../../../core/utils/format_util.dart';
+import '../../../shared/widgets/app_feedback.dart';
+import '../../../shared/widgets/x_card.dart';
+import '../../../shared/widgets/x_chip.dart';
+import '../../../shared/widgets/x_common.dart';
+import '../../../shared/widgets/x_icon.dart';
+import '../../../shared/widgets/x_top_bar.dart';
+import '../../customer/data/customer_api.dart';
+import '../data/project_api.dart';
+import '../data/project_models.dart';
+
+/// 项目 Tab。
+///
+/// 源 `components/tabs/ProjectTab.vue` 使用本地 mock(字段为 projectName / progress / stage 等),
+/// 这里按用户确认改为直连后端 `/api/project-plans`,视觉沿用原卡片式语言但字段换成后端真实字段。
+class ProjectTab extends ConsumerStatefulWidget {
+ const ProjectTab({super.key});
+
+ @override
+ ConsumerState createState() => _ProjectTabState();
+}
+
+class _ProjectTabState extends ConsumerState {
+ static const _statusOptions = <({String label, int? value})>[
+ (label: '全部', value: null),
+ (label: '草稿', value: ProjectPlanStatus.draft),
+ (label: '已确认', value: ProjectPlanStatus.confirmed),
+ ];
+
+ final _searchController = TextEditingController();
+
+ Timer? _searchTimer;
+ List _list = const [];
+ Map _customerNames = const {};
+ bool _loading = false;
+ int? _filterStatus;
+
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ _loadCustomers();
+ _load();
+ });
+ }
+
+ @override
+ void dispose() {
+ _searchTimer?.cancel();
+ _searchController.dispose();
+ super.dispose();
+ }
+
+ /// `/project-plans` 只返回 customerId,需要先建立 id → 客户名称 映射用于展示。
+ Future _loadCustomers() async {
+ try {
+ final res = await ref
+ .read(customerApiProvider)
+ .list(page: 1, pageSize: 200);
+ if (!mounted) return;
+ if (res.isOk && res.data != null) {
+ setState(() {
+ _customerNames = {
+ for (final c in res.data!.list) c.id: c.customerName,
+ };
+ });
+ }
+ } catch (_) {
+ // 客户名称映射失败时降级为「客户 #id」,不影响方案列表展示
+ }
+ }
+
+ Future _load() async {
+ setState(() => _loading = true);
+ try {
+ final keyword = _searchController.text.trim();
+ final res = await ref
+ .read(projectApiProvider)
+ .list(
+ page: 1,
+ pageSize: 100,
+ keyword: keyword.isEmpty ? null : keyword,
+ status: _filterStatus,
+ );
+ if (!mounted) return;
+ if (res.isOk && res.data != null) {
+ setState(() => _list = res.data!.list);
+ }
+ } catch (_) {
+ showAppToast('加载方案失败');
+ } finally {
+ if (mounted) setState(() => _loading = false);
+ }
+ }
+
+ void _onSearchChanged(String _) {
+ _searchTimer?.cancel();
+ _searchTimer = Timer(const Duration(milliseconds: 350), _load);
+ }
+
+ void _onStatusChip(int? value) {
+ if (_filterStatus == value) return;
+ setState(() => _filterStatus = value);
+ _load();
+ }
+
+ Future _goDetail(ProjectPlan p) async {
+ await context.push('/project/detail/${p.id}');
+ if (mounted) _load();
+ }
+
+ String _customerLabel(int customerId) {
+ final name = _customerNames[customerId];
+ return (name == null || name.isEmpty) ? '客户 #$customerId' : name;
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ ref.listen(tabIndexProvider, (previous, next) {
+ if (next == AppTab.project) _load();
+ });
+
+ return XPageShell(
+ topBar: XTopBar.brand(
+ title: '鑫潼智电',
+ trailingColor: AppColors.primary,
+ ),
+ child: Column(
+ children: [
+ _buildPageHead(),
+ _buildSearchBar(),
+ _buildFilters(),
+ Expanded(child: _buildList()),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildPageHead() {
+ return Padding(
+ padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text('项目', style: AppTypography.headlineMd),
+ Padding(
+ padding: const EdgeInsets.only(top: 2),
+ child: const Text(
+ '管理与跟踪智电改造方案',
+ style: TextStyle(fontSize: 14, color: AppColors.textMuted),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildSearchBar() {
+ return Padding(
+ padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
+ child: XSearchField(
+ controller: _searchController,
+ hintText: '按方案单号或说明搜索...',
+ onChanged: _onSearchChanged,
+ ),
+ );
+ }
+
+ Widget _buildFilters() {
+ return Padding(
+ padding: const EdgeInsets.only(left: 16, right: 16, top: 4, bottom: 8),
+ child: SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
+ child: Row(
+ children: [
+ for (final option in _statusOptions)
+ Padding(
+ padding: const EdgeInsets.only(right: 8),
+ child: XFilterChip(
+ label: option.label,
+ selected: _filterStatus == option.value,
+ onTap: () => _onStatusChip(option.value),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildList() {
+ if (_loading && _list.isEmpty) {
+ return const XPlaceholder(text: '加载中...');
+ }
+ if (_list.isEmpty) {
+ return const XPlaceholder(text: '暂无方案');
+ }
+ return RefreshIndicator(
+ color: AppColors.primary,
+ onRefresh: _load,
+ child: ListView.builder(
+ padding: const EdgeInsets.fromLTRB(16, 4, 16, 120),
+ itemCount: _list.length,
+ itemBuilder: (context, index) => _planCard(_list[index]),
+ ),
+ );
+ }
+
+ Widget _planCard(ProjectPlan p) {
+ final registrar = p.registrar;
+
+ return XCard(
+ padding: EdgeInsets.zero,
+ margin: const EdgeInsets.only(bottom: 12),
+ onTap: () => _goDetail(p),
+ child: Stack(
+ children: [
+ // 左侧状态色条贴边,圆角按卡片圆角裁出
+ Positioned(
+ left: 0,
+ top: 0,
+ bottom: 0,
+ width: 4,
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ color: projectStatusBarColor(p.status),
+ borderRadius: const BorderRadius.horizontal(
+ left: Radius.circular(AppRadius.lg),
+ ),
+ ),
+ ),
+ ),
+ Padding(
+ padding: const EdgeInsets.only(left: 4),
+ child: Padding(
+ padding: const EdgeInsets.all(14),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Expanded(
+ child: Text(
+ _customerLabel(p.customerId),
+ style: const TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w700,
+ color: AppColors.onSurface,
+ ),
+ ),
+ ),
+ const SizedBox(width: 8),
+ XChip(
+ projectStatusText(p.status),
+ tone: projectStatusTone(p.status),
+ ),
+ ],
+ ),
+ const SizedBox(height: 6),
+ Text(
+ p.displayTitle,
+ style: AppTypography.dataMono.copyWith(
+ color: AppColors.textMuted,
+ ),
+ ),
+ const SizedBox(height: 10),
+ Row(
+ children: [
+ const XIcon(
+ 'person',
+ size: 15,
+ color: AppColors.textMuted,
+ ),
+ const SizedBox(width: 4),
+ Expanded(
+ child: Text(
+ (registrar == null || registrar.isEmpty)
+ ? '未填写登记人'
+ : registrar,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: const TextStyle(
+ fontSize: 13,
+ color: AppColors.textMuted,
+ ),
+ ),
+ ),
+ const SizedBox(width: 12),
+ const XIcon('clock', size: 15, color: AppColors.textMuted),
+ const SizedBox(width: 4),
+ Text(
+ formatDate(p.registerTime),
+ style: AppTypography.dataMono.copyWith(
+ color: AppColors.textMuted,
+ ),
+ ),
+ ],
+ ),
+ if (p.planDesc != null && p.planDesc!.isNotEmpty) ...[
+ const SizedBox(height: 8),
+ Text(
+ p.planDesc!,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: const TextStyle(
+ fontSize: 13,
+ color: AppColors.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
\ No newline at end of file
diff --git a/lib/features/survey/data/survey_api.dart b/lib/features/survey/data/survey_api.dart
new file mode 100644
index 0000000..a65ffd8
--- /dev/null
+++ b/lib/features/survey/data/survey_api.dart
@@ -0,0 +1,95 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../../../core/network/api_client.dart';
+import '../../../core/network/api_response.dart';
+import '../../../core/providers.dart';
+import 'survey_models.dart';
+
+final surveyApiProvider = Provider(
+ (ref) => SurveyApi(ref.watch(apiClientProvider)),
+);
+
+/// SOP 回执增量保存结果。
+class SopSaveResult {
+ const SopSaveResult({required this.updated, required this.skipped, required this.sopResults});
+
+ final int updated;
+ final List skipped;
+ final List sopResults;
+
+ factory SopSaveResult.fromJson(Map json) => SopSaveResult(
+ updated: (json['updated'] as num?)?.toInt() ?? 0,
+ skipped: (json['skipped'] as List? ?? []).map((e) => (e as num).toInt()).toList(),
+ sopResults: (json['sopResults'] as List? ?? [])
+ .map((e) => SurveySopResult.fromJson((e as Map).cast()))
+ .toList(),
+ );
+}
+
+/// 勘察工单接口,对应 uni-app 版 `api/survey.ts`。
+class SurveyApi {
+ SurveyApi(this._client);
+
+ final ApiClient _client;
+
+ Future> list({
+ int page = 1,
+ int pageSize = 100,
+ String? keyword,
+ int? status,
+ int? customerId,
+ int? priority,
+ int? workerId,
+ int? salesmanId,
+ String? planStartDate,
+ String? planEndDate,
+ int? includeDraft,
+ }) => _client.get(
+ '/survey-orders/',
+ query: {
+ 'page': page,
+ 'pageSize': pageSize,
+ 'keyword': keyword,
+ 'status': status,
+ 'customerId': customerId,
+ 'priority': priority,
+ 'workerId': workerId,
+ 'salesmanId': salesmanId,
+ 'planStartDate': planStartDate,
+ 'planEndDate': planEndDate,
+ 'includeDraft': includeDraft,
+ },
+ parse: (d) {
+ final m = (d as Map).cast