feat: 完成 Flutter 版智能电网 IoT 管理应用初始化搭建
No files matched your search
@@ -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
|
||||||
@@ -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'
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 = "../.."
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
|
the Flutter tool needs it to communicate with the running application
|
||||||
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- 与 uni-app 版 source manifest 保持一致:联网 + 拍照/存储 + 定位 -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
|
<uses-permission android:name="android.permission.CAMERA"/>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:label="鑫潼智电运营管理"
|
||||||
|
android:name="${applicationName}"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:theme="@style/LaunchTheme"
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
|
the Android process has started. This theme is visible to the user
|
||||||
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
|
to determine the Window background behind the Flutter UI. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
|
android:resource="@style/NormalTheme"
|
||||||
|
/>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<!-- Don't delete the meta-data below.
|
||||||
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
<meta-data
|
||||||
|
android:name="flutterEmbedding"
|
||||||
|
android:value="2" />
|
||||||
|
</application>
|
||||||
|
<!-- Required to query activities that can process text, see:
|
||||||
|
https://developer.android.com/training/package-visibility and
|
||||||
|
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||||
|
|
||||||
|
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
|
<data android:mimeType="text/plain"/>
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package cn.xintong.smart_grid_iot_manage_app_flutter
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity()
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="?android:colorBackground" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@android:color/white" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
||||||
|
After Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 442 B |
|
After Width: | Height: | Size: 721 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
|
the Flutter engine draws its first frame -->
|
||||||
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
|
the Flutter engine draws its first frame -->
|
||||||
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
|
the Flutter tool needs it to communicate with the running application
|
||||||
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
||||||
@@ -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<Delete>("clean") {
|
||||||
|
delete(rootProject.layout.buildDirectory)
|
||||||
|
}
|
||||||
@@ -0,0 +1,663 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<!-- Required meta tags -->
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
|
||||||
|
<style type="text/css">
|
||||||
|
/*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */
|
||||||
|
html {
|
||||||
|
line-height: 1.15;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
-webkit-text-size-adjust: 100%
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
article, aside, footer, header, nav, section {
|
||||||
|
display: block
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2em;
|
||||||
|
margin: .67em 0
|
||||||
|
}
|
||||||
|
|
||||||
|
figcaption, figure, main {
|
||||||
|
display: block
|
||||||
|
}
|
||||||
|
|
||||||
|
figure {
|
||||||
|
margin: 1em 40px
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
box-sizing: content-box;
|
||||||
|
height: 0;
|
||||||
|
overflow: visible
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
font-family: monospace, monospace;
|
||||||
|
font-size: 1em
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
background-color: transparent;
|
||||||
|
-webkit-text-decoration-skip: objects
|
||||||
|
}
|
||||||
|
|
||||||
|
abbr[title] {
|
||||||
|
border-bottom: none;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration: underline dotted
|
||||||
|
}
|
||||||
|
|
||||||
|
b, strong {
|
||||||
|
font-weight: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
b, strong {
|
||||||
|
font-weight: bolder
|
||||||
|
}
|
||||||
|
|
||||||
|
code, kbd, samp {
|
||||||
|
font-family: monospace, monospace;
|
||||||
|
font-size: 1em
|
||||||
|
}
|
||||||
|
|
||||||
|
dfn {
|
||||||
|
font-style: italic
|
||||||
|
}
|
||||||
|
|
||||||
|
mark {
|
||||||
|
background-color: #ff0;
|
||||||
|
color: #000
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: 80%
|
||||||
|
}
|
||||||
|
|
||||||
|
sub, sup {
|
||||||
|
font-size: 75%;
|
||||||
|
line-height: 0;
|
||||||
|
position: relative;
|
||||||
|
vertical-align: baseline
|
||||||
|
}
|
||||||
|
|
||||||
|
sub {
|
||||||
|
bottom: -.25em
|
||||||
|
}
|
||||||
|
|
||||||
|
sup {
|
||||||
|
top: -.5em
|
||||||
|
}
|
||||||
|
|
||||||
|
audio, video {
|
||||||
|
display: inline-block
|
||||||
|
}
|
||||||
|
|
||||||
|
audio:not([controls]) {
|
||||||
|
display: none;
|
||||||
|
height: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
border-style: none
|
||||||
|
}
|
||||||
|
|
||||||
|
svg:not(:root) {
|
||||||
|
overflow: hidden
|
||||||
|
}
|
||||||
|
|
||||||
|
button, input, optgroup, select, textarea {
|
||||||
|
font-family: sans-serif;
|
||||||
|
font-size: 100%;
|
||||||
|
line-height: 1.15;
|
||||||
|
margin: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
button, input {
|
||||||
|
overflow: visible
|
||||||
|
}
|
||||||
|
|
||||||
|
button, select {
|
||||||
|
text-transform: none
|
||||||
|
}
|
||||||
|
|
||||||
|
[type=reset], [type=submit], button, html [type=button] {
|
||||||
|
-webkit-appearance: button
|
||||||
|
}
|
||||||
|
|
||||||
|
[type=button]::-moz-focus-inner, [type=reset]::-moz-focus-inner, [type=submit]::-moz-focus-inner, button::-moz-focus-inner {
|
||||||
|
border-style: none;
|
||||||
|
padding: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
[type=button]:-moz-focusring, [type=reset]:-moz-focusring, [type=submit]:-moz-focusring, button:-moz-focusring {
|
||||||
|
outline: 1px dotted ButtonText
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
padding: .35em .75em .625em
|
||||||
|
}
|
||||||
|
|
||||||
|
legend {
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: inherit;
|
||||||
|
display: table;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
white-space: normal
|
||||||
|
}
|
||||||
|
|
||||||
|
progress {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: baseline
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
overflow: auto
|
||||||
|
}
|
||||||
|
|
||||||
|
[type=checkbox], [type=radio] {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
[type=number]::-webkit-inner-spin-button, [type=number]::-webkit-outer-spin-button {
|
||||||
|
height: auto
|
||||||
|
}
|
||||||
|
|
||||||
|
[type=search] {
|
||||||
|
-webkit-appearance: textfield;
|
||||||
|
outline-offset: -2px
|
||||||
|
}
|
||||||
|
|
||||||
|
[type=search]::-webkit-search-cancel-button, [type=search]::-webkit-search-decoration {
|
||||||
|
-webkit-appearance: none
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-file-upload-button {
|
||||||
|
-webkit-appearance: button;
|
||||||
|
font: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
details, menu {
|
||||||
|
display: block
|
||||||
|
}
|
||||||
|
|
||||||
|
summary {
|
||||||
|
display: list-item
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
display: inline-block
|
||||||
|
}
|
||||||
|
|
||||||
|
template {
|
||||||
|
display: none
|
||||||
|
}
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none
|
||||||
|
}
|
||||||
|
|
||||||
|
/* configuration cache styles */
|
||||||
|
|
||||||
|
.report-wrapper {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradle-logo {
|
||||||
|
width: 32px;
|
||||||
|
height: 24px;
|
||||||
|
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAYCAYAAACbU/80AAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAIKADAAQAAAABAAAAGAAAAAA915G0AAAD5klEQVRIDbVWC0xTZxT+emmhVUEeA1/ROh/tFAFFGK7oJisIKsNVoOwBbJPowEWHzikRxeiMRpwwjDWRBHQLIzOmiRhe22BT40TitiyaMBQFfMEeLMIEaSmk+/+rvd7be4no6Elu7n++c/5zzv845/wyOyG4iGyDgzCdNOPLM9W41n4bnmNUiHo5DNsz0hGsmcV6lbkyAOOWXJjrz4qWp1C4o3z/LqzWL4VcJB1FIHmZHn/f78a6pDcxbeIEfNvQiPwTZbDZBpC24zOEaGfDpTsgtZby6u+QlrubFWUY3nh6AH39/ahr/Bn1jZfxW3ML2js60dtvgbtcQVblj8CZM7A0PBSrol6Ft+c4KZ8iTB1nwN0//8IEP9/hA2i924Gir0/iq8oa/NvbJzLiDKiUSqTE6pGVbEBY4BxnsYAPSnwXTa3tLCZ5BF3dPdAkGNHzoFcwcaRMnC4CeZkZiAgKFE252nITC1Pew9Dj5GNEGgS4Rbb5eZ1Te7UXG6FLX4cV6zeh5kIDaDpSunL9Boyf5nLOpwT4Sx+BxWrFK8QAnTAapPRQwofcj86uLoG59cbVEOzA0NAQNh38Atn5RSjY8rFAmc/I3dyQvOx1PsSNVy7Roa3ajHDePbBYLSLn1MaGd5KFAXy07xAOl59C6elK+I73hIHcbGd6wXs8qkyH8FZcjLOI5X/9/TrOnLsAldJDUu4As1NToFFPe3IEpm/M2HigwCFnU6t4Zw6Ck1JhGRhgcXq5juXloKyqFnlHirmz5CaNcEAv59kSE9wVikcB3O78A/MSU0Fznk/H9+yAetJEnPr+B8RFLsLcGS8ia28+qQuX+WrPNNZOV+Nc6VH4+3iz89g0pEaLzRUiQ3LGDWsM8Qidq2WL0PGKKlgf74ZIeQTAfFJ6a44WIsDXh9OW/dPdY58aawC9KK6kpOgolO7JxViVSuBGXnvxksudZ5F0O5yzGYxMJnBOGaau4fnPU2RNAtCFBKFoa7akczaAptY2iWmjB33+yQa4kZwfjpi2ex3Dyf43vuAljWQ/4Btmei1WPj+q45hF4U+1J4fEizCEvNf0EWHoIW244sfzoN1RipaT2kDfdjfv3MNpojdISjmfIheE8Fnp8WR9vJ2Zr+O+bYUmO+kJ9KnIUtf9bnvY2x9wcqrrvnCJvfL8Tw4V9v9LU7PdKzJaoNdy645AR4ph1JMncZHRKrVvYyYY5kmP8iO1v2T3dk6HDtYmrgJtOnwKnaPFrg8z+BBX7QSgEyOPJfX9Qd9DFs40GgTOHbrBs2ch4bXFuEG2mmFkeD9hpUMk+NMXEe0TNtsg/Ly94DVurEAuxfwHC1WiVbe0U7MAAAAASUVORK5CYII=");
|
||||||
|
background-size: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
padding: 24px 24px 0 24px;
|
||||||
|
background-color: white;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learn-more {
|
||||||
|
margin-left: auto;
|
||||||
|
align-self: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 18px 0 24px 0;
|
||||||
|
flex: 1 0 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 240px 0 48px;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content ol:first-of-type {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-btn {
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-top: -0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-btn.collapsed {
|
||||||
|
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 512"><path d="M166.9 264.5l-117.8 116c-4.7 4.7-12.3 4.7-17 0l-7.1-7.1c-4.7-4.7-4.7-12.3 0-17L127.3 256 25.1 155.6c-4.7-4.7-4.7-12.3 0-17l7.1-7.1c4.7-4.7 12.3-4.7 17 0l117.8 116c4.6 4.7 4.6 12.3-.1 17z" fill="%23999999" stroke="%23999999"/></svg>');
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-btn.expanded {
|
||||||
|
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 512"><path d="M119.5 326.9L3.5 209.1c-4.7-4.7-4.7-12.3 0-17l7.1-7.1c4.7-4.7 12.3-4.7 17 0L128 287.3l100.4-102.2c4.7-4.7 12.3-4.7 17 0l7.1 7.1c4.7 4.7 4.7 12.3 0 17L136.5 327c-4.7 4.6-12.3 4.6-17-.1z" fill="%23999999" stroke="%23999999"/></svg>');
|
||||||
|
}
|
||||||
|
|
||||||
|
ul .tree-btn {
|
||||||
|
margin-right: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaf-icon {
|
||||||
|
display: inline-block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 512"><path d="M32 256 H224" stroke="%23999999" stroke-width="48" stroke-linecap="round"/></svg>');
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-top: -0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invisible-text {
|
||||||
|
user-select: all; /* Allow the text to be selectable */
|
||||||
|
color: transparent; /* Hide the text */
|
||||||
|
text-indent: -9999px; /* Move the text out of view */
|
||||||
|
position: relative;
|
||||||
|
white-space: pre; /* Preserve meaningful whitespace in the invisible text for copying */
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-for-copy {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enum-icon {
|
||||||
|
display: inline-block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><circle cx="512" cy="512" r="200" /></svg>');
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-inline-start: 0.5ex;
|
||||||
|
margin-inline-end: 0.5ex;
|
||||||
|
margin-top: -0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-icon {
|
||||||
|
display: inline-block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path d="M193.94 256L296.5 153.44l21.15-21.15c3.12-3.12 3.12-8.19 0-11.31l-22.63-22.63c-3.12-3.12-8.19-3.12-11.31 0L160 222.06 36.29 98.34c-3.12-3.12-8.19-3.12-11.31 0L2.34 120.97c-3.12 3.12-3.12 8.19 0 11.31L126.06 256 2.34 379.71c-3.12 3.12-3.12 8.19 0 11.31l22.63 22.63c3.12 3.12 8.19 3.12 11.31 0L160 289.94 262.56 392.5l21.15 21.15c3.12 3.12 8.19 3.12 11.31 0l22.63-22.63c3.12-3.12 3.12-8.19 0-11.31L193.94 256z" fill="%23FC461E" stroke="%23FC461E"/></svg>');
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-inline-start: 0.5ex;
|
||||||
|
margin-inline-end: 0.5ex;
|
||||||
|
margin-top: -0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advice-icon {
|
||||||
|
display: inline-block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background-image: url('data:image/svg+xml;utf8,<svg width="800px" height="800px" viewBox="-4.93 0 122.88 122.88" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="enable-background:new 0 0 113.01 122.88" xml:space="preserve"><g><path d="M44.13,102.06c-1.14,0.03-2.14-0.81-2.3-1.96c-0.17-1.2,0.64-2.31,1.82-2.54c-1.3-7.37-4.85-11.43-8.6-15.72 c-2.92-3.34-5.95-6.81-8.34-11.92c-2.35-5.03-3.64-10.23-3.6-15.63c0.05-5.4,1.42-10.96,4.4-16.71c0.02-0.04,0.04-0.07,0.06-0.11 l0,0c3.91-6.62,9.38-11.04,15.47-13.52c5.11-2.09,10.66-2.8,16.1-2.3c5.42,0.5,10.73,2.2,15.37,4.94 c5.9,3.49,10.75,8.67,13.42,15.21c1.44,3.54,2.42,7.49,2.54,11.82c0.12,4.31-0.62,8.96-2.61,13.88 c-2.66,6.59-6.18,10.68-9.47,14.51c-3.03,3.53-5.85,6.81-7.42,11.84c0.89,0.21,1.59,0.94,1.73,1.9c0.17,1.24-0.7,2.39-1.94,2.56 l-0.77,0.11c-0.14,1.09-0.23,2.26-0.27,3.51l0.25-0.04c1.24-0.17,2.39,0.7,2.56,1.94c0.17,1.24-0.7,2.39-1.94,2.56l-0.78,0.11 c0.01,0.15,0.02,0.3,0.03,0.45l0,0c0.07,0.88,0.08,1.73,0.03,2.54l0.13-0.02c1.25-0.15,2.38,0.74,2.54,1.98 c0.15,1.25-0.74,2.38-1.98,2.54l-1.68,0.21c-1.2,3.11-3.34,5.48-5.87,6.94c-1.74,1.01-3.67,1.59-5.61,1.71 c-1.97,0.12-3.96-0.25-5.78-1.13c-2.08-1.02-3.94-2.71-5.29-5.14c-0.65-0.33-1.13-0.97-1.23-1.75c-0.04-0.31-0.01-0.61,0.07-0.89 c-0.39-1.16-0.68-2.43-0.87-3.83l-0.07,0.01c-1.24,0.17-2.39-0.7-2.56-1.94c-0.17-1.24,0.7-2.39,1.94-2.56l0.54-0.08 C44.19,104.32,44.18,103.16,44.13,102.06L44.13,102.06z M2.18,58.86C1.01,58.89,0.04,57.98,0,56.81c-0.04-1.17,0.88-2.14,2.05-2.18 l8.7-0.3c1.17-0.04,2.14,0.88,2.18,2.05c0.04,1.17-0.88,2.14-2.05,2.18L2.18,58.86L2.18,58.86z M110.68,50.25 c1.16-0.12,2.2,0.73,2.32,1.89c0.12,1.16-0.73,2.2-1.89,2.32l-8.66,0.91c-1.16,0.12-2.2-0.73-2.32-1.89 c-0.12-1.16,0.73-2.2,1.89-2.32L110.68,50.25L110.68,50.25z M94.91,14.78c0.65-0.97,1.96-1.23,2.93-0.58 c0.97,0.65,1.23,1.96,0.58,2.93l-4.84,7.24c-0.65,0.97-1.96,1.23-2.93,0.58c-0.97-0.65-1.23-1.96-0.58-2.93L94.91,14.78 L94.91,14.78z M57.63,2.06c0.03-1.17,1-2.09,2.16-2.06c1.17,0.03,2.09,1,2.06,2.16l-0.22,8.7c-0.03,1.17-1,2.09-2.16,2.06 c-1.17-0.03-2.09-1-2.06-2.16L57.63,2.06L57.63,2.06z M13.88,15.53c-0.86-0.8-0.9-2.14-0.11-2.99c0.8-0.86,2.14-0.9,2.99-0.11 l6.37,5.94c0.86,0.8,0.9,2.14,0.11,2.99c-0.8,0.86-2.14,0.9-2.99,0.11L13.88,15.53L13.88,15.53z M47.88,96.95l18.49-2.63 c1.59-6.7,5.05-10.73,8.8-15.08c3.08-3.58,6.36-7.4,8.76-13.34c1.76-4.35,2.41-8.43,2.31-12.19c-0.1-3.75-0.96-7.21-2.24-10.34 c-2.3-5.63-6.51-10.11-11.65-13.15c-4.11-2.43-8.8-3.94-13.59-4.37c-4.77-0.44-9.64,0.19-14.13,2.02 c-5.26,2.15-9.99,5.97-13.39,11.72c-2.64,5.12-3.86,10.02-3.9,14.73c-0.04,4.74,1.11,9.33,3.2,13.8c2.13,4.56,4.97,7.8,7.69,10.92 C42.47,83.9,46.48,88.49,47.88,96.95L47.88,96.95z M65.62,99.02l-17.27,2.45c0.05,1.1,0.07,2.25,0.05,3.47l17.05-2.42 C65.47,101.29,65.52,100.12,65.62,99.02L65.62,99.02z M48.49,109.52c0.12,0.92,0.3,1.76,0.53,2.54l16.55-2.04 c0.11-0.86,0.13-1.77,0.05-2.74l0,0l0-0.02l-0.01-0.17L48.49,109.52L48.49,109.52z M51.37,116.36c0.64,0.67,1.35,1.19,2.1,1.55 c1.15,0.56,2.42,0.79,3.67,0.72c1.29-0.08,2.57-0.47,3.74-1.15c1.1-0.64,2.09-1.53,2.88-2.65L51.37,116.36L51.37,116.36z"/></g></svg>');
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-inline-start: 0.5ex;
|
||||||
|
margin-inline-end: 0.5ex;
|
||||||
|
margin-top: -0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-icon {
|
||||||
|
display: inline-block;
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M270.2 160h35.5c3.4 0 6.1 2.8 6 6.2l-7.5 196c-.1 3.2-2.8 5.8-6 5.8h-20.5c-3.2 0-5.9-2.5-6-5.8l-7.5-196c-.1-3.4 2.6-6.2 6-6.2zM288 388c-15.5 0-28 12.5-28 28s12.5 28 28 28 28-12.5 28-28-12.5-28-28-28zm281.5 52L329.6 24c-18.4-32-64.7-32-83.2 0L6.5 440c-18.4 31.9 4.6 72 41.6 72H528c36.8 0 60-40 41.5-72zM528 480H48c-12.3 0-20-13.3-13.9-24l240-416c6.1-10.6 21.6-10.7 27.7 0l240 416c6.2 10.6-1.5 24-13.8 24z" fill="%23DEAD22" stroke="%23DEAD22"/></svg>');
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-inline-start: 0.3ex;
|
||||||
|
margin-inline-end: 1.1ex;
|
||||||
|
margin-top: -0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-button {
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-block;
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M256 340c-15.464 0-28 12.536-28 28s12.536 28 28 28 28-12.536 28-28-12.536-28-28-28zm7.67-24h-16c-6.627 0-12-5.373-12-12v-.381c0-70.343 77.44-63.619 77.44-107.408 0-20.016-17.761-40.211-57.44-40.211-29.144 0-44.265 9.649-59.211 28.692-3.908 4.98-11.054 5.995-16.248 2.376l-13.134-9.15c-5.625-3.919-6.86-11.771-2.645-17.177C185.658 133.514 210.842 116 255.67 116c52.32 0 97.44 29.751 97.44 80.211 0 67.414-77.44 63.849-77.44 107.408V304c0 6.627-5.373 12-12 12zM256 40c118.621 0 216 96.075 216 216 0 119.291-96.61 216-216 216-119.244 0-216-96.562-216-216 0-119.203 96.602-216 216-216m0-32C119.043 8 8 119.083 8 256c0 136.997 111.043 248 248 248s248-111.003 248-248C504 119.083 392.957 8 256 8z" fill="%23999999" stroke="%23999999"/></svg>');
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-inline-start: 0.5ex;
|
||||||
|
margin-inline-end: 0.5ex;
|
||||||
|
margin-top: -0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-button::selection {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-button:hover {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button {
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M433.941 193.941l-51.882-51.882A48 48 0 0 0 348.118 128H320V80c0-26.51-21.49-48-48-48h-66.752C198.643 13.377 180.858 0 160 0s-38.643 13.377-45.248 32H48C21.49 32 0 53.49 0 80v288c0 26.51 21.49 48 48 48h80v48c0 26.51 21.49 48 48 48h224c26.51 0 48-21.49 48-48V227.882a48 48 0 0 0-14.059-33.941zm-22.627 22.627a15.888 15.888 0 0 1 4.195 7.432H352v-63.509a15.88 15.88 0 0 1 7.431 4.195l51.883 51.882zM160 30c9.941 0 18 8.059 18 18s-8.059 18-18 18-18-8.059-18-18 8.059-18 18-18zM48 384c-8.822 0-16-7.178-16-16V80c0-8.822 7.178-16 16-16h66.752c6.605 18.623 24.389 32 45.248 32s38.643-13.377 45.248-32H272c8.822 0 16 7.178 16 16v48H176c-26.51 0-48 21.49-48 48v208H48zm352 96H176c-8.822 0-16-7.178-16-16V176c0-8.822 7.178-16 16-16h144v72c0 13.2 10.8 24 24 24h72v208c0 8.822-7.178 16-16 16z" fill="%23999999" stroke="%23999999"/></svg>');
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-inline-start: 0.5ex;
|
||||||
|
margin-top: -0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups{
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid #EDEEEF;
|
||||||
|
flex: 1 0 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uncategorized {
|
||||||
|
display: flex;
|
||||||
|
border-top: 4px solid #EDEEEF;
|
||||||
|
flex: 1 0 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-selector {
|
||||||
|
padding: 0 52px 24px 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #999999;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-selector__count {
|
||||||
|
margin: 0 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: #999;
|
||||||
|
color: #fff;
|
||||||
|
padding: 1px 8px 2px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-selector--active {
|
||||||
|
color: #02303A;
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-selector--active .group-selector__count {
|
||||||
|
background-color: #686868;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-selector--disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-header {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding-left: 0.5em;
|
||||||
|
padding-right: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacktrace {
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
min-width: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lato (bold, regular) */
|
||||||
|
@font-face {
|
||||||
|
font-display: swap;
|
||||||
|
font-family: Lato;
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: normal;
|
||||||
|
src: url("https://assets.gradle.com/lato/fonts/lato-semibold/lato-semibold.woff2") format("woff2"),
|
||||||
|
url("https://assets.gradle.com/lato/fonts/lato-semibold/lato-semibold.woff") format("woff");
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-display: swap;
|
||||||
|
font-family: Lato;
|
||||||
|
font-weight: bold;
|
||||||
|
font-style: normal;
|
||||||
|
src: url("https://assets.gradle.com/lato/fonts/lato-bold/lato-bold.woff2") format("woff2"),
|
||||||
|
url("https://assets.gradle.com/lato/fonts/lato-bold/lato-bold.woff") format("woff");
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
-webkit-box-sizing: border-box;
|
||||||
|
-moz-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: "Lato", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: #02303A;
|
||||||
|
background-color: #ffffff;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* typography */
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
color: #02303A;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4, h5, h6 {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 code {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol, dl {
|
||||||
|
list-style-position: outside;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 0 20px;
|
||||||
|
list-style-type: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
line-height: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #1DA2BD;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.3s ease, visibility 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: #35c1e4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* code */
|
||||||
|
code, pre {
|
||||||
|
font-family: Inconsolata, Monaco, "Courier New", monospace;
|
||||||
|
font-style: normal;
|
||||||
|
font-variant-ligatures: normal;
|
||||||
|
font-variant-caps: normal;
|
||||||
|
font-variant-numeric: normal;
|
||||||
|
font-variant-east-asian: normal;
|
||||||
|
font-weight: normal;
|
||||||
|
font-stretch: normal;
|
||||||
|
color: #686868;
|
||||||
|
}
|
||||||
|
|
||||||
|
*:not(pre) > code {
|
||||||
|
letter-spacing: 0;
|
||||||
|
padding: 0.1em 0.5ex;
|
||||||
|
text-rendering: optimizeSpeed;
|
||||||
|
word-spacing: -0.15em;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.8;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1.5em;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
background-color: transparent;
|
||||||
|
color: inherit;
|
||||||
|
line-height: 1.8;
|
||||||
|
font-size: 100%;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a code {
|
||||||
|
color: #1BA8CB;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre.code, pre.programlisting, pre.screen, pre.tt {
|
||||||
|
background-color: #f7f7f8;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1em;
|
||||||
|
line-height: 1.45;
|
||||||
|
margin-bottom: 1.25em;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
li em, p em {
|
||||||
|
padding: 0 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
code em, tt em {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
code + .copy-button {
|
||||||
|
margin-inline-start: 0.2ex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.java-exception {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.java-exception ul {
|
||||||
|
margin: 0;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.java-exception code {
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.java-exception-part-toggle {
|
||||||
|
user-select: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 0.1em 0.2em;
|
||||||
|
background: azure;
|
||||||
|
color: #686868;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
<!-- Inconsolata is used as a default monospace font in the report. -->
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Inconsolata:400,700" />
|
||||||
|
|
||||||
|
<title>Gradle Configuration Cache</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="playground"></div>
|
||||||
|
|
||||||
|
<div class="report" id="report">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
function configurationCacheProblems() { return (
|
||||||
|
// begin-report-data
|
||||||
|
{"diagnostics":[{"locations":[{}],"problem":[{"text":"Executing Gradle on JVM versions 16 and lower has been deprecated."}],"severity":"WARNING","problemDetails":[{"text":"This will fail with an error in Gradle 9.0."}],"contextualLabel":"Executing Gradle on JVM versions 16 and lower has been deprecated.","documentationLink":"https://docs.gradle.org/8.14/userguide/upgrading_version_8.html#minimum_daemon_jvm_version","problemId":[{"name":"deprecation","displayName":"Deprecation"},{"name":"executing-gradle-on-jvm-versions-and-lower","displayName":"Executing Gradle on JVM versions 16 and lower has been deprecated."}],"solutions":[[{"text":"Use JVM 17 or greater to execute Gradle. Projects can continue to use older JVM versions via toolchains."}]]},{"problem":[{"text":"No variants exist that would match the request"}],"severity":"ERROR","contextualLabel":"Dependency requires at least JVM runtime version 11. This build uses a Java 8 JVM.","documentationLink":"https://docs.gradle.org/8.14/userguide/variant_model.html#sec:variant-select-errors","problemId":[{"name":"dependency-variant-resolution","displayName":"Variant resolution"},{"name":"no-compatible-variants","displayName":"No variants exist that would match the request"}]}],"problemsReport":{"totalProblemCount":2,"requestedTasks":"","documentationLink":"https://docs.gradle.org/8.14/userguide/reporting_problems.html","documentationLinkCaption":"Problem report","summaries":[]}}
|
||||||
|
// end-report-data
|
||||||
|
);}
|
||||||
|
</script>
|
||||||
|
<script type="text/javascript">
|
||||||
|
!function(n,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports["configuration-cache-report"]=t():n["configuration-cache-report"]=t()}(this,(()=>(({70:function(){void 0===ArrayBuffer.isView&&(ArrayBuffer.isView=function(n){return null!=n&&null!=n.__proto__&&n.__proto__.__proto__===Int8Array.prototype.__proto__}),void 0===Math.imul&&(Math.imul=function(n,t){return(4294901760&n)*(65535&t)+(65535&n)*(0|t)|0}),this["configuration-cache-report"]=function(n){"use strict";var t,r,i,e,u,o,f,s,c,a,h,l,_,v,d,g,w,b,p,m,k,q,y,B,C,x,j,P,I,S,z,T,E,L,N,A,M,F,D,O,R,H,$,G,U,V,Q,Z,Y,W,K,X,J,nn,tn,rn,en,un,on,fn,sn,cn,an,hn,ln,_n,vn,dn,gn,wn,bn,pn,mn,kn,qn,yn,Bn,Cn,xn,jn,Pn,In,Sn,zn,Tn,En,Ln,Nn,An,Mn=Math.imul,Fn=ArrayBuffer.isView;function Dn(n,t){if(!(t>=0))throw _u(ce("Requested element count "+t+" is less than zero."));return function(n,t){if(!(t>=0))throw _u(ce("Requested element count "+t+" is less than zero."));if(0===t)return Ct();if(t>=n.length)return function(n){switch(n.length){case 0:return Ct();case 1:return qr(n[0]);default:return function(n){return Hr(function(n){return new It(n,!1)}(n))}(n)}}(n);if(1===t)return qr(n[0]);var r=0,i=Rr(),e=0,u=n.length;n:for(;e<u;){var o=n[e];if(e=e+1|0,i.d(o),(r=r+1|0)===t)break n}return i}(n,rt(n.length-t|0,0))}function On(n,t,r,i,e,u,o){return t=t===A?", ":t,r=r===A?"":r,i=i===A?"":i,e=e===A?-1:e,u=u===A?"...":u,o=o===A?null:o,function(n,t,r,i,e,u,o,f){r=r===A?", ":r,i=i===A?"":i,e=e===A?"":e,u=u===A?-1:u,o=o===A?"...":o,f=f===A?null:f,t.e(i);var s=0,c=0,a=n.length;n:for(;c<a;){var h=n[c];if(c=c+1|0,(s=s+1|0)>1&&t.e(r),!(u<0||s<=u))break n;Jt(t,h,f)}return u>=0&&s>u&&t.e(o),t.e(e),t}(n,Bi(),t,r,i,e,u,o).toString()}function Rn(n){return n.length-1|0}function Hn(n,t){if(null==t){var r=0,i=n.length-1|0;if(r<=i)do{var e=r;if(r=r+1|0,null==n[e])return e}while(r<=i)}else{var u=0,o=n.length-1|0;if(u<=o)do{var f=u;if(u=u+1|0,le(t,n[f]))return f}while(u<=o)}return-1}function $n(n,t,r,i,e,u,o){return t=t===A?", ":t,r=r===A?"":r,i=i===A?"":i,e=e===A?-1:e,u=u===A?"...":u,o=o===A?null:o,Gn(n,Bi(),t,r,i,e,u,o).toString()}function Gn(n,t,r,i,e,u,o,f){r=r===A?", ":r,i=i===A?"":i,e=e===A?"":e,u=u===A?-1:u,o=o===A?"...":o,f=f===A?null:f,t.e(i);var s=0,c=n.f();n:for(;c.g();){var a=c.h();if((s=s+1|0)>1&&t.e(r),!(u<0||s<=u))break n;Jt(t,a,f)}return u>=0&&s>u&&t.e(o),t.e(e),t}function Un(n){if(n.i())throw xu("List is empty.");return n.j(0)}function Vn(n){return new tt(n)}function Qn(n){if(Ke(n,Di)){var t;switch(n.k()){case 0:t=Ct();break;case 1:t=qr(Ke(n,Fi)?n.j(0):n.f().h());break;default:t=Zn(n)}return t}return xt(Xn(n))}function Zn(n){return Hr(n)}function Yn(n){if(Ke(n,Di)&&n.k()<=1)return Qn(n);var t=Xn(n);return function(n){var t=(n.k()/2|0)-1|0;if(t<0)return br();var r=jt(n),i=0;if(i<=t)do{var e=i;i=i+1|0;var u=n.j(e);n.f4(e,n.j(r)),n.f4(r,u),r=r-1|0}while(e!==t)}(t),t}function Wn(n,t){if(!(t>=0))throw _u(ce("Requested element count "+t+" is less than zero."));return function(n,t){if(!(t>=0))throw _u(ce("Requested element count "+t+" is less than zero."));if(0===t)return Ct();if(Ke(n,Di)){if(t>=n.k())return Qn(n);if(1===t)return qr(function(n){if(Ke(n,Fi))return Un(n);var t=n.f();if(!t.g())throw xu("Collection is empty.");return t.h()}(n))}var r=0,i=Rr(),e=n.f();n:for(;e.g();){var u=e.h();if(i.d(u),(r=r+1|0)===t)break n}return xt(i)}(n,rt(n.k()-t|0,0))}function Kn(n,t){if(!(t>=0))throw _u(ce("Requested element count "+t+" is less than zero."));if(0===t)return Ct();var r=n.k();if(t>=r)return Qn(n);if(1===t)return qr(Jn(n));var i=Rr();if(Ke(n,bi)){var e=r-t|0;if(e<r)do{var u=e;e=e+1|0,i.d(n.j(u))}while(e<r)}else for(var o=n.l(r-t|0);o.g();){var f=o.h();i.d(f)}return i}function Xn(n){return Ke(n,Di)?Zn(n):nt(n,Or())}function Jn(n){if(n.i())throw xu("List is empty.");return n.j(jt(n))}function nt(n,t){for(var r=n.f();r.g();){var i=r.h();t.d(i)}return t}function tt(n){this.n_1=n}function rt(n,t){return n<t?t:n}function it(n,t){return n>t?t:n}function et(n,t){return Kt().q(n,t,-1)}function ut(n,t){return new Ft(n,t)}function ot(n){var t=n.f();if(!t.g())return Ct();var r=t.h();if(!t.g())return qr(r);var i=Or();for(i.d(r);t.g();)i.d(t.h());return i}function ft(n){this.r_1=n}function st(n,t){this.s_1=n,this.t_1=t}function ct(){}function at(n){this.x_1=n,this.w_1=0}function ht(n,t){this.a1_1=n,at.call(this,n),_t().b1(t,this.a1_1.k()),this.w_1=t}function lt(){t=this}function _t(){return null==t&&new lt,t}function vt(){_t(),ct.call(this)}function dt(n){this.h1_1=n}function gt(n,t){return t===n?"(this Map)":Vi(t)}function wt(n,t){var r;n:{for(var i=n.o().f();i.g();){var e=i.h();if(le(e.j1(),t)){r=e;break n}}r=null}return r}function bt(){r=this}function pt(){return null==r&&new bt,r}function mt(n){this.q1_1=n,ct.call(this)}function kt(){pt(),this.n1_1=null,this.o1_1=null}function qt(){i=this}function yt(){return null==i&&new qt,i}function Bt(n){return n.length>0?ou(n):Ct()}function Ct()Line truncated
|
||||||
|
//# sourceMappingURL=configuration-cache-report.js.map
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>en</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>App</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>io.flutter.flutter.app</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>App</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>FMWK</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleSignature</key>
|
||||||
|
<string>????</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
#include "Generated.xcconfig"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
#include "Generated.xcconfig"
|
||||||
@@ -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 = "<group>"; };
|
||||||
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||||
|
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||||
|
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 = "<group>"; };
|
||||||
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||||
|
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||||
|
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||||
|
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 = "<group>"; };
|
||||||
|
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
|
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
|
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
/* 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 = "<group>";
|
||||||
|
};
|
||||||
|
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||||
|
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||||
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||||
|
9740EEB31CF90195004384FC /* Generated.xcconfig */,
|
||||||
|
);
|
||||||
|
name = Flutter;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
97C146E51CF9000F007C117D = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
9740EEB11CF90186004384FC /* Flutter */,
|
||||||
|
97C146F01CF9000F007C117D /* Runner */,
|
||||||
|
97C146EF1CF9000F007C117D /* Products */,
|
||||||
|
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
97C146EF1CF9000F007C117D /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||||
|
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
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 = "<group>";
|
||||||
|
};
|
||||||
|
/* 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 = "<group>";
|
||||||
|
};
|
||||||
|
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
|
||||||
|
isa = PBXVariantGroup;
|
||||||
|
children = (
|
||||||
|
97C147001CF9000F007C117D /* Base */,
|
||||||
|
);
|
||||||
|
name = LaunchScreen.storyboard;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* 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 */;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>IDEDidComputeMac32BitWarning</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>PreviewsEnabled</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1510"
|
||||||
|
version = "1.3">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "331C8080294A63A400263BE5"
|
||||||
|
BuildableName = "RunnerTests.xctest"
|
||||||
|
BlueprintName = "RunnerTests"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
enableGPUValidationMode = "1"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Profile"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "group:Runner.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>IDEDidComputeMac32BitWarning</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>PreviewsEnabled</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 68 B |
@@ -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.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||||
|
<dependencies>
|
||||||
|
<deployment identifier="iOS"/>
|
||||||
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
|
||||||
|
</dependencies>
|
||||||
|
<scenes>
|
||||||
|
<!--View Controller-->
|
||||||
|
<scene sceneID="EHf-IW-A2E">
|
||||||
|
<objects>
|
||||||
|
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||||
|
<layoutGuides>
|
||||||
|
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
|
||||||
|
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
|
||||||
|
</layoutGuides>
|
||||||
|
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||||
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
|
<subviews>
|
||||||
|
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||||
|
</imageView>
|
||||||
|
</subviews>
|
||||||
|
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||||
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||||
|
</constraints>
|
||||||
|
</view>
|
||||||
|
</viewController>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||||
|
</objects>
|
||||||
|
<point key="canvasLocation" x="53" y="375"/>
|
||||||
|
</scene>
|
||||||
|
</scenes>
|
||||||
|
<resources>
|
||||||
|
<image name="LaunchImage" width="168" height="185"/>
|
||||||
|
</resources>
|
||||||
|
</document>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
|
||||||
|
<dependencies>
|
||||||
|
<deployment identifier="iOS"/>
|
||||||
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
|
||||||
|
</dependencies>
|
||||||
|
<scenes>
|
||||||
|
<!--Flutter View Controller-->
|
||||||
|
<scene sceneID="tne-QT-ifu">
|
||||||
|
<objects>
|
||||||
|
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
|
||||||
|
<layoutGuides>
|
||||||
|
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
|
||||||
|
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
|
||||||
|
</layoutGuides>
|
||||||
|
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
|
||||||
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
|
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
|
||||||
|
</view>
|
||||||
|
</viewController>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||||
|
</objects>
|
||||||
|
</scene>
|
||||||
|
</scenes>
|
||||||
|
</document>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
|
<true/>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>鑫潼智电运营管理</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>smart_grid_iot_manage_app_flutter</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||||
|
<key>CFBundleSignature</key>
|
||||||
|
<string>????</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>UIApplicationSceneManifest</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
<false/>
|
||||||
|
<key>UISceneConfigurations</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIWindowSceneSessionRoleApplication</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>UISceneClassName</key>
|
||||||
|
<string>UIWindowScene</string>
|
||||||
|
<key>UISceneConfigurationName</key>
|
||||||
|
<string>flutter</string>
|
||||||
|
<key>UISceneDelegateClassName</key>
|
||||||
|
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||||
|
<key>UISceneStoryboardFile</key>
|
||||||
|
<string>Main</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
|
<true/>
|
||||||
|
<key>UILaunchStoryboardName</key>
|
||||||
|
<string>LaunchScreen</string>
|
||||||
|
<key>UIMainStoryboardFile</key>
|
||||||
|
<string>Main</string>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>用于拍摄勘测现场照片与拜访取证照片</string>
|
||||||
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
|
<string>用于选择勘测现场照片与拜访取证照片</string>
|
||||||
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
|
<string>用于拜访签到时记录当前位置</string>
|
||||||
|
<!-- 后端为 http://192.168.3.29:7001,与 uni-app 版行为一致,允许明文 HTTP -->
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>用于访问局域网内的运营管理后端服务</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
#import "GeneratedPluginRegistrant.h"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import Flutter
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class SceneDelegate: FlutterSceneDelegate {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<GoRouter>((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']!)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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<AppTab> {
|
||||||
|
@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, AppTab>(
|
||||||
|
TabIndexController.new,
|
||||||
|
);
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// 全局 Key:供非 Widget 层(拦截器、工具函数)访问 Navigator / ScaffoldMessenger。
|
||||||
|
abstract final class AppKeys {
|
||||||
|
static final rootNavigator = GlobalKey<NavigatorState>();
|
||||||
|
static final scaffoldMessenger = GlobalKey<ScaffoldMessengerState>();
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<String, dynamic> json) => UploadResult(
|
||||||
|
key: json['key'] as String? ?? '',
|
||||||
|
url: json['url'] as String? ?? '',
|
||||||
|
name: json['name'] as String? ?? '',
|
||||||
|
size: (json['size'] as num?)?.toInt() ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 统一请求封装,等价于 uni-app 版 `utils/request.ts`:
|
||||||
|
/// - 自动注入 `Authorization: Bearer <token>`
|
||||||
|
/// - 解析 `{ code, message, data }`,非 200 也照常返回给调用方判断
|
||||||
|
/// - 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<void>.broadcast();
|
||||||
|
|
||||||
|
/// 会话失效事件:刷新失败或非过期类 401 时触发,由会话层订阅后跳转登录页。
|
||||||
|
Stream<void> get onUnauthorized => _unauthorizedController.stream;
|
||||||
|
|
||||||
|
Dio get raw => _dio;
|
||||||
|
|
||||||
|
// ---- 对外请求方法 ----
|
||||||
|
|
||||||
|
Future<ApiResponse<T>> get<T>(
|
||||||
|
String url, {
|
||||||
|
Map<String, dynamic>? query,
|
||||||
|
T Function(dynamic data)? parse,
|
||||||
|
bool needAuth = true,
|
||||||
|
}) => _send<T>(
|
||||||
|
'GET',
|
||||||
|
url,
|
||||||
|
query: _compact(query),
|
||||||
|
parse: parse,
|
||||||
|
needAuth: needAuth,
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<ApiResponse<T>> post<T>(
|
||||||
|
String url, {
|
||||||
|
Object? body,
|
||||||
|
T Function(dynamic data)? parse,
|
||||||
|
bool needAuth = true,
|
||||||
|
}) => _send<T>('POST', url, body: body, parse: parse, needAuth: needAuth);
|
||||||
|
|
||||||
|
Future<ApiResponse<T>> put<T>(
|
||||||
|
String url, {
|
||||||
|
Object? body,
|
||||||
|
T Function(dynamic data)? parse,
|
||||||
|
bool needAuth = true,
|
||||||
|
}) => _send<T>('PUT', url, body: body, parse: parse, needAuth: needAuth);
|
||||||
|
|
||||||
|
Future<ApiResponse<T>> delete<T>(
|
||||||
|
String url, {
|
||||||
|
T Function(dynamic data)? parse,
|
||||||
|
bool needAuth = true,
|
||||||
|
}) => _send<T>('DELETE', url, parse: parse, needAuth: needAuth);
|
||||||
|
|
||||||
|
/// 上传现场照片到对象存储(multipart/form-data,字段名 `file`)。
|
||||||
|
/// 严格依赖对象存储:失败直接抛异常,由调用方阻断保存并提示用户。
|
||||||
|
Future<UploadResult> uploadImage(String filePath) async {
|
||||||
|
try {
|
||||||
|
final form = FormData.fromMap({
|
||||||
|
'file': await MultipartFile.fromFile(filePath),
|
||||||
|
});
|
||||||
|
final res = await _dio.post<dynamic>(
|
||||||
|
'/upload/image',
|
||||||
|
data: form,
|
||||||
|
options: Options(headers: _authHeader()),
|
||||||
|
);
|
||||||
|
final json = _asMap(res.data);
|
||||||
|
final code = (json['code'] as num?)?.toInt();
|
||||||
|
final data = json['data'];
|
||||||
|
if (code == 200 && data is Map) {
|
||||||
|
return UploadResult.fromJson(data.cast<String, dynamic>());
|
||||||
|
}
|
||||||
|
throw ApiException(
|
||||||
|
(json['message'] as String?) ?? '图片上传失败',
|
||||||
|
code: code,
|
||||||
|
);
|
||||||
|
} on ApiException {
|
||||||
|
rethrow;
|
||||||
|
} catch (_) {
|
||||||
|
throw const ApiException('图片上传失败:网络异常');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() => _unauthorizedController.close();
|
||||||
|
|
||||||
|
// ---- 内部实现 ----
|
||||||
|
|
||||||
|
Future<ApiResponse<T>> _send<T>(
|
||||||
|
String method,
|
||||||
|
String url, {
|
||||||
|
Map<String, dynamic>? query,
|
||||||
|
Object? body,
|
||||||
|
T Function(dynamic data)? parse,
|
||||||
|
required bool needAuth,
|
||||||
|
bool allowRetry = true,
|
||||||
|
}) async {
|
||||||
|
late Response<dynamic> res;
|
||||||
|
try {
|
||||||
|
res = await _dio.request<dynamic>(
|
||||||
|
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<T>.fromJson(json, parse);
|
||||||
|
|
||||||
|
if (response.code == 401) {
|
||||||
|
final expired = response.message.contains('过期');
|
||||||
|
if (expired && allowRetry) {
|
||||||
|
final refreshed = await _refreshToken();
|
||||||
|
if (refreshed) {
|
||||||
|
return _send<T>(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
query: query,
|
||||||
|
body: body,
|
||||||
|
parse: parse,
|
||||||
|
needAuth: needAuth,
|
||||||
|
allowRetry: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_unauthorizedController.add(null);
|
||||||
|
throw const ApiException('登录已失效,请重新登录', code: 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _authHeader() {
|
||||||
|
final token = _storage.accessToken;
|
||||||
|
return token.isEmpty ? const {} : {'Authorization': 'Bearer $token'};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool>? _refreshing;
|
||||||
|
|
||||||
|
/// 刷新 accessToken;并发调用共用同一个请求,避免刷新风暴。
|
||||||
|
Future<bool> _refreshToken() {
|
||||||
|
return _refreshing ??= _doRefresh().whenComplete(() => _refreshing = null);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _doRefresh() async {
|
||||||
|
final refreshToken = _storage.refreshToken;
|
||||||
|
if (refreshToken.isEmpty) return false;
|
||||||
|
try {
|
||||||
|
final res = await _dio.post<dynamic>(
|
||||||
|
'/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<String, dynamic> _asMap(dynamic data) {
|
||||||
|
if (data is Map) return data.cast<String, dynamic>();
|
||||||
|
if (data is String && data.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(data);
|
||||||
|
if (decoded is Map) return decoded.cast<String, dynamic>();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
return const {'code': -1, 'message': '响应解析失败'};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 去掉 null / 空串查询条件。
|
||||||
|
/// uni-app 会把 undefined 序列化成空串(如 `status=`),后端对空串查询条件会 500,
|
||||||
|
/// 因此这里同样只在有值时才拼接参数。
|
||||||
|
static Map<String, dynamic>? _compact(Map<String, dynamic>? src) {
|
||||||
|
if (src == null || src.isEmpty) return null;
|
||||||
|
final out = <String, dynamic>{};
|
||||||
|
src.forEach((k, v) {
|
||||||
|
if (v == null) return;
|
||||||
|
if (v is String && v.isEmpty) return;
|
||||||
|
out[k] = v;
|
||||||
|
});
|
||||||
|
return out.isEmpty ? null : out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/// 后端统一响应体:`{ code, message, data }`。
|
||||||
|
class ApiResponse<T> {
|
||||||
|
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<String, dynamic> json,
|
||||||
|
T Function(dynamic data)? parse,
|
||||||
|
) {
|
||||||
|
final raw = json['data'];
|
||||||
|
return ApiResponse<T>(
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<TokenStorage>(
|
||||||
|
(ref) => throw UnimplementedError('tokenStorageProvider 必须在 ProviderScope 中 override'),
|
||||||
|
);
|
||||||
|
|
||||||
|
final apiClientProvider = Provider<ApiClient>((ref) {
|
||||||
|
final client = ApiClient(storage: ref.watch(tokenStorageProvider));
|
||||||
|
ref.onDispose(client.dispose);
|
||||||
|
return client;
|
||||||
|
});
|
||||||
@@ -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<TokenStorage> open() async =>
|
||||||
|
TokenStorage(await SharedPreferences.getInstance());
|
||||||
|
|
||||||
|
String get accessToken => _prefs.getString(_kAccessToken) ?? '';
|
||||||
|
|
||||||
|
String get refreshToken => _prefs.getString(_kRefreshToken) ?? '';
|
||||||
|
|
||||||
|
Map<String, dynamic>? get userInfo {
|
||||||
|
final raw = _prefs.getString(_kUserInfo);
|
||||||
|
if (raw == null || raw.isEmpty) return null;
|
||||||
|
try {
|
||||||
|
return jsonDecode(raw) as Map<String, dynamic>;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setAccessToken(String token) =>
|
||||||
|
_prefs.setString(_kAccessToken, token);
|
||||||
|
|
||||||
|
Future<void> setRefreshToken(String token) =>
|
||||||
|
_prefs.setString(_kRefreshToken, token);
|
||||||
|
|
||||||
|
Future<void> setUserInfo(Map<String, dynamic> info) =>
|
||||||
|
_prefs.setString(_kUserInfo, jsonEncode(info));
|
||||||
|
|
||||||
|
Future<void> clearSession() async {
|
||||||
|
await _prefs.remove(_kAccessToken);
|
||||||
|
await _prefs.remove(_kRefreshToken);
|
||||||
|
await _prefs.remove(_kUserInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get hasSession => accessToken.isNotEmpty;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = <String>[
|
||||||
|
'PingFang SC',
|
||||||
|
'Microsoft YaHei',
|
||||||
|
'Noto Sans CJK SC',
|
||||||
|
];
|
||||||
|
|
||||||
|
static const _monoFallback = <String>[
|
||||||
|
'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,
|
||||||
|
);
|
||||||
@@ -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());
|
||||||
@@ -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;
|
||||||
@@ -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<AuthApi>(
|
||||||
|
(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<SessionState> {
|
||||||
|
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<bool> 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<bool> 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<void> logout() async {
|
||||||
|
await _storage.clearSession();
|
||||||
|
state = const SessionState(isLoggedIn: false, user: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空本地会话但不触发网络请求(被拦截器判定会话失效时调用)。
|
||||||
|
Future<void> clearLocal() => logout();
|
||||||
|
}
|
||||||
|
|
||||||
|
final sessionProvider = NotifierProvider<SessionController, SessionState>(
|
||||||
|
SessionController.new,
|
||||||
|
);
|
||||||
@@ -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<ApiResponse<LoginResult>> login({
|
||||||
|
required String username,
|
||||||
|
required String password,
|
||||||
|
}) => _client.post<LoginResult>(
|
||||||
|
'/auth/login',
|
||||||
|
body: {'username': username, 'password': password},
|
||||||
|
parse: (d) => LoginResult.fromJson((d as Map).cast<String, dynamic>()),
|
||||||
|
needAuth: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<ApiResponse<UserInfo>> currentUser() => _client.get<UserInfo>(
|
||||||
|
'/auth/currentUser',
|
||||||
|
parse: (d) => UserInfo.fromJson((d as Map).cast<String, dynamic>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<ApiResponse<void>> logout() => _client.post<void>('/auth/logout');
|
||||||
|
}
|
||||||
@@ -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<String> 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<String, dynamic> 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<String, dynamic> 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<String> roles;
|
||||||
|
|
||||||
|
factory LoginResult.fromJson(Map<String, dynamic> json) => LoginResult(
|
||||||
|
accessToken: json['accessToken'] as String? ?? '',
|
||||||
|
refreshToken: json['refreshToken'] as String? ?? '',
|
||||||
|
userInfo: UserInfo.fromJson(
|
||||||
|
(json['userInfo'] as Map?)?.cast<String, dynamic>() ?? const {},
|
||||||
|
),
|
||||||
|
roles: (json['roles'] as List?)?.map((e) => e.toString()).toList() ?? const [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 后端角色编码 → 中文名称映射。
|
||||||
|
const roleLabelMap = <String, String>{
|
||||||
|
'ADMIN': '运营管理员',
|
||||||
|
'SALESMAN': '业务员',
|
||||||
|
'WORKER': '共享电工',
|
||||||
|
'CUSTOMER': '客户',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 将角色编码数组映射为可读文本(多个角色以 / 分隔)。
|
||||||
|
String roleLabel(List<String>? roles) {
|
||||||
|
if (roles != null && roles.isNotEmpty) {
|
||||||
|
return roles.map((r) => roleLabelMap[r] ?? r).join(' / ');
|
||||||
|
}
|
||||||
|
return '运营管理员';
|
||||||
|
}
|
||||||
@@ -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<LoginPage> createState() => _LoginPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||||
|
final _username = TextEditingController();
|
||||||
|
final _password = TextEditingController();
|
||||||
|
|
||||||
|
bool _loading = false;
|
||||||
|
String _errorMsg = '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_username.dispose();
|
||||||
|
_password.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<CustomerApi>(
|
||||||
|
(ref) => CustomerApi(ref.watch(apiClientProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 客户档案接口,对应 uni-app 版 `api/customer.ts`。
|
||||||
|
class CustomerApi {
|
||||||
|
CustomerApi(this._client);
|
||||||
|
|
||||||
|
final ApiClient _client;
|
||||||
|
|
||||||
|
Future<ApiResponse<CustomerPage>> list({
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 100,
|
||||||
|
String? keyword,
|
||||||
|
int? status,
|
||||||
|
int? salesmanId,
|
||||||
|
}) => _client.get<CustomerPage>(
|
||||||
|
'/customers',
|
||||||
|
query: {
|
||||||
|
'page': page,
|
||||||
|
'pageSize': pageSize,
|
||||||
|
'keyword': keyword,
|
||||||
|
'status': status,
|
||||||
|
'salesmanId': salesmanId,
|
||||||
|
},
|
||||||
|
parse: (d) {
|
||||||
|
final m = (d as Map).cast<String, dynamic>();
|
||||||
|
return CustomerPage(
|
||||||
|
list: (m['list'] as List? ?? [])
|
||||||
|
.map((e) => Customer.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(),
|
||||||
|
total: (m['total'] as num?)?.toInt() ?? 0,
|
||||||
|
page: (m['page'] as num?)?.toInt() ?? 1,
|
||||||
|
pageSize: (m['pageSize'] as num?)?.toInt() ?? pageSize,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<ApiResponse<Customer>> detail(int id) => _client.get<Customer>(
|
||||||
|
'/customers/$id',
|
||||||
|
parse: (d) => Customer.fromJson((d as Map).cast<String, dynamic>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<ApiResponse<Customer>> create(Map<String, dynamic> body) =>
|
||||||
|
_client.post<Customer>(
|
||||||
|
'/customers',
|
||||||
|
body: body,
|
||||||
|
parse: (d) => Customer.fromJson((d as Map).cast<String, dynamic>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<ApiResponse<Customer>> update(int id, Map<String, dynamic> body) =>
|
||||||
|
_client.put<Customer>(
|
||||||
|
'/customers/$id',
|
||||||
|
body: body,
|
||||||
|
parse: (d) => Customer.fromJson((d as Map).cast<String, dynamic>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<ApiResponse<void>> delete(int id) => _client.delete<void>('/customers/$id');
|
||||||
|
|
||||||
|
Future<ApiResponse<void>> updateStatus(int id, int status) =>
|
||||||
|
_client.put<void>('/customers/$id/status', body: {'status': status});
|
||||||
|
|
||||||
|
Future<ApiResponse<List<CustomerOption>>> options() =>
|
||||||
|
_client.get<List<CustomerOption>>(
|
||||||
|
'/customers/options/all',
|
||||||
|
parse: (d) => (d as List)
|
||||||
|
.map((e) => CustomerOption.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<Customer> list;
|
||||||
|
final int total;
|
||||||
|
final int page;
|
||||||
|
final int pageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 客户状态:1 意向 2 签约 3 运维中 4 终止
|
||||||
|
const customerStatusMap = <int, ({String text, ChipTone tone})>{
|
||||||
|
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,
|
||||||
|
};
|
||||||
@@ -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<UserApi>(
|
||||||
|
(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<String, dynamic> 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<String, dynamic> 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<ApiResponse<List<SysRole>>> roles() => _client.get<List<SysRole>>(
|
||||||
|
'/users/roles',
|
||||||
|
parse: (d) => (d as List)
|
||||||
|
.map((e) => SysRole.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<ApiResponse<List<SysUser>>> list({
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 100,
|
||||||
|
int? status,
|
||||||
|
int? roleId,
|
||||||
|
}) => _client.get<List<SysUser>>(
|
||||||
|
'/users',
|
||||||
|
query: {'page': page, 'pageSize': pageSize, 'status': status, 'roleId': roleId},
|
||||||
|
parse: (d) {
|
||||||
|
final m = (d as Map).cast<String, dynamic>();
|
||||||
|
return (m['list'] as List? ?? [])
|
||||||
|
.map((e) => SysUser.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 业务员下拉选项:先按 roleCode 解析 SALESMAN 角色 id,再取其启用状态的用户。
|
||||||
|
Future<List<SysUser>> 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = <String>['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<CustomerDetailPage> createState() => _CustomerDetailPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CustomerDetailPageState extends ConsumerState<CustomerDetailPage> {
|
||||||
|
Customer? _customer;
|
||||||
|
bool _loading = true;
|
||||||
|
List<VisitInfo> _visits = const [];
|
||||||
|
int _visitTotal = 0;
|
||||||
|
bool _visitLoading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
await Future.wait([_loadCustomer(), _loadVisits()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _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<void> _goEdit() async {
|
||||||
|
await context.push('/customer/edit?id=${widget.id}');
|
||||||
|
if (!mounted) return;
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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 = <Widget>[
|
||||||
|
_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 = <Widget>[];
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = <String, Map<String, List<String>>>{
|
||||||
|
'北京市': {
|
||||||
|
'北京市': ['东城区', '西城区', '朝阳区', '海淀区', '丰台区', '石景山区', '通州区'],
|
||||||
|
},
|
||||||
|
'上海市': {
|
||||||
|
'上海市': ['黄浦区', '徐汇区', '长宁区', '静安区', '普陀区', '浦东新区', '闵行区'],
|
||||||
|
},
|
||||||
|
'广东省': {
|
||||||
|
'广州市': ['越秀区', '天河区', '海珠区', '白云区', '番禺区'],
|
||||||
|
'深圳市': ['福田区', '南山区', '罗湖区', '宝安区', '龙岗区'],
|
||||||
|
'珠海市': ['香洲区', '金湾区', '斗门区'],
|
||||||
|
},
|
||||||
|
'浙江省': {
|
||||||
|
'杭州市': ['上城区', '拱墅区', '西湖区', '滨江区', '余杭区'],
|
||||||
|
'宁波市': ['海曙区', '江北区', '鄞州区', '北仑区'],
|
||||||
|
'温州市': ['鹿城区', '龙湾区', '瓯海区'],
|
||||||
|
},
|
||||||
|
'江苏省': {
|
||||||
|
'南京市': ['玄武区', '秦淮区', '鼓楼区', '江宁区'],
|
||||||
|
'苏州市': ['姑苏区', '吴中区', '相城区', '工业园区'],
|
||||||
|
'无锡市': ['梁溪区', '滨湖区', '新吴区'],
|
||||||
|
},
|
||||||
|
'四川省': {
|
||||||
|
'成都市': ['锦江区', '青羊区', '武侯区', '成华区', '高新区'],
|
||||||
|
'绵阳市': ['涪城区', '游仙区', '安州区'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 客户编辑页(新增 / 编辑),对应 `pages/customer/edit.vue`。
|
||||||
|
class CustomerEditPage extends ConsumerStatefulWidget {
|
||||||
|
const CustomerEditPage({super.key, this.id});
|
||||||
|
|
||||||
|
/// 为 null 表示新增。
|
||||||
|
final int? id;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<CustomerEditPage> createState() => _CustomerEditPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CustomerEditPageState extends ConsumerState<CustomerEditPage> {
|
||||||
|
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<SysUser> _salesmen = const [];
|
||||||
|
int _salesmanIndex = 0;
|
||||||
|
|
||||||
|
List<String> 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<void> _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<void> _onSave() async {
|
||||||
|
if (_customerName.text.trim().isEmpty) {
|
||||||
|
showAppToast('请输入客户名称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _saving = true);
|
||||||
|
|
||||||
|
final payload = <String, dynamic>{
|
||||||
|
'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<Customer> 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<void> _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<void> _pickLevel() async {
|
||||||
|
final index = await _pickIndex(
|
||||||
|
'选择设施类型',
|
||||||
|
_levelOptions,
|
||||||
|
selected: _levelOptions.indexOf(_customerLevel),
|
||||||
|
);
|
||||||
|
if (index == null) return;
|
||||||
|
setState(() => _customerLevel = _levelOptions[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickStatus() async {
|
||||||
|
final index = await _pickIndex(
|
||||||
|
'选择状态',
|
||||||
|
_statusValues.map(customerStatusText).toList(),
|
||||||
|
selected: _statusValues.indexOf(_status),
|
||||||
|
);
|
||||||
|
if (index == null) return;
|
||||||
|
setState(() => _status = _statusValues[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickSalesman() async {
|
||||||
|
final index = await _pickIndex(
|
||||||
|
'选择负责业务员',
|
||||||
|
_salesmanLabels,
|
||||||
|
selected: _salesmanIndex,
|
||||||
|
);
|
||||||
|
if (index == null) return;
|
||||||
|
setState(() => _salesmanIndex = index);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int?> _pickIndex(
|
||||||
|
String title,
|
||||||
|
List<String> options, {
|
||||||
|
int? selected,
|
||||||
|
}) {
|
||||||
|
return showModalBottomSheet<int>(
|
||||||
|
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 = <Widget>[
|
||||||
|
_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<String> 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<String>().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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<CustomerTab> createState() => _CustomerTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CustomerTabState extends ConsumerState<CustomerTab> {
|
||||||
|
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<Customer> _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<void> _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<void> _goDetail(Customer c) async {
|
||||||
|
await context.push('/customer/detail/${c.id}');
|
||||||
|
if (mounted) _load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _goEdit(Customer c) async {
|
||||||
|
await context.push('/customer/edit?id=${c.id}');
|
||||||
|
if (mounted) _load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<Customer> 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<DashboardApi>(
|
||||||
|
(ref) => DashboardApi(ref.watch(apiClientProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 工作台统计接口,对应 uni-app 版 `api/dashboard.ts`。
|
||||||
|
class DashboardApi {
|
||||||
|
DashboardApi(this._client);
|
||||||
|
|
||||||
|
final ApiClient _client;
|
||||||
|
|
||||||
|
Future<ApiResponse<DashboardStats>> stats() => _client.get<DashboardStats>(
|
||||||
|
'/dashboard/stats',
|
||||||
|
parse: (d) => DashboardStats.fromJson((d as Map).cast<String, dynamic>()),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<NameValue> surveyByStatus;
|
||||||
|
final List<NameValue> modules;
|
||||||
|
final List<NameValue> customerStatus;
|
||||||
|
final num customerSignRate;
|
||||||
|
final List<DashboardSurveyItem> surveyList;
|
||||||
|
final List<DashboardProjectItem> projects;
|
||||||
|
|
||||||
|
/// 趋势项字段为 `{ date, customers, contracts }`,App 端暂未使用,保留原始结构。
|
||||||
|
final List<Map<String, dynamic>> trend;
|
||||||
|
|
||||||
|
factory DashboardStats.fromJson(Map<String, dynamic> 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<String, dynamic>())
|
||||||
|
.toList(),
|
||||||
|
customerStatus: _nameValues(json['customerStatus']),
|
||||||
|
customerSignRate: (json['customerSignRate'] as num?) ?? 0,
|
||||||
|
surveyList: (json['surveyList'] as List? ?? [])
|
||||||
|
.map((e) => DashboardSurveyItem.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(),
|
||||||
|
projects: (json['projects'] as List? ?? [])
|
||||||
|
.map((e) => DashboardProjectItem.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
static List<NameValue> _nameValues(dynamic raw) => (raw as List? ?? [])
|
||||||
|
.map((e) => NameValue.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
@@ -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<MineTab> createState() => _MineTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MineTabState extends ConsumerState<MineTab> {
|
||||||
|
int _customerCount = 0;
|
||||||
|
int _activeProjects = 0;
|
||||||
|
int _surveys = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _goSurvey() async {
|
||||||
|
await context.push('/survey');
|
||||||
|
if (mounted) _load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ProjectApi>(
|
||||||
|
(ref) => ProjectApi(ref.watch(apiClientProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 智电改造方案接口,对应后端 `/api/project-plans`。
|
||||||
|
///
|
||||||
|
/// 说明:uni-app 版项目模块使用本地 mock(`api/mock.ts`),字段与后端契约完全不一致;
|
||||||
|
/// Flutter 版按用户确认改为直连后端真实接口。
|
||||||
|
class ProjectApi {
|
||||||
|
ProjectApi(this._client);
|
||||||
|
|
||||||
|
final ApiClient _client;
|
||||||
|
|
||||||
|
Future<ApiResponse<ProjectPlanPage>> list({
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 100,
|
||||||
|
String? keyword,
|
||||||
|
int? customerId,
|
||||||
|
int? status,
|
||||||
|
String? registerStartDate,
|
||||||
|
String? registerEndDate,
|
||||||
|
}) => _client.get<ProjectPlanPage>(
|
||||||
|
'/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<String, dynamic>();
|
||||||
|
return ProjectPlanPage(
|
||||||
|
list: (m['list'] as List? ?? [])
|
||||||
|
.map((e) => ProjectPlan.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(),
|
||||||
|
total: (m['total'] as num?)?.toInt() ?? 0,
|
||||||
|
page: (m['page'] as num?)?.toInt() ?? 1,
|
||||||
|
pageSize: (m['pageSize'] as num?)?.toInt() ?? pageSize,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<ApiResponse<ProjectPlan>> detail(int id) => _client.get<ProjectPlan>(
|
||||||
|
'/project-plans/$id',
|
||||||
|
parse: (d) => ProjectPlan.fromJson((d as Map).cast<String, dynamic>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<ApiResponse<ProjectPlan>> create(Map<String, dynamic> body) =>
|
||||||
|
_client.post<ProjectPlan>(
|
||||||
|
'/project-plans',
|
||||||
|
body: body,
|
||||||
|
parse: (d) => ProjectPlan.fromJson((d as Map).cast<String, dynamic>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<ApiResponse<ProjectPlan>> update(int id, Map<String, dynamic> body) =>
|
||||||
|
_client.put<ProjectPlan>(
|
||||||
|
'/project-plans/$id',
|
||||||
|
body: body,
|
||||||
|
parse: (d) => ProjectPlan.fromJson((d as Map).cast<String, dynamic>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<ApiResponse<void>> delete(int id) => _client.delete<void>('/project-plans/$id');
|
||||||
|
}
|
||||||
@@ -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 = <int, ({String text, ChipTone tone})>{
|
||||||
|
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<String, dynamic> 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<String, dynamic> 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<ProjectPlanDetail> details;
|
||||||
|
final int status;
|
||||||
|
|
||||||
|
factory ProjectPlan.fromJson(Map<String, dynamic> 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<String, dynamic>()))
|
||||||
|
.toList(),
|
||||||
|
status: toIntOr(json['status'], 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 设备明细合计数量。
|
||||||
|
num get detailQuantityTotal =>
|
||||||
|
details.fold<num>(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<ProjectPlan> list;
|
||||||
|
final int total;
|
||||||
|
final int page;
|
||||||
|
final int pageSize;
|
||||||
|
}
|
||||||
@@ -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<ProjectDetailPage> createState() => _ProjectDetailPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProjectDetailPageState extends ConsumerState<ProjectDetailPage> {
|
||||||
|
ProjectPlan? _plan;
|
||||||
|
String? _customerName;
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _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 = <Widget>[
|
||||||
|
_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<ProjectPlanDetail> details) {
|
||||||
|
final items = <Widget>[];
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ProjectTab> createState() => _ProjectTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProjectTabState extends ConsumerState<ProjectTab> {
|
||||||
|
static const _statusOptions = <({String label, int? value})>[
|
||||||
|
(label: '全部', value: null),
|
||||||
|
(label: '草稿', value: ProjectPlanStatus.draft),
|
||||||
|
(label: '已确认', value: ProjectPlanStatus.confirmed),
|
||||||
|
];
|
||||||
|
|
||||||
|
final _searchController = TextEditingController();
|
||||||
|
|
||||||
|
Timer? _searchTimer;
|
||||||
|
List<ProjectPlan> _list = const [];
|
||||||
|
Map<int, String> _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<void> _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<void> _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<void> _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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SurveyApi>(
|
||||||
|
(ref) => SurveyApi(ref.watch(apiClientProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// SOP 回执增量保存结果。
|
||||||
|
class SopSaveResult {
|
||||||
|
const SopSaveResult({required this.updated, required this.skipped, required this.sopResults});
|
||||||
|
|
||||||
|
final int updated;
|
||||||
|
final List<int> skipped;
|
||||||
|
final List<SurveySopResult> sopResults;
|
||||||
|
|
||||||
|
factory SopSaveResult.fromJson(Map<String, dynamic> 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<String, dynamic>()))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 勘察工单接口,对应 uni-app 版 `api/survey.ts`。
|
||||||
|
class SurveyApi {
|
||||||
|
SurveyApi(this._client);
|
||||||
|
|
||||||
|
final ApiClient _client;
|
||||||
|
|
||||||
|
Future<ApiResponse<SurveyPage>> 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<SurveyPage>(
|
||||||
|
'/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<String, dynamic>();
|
||||||
|
return SurveyPage(
|
||||||
|
list: (m['list'] as List? ?? [])
|
||||||
|
.map((e) => SurveyItem.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(),
|
||||||
|
total: (m['total'] as num?)?.toInt() ?? 0,
|
||||||
|
page: (m['page'] as num?)?.toInt() ?? 1,
|
||||||
|
pageSize: (m['pageSize'] as num?)?.toInt() ?? pageSize,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<ApiResponse<SurveyItem>> detail(int id) => _client.get<SurveyItem>(
|
||||||
|
'/survey-orders/$id',
|
||||||
|
parse: (d) => SurveyItem.fromJson((d as Map).cast<String, dynamic>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<ApiResponse<SurveyItem>> update(int id, Map<String, dynamic> body) =>
|
||||||
|
_client.put<SurveyItem>(
|
||||||
|
'/survey-orders/$id',
|
||||||
|
body: body,
|
||||||
|
parse: (d) => SurveyItem.fromJson((d as Map).cast<String, dynamic>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 按 SOP 检查项增量保存现场回执;
|
||||||
|
/// 仅现场勘察中(status=1)且工单对当前用户可见时后端才放行。
|
||||||
|
Future<ApiResponse<SopSaveResult>> saveSopResults(int id, List<SopResultPayload> results) =>
|
||||||
|
_client.put<SopSaveResult>(
|
||||||
|
'/survey-orders/$id/sop-results',
|
||||||
|
body: {'results': results.map((e) => e.toJson()).toList()},
|
||||||
|
parse: (d) => SopSaveResult.fromJson((d as Map).cast<String, dynamic>()),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import 'package:flutter/painting.dart';
|
||||||
|
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import '../../../core/utils/id_util.dart';
|
||||||
|
|
||||||
|
/// 勘察工单状态:0 待指派 1 现场勘察中 2 待技术复核 3 已归档 4 预留 5 已取消。
|
||||||
|
abstract final class SurveyStatus {
|
||||||
|
static const pendingAssign = 0;
|
||||||
|
static const surveying = 1;
|
||||||
|
static const pendingReview = 2;
|
||||||
|
static const archived = 3;
|
||||||
|
static const reserved = 4;
|
||||||
|
static const canceled = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 优先级:1 特急 P1 2 加急 P2 3 常规 P3。
|
||||||
|
abstract final class SurveyPriority {
|
||||||
|
static const urgent = 1;
|
||||||
|
static const expedited = 2;
|
||||||
|
static const regular = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// App 端展示用的状态枚举(与 uni-app 版 `SurveyStatusMap` 一一对应)。
|
||||||
|
enum SurveyStatusKey { pending, inProgress, review, completed, canceled }
|
||||||
|
|
||||||
|
const surveyStatusMap = <SurveyStatusKey, ({String text, ChipTone tone})>{
|
||||||
|
SurveyStatusKey.pending: (text: '待处理', tone: ChipTone.warn),
|
||||||
|
SurveyStatusKey.inProgress: (text: '进行中', tone: ChipTone.primary),
|
||||||
|
SurveyStatusKey.review: (text: '待复核', tone: ChipTone.secondary),
|
||||||
|
SurveyStatusKey.completed: (text: '已完成', tone: ChipTone.tertiary),
|
||||||
|
SurveyStatusKey.canceled: (text: '已取消', tone: ChipTone.error),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 数字状态 → App 展示键。
|
||||||
|
SurveyStatusKey mapSurveyStatus(int? code) => switch (code) {
|
||||||
|
SurveyStatus.surveying => SurveyStatusKey.inProgress,
|
||||||
|
SurveyStatus.pendingReview => SurveyStatusKey.review,
|
||||||
|
SurveyStatus.archived => SurveyStatusKey.completed,
|
||||||
|
SurveyStatus.canceled => SurveyStatusKey.canceled,
|
||||||
|
_ => SurveyStatusKey.pending,
|
||||||
|
};
|
||||||
|
|
||||||
|
String surveyStatusText(int? code) => surveyStatusMap[mapSurveyStatus(code)]!.text;
|
||||||
|
|
||||||
|
ChipTone surveyStatusTone(int? code) => surveyStatusMap[mapSurveyStatus(code)]!.tone;
|
||||||
|
|
||||||
|
const surveyPriorityMap = <int, ({String text, ChipTone tone})>{
|
||||||
|
1: (text: '特急 P1', tone: ChipTone.error),
|
||||||
|
2: (text: '加急 P2', tone: ChipTone.warn),
|
||||||
|
3: (text: '常规 P3', tone: ChipTone.muted),
|
||||||
|
};
|
||||||
|
|
||||||
|
String surveyPriorityText(int? p) => surveyPriorityMap[p]?.text ?? '—';
|
||||||
|
|
||||||
|
ChipTone surveyPriorityTone(int? p) => surveyPriorityMap[p]?.tone ?? ChipTone.muted;
|
||||||
|
|
||||||
|
/// 列表卡片左侧色条颜色。
|
||||||
|
Color surveyStatusBarColor(int? status) => switch (status) {
|
||||||
|
SurveyStatus.pendingAssign => AppColors.statusWarn,
|
||||||
|
SurveyStatus.surveying => AppColors.primary,
|
||||||
|
SurveyStatus.pendingReview => AppColors.secondary,
|
||||||
|
SurveyStatus.archived => AppColors.tertiaryContainer,
|
||||||
|
SurveyStatus.canceled => AppColors.statusError,
|
||||||
|
_ => AppColors.borderRule,
|
||||||
|
};
|
||||||
|
|
||||||
|
class SurveyCustomer {
|
||||||
|
const SurveyCustomer({required this.id, required this.customerName, this.city, this.address});
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final String customerName;
|
||||||
|
final String? city;
|
||||||
|
final String? address;
|
||||||
|
|
||||||
|
factory SurveyCustomer.fromJson(Map<String, dynamic> json) => SurveyCustomer(
|
||||||
|
id: toIntOr(json['id'], 0),
|
||||||
|
customerName: json['customerName'] as String? ?? '',
|
||||||
|
city: json['city'] as String?,
|
||||||
|
address: json['address'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class SurveyWorker {
|
||||||
|
const SurveyWorker({required this.id, required this.workerName, this.phone});
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final String workerName;
|
||||||
|
final String? phone;
|
||||||
|
|
||||||
|
factory SurveyWorker.fromJson(Map<String, dynamic> json) => SurveyWorker(
|
||||||
|
id: toIntOr(json['id'], 0),
|
||||||
|
workerName: json['workerName'] as String? ?? '',
|
||||||
|
phone: json['phone'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class SurveySopResult {
|
||||||
|
const SurveySopResult({
|
||||||
|
required this.id,
|
||||||
|
required this.surveyOrderId,
|
||||||
|
required this.sopItemId,
|
||||||
|
required this.checked,
|
||||||
|
required this.itemName,
|
||||||
|
this.itemDesc = '',
|
||||||
|
this.receiptType = '',
|
||||||
|
this.receiptRequire = '',
|
||||||
|
this.required = false,
|
||||||
|
this.receiptValue = const [],
|
||||||
|
this.receiptUrls = const [],
|
||||||
|
this.remark = '',
|
||||||
|
});
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final int surveyOrderId;
|
||||||
|
final int sopItemId;
|
||||||
|
final bool checked;
|
||||||
|
final String itemName;
|
||||||
|
final String itemDesc;
|
||||||
|
final String receiptType;
|
||||||
|
final String receiptRequire;
|
||||||
|
final bool required;
|
||||||
|
|
||||||
|
/// 回执内容:photo/meter 类为照片对象 key 数组,form 类为单元素文本值数组。
|
||||||
|
final List<String> receiptValue;
|
||||||
|
|
||||||
|
/// 详情接口附带:照片类回执的预签名访问 URL(与 receiptValue 顺序一致)。
|
||||||
|
final List<String> receiptUrls;
|
||||||
|
final String remark;
|
||||||
|
|
||||||
|
factory SurveySopResult.fromJson(Map<String, dynamic> json) => SurveySopResult(
|
||||||
|
id: toIntOr(json['id'], 0),
|
||||||
|
surveyOrderId: toIntOr(json['surveyOrderId'], 0),
|
||||||
|
sopItemId: toIntOr(json['sopItemId'], 0),
|
||||||
|
checked: json['checked'] == true,
|
||||||
|
itemName: json['itemName'] as String? ?? '',
|
||||||
|
itemDesc: json['itemDesc'] as String? ?? '',
|
||||||
|
receiptType: json['receiptType'] as String? ?? '',
|
||||||
|
receiptRequire: json['receiptRequire'] as String? ?? '',
|
||||||
|
required: json['required'] == true,
|
||||||
|
receiptValue: (json['receiptValue'] as List? ?? []).map((e) => e.toString()).toList(),
|
||||||
|
receiptUrls: (json['receiptUrls'] as List? ?? []).map((e) => e.toString()).toList(),
|
||||||
|
remark: json['remark'] as String? ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 逐项回执的增量提交体:只传需要变更的 SOP 项,未传项保持原值。
|
||||||
|
class SopResultPayload {
|
||||||
|
const SopResultPayload({required this.sopItemId, this.checked, this.receiptValue, this.remark});
|
||||||
|
|
||||||
|
final int sopItemId;
|
||||||
|
final bool? checked;
|
||||||
|
final List<String>? receiptValue;
|
||||||
|
final String? remark;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'sopItemId': sopItemId,
|
||||||
|
if (checked != null) 'checked': checked,
|
||||||
|
if (receiptValue != null) 'receiptValue': receiptValue,
|
||||||
|
if (remark != null) 'remark': remark,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 勘察工单,与后端 `/api/survey-orders` 契约对齐。
|
||||||
|
class SurveyItem {
|
||||||
|
const SurveyItem({
|
||||||
|
required this.id,
|
||||||
|
required this.surveyNo,
|
||||||
|
required this.customerId,
|
||||||
|
required this.surveyor,
|
||||||
|
required this.priority,
|
||||||
|
required this.taskTitle,
|
||||||
|
required this.siteLocation,
|
||||||
|
required this.source,
|
||||||
|
required this.loopSpec,
|
||||||
|
required this.progressDone,
|
||||||
|
required this.drawingCount,
|
||||||
|
required this.techContact,
|
||||||
|
required this.slaLevel,
|
||||||
|
required this.urgeCount,
|
||||||
|
required this.isDraft,
|
||||||
|
required this.photos,
|
||||||
|
required this.reviewRemark,
|
||||||
|
required this.cancelReason,
|
||||||
|
required this.remark,
|
||||||
|
required this.status,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
required this.customerName,
|
||||||
|
required this.workerName,
|
||||||
|
required this.salesmanName,
|
||||||
|
required this.salesmanPhone,
|
||||||
|
this.workerId,
|
||||||
|
this.surveyTime,
|
||||||
|
this.loopCount,
|
||||||
|
this.progressTotal,
|
||||||
|
this.salesmanId,
|
||||||
|
this.planStart,
|
||||||
|
this.planEnd,
|
||||||
|
this.expectMinutes,
|
||||||
|
this.expectArrivalAt,
|
||||||
|
this.lastUrgedAt,
|
||||||
|
this.estimatedFee,
|
||||||
|
this.actualFee,
|
||||||
|
this.reviewedAt,
|
||||||
|
this.customer,
|
||||||
|
this.worker,
|
||||||
|
this.sopResults,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final String surveyNo;
|
||||||
|
final int customerId;
|
||||||
|
final String surveyor;
|
||||||
|
final int? workerId;
|
||||||
|
final String? surveyTime;
|
||||||
|
final int priority;
|
||||||
|
final String taskTitle;
|
||||||
|
final String siteLocation;
|
||||||
|
final String source;
|
||||||
|
final String loopSpec;
|
||||||
|
final int? loopCount;
|
||||||
|
final int progressDone;
|
||||||
|
final int? progressTotal;
|
||||||
|
final int drawingCount;
|
||||||
|
final int? salesmanId;
|
||||||
|
final String techContact;
|
||||||
|
final String? planStart;
|
||||||
|
final String? planEnd;
|
||||||
|
final int? expectMinutes;
|
||||||
|
final String? expectArrivalAt;
|
||||||
|
final String slaLevel;
|
||||||
|
final int urgeCount;
|
||||||
|
final String? lastUrgedAt;
|
||||||
|
final int isDraft;
|
||||||
|
final num? estimatedFee;
|
||||||
|
final num? actualFee;
|
||||||
|
final List<String> photos;
|
||||||
|
final String reviewRemark;
|
||||||
|
final String? reviewedAt;
|
||||||
|
final String cancelReason;
|
||||||
|
final String remark;
|
||||||
|
final int status;
|
||||||
|
final String? createdAt;
|
||||||
|
final String? updatedAt;
|
||||||
|
|
||||||
|
// decorate 注入的冗余字段
|
||||||
|
final SurveyCustomer? customer;
|
||||||
|
final String customerName;
|
||||||
|
final SurveyWorker? worker;
|
||||||
|
final String workerName;
|
||||||
|
final String salesmanName;
|
||||||
|
final String salesmanPhone;
|
||||||
|
|
||||||
|
// 仅详情接口返回
|
||||||
|
final List<SurveySopResult>? sopResults;
|
||||||
|
|
||||||
|
factory SurveyItem.fromJson(Map<String, dynamic> json) => SurveyItem(
|
||||||
|
id: toIntOr(json['id'], 0),
|
||||||
|
surveyNo: json['surveyNo'] as String? ?? '',
|
||||||
|
customerId: toIntOr(json['customerId'], 0),
|
||||||
|
surveyor: json['surveyor'] as String? ?? '',
|
||||||
|
workerId: toIntOrNull(json['workerId']),
|
||||||
|
surveyTime: json['surveyTime'] as String?,
|
||||||
|
priority: toIntOr(json['priority'], 3),
|
||||||
|
taskTitle: json['taskTitle'] as String? ?? '',
|
||||||
|
siteLocation: json['siteLocation'] as String? ?? '',
|
||||||
|
source: json['source'] as String? ?? '',
|
||||||
|
loopSpec: json['loopSpec'] as String? ?? '',
|
||||||
|
loopCount: toIntOrNull(json['loopCount']),
|
||||||
|
progressDone: toIntOr(json['progressDone'], 0),
|
||||||
|
progressTotal: toIntOrNull(json['progressTotal']),
|
||||||
|
drawingCount: toIntOr(json['drawingCount'], 0),
|
||||||
|
salesmanId: toIntOrNull(json['salesmanId']),
|
||||||
|
techContact: json['techContact'] as String? ?? '',
|
||||||
|
planStart: json['planStart'] as String?,
|
||||||
|
planEnd: json['planEnd'] as String?,
|
||||||
|
expectMinutes: toIntOrNull(json['expectMinutes']),
|
||||||
|
expectArrivalAt: json['expectArrivalAt'] as String?,
|
||||||
|
slaLevel: json['slaLevel'] as String? ?? '',
|
||||||
|
urgeCount: toIntOr(json['urgeCount'], 0),
|
||||||
|
lastUrgedAt: json['lastUrgedAt'] as String?,
|
||||||
|
isDraft: toIntOr(json['isDraft'], 0),
|
||||||
|
estimatedFee: json['estimatedFee'] as num?,
|
||||||
|
actualFee: json['actualFee'] as num?,
|
||||||
|
photos: (json['photos'] as List? ?? []).map((e) => e.toString()).toList(),
|
||||||
|
reviewRemark: json['reviewRemark'] as String? ?? '',
|
||||||
|
reviewedAt: json['reviewedAt'] as String?,
|
||||||
|
cancelReason: json['cancelReason'] as String? ?? '',
|
||||||
|
remark: json['remark'] as String? ?? '',
|
||||||
|
status: toIntOr(json['status'], 0),
|
||||||
|
createdAt: json['createdAt'] as String?,
|
||||||
|
updatedAt: json['updatedAt'] as String?,
|
||||||
|
customer: json['customer'] == null
|
||||||
|
? null
|
||||||
|
: SurveyCustomer.fromJson((json['customer'] as Map).cast<String, dynamic>()),
|
||||||
|
customerName: json['customerName'] as String? ?? '',
|
||||||
|
worker: json['worker'] == null
|
||||||
|
? null
|
||||||
|
: SurveyWorker.fromJson((json['worker'] as Map).cast<String, dynamic>()),
|
||||||
|
workerName: json['workerName'] as String? ?? '',
|
||||||
|
salesmanName: json['salesmanName'] as String? ?? '',
|
||||||
|
salesmanPhone: json['salesmanPhone'] as String? ?? '',
|
||||||
|
sopResults: json['sopResults'] == null
|
||||||
|
? null
|
||||||
|
: (json['sopResults'] as List)
|
||||||
|
.map((e) => SurveySopResult.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 计划时间展示,等价于 `formatSurveyPlanTime`。
|
||||||
|
String get planTimeText => formatSurveyPlanTime(planStart, planEnd);
|
||||||
|
|
||||||
|
String get displayTitle => taskTitle.isNotEmpty ? taskTitle : '勘察 #$surveyNo';
|
||||||
|
|
||||||
|
String get displayCustomer => customerName.isNotEmpty ? customerName : (customer?.customerName ?? '客户 #$customerId');
|
||||||
|
}
|
||||||
|
|
||||||
|
class SurveyPage {
|
||||||
|
const SurveyPage({
|
||||||
|
required this.list,
|
||||||
|
required this.total,
|
||||||
|
required this.page,
|
||||||
|
required this.pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<SurveyItem> list;
|
||||||
|
final int total;
|
||||||
|
final int page;
|
||||||
|
final int pageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 勘察单计划时间展示:`05-12 09:00 - 11:00` / `05-12 09:00 - 05-13 10:00` / `未安排`。
|
||||||
|
String formatSurveyPlanTime(String? planStart, String? planEnd) {
|
||||||
|
if ((planStart == null || planStart.isEmpty) && (planEnd == null || planEnd.isEmpty)) {
|
||||||
|
return '未安排';
|
||||||
|
}
|
||||||
|
String hm(DateTime dt) =>
|
||||||
|
'${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} '
|
||||||
|
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||||||
|
String md(DateTime dt) =>
|
||||||
|
'${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
|
final start = parseSurveyDate(planStart);
|
||||||
|
final end = parseSurveyDate(planEnd);
|
||||||
|
if (start != null && end != null) {
|
||||||
|
final sameDay =
|
||||||
|
start.year == end.year && start.month == end.month && start.day == end.day;
|
||||||
|
return sameDay ? '${hm(start)} - ${md(end)}' : '${hm(start)} - ${hm(end)}';
|
||||||
|
}
|
||||||
|
final only = start ?? end;
|
||||||
|
return only == null ? (planStart ?? planEnd ?? '未安排') : hm(only);
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? parseSurveyDate(String? raw) {
|
||||||
|
if (raw == null || raw.isEmpty) return null;
|
||||||
|
return DateTime.tryParse(raw);
|
||||||
|
}
|
||||||