feat: 完成 Flutter 版智能电网 IoT 管理应用初始化搭建

This commit is contained in:
andyesfly committed 2026-10-02 17:21:53 +08:00
commit f61b7bf90b
120 files changed
+15229

No files matched your search

+45
View File
@@ -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
+33
View File
@@ -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'
+17
View File
@@ -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.
+28
View File
@@ -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
+14
View File
@@ -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
+44
View File
@@ -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>
+53
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

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>
+25
View File
@@ -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>
+6
View File
@@ -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
+5
View File
@@ -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
+27
View File
@@ -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")
+34
View File
@@ -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
+24
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
#include "Generated.xcconfig"
+1
View File
@@ -0,0 +1 @@
#include "Generated.xcconfig"
+620
View File
@@ -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>
+7
View File
@@ -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>
+16
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

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>
+26
View File
@@ -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>
+84
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"
+6
View File
@@ -0,0 +1,6 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}
+12
View File
@@ -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.
}
}
+37
View File
@@ -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(),
),
);
}
}
+109
View File
@@ -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']!)),
),
],
);
});
+31
View File
@@ -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,
);
+52
View File
@@ -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),
),
),
],
),
);
}
}
+7
View File
@@ -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>();
}
+10
View File
@@ -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',
);
}
+242
View File
@@ -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;
}
}
+33
View File
@@ -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;
}
+15
View File
@@ -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;
});
+49
View File
@@ -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;
}
+103
View File
@@ -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,
};
}
+32
View File
@@ -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)),
];
}
+71
View File
@@ -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)),
);
}
}
+93
View File
@@ -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,
);
+46
View File
@@ -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());
+17
View File
@@ -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,
);
+27
View File
@@ -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');
}
+80
View File
@@ -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,
};
+99
View File
@@ -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,
),
),
],
],
),
),
),
],
),
);
}
}
+95
View File
@@ -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>()),
);
}
+357
View File
@@ -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);
}
File diff suppressed because it is too large. Load diff
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more