diff --git a/.github/workflows/build_windows_app.yml b/.github/workflows/build_windows_app.yml index 2444bddc..bd73687f 100644 --- a/.github/workflows/build_windows_app.yml +++ b/.github/workflows/build_windows_app.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - develop jobs: build: diff --git a/Makefile b/Makefile index facd9e58..7cd96496 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ build-dmg: build-macos mkdir -p build/macos/Build/Products/Package && cp -r build/macos/Build/Products/Release/AIdea.app build/macos/Build/Products/Package create-dmg --volname "AIdea Installer" \ --volicon "install.icns" \ - --background "assets/background-discovery.png" \ + --background "assets/background-dark-s1.jpg" \ --window-pos 200 120 \ --window-size 600 320 \ --icon-size 100 \ diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 00000000..e6d1506c --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,5 @@ +-dontwarn com.stripe.android.pushProvisioning.PushProvisioningActivity$g +-dontwarn com.stripe.android.pushProvisioning.PushProvisioningActivityStarter$Args +-dontwarn com.stripe.android.pushProvisioning.PushProvisioningActivityStarter$Error +-dontwarn com.stripe.android.pushProvisioning.PushProvisioningActivityStarter +-dontwarn com.stripe.android.pushProvisioning.PushProvisioningEphemeralKeyProvider \ No newline at end of file diff --git a/android/app/src/main/kotlin/cc/aicode/flutter/askaide/askaide/MainActivity.kt b/android/app/src/main/kotlin/cc/aicode/flutter/askaide/askaide/MainActivity.kt index 899a2546..6b1dc702 100644 --- a/android/app/src/main/kotlin/cc/aicode/flutter/askaide/askaide/MainActivity.kt +++ b/android/app/src/main/kotlin/cc/aicode/flutter/askaide/askaide/MainActivity.kt @@ -1,7 +1,7 @@ package cc.aicode.flutter.askaide.askaide import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.android.FlutterFragmentActivity -class MainActivity: FlutterActivity() { - -} +class MainActivity: FlutterFragmentActivity() { +} \ No newline at end of file diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml index 06952be7..861f0e61 100644 --- a/android/app/src/main/res/values-night/styles.xml +++ b/android/app/src/main/res/values-night/styles.xml @@ -1,7 +1,7 @@ - diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index 0d1fa8fc..f6888299 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -1,7 +1,7 @@ - diff --git a/assets/apple.webp b/assets/apple.webp new file mode 100644 index 00000000..a64f8ba5 Binary files /dev/null and b/assets/apple.webp differ diff --git a/assets/background-creative-island-dark.webp b/assets/background-creative-island-dark.webp deleted file mode 100644 index d46603d5..00000000 Binary files a/assets/background-creative-island-dark.webp and /dev/null differ diff --git a/assets/background-creative-island.jpg b/assets/background-creative-island.jpg new file mode 100644 index 00000000..eb964ad1 Binary files /dev/null and b/assets/background-creative-island.jpg differ diff --git a/assets/background-creative-island.webp b/assets/background-creative-island.webp deleted file mode 100644 index d53d9bb0..00000000 Binary files a/assets/background-creative-island.webp and /dev/null differ diff --git a/assets/background-dark-s1.jpg b/assets/background-dark-s1.jpg new file mode 100644 index 00000000..58d78dda Binary files /dev/null and b/assets/background-dark-s1.jpg differ diff --git a/assets/background-dark-s3.jpg b/assets/background-dark-s3.jpg new file mode 100644 index 00000000..28a66182 Binary files /dev/null and b/assets/background-dark-s3.jpg differ diff --git a/assets/background-dark.jpg b/assets/background-dark.jpg new file mode 100644 index 00000000..4f086999 Binary files /dev/null and b/assets/background-dark.jpg differ diff --git a/assets/background-dark.png b/assets/background-dark.png deleted file mode 100644 index 0c05829e..00000000 Binary files a/assets/background-dark.png and /dev/null differ diff --git a/assets/background-discovery-dark.jpg b/assets/background-discovery-dark.jpg new file mode 100644 index 00000000..42293340 Binary files /dev/null and b/assets/background-discovery-dark.jpg differ diff --git a/assets/background-discovery-dark.webp b/assets/background-discovery-dark.webp deleted file mode 100644 index e056a176..00000000 Binary files a/assets/background-discovery-dark.webp and /dev/null differ diff --git a/assets/background-discovery.png b/assets/background-discovery.png deleted file mode 100644 index 4de58365..00000000 Binary files a/assets/background-discovery.png and /dev/null differ diff --git a/assets/background-light-s1.jpg b/assets/background-light-s1.jpg new file mode 100644 index 00000000..ae9428cf Binary files /dev/null and b/assets/background-light-s1.jpg differ diff --git a/assets/background-team-dark.png b/assets/background-team-dark.png deleted file mode 100644 index ce756861..00000000 Binary files a/assets/background-team-dark.png and /dev/null differ diff --git a/assets/background-team.jpg b/assets/background-team.jpg new file mode 100644 index 00000000..896efd0f Binary files /dev/null and b/assets/background-team.jpg differ diff --git a/assets/background-team.png b/assets/background-team.png deleted file mode 100644 index 33e7544c..00000000 Binary files a/assets/background-team.png and /dev/null differ diff --git a/assets/background.jpg b/assets/background.jpg new file mode 100644 index 00000000..0e68170e Binary files /dev/null and b/assets/background.jpg differ diff --git a/assets/background.png b/assets/background.png deleted file mode 100644 index 44230799..00000000 Binary files a/assets/background.png and /dev/null differ diff --git a/assets/stripe.png b/assets/stripe.png new file mode 100644 index 00000000..aa2a7804 Binary files /dev/null and b/assets/stripe.png differ diff --git a/assets/wechat-pay.png b/assets/wechat-pay.png new file mode 100644 index 00000000..d17ce60d Binary files /dev/null and b/assets/wechat-pay.png differ diff --git a/assets/zhifubao.png b/assets/zhifubao.png new file mode 100644 index 00000000..ab696f32 Binary files /dev/null and b/assets/zhifubao.png differ diff --git a/build-win.bat b/build-win.bat index 03ee7ea7..c7269d8b 100644 --- a/build-win.bat +++ b/build-win.bat @@ -1 +1 @@ -dart run msix:create --release -v --output-path build/windows/runner --output-name AIdea \ No newline at end of file +flutter build windows --release \ No newline at end of file diff --git a/docker-build.sh b/docker-build.sh index 31269646..0a5313cf 100755 --- a/docker-build.sh +++ b/docker-build.sh @@ -1,13 +1,9 @@ #!/usr/bin/env bash -VERSION=1.0.13 +VERSION=1.0.13.1 rm -fr build/web flutter build web --web-renderer canvaskit --release --dart-define=API_SERVER_URL=/ -cd scripts && go run main.go ../build/web/main.dart.js && cd .. -rm -fr build/web/fonts/ && mkdir build/web/fonts -cp -r scripts/s build/web/fonts/s - docker buildx build --platform=linux/amd64,linux/arm64 -t mylxsw/aidea-web:$VERSION . --push diff --git a/install.iss b/install.iss index 7d224bbf..93e71a85 100644 --- a/install.iss +++ b/install.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "AIdea" -#define MyAppVersion "1.0.13" +#define MyAppVersion "1.0.14" #define MyAppPublisher "Shenzhen Gulu Artificial Intelligence Technology Co., Ltd." #define MyAppURL "https://ai.aicode.cc/" #define MyAppExeName "AIdea.exe" @@ -23,7 +23,7 @@ DisableProgramGroupPage=yes ; Uncomment the following line to run in non administrative install mode (install for current user only.) ;PrivilegesRequired=lowest OutputDir=C:\Users\GYY\Desktop -OutputBaseFilename=AIdeaInstaller +OutputBaseFilename={#MyAppName}-{#MyAppVersion}-Installer SetupIconFile=D:\projects\aidea\app.ico Compression=lzma SolidCompression=yes @@ -36,87 +36,87 @@ Name: "english"; MessagesFile: "compiler:Default.isl" Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: checkablealone [Files] -Source: "D:\projects\aidea\build\windows\runner\Release\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-core-console-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-core-console-l1-2-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-core-datetime-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-core-debug-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-core-errorhandling-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-core-fibers-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-core-file-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-core-file-l1-2-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-core-file-l2-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-core-handle-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-core-heap-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-core-interlocked-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-core-libraryloader-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-core-localization-l1-2-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-core-memory-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-core-namedpipe-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-core-processenvironment-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-core-processthreads-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-core-processthreads-l1-1-1.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-core-profile-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-core-rtlsupport-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-core-string-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-core-synch-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-core-synch-l1-2-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-core-sysinfo-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-core-timezone-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-core-util-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-crt-conio-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-crt-convert-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-crt-environment-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-crt-filesystem-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-crt-heap-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-crt-locale-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-crt-math-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-crt-multibyte-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-crt-private-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-crt-process-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-crt-runtime-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-crt-stdio-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-crt-string-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-crt-time-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-crt-utility-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-downlevel-kernel32-l2-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\api-ms-win-eventing-provider-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\audioplayers_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\concrt140.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\d3dcompiler_47.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\file_saver_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\flutter_localization_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\flutter_tts_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\flutter_windows.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\libc++.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\libEGL.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\libGLESv2.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\libmpv-2.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\media_kit_libs_windows_video_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\media_kit_native_event_loop.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\media_kit_video_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\msvcp140.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\msvcp140_1.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\msvcp140_2.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\msvcp140_atomic_wait.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\msvcp140_codecvt_ids.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\record_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\screen_brightness_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\share_plus_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\sqlite3.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\ucrtbase.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\ucrtbased.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\url_launcher_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\vccorlib140.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\vccorlib140d.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\vcruntime140.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\vcruntime140_1.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\vcruntime140_1d.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\vcruntime140d.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\vk_swiftshader.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\vulkan-1.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\zlib.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "D:\projects\aidea\build\windows\runner\Release\data\*"; DestDir: "{app}\data"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "D:\projects\aidea\build\windows\x64\runner\Release\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-core-console-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-core-console-l1-2-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-core-datetime-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-core-debug-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-core-errorhandling-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-core-fibers-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-core-file-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-core-file-l1-2-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-core-file-l2-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-core-handle-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-core-heap-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-core-interlocked-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-core-libraryloader-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-core-localization-l1-2-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-core-memory-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-core-namedpipe-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-core-processenvironment-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-core-processthreads-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-core-processthreads-l1-1-1.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-core-profile-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-core-rtlsupport-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-core-string-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-core-synch-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-core-synch-l1-2-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-core-sysinfo-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-core-timezone-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-core-util-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-crt-conio-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-crt-convert-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-crt-environment-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-crt-filesystem-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-crt-heap-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-crt-locale-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-crt-math-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-crt-multibyte-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-crt-private-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-crt-process-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-crt-runtime-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-crt-stdio-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-crt-string-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-crt-time-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-crt-utility-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-downlevel-kernel32-l2-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\api-ms-win-eventing-provider-l1-1-0.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\audioplayers_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\concrt140.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\d3dcompiler_47.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\file_saver_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\flutter_localization_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\flutter_tts_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\flutter_windows.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\libc++.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\libEGL.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\libGLESv2.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\libmpv-2.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\media_kit_libs_windows_video_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\media_kit_native_event_loop.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\media_kit_video_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\msvcp140.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\msvcp140_1.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\msvcp140_2.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\msvcp140_atomic_wait.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\msvcp140_codecvt_ids.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\record_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\screen_brightness_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\share_plus_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\sqlite3.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\ucrtbase.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\ucrtbased.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\url_launcher_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\vccorlib140.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\vccorlib140d.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\vcruntime140.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\vcruntime140_1.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\vcruntime140_1d.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\vcruntime140d.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\vk_swiftshader.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\vulkan-1.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\zlib.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "D:\projects\aidea\build\windows\x64\runner\Release\data\*"; DestDir: "{app}\data"; Flags: ignoreversion recursesubdirs createallsubdirs ; NOTE: Don't use "Flags: ignoreversion" on any shared system files [Icons] diff --git a/ios/Podfile b/ios/Podfile index 2c068c40..10f3c9b4 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -platform :ios, '12.0' +platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 092b297b..028ae98c 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -103,6 +103,42 @@ PODS: - sqflite (0.0.3): - Flutter - FlutterMacOS + - Stripe (23.22.1): + - StripeApplePay (= 23.22.1) + - StripeCore (= 23.22.1) + - StripePayments (= 23.22.1) + - StripePaymentsUI (= 23.22.1) + - StripeUICore (= 23.22.1) + - stripe_ios (0.0.1): + - Flutter + - Stripe (~> 23.22.0) + - StripeApplePay (~> 23.22.0) + - StripeFinancialConnections (~> 23.22.0) + - StripePayments (~> 23.22.0) + - StripePaymentSheet (~> 23.22.0) + - StripePaymentsUI (~> 23.22.0) + - StripeApplePay (23.22.1): + - StripeCore (= 23.22.1) + - StripeCore (23.22.1) + - StripeFinancialConnections (23.22.1): + - StripeCore (= 23.22.1) + - StripeUICore (= 23.22.1) + - StripePayments (23.22.1): + - StripeCore (= 23.22.1) + - StripePayments/Stripe3DS2 (= 23.22.1) + - StripePayments/Stripe3DS2 (23.22.1): + - StripeCore (= 23.22.1) + - StripePaymentSheet (23.22.1): + - StripeApplePay (= 23.22.1) + - StripeCore (= 23.22.1) + - StripePayments (= 23.22.1) + - StripePaymentsUI (= 23.22.1) + - StripePaymentsUI (23.22.1): + - StripeCore (= 23.22.1) + - StripePayments (= 23.22.1) + - StripeUICore (= 23.22.1) + - StripeUICore (23.22.1): + - StripeCore (= 23.22.1) - SwiftyGif (5.4.4) - tobias (0.0.1): - Flutter @@ -138,6 +174,7 @@ DEPENDENCIES: - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sign_in_with_apple (from `.symlinks/plugins/sign_in_with_apple/ios`) - sqflite (from `.symlinks/plugins/sqflite/darwin`) + - stripe_ios (from `.symlinks/plugins/stripe_ios/ios`) - tobias (from `.symlinks/plugins/tobias/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - volume_controller (from `.symlinks/plugins/volume_controller/ios`) @@ -151,6 +188,14 @@ SPEC REPOS: - Mantle - SDWebImage - SDWebImageWebPCoder + - Stripe + - StripeApplePay + - StripeCore + - StripeFinancialConnections + - StripePayments + - StripePaymentSheet + - StripePaymentsUI + - StripeUICore - SwiftyGif - WechatOpenSDK-XCFramework @@ -201,6 +246,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/sign_in_with_apple/ios" sqflite: :path: ".symlinks/plugins/sqflite/darwin" + stripe_ios: + :path: ".symlinks/plugins/stripe_ios/ios" tobias: :path: ".symlinks/plugins/tobias/ios" url_launcher_ios: @@ -240,6 +287,15 @@ SPEC CHECKSUMS: shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 sign_in_with_apple: f3bf75217ea4c2c8b91823f225d70230119b8440 sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec + Stripe: b65e9f748f8f51b51e015b0d72f5474dc0708788 + stripe_ios: 433385aa051f8965915d495744946ee5a5d657db + StripeApplePay: 4a2fef6cd4e1e9b2c0849919fc7b3a9c5c3684b1 + StripeCore: e1f6cd91d1487c0f6b5db81b42aec860420f75c9 + StripeFinancialConnections: d223a44613e6982cedd552c5950edc9b4901c90a + StripePayments: 76144e9e6b5fee859954238a175f859043562659 + StripePaymentSheet: a25d920bb3bb5e2580696476482dc7df9cb5e4e2 + StripePaymentsUI: 66088abec88754bbdd522ef227dfdbb2265a653e + StripeUICore: b193c7d35e9cd1b04bc9ed4a6fb8c548fcee83fa SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f tobias: 2aded9b83e3663b907360a800d8e3c13284f25c5 url_launcher_ios: 6116280ddcfe98ab8820085d8d76ae7449447586 @@ -247,6 +303,6 @@ SPEC CHECKSUMS: wakelock_plus: 8b09852c8876491e4b6d179e17dfe2a0b5f60d47 WechatOpenSDK-XCFramework: acdeeda129efbef9532bca8a10c24e1b4b8c7d69 -PODFILE CHECKSUM: 4e8f8b2be68aeea4c0d5beb6ff1e79fface1d048 +PODFILE CHECKSUM: cc1f88378b4bfcf93a6ce00d2c587857c6008d3b COCOAPODS: 1.14.3 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index d6be47d9..09204d39 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -366,7 +366,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -540,7 +540,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -589,7 +589,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/lib/bloc/admin_payment_bloc.dart b/lib/bloc/admin_payment_bloc.dart new file mode 100644 index 00000000..cacd5c33 --- /dev/null +++ b/lib/bloc/admin_payment_bloc.dart @@ -0,0 +1,21 @@ +import 'package:askaide/repo/api/admin/payment.dart'; +import 'package:askaide/repo/api/page.dart'; +import 'package:askaide/repo/api_server.dart'; +import 'package:bloc/bloc.dart'; +import 'package:meta/meta.dart'; + +part 'admin_payment_event.dart'; +part 'admin_payment_state.dart'; + +class AdminPaymentBloc extends Bloc { + AdminPaymentBloc() : super(AdminPaymentInitial()) { + on((event, emit) async { + final histories = await APIServer().adminPaymentHistories( + page: event.page, + perPage: event.perPage, + keyword: event.keyword, + ); + emit(AdminPaymentHistoriesLoaded(histories)); + }); + } +} diff --git a/lib/bloc/admin_payment_event.dart b/lib/bloc/admin_payment_event.dart new file mode 100644 index 00000000..f0c808dd --- /dev/null +++ b/lib/bloc/admin_payment_event.dart @@ -0,0 +1,16 @@ +part of 'admin_payment_bloc.dart'; + +@immutable +sealed class AdminPaymentEvent {} + +class AdminPaymentHistoriesLoadEvent extends AdminPaymentEvent { + final int page; + final int perPage; + final String? keyword; + + AdminPaymentHistoriesLoadEvent({ + this.page = 1, + this.perPage = 20, + this.keyword, + }); +} diff --git a/lib/bloc/admin_payment_state.dart b/lib/bloc/admin_payment_state.dart new file mode 100644 index 00000000..3f46a3d0 --- /dev/null +++ b/lib/bloc/admin_payment_state.dart @@ -0,0 +1,19 @@ +part of 'admin_payment_bloc.dart'; + +@immutable +sealed class AdminPaymentState {} + +final class AdminPaymentInitial extends AdminPaymentState {} + +class AdminPaymentOperationResult extends AdminPaymentState { + final bool success; + final String message; + + AdminPaymentOperationResult(this.success, this.message); +} + +class AdminPaymentHistoriesLoaded extends AdminPaymentState { + final PagedData histories; + + AdminPaymentHistoriesLoaded(this.histories); +} diff --git a/lib/bloc/channel_bloc.dart b/lib/bloc/channel_bloc.dart new file mode 100644 index 00000000..360dedc1 --- /dev/null +++ b/lib/bloc/channel_bloc.dart @@ -0,0 +1,56 @@ +import 'package:askaide/repo/api/admin/channels.dart'; +import 'package:askaide/repo/api_server.dart'; +import 'package:bloc/bloc.dart'; +import 'package:meta/meta.dart'; + +part 'channel_event.dart'; +part 'channel_state.dart'; + +class ChannelBloc extends Bloc { + ChannelBloc() : super(ChannelInitial()) { + /// 加载所有渠道 + on((event, emit) async { + final channels = await APIServer().adminChannels(); + emit(ChannelsLoaded(channels)); + }); + + /// 加载单个渠道 + on((event, emit) async { + final channel = await APIServer().adminChannel(id: event.channelId); + emit(ChannelLoaded(channel)); + }); + + /// 创建渠道 + on((event, emit) async { + try { + await APIServer().adminCreateChannel(event.req); + emit(ChannelOperationResult(true, '创建成功')); + } catch (e) { + emit(ChannelOperationResult(false, e.toString())); + } + }); + + /// 更新渠道 + on((event, emit) async { + try { + await APIServer().adminUpdateChannel( + id: event.channelId, + req: event.req, + ); + emit(ChannelOperationResult(true, '更新成功')); + } catch (e) { + emit(ChannelOperationResult(false, e.toString())); + } + }); + + /// 删除渠道 + on((event, emit) async { + try { + await APIServer().adminDeleteChannel(id: event.channelId); + emit(ChannelOperationResult(true, '删除成功')); + } catch (e) { + emit(ChannelOperationResult(false, e.toString())); + } + }); + } +} diff --git a/lib/bloc/channel_event.dart b/lib/bloc/channel_event.dart new file mode 100644 index 00000000..a9897db0 --- /dev/null +++ b/lib/bloc/channel_event.dart @@ -0,0 +1,31 @@ +part of 'channel_bloc.dart'; + +@immutable +sealed class ChannelEvent {} + +class ChannelsLoadEvent extends ChannelEvent {} + +class ChannelLoadEvent extends ChannelEvent { + final int channelId; + + ChannelLoadEvent(this.channelId); +} + +class ChannelCreateEvent extends ChannelEvent { + final AdminChannelAddReq req; + + ChannelCreateEvent(this.req); +} + +class ChannelUpdateEvent extends ChannelEvent { + final int channelId; + final AdminChannelUpdateReq req; + + ChannelUpdateEvent(this.channelId, this.req); +} + +class ChannelDeleteEvent extends ChannelEvent { + final int channelId; + + ChannelDeleteEvent(this.channelId); +} diff --git a/lib/bloc/channel_state.dart b/lib/bloc/channel_state.dart new file mode 100644 index 00000000..5d51476d --- /dev/null +++ b/lib/bloc/channel_state.dart @@ -0,0 +1,25 @@ +part of 'channel_bloc.dart'; + +@immutable +sealed class ChannelState {} + +final class ChannelInitial extends ChannelState {} + +class ChannelsLoaded extends ChannelState { + final List channels; + + ChannelsLoaded(this.channels); +} + +class ChannelLoaded extends ChannelState { + final AdminChannel channel; + + ChannelLoaded(this.channel); +} + +class ChannelOperationResult extends ChannelState { + final bool success; + final String message; + + ChannelOperationResult(this.success, this.message); +} diff --git a/lib/bloc/chat_event.dart b/lib/bloc/chat_event.dart index 648e5944..9dc61b22 100644 --- a/lib/bloc/chat_event.dart +++ b/lib/bloc/chat_event.dart @@ -13,8 +13,10 @@ class ChatMessageSendEvent extends ChatMessageEvent { final Message message; final int? index; final bool isResent; + final String? tempModel; - ChatMessageSendEvent(this.message, {this.index, this.isResent = false}); + ChatMessageSendEvent(this.message, + {this.index, this.isResent = false, this.tempModel}); } class ChatMessageGetRecentEvent extends ChatMessageEvent { @@ -32,3 +34,5 @@ class ChatMessageDeleteEvent extends ChatMessageEvent { final int? chatHistoryId; ChatMessageDeleteEvent(this.ids, {this.chatHistoryId}); } + +class ChatMessageStopEvent extends ChatMessageEvent {} diff --git a/lib/bloc/chat_message_bloc.dart b/lib/bloc/chat_message_bloc.dart index 23d61a54..0688157c 100644 --- a/lib/bloc/chat_message_bloc.dart +++ b/lib/bloc/chat_message_bloc.dart @@ -28,6 +28,8 @@ class ChatMessageBloc extends BlocExt { final int roomId; final int? chatHistoryId; + GracefulQueue? currentQueue; + ChatMessageBloc( this.roomId, { required this.chatMsgRepo, @@ -39,6 +41,7 @@ class ChatMessageBloc extends BlocExt { on(_clearAllEventHandler); on(_breakContextEventHandler); on(_deleteMessageEventHandler); + on(_stopEventHandler); } Future _deleteMessageEventHandler(event, emit) async { @@ -176,6 +179,13 @@ class ChatMessageBloc extends BlocExt { )); } + /// 停止输出事件处理 + Future _stopEventHandler(event, emit) async { + if (currentQueue != null) { + currentQueue!.finish(); + } + } + /// 消息发送事件处理 Future _messageSendEventHandler(event, emit) async { if (event.message is! Message) { @@ -257,10 +267,17 @@ class ChatMessageBloc extends BlocExt { } // 发送当前用户消息 - message.model = room.model; + message.model ??= room.model; message.userId = APIServer().localUserID(); message.status = 0; + // 模型切换 + String? tempModel = event.tempModel; + String? originalModel = message.model; + + // Logger.instance + // .d('发送消息, originalModel: $originalModel, tempModel: $tempModel'); + // 聊天历史记录中,所有发送状态为 pending 状态的消息,全部设置为失败 await chatMsgRepo.fixMessageStatus(roomId); @@ -279,7 +296,9 @@ class ChatMessageBloc extends BlocExt { ]); } } else { + message.model = tempModel ?? message.model; sentMessageId = await chatMsgRepo.sendMessage(roomId, message); + message.model = originalModel; } // 更新 Room 最后活跃时间 @@ -301,7 +320,7 @@ class ChatMessageBloc extends BlocExt { '', ts: DateTime.now(), type: MessageType.text, - model: room.model, + model: tempModel ?? originalModel, roomId: roomId, userId: APIServer().localUserID(), refId: sentMessageId, @@ -320,6 +339,7 @@ class ChatMessageBloc extends BlocExt { // 等待监听机器人应答消息 final queue = GracefulQueue(); + currentQueue = queue; try { RequestFailedException? error; var listener = queue.listen(const Duration(milliseconds: 10), (items) { @@ -378,6 +398,7 @@ class ChatMessageBloc extends BlocExt { await ModelResolver.instance .request( room: room, + tempModel: tempModel, contextMessages: messages.sublist(0, messages.length - 1), onMessage: queue.add, maxTokens: room.maxTokens, @@ -459,6 +480,7 @@ class ChatMessageBloc extends BlocExt { roomId: roomId, userId: APIServer().localUserID(), chatHistoryId: localChatHistoryId, + model: tempModel ?? originalModel, ), ); } else { @@ -479,6 +501,7 @@ class ChatMessageBloc extends BlocExt { queue.finish(); } finally { queue.dispose(); + currentQueue = null; } emit(ChatMessageUpdated(waitMessage)); diff --git a/lib/bloc/model_bloc.dart b/lib/bloc/model_bloc.dart new file mode 100644 index 00000000..e0cf4bd4 --- /dev/null +++ b/lib/bloc/model_bloc.dart @@ -0,0 +1,56 @@ +import 'package:askaide/repo/api/admin/models.dart'; +import 'package:askaide/repo/api_server.dart'; +import 'package:bloc/bloc.dart'; +import 'package:meta/meta.dart'; + +part 'model_event.dart'; +part 'model_state.dart'; + +class ModelBloc extends Bloc { + ModelBloc() : super(ModelInitial()) { + /// 加载所有模型 + on((event, emit) async { + final channels = await APIServer().adminModels(); + emit(ModelsLoaded(channels)); + }); + + /// 加载单个模型 + on((event, emit) async { + final channel = await APIServer().adminModel(modelId: event.modelId); + emit(ModelLoaded(channel)); + }); + + /// 创建模型 + on((event, emit) async { + try { + await APIServer().adminCreateModel(event.req); + emit(ModelOperationResult(true, '创建成功')); + } catch (e) { + emit(ModelOperationResult(false, e.toString())); + } + }); + + /// 更新模型 + on((event, emit) async { + try { + await APIServer().adminUpdateModel( + modelId: event.modelId, + req: event.req, + ); + emit(ModelOperationResult(true, '更新成功')); + } catch (e) { + emit(ModelOperationResult(false, e.toString())); + } + }); + + /// 删除模型 + on((event, emit) async { + try { + await APIServer().adminDeleteModel(modelId: event.modelId); + emit(ModelOperationResult(true, '删除成功')); + } catch (e) { + emit(ModelOperationResult(false, e.toString())); + } + }); + } +} diff --git a/lib/bloc/model_event.dart b/lib/bloc/model_event.dart new file mode 100644 index 00000000..b15f9134 --- /dev/null +++ b/lib/bloc/model_event.dart @@ -0,0 +1,31 @@ +part of 'model_bloc.dart'; + +@immutable +sealed class ModelEvent {} + +class ModelsLoadEvent extends ModelEvent {} + +class ModelLoadEvent extends ModelEvent { + final String modelId; + + ModelLoadEvent(this.modelId); +} + +class ModelCreateEvent extends ModelEvent { + final AdminModelAddReq req; + + ModelCreateEvent(this.req); +} + +class ModelUpdateEvent extends ModelEvent { + final String modelId; + final AdminModelUpdateReq req; + + ModelUpdateEvent(this.modelId, this.req); +} + +class ModelDeleteEvent extends ModelEvent { + final String modelId; + + ModelDeleteEvent(this.modelId); +} diff --git a/lib/bloc/model_state.dart b/lib/bloc/model_state.dart new file mode 100644 index 00000000..150c3113 --- /dev/null +++ b/lib/bloc/model_state.dart @@ -0,0 +1,25 @@ +part of 'model_bloc.dart'; + +@immutable +sealed class ModelState {} + +final class ModelInitial extends ModelState {} + +class ModelsLoaded extends ModelState { + final List models; + + ModelsLoaded(this.models); +} + +class ModelLoaded extends ModelState { + final AdminModel model; + + ModelLoaded(this.model); +} + +class ModelOperationResult extends ModelState { + final bool success; + final String message; + + ModelOperationResult(this.success, this.message); +} diff --git a/lib/bloc/payment_bloc.dart b/lib/bloc/payment_bloc.dart index a8c96e5d..b33529ae 100644 --- a/lib/bloc/payment_bloc.dart +++ b/lib/bloc/payment_bloc.dart @@ -89,7 +89,9 @@ class PaymentBloc extends Bloc { id: e.id, title: e.name, description: '', - price: e.retailPriceText, + price: products.preferUSD + ? e.retailPriceUSDText + : e.retailPriceText, rawPrice: e.retailPrice.toDouble(), currencyCode: '', ), @@ -98,6 +100,7 @@ class PaymentBloc extends Bloc { note: products.note, localProducts: products.consume, loading: false, + preferUSD: products.preferUSD, ), ); } diff --git a/lib/bloc/payment_state.dart b/lib/bloc/payment_state.dart index 6d250aa1..52aa0d66 100644 --- a/lib/bloc/payment_state.dart +++ b/lib/bloc/payment_state.dart @@ -11,6 +11,7 @@ class PaymentAppleProductsLoaded extends PaymentState { final Object? error; final bool loading; final String? note; + final bool preferUSD; PaymentAppleProductsLoaded( this.products, { @@ -18,5 +19,6 @@ class PaymentAppleProductsLoaded extends PaymentState { required this.localProducts, this.error, required this.loading, + this.preferUSD = false, }); } diff --git a/lib/bloc/room_bloc.dart b/lib/bloc/room_bloc.dart index 8417ed20..c977baa9 100644 --- a/lib/bloc/room_bloc.dart +++ b/lib/bloc/room_bloc.dart @@ -136,15 +136,18 @@ class RoomBloc extends BlocExt { emit(RoomsLoading()); try { + int id = 0; if (Ability().isUserLogon()) { - await APIServer().createRoom( + final segs = event.model.split(':'); + + id = await APIServer().createRoom( name: event.name, vendor: event.model.startsWith('v2@') ? '' - : event.model.split(':').first, + : (segs.length > 1 ? segs.first : ''), model: event.model.startsWith('v2@') ? event.model - : event.model.split(':').last, + : (segs.length > 1 ? segs.last : event.model), systemPrompt: event.prompt, avatarId: event.avatarId, avatarUrl: event.avatarUrl, @@ -152,7 +155,7 @@ class RoomBloc extends BlocExt { initMessage: event.initMessage, ); } else { - await chatMsgRepo.createRoom( + final room = await chatMsgRepo.createRoom( name: event.name, category: 'chat', model: event.model, @@ -160,8 +163,11 @@ class RoomBloc extends BlocExt { userId: APIServer().localUserID(), maxContext: event.maxContext, ); + + id = room.id!; } + emit(RoomOperationResult(true, redirect: '/room/$id/chat')); emit(await createRoomsLoadedState(cache: false)); } catch (e) { emit(RoomsLoaded(const [], error: e.toString())); diff --git a/lib/bloc/room_state.dart b/lib/bloc/room_state.dart index b822eb75..d695d64a 100644 --- a/lib/bloc/room_state.dart +++ b/lib/bloc/room_state.dart @@ -51,3 +51,11 @@ class GroupRoomUpdateResultState extends RoomState { GroupRoomUpdateResultState(this.success, {this.error}); } + +class RoomOperationResult extends RoomState { + final bool success; + final Object? error; + final String? redirect; + + RoomOperationResult(this.success, {this.error, this.redirect}); +} diff --git a/lib/bloc/user_bloc.dart b/lib/bloc/user_bloc.dart new file mode 100644 index 00000000..c46cc9c4 --- /dev/null +++ b/lib/bloc/user_bloc.dart @@ -0,0 +1,35 @@ +import 'package:askaide/repo/api/admin/users.dart'; +import 'package:askaide/repo/api/page.dart'; +import 'package:askaide/repo/api/quota.dart'; +import 'package:askaide/repo/api_server.dart'; +import 'package:bloc/bloc.dart'; +import 'package:meta/meta.dart'; + +part 'user_event.dart'; +part 'user_state.dart'; + +class UserBloc extends Bloc { + UserBloc() : super(UserInitial()) { + // 加载指定用户信息 + on((event, emit) async { + final user = await APIServer().adminUser(id: event.userId); + emit(UserLoaded(user)); + }); + + // 加载用户列表 + on((event, emit) async { + final users = await APIServer().adminUsers( + page: event.page, + perPage: event.perPage, + keyword: event.keyword, + ); + emit(UsersLoaded(users)); + }); + + // 加载用户配额 + on((event, emit) async { + final quota = await APIServer().adminUserQuota(userId: event.userId); + emit(UserQuotaLoaded(quota)); + }); + } +} diff --git a/lib/bloc/user_event.dart b/lib/bloc/user_event.dart new file mode 100644 index 00000000..e690fda8 --- /dev/null +++ b/lib/bloc/user_event.dart @@ -0,0 +1,28 @@ +part of 'user_bloc.dart'; + +@immutable +sealed class UserEvent {} + +class UserLoadEvent extends UserEvent { + final int userId; + + UserLoadEvent(this.userId); +} + +class UserListLoadEvent extends UserEvent { + final int page; + final int perPage; + final String? keyword; + + UserListLoadEvent({ + this.page = 1, + this.perPage = 20, + this.keyword, + }); +} + +class UserQuotaLoadEvent extends UserEvent { + final int userId; + + UserQuotaLoadEvent(this.userId); +} diff --git a/lib/bloc/user_state.dart b/lib/bloc/user_state.dart new file mode 100644 index 00000000..5fd4c3d6 --- /dev/null +++ b/lib/bloc/user_state.dart @@ -0,0 +1,31 @@ +part of 'user_bloc.dart'; + +@immutable +sealed class UserState {} + +final class UserInitial extends UserState {} + +class UserLoaded extends UserState { + final AdminUser user; + + UserLoaded(this.user); +} + +class UserOperationResult extends UserState { + final bool success; + final String? message; + + UserOperationResult(this.success, {this.message}); +} + +class UsersLoaded extends UserState { + final PagedData users; + + UsersLoaded(this.users); +} + +class UserQuotaLoaded extends UserState { + final QuotaResp quota; + + UserQuotaLoaded(this.quota); +} diff --git a/lib/helper/ability.dart b/lib/helper/ability.dart index 84d39ba7..a3c2bf7e 100644 --- a/lib/helper/ability.dart +++ b/lib/helper/ability.dart @@ -92,6 +92,11 @@ class Ability { return capabilities.otherPayEnabled; } + /// 是否支持 Stripe 支付 + bool get enableStripe { + return capabilities.stripeEnabled; + } + /// 是否支持 ApplePay bool get enableApplePay { return capabilities.applePayEnabled; @@ -113,7 +118,7 @@ class Ability { /// 是否支持支付功能 bool get enablePayment { - if (!enableApplePay && !enableOtherPay) { + if (!enableApplePay && !enableOtherPay && !enableStripe) { return false; } @@ -121,7 +126,7 @@ class Ability { return true; } - return enableOtherPay; + return true; } /// 是否用户已经登陆 @@ -171,4 +176,9 @@ class Ability { bool supportQiniuUploader() { return setting.stringDefault(settingAPIServerToken, '') != ''; } + + /// 获取当前主题模式 + String get themeMode { + return setting.stringDefault(settingThemeMode, 'system'); + } } diff --git a/lib/helper/constant.dart b/lib/helper/constant.dart index 905fe920..5c5ad971 100644 --- a/lib/helper/constant.dart +++ b/lib/helper/constant.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; // 客户端应用版本号 -const clientVersion = '1.0.13'; +const clientVersion = '1.0.14'; // 本地数据库版本号 const databaseVersion = 26; @@ -92,3 +92,7 @@ const universalLink = 'https://ai.aicode.cc/wechat-login/'; const qiniuImageTypeAvatar = 'avatar'; const qiniuImageTypeThumb = 'thumb'; const qiniuImageTypeThumbMedium = 'thumb_500'; + +// 缓存相关的 Keys +// 最后一次使用的模型 +const cacheKeyLastModel = 'last-model'; diff --git a/lib/helper/model.dart b/lib/helper/model.dart index 5b84ac53..034fef2f 100644 --- a/lib/helper/model.dart +++ b/lib/helper/model.dart @@ -64,8 +64,9 @@ class ModelAggregate { /// 根据模型唯一id查找模型 static Future model(String uid) async { - final supportModels = await models(); + uid = uid.split(':').last; + final supportModels = await models(); return supportModels.firstWhere( (element) => element.uid() == uid || element.id == uid, orElse: () => mm.Model(defaultChatModel, defaultChatModel, 'openai', diff --git a/lib/helper/model_resolver.dart b/lib/helper/model_resolver.dart index 9e1b35e7..c96345a1 100644 --- a/lib/helper/model_resolver.dart +++ b/lib/helper/model_resolver.dart @@ -47,6 +47,7 @@ class ModelResolver { required List contextMessages, required Function(ChatStreamRespData value) onMessage, int? maxTokens, + String? tempModel, }) async { if (room.modelCategory() == modelTypeDeepAI) { return await _deepAIModel( @@ -72,6 +73,7 @@ class ModelResolver { contextMessages: contextMessages, onMessage: onMessage, maxTokens: maxTokens, + tempModel: tempModel, ); } } @@ -151,6 +153,7 @@ class ModelResolver { required List contextMessages, required Function(ChatStreamRespData value) onMessage, int? maxTokens, + String? tempModel, }) async { // 图像模式 if (OpenAIRepository.isImageModel(room.modelName())) { @@ -167,6 +170,7 @@ class ModelResolver { _buildRequestContext(room, contextMessages), onMessage, model: room.modelName(), + tempModel: tempModel, maxTokens: maxTokens, roomId: room.isLocalRoom ? null : room.id, ); diff --git a/lib/helper/queue.dart b/lib/helper/queue.dart index 2a262aa9..71f6c93d 100644 --- a/lib/helper/queue.dart +++ b/lib/helper/queue.dart @@ -1,6 +1,11 @@ import 'dart:async'; import 'dart:collection'; +class QueueFinishedException implements Exception { + final String message; + QueueFinishedException(this.message); +} + /// 该队列以一定的时间间隔将队列中的元素传递给回调函数,实现平滑的队列处理 class GracefulQueue { final Queue _queue = Queue(); @@ -8,6 +13,10 @@ class GracefulQueue { Timer? _timer; void add(T item) { + if (finished) { + throw QueueFinishedException('Queue is finished'); + } + _queue.add(item); } diff --git a/lib/main.dart b/lib/main.dart index 5fb08e00..4a3d9c23 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,20 @@ +import 'package:askaide/bloc/admin_payment_bloc.dart'; +import 'package:askaide/bloc/channel_bloc.dart'; +import 'package:askaide/bloc/model_bloc.dart'; +import 'package:askaide/bloc/user_bloc.dart'; import 'package:askaide/helper/path.dart'; +import 'package:askaide/page/admin/channels.dart'; +import 'package:askaide/page/admin/channels_add.dart'; +import 'package:askaide/page/admin/channels_edit.dart'; +import 'package:askaide/page/admin/dashboard.dart'; +import 'package:askaide/page/admin/models.dart'; +import 'package:askaide/page/admin/models_add.dart'; +import 'package:askaide/page/admin/models_edit.dart'; +import 'package:askaide/page/admin/payments.dart'; +import 'package:askaide/page/admin/user.dart'; +import 'package:askaide/page/admin/users.dart'; +import 'package:askaide/page/balance/web_payment_proxy.dart'; +import 'package:askaide/page/balance/web_payment_result.dart'; import 'package:askaide/page/creative_island/draw/artistic_wordart.dart'; import 'package:path/path.dart'; @@ -1029,6 +1045,209 @@ class MyApp extends StatefulWidget { ); }, ), + GoRoute( + name: 'web-payment-result', + path: '/payment/result', + parentNavigatorKey: _shellNavigatorKey, + pageBuilder: (context, state) { + return transitionResolver(WebPaymentResult( + paymentId: state.queryParameters['payment_id']!, + action: state.queryParameters['action'], + )); + }, + ), + GoRoute( + name: 'web-payment-proxy', + path: '/payment/proxy', + parentNavigatorKey: _shellNavigatorKey, + pageBuilder: (context, state) { + return transitionResolver(WebPaymentProxy( + setting: settingRepo, + paymentId: state.queryParameters['id']!, + paymentIntent: state.queryParameters['intent']!, + price: state.queryParameters['price']!, + publishableKey: state.queryParameters['key']!, + finishAction: + state.queryParameters['finish_action'] ?? 'close', + )); + }, + ), + + /// 管理员接口 + GoRoute( + name: 'admin-dashboard', + path: '/admin/dashboard', + parentNavigatorKey: _shellNavigatorKey, + pageBuilder: (context, state) { + return transitionResolver( + AdminDashboardPage(setting: settingRepo), + ); + }, + ), + GoRoute( + name: 'admin-models', + path: '/admin/models', + parentNavigatorKey: _shellNavigatorKey, + pageBuilder: (context, state) { + return transitionResolver( + MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => ModelBloc(), + ), + ], + child: AdminModelsPage(setting: settingRepo), + ), + ); + }, + ), + GoRoute( + name: 'admin-models-create', + path: '/admin/models/create', + parentNavigatorKey: _shellNavigatorKey, + pageBuilder: (context, state) { + return transitionResolver( + MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => ModelBloc(), + ), + ], + child: AdminModelCreatePage(setting: settingRepo), + ), + ); + }, + ), + GoRoute( + name: 'admin-models-edit', + path: '/admin/models/edit/:id', + parentNavigatorKey: _shellNavigatorKey, + pageBuilder: (context, state) { + return transitionResolver( + MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => ModelBloc(), + ), + ], + child: AdminModelEditPage( + setting: settingRepo, + modelId: state.pathParameters['id']!, + ), + ), + ); + }, + ), + GoRoute( + name: 'admin-channels', + path: '/admin/channels', + parentNavigatorKey: _shellNavigatorKey, + pageBuilder: (context, state) { + return transitionResolver( + MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => ChannelBloc(), + ), + ], + child: ChannelsPage(setting: settingRepo), + ), + ); + }, + ), + GoRoute( + name: 'admin-channels-create', + path: '/admin/channels/create', + parentNavigatorKey: _shellNavigatorKey, + pageBuilder: (context, state) { + return transitionResolver( + MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => ChannelBloc(), + ), + ], + child: ChannelAddPage(setting: settingRepo), + ), + ); + }, + ), + GoRoute( + name: 'admin-channels-edit', + path: '/admin/channels/edit/:id', + parentNavigatorKey: _shellNavigatorKey, + pageBuilder: (context, state) { + final channelId = int.parse(state.pathParameters['id']!); + + return transitionResolver( + MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => ChannelBloc(), + ), + ], + child: ChannelEditPage( + setting: settingRepo, + channelId: channelId, + ), + ), + ); + }, + ), + GoRoute( + name: 'admin-users', + path: '/admin/users', + parentNavigatorKey: _shellNavigatorKey, + pageBuilder: (context, state) { + return transitionResolver( + MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => UserBloc(), + ), + ], + child: AdminUsersPage(setting: settingRepo), + ), + ); + }, + ), + GoRoute( + name: 'admin-users-detail', + path: '/admin/users/:id', + parentNavigatorKey: _shellNavigatorKey, + pageBuilder: (context, state) { + final userId = int.parse(state.pathParameters['id']!); + + return transitionResolver( + MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => UserBloc(), + ), + ], + child: AdminUserPage(setting: settingRepo, userId: userId), + ), + ); + }, + ), + + GoRoute( + name: 'admin-payment-histories', + path: '/admin/payment/histories', + parentNavigatorKey: _shellNavigatorKey, + pageBuilder: (context, state) { + return transitionResolver( + MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => AdminPaymentBloc(), + ), + ], + child: PaymentHistoriesPage(setting: settingRepo), + ), + ); + }, + ), ], ) ], diff --git a/lib/page/admin/channels.dart b/lib/page/admin/channels.dart new file mode 100644 index 00000000..4e2b254b --- /dev/null +++ b/lib/page/admin/channels.dart @@ -0,0 +1,237 @@ +import 'package:askaide/bloc/channel_bloc.dart'; +import 'package:askaide/lang/lang.dart'; +import 'package:askaide/page/component/background_container.dart'; +import 'package:askaide/page/component/dialog.dart'; +import 'package:askaide/page/component/theme/custom_size.dart'; +import 'package:askaide/page/component/theme/custom_theme.dart'; +import 'package:askaide/repo/api/admin/channels.dart'; +import 'package:askaide/repo/api_server.dart'; +import 'package:askaide/repo/settings_repo.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_initicon/flutter_initicon.dart'; +import 'package:flutter_localization/flutter_localization.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:go_router/go_router.dart'; + +class ChannelsPage extends StatefulWidget { + final SettingRepository setting; + const ChannelsPage({ + super.key, + required this.setting, + }); + + @override + State createState() => _ChannelsPageState(); +} + +class _ChannelsPageState extends State { + // 渠道类型 + List channelTypes = []; + + @override + void initState() { + context.read().add(ChannelsLoadEvent()); + + // 加载渠道类型 + APIServer().adminChannelTypes().then((value) { + if (context.mounted) { + setState(() { + channelTypes = value; + }); + } + }); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + final customColors = Theme.of(context).extension()!; + + return Scaffold( + appBar: AppBar( + toolbarHeight: CustomSize.toolbarHeight, + title: const Text( + '渠道管理', + style: TextStyle(fontSize: CustomSize.appBarTitleSize), + ), + centerTitle: true, + actions: [ + IconButton( + icon: const Icon(Icons.add), + onPressed: () { + context.push('/admin/channels/create').then((value) { + context.read().add(ChannelsLoadEvent()); + }); + }, + ), + ], + ), + backgroundColor: customColors.chatInputPanelBackground, + body: BackgroundContainer( + setting: widget.setting, + enabled: false, + child: RefreshIndicator( + color: customColors.linkColor, + onRefresh: () async { + context.read().add(ChannelsLoadEvent()); + }, + displacement: 20, + child: BlocConsumer( + listenWhen: (previous, current) => + current is ChannelOperationResult, + listener: (context, state) { + if (state is ChannelOperationResult) { + if (state.success) { + showSuccessMessage(state.message); + context.read().add(ChannelsLoadEvent()); + } else { + showErrorMessage(state.message); + } + } + }, + buildWhen: (previous, current) => current is ChannelsLoaded, + builder: (context, state) { + if (state is ChannelsLoaded) { + return SafeArea( + top: false, + child: ListView.builder( + padding: const EdgeInsets.all(5), + itemCount: state.channels.length, + itemBuilder: (context, index) { + final channel = state.channels[index]; + + return buildChannelItem(context, customColors, channel); + }, + ), + ); + } + + return const Center( + child: CircularProgressIndicator(), + ); + }, + ), + ), + ), + ); + } + + Widget buildChannelItem( + BuildContext context, + CustomColors customColors, + AdminChannel channel, + ) { + return Container( + margin: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(customColors.borderRadius ?? 8), + ), + child: Slidable( + endActionPane: ActionPane( + motion: const ScrollMotion(), + children: [ + const SizedBox(width: 10), + SlidableAction( + label: AppLocale.delete.getString(context), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(customColors.borderRadius ?? 8), + bottomLeft: Radius.circular(customColors.borderRadius ?? 8), + topRight: Radius.circular(customColors.borderRadius ?? 8), + bottomRight: Radius.circular(customColors.borderRadius ?? 8), + ), + backgroundColor: Colors.red, + icon: Icons.delete, + onPressed: (_) { + openConfirmDialog( + context, + AppLocale.confirmToDeleteRoom.getString(context), + () => context + .read() + .add(ChannelDeleteEvent(channel.id!)), + danger: true, + ); + }, + ), + ], + ), + child: Material( + borderRadius: + BorderRadius.all(Radius.circular(customColors.borderRadius ?? 8)), + color: customColors.columnBlockBackgroundColor, + child: InkWell( + borderRadius: BorderRadius.all( + Radius.circular(customColors.borderRadius ?? 8)), + onTap: () { + context.push('/admin/channels/edit/${channel.id}').then((value) { + context.read().add(ChannelsLoadEvent()); + }); + }, + child: Stack( + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // 渠道头像 + Initicon( + text: channel.name.split('、').join(' '), + size: 50, + backgroundColor: Colors.grey.withAlpha(100), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(8), + bottomLeft: Radius.circular(8), + ), + ), + // 渠道名称 + Expanded( + child: Container( + padding: const EdgeInsets.all(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + channel.name, + style: const TextStyle( + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ], + ), + Positioned( + right: 0, + top: 0, + child: Container( + padding: const EdgeInsets.all(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + channelTypes + .firstWhere((e) => e.name == channel.type) + .text, + style: TextStyle( + fontSize: 10, + overflow: TextOverflow.ellipsis, + color: customColors.weakTextColor, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/page/admin/channels_add.dart b/lib/page/admin/channels_add.dart new file mode 100644 index 00000000..0e39d1aa --- /dev/null +++ b/lib/page/admin/channels_add.dart @@ -0,0 +1,321 @@ +import 'package:askaide/bloc/channel_bloc.dart'; +import 'package:askaide/lang/lang.dart'; +import 'package:askaide/page/component/background_container.dart'; +import 'package:askaide/page/component/column_block.dart'; +import 'package:askaide/page/component/dialog.dart'; +import 'package:askaide/page/component/enhanced_button.dart'; +import 'package:askaide/page/component/enhanced_input.dart'; +import 'package:askaide/page/component/enhanced_textfield.dart'; +import 'package:askaide/page/component/item_selector_search.dart'; +import 'package:askaide/page/component/theme/custom_size.dart'; +import 'package:askaide/page/component/theme/custom_theme.dart'; +import 'package:askaide/repo/api/admin/channels.dart'; +import 'package:askaide/repo/api_server.dart'; +import 'package:askaide/repo/settings_repo.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localization/flutter_localization.dart'; +import 'package:go_router/go_router.dart'; + +class ChannelAddPage extends StatefulWidget { + final SettingRepository setting; + const ChannelAddPage({ + super.key, + required this.setting, + }); + + @override + State createState() => _ChannelAddPageState(); +} + +class _ChannelAddPageState extends State { + // 渠道类型 + List channelTypes = []; + + final TextEditingController nameController = TextEditingController(); + final TextEditingController typeController = TextEditingController(); + final TextEditingController serverController = TextEditingController(); + final TextEditingController secretController = TextEditingController(); + + /// 当前选中的渠道类型 + String? selectedChannelType; + + /// 用于控制是否显示高级选项 + bool showAdvancedOptions = false; + + /// 是否使用代理 + bool usingProxy = false; + + /// 是否是 Azure API + bool openaiAzure = false; + + /// OpenAI Azure API 版本 + final TextEditingController azureAPIVersionController = + TextEditingController(); + + @override + void dispose() { + nameController.dispose(); + typeController.dispose(); + serverController.dispose(); + secretController.dispose(); + azureAPIVersionController.dispose(); + + super.dispose(); + } + + @override + void initState() { + // 加载渠道类型 + APIServer().adminChannelTypes().then((value) { + if (context.mounted) { + setState(() { + channelTypes = value.where((e) => e.dynamicType).toList(); + }); + } + }); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + final customColors = Theme.of(context).extension()!; + + return Scaffold( + appBar: AppBar( + toolbarHeight: CustomSize.toolbarHeight, + title: const Text( + '新增渠道', + style: TextStyle(fontSize: CustomSize.appBarTitleSize), + ), + centerTitle: true, + ), + backgroundColor: customColors.chatInputPanelBackground, + body: BackgroundContainer( + setting: widget.setting, + enabled: false, + child: BlocListener( + listenWhen: (previous, current) => current is ChannelOperationResult, + listener: (context, state) { + if (state is ChannelOperationResult) { + if (state.success) { + showSuccessMessage(state.message); + context.pop(); + } else { + showErrorMessage(state.message); + } + } + }, + child: Container( + padding: const EdgeInsets.all(10), + child: Column( + children: [ + ColumnBlock( + children: [ + EnhancedTextField( + labelText: '渠道名称', + customColors: customColors, + controller: nameController, + textAlignVertical: TextAlignVertical.top, + hintText: '请输入渠道名称', + maxLength: 100, + showCounter: false, + ), + EnhancedInput( + title: Text( + '类型', + style: TextStyle( + color: customColors.textfieldLabelColor, + fontSize: 16, + ), + ), + value: Text( + buildSelectedChannelTypeText(), + style: TextStyle( + color: customColors.textfieldValueColor, + fontSize: 16, + ), + ), + onPressed: () { + openListSelectDialog( + context, + channelTypes + .map((e) => SelectorItem(Text(e.text), e.name)) + .toList(), + (value) { + setState(() { + selectedChannelType = value.value; + }); + return true; + }, + heightFactor: 0.5, + value: selectedChannelType, + ); + }, + ), + EnhancedTextField( + labelText: '服务器', + customColors: customColors, + controller: serverController, + textAlignVertical: TextAlignVertical.top, + hintText: 'https://api.openai.com/v1', + maxLength: 255, + showCounter: false, + ), + EnhancedTextField( + labelText: '鉴权密钥', + customColors: customColors, + controller: secretController, + textAlignVertical: TextAlignVertical.top, + hintText: '请输入鉴权密钥', + maxLength: 255, + obscureText: true, + showCounter: false, + ), + ], + ), + // 高级选项 + if (showAdvancedOptions) + ColumnBlock( + innerPanding: 5, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + '使用代理', + style: TextStyle(fontSize: 16), + ), + CupertinoSwitch( + activeColor: customColors.linkColor, + value: usingProxy, + onChanged: (value) { + setState(() { + usingProxy = value; + }); + }, + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Azure 模式', + style: TextStyle(fontSize: 16), + ), + CupertinoSwitch( + activeColor: customColors.linkColor, + value: openaiAzure, + onChanged: (value) { + setState(() { + openaiAzure = value; + }); + }, + ), + ], + ), + EnhancedTextField( + labelText: 'API 版本', + customColors: customColors, + controller: azureAPIVersionController, + textAlignVertical: TextAlignVertical.top, + hintText: '2023-05-15', + maxLength: 30, + showCounter: false, + ), + ], + ), + const SizedBox(height: 15), + Row( + children: [ + EnhancedButton( + title: showAdvancedOptions + ? AppLocale.simpleMode.getString(context) + : AppLocale.professionalMode.getString(context), + width: 120, + backgroundColor: Colors.transparent, + color: customColors.weakLinkColor, + fontSize: 15, + icon: Icon( + showAdvancedOptions + ? Icons.unfold_less + : Icons.unfold_more, + color: customColors.weakLinkColor, + size: 15, + ), + onPressed: () { + setState(() { + showAdvancedOptions = !showAdvancedOptions; + }); + }, + ), + const SizedBox(width: 10), + Expanded( + flex: 1, + child: EnhancedButton( + title: AppLocale.save.getString(context), + onPressed: onSubmit, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } + + /// 提交 + void onSubmit() { + if (nameController.text.isEmpty) { + showErrorMessage('请输入渠道名称'); + return; + } + + if (selectedChannelType == null) { + showErrorMessage('请选择渠道类型'); + return; + } + + if (serverController.text.isEmpty) { + showErrorMessage('请输入服务器地址'); + return; + } + + if (!serverController.text.startsWith('http://') && + !serverController.text.startsWith('https://')) { + showErrorMessage('服务器地址格式不正确'); + return; + } + + final req = AdminChannelAddReq( + name: nameController.text, + type: selectedChannelType!, + server: serverController.text, + secret: secretController.text, + meta: AdminChannelMeta( + usingProxy: usingProxy, + openaiAzure: openaiAzure, + openaiAzureAPIVersion: azureAPIVersionController.text, + ), + ); + + context.read().add(ChannelCreateEvent(req)); + } + + /// 生成选中的渠道类型文本 + String buildSelectedChannelTypeText() { + if (selectedChannelType == null) { + return '请选择'; + } + + return channelTypes + .firstWhere((element) => element.name == selectedChannelType) + .text; + } +} diff --git a/lib/page/admin/channels_edit.dart b/lib/page/admin/channels_edit.dart new file mode 100644 index 00000000..bb0ca01d --- /dev/null +++ b/lib/page/admin/channels_edit.dart @@ -0,0 +1,353 @@ +import 'package:askaide/bloc/channel_bloc.dart'; +import 'package:askaide/lang/lang.dart'; +import 'package:askaide/page/component/background_container.dart'; +import 'package:askaide/page/component/column_block.dart'; +import 'package:askaide/page/component/dialog.dart'; +import 'package:askaide/page/component/enhanced_button.dart'; +import 'package:askaide/page/component/enhanced_input.dart'; +import 'package:askaide/page/component/enhanced_textfield.dart'; +import 'package:askaide/page/component/item_selector_search.dart'; +import 'package:askaide/page/component/theme/custom_size.dart'; +import 'package:askaide/page/component/theme/custom_theme.dart'; +import 'package:askaide/repo/api/admin/channels.dart'; +import 'package:askaide/repo/api_server.dart'; +import 'package:askaide/repo/settings_repo.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localization/flutter_localization.dart'; + +class ChannelEditPage extends StatefulWidget { + final SettingRepository setting; + final int channelId; + const ChannelEditPage({ + super.key, + required this.setting, + required this.channelId, + }); + + @override + State createState() => _ChannelEditPageState(); +} + +class _ChannelEditPageState extends State { + // 渠道类型 + List channelTypes = []; + + final TextEditingController nameController = TextEditingController(); + final TextEditingController typeController = TextEditingController(); + final TextEditingController serverController = TextEditingController(); + final TextEditingController secretController = TextEditingController(); + + /// 当前选中的渠道类型 + String? selectedChannelType; + + /// 用于控制是否显示高级选项 + bool showAdvancedOptions = false; + + /// 是否使用代理 + bool usingProxy = false; + + /// 是否是 Azure API + bool openaiAzure = false; + + /// OpenAI Azure API 版本 + final TextEditingController azureAPIVersionController = + TextEditingController(); + + /// 是否锁定编辑 + bool editLocked = true; + + @override + void dispose() { + nameController.dispose(); + typeController.dispose(); + serverController.dispose(); + secretController.dispose(); + azureAPIVersionController.dispose(); + + super.dispose(); + } + + @override + void initState() { + // 加载渠道类型 + APIServer().adminChannelTypes().then((value) { + if (context.mounted) { + setState(() { + channelTypes = value.where((e) => e.dynamicType).toList(); + }); + } + }); + + // 加载渠道信息 + context.read().add(ChannelLoadEvent(widget.channelId)); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + final customColors = Theme.of(context).extension()!; + + return Scaffold( + appBar: AppBar( + toolbarHeight: CustomSize.toolbarHeight, + title: const Text( + '编辑渠道', + style: TextStyle(fontSize: CustomSize.appBarTitleSize), + ), + centerTitle: true, + ), + backgroundColor: customColors.chatInputPanelBackground, + body: BackgroundContainer( + setting: widget.setting, + enabled: false, + child: BlocListener( + listenWhen: (previous, current) => + current is ChannelOperationResult || current is ChannelLoaded, + listener: (context, state) { + if (state is ChannelOperationResult) { + if (state.success) { + showSuccessMessage(state.message); + context + .read() + .add(ChannelLoadEvent(widget.channelId)); + } else { + showErrorMessage(state.message); + } + } else if (state is ChannelLoaded) { + nameController.text = state.channel.name; + selectedChannelType = state.channel.type; + serverController.text = state.channel.server ?? ''; + secretController.text = state.channel.secret ?? ''; + usingProxy = state.channel.meta?.usingProxy ?? false; + openaiAzure = state.channel.meta?.openaiAzure ?? false; + azureAPIVersionController.text = + state.channel.meta?.openaiAzureAPIVersion ?? ''; + + setState(() { + editLocked = false; + }); + } + }, + child: Container( + padding: const EdgeInsets.all(10), + child: Column( + children: [ + ColumnBlock( + children: [ + EnhancedTextField( + labelText: '渠道名称', + customColors: customColors, + controller: nameController, + textAlignVertical: TextAlignVertical.top, + hintText: '请输入渠道名称', + maxLength: 100, + showCounter: false, + ), + EnhancedInput( + title: Text( + '类型', + style: TextStyle( + color: customColors.textfieldLabelColor, + fontSize: 16, + ), + ), + value: Text( + buildSelectedChannelTypeText(), + style: TextStyle( + color: customColors.textfieldValueColor, + fontSize: 16, + ), + ), + onPressed: () { + openListSelectDialog( + context, + channelTypes + .map((e) => SelectorItem(Text(e.text), e.name)) + .toList(), + (value) { + setState(() { + selectedChannelType = value.value; + }); + return true; + }, + heightFactor: 0.5, + value: selectedChannelType, + ); + }, + ), + EnhancedTextField( + labelText: '服务器', + customColors: customColors, + controller: serverController, + textAlignVertical: TextAlignVertical.top, + hintText: 'https://api.openai.com/v1', + maxLength: 255, + showCounter: false, + ), + EnhancedTextField( + labelText: '鉴权密钥', + customColors: customColors, + controller: secretController, + textAlignVertical: TextAlignVertical.top, + hintText: '请输入鉴权密钥', + maxLength: 255, + obscureText: true, + showCounter: false, + ), + ], + ), + // 高级选项 + if (showAdvancedOptions) + ColumnBlock( + innerPanding: 5, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + '使用代理', + style: TextStyle(fontSize: 16), + ), + CupertinoSwitch( + activeColor: customColors.linkColor, + value: usingProxy, + onChanged: (value) { + setState(() { + usingProxy = value; + }); + }, + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Azure 模式', + style: TextStyle(fontSize: 16), + ), + CupertinoSwitch( + activeColor: customColors.linkColor, + value: openaiAzure, + onChanged: (value) { + setState(() { + openaiAzure = value; + }); + }, + ), + ], + ), + EnhancedTextField( + labelText: 'API 版本', + customColors: customColors, + controller: azureAPIVersionController, + textAlignVertical: TextAlignVertical.top, + hintText: '2023-05-15', + maxLength: 30, + showCounter: false, + ), + ], + ), + const SizedBox(height: 15), + Row( + children: [ + EnhancedButton( + title: showAdvancedOptions + ? AppLocale.simpleMode.getString(context) + : AppLocale.professionalMode.getString(context), + width: 120, + backgroundColor: Colors.transparent, + color: customColors.weakLinkColor, + fontSize: 15, + icon: Icon( + showAdvancedOptions + ? Icons.unfold_less + : Icons.unfold_more, + color: customColors.weakLinkColor, + size: 15, + ), + onPressed: () { + setState(() { + showAdvancedOptions = !showAdvancedOptions; + }); + }, + ), + const SizedBox(width: 10), + Expanded( + flex: 1, + child: EnhancedButton( + title: AppLocale.save.getString(context), + onPressed: onSubmit, + icon: editLocked + ? const Icon(Icons.lock, + color: Colors.white, size: 16) + : const Icon(Icons.lock_open, + color: Colors.white, size: 16), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } + + /// 提交 + void onSubmit() { + if (editLocked) { + return; + } + + if (nameController.text.isEmpty) { + showErrorMessage('请输入渠道名称'); + return; + } + + if (selectedChannelType == null) { + showErrorMessage('请选择渠道类型'); + return; + } + + if (serverController.text.isEmpty) { + showErrorMessage('请输入服务器地址'); + return; + } + + if (!serverController.text.startsWith('http://') && + !serverController.text.startsWith('https://')) { + showErrorMessage('服务器地址格式不正确'); + return; + } + + final req = AdminChannelUpdateReq( + name: nameController.text, + type: selectedChannelType!, + server: serverController.text, + secret: secretController.text, + meta: AdminChannelMeta( + usingProxy: usingProxy, + openaiAzure: openaiAzure, + openaiAzureAPIVersion: azureAPIVersionController.text, + ), + ); + + context.read().add(ChannelUpdateEvent(widget.channelId, req)); + } + + /// 生成选中的渠道类型文本 + String buildSelectedChannelTypeText() { + if (selectedChannelType == null) { + return '请选择'; + } + + return channelTypes + .firstWhere((element) => element.name == selectedChannelType) + .text; + } +} diff --git a/lib/page/admin/dashboard.dart b/lib/page/admin/dashboard.dart new file mode 100644 index 00000000..f3bbe412 --- /dev/null +++ b/lib/page/admin/dashboard.dart @@ -0,0 +1,156 @@ +import 'package:askaide/page/component/background_container.dart'; +import 'package:askaide/page/component/dialog.dart'; +import 'package:askaide/page/component/theme/custom_size.dart'; +import 'package:askaide/repo/api_server.dart'; +import 'package:askaide/repo/settings_repo.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:settings_ui/settings_ui.dart'; + +class AdminDashboardPage extends StatefulWidget { + final SettingRepository setting; + const AdminDashboardPage({super.key, required this.setting}); + + @override + State createState() => _AdminDashboardPageState(); +} + +class _AdminDashboardPageState extends State { + @override + Widget build(BuildContext context) { + // final customColors = Theme.of(context).extension()!; + + return BackgroundContainer( + setting: widget.setting, + child: Scaffold( + appBar: AppBar( + toolbarHeight: CustomSize.toolbarHeight, + title: const Text( + 'Dashboard', + style: TextStyle(fontSize: CustomSize.appBarTitleSize), + ), + centerTitle: true, + ), + backgroundColor: Colors.transparent, + body: Column( + children: [ + Expanded( + child: SettingsList( + platform: DevicePlatform.iOS, + lightTheme: const SettingsThemeData( + settingsListBackground: Colors.transparent, + settingsSectionBackground: Color.fromARGB(255, 255, 255, 255), + ), + darkTheme: const SettingsThemeData( + settingsListBackground: Colors.transparent, + settingsSectionBackground: Color.fromARGB(255, 27, 27, 27), + titleTextColor: Color.fromARGB(255, 239, 239, 239), + ), + sections: [ + SettingsSection( + title: const Text('创作岛'), + tiles: [ + SettingsTile( + title: const Text('创作历史'), + trailing: const Icon( + CupertinoIcons.chevron_forward, + size: 18, + color: Colors.grey, + ), + onPressed: (context) { + context.push('/creative-island/models'); + }, + ), + ], + ), + SettingsSection( + title: const Text('用户 & 收入'), + tiles: [ + SettingsTile( + title: const Text('用户管理'), + trailing: const Icon( + CupertinoIcons.chevron_forward, + size: 18, + color: Colors.grey, + ), + onPressed: (context) { + context.push('/admin/users'); + }, + ), + SettingsTile( + title: const Text('支付订单历史'), + trailing: const Icon( + CupertinoIcons.chevron_forward, + size: 18, + color: Colors.grey, + ), + onPressed: (context) { + context.push('/admin/payment/histories'); + }, + ), + ], + ), + SettingsSection( + title: const Text('模型管理'), + tiles: [ + SettingsTile( + title: const Text('渠道'), + trailing: const Icon( + CupertinoIcons.chevron_forward, + size: 18, + color: Colors.grey, + ), + onPressed: (context) { + context.push('/admin/channels'); + }, + ), + SettingsTile( + title: const Text('大语言模型'), + trailing: const Icon( + CupertinoIcons.chevron_forward, + size: 18, + color: Colors.grey, + ), + onPressed: (context) { + context.push('/admin/models'); + }, + ), + ], + ), + SettingsSection( + title: const Text('系统设置'), + tiles: [ + SettingsTile( + title: const Text('更新配置缓存'), + trailing: const Icon( + CupertinoIcons.chevron_forward, + size: 18, + color: Colors.grey, + ), + onPressed: (context) { + openConfirmDialog( + context, + '该操作将重新加载全部系统配置,确定继续?', + () { + APIServer().adminSettingsReload().then((value) { + showSuccessMessage('更新成功'); + }).onError((error, stackTrace) { + showErrorMessageEnhanced(context, error!); + }); + }, + ); + }, + ), + ], + ), + ], + contentPadding: const EdgeInsets.all(0), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/page/admin/models.dart b/lib/page/admin/models.dart new file mode 100644 index 00000000..a9add4b7 --- /dev/null +++ b/lib/page/admin/models.dart @@ -0,0 +1,403 @@ +import 'package:askaide/bloc/model_bloc.dart'; +import 'package:askaide/helper/constant.dart'; +import 'package:askaide/helper/image.dart'; +import 'package:askaide/lang/lang.dart'; +import 'package:askaide/page/component/background_container.dart'; +import 'package:askaide/page/component/dialog.dart'; +import 'package:askaide/page/component/theme/custom_size.dart'; +import 'package:askaide/page/component/theme/custom_theme.dart'; +import 'package:askaide/repo/api/admin/channels.dart'; +import 'package:askaide/repo/api/admin/models.dart'; +import 'package:askaide/repo/api_server.dart'; +import 'package:askaide/repo/settings_repo.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_initicon/flutter_initicon.dart'; +import 'package:flutter_localization/flutter_localization.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:go_router/go_router.dart'; + +class AdminModelsPage extends StatefulWidget { + final SettingRepository setting; + const AdminModelsPage({ + super.key, + required this.setting, + }); + + @override + State createState() => _AdminModelsPageState(); +} + +class _AdminModelsPageState extends State { + // 渠道 + List channels = []; + + // 搜索关键字 + String keyword = ''; + + /// 查找渠道 + AdminChannel searchChannel(AdminModelProvider provider) { + return channels.firstWhere( + (e) { + if (e.id == null && (provider.id == null || provider.id == 0)) { + return provider.name == e.type; + } + + return provider.id == e.id; + }, + orElse: () { + return AdminChannel( + id: provider.id, + name: '未知', + type: 'unknown', + ); + }, + ); + } + + @override + void initState() { + // 加载渠道 + APIServer().adminChannelsAgg().then((value) { + if (context.mounted) { + setState(() { + channels = value; + }); + + // 加载模型列表 + context.read().add(ModelsLoadEvent()); + } + }); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + final customColors = Theme.of(context).extension()!; + + return Scaffold( + appBar: AppBar( + toolbarHeight: CustomSize.toolbarHeight, + title: const Text( + '大语言模型管理', + style: TextStyle(fontSize: CustomSize.appBarTitleSize), + ), + centerTitle: true, + actions: [ + IconButton( + icon: const Icon(Icons.add), + onPressed: () { + context.push('/admin/models/create').then((value) { + context.read().add(ModelsLoadEvent()); + }); + }, + ), + ], + ), + backgroundColor: customColors.chatInputPanelBackground, + body: BackgroundContainer( + setting: widget.setting, + enabled: false, + child: Column( + children: [ + Container( + padding: const EdgeInsets.only(left: 10, right: 10, bottom: 5), + child: TextField( + textAlignVertical: TextAlignVertical.center, + style: TextStyle(color: customColors.dialogDefaultTextColor), + decoration: InputDecoration( + hintText: AppLocale.search.getString(context), + hintStyle: TextStyle( + color: customColors.dialogDefaultTextColor, + ), + prefixIcon: Icon( + Icons.search, + color: customColors.dialogDefaultTextColor, + ), + isDense: true, + border: InputBorder.none, + ), + onChanged: (value) => setState(() => keyword = value), + ), + ), + Expanded( + child: RefreshIndicator( + color: customColors.linkColor, + onRefresh: () async { + context.read().add(ModelsLoadEvent()); + }, + displacement: 20, + child: BlocConsumer( + listenWhen: (previous, current) => + current is ModelOperationResult, + listener: (context, state) { + if (state is ModelOperationResult) { + if (state.success) { + showSuccessMessage(state.message); + context.read().add(ModelsLoadEvent()); + } else { + showErrorMessage(state.message); + } + } + }, + buildWhen: (previous, current) => current is ModelsLoaded, + builder: (context, state) { + if (state is ModelsLoaded) { + final models = state.models + .where((e) => + keyword == '' || + e.name + .toLowerCase() + .contains(keyword.toLowerCase()) || + e.modelId + .toLowerCase() + .contains(keyword.toLowerCase()) || + (e.description ?? '') + .toLowerCase() + .contains(keyword.toLowerCase())) + .toList(); + return SafeArea( + top: false, + child: ListView.builder( + padding: const EdgeInsets.all(5), + itemCount: models.length, + itemBuilder: (context, index) { + final mod = models[index]; + + return buildModelItem(context, customColors, mod); + }, + ), + ); + } + + return const Center( + child: CircularProgressIndicator(), + ); + }, + ), + ), + ), + ], + ), + ), + ); + } + + Widget buildModelItem( + BuildContext context, + CustomColors customColors, + AdminModel mod, + ) { + return Container( + margin: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(customColors.borderRadius ?? 8), + ), + child: Slidable( + endActionPane: ActionPane( + motion: const ScrollMotion(), + children: [ + const SizedBox(width: 10), + SlidableAction( + label: AppLocale.delete.getString(context), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(customColors.borderRadius ?? 8), + bottomLeft: Radius.circular(customColors.borderRadius ?? 8), + topRight: Radius.circular(customColors.borderRadius ?? 8), + bottomRight: Radius.circular(customColors.borderRadius ?? 8), + ), + backgroundColor: Colors.red, + icon: Icons.delete, + onPressed: (_) { + openConfirmDialog( + context, + AppLocale.confirmToDeleteRoom.getString(context), + () => context + .read() + .add(ModelDeleteEvent(mod.modelId)), + danger: true, + ); + }, + ), + ], + ), + child: Material( + borderRadius: + BorderRadius.all(Radius.circular(customColors.borderRadius ?? 8)), + color: customColors.columnBlockBackgroundColor, + child: InkWell( + borderRadius: BorderRadius.all( + Radius.circular(customColors.borderRadius ?? 8)), + onTap: () { + context + .push( + '/admin/models/edit/${Uri.encodeComponent(mod.modelId)}') + .then((value) { + context.read().add(ModelsLoadEvent()); + }); + }, + child: Stack( + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // 头像 + Stack( + children: [ + buildAvatar(mod), + if (mod.isVision) + Positioned( + left: 0, + bottom: 0, + child: ClipRRect( + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(8), + ), + child: Container( + padding: const EdgeInsets.all(3), + width: 80, + color: Colors.black.withAlpha(30), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.remove_red_eye_outlined, + color: Colors.white, + size: 12, + ), + SizedBox(width: 3), + Text( + '视觉', + style: TextStyle( + color: Colors.white, + fontSize: 12, + ), + ) + ], + ), + ), + ), + ) + ], + ), + // 名称 + Expanded( + child: Container( + padding: const EdgeInsets.all(15), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + mod.name, + style: const TextStyle( + overflow: TextOverflow.ellipsis, + ), + ), + Text( + buildModelDescription(mod), + style: TextStyle( + fontSize: 10, + overflow: TextOverflow.ellipsis, + color: customColors.weakTextColor, + ), + maxLines: 2, + ), + ], + ), + ), + ), + ], + ), + Positioned( + right: 0, + top: 0, + child: Container( + padding: const EdgeInsets.all(10), + width: MediaQuery.of(context).size.width / 2.0, + alignment: Alignment.centerRight, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + mod.providers + .map((e) => searchChannel(e).display) + .join('|'), + style: TextStyle( + fontSize: 10, + overflow: TextOverflow.ellipsis, + color: customColors.weakTextColor, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget buildAvatar(AdminModel mod) { + if (mod.avatarUrl != null && mod.avatarUrl!.startsWith('http')) { + return SizedBox( + width: 80, + height: 80, + child: ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(8), + bottomLeft: Radius.circular(8), + ), + child: CachedNetworkImage( + imageUrl: imageURL(mod.avatarUrl!, qiniuImageTypeAvatar), + fit: BoxFit.fill, + ), + ), + ); + } + + return Initicon( + text: mod.name.split('、').join(' '), + size: 80, + backgroundColor: Colors.grey.withAlpha(100), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(8), + bottomLeft: Radius.circular(8), + ), + ); + } + + String buildModelDescription(AdminModel mod) { + String desc = ''; + if (mod.inputPrice > 0 || mod.outputPrice > 0) { + desc += '💰 '; + if (mod.inputPrice == mod.outputPrice) { + desc += '${mod.inputPrice} 智慧果/1K Token'; + } else { + desc += '${mod.inputPrice} / ${mod.outputPrice} 智慧果/1K Token'; + } + } + + if (mod.maxContext > 0) { + if (desc.isNotEmpty) { + desc += ','; + } + + desc += '🎞️ ${mod.maxContext} Tokens'; + } + + if (desc != '') { + desc += '\n'; + } + + return desc + (mod.description ?? ''); + } +} diff --git a/lib/page/admin/models_add.dart b/lib/page/admin/models_add.dart new file mode 100644 index 00000000..adf047bf --- /dev/null +++ b/lib/page/admin/models_add.dart @@ -0,0 +1,650 @@ +import 'dart:io'; + +import 'package:askaide/bloc/model_bloc.dart'; +import 'package:askaide/helper/upload.dart'; +import 'package:askaide/lang/lang.dart'; +import 'package:askaide/page/component/avatar_selector.dart'; +import 'package:askaide/page/component/background_container.dart'; +import 'package:askaide/page/component/column_block.dart'; +import 'package:askaide/page/component/dialog.dart'; +import 'package:askaide/page/component/enhanced_button.dart'; +import 'package:askaide/page/component/enhanced_input.dart'; +import 'package:askaide/page/component/enhanced_textfield.dart'; +import 'package:askaide/page/component/image.dart'; +import 'package:askaide/page/component/item_selector_search.dart'; +import 'package:askaide/page/component/loading.dart'; +import 'package:askaide/page/component/random_avatar.dart'; +import 'package:askaide/page/component/theme/custom_size.dart'; +import 'package:askaide/page/component/theme/custom_theme.dart'; +import 'package:askaide/page/component/weak_text_button.dart'; +import 'package:askaide/repo/api/admin/channels.dart'; +import 'package:askaide/repo/api/admin/models.dart'; +import 'package:askaide/repo/api_server.dart'; +import 'package:askaide/repo/settings_repo.dart'; +import 'package:bot_toast/bot_toast.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localization/flutter_localization.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:go_router/go_router.dart'; +import 'package:quickalert/models/quickalert_type.dart'; + +class AdminModelCreatePage extends StatefulWidget { + final SettingRepository setting; + const AdminModelCreatePage({ + super.key, + required this.setting, + }); + + @override + State createState() => _AdminModelCreatePageState(); +} + +class _AdminModelCreatePageState extends State { + final TextEditingController nameController = TextEditingController(); + final TextEditingController modelIdController = TextEditingController(); + final TextEditingController shortNameController = TextEditingController(); + final TextEditingController descriptionController = TextEditingController(); + final TextEditingController maxContextController = TextEditingController(); + final TextEditingController inputPriceController = TextEditingController(); + final TextEditingController outputPriceController = TextEditingController(); + final TextEditingController promptController = TextEditingController(); + + /// 用于控制是否显示高级选项 + bool showAdvancedOptions = false; + + /// 视觉能力 + bool supportVision = false; + + /// 受限模型 + bool restricted = false; + + /// 模型状态 + bool modelEnabled = true; + + /// 模型头像 + String? avatarUrl; + List avatarPresets = []; + + // 模型渠道 + List modelChannels = []; + // 选择的渠道 + List providers = []; + + @override + void dispose() { + nameController.dispose(); + modelIdController.dispose(); + shortNameController.dispose(); + descriptionController.dispose(); + maxContextController.dispose(); + inputPriceController.dispose(); + outputPriceController.dispose(); + promptController.dispose(); + + super.dispose(); + } + + @override + void initState() { + // 加载预设头像 + APIServer().avatars().then((value) { + avatarPresets = value; + }); + // 加载模型渠道 + APIServer().adminChannelsAgg().then((value) { + modelChannels = value; + }); + + // 初始值设置 + maxContextController.value = const TextEditingValue(text: '3500'); + inputPriceController.value = const TextEditingValue(text: '1'); + outputPriceController.value = const TextEditingValue(text: '1'); + providers.add(AdminModelProvider()); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + final customColors = Theme.of(context).extension()!; + + return Scaffold( + appBar: AppBar( + toolbarHeight: CustomSize.toolbarHeight, + title: const Text( + '新增模型', + style: TextStyle(fontSize: CustomSize.appBarTitleSize), + ), + centerTitle: true, + ), + backgroundColor: customColors.chatInputPanelBackground, + body: BackgroundContainer( + setting: widget.setting, + enabled: false, + child: SingleChildScrollView( + child: BlocListener( + listenWhen: (previous, current) => current is ModelOperationResult, + listener: (context, state) { + if (state is ModelOperationResult) { + if (state.success) { + showSuccessMessage(state.message); + context.pop(); + } else { + showErrorMessage(state.message); + } + } + }, + child: Container( + padding: const EdgeInsets.only( + left: 10, right: 10, top: 10, bottom: 20), + child: Column( + children: [ + ColumnBlock( + children: [ + EnhancedTextField( + labelText: '唯一标识', + customColors: customColors, + controller: modelIdController, + textAlignVertical: TextAlignVertical.top, + hintText: '请输入模型唯一标识', + maxLength: 100, + showCounter: false, + ), + EnhancedTextField( + labelText: '名称', + customColors: customColors, + controller: nameController, + textAlignVertical: TextAlignVertical.top, + hintText: '请输入模型名称', + maxLength: 100, + showCounter: false, + ), + EnhancedInput( + padding: const EdgeInsets.only(top: 10, bottom: 5), + title: Text( + '头像', + style: TextStyle( + color: customColors.textfieldLabelColor, + fontSize: 16, + ), + ), + value: Row( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 45, + height: 45, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + image: avatarUrl == null + ? null + : DecorationImage( + image: (avatarUrl!.startsWith('http') + ? CachedNetworkImageProviderEnhanced( + avatarUrl!) + : FileImage(File( + avatarUrl!))) as ImageProvider, + fit: BoxFit.cover, + ), + ), + child: avatarUrl == null + ? const Center( + child: Icon( + Icons.interests, + color: Colors.grey, + ), + ) + : const SizedBox(), + ), + ], + ), + onPressed: () { + openModalBottomSheet( + context, + (context) { + return AvatarSelector( + onSelected: (selected) { + setState(() { + avatarUrl = selected.url; + }); + context.pop(); + }, + usage: AvatarUsage.user, + defaultAvatarUrl: avatarUrl, + externalAvatarUrls: [ + ...avatarPresets, + ], + ); + }, + heightFactor: 0.8, + ); + }, + ), + EnhancedTextField( + labelText: '描述', + customColors: customColors, + controller: descriptionController, + textAlignVertical: TextAlignVertical.top, + hintText: '可选', + maxLength: 255, + showCounter: false, + maxLines: 3, + ), + ], + ), + ColumnBlock( + children: [ + EnhancedTextField( + labelText: '输入价格', + customColors: customColors, + controller: inputPriceController, + textAlignVertical: TextAlignVertical.top, + hintText: '可选', + showCounter: false, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly + ], + textDirection: TextDirection.rtl, + suffixIcon: Container( + width: 110, + alignment: Alignment.center, + child: Text( + '智慧果/1K Token', + style: TextStyle( + color: customColors.weakTextColor, + fontSize: 12), + ), + ), + ), + EnhancedTextField( + labelText: '输出价格', + customColors: customColors, + controller: outputPriceController, + textAlignVertical: TextAlignVertical.top, + hintText: '可选', + showCounter: false, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly + ], + textDirection: TextDirection.rtl, + suffixIcon: Container( + width: 110, + alignment: Alignment.center, + child: Text( + '智慧果/1K Token', + style: TextStyle( + color: customColors.weakTextColor, + fontSize: 12), + ), + ), + ), + EnhancedTextField( + labelText: '输入限制', + customColors: customColors, + controller: maxContextController, + textAlignVertical: TextAlignVertical.top, + hintText: '最大上下文减掉预期的输出长度', + showCounter: false, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly + ], + textDirection: TextDirection.rtl, + suffixIcon: Container( + width: 50, + alignment: Alignment.center, + child: Text( + 'Token', + style: TextStyle( + color: customColors.weakTextColor, + fontSize: 12), + ), + ), + ), + ], + ), + ...providers.map((e) { + return Container( + margin: + const EdgeInsets.only(bottom: 10, left: 5, right: 5), + child: Slidable( + endActionPane: ActionPane( + motion: const ScrollMotion(), + children: [ + const SizedBox(width: 10), + SlidableAction( + label: AppLocale.delete.getString(context), + borderRadius: BorderRadius.circular( + customColors.borderRadius ?? 8), + backgroundColor: Colors.red, + icon: Icons.delete, + onPressed: (_) { + if (providers.length == 1) { + showErrorMessage('至少需要一个渠道'); + return; + } + + openConfirmDialog( + context, + AppLocale.confirmToDeleteRoom + .getString(context), + () { + setState(() { + providers + .removeWhere((item) => item == e); + }); + }, + danger: true, + ); + }, + ), + ], + ), + child: ColumnBlock( + margin: const EdgeInsets.all(0), + children: [ + EnhancedInput( + title: Text( + '渠道', + style: TextStyle( + color: customColors.textfieldLabelColor, + fontSize: 16, + ), + ), + value: Text( + buildChannelName(e), + style: TextStyle( + color: customColors.textfieldValueColor, + fontSize: 16, + ), + ), + onPressed: () { + openListSelectDialog( + context, + >[ + ...modelChannels + .map( + (e) => SelectorItem( + Text( + '${e.id == null ? '【系统】' : ''}${e.name}'), + e, + ), + ) + .toList(), + ], + (value) { + setState(() { + e.id = value.value.id; + if (value.value.id == null) { + e.name = value.value.type; + } + }); + return true; + }, + heightFactor: 0.5, + value: e, + ); + }, + ), + EnhancedTextField( + labelText: '模型重写', + customColors: customColors, + textAlignVertical: TextAlignVertical.top, + hintText: '可选', + maxLength: 100, + showCounter: false, + initValue: e.modelRewrite, + onChanged: (value) { + e.modelRewrite = value; + }, + ), + ], + ), + ), + ); + }).toList(), + const SizedBox(width: 10), + WeakTextButton( + title: '添加渠道', + icon: Icons.add, + onPressed: () { + setState(() { + providers.add(AdminModelProvider()); + }); + }, + ), + // 高级选项 + if (showAdvancedOptions) + ColumnBlock( + innerPanding: 5, + children: [ + EnhancedTextField( + labelText: '简称', + customColors: customColors, + controller: shortNameController, + textAlignVertical: TextAlignVertical.top, + hintText: '请输入模型简称', + maxLength: 100, + showCounter: false, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + '启用', + style: TextStyle(fontSize: 16), + ), + CupertinoSwitch( + activeColor: customColors.linkColor, + value: modelEnabled, + onChanged: (value) { + setState(() { + modelEnabled = value; + }); + }, + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + '视觉能力', + style: TextStyle(fontSize: 16), + ), + CupertinoSwitch( + activeColor: customColors.linkColor, + value: supportVision, + onChanged: (value) { + setState(() { + supportVision = value; + }); + }, + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Text( + '受限模型', + style: TextStyle(fontSize: 16), + ), + const SizedBox(width: 5), + InkWell( + onTap: () { + showBeautyDialog( + context, + type: QuickAlertType.info, + text: '受限模型是指因政策因素,不能在中国大陆地区使用的模型。', + confirmBtnText: + AppLocale.gotIt.getString(context), + showCancelBtn: false, + ); + }, + child: Icon( + Icons.help_outline, + size: 16, + color: customColors.weakLinkColor + ?.withAlpha(150), + ), + ), + ], + ), + CupertinoSwitch( + activeColor: customColors.linkColor, + value: restricted, + onChanged: (value) { + setState(() { + restricted = value; + }); + }, + ), + ], + ), + EnhancedTextField( + labelPosition: LabelPosition.top, + labelText: '系统提示语', + customColors: customColors, + controller: promptController, + textAlignVertical: TextAlignVertical.top, + hintText: '全局系统提示语', + maxLength: 2000, + maxLines: 3, + ), + ], + ), + const SizedBox(height: 15), + Row( + children: [ + EnhancedButton( + title: showAdvancedOptions + ? AppLocale.simpleMode.getString(context) + : AppLocale.professionalMode.getString(context), + width: 120, + backgroundColor: Colors.transparent, + color: customColors.weakLinkColor, + fontSize: 15, + icon: Icon( + showAdvancedOptions + ? Icons.unfold_less + : Icons.unfold_more, + color: customColors.weakLinkColor, + size: 15, + ), + onPressed: () { + setState(() { + showAdvancedOptions = !showAdvancedOptions; + }); + }, + ), + const SizedBox(width: 10), + Expanded( + flex: 1, + child: EnhancedButton( + title: AppLocale.save.getString(context), + onPressed: onSubmit, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } + + /// 提交 + void onSubmit() async { + if (nameController.text.isEmpty) { + showErrorMessage('请输入模型名称'); + return; + } + + if (modelIdController.text.isEmpty) { + showErrorMessage('请输入模型唯一标识'); + return; + } + + final ps = providers.where((e) => e.id != null || e.name != null).toList(); + if (ps.isEmpty) { + showErrorMessage('至少需要一个渠道'); + return; + } + + if (avatarUrl != null && + (!avatarUrl!.startsWith('http://') && + !avatarUrl!.startsWith('https://'))) { + final cancel = BotToast.showCustomLoading( + toastBuilder: (cancel) { + return const LoadingIndicator( + message: '正在上传头像,请稍后...', + ); + }, + allowClick: false, + ); + + try { + final res = await ImageUploader(widget.setting) + .upload(avatarUrl!, usage: 'avatar'); + avatarUrl = res.url; + } catch (e) { + showErrorMessage('上传头像失败'); + cancel(); + return; + } finally { + cancel(); + } + } + + final model = AdminModelAddReq( + name: nameController.text, + modelId: modelIdController.text, + description: descriptionController.text, + shortName: shortNameController.text, + meta: AdminModelMeta( + maxContext: int.parse(maxContextController.text), + inputPrice: int.parse(inputPriceController.text), + outputPrice: int.parse(outputPriceController.text), + prompt: promptController.text, + vision: supportVision, + restricted: restricted, + ), + status: modelEnabled ? 1 : 2, + providers: ps, + avatarUrl: avatarUrl, + ); + + // ignore: use_build_context_synchronously + context.read().add(ModelCreateEvent(model)); + } + + /// 渠道名称 + String buildChannelName(AdminModelProvider provider) { + if (provider.id != null) { + return modelChannels.firstWhere((e) => e.id == provider.id).name; + } + + if (provider.name != null) { + return modelChannels + .firstWhere( + (e) => e.type == provider.name! && e.id == null, + orElse: () => AdminChannel(name: '未知', type: ''), + ) + .display; + } + + return '请选择'; + } +} diff --git a/lib/page/admin/models_edit.dart b/lib/page/admin/models_edit.dart new file mode 100644 index 00000000..cefc5e38 --- /dev/null +++ b/lib/page/admin/models_edit.dart @@ -0,0 +1,734 @@ +import 'dart:io'; + +import 'package:askaide/bloc/model_bloc.dart'; +import 'package:askaide/helper/upload.dart'; +import 'package:askaide/lang/lang.dart'; +import 'package:askaide/page/component/avatar_selector.dart'; +import 'package:askaide/page/component/background_container.dart'; +import 'package:askaide/page/component/column_block.dart'; +import 'package:askaide/page/component/dialog.dart'; +import 'package:askaide/page/component/enhanced_button.dart'; +import 'package:askaide/page/component/enhanced_input.dart'; +import 'package:askaide/page/component/enhanced_textfield.dart'; +import 'package:askaide/page/component/image.dart'; +import 'package:askaide/page/component/item_selector_search.dart'; +import 'package:askaide/page/component/loading.dart'; +import 'package:askaide/page/component/random_avatar.dart'; +import 'package:askaide/page/component/theme/custom_size.dart'; +import 'package:askaide/page/component/theme/custom_theme.dart'; +import 'package:askaide/page/component/weak_text_button.dart'; +import 'package:askaide/repo/api/admin/channels.dart'; +import 'package:askaide/repo/api/admin/models.dart'; +import 'package:askaide/repo/api_server.dart'; +import 'package:askaide/repo/settings_repo.dart'; +import 'package:bot_toast/bot_toast.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localization/flutter_localization.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:go_router/go_router.dart'; +import 'package:quickalert/models/quickalert_type.dart'; + +class AdminModelEditPage extends StatefulWidget { + final SettingRepository setting; + final String modelId; + const AdminModelEditPage({ + super.key, + required this.setting, + required this.modelId, + }); + + @override + State createState() => _AdminModelEditPageState(); +} + +class _AdminModelEditPageState extends State { + final TextEditingController nameController = TextEditingController(); + final TextEditingController modelIdController = TextEditingController(); + final TextEditingController shortNameController = TextEditingController(); + final TextEditingController descriptionController = TextEditingController(); + final TextEditingController maxContextController = TextEditingController(); + final TextEditingController inputPriceController = TextEditingController(); + final TextEditingController outputPriceController = TextEditingController(); + final TextEditingController promptController = TextEditingController(); + + /// 用于控制是否显示高级选项 + bool showAdvancedOptions = false; + + /// 视觉能力 + bool supportVision = false; + + /// 受限模型 + bool restricted = false; + + /// 模型状态 + bool modelEnabled = true; + + /// 模型头像 + String? avatarUrl; + List avatarPresets = []; + + // 模型渠道 + List modelChannels = []; + // 选择的渠道 + List providers = []; + + /// 是否锁定编辑 + bool editLocked = true; + + @override + void dispose() { + nameController.dispose(); + modelIdController.dispose(); + shortNameController.dispose(); + descriptionController.dispose(); + maxContextController.dispose(); + inputPriceController.dispose(); + outputPriceController.dispose(); + promptController.dispose(); + + super.dispose(); + } + + @override + void initState() { + // 加载预设头像 + APIServer().avatars().then((value) { + avatarPresets = value; + }); + // 加载模型渠道 + APIServer().adminChannelsAgg().then((value) { + setState(() { + modelChannels = value; + }); + + // 加载模型 + context.read().add(ModelLoadEvent(widget.modelId)); + }); + + // 初始值设置 + maxContextController.value = const TextEditingValue(text: '3500'); + inputPriceController.value = const TextEditingValue(text: '1'); + outputPriceController.value = const TextEditingValue(text: '1'); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + final customColors = Theme.of(context).extension()!; + + return Scaffold( + appBar: AppBar( + toolbarHeight: CustomSize.toolbarHeight, + title: const Text( + '编辑模型', + style: TextStyle(fontSize: CustomSize.appBarTitleSize), + ), + centerTitle: true, + ), + backgroundColor: customColors.chatInputPanelBackground, + body: BackgroundContainer( + setting: widget.setting, + enabled: false, + child: SingleChildScrollView( + child: BlocListener( + listenWhen: (previous, current) => + current is ModelOperationResult || current is ModelLoaded, + listener: (context, state) { + if (state is ModelOperationResult) { + if (state.success) { + showSuccessMessage(state.message); + context.read().add(ModelLoadEvent(widget.modelId)); + } else { + showErrorMessage(state.message); + } + } + + if (state is ModelLoaded) { + modelIdController.value = + TextEditingValue(text: state.model.modelId); + nameController.value = TextEditingValue(text: state.model.name); + if (state.model.description != null) { + descriptionController.value = + TextEditingValue(text: state.model.description!); + } + + if (state.model.avatarUrl != null) { + avatarUrl = state.model.avatarUrl; + } + + modelEnabled = state.model.status == 1; + + if (state.model.providers.isNotEmpty) { + providers = state.model.providers; + } + + if (state.model.meta != null) { + if (state.model.meta!.maxContext != null) { + maxContextController.value = TextEditingValue( + text: state.model.meta!.maxContext.toString()); + } + + if (state.model.meta!.inputPrice != null) { + inputPriceController.value = TextEditingValue( + text: state.model.meta!.inputPrice.toString()); + } + + if (state.model.meta!.outputPrice != null) { + outputPriceController.value = TextEditingValue( + text: state.model.meta!.outputPrice.toString()); + } + + promptController.value = + TextEditingValue(text: state.model.meta!.prompt ?? ''); + supportVision = state.model.meta!.vision ?? false; + restricted = state.model.meta!.restricted ?? false; + } + } + + setState(() { + editLocked = false; + }); + }, + child: Container( + padding: const EdgeInsets.only( + left: 10, right: 10, top: 10, bottom: 20), + child: Column( + children: [ + ColumnBlock( + children: [ + EnhancedTextField( + labelText: '唯一标识', + customColors: customColors, + controller: modelIdController, + textAlignVertical: TextAlignVertical.top, + hintText: '请输入模型唯一标识', + maxLength: 100, + showCounter: false, + readOnly: true, + ), + EnhancedTextField( + labelText: '名称', + customColors: customColors, + controller: nameController, + textAlignVertical: TextAlignVertical.top, + hintText: '请输入模型名称', + maxLength: 100, + showCounter: false, + ), + EnhancedInput( + padding: const EdgeInsets.only(top: 10, bottom: 5), + title: Text( + '头像', + style: TextStyle( + color: customColors.textfieldLabelColor, + fontSize: 16, + ), + ), + value: Row( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 45, + height: 45, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + image: avatarUrl == null + ? null + : DecorationImage( + image: (avatarUrl!.startsWith('http') + ? CachedNetworkImageProviderEnhanced( + avatarUrl!) + : FileImage(File( + avatarUrl!))) as ImageProvider, + fit: BoxFit.cover, + ), + ), + child: avatarUrl == null + ? const Center( + child: Icon( + Icons.interests, + color: Colors.grey, + ), + ) + : const SizedBox(), + ), + ], + ), + onPressed: () { + openModalBottomSheet( + context, + (context) { + return AvatarSelector( + onSelected: (selected) { + setState(() { + avatarUrl = selected.url; + }); + context.pop(); + }, + usage: AvatarUsage.user, + defaultAvatarUrl: avatarUrl, + externalAvatarUrls: [ + ...avatarPresets, + ], + ); + }, + heightFactor: 0.8, + ); + }, + ), + EnhancedTextField( + labelText: '描述', + customColors: customColors, + controller: descriptionController, + textAlignVertical: TextAlignVertical.top, + hintText: '可选', + maxLength: 255, + showCounter: false, + maxLines: 3, + ), + ], + ), + ColumnBlock( + children: [ + EnhancedTextField( + labelText: '输入价格', + customColors: customColors, + controller: inputPriceController, + textAlignVertical: TextAlignVertical.top, + hintText: '可选', + showCounter: false, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly + ], + textDirection: TextDirection.rtl, + suffixIcon: Container( + width: 110, + alignment: Alignment.center, + child: Text( + '智慧果/1K Token', + style: TextStyle( + color: customColors.weakTextColor, + fontSize: 12), + ), + ), + ), + EnhancedTextField( + labelText: '输出价格', + customColors: customColors, + controller: outputPriceController, + textAlignVertical: TextAlignVertical.top, + hintText: '可选', + showCounter: false, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly + ], + textDirection: TextDirection.rtl, + suffixIcon: Container( + width: 110, + alignment: Alignment.center, + child: Text( + '智慧果/1K Token', + style: TextStyle( + color: customColors.weakTextColor, + fontSize: 12), + ), + ), + ), + EnhancedTextField( + labelText: '输入限制', + customColors: customColors, + controller: maxContextController, + textAlignVertical: TextAlignVertical.top, + hintText: '最大上下文减掉预期的输出长度', + showCounter: false, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly + ], + textDirection: TextDirection.rtl, + suffixIcon: Container( + width: 50, + alignment: Alignment.center, + child: Text( + 'Token', + style: TextStyle( + color: customColors.weakTextColor, + fontSize: 12), + ), + ), + ), + ], + ), + for (var i = 0; i < providers.length; i++) + Container( + margin: + const EdgeInsets.only(bottom: 10, left: 5, right: 5), + child: Slidable( + endActionPane: ActionPane( + motion: const ScrollMotion(), + children: [ + const SizedBox(width: 10), + SlidableAction( + label: AppLocale.delete.getString(context), + borderRadius: BorderRadius.circular( + customColors.borderRadius ?? 8), + backgroundColor: Colors.red, + icon: Icons.delete, + onPressed: (_) { + if (providers.length == 1) { + showErrorMessage('至少需要一个渠道'); + return; + } + + openConfirmDialog( + context, + AppLocale.confirmToDeleteRoom + .getString(context), + () { + setState(() { + providers.removeAt(i); + }); + }, + danger: true, + ); + }, + ), + ], + ), + child: ColumnBlock( + margin: const EdgeInsets.all(0), + children: [ + EnhancedInput( + title: Text( + '渠道', + style: TextStyle( + color: customColors.textfieldLabelColor, + fontSize: 16, + ), + ), + value: Text( + buildChannelName(providers[i]), + style: TextStyle( + color: customColors.textfieldValueColor, + fontSize: 16, + ), + ), + onPressed: () { + openListSelectDialog( + context, + >[ + ...modelChannels + .map( + (e) => SelectorItem( + Text( + '${e.id == null ? '【系统】' : ''}${e.name}'), + e, + ), + ) + .toList(), + ], + (value) { + setState(() { + providers[i].id = value.value.id; + if (value.value.id == null) { + providers[i].name = value.value.type; + } + }); + return true; + }, + heightFactor: 0.5, + value: providers[i], + ); + }, + ), + EnhancedTextField( + labelText: '模型重写', + labelFontSize: 12, + customColors: customColors, + textAlignVertical: TextAlignVertical.top, + hintText: '可选', + maxLength: 100, + showCounter: false, + initValue: providers[i].modelRewrite, + onChanged: (value) { + setState(() { + providers[i].modelRewrite = value; + }); + }, + labelHelpWidget: InkWell( + onTap: () { + showBeautyDialog( + context, + type: QuickAlertType.info, + text: + '渠道对应的模型标识和这里的 ID 不一致时,调用渠道接口时将会自动将模型替换为这里配置的值。', + confirmBtnText: + AppLocale.gotIt.getString(context), + showCancelBtn: false, + ); + }, + child: Icon( + Icons.help_outline, + size: 16, + color: customColors.weakLinkColor + ?.withAlpha(150), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(width: 10), + WeakTextButton( + title: '添加渠道', + icon: Icons.add, + onPressed: () { + setState(() { + providers.add(AdminModelProvider()); + }); + }, + ), + // 高级选项 + if (showAdvancedOptions) + ColumnBlock( + innerPanding: 5, + children: [ + EnhancedTextField( + labelText: '简称', + customColors: customColors, + controller: shortNameController, + textAlignVertical: TextAlignVertical.top, + hintText: '请输入模型简称', + maxLength: 100, + showCounter: false, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + '启用', + style: TextStyle(fontSize: 16), + ), + CupertinoSwitch( + activeColor: customColors.linkColor, + value: modelEnabled, + onChanged: (value) { + setState(() { + modelEnabled = value; + }); + }, + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + '视觉能力', + style: TextStyle(fontSize: 16), + ), + CupertinoSwitch( + activeColor: customColors.linkColor, + value: supportVision, + onChanged: (value) { + setState(() { + supportVision = value; + }); + }, + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Text( + '受限模型', + style: TextStyle(fontSize: 16), + ), + const SizedBox(width: 5), + InkWell( + onTap: () { + showBeautyDialog( + context, + type: QuickAlertType.info, + text: '受限模型是指因政策因素,不能在中国大陆地区使用的模型。', + confirmBtnText: + AppLocale.gotIt.getString(context), + showCancelBtn: false, + ); + }, + child: Icon( + Icons.help_outline, + size: 16, + color: customColors.weakLinkColor + ?.withAlpha(150), + ), + ), + ], + ), + CupertinoSwitch( + activeColor: customColors.linkColor, + value: restricted, + onChanged: (value) { + setState(() { + restricted = value; + }); + }, + ), + ], + ), + EnhancedTextField( + labelPosition: LabelPosition.top, + labelText: '系统提示语', + customColors: customColors, + controller: promptController, + textAlignVertical: TextAlignVertical.top, + hintText: '全局系统提示语', + maxLength: 2000, + maxLines: 3, + ), + ], + ), + const SizedBox(height: 15), + Row( + children: [ + EnhancedButton( + title: showAdvancedOptions + ? AppLocale.simpleMode.getString(context) + : AppLocale.professionalMode.getString(context), + width: 120, + backgroundColor: Colors.transparent, + color: customColors.weakLinkColor, + fontSize: 15, + icon: Icon( + showAdvancedOptions + ? Icons.unfold_less + : Icons.unfold_more, + color: customColors.weakLinkColor, + size: 15, + ), + onPressed: () { + setState(() { + showAdvancedOptions = !showAdvancedOptions; + }); + }, + ), + const SizedBox(width: 10), + Expanded( + flex: 1, + child: EnhancedButton( + title: AppLocale.save.getString(context), + onPressed: onSubmit, + icon: editLocked + ? const Icon(Icons.lock, + color: Colors.white, size: 16) + : const Icon(Icons.lock_open, + color: Colors.white, size: 16), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } + + /// 提交 + void onSubmit() async { + if (editLocked) { + return; + } + + if (nameController.text.isEmpty) { + showErrorMessage('请输入模型名称'); + return; + } + + final ps = providers.where((e) => e.id != null || e.name != null).toList(); + if (ps.isEmpty) { + showErrorMessage('至少需要一个渠道'); + return; + } + + if (avatarUrl != null && + (!avatarUrl!.startsWith('http://') && + !avatarUrl!.startsWith('https://'))) { + final cancel = BotToast.showCustomLoading( + toastBuilder: (cancel) { + return const LoadingIndicator( + message: '正在上传头像,请稍后...', + ); + }, + allowClick: false, + ); + + try { + final res = await ImageUploader(widget.setting) + .upload(avatarUrl!, usage: 'avatar'); + avatarUrl = res.url; + } catch (e) { + showErrorMessage('上传头像失败'); + cancel(); + return; + } finally { + cancel(); + } + } + + final model = AdminModelUpdateReq( + name: nameController.text, + description: descriptionController.text, + shortName: shortNameController.text, + meta: AdminModelMeta( + maxContext: int.parse(maxContextController.text), + inputPrice: int.parse(inputPriceController.text), + outputPrice: int.parse(outputPriceController.text), + prompt: promptController.text, + vision: supportVision, + restricted: restricted, + ), + status: modelEnabled ? 1 : 2, + providers: ps, + avatarUrl: avatarUrl, + ); + + setState(() { + editLocked = true; + }); + + // ignore: use_build_context_synchronously + context.read().add(ModelUpdateEvent(widget.modelId, model)); + } + + /// 渠道名称 + String buildChannelName(AdminModelProvider provider) { + if (provider.id != null) { + return modelChannels.firstWhere((e) => e.id == provider.id).name; + } + + if (provider.name != null) { + return modelChannels + .firstWhere( + (e) => e.type == provider.name! && e.id == null, + orElse: () => AdminChannel(name: '未知', type: ''), + ) + .display; + } + + return '请选择'; + } +} diff --git a/lib/page/admin/payments.dart b/lib/page/admin/payments.dart new file mode 100644 index 00000000..3284211d --- /dev/null +++ b/lib/page/admin/payments.dart @@ -0,0 +1,378 @@ +import 'package:askaide/bloc/admin_payment_bloc.dart'; +import 'package:askaide/bloc/user_bloc.dart'; +import 'package:askaide/lang/lang.dart'; +import 'package:askaide/page/component/background_container.dart'; +import 'package:askaide/page/component/dialog.dart'; +import 'package:askaide/page/component/pagination.dart'; +import 'package:askaide/page/component/theme/custom_size.dart'; +import 'package:askaide/page/component/theme/custom_theme.dart'; +import 'package:askaide/repo/api/admin/payment.dart'; +import 'package:askaide/repo/settings_repo.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localization/flutter_localization.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +class PaymentHistoriesPage extends StatefulWidget { + final SettingRepository setting; + const PaymentHistoriesPage({ + super.key, + required this.setting, + }); + + @override + State createState() => _PaymentHistoriesPageState(); +} + +class _PaymentHistoriesPageState extends State { + /// 当前页码 + int page = 1; + + /// 每页数量 + int perPage = 20; + + /// 搜索关键字 + final TextEditingController keywordController = TextEditingController(); + + @override + void initState() { + context.read().add(AdminPaymentHistoriesLoadEvent( + perPage: perPage, + page: page, + keyword: keywordController.text, + )); + super.initState(); + } + + @override + void dispose() { + keywordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final customColors = Theme.of(context).extension()!; + + return Scaffold( + appBar: AppBar( + toolbarHeight: CustomSize.toolbarHeight, + title: const Text( + '支付订单历史', + style: TextStyle(fontSize: CustomSize.appBarTitleSize), + ), + centerTitle: true, + ), + backgroundColor: customColors.chatInputPanelBackground, + body: BackgroundContainer( + setting: widget.setting, + enabled: false, + child: Column( + children: [ + Container( + padding: const EdgeInsets.only(left: 10, right: 10, bottom: 5), + child: TextField( + controller: keywordController, + textAlignVertical: TextAlignVertical.center, + style: TextStyle(color: customColors.dialogDefaultTextColor), + decoration: InputDecoration( + hintText: AppLocale.search.getString(context), + hintStyle: TextStyle( + color: customColors.dialogDefaultTextColor, + ), + prefixIcon: Icon( + Icons.search, + color: customColors.dialogDefaultTextColor, + ), + isDense: true, + border: InputBorder.none, + ), + onEditingComplete: () { + context + .read() + .add(AdminPaymentHistoriesLoadEvent( + perPage: perPage, + page: page, + keyword: keywordController.text, + )); + }, + ), + ), + Expanded( + child: RefreshIndicator( + color: customColors.linkColor, + onRefresh: () async { + context + .read() + .add(AdminPaymentHistoriesLoadEvent( + perPage: perPage, + page: page, + keyword: keywordController.text, + )); + }, + displacement: 20, + child: BlocConsumer( + listener: (context, state) { + if (state is AdminPaymentOperationResult) { + if (state.success) { + showSuccessMessage(state.message); + context.read().add(UserListLoadEvent()); + } else { + showErrorMessage(state.message); + } + } + + if (state is AdminPaymentHistoriesLoaded) { + setState(() { + page = state.histories.page; + perPage = state.histories.perPage; + }); + } + }, + buildWhen: (previous, current) => + current is AdminPaymentHistoriesLoaded, + builder: (context, state) { + if (state is AdminPaymentHistoriesLoaded) { + return SafeArea( + top: false, + child: Column( + children: [ + Expanded( + child: ListView.builder( + padding: const EdgeInsets.all(5), + itemCount: state.histories.data.length, + itemBuilder: (context, index) { + return buildHistoryInfo( + context, + customColors, + state.histories.data[index], + ); + }, + ), + ), + if (state.histories.lastPage != null && + state.histories.lastPage! > 1) + Container( + padding: const EdgeInsets.all(10), + child: Pagination( + numOfPages: state.histories.lastPage ?? 1, + selectedPage: page, + pagesVisible: 5, + onPageChanged: (selected) { + context + .read() + .add(AdminPaymentHistoriesLoadEvent( + perPage: perPage, + page: selected, + keyword: keywordController.text, + )); + }, + ), + ), + ], + ), + ); + } + + return const Center( + child: CircularProgressIndicator(), + ); + }, + ), + ), + ), + ], + ), + ), + ); + } + + Widget buildHistoryInfo( + BuildContext context, + CustomColors customColors, + AdminPaymentHistory his, + ) { + return Container( + margin: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(customColors.borderRadius ?? 8), + ), + child: Slidable( + child: Material( + borderRadius: + BorderRadius.all(Radius.circular(customColors.borderRadius ?? 8)), + color: customColors.columnBlockBackgroundColor, + child: InkWell( + borderRadius: BorderRadius.all( + Radius.circular(customColors.borderRadius ?? 8)), + onTap: () { + context.push('/admin/users/${his.userId}'); + }, + child: Stack( + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // 头像 + buildAvatar(his, radius: BorderRadius.circular(15)), + // 名称 + Expanded( + child: Container( + padding: const EdgeInsets.all(15), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + '用户 ${his.userId} 充值 ${(his.retailPrice / 100).ceil()} 元', + style: const TextStyle( + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 5), + Text( + '#${his.id}', + style: TextStyle( + fontSize: 10, + color: customColors.weakTextColor, + ), + ), + ], + ), + const SizedBox(height: 5), + buildTags(context, customColors, his), + ], + ), + ), + ), + ], + ), + Positioned( + right: 0, + top: 0, + child: Container( + padding: const EdgeInsets.all(10), + width: MediaQuery.of(context).size.width / 2.0, + alignment: Alignment.centerRight, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + his.environment, + style: TextStyle( + fontSize: 10, + overflow: TextOverflow.ellipsis, + fontWeight: FontWeight.bold, + color: his.environment.toLowerCase() == 'production' + ? customColors.linkColor + : Colors.amber, + ), + ), + ], + ), + ), + ), + Positioned( + right: 0, + bottom: 0, + child: Container( + padding: const EdgeInsets.all(10), + width: MediaQuery.of(context).size.width / 2.0, + alignment: Alignment.centerRight, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + DateFormat('y-MM-dd HH:mm') + .format(his.purchaseAt.toLocal()), + style: TextStyle( + fontSize: 10, + overflow: TextOverflow.ellipsis, + color: customColors.weakTextColor, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +Widget buildAvatar( + AdminPaymentHistory his, { + BorderRadius radius = const BorderRadius.only( + topLeft: Radius.circular(8), + bottomLeft: Radius.circular(8), + ), +}) { + final source = (his.source ?? '').toLowerCase(); + + var image = ''; + if (source.contains('支付宝') || source.contains('alipay')) { + image = 'assets/zhifubao.png'; + } else if (source.contains('微信') || source.contains('wechat')) { + image = 'assets/wechat-pay.png'; + } else if (source.contains('stripe')) { + image = 'assets/stripe.png'; + } else if (source.contains('apple')) { + image = 'assets/apple.webp'; + } else { + image = 'assets/app.png'; + } + + return SizedBox( + width: 70, + height: 70, + child: ClipRRect( + borderRadius: radius, + child: Image.asset(image), + ), + ); +} + +Widget buildTags( + BuildContext context, CustomColors customColors, AdminPaymentHistory his) { + final tags = []; + + if (his.source != null) { + tags.add(buildTag(context, customColors, his.source!)); + } + + return Wrap( + spacing: 5, + runSpacing: 5, + children: tags, + ); +} + +Widget buildTag(BuildContext context, CustomColors customColors, String s) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 5, + vertical: 2, + ), + decoration: BoxDecoration( + color: customColors.tagsBackground, + borderRadius: BorderRadius.circular(5), + ), + child: Text( + s, + style: TextStyle( + fontSize: 10, + color: customColors.tagsText, + ), + ), + ); +} diff --git a/lib/page/admin/user.dart b/lib/page/admin/user.dart new file mode 100644 index 00000000..9eebcff6 --- /dev/null +++ b/lib/page/admin/user.dart @@ -0,0 +1,489 @@ +import 'package:askaide/bloc/user_bloc.dart'; +import 'package:askaide/lang/lang.dart'; +import 'package:askaide/page/admin/users.dart'; +import 'package:askaide/page/component/background_container.dart'; +import 'package:askaide/page/component/coin.dart'; +import 'package:askaide/page/component/column_block.dart'; +import 'package:askaide/page/component/dialog.dart'; +import 'package:askaide/page/component/enhanced_textfield.dart'; +import 'package:askaide/page/component/theme/custom_size.dart'; +import 'package:askaide/page/component/theme/custom_theme.dart'; +import 'package:askaide/page/creative_island/gallery/gallery_item.dart'; +import 'package:askaide/repo/api/quota.dart'; +import 'package:askaide/repo/api_server.dart'; +import 'package:askaide/repo/settings_repo.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localization/flutter_localization.dart'; +import 'package:intl/intl.dart'; + +class AdminUserPage extends StatefulWidget { + final SettingRepository setting; + final int userId; + const AdminUserPage({ + super.key, + required this.setting, + required this.userId, + }); + + @override + State createState() => _AdminUserPageState(); +} + +class _AdminUserPageState extends State { + @override + void initState() { + context.read().add(UserLoadEvent(widget.userId)); + context.read().add(UserQuotaLoadEvent(widget.userId)); + super.initState(); + } + + @override + Widget build(BuildContext context) { + final customColors = Theme.of(context).extension()!; + + return Scaffold( + appBar: AppBar( + toolbarHeight: CustomSize.toolbarHeight, + title: const Text( + '用户详情', + style: TextStyle(fontSize: CustomSize.appBarTitleSize), + ), + centerTitle: true, + actions: [ + IconButton( + icon: const Icon(Icons.card_giftcard_outlined), + tooltip: '赠送智慧果', + onPressed: () { + int sendCount = 1000; + String? note; + int validDays = 365; + + openDialog( + context, + builder: Builder(builder: (context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + '赠送智慧果', + style: TextStyle(fontSize: 18), + ), + const SizedBox(height: 10), + EnhancedTextField( + labelText: '数量', + customColors: customColors, + textAlignVertical: TextAlignVertical.top, + showCounter: false, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly + ], + suffixIcon: Container( + width: 110, + alignment: Alignment.center, + child: Text( + '个智慧果', + style: TextStyle( + color: customColors.weakTextColor, + fontSize: 12, + ), + ), + ), + onChanged: (value) { + sendCount = int.tryParse(value) ?? 0; + }, + initValue: sendCount.toString(), + ), + const SizedBox(height: 10), + EnhancedTextField( + labelText: '有效期', + customColors: customColors, + textAlignVertical: TextAlignVertical.top, + showCounter: false, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly + ], + suffixIcon: Container( + width: 110, + alignment: Alignment.center, + child: Text( + '天', + style: TextStyle( + color: customColors.weakTextColor, + fontSize: 12, + ), + ), + ), + onChanged: (value) { + validDays = int.tryParse(value) ?? 0; + }, + initValue: validDays.toString(), + ), + const SizedBox(height: 10), + EnhancedTextField( + labelText: '备注', + customColors: customColors, + textAlignVertical: TextAlignVertical.top, + showCounter: false, + hintText: '可选', + onChanged: (value) { + note = value; + }, + initValue: note, + ), + ], + ); + }), + onSubmit: () { + if (sendCount <= 0) { + showErrorMessage('数量必须大于 0'); + return false; + } + + if (validDays <= 0) { + showErrorMessage('有效期必须大于 0'); + return false; + } + + APIServer() + .adminUserQuotaAssign( + userId: widget.userId, + quota: sendCount, + validPeriod: validDays * 24, + note: note, + ) + .then((value) { + showSuccessMessage('赠送成功'); + context + .read() + .add(UserQuotaLoadEvent(widget.userId)); + }).onError( + (error, stackTrace) => + showErrorMessageEnhanced(context, error!), + ); + + return true; + }, + ); + }, + ), + ], + ), + backgroundColor: customColors.chatInputPanelBackground, + body: BackgroundContainer( + setting: widget.setting, + enabled: false, + child: RefreshIndicator( + color: customColors.linkColor, + onRefresh: () async { + context.read().add(UserLoadEvent(widget.userId)); + context.read().add(UserQuotaLoadEvent(widget.userId)); + }, + displacement: 20, + child: SafeArea( + top: false, + child: SingleChildScrollView( + child: Column( + children: [ + BlocConsumer( + listenWhen: (previous, current) => + current is UserOperationResult, + listener: (context, state) { + if (state is UserOperationResult) { + if (state.success) { + showSuccessMessage(state.message ?? + AppLocale.operateSuccess.getString(context)); + context.read().add(UserListLoadEvent()); + } else { + showErrorMessage(state.message ?? + AppLocale.operateFailed.getString(context)); + } + } + }, + buildWhen: (previous, current) => current is UserLoaded, + builder: (context, state) { + if (state is UserLoaded) { + return ColumnBlock( + innerPanding: 10, + padding: const EdgeInsets.all(15), + margin: const EdgeInsets.all(15), + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + SizedBox( + width: double.infinity, + child: Row( + crossAxisAlignment: + CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'ID', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: + customColors.weakTextColor, + ), + ), + const SizedBox(width: 10), + Text( + '${state.user.id}', + style: TextStyle( + fontSize: 12, + color: + customColors.weakTextColor, + ), + maxLines: 5, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(height: 10), + buildTags( + context, customColors, state.user), + ], + ), + ), + buildUserAvatar( + state.user, + radius: BorderRadius.circular(8), + ), + ], + ), + TextItem( + title: '类型', + value: state.user.userType ?? '-', + ), + if (state.user.phone != null && + state.user.phone!.isNotEmpty) + TextItem( + title: '手机号', + value: state.user.phone!, + ), + if (state.user.email != null && + state.user.email!.isNotEmpty) + TextItem( + title: '邮箱', + value: state.user.email!, + ), + if (state.user.realname != null && + state.user.realname!.isNotEmpty) + TextItem( + title: '昵称', + value: state.user.realname!, + ), + if (state.user.invitedBy != null && + state.user.invitedBy! > 0) + TextItem( + title: '邀请人 ID', + value: '${state.user.invitedBy}', + ), + if (state.user.createdAt != null) + TextItem( + title: '注册时间', + value: + state.user.createdAt!.toLocal().toString(), + ), + TextItem( + title: '状态', + value: state.user.status ?? '-', + ), + ], + ); + } + + return Center( + child: CircularProgressIndicator( + color: customColors.linkColor, + ), + ); + }, + ), + BlocBuilder( + buildWhen: (previous, current) => + current is UserQuotaLoaded, + builder: (context, state) { + if (state is UserQuotaLoaded) { + return ColumnBlock( + innerPanding: 10, + padding: const EdgeInsets.all(15), + margin: const EdgeInsets.only( + left: 15, + right: 15, + bottom: 15, + ), + children: [ + TextItem( + title: '剩余智慧果', + value: state.quota.total.toString(), + ), + buildPaymentDetails(customColors, state) + ], + ); + } + + return Center( + child: CircularProgressIndicator( + color: customColors.linkColor, + ), + ); + }, + ), + ], + ), + ), + ), + ), + ), + ); + } + + // 购买历史记录 + Widget buildPaymentDetails( + CustomColors customColors, + UserQuotaLoaded state, + ) { + return SizedBox( + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '充值历史', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: customColors.weakTextColor, + ), + ), + const SizedBox(height: 10), + if (state.quota.details.isEmpty) + const Text('无充值记录') + else + ListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: [ + for (var item in state.quota.details) + Stack( + children: [ + Container( + margin: const EdgeInsets.symmetric(vertical: 6), + padding: const EdgeInsets.only( + top: 20, + bottom: 10, + left: 16, + right: 16, + ), + decoration: BoxDecoration( + color: customColors.paymentItemBackgroundColor, + borderRadius: BorderRadius.circular(10), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + (item.note == null || item.note == '') + ? '购买' + : item.note!, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 5), + Text( + DateFormat( + 'yyyy/MM/dd HH:mm', + ).format(item.createdAt.toLocal()), + textScaleFactor: 0.8, + style: TextStyle( + color: Colors.grey[600], + ), + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Coin( + count: item.quota, + color: Colors.amber, + withAddPrefix: true, + fontWeight: FontWeight.w500, + ), + Text( + '${DateFormat('yyyy/MM/dd').format(item.periodEndAt.toLocal())} 过期', + textScaleFactor: 0.7, + ), + ], + ), + ], + ), + ], + ), + ), + _buildTagForItem(item), + ], + ) + ], + ), + ], + ), + ); + } + + Widget _buildTagForItem(QuotaDetail item) { + if (item.rest <= 0) { + return _buildTag(AppLocale.usedUp.getString(context), Colors.orange); + } + + if (item.expired) { + return _buildTag(AppLocale.expired.getString(context), Colors.grey[600]!); + } + + return const SizedBox(); + } + + Widget _buildTag(String text, Color color) { + return Positioned( + right: 1, + top: 7, + child: Container( + decoration: BoxDecoration( + color: color, + borderRadius: const BorderRadius.only( + topRight: Radius.circular(9), + bottomLeft: Radius.circular(9), + ), + ), + padding: const EdgeInsets.symmetric( + horizontal: 5, + vertical: 2, + ), + child: Text( + text, + textScaleFactor: 0.6, + style: const TextStyle(color: Colors.white70), + ), + ), + ); + } +} diff --git a/lib/page/admin/users.dart b/lib/page/admin/users.dart new file mode 100644 index 00000000..3aa291e8 --- /dev/null +++ b/lib/page/admin/users.dart @@ -0,0 +1,400 @@ +import 'package:askaide/bloc/user_bloc.dart'; +import 'package:askaide/helper/constant.dart'; +import 'package:askaide/helper/helper.dart'; +import 'package:askaide/helper/image.dart'; +import 'package:askaide/lang/lang.dart'; +import 'package:askaide/page/component/background_container.dart'; +import 'package:askaide/page/component/dialog.dart'; +import 'package:askaide/page/component/pagination.dart'; +import 'package:askaide/page/component/theme/custom_size.dart'; +import 'package:askaide/page/component/theme/custom_theme.dart'; +import 'package:askaide/repo/api/admin/users.dart'; +import 'package:askaide/repo/settings_repo.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_initicon/flutter_initicon.dart'; +import 'package:flutter_localization/flutter_localization.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:go_router/go_router.dart'; + +class AdminUsersPage extends StatefulWidget { + final SettingRepository setting; + const AdminUsersPage({ + super.key, + required this.setting, + }); + + @override + State createState() => _AdminUsersPageState(); +} + +class _AdminUsersPageState extends State { + /// 当前页码 + int page = 1; + + /// 每页数量 + int perPage = 20; + + /// 搜索关键字 + final TextEditingController keywordController = TextEditingController(); + + @override + void initState() { + context.read().add(UserListLoadEvent( + perPage: perPage, + page: page, + keyword: keywordController.text, + )); + super.initState(); + } + + @override + void dispose() { + keywordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final customColors = Theme.of(context).extension()!; + + return Scaffold( + appBar: AppBar( + toolbarHeight: CustomSize.toolbarHeight, + title: const Text( + '用户管理', + style: TextStyle(fontSize: CustomSize.appBarTitleSize), + ), + centerTitle: true, + ), + backgroundColor: customColors.chatInputPanelBackground, + body: BackgroundContainer( + setting: widget.setting, + enabled: false, + child: Column( + children: [ + Container( + padding: const EdgeInsets.only(left: 10, right: 10, bottom: 5), + child: TextField( + controller: keywordController, + textAlignVertical: TextAlignVertical.center, + style: TextStyle(color: customColors.dialogDefaultTextColor), + decoration: InputDecoration( + hintText: AppLocale.search.getString(context), + hintStyle: TextStyle( + color: customColors.dialogDefaultTextColor, + ), + prefixIcon: Icon( + Icons.search, + color: customColors.dialogDefaultTextColor, + ), + isDense: true, + border: InputBorder.none, + ), + onEditingComplete: () { + context.read().add(UserListLoadEvent( + perPage: perPage, + page: page, + keyword: keywordController.text, + )); + }, + ), + ), + Expanded( + child: RefreshIndicator( + color: customColors.linkColor, + onRefresh: () async { + context.read().add(UserListLoadEvent( + perPage: perPage, + page: page, + keyword: keywordController.text, + )); + }, + displacement: 20, + child: BlocConsumer( + listener: (context, state) { + if (state is UserOperationResult) { + if (state.success) { + showSuccessMessage(state.message ?? + AppLocale.operateSuccess.getString(context)); + context.read().add(UserListLoadEvent()); + } else { + showErrorMessage(state.message ?? + AppLocale.operateFailed.getString(context)); + } + } + + if (state is UsersLoaded) { + setState(() { + page = state.users.page; + perPage = state.users.perPage; + }); + } + }, + buildWhen: (previous, current) => current is UsersLoaded, + builder: (context, state) { + if (state is UsersLoaded) { + return SafeArea( + top: false, + child: Column( + children: [ + Expanded( + child: ListView.builder( + padding: const EdgeInsets.all(5), + itemCount: state.users.data.length, + itemBuilder: (context, index) { + return buildUserInfo( + context, + customColors, + state.users.data[index], + ); + }, + ), + ), + if (state.users.lastPage != null && + state.users.lastPage! > 1) + Container( + padding: const EdgeInsets.all(10), + child: Pagination( + numOfPages: state.users.lastPage ?? 1, + selectedPage: page, + pagesVisible: 5, + onPageChanged: (selected) { + context + .read() + .add(UserListLoadEvent( + perPage: perPage, + page: selected, + keyword: keywordController.text, + )); + }, + ), + ), + ], + ), + ); + } + + return const Center( + child: CircularProgressIndicator(), + ); + }, + ), + ), + ), + ], + ), + ), + ); + } + + Widget buildUserInfo( + BuildContext context, + CustomColors customColors, + AdminUser user, + ) { + return Container( + margin: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(customColors.borderRadius ?? 8), + ), + child: Slidable( + child: Material( + borderRadius: + BorderRadius.all(Radius.circular(customColors.borderRadius ?? 8)), + color: customColors.columnBlockBackgroundColor, + child: InkWell( + borderRadius: BorderRadius.all( + Radius.circular(customColors.borderRadius ?? 8)), + onTap: () { + context.push('/admin/users/${user.id}'); + }, + child: Stack( + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // 头像 + Stack( + children: [ + buildUserAvatar(user), + Positioned( + bottom: 0, + width: 70, + child: ClipRRect( + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(8), + ), + child: Container( + color: Colors.black.withAlpha(100), + padding: const EdgeInsets.symmetric(vertical: 2), + child: Center( + child: Text( + '#${user.id}', + style: const TextStyle( + fontSize: 10, + overflow: TextOverflow.ellipsis, + color: Colors.white, + ), + ), + ), + ), + ), + ), + ], + ), + // 名称 + Expanded( + child: Container( + padding: const EdgeInsets.all(15), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + user.displayName, + style: const TextStyle( + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(height: 5), + buildTags(context, customColors, user), + ], + ), + ), + ), + ], + ), + Positioned( + right: 0, + top: 0, + child: Container( + padding: const EdgeInsets.all(10), + width: MediaQuery.of(context).size.width / 2.0, + alignment: Alignment.centerRight, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${user.userType}', + style: TextStyle( + fontSize: 10, + overflow: TextOverflow.ellipsis, + color: customColors.weakTextColor, + ), + ), + ], + ), + ), + ), + Positioned( + right: 0, + bottom: 0, + child: Container( + padding: const EdgeInsets.all(10), + width: MediaQuery.of(context).size.width / 2.0, + alignment: Alignment.centerRight, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + user.createdAt != null + ? humanTime(user.createdAt) + : '', + style: TextStyle( + fontSize: 10, + overflow: TextOverflow.ellipsis, + color: customColors.weakTextColor, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +Widget buildUserAvatar( + AdminUser user, { + BorderRadius radius = const BorderRadius.only( + topLeft: Radius.circular(8), + bottomLeft: Radius.circular(8), + ), +}) { + if (user.avatar != null && user.avatar!.startsWith('http')) { + return SizedBox( + width: 70, + height: 70, + child: ClipRRect( + borderRadius: radius, + child: CachedNetworkImage( + imageUrl: imageURL(user.avatar!, qiniuImageTypeAvatar), + fit: BoxFit.fill, + ), + ), + ); + } + + return Initicon( + text: user.displayName.split('、').join(' '), + size: 70, + backgroundColor: Colors.grey.withAlpha(100), + borderRadius: radius, + ); +} + +Widget buildTags( + BuildContext context, CustomColors customColors, AdminUser user) { + final tags = []; + + if (user.email != null && user.email!.isNotEmpty) { + tags.add(buildTag(context, customColors, '邮箱')); + } + + if (user.phone != null && user.phone!.isNotEmpty) { + tags.add(buildTag(context, customColors, '手机')); + } + + if (user.unionId != null && user.unionId!.isNotEmpty) { + tags.add(buildTag(context, customColors, '微信')); + } + + if (user.appleUid != null && user.appleUid!.isNotEmpty) { + tags.add(buildTag(context, customColors, 'Apple')); + } + + return Wrap( + spacing: 5, + runSpacing: 5, + children: tags, + ); +} + +Widget buildTag(BuildContext context, CustomColors customColors, String s) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 5, + vertical: 2, + ), + decoration: BoxDecoration( + color: customColors.tagsBackground, + borderRadius: BorderRadius.circular(5), + ), + child: Text( + s, + style: TextStyle( + fontSize: 10, + color: customColors.tagsText, + ), + ), + ); +} diff --git a/lib/page/balance/free_statistics.dart b/lib/page/balance/free_statistics.dart index 5c41309c..6fb63366 100644 --- a/lib/page/balance/free_statistics.dart +++ b/lib/page/balance/free_statistics.dart @@ -55,139 +55,143 @@ class _FreeStatisticsPageState extends State { onRefresh: () async { context.read().add(FreeCountReloadAllEvent()); }, - child: SizedBox( - height: double.infinity, - child: SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: BlocConsumer( - listenWhen: (previous, current) => - current is FreeCountLoadedState, - listener: (BuildContext context, FreeCountState state) { - if (state is FreeCountLoadedState) { - if (state.needSignin) { - showBeautyDialog( - context, - type: QuickAlertType.warning, - text: '免费模型需登录账号后使用', - confirmBtnText: '去登录', - onConfirmBtnTap: () { - context.pop(); - context.go('/login'); - }, - showCancelBtn: true, - ); - } - } - }, - builder: (context, state) { - if (state is FreeCountLoadedState) { - if (state.counts.isEmpty) { - return const Padding( - padding: EdgeInsets.symmetric(horizontal: 10), - child: Center( - child: MessageBox( - message: '当前无可用的免费模型。', - type: MessageBoxType.warning, - ), - ), - ); + child: SafeArea( + child: SizedBox( + height: double.infinity, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: BlocConsumer( + listenWhen: (previous, current) => + current is FreeCountLoadedState, + listener: (BuildContext context, FreeCountState state) { + if (state is FreeCountLoadedState) { + if (state.needSignin) { + showBeautyDialog( + context, + type: QuickAlertType.warning, + text: '免费模型需登录账号后使用', + confirmBtnText: '去登录', + onConfirmBtnTap: () { + context.pop(); + context.go('/login'); + }, + showCancelBtn: true, + ); + } } - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 10), - child: Column( - children: [ - const MessageBox( - message: '以下模型享有每日免费额度。', - type: MessageBoxType.info, + }, + builder: (context, state) { + if (state is FreeCountLoadedState) { + if (state.counts.isEmpty) { + return const Padding( + padding: EdgeInsets.symmetric(horizontal: 10), + child: Center( + child: MessageBox( + message: '当前无可用的免费模型。', + type: MessageBoxType.warning, + ), ), - const SizedBox(height: 10), - ColumnBlock( - innerPanding: 5, - children: [ - const Padding( - padding: EdgeInsets.symmetric(vertical: 5), - child: Row( - children: [ - Expanded( - child: Text( - '模型', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - ), - )), - Row( - children: [ - Text( - '今日可用', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - ), - ), - ], - ), - ], - ), - ), - ...state.counts.map((e) { - return Padding( - padding: - const EdgeInsets.symmetric(vertical: 5), + ); + } + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Column( + children: [ + const MessageBox( + message: '以下模型享有每日免费额度。', + type: MessageBoxType.info, + ), + const SizedBox(height: 10), + ColumnBlock( + innerPanding: 5, + children: [ + const Padding( + padding: EdgeInsets.symmetric(vertical: 5), child: Row( children: [ Expanded( - child: Row( - children: [ - Text( - e.name, - style: const TextStyle( - fontSize: 14, - ), - ), - if (e.info != null && e.info != '') - const SizedBox(width: 5), - if (e.info != null && e.info != '') - InkWell( - onTap: () { - showBeautyDialog( - context, - type: QuickAlertType.info, - text: e.info ?? '', - confirmBtnText: AppLocale - .gotIt - .getString(context), - showCancelBtn: false, - ); - }, - child: Icon( - Icons.help_outline, - size: 16, - color: customColors - .weakLinkColor - ?.withAlpha(150), - ), - ), - ], + child: Text( + '模型', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, ), - ), - buildLeftCountWidget( - leftCount: e.leftCount, - maxCount: e.maxCount, + )), + Row( + children: [ + Text( + '今日可用', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ], ), ], ), - ); - }), - ], - ), - ], - ), - ); - } + ), + ...state.counts.map((e) { + return Padding( + padding: + const EdgeInsets.symmetric(vertical: 5), + child: Row( + children: [ + Expanded( + child: Row( + children: [ + Text( + e.name, + style: const TextStyle( + fontSize: 14, + ), + ), + if (e.info != null && + e.info != '') + const SizedBox(width: 5), + if (e.info != null && + e.info != '') + InkWell( + onTap: () { + showBeautyDialog( + context, + type: QuickAlertType.info, + text: e.info ?? '', + confirmBtnText: AppLocale + .gotIt + .getString(context), + showCancelBtn: false, + ); + }, + child: Icon( + Icons.help_outline, + size: 16, + color: customColors + .weakLinkColor + ?.withAlpha(150), + ), + ), + ], + ), + ), + buildLeftCountWidget( + leftCount: e.leftCount, + maxCount: e.maxCount, + ), + ], + ), + ); + }), + ], + ), + ], + ), + ); + } - return const Center(child: LoadingIndicator()); - }, + return const Center(child: LoadingIndicator()); + }, + ), ), ), ), diff --git a/lib/page/balance/payment.dart b/lib/page/balance/payment.dart index 48dcac6d..64885acc 100644 --- a/lib/page/balance/payment.dart +++ b/lib/page/balance/payment.dart @@ -15,18 +15,23 @@ import 'package:askaide/page/component/loading.dart'; import 'package:askaide/page/component/dialog.dart'; import 'package:askaide/page/component/theme/custom_size.dart'; import 'package:askaide/page/component/theme/custom_theme.dart'; +import 'package:askaide/repo/api/payment.dart'; import 'package:askaide/repo/api_server.dart'; import 'package:askaide/repo/settings_repo.dart'; import 'package:bot_toast/bot_toast.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localization/flutter_localization.dart'; +import 'package:flutter_stripe/flutter_stripe.dart'; import 'package:go_router/go_router.dart'; import 'package:in_app_purchase/in_app_purchase.dart'; import 'package:quickalert/models/quickalert_type.dart'; import 'package:tobias/tobias.dart'; import 'package:url_launcher/url_launcher_string.dart'; +import 'web/payment_element.dart' + if (dart.library.js) 'web/payment_element_web.dart'; + class PaymentScreen extends StatefulWidget { final SettingRepository setting; const PaymentScreen({super.key, required this.setting}); @@ -182,6 +187,19 @@ class _PaymentScreenState extends State { fontSize: CustomSize.appBarTitleSize, ), ), + leading: IconButton( + icon: Icon( + Icons.arrow_back_ios, + color: customColors.weakLinkColor, + ), + onPressed: () { + if (context.canPop()) { + context.pop(); + } else { + context.go('/setting'); + } + }, + ), actions: [ if (Ability().isUserLogon()) TextButton( @@ -312,53 +330,26 @@ class _PaymentScreenState extends State { return; } - if (PlatformTool.isIOS() || - PlatformTool.isAndroid() || - PlatformTool.isMacOS()) { + // 根据当前平台不通,调用不同的支付方式 + if (PlatformTool.isAndroid()) { + handlePaymentForAndroid( + state, + context, + customColors, + ); + } else if (PlatformTool.isIOS()) { _startPaymentLoading(); try { - if (PlatformTool.isAndroid()) { - await createAppAlipay(); - } else if (PlatformTool.isIOS()) { - await createAppApplePay(); - } else { - await createWebOrWapAlipay(source: 'web'); - } + await createAppApplePay(); } catch (e) { _closePaymentLoading(); + // ignore: use_build_context_synchronously showErrorMessage(resolveError(context, e)); } + } else if (PlatformTool.isWeb()) { + handlePaymentForWeb(state, context, customColors); } else { - // openConfirmDialog( - // context, - // '当前终端在线支付暂不可用,预计最晚 2023 年 10 月 15 日恢复,如需充值,请使用移动端 APP(支持 Android 手机、Apple 手机)。', - // () { - // launchUrlString( - // 'https://aidea.aicode.cc', - // mode: LaunchMode.externalApplication, - // ); - // }, - // confirmText: '前往下载移动端 APP', - // ); - openListSelectDialog( - context, - [ - SelectorItem(const Text('支付宝电脑端(扫码支付)'), 'web'), - SelectorItem(const Text('支付宝手机端'), 'wap'), - ], - (value) { - _startPaymentLoading(); - createWebOrWapAlipay(source: value.value) - .onError((error, stackTrace) { - _closePaymentLoading(); - showErrorMessageEnhanced(context, error!); - }); - - return true; - }, - title: '请选择支付方式', - heightFactor: 0.3, - ); + handlePaymentForPC(state, context, customColors); } }, ), @@ -401,6 +392,214 @@ class _PaymentScreenState extends State { ); } + void handlePaymentForWeb(PaymentAppleProductsLoaded state, + BuildContext context, CustomColors customColors) { + // openConfirmDialog( + // context, + // '当前终端在线支付暂不可用,预计最晚 2023 年 10 月 15 日恢复,如需充值,请使用移动端 APP(支持 Android 手机、Apple 手机)。', + // () { + // launchUrlString( + // 'https://aidea.aicode.cc', + // mode: LaunchMode.externalApplication, + // ); + // }, + // confirmText: '前往下载移动端 APP', + // ) + + final localProduct = + state.localProducts.firstWhere((e) => e.id == selectedProduct!.id); + + final enableStripe = Ability().enableStripe && localProduct.supportStripe; + + openListSelectDialog( + context, + [ + SelectorItem( + const PaymentMethodItem( + title: Text('支付宝扫码'), + image: 'assets/zhifubao.png', + ), + 'web', + ), + SelectorItem( + const PaymentMethodItem( + title: Text('支付宝手机版'), + image: 'assets/zhifubao.png', + ), + 'wap', + ), + if (enableStripe) + SelectorItem( + PaymentMethodItem( + title: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Stripe'), + const SizedBox(width: 5), + Text( + '(${localProduct.retailPriceUSDText})', + style: TextStyle( + color: + customColors.paymentItemTitleColor?.withOpacity(0.5), + fontSize: 12, + ), + ), + ], + ), + image: 'assets/stripe.png', + ), + 'stripe', + ), + ], + (value) { + _startPaymentLoading(); + if (value.value != 'stripe') { + createWebOrWapAlipay(source: value.value) + .onError((error, stackTrace) { + _closePaymentLoading(); + showErrorMessageEnhanced(context, error!); + }); + } else { + createStripePayment(localProduct); + } + + return true; + }, + title: '请选择支付方式', + heightFactor: 0.4, + ); + } + + /// 处理 PC 端支付 + void handlePaymentForPC( + PaymentAppleProductsLoaded state, + BuildContext context, + CustomColors customColors, + ) async { + final localProduct = + state.localProducts.firstWhere((e) => e.id == selectedProduct!.id); + final enableStripe = Ability().enableStripe && localProduct.supportStripe; + openListSelectDialog( + context, + [ + // SelectorItem( + // const PaymentMethodItem( + // title: Text('微信支付'), + // image: 'assets/wechat-pay.png', + // ), + // 'alipay', + // ), + if (Ability().enableOtherPay) + SelectorItem( + const PaymentMethodItem( + title: Text('支付宝'), + image: 'assets/zhifubao.png', + ), + 'alipay', + ), + if (enableStripe) + SelectorItem( + PaymentMethodItem( + title: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Stripe'), + const SizedBox(width: 5), + Text( + '(${localProduct.retailPriceUSDText})', + style: TextStyle( + color: + customColors.paymentItemTitleColor?.withOpacity(0.5), + fontSize: 12, + ), + ), + ], + ), + image: 'assets/stripe.png', + ), + 'stripe', + ), + ], + (value) { + _startPaymentLoading(); + + if (value.value == 'alipay') { + createWebOrWapAlipay(source: 'web').onError((error, stackTrace) { + _closePaymentLoading(); + showErrorMessageEnhanced(context, error!); + }); + } else { + createStripePayment(localProduct); + } + + return true; + }, + title: '请选择支付方式', + heightFactor: 0.4, + ); + } + + void handlePaymentForAndroid( + PaymentAppleProductsLoaded state, + BuildContext context, + CustomColors customColors, + ) { + final localProduct = + state.localProducts.firstWhere((e) => e.id == selectedProduct!.id); + final enableStripe = Ability().enableStripe && localProduct.supportStripe; + openListSelectDialog( + context, + [ + if (Ability().enableOtherPay) + SelectorItem( + const PaymentMethodItem( + title: Text('支付宝'), + image: 'assets/zhifubao.png', + ), + 'alipay', + ), + if (enableStripe) + SelectorItem( + PaymentMethodItem( + title: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Stripe'), + const SizedBox(width: 5), + Text( + '(${localProduct.retailPriceUSDText})', + style: TextStyle( + color: + customColors.paymentItemTitleColor?.withOpacity(0.5), + fontSize: 12, + ), + ), + ], + ), + image: 'assets/stripe.png', + ), + 'stripe', + ), + ], + (value) { + _startPaymentLoading(); + + if (value.value == 'alipay') { + createAppAlipay().onError((error, stackTrace) { + _closePaymentLoading(); + showErrorMessageEnhanced(context, error!); + }); + } else { + createStripePayment(localProduct); + } + + return true; + }, + title: '请选择支付方式', + heightFactor: 0.3, + ); + } + /// 创建苹果应用内支付 Future createAppApplePay() async { // 创建支付,服务端保存支付信息,创建支付订单 @@ -448,12 +647,14 @@ class _PaymentScreenState extends State { _closePaymentLoading(); } catch (e) { _closePaymentLoading(); + // ignore: use_build_context_synchronously showErrorMessage(resolveError(context, e)); } }); } } catch (e) { _closePaymentLoading(); + // ignore: use_build_context_synchronously showErrorMessage(resolveError(context, e)); } }, @@ -463,6 +664,181 @@ class _PaymentScreenState extends State { }); } + /// 获取当前支付来源参数 + String paymentSource() { + if (PlatformTool.isWeb()) { + return 'web'; + } else if (PlatformTool.isIOS() || PlatformTool.isAndroid()) { + return 'app'; + } + return 'pc'; + } + + /// 创建 Stripe 支付 + Future createStripePayment(PaymentProduct product) async { + try { + final created = await APIServer().createStripePaymentSheet( + productId: product.id, + source: paymentSource(), + ); + paymentId = created.paymentId; + + if (PlatformTool.isWeb() || + PlatformTool.isAndroid() || + PlatformTool.isIOS()) { + Stripe.publishableKey = created.publishableKey; + Stripe.urlScheme = 'flutterstripe'; + + await Stripe.instance.applySettings(); + } + + if (PlatformTool.isWeb()) { + Navigator.push( + // ignore: use_build_context_synchronously + context, + MaterialPageRoute( + fullscreenDialog: true, + builder: (context) { + return Scaffold( + appBar: AppBar(), + body: SafeArea( + child: Column( + children: [ + Expanded( + child: Container( + padding: const EdgeInsets.all(15), + child: Builder( + builder: (context) { + return PlatformPaymentElement( + created.paymentIntent, + ); + }, + ), + ), + ), + Container( + padding: const EdgeInsets.all(15), + child: EnhancedButton( + title: '确定付款(${product.retailPriceUSDText})', + onPressed: () async { + final cancel = BotToast.showCustomLoading( + toastBuilder: (cancel) { + return LoadingIndicator( + message: AppLocale.processingWait + .getString(context), + ); + }, + allowClick: false, + duration: const Duration(seconds: 120), + ); + + try { + await pay(created.paymentId); + } catch (e) { + Logger.instance.e('支付失败:$e'); + // ignore: use_build_context_synchronously + showErrorMessageEnhanced(context, '请填写完整的支付信息'); + } finally { + cancel(); + } + }, + ), + ) + ], + ), + ), + ); + }, + ), + ); + } else if (PlatformTool.isAndroid() || PlatformTool.isIOS()) { + // 调起 Stripe 支付 + await Stripe.instance.initPaymentSheet( + paymentSheetParameters: SetupPaymentSheetParameters( + paymentIntentClientSecret: created.paymentIntent, + merchantDisplayName: 'AIdea', + customerId: created.customer, + customerEphemeralKeySecret: created.ephemeralKey, + returnURL: 'flutterstripe://redirect', + // ignore: use_build_context_synchronously + style: Ability().themeMode == 'dark' + ? ThemeMode.dark + : ThemeMode.light, + ), + ); + + // 确认支付 + await Stripe.instance.presentPaymentSheet(); + + showSuccessMessage('购买成功'); + } else { + // PC 端支付,发起 Web 页面 + if (created.proxyUrl == '') { + showErrorMessage('支付失败:未能获取支付链接'); + return; + } + + Logger.instance.d(created.proxyUrl); + + launchUrlString( + created.proxyUrl, + mode: LaunchMode.externalApplication, + ).then((value) { + _closePaymentLoading(); + openConfirmDialog( + context, + '请确认支付是否已完成', + () async { + _startPaymentLoading(); + try { + final resp = + await APIServer().queryPaymentStatus(created.paymentId); + if (resp.success) { + showSuccessMessage(resp.note ?? '支付成功'); + _closePaymentLoading(); + } else { + // 支付失败,延迟 5s 再次查询支付状态 + await Future.delayed(const Duration(seconds: 5), () async { + try { + final value = await APIServer() + .queryPaymentStatus(created.paymentId); + + if (value.success) { + showSuccessMessage(value.note ?? '支付成功'); + } else { + showErrorMessage('支付未完成,我们接收到的状态为:${value.note}'); + } + _closePaymentLoading(); + } catch (e) { + _closePaymentLoading(); + // ignore: use_build_context_synchronously + showErrorMessage(resolveError(context, e)); + } + }); + } + } catch (e) { + _closePaymentLoading(); + // ignore: use_build_context_synchronously + showErrorMessage(resolveError(context, e)); + } + }, + confirmText: '已完成支付', + cancelText: '支付遇到问题,稍后继续', + ); + }); + } + } on Exception catch (e) { + if (e is StripeException) { + showErrorMessage('支付失败:${e.error.localizedMessage}'); + } else { + // ignore: use_build_context_synchronously + showErrorMessageEnhanced(context, e); + } + } finally { + _closePaymentLoading(); + } + } + /// 创建其它付款(App) Future createAppAlipay() async { // 其它支付 @@ -514,3 +890,32 @@ class _PaymentScreenState extends State { print("-----------------"); } } + +/// 支付方式选择项 +class PaymentMethodItem extends StatelessWidget { + final Widget title; + final String? image; + + const PaymentMethodItem({super.key, required this.title, this.image}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (image != null) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(5), + child: Image.asset( + image!, + width: 20, + height: 20, + ), + ), + const SizedBox(width: 10), + ], + title, + ], + ); + } +} diff --git a/lib/page/balance/price_block.dart b/lib/page/balance/price_block.dart index 3591414c..fefe4f03 100644 --- a/lib/page/balance/price_block.dart +++ b/lib/page/balance/price_block.dart @@ -72,31 +72,13 @@ class PriceBlock extends StatelessWidget { const SizedBox(height: 10), loading ? const Text('加载中...') - : Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (detail.price != product.retailPriceText) - Text( - product.retailPriceText, - style: TextStyle( - fontSize: 13, - decoration: TextDecoration.lineThrough, - color: customColors.paymentItemDescriptionColor - ?.withAlpha(200), - ), - ), - if (detail.price != product.retailPriceText) - const SizedBox(width: 10), - Text( - detail.price, - style: TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - color: customColors.linkColor, - ), - ), - ], + : Text( + detail.price, + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: customColors.linkColor, + ), ), ], ), diff --git a/lib/page/balance/web/payment_element.dart b/lib/page/balance/web/payment_element.dart new file mode 100644 index 00000000..17a2af2c --- /dev/null +++ b/lib/page/balance/web/payment_element.dart @@ -0,0 +1,21 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_stripe/flutter_stripe.dart'; + +Future pay(String paymentId, {String? action}) async { + throw UnimplementedError(); +} + +void closeWindow() { + throw UnimplementedError(); +} + +class PlatformPaymentElement extends StatelessWidget { + const PlatformPaymentElement(this.clientSecret, {super.key}); + + final String? clientSecret; + + @override + Widget build(BuildContext context) { + return Container(); + } +} diff --git a/lib/page/balance/web/payment_element_web.dart b/lib/page/balance/web/payment_element_web.dart new file mode 100644 index 00000000..0fce1f8d --- /dev/null +++ b/lib/page/balance/web/payment_element_web.dart @@ -0,0 +1,48 @@ +import 'package:askaide/helper/ability.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_stripe_web/flutter_stripe_web.dart'; +import 'dart:html' as html; +import 'package:stripe_js/stripe_api.dart' as js; + +Future pay(String paymentId, {String? action}) async { + final currentUrl = Uri.parse(html.window.location.href); + var href = Uri( + scheme: currentUrl.scheme, + host: currentUrl.host, + port: currentUrl.port, + fragment: '/payment/result?payment_id=$paymentId&action=$action', + ).toString(); + + return await WebStripe.instance.confirmPaymentElement( + ConfirmPaymentElementOptions( + confirmParams: ConfirmPaymentParams( + return_url: href, + ), + ), + ); +} + +void closeWindow() { + html.window.close(); +} + +class PlatformPaymentElement extends StatelessWidget { + const PlatformPaymentElement(this.clientSecret, {super.key}); + + final String? clientSecret; + + @override + Widget build(BuildContext context) { + return PaymentElement( + autofocus: true, + enablePostalCode: true, + onCardChanged: (_) {}, + clientSecret: clientSecret ?? '', + appearance: js.ElementAppearance( + theme: Ability().themeMode == 'dark' + ? js.ElementTheme.night + : js.ElementTheme.stripe, + ), + ); + } +} diff --git a/lib/page/balance/web_payment_proxy.dart b/lib/page/balance/web_payment_proxy.dart new file mode 100644 index 00000000..e454f713 --- /dev/null +++ b/lib/page/balance/web_payment_proxy.dart @@ -0,0 +1,134 @@ +import 'package:askaide/helper/logger.dart'; +import 'package:askaide/lang/lang.dart'; +import 'package:askaide/page/component/background_container.dart'; +import 'package:askaide/page/component/dialog.dart'; +import 'package:askaide/page/component/enhanced_button.dart'; +import 'package:askaide/page/component/loading.dart'; +import 'package:askaide/page/component/theme/custom_size.dart'; +import 'package:askaide/page/component/theme/custom_theme.dart'; +import 'package:askaide/repo/settings_repo.dart'; +import 'package:bot_toast/bot_toast.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localization/flutter_localization.dart'; +import 'package:flutter_stripe/flutter_stripe.dart'; + +import 'web/payment_element.dart' + if (dart.library.js) 'web/payment_element_web.dart'; + +class WebPaymentProxy extends StatefulWidget { + final SettingRepository setting; + final String paymentId; + final String paymentIntent; + final String price; + final String publishableKey; + final String? finishAction; + + const WebPaymentProxy({ + super.key, + required this.setting, + required this.paymentId, + required this.paymentIntent, + required this.price, + required this.publishableKey, + this.finishAction, + }); + + @override + State createState() => _WebPaymentProxyState(); +} + +class _WebPaymentProxyState extends State { + @override + void initState() { + super.initState(); + + Stripe.publishableKey = widget.publishableKey; + Stripe.urlScheme = 'flutterstripe'; + } + + @override + Widget build(BuildContext context) { + var customColors = Theme.of(context).extension()!; + return Scaffold( + appBar: AppBar( + toolbarHeight: CustomSize.toolbarHeight, + title: const Text( + '', + style: TextStyle(fontSize: CustomSize.appBarTitleSize), + ), + centerTitle: true, + elevation: 0, + ), + backgroundColor: customColors.backgroundContainerColor, + body: BackgroundContainer( + setting: widget.setting, + enabled: false, + maxWidth: CustomSize.smallWindowSize, + child: Center( + child: FutureBuilder( + future: Stripe.instance.applySettings(), + builder: (context, snapshot) { + if (snapshot.hasError) { + Logger.instance.e('Stripe 初始化失败:${snapshot.error}'); + return Center( + child: Text( + snapshot.error.toString(), + style: const TextStyle(color: Colors.red), + ), + ); + } + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: Container( + padding: const EdgeInsets.all(15), + child: Builder( + builder: (context) { + return PlatformPaymentElement( + widget.paymentIntent, + ); + }, + ), + ), + ), + Container( + padding: const EdgeInsets.all(15), + child: EnhancedButton( + title: '确定付款(${widget.price})', + onPressed: () async { + final cancel = BotToast.showCustomLoading( + toastBuilder: (cancel) { + return LoadingIndicator( + message: + AppLocale.processingWait.getString(context), + ); + }, + allowClick: false, + duration: const Duration(seconds: 120), + ); + + try { + await pay( + widget.paymentId, + action: widget.finishAction, + ); + } catch (e) { + Logger.instance.e('支付失败:$e'); + // ignore: use_build_context_synchronously + showErrorMessageEnhanced(context, '请填写完整的支付信息'); + } finally { + cancel(); + } + }, + ), + ) + ], + ); + }), + ), + ), + ); + } +} diff --git a/lib/page/balance/web_payment_result.dart b/lib/page/balance/web_payment_result.dart new file mode 100644 index 00000000..c07f5003 --- /dev/null +++ b/lib/page/balance/web_payment_result.dart @@ -0,0 +1,125 @@ +import 'package:askaide/page/component/theme/custom_theme.dart'; +import 'package:askaide/repo/api/payment.dart'; +import 'package:askaide/repo/api_server.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'web/payment_element.dart' + if (dart.library.js) 'web/payment_element_web.dart'; + +class WebPaymentResult extends StatefulWidget { + final String paymentId; + final String? action; + const WebPaymentResult({ + super.key, + required this.paymentId, + this.action, + }); + + @override + State createState() => _WebPaymentResultState(); +} + +class _WebPaymentResultState extends State { + PaymentStatus? paymentStatus; + DateTime startTime = DateTime.now(); + + @override + void initState() { + super.initState(); + + updatePaymentStatus(); + } + + updatePaymentStatus() { + if (!context.mounted) { + return; + } + + if (DateTime.now().difference(startTime).inSeconds > 60) { + setState(() { + paymentStatus = PaymentStatus(false, note: '查询超时'); + }); + return; + } + + APIServer().queryPaymentStatus(widget.paymentId).then((value) { + if (!value.success) { + Future.delayed(const Duration(seconds: 3), () { + if (context.mounted) { + updatePaymentStatus(); + } + }); + } else { + setState(() { + paymentStatus = value; + }); + } + }); + } + + @override + Widget build(BuildContext context) { + var customColors = Theme.of(context).extension()!; + return Scaffold( + appBar: AppBar( + title: const Text('支付结果'), + leading: IconButton( + icon: Icon( + Icons.close, + color: customColors.weakLinkColor, + ), + onPressed: () { + if (widget.action != null && widget.action == 'close') { + closeWindow(); + } else { + if (context.canPop()) { + context.pop(); + } else { + context.go('/payment'); + } + } + }, + ), + ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: buildResult(), + ), + ), + ); + } + + List buildResult() { + if (paymentStatus == null) { + return const [ + CircularProgressIndicator(), + SizedBox(height: 20), + Text('正在查询支付结果'), + ]; + } + + if (!paymentStatus!.success) { + return [ + const Icon( + Icons.error, + color: Colors.red, + size: 100, + ), + Text(paymentStatus!.note ?? '支付失败'), + ]; + } + + return [ + const Icon( + Icons.check_circle, + color: Colors.green, + size: 100, + ), + const Text( + '支付成功', + style: TextStyle(fontSize: 24), + ), + ]; + } +} diff --git a/lib/page/chat/component/model_switcher.dart b/lib/page/chat/component/model_switcher.dart new file mode 100644 index 00000000..1f8f0455 --- /dev/null +++ b/lib/page/chat/component/model_switcher.dart @@ -0,0 +1,55 @@ +import 'package:askaide/helper/haptic_feedback.dart'; +import 'package:askaide/page/chat/room_create.dart'; +import 'package:askaide/page/component/random_avatar.dart'; +import 'package:askaide/page/component/theme/custom_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:askaide/repo/model/model.dart' as mm; +import 'package:flutter_initicon/flutter_initicon.dart'; + +class ModelSwitcher extends StatelessWidget { + final mm.Model? value; + final Function(mm.Model? selected) onSelected; + + const ModelSwitcher({ + super.key, + required this.onSelected, + this.value, + }); + + @override + Widget build(BuildContext context) { + final customColors = Theme.of(context).extension()!; + + return IconButton( + onPressed: () async { + HapticFeedbackHelper.mediumImpact(); + openSelectModelDialog( + context, + (selected) { + onSelected(selected); + }, + initValue: value?.uid(), + enableClear: true, + title: '选择要切换的对话模型', + ); + }, + icon: value == null + ? const Icon(Icons.smart_toy_outlined) + : value!.avatarUrl == null + ? Initicon( + text: value!.name.split('、').join(' '), + size: 25, + backgroundColor: Colors.grey.withAlpha(100), + borderRadius: BorderRadius.circular(100), + ) + : RemoteAvatar( + avatarUrl: value!.avatarUrl!, + size: 25, + radius: 100, + ), + color: customColors.chatInputPanelText, + splashRadius: 20, + tooltip: '切换对话模型', + ); + } +} diff --git a/lib/page/chat/component/room_item.dart b/lib/page/chat/component/room_item.dart index 7627fb4c..c47736dd 100644 --- a/lib/page/chat/component/room_item.dart +++ b/lib/page/chat/component/room_item.dart @@ -238,6 +238,7 @@ class RoomItem extends StatelessWidget { if (room.avatarUrl != null && room.avatarUrl!.startsWith('http')) { return SizedBox( width: 70, + height: 70, child: ClipRRect( borderRadius: const BorderRadius.only( topLeft: Radius.circular(8), diff --git a/lib/page/chat/component/stop_button.dart b/lib/page/chat/component/stop_button.dart new file mode 100644 index 00000000..f1fb22f4 --- /dev/null +++ b/lib/page/chat/component/stop_button.dart @@ -0,0 +1,35 @@ +import 'package:askaide/page/component/theme/custom_theme.dart'; +import 'package:flutter/material.dart'; + +class StopButton extends StatelessWidget { + final Function()? onPressed; + final String label; + const StopButton({super.key, this.onPressed, required this.label}); + + @override + Widget build(BuildContext context) { + final customColors = Theme.of(context).extension()!; + + return TextButton.icon( + style: ButtonStyle( + // minimumSize: MaterialStateProperty.all(const Size(0, 0)), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + iconColor: const MaterialStatePropertyAll(Colors.red), + backgroundColor: + MaterialStatePropertyAll(customColors.chatInputPanelBackground), + ), + label: Text( + label, + style: TextStyle( + fontSize: 12, + color: customColors.textfieldLabelColor, + ), + ), + icon: const Icon( + Icons.stop_circle_outlined, + size: 13, + ), + onPressed: onPressed, + ); + } +} diff --git a/lib/page/chat/group/edit.dart b/lib/page/chat/group/edit.dart index 9fb3a064..b62b99e3 100644 --- a/lib/page/chat/group/edit.dart +++ b/lib/page/chat/group/edit.dart @@ -238,7 +238,6 @@ class _GroupEditPageState extends State { context.pop(); }, usage: AvatarUsage.room, - randomSeed: randomSeed, defaultAvatarUrl: _avatarUrl, externalAvatarUrls: [ ...avatarPresets, diff --git a/lib/page/chat/home.dart b/lib/page/chat/home.dart index 977138f3..c1b74e2f 100644 --- a/lib/page/chat/home.dart +++ b/lib/page/chat/home.dart @@ -77,20 +77,20 @@ class _HomePageState extends State { List models = [ HomeModelV2( modelId: "openai:gpt-3.5-turbo", - modelName: 'GPT-3.5', + modelName: 'Chat-3.5', type: 'model', id: 'openai:gpt-3.5-turbo', supportVision: false, - name: 'GPT-3.5', + name: 'Chat-3.5', avatarUrl: 'https://ssl.aicode.cc/ai-server/assets/avatar/gpt35.png', ), HomeModelV2( modelId: "openai:gpt-4", - modelName: 'GPT-4', + modelName: 'Chat-4', type: 'model', id: 'openai:gpt-4', supportVision: false, - name: 'GPT-4', + name: 'Chat-4', avatarUrl: 'https://ssl.aicode.cc/ai-server/assets/avatar/gpt4-preview.png', ), @@ -104,11 +104,15 @@ class _HomePageState extends State { /// 促销事件 PromotionEvent? promotionEvent; + /// Maximum height of the chat input box + int inputMaxLines = 6; + /// 用于监听键盘事件,实现回车发送消息,Shift+Enter换行 late final FocusNode _focusNode = FocusNode( - onKey: (node, event) { - if (!event.isShiftPressed && event.logicalKey.keyLabel == 'Enter') { - if (event is RawKeyDownEvent) { + onKeyEvent: (node, event) { + if (!HardwareKeyboard.instance.isShiftPressed && + event.logicalKey.keyLabel == 'Enter') { + if (event is KeyDownEvent) { onSubmit(context, _textController.text.trim()); } @@ -486,10 +490,21 @@ class _HomePageState extends State { ), Expanded( child: EnhancedTextField( + onFocusChange: (hasFocus) { + if (hasFocus) { + setState(() { + inputMaxLines = 15; + }); + } else { + setState(() { + inputMaxLines = 6; + }); + } + }, focusNode: _focusNode, controller: _textController, customColors: customColors, - maxLines: 10, + maxLines: inputMaxLines, minLines: 6, hintText: AppLocale.askMeAnyQuestion.getString(context), @@ -846,7 +861,7 @@ class _HomePageState extends State { matched.leftCount > 0 && matched.maxCount > 0) { return Text( - '今日还可免费畅享 ${matched.leftCount} 次', + '今日还可免费${matched.leftCount}次', style: TextStyle( color: customColors.weakTextColor?.withAlpha(120), fontSize: 11, diff --git a/lib/page/chat/home_chat.dart b/lib/page/chat/home_chat.dart index 3926ca82..2c07d860 100644 --- a/lib/page/chat/home_chat.dart +++ b/lib/page/chat/home_chat.dart @@ -5,9 +5,12 @@ import 'package:askaide/bloc/room_bloc.dart'; import 'package:askaide/helper/ability.dart'; import 'package:askaide/helper/constant.dart'; import 'package:askaide/helper/global_store.dart'; +import 'package:askaide/helper/haptic_feedback.dart'; import 'package:askaide/helper/model.dart'; import 'package:askaide/helper/upload.dart'; import 'package:askaide/lang/lang.dart'; +import 'package:askaide/page/chat/component/model_switcher.dart'; +import 'package:askaide/page/chat/component/stop_button.dart'; import 'package:askaide/page/chat/room_chat.dart'; import 'package:askaide/page/component/audio_player.dart'; import 'package:askaide/page/component/background_container.dart'; @@ -100,6 +103,9 @@ class _HomeChatPageState extends State { // 当前聊天所使用的模型(v2) HomeModelV2? currentModelV2; + /// 当前选择的模型 + mm.Model? tempModel; + @override void initState() { // 设置当前聊天 ID,当没有值时,会在第一个聊天消息发送后自动设置新值 @@ -200,17 +206,21 @@ class _HomeChatPageState extends State { if (state.room.model.startsWith('v2@')) { if (currentModelV2 != null && currentModelV2!.modelId != null) { // 加载免费使用次数 + if (tempModel == null) { + // ignore: use_build_context_synchronously + context.read().add(FreeCountReloadEvent( + model: currentModelV2!.modelId!, + )); + } + } + } else { + // 加载免费使用次数 + if (tempModel == null) { // ignore: use_build_context_synchronously context.read().add(FreeCountReloadEvent( - model: currentModelV2!.modelId!, + model: widget.model ?? state.room.model, )); } - } else { - // 加载免费使用次数 - // ignore: use_build_context_synchronously - context.read().add(FreeCountReloadEvent( - model: widget.model ?? state.room.model, - )); } } }, @@ -333,62 +343,85 @@ class _HomeChatPageState extends State { ), // 聊天内容窗口 Expanded( - child: BlocConsumer( - listener: (context, state) { - if (state is ChatAnywhereInited) { - setState(() { - chatId = state.chatId; - }); - } - - if (state is ChatMessagesLoaded && state.error == null) { - setState(() { - selectedImageFiles = []; - }); - } - // 显示错误提示 - else if (state is ChatMessagesLoaded && state.error != null) { - showErrorMessageEnhanced(context, state.error); - } else if (state is ChatMessageUpdated) { - // 聊天内容窗口滚动到底部 - if (!state.processing && scrollController.hasClients) { - scrollController.animateTo( - 0, - duration: const Duration(milliseconds: 500), - curve: Curves.easeOut, - ); - } + child: Stack( + fit: StackFit.expand, + children: [ + BlocConsumer( + listener: (context, state) { + if (state is ChatAnywhereInited) { + setState(() { + chatId = state.chatId; + }); + } - if (state.processing && enableInput.value) { - // 聊天回复中时,禁止输入框编辑 - setState(() { - enableInput.value = false; - }); - } else if (!state.processing && !enableInput.value) { - // 更新免费使用次数 - context.read().add(FreeCountReloadEvent( - model: widget.model ?? room.room.model)); + if (state is ChatMessagesLoaded && state.error == null) { + setState(() { + selectedImageFiles = []; + }); + } + // 显示错误提示 + else if (state is ChatMessagesLoaded && state.error != null) { + showErrorMessageEnhanced(context, state.error); + } else if (state is ChatMessageUpdated) { + // 聊天内容窗口滚动到底部 + if (!state.processing && scrollController.hasClients) { + scrollController.animateTo( + 0, + duration: const Duration(milliseconds: 500), + curve: Curves.easeOut, + ); + } + + if (state.processing && enableInput.value) { + // 聊天回复中时,禁止输入框编辑 + setState(() { + enableInput.value = false; + }); + } else if (!state.processing && !enableInput.value) { + if (tempModel == null) { + // 更新免费使用次数 + context.read().add(FreeCountReloadEvent( + model: widget.model ?? room.room.model)); + } - // 聊天回复完成时,取消输入框的禁止编辑状态 - setState(() { - enableInput.value = true; - }); - } - } - }, - buildWhen: (prv, cur) => cur is ChatMessagesLoaded, - builder: (context, state) { - if (state is ChatMessagesLoaded) { - return buildChatPreviewArea( - state, - room.examples ?? [], - room, - customColors, - chatPreviewController.selectMode, - ); - } - return const Center(child: CircularProgressIndicator()); - }, + // 聊天回复完成时,取消输入框的禁止编辑状态 + setState(() { + enableInput.value = true; + }); + } + } + }, + buildWhen: (prv, cur) => cur is ChatMessagesLoaded, + builder: (context, state) { + if (state is ChatMessagesLoaded) { + return buildChatPreviewArea( + state, + room.examples ?? [], + room, + customColors, + chatPreviewController.selectMode, + ); + } + return const Center(child: CircularProgressIndicator()); + }, + ), + if (!enableInput.value) + Positioned( + bottom: 10, + width: MediaQuery.of(context).size.width, + child: Center( + child: StopButton( + label: '停止输出', + onPressed: () { + HapticFeedbackHelper.mediumImpact(); + context + .read() + .add(ChatMessageStopEvent()); + }, + ), + ), + ), + ], ), ), @@ -405,57 +438,73 @@ class _HomeChatPageState extends State { child: BlocBuilder( builder: (context, freeState) { var hintText = '有问题尽管问我'; - if (freeState is FreeCountLoadedState) { + if (freeState is FreeCountLoadedState && tempModel == null) { final matched = freeState.model(widget.model ?? room.room.model); if (matched != null && matched.leftCount > 0 && matched.maxCount > 0) { - hintText += '(今日还可免费畅享${matched.leftCount}次)'; + hintText += '(今日还可免费${matched.leftCount}次)'; } } - return SafeArea( - child: BlocBuilder( - buildWhen: (previous, current) => - current is ChatMessagesLoaded, - builder: (context, state) { - var enableImageUpload = false; - if (state is ChatMessagesLoaded) { - if (currentModelV2 != null) { - enableImageUpload = - currentModelV2?.supportVision ?? false; - } else { - var model = - state.chatHistory?.model ?? room.room.model; - final cur = supportModels - .where((e) => e.id == model) - .firstOrNull; - enableImageUpload = cur?.supportVision ?? false; - } + return BlocBuilder( + buildWhen: (previous, current) => + current is ChatMessagesLoaded, + builder: (context, state) { + var enableImageUpload = false; + if (state is ChatMessagesLoaded) { + if (currentModelV2 != null) { + enableImageUpload = + currentModelV2?.supportVision ?? false; + } else { + var model = state.chatHistory?.model ?? room.room.model; + final cur = supportModels + .where((e) => e.id == model) + .firstOrNull; + enableImageUpload = cur?.supportVision ?? false; } - - return ChatInput( - enableNotifier: enableInput, - onSubmit: (value) { - handleSubmit(value); - FocusManager.instance.primaryFocus?.unfocus(); - }, - enableImageUpload: enableImageUpload, - onImageSelected: (files) { - setState(() { - selectedImageFiles = files; - }); - }, - selectedImageFiles: - enableImageUpload ? selectedImageFiles : [], - hintText: hintText, - onVoiceRecordTappedEvent: () { - audioPlayerController.stop(); - }, - ); - }, - ), + } + + return ChatInput( + enableNotifier: enableInput, + onSubmit: (value) { + handleSubmit(value); + FocusManager.instance.primaryFocus?.unfocus(); + }, + enableImageUpload: tempModel == null + ? enableImageUpload + : (tempModel?.supportVision ?? false), + onImageSelected: (files) { + setState(() { + selectedImageFiles = files; + }); + }, + selectedImageFiles: + enableImageUpload ? selectedImageFiles : [], + hintText: hintText, + onVoiceRecordTappedEvent: () { + audioPlayerController.stop(); + }, + onStopGenerate: () { + context + .read() + .add(ChatMessageStopEvent()); + }, + leftSideToolsBuilder: () { + return [ + ModelSwitcher( + onSelected: (selected) { + setState(() { + tempModel = selected; + }); + }, + value: tempModel, + ), + ]; + }, + ); + }, ); }, ), @@ -502,18 +551,28 @@ class _HomeChatPageState extends State { } final messages = loadedMessages.map((e) { - if (loadedState.chatHistory != null && - loadedState.chatHistory!.model != null) { - if (currentModelV2 != null) { - e.senderName = currentModelV2!.name; - e.avatarUrl = currentModelV2!.avatarUrl; - } else { - final mod = supportModels - .where((e) => e.id == loadedState.chatHistory!.model!) - .firstOrNull; - if (mod != null) { - e.senderName = mod.shortName; - e.avatarUrl = mod.avatarUrl; + if (e.model != null && !e.model!.startsWith('v2@')) { + final mod = supportModels.where((m) => m.id == e.model).firstOrNull; + if (mod != null) { + e.senderName = mod.shortName; + e.avatarUrl = mod.avatarUrl; + } + } + + if (e.avatarUrl == null || e.senderName == null) { + if (loadedState.chatHistory != null && + loadedState.chatHistory!.model != null) { + if (currentModelV2 != null) { + e.senderName = currentModelV2!.name; + e.avatarUrl = currentModelV2!.avatarUrl; + } else { + final mod = supportModels + .where((e) => e.id == loadedState.chatHistory!.model!) + .firstOrNull; + if (mod != null) { + e.senderName = mod.shortName; + e.avatarUrl = mod.avatarUrl; + } } } } @@ -527,6 +586,7 @@ class _HomeChatPageState extends State { chatPreviewController.setAllMessageIds(messages); return ChatPreview( + padding: enableInput.value ? null : const EdgeInsets.only(bottom: 30), messages: messages, scrollController: scrollController, controller: chatPreviewController, @@ -628,6 +688,7 @@ class _HomeChatPageState extends State { ), index: index, isResent: isResent, + tempModel: tempModel?.id, ), ); diff --git a/lib/page/chat/room_chat.dart b/lib/page/chat/room_chat.dart index e291c772..c7bd0425 100644 --- a/lib/page/chat/room_chat.dart +++ b/lib/page/chat/room_chat.dart @@ -6,6 +6,8 @@ import 'package:askaide/helper/image.dart'; import 'package:askaide/helper/model.dart'; import 'package:askaide/helper/upload.dart'; import 'package:askaide/lang/lang.dart'; +import 'package:askaide/page/chat/component/model_switcher.dart'; +import 'package:askaide/page/chat/component/stop_button.dart'; import 'package:askaide/page/component/audio_player.dart'; import 'package:askaide/page/component/background_container.dart'; import 'package:askaide/page/component/chat/chat_share.dart'; @@ -67,6 +69,12 @@ class _RoomChatPageState extends State { List selectedImageFiles = []; + /// 当前选择的模型 + mm.Model? tempModel; + + // 全量模型列表 + List supportModels = []; + @override void initState() { super.initState(); @@ -93,6 +101,13 @@ class _RoomChatPageState extends State { audioLoadding = loading; }); }; + + // 加载模型列表,用于查询模型名称 + ModelAggregate.models().then((value) { + setState(() { + supportModels = value; + }); + }); } @override @@ -125,9 +140,11 @@ class _RoomChatPageState extends State { listener: (context, state) { if (state is RoomLoaded && state.cascading) { // 加载免费使用次数 - context - .read() - .add(FreeCountReloadEvent(model: state.room.model)); + if (tempModel == null) { + context + .read() + .add(FreeCountReloadEvent(model: state.room.model)); + } } if (state is RoomLoaded) { @@ -156,8 +173,32 @@ class _RoomChatPageState extends State { ), // 聊天内容窗口 Expanded( - child: _buildChatPreviewArea( - room, customColors, _chatPreviewController.selectMode), + child: Stack( + fit: StackFit.expand, + children: [ + _buildChatPreviewArea( + room, + customColors, + _chatPreviewController.selectMode, + ), + if (!_inputEnabled.value) + Positioned( + bottom: 10, + width: MediaQuery.of(context).size.width, + child: Center( + child: StopButton( + label: '停止输出', + onPressed: () { + HapticFeedbackHelper.mediumImpact(); + context + .read() + .add(ChatMessageStopEvent()); + }, + ), + ), + ), + ], + ), ), // 聊天输入窗口 @@ -172,43 +213,61 @@ class _RoomChatPageState extends State { child: BlocBuilder( builder: (context, freeState) { var hintText = '有问题尽管问我'; - if (freeState is FreeCountLoadedState) { + if (freeState is FreeCountLoadedState && + tempModel == null) { final matched = freeState.model(room.room.model); if (matched != null && matched.leftCount > 0 && matched.maxCount > 0) { - hintText += '(今日还可免费畅享${matched.leftCount}次)'; + hintText += '(今日还可免费${matched.leftCount}次)'; } } - return SafeArea( - child: _chatPreviewController.selectMode - ? buildSelectModeToolbars( - context, - _chatPreviewController, - customColors, - ) - : ChatInput( - enableNotifier: _inputEnabled, - onSubmit: (value) { - _handleSubmit(value); - FocusManager.instance.primaryFocus?.unfocus(); - }, - enableImageUpload: roomModel != null && - roomModel!.supportVision, - onImageSelected: (files) { - setState(() { - selectedImageFiles = files; - }); - }, - selectedImageFiles: selectedImageFiles, - onNewChat: () => handleResetContext(context), - hintText: hintText, - onVoiceRecordTappedEvent: () { - _audioPlayerController.stop(); - }, - ), - ); + return _chatPreviewController.selectMode + ? buildSelectModeToolbars( + context, + _chatPreviewController, + customColors, + ) + : ChatInput( + enableNotifier: _inputEnabled, + onSubmit: (value) { + _handleSubmit(value); + FocusManager.instance.primaryFocus?.unfocus(); + }, + enableImageUpload: tempModel == null + ? (roomModel != null && + roomModel!.supportVision) + : (tempModel?.supportVision ?? false), + onImageSelected: (files) { + setState(() { + selectedImageFiles = files; + }); + }, + selectedImageFiles: selectedImageFiles, + onNewChat: () => handleResetContext(context), + hintText: hintText, + onVoiceRecordTappedEvent: () { + _audioPlayerController.stop(); + }, + onStopGenerate: () { + context + .read() + .add(ChatMessageStopEvent()); + }, + leftSideToolsBuilder: () { + return [ + ModelSwitcher( + onSelected: (selected) { + setState(() { + tempModel = selected; + }); + }, + value: tempModel, + ), + ]; + }, + ); }, ), ), @@ -254,9 +313,11 @@ class _RoomChatPageState extends State { }); } else if (!state.processing && !_inputEnabled.value) { // 更新免费使用次数 - context - .read() - .add(FreeCountReloadEvent(model: room.room.model)); + if (tempModel == null) { + context + .read() + .add(FreeCountReloadEvent(model: room.room.model)); + } // 聊天回复完成时,取消输入框的禁止编辑状态 setState(() { @@ -293,8 +354,18 @@ class _RoomChatPageState extends State { } final messages = loadedMessages.map((e) { - e.avatarUrl = room.room.avatarUrl; - e.senderName = room.room.name; + if (e.model != null && !e.model!.startsWith('v2@')) { + final mod = + supportModels.where((m) => m.id == e.model).firstOrNull; + if (mod != null) { + e.senderName = mod.shortName; + e.avatarUrl = mod.avatarUrl; + } + } + if (e.avatarUrl == null || e.senderName == null) { + e.avatarUrl = room.room.avatarUrl; + e.senderName = room.room.name; + } return MessageWithState( e, @@ -307,6 +378,8 @@ class _RoomChatPageState extends State { _chatPreviewController.setAllMessageIds(messages); return ChatPreview( + padding: + _inputEnabled.value ? null : const EdgeInsets.only(bottom: 30), messages: messages, scrollController: _scrollController, controller: _chatPreviewController, @@ -509,6 +582,7 @@ class _RoomChatPageState extends State { ), index: index, isResent: isResent, + tempModel: tempModel?.id, ), ); @@ -682,105 +756,108 @@ Widget buildSelectModeToolbars( ), color: customColors.backgroundColor, ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - TextButton.icon( - onPressed: () { - var messages = chatPreviewController.selectedMessages(); - if (messages.isEmpty) { - showErrorMessageEnhanced( - context, AppLocale.noMessageSelected.getString(context)); - return; - } - - Navigator.push( - context, - MaterialPageRoute( - fullscreenDialog: true, - builder: (context) => ChatShareScreen( - messages: messages - .map((e) => ChatShareMessage( - content: e.message.text, - username: e.message.senderName, - avatarURL: e.message.avatarUrl, - leftSide: e.message.role == Role.receiver, - images: e.message.images, - )) - .toList(), + child: SafeArea( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + TextButton.icon( + onPressed: () { + var messages = chatPreviewController.selectedMessages(); + if (messages.isEmpty) { + showErrorMessageEnhanced( + context, AppLocale.noMessageSelected.getString(context)); + return; + } + + Navigator.push( + context, + MaterialPageRoute( + fullscreenDialog: true, + builder: (context) => ChatShareScreen( + messages: messages + .map((e) => ChatShareMessage( + content: e.message.text, + username: e.message.senderName, + avatarURL: e.message.avatarUrl, + leftSide: e.message.role == Role.receiver, + images: e.message.images, + )) + .toList(), + ), ), - ), - ); - // var messages = chatPreviewController.selectedMessages(); - // if (messages.isEmpty) { - // showErrorMessageEnhanced( - // context, AppLocale.noMessageSelected.getString(context)); - // return; - // } - // var shareText = messages.map((e) { - // if (e.message.role == Role.sender) { - // return '我:\n${e.message.text}'; - // } - - // return '助理:\n${e.message.text}'; - // }).join('\n\n'); - - // shareTo( - // context, - // content: shareText, - // title: AppLocale.chatHistory.getString(context), - // ); - }, - icon: Icon(Icons.share, color: customColors.linkColor), - label: Text( - AppLocale.share.getString(context), - style: TextStyle(color: customColors.linkColor), + ); + // var messages = chatPreviewController.selectedMessages(); + // if (messages.isEmpty) { + // showErrorMessageEnhanced( + // context, AppLocale.noMessageSelected.getString(context)); + // return; + // } + // var shareText = messages.map((e) { + // if (e.message.role == Role.sender) { + // return '我:\n${e.message.text}'; + // } + + // return '助理:\n${e.message.text}'; + // }).join('\n\n'); + + // shareTo( + // context, + // content: shareText, + // title: AppLocale.chatHistory.getString(context), + // ); + }, + icon: Icon(Icons.share, color: customColors.linkColor), + label: Text( + AppLocale.share.getString(context), + style: TextStyle(color: customColors.linkColor), + ), ), - ), - TextButton.icon( - onPressed: () { - chatPreviewController.selectAllMessage(); - }, - icon: Icon(Icons.select_all_outlined, color: customColors.linkColor), - label: Text( - AppLocale.selectAll.getString(context), - style: TextStyle(color: customColors.linkColor), + TextButton.icon( + onPressed: () { + chatPreviewController.selectAllMessage(); + }, + icon: + Icon(Icons.select_all_outlined, color: customColors.linkColor), + label: Text( + AppLocale.selectAll.getString(context), + style: TextStyle(color: customColors.linkColor), + ), ), - ), - TextButton.icon( - onPressed: () { - if (chatPreviewController.selectedMessageIds.isEmpty) { - showErrorMessageEnhanced( - context, AppLocale.noMessageSelected.getString(context)); - return; - } - - openConfirmDialog( - context, - AppLocale.confirmDelete.getString(context), - () { - final ids = chatPreviewController.selectedMessageIds.toList(); - if (ids.isNotEmpty) { - context - .read() - .add(ChatMessageDeleteEvent(ids)); - - showErrorMessageEnhanced( - context, AppLocale.operateSuccess.getString(context)); - - chatPreviewController.exitSelectMode(); - } - }, - danger: true, - ); - }, - icon: Icon(Icons.delete, color: customColors.linkColor), - label: Text( - AppLocale.delete.getString(context), - style: TextStyle(color: customColors.linkColor), + TextButton.icon( + onPressed: () { + if (chatPreviewController.selectedMessageIds.isEmpty) { + showErrorMessageEnhanced( + context, AppLocale.noMessageSelected.getString(context)); + return; + } + + openConfirmDialog( + context, + AppLocale.confirmDelete.getString(context), + () { + final ids = chatPreviewController.selectedMessageIds.toList(); + if (ids.isNotEmpty) { + context + .read() + .add(ChatMessageDeleteEvent(ids)); + + showErrorMessageEnhanced( + context, AppLocale.operateSuccess.getString(context)); + + chatPreviewController.exitSelectMode(); + } + }, + danger: true, + ); + }, + icon: Icon(Icons.delete, color: customColors.linkColor), + label: Text( + AppLocale.delete.getString(context), + style: TextStyle(color: customColors.linkColor), + ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/page/chat/room_create.dart b/lib/page/chat/room_create.dart index 73d384f5..0b93bd6a 100644 --- a/lib/page/chat/room_create.dart +++ b/lib/page/chat/room_create.dart @@ -347,7 +347,6 @@ class _RoomCreatePageState extends State { context.pop(); }, usage: AvatarUsage.room, - randomSeed: randomSeed, defaultAvatarId: _avatarId, defaultAvatarUrl: _avatarUrl, externalAvatarUrls: [ @@ -609,15 +608,34 @@ class _RoomCreatePageState extends State { void openSelectModelDialog( BuildContext context, - Function(mm.Model selected) onSelected, { + Function(mm.Model? selected) onSelected, { String? initValue, List? reservedModels, + bool enableClear = false, + String? title, + String? priorityModelId, }) { + future() async { + final models = await ModelAggregate.models(); + + if (priorityModelId != null) { + // 将 models 中,id 与 priorityModelId 相同的元素排序到最前面 + final index = models.indexWhere( + (e) => e.id == priorityModelId || e.uid() == priorityModelId); + if (index != -1) { + final model = models.removeAt(index); + models.insert(0, model); + } + } + + return models; + } + openModalBottomSheet( context, (context) { return FutureBuilder( - future: ModelAggregate.models(), + future: future(), builder: (context, snapshot) { if (snapshot.hasError) { showErrorMessage(resolveError(context, snapshot.error!)); @@ -638,10 +656,12 @@ void openSelectModelDialog( context.pop(); }, initValue: initValue, + enableClear: enableClear, ); }); }, - heightFactor: 0.7, + heightFactor: 0.85, + title: title, ); } diff --git a/lib/page/chat/room_edit.dart b/lib/page/chat/room_edit.dart index 56a8d325..b88dbe65 100644 --- a/lib/page/chat/room_edit.dart +++ b/lib/page/chat/room_edit.dart @@ -236,7 +236,6 @@ class _RoomEditPageState extends State { context.pop(); }, usage: AvatarUsage.room, - randomSeed: randomSeed, defaultAvatarId: _avatarId, defaultAvatarUrl: _avatarUrl, externalAvatarIds: diff --git a/lib/page/chat/rooms.dart b/lib/page/chat/rooms.dart index 904f28be..8dc4b258 100644 --- a/lib/page/chat/rooms.dart +++ b/lib/page/chat/rooms.dart @@ -1,7 +1,10 @@ import 'package:askaide/helper/ability.dart'; +import 'package:askaide/helper/cache.dart'; +import 'package:askaide/helper/constant.dart'; import 'package:askaide/helper/event.dart'; import 'package:askaide/lang/lang.dart'; import 'package:askaide/bloc/room_bloc.dart'; +import 'package:askaide/page/chat/room_create.dart'; import 'package:askaide/page/component/background_container.dart'; import 'package:askaide/page/component/enhanced_button.dart'; import 'package:askaide/page/component/enhanced_error.dart'; @@ -60,6 +63,18 @@ class _RoomsPageState extends State { if (state is RoomCreateError) { showErrorMessageEnhanced(context, state.error); } + + if (state is RoomOperationResult) { + if (state.success) { + if (state.redirect != null) { + context.push(state.redirect!).then((value) { + context.read().add(RoomsLoadEvent()); + }); + } + } else { + showErrorMessageEnhanced(context, state.error ?? '操作失败'); + } + } }, buildWhen: (previous, current) => current is RoomsLoading || current is RoomsLoaded, @@ -75,6 +90,40 @@ class _RoomsPageState extends State { if (selectedSuggestions.isEmpty) EnhancedPopupMenu( items: [ + if (Ability().isUserLogon() && + !Ability().enableLocalOpenAI) + EnhancedPopupMenuItem( + title: '快速开始', + icon: Icons.quickreply_outlined, + onTap: (p0) async { + final lastModel = await Cache() + .stringGet(key: cacheKeyLastModel); + openSelectModelDialog( + // ignore: use_build_context_synchronously + context, + (selected) { + if (selected == null) { + return; + } + // 缓存最后一次使用的模型 ID,下次创建时自动排在最前面 + Cache().setString( + key: cacheKeyLastModel, + value: selected.id, + ); + + context.read().add( + RoomCreateEvent( + selected.name, + selected.id, + null, + ), + ); + }, + title: '选择要对话的模型', + priorityModelId: lastModel, + ); + }, + ), EnhancedPopupMenuItem( title: '创建数字人', icon: Icons.person_add_alt_outlined, @@ -88,7 +137,7 @@ class _RoomsPageState extends State { !Ability().enableLocalOpenAI) EnhancedPopupMenuItem( title: '发起群聊', - icon: Icons.chat_bubble_outline, + icon: Icons.forum_outlined, onTap: (p0) { context .push('/group-chat-create') diff --git a/lib/page/component/avatar_selector.dart b/lib/page/component/avatar_selector.dart index 5528b276..8e2d4929 100644 --- a/lib/page/component/avatar_selector.dart +++ b/lib/page/component/avatar_selector.dart @@ -27,7 +27,6 @@ class Avatar { class AvatarSelector extends StatefulWidget { final Function(Avatar selected) onSelected; - final int randomSeed; final int? defaultAvatarId; final String? defaultAvatarUrl; @@ -40,7 +39,6 @@ class AvatarSelector extends StatefulWidget { const AvatarSelector({ super.key, required this.onSelected, - required this.randomSeed, this.defaultAvatarId, this.defaultAvatarUrl, required this.usage, diff --git a/lib/page/component/background_container.dart b/lib/page/component/background_container.dart index 9c9a0b74..dd8c7b2b 100644 --- a/lib/page/component/background_container.dart +++ b/lib/page/component/background_container.dart @@ -1,6 +1,7 @@ import 'dart:ui'; import 'package:askaide/helper/constant.dart'; +import 'package:askaide/helper/platform.dart'; import 'package:askaide/page/component/image.dart'; import 'package:askaide/page/component/theme/custom_size.dart'; import 'package:askaide/page/component/theme/custom_theme.dart'; @@ -75,10 +76,13 @@ class _BackgroundContainerState extends State { FocusScope.of(context).requestFocus(FocusNode()); }, onHorizontalDragUpdate: (details) { - int sensitivity = 10; - if (details.delta.dx > sensitivity) { - if (Navigator.of(context).canPop()) { - Navigator.of(context).pop(); + // Only the mobile app supports horizontal swiping to go back to the previous page. + if (PlatformTool.isAndroid() || PlatformTool.isIOS()) { + int sensitivity = 15; + if (details.delta.dx > sensitivity) { + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } } } }, diff --git a/lib/page/component/chat/chat_input.dart b/lib/page/component/chat/chat_input.dart index 16109d7d..94db5542 100644 --- a/lib/page/component/chat/chat_input.dart +++ b/lib/page/component/chat/chat_input.dart @@ -15,8 +15,8 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localization/flutter_localization.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; -import 'package:loading_animation_widget/loading_animation_widget.dart'; import 'package:askaide/page/component/theme/custom_theme.dart'; +import 'package:loading_animation_widget/loading_animation_widget.dart'; class ChatInput extends StatefulWidget { final Function(String value) onSubmit; @@ -29,6 +29,8 @@ class ChatInput extends StatefulWidget { final String hintText; final Function()? onVoiceRecordTappedEvent; final List Function()? leftSideToolsBuilder; + final Function()? onStopGenerate; + final Function(bool hasFocus)? onFocusChange; const ChatInput({ super.key, @@ -42,20 +44,23 @@ class ChatInput extends StatefulWidget { this.leftSideToolsBuilder, this.onImageSelected, this.selectedImageFiles, + this.onStopGenerate, + this.onFocusChange, }); @override State createState() => _ChatInputState(); } -class _ChatInputState extends State { +class _ChatInputState extends State with TickerProviderStateMixin { final TextEditingController _textController = TextEditingController(); /// 用于监听键盘事件,实现回车发送消息,Shift+Enter换行 late final FocusNode _focusNode = FocusNode( - onKey: (node, event) { - if (!event.isShiftPressed && event.logicalKey.keyLabel == 'Enter') { - if (event is RawKeyDownEvent && widget.enableNotifier.value) { + onKeyEvent: (node, event) { + if (!HardwareKeyboard.instance.isShiftPressed && + event.logicalKey.keyLabel == 'Enter') { + if (event is KeyDownEvent && widget.enableNotifier.value) { _handleSubmited(_textController.text.trim()); } @@ -68,6 +73,9 @@ class _ChatInputState extends State { final maxLength = 150000; + /// Maximum height of the chat input box + var maxLines = 5; + @override void initState() { super.initState(); @@ -102,152 +110,170 @@ class _ChatInputState extends State { ), child: Builder(builder: (context) { final setting = context.read(); - return Column( - children: [ - if (widget.selectedImageFiles != null && - widget.selectedImageFiles!.isNotEmpty) - SizedBox( - height: 110, - child: ListView( - scrollDirection: Axis.horizontal, - children: widget.selectedImageFiles! - .map( - (e) => Container( - margin: const EdgeInsets.only(right: 8), - padding: const EdgeInsets.all(5), - child: Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(5), - child: e.file.bytes != null - ? Image.memory( - e.file.bytes!, - fit: BoxFit.cover, - width: 100, - height: 100, - ) - : Image.file( - File(e.file.path!), - fit: BoxFit.cover, - width: 100, - height: 100, - ), - ), - if (widget.enableNotifier.value) - Positioned( - right: 5, - top: 5, - child: InkWell( - onTap: () { - setState(() { - widget.selectedImageFiles!.remove(e); - widget.onImageSelected - ?.call(widget.selectedImageFiles!); - }); - }, - child: Container( - padding: const EdgeInsets.all(3), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - color: customColors.chatRoomBackground, - ), - child: Icon( - Icons.close, - size: 10, - color: customColors.weakTextColor, + return SafeArea( + child: Column( + children: [ + if (widget.selectedImageFiles != null && + widget.selectedImageFiles!.isNotEmpty) + SizedBox( + height: 110, + child: ListView( + scrollDirection: Axis.horizontal, + children: widget.selectedImageFiles! + .map( + (e) => Container( + margin: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.all(5), + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(5), + child: e.file.bytes != null + ? Image.memory( + e.file.bytes!, + fit: BoxFit.cover, + width: 100, + height: 100, + ) + : Image.file( + File(e.file.path!), + fit: BoxFit.cover, + width: 100, + height: 100, + ), + ), + if (widget.enableNotifier.value) + Positioned( + right: 5, + top: 5, + child: InkWell( + onTap: () { + setState(() { + widget.selectedImageFiles!.remove(e); + widget.onImageSelected?.call( + widget.selectedImageFiles!); + }); + }, + child: Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + borderRadius: + BorderRadius.circular(10), + color: + customColors.chatRoomBackground, + ), + child: Icon( + Icons.close, + size: 10, + color: customColors.weakTextColor, + ), ), ), ), - ), - ], + ], + ), ), - ), - ) - .toList(), + ) + .toList(), + ), ), - ), - // 工具栏 - if (widget.toolbar != null) widget.toolbar!, - // if (widget.toolbar != null) - const SizedBox(height: 8), - // 聊天内容输入栏 - SingleChildScrollView( - child: Slidable( - startActionPane: widget.onNewChat != null - ? ActionPane( - extentRatio: 0.3, - motion: const ScrollMotion(), + // 工具栏 + if (widget.toolbar != null) widget.toolbar!, + // if (widget.toolbar != null) + const SizedBox(height: 8), + // 聊天内容输入栏 + SingleChildScrollView( + child: Slidable( + startActionPane: widget.onNewChat != null + ? ActionPane( + extentRatio: 0.3, + motion: const ScrollMotion(), + children: [ + SlidableAction( + autoClose: true, + label: AppLocale.newChat.getString(context), + backgroundColor: Colors.blue, + borderRadius: + const BorderRadius.all(Radius.circular(20)), + onPressed: (_) { + widget.onNewChat!(); + }, + ), + const SizedBox(width: 10), + ], + ) + : null, + child: Row( + children: [ + // 聊天功能按钮 + Row( children: [ - SlidableAction( - autoClose: true, - label: AppLocale.newChat.getString(context), - backgroundColor: Colors.blue, - borderRadius: - const BorderRadius.all(Radius.circular(20)), - onPressed: (_) { - widget.onNewChat!(); - }, - ), - const SizedBox(width: 10), + if (widget.leftSideToolsBuilder != null) + ...widget.leftSideToolsBuilder!(), + if (widget.enableNotifier.value && + widget.enableImageUpload && + Ability().supportImageUploader && + widget.onImageSelected != null && + Ability().supportWebSocket) + _buildImageUploadButton( + context, setting, customColors), ], - ) - : null, - child: Row( - children: [ - // 聊天功能按钮 - Row( - children: [ - if (widget.enableNotifier.value && - widget.enableImageUpload && - Ability().supportImageUploader && - widget.onImageSelected != null && - Ability().supportWebSocket) - _buildImageUploadButton( - context, setting, customColors), - if (widget.leftSideToolsBuilder != null) - ...widget.leftSideToolsBuilder!(), - ], - ), - // 聊天输入框 - Expanded( - child: Container( - decoration: BoxDecoration( - color: customColors.chatInputAreaBackground, - borderRadius: BorderRadius.circular(20), - ), - padding: const EdgeInsets.symmetric(horizontal: 10), - child: Row( - children: [ - Expanded( - child: TextFormField( - keyboardType: TextInputType.multiline, - textInputAction: TextInputAction.newline, - maxLines: 5, - minLines: 1, - maxLength: maxLength, - focusNode: _focusNode, - controller: _textController, - decoration: InputDecoration( - hintText: widget.hintText, - hintStyle: const TextStyle( - fontSize: CustomSize.defaultHintTextSize, + ), + // 聊天输入框 + Expanded( + child: Container( + decoration: BoxDecoration( + color: customColors.chatInputAreaBackground, + borderRadius: BorderRadius.circular(20), + ), + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Row( + children: [ + Expanded( + child: Focus( + onFocusChange: (hasFocus) { + setState(() { + if (hasFocus) { + maxLines = 10; + } else { + maxLines = 5; + } + }); + + widget.onFocusChange?.call(hasFocus); + }, + child: TextFormField( + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.newline, + maxLines: maxLines, + minLines: 1, + maxLength: maxLength, + focusNode: _focusNode, + controller: _textController, + decoration: InputDecoration( + hintText: widget.hintText, + hintStyle: const TextStyle( + fontSize: + CustomSize.defaultHintTextSize, + ), + border: InputBorder.none, + counterText: '', + ), ), - border: InputBorder.none, - counterText: '', ), ), - ), - // 聊天发送按钮 - _buildSendOrVoiceButton(context, customColors), - ], + // 聊天发送按钮 + _buildSendOrVoiceButton(context, customColors), + ], + ), ), ), - ), - ], + ], + ), ), ), - ), - ], + ], + ), ); }), ); @@ -259,9 +285,24 @@ class _ChatInputState extends State { CustomColors customColors, ) { if (!widget.enableNotifier.value) { - return LoadingAnimationWidget.beat( - color: customColors.linkColor!, - size: 20, + return InkWell( + onTap: () { + if (widget.onStopGenerate != null) { + openConfirmDialog( + context, + '确定要停止当前输出?', + () { + widget.onStopGenerate!(); + HapticFeedbackHelper.heavyImpact(); + }, + danger: true, + ); + } + }, + child: LoadingAnimationWidget.beat( + color: customColors.linkColor!, + size: 20, + ), ); } diff --git a/lib/page/component/chat/chat_preview.dart b/lib/page/component/chat/chat_preview.dart index 595e60bc..82e909a2 100644 --- a/lib/page/component/chat/chat_preview.dart +++ b/lib/page/component/chat/chat_preview.dart @@ -9,9 +9,11 @@ import 'package:askaide/helper/helper.dart'; import 'package:askaide/lang/lang.dart'; import 'package:askaide/page/component/attached_button_panel.dart'; import 'package:askaide/page/component/chat/chat_share.dart'; +import 'package:askaide/page/component/chat/enhanced_selectable_text.dart'; import 'package:askaide/page/component/chat/file_upload.dart'; import 'package:askaide/page/component/chat/message_state_manager.dart'; import 'package:askaide/page/component/dialog.dart'; +import 'package:askaide/page/component/random_avatar.dart'; import 'package:askaide/page/component/theme/custom_size.dart'; import 'package:askaide/repo/api_server.dart'; import 'package:bot_toast/bot_toast.dart'; @@ -41,6 +43,7 @@ class ChatPreview extends StatefulWidget { final bool supportBloc; final void Function(Message message)? onSpeakEvent; final void Function(Message message, int index)? onResentEvent; + final EdgeInsetsGeometry? padding; const ChatPreview({ super.key, @@ -57,6 +60,7 @@ class ChatPreview extends StatefulWidget { this.onSpeakEvent, this.onResentEvent, this.supportBloc = true, + this.padding, }); @override @@ -85,6 +89,7 @@ class _ChatPreviewState extends State { shrinkWrap: true, reverse: true, physics: const AlwaysScrollableScrollPhysics(), + padding: widget.padding, cacheExtent: MediaQuery.of(context).size.height * 10, itemBuilder: (context, index) { final message = messages[index]; @@ -547,8 +552,17 @@ class _ChatPreviewState extends State { } } - if (widget.robotAvatar != null && message.role == Role.receiver) { - return widget.robotAvatar!; + if (widget.robotAvatar != null) { + if (message.role == Role.receiver && message.avatarUrl != null) { + return RemoteAvatar( + avatarUrl: message.avatarUrl!, + size: 30, + ); + } + + if (message.role == Role.receiver) { + return widget.robotAvatar!; + } } return const SizedBox(); @@ -586,20 +600,16 @@ class _ChatPreviewState extends State { buttons: [ TextButton.icon( onPressed: () { - if (!state.showMarkdown) { - state.showMarkdown = true; - } else { - state.showMarkdown = false; - } - - widget.stateManager - .setState(message.roomId!, message.id!, state) - .then((value) { - setState(() {}); - context - .read() - .add(RoomLoadEvent(message.roomId!, cascading: false)); - }); + openFullscreenDialog( + context, + child: Container( + margin: const EdgeInsets.only(top: 15, bottom: 30), + child: EnhancedSelectableText( + text: message.text, + ), + ), + title: '选择文本', + ); cancel(); }, @@ -612,9 +622,9 @@ class _ChatPreviewState extends State { color: const Color.fromARGB(255, 255, 255, 255), size: 14, ), - Text( - state.showMarkdown ? "文本" : "预览", - style: const TextStyle(fontSize: 12, color: Colors.white), + const Text( + "文本", + style: TextStyle(fontSize: 12, color: Colors.white), ), ], ), diff --git a/lib/page/component/chat/empty.dart b/lib/page/component/chat/empty.dart index 1628fa1b..878857e5 100644 --- a/lib/page/component/chat/empty.dart +++ b/lib/page/component/chat/empty.dart @@ -130,7 +130,7 @@ class _EmptyPreviewState extends State { return screenWidth / 1.15; } - return 400; + return 348; } double _resolveTipHeight(BuildContext context) { diff --git a/lib/page/component/chat/enhanced_selectable_text.dart b/lib/page/component/chat/enhanced_selectable_text.dart new file mode 100644 index 00000000..0779eb5e --- /dev/null +++ b/lib/page/component/chat/enhanced_selectable_text.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +class EnhancedSelectableText extends StatefulWidget { + final String text; + const EnhancedSelectableText({super.key, required this.text}); + + @override + State createState() => _EnhancedSelectableTextState(); +} + +class _EnhancedSelectableTextState extends State { + @override + Widget build(BuildContext context) { + return SelectionArea( + child: SingleChildScrollView( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 30), + child: Text( + widget.text, + style: const TextStyle( + fontSize: 14, + ), + ), + ), + ), + ); + } +} diff --git a/lib/page/component/dialog.dart b/lib/page/component/dialog.dart index 672f6247..8477a449 100644 --- a/lib/page/component/dialog.dart +++ b/lib/page/component/dialog.dart @@ -8,6 +8,7 @@ import 'package:askaide/page/component/bottom_sheet_box.dart'; import 'package:askaide/page/component/button.dart'; import 'package:askaide/page/component/enhanced_textfield.dart'; import 'package:askaide/page/component/item_selector_search.dart'; +import 'package:askaide/page/component/theme/custom_size.dart'; import 'package:askaide/page/component/theme/custom_theme.dart'; import 'package:bot_toast/bot_toast.dart'; import 'package:flutter/material.dart'; @@ -246,7 +247,11 @@ Future openModalBottomSheet( if (title != null) Text( title, - style: const TextStyle(fontSize: 16), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: customColors.weakTextColorPlus, + ), ), if (title != null) const SizedBox(height: 10), Expanded( @@ -694,9 +699,16 @@ class _FullScreenDialog extends StatelessWidget { return Scaffold( appBar: AppBar( title: Text(title), + toolbarHeight: CustomSize.toolbarHeight, actions: actions, + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ), + body: SafeArea( + child: child, ), - body: child, ); } } diff --git a/lib/page/component/enhanced_textfield.dart b/lib/page/component/enhanced_textfield.dart index 7a027862..7428b2d3 100644 --- a/lib/page/component/enhanced_textfield.dart +++ b/lib/page/component/enhanced_textfield.dart @@ -66,6 +66,8 @@ class EnhancedTextField extends StatefulWidget { final Widget? middleWidget; + final Function(bool hasFocus)? onFocusChange; + const EnhancedTextField({ super.key, required this.customColors, @@ -103,6 +105,7 @@ class EnhancedTextField extends StatefulWidget { this.hintTextSize, this.labelHelpWidget, this.middleWidget, + this.onFocusChange, }); @override @@ -186,13 +189,24 @@ class _EnhancedTextFieldState extends State { width: widget.labelWidth ?? 80, child: widget.labelWidget != null ? widget.labelWidget! - : Text( - widget.labelText!, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: widget.labelFontSize ?? 16, - color: widget.customColors.textfieldLabelColor, - ), + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: Text( + widget.labelText!, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: widget.labelFontSize ?? 16, + color: widget.customColors.textfieldLabelColor, + ), + ), + ), + if (widget.labelHelpWidget != null) ...[ + const SizedBox(width: 5), + widget.labelHelpWidget!, + ] + ], ), ), const SizedBox(width: 10), @@ -221,73 +235,76 @@ class _EnhancedTextFieldState extends State { Column( mainAxisSize: MainAxisSize.min, children: [ - TextFormField( - initialValue: widget.initValue, - readOnly: widget.readOnly ?? false, - focusNode: widget.focusNode, - controller: widget.controller, - inputFormatters: widget.inputFormatters, - textDirection: widget.textDirection, - obscureText: widget.obscureText ?? false, - enabled: widget.enabled ?? true, - style: TextStyle( - color: widget.customColors.textfieldValueColor, - fontSize: widget.fontSize ?? 15, - ), - decoration: InputDecoration( - filled: widget.enableBackground, - fillColor: widget.customColors.textfieldBackgroundColor, - hintText: widget.hintText, - hintStyle: TextStyle( - fontSize: - widget.hintTextSize ?? CustomSize.defaultHintTextSize, - color: widget.hintColor ?? - widget.customColors.textfieldHintColor, - ), - hintTextDirection: widget.textDirection, - counterText: "", - border: resolveInputBorder(), - enabledBorder: resolveInputBorder(), - focusedBorder: resolveInputBorder(), - // isDense: true, - contentPadding: EdgeInsets.only( - top: widget.labelPosition == LabelPosition.top ? 0 : 10, - left: widget.enableBackground ? 15 : 0, - right: widget.enableBackground ? 15 : 0, - bottom: - (widget.showCounter || widget.bottomButton != null) && - widget.middleWidget == null - ? 30 - : 10, + Focus( + onFocusChange: widget.onFocusChange, + child: TextFormField( + initialValue: widget.initValue, + readOnly: widget.readOnly ?? false, + focusNode: widget.focusNode, + controller: widget.controller, + inputFormatters: widget.inputFormatters, + textDirection: widget.textDirection, + obscureText: widget.obscureText ?? false, + enabled: widget.enabled ?? true, + style: TextStyle( + color: widget.customColors.textfieldValueColor, + fontSize: widget.fontSize ?? 15, ), - labelText: widget.labelPosition == LabelPosition.inner - ? widget.labelText - : null, - labelStyle: TextStyle( - color: widget.customColors.textfieldLabelColor, + decoration: InputDecoration( + filled: widget.enableBackground, + fillColor: widget.customColors.textfieldBackgroundColor, + hintText: widget.hintText, + hintStyle: TextStyle( + fontSize: widget.hintTextSize ?? + CustomSize.defaultHintTextSize, + color: widget.hintColor ?? + widget.customColors.textfieldHintColor, + ), + hintTextDirection: widget.textDirection, + counterText: "", + border: resolveInputBorder(), + enabledBorder: resolveInputBorder(), + focusedBorder: resolveInputBorder(), + // isDense: true, + contentPadding: EdgeInsets.only( + top: widget.labelPosition == LabelPosition.top ? 0 : 10, + left: widget.enableBackground ? 15 : 0, + right: widget.enableBackground ? 15 : 0, + bottom: (widget.showCounter || + widget.bottomButton != null) && + widget.middleWidget == null + ? 30 + : 10, + ), + labelText: widget.labelPosition == LabelPosition.inner + ? widget.labelText + : null, + labelStyle: TextStyle( + color: widget.customColors.textfieldLabelColor, + ), + suffixIcon: widget.suffixIcon ?? + (widget.labelPosition == LabelPosition.left + ? widget.inputSelector + : null), ), - suffixIcon: widget.suffixIcon ?? - (widget.labelPosition == LabelPosition.left - ? widget.inputSelector - : null), - ), - cursorRadius: const Radius.circular(10), - keyboardType: widget.keyboardType, - autofocus: widget.autofocus ?? false, - maxLength: widget.maxLength, - minLines: widget.minLines, - maxLines: widget.maxLines, - onChanged: widget.controller == null - ? (value) { - setState(() { - textLength = value.length; - }); + cursorRadius: const Radius.circular(10), + keyboardType: widget.keyboardType, + autofocus: widget.autofocus ?? false, + maxLength: widget.maxLength, + minLines: widget.minLines, + maxLines: widget.maxLines, + onChanged: widget.controller == null + ? (value) { + setState(() { + textLength = value.length; + }); - if (widget.onChanged != null) { - widget.onChanged!(value); + if (widget.onChanged != null) { + widget.onChanged!(value); + } } - } - : null, + : null, + ), ), widget.middleWidget ?? const SizedBox(), ], diff --git a/lib/page/component/model_item.dart b/lib/page/component/model_item.dart index 9fe02516..249e1eef 100644 --- a/lib/page/component/model_item.dart +++ b/lib/page/component/model_item.dart @@ -1,99 +1,194 @@ import 'package:askaide/helper/ability.dart'; import 'package:askaide/helper/constant.dart'; import 'package:askaide/helper/image.dart'; +import 'package:askaide/lang/lang.dart'; import 'package:askaide/page/component/random_avatar.dart'; import 'package:askaide/page/component/theme/custom_theme.dart'; +import 'package:askaide/page/component/weak_text_button.dart'; import 'package:askaide/repo/model/model.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_localization/flutter_localization.dart'; -class ModelItem extends StatelessWidget { +class ModelItem extends StatefulWidget { final List models; - final Function(Model selected) onSelected; + final Function(Model? selected) onSelected; final String? initValue; + final bool enableClear; const ModelItem({ super.key, required this.models, required this.onSelected, this.initValue, + this.enableClear = false, }); + @override + State createState() => _ModelItemState(); +} + +class _ModelItemState extends State { + String keyword = ''; + @override Widget build(BuildContext context) { final customColors = Theme.of(context).extension()!; - return models.isNotEmpty - ? Padding( - padding: const EdgeInsets.only(top: 15), - child: ListView.separated( - itemCount: models.length, - itemBuilder: (context, i) { - var item = models[i]; - return ListTile( - title: Container( - alignment: Alignment.center, - padding: - const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - mainAxisSize: MainAxisSize.min, - children: [ - _buildAvatar(avatarUrl: item.avatarUrl, size: 40), - const SizedBox(width: 20), - Expanded( - child: Container( - alignment: Alignment.centerLeft, - child: Row(children: [ - Text( - item.name, - overflow: TextOverflow.ellipsis, - ), - if (item.tag != null && item.tag!.isNotEmpty) - Container( - decoration: BoxDecoration( - color: customColors.tagsBackgroundHover, - borderRadius: BorderRadius.circular(8), - ), - margin: const EdgeInsets.only(left: 5), - padding: const EdgeInsets.symmetric( - horizontal: 5, - vertical: 2, + + if (widget.enableClear && widget.initValue != null) { + // 将当前选中的模型放在第一位 + var index = widget.models.indexWhere( + (e) => e.uid() == widget.initValue || e.id == widget.initValue); + if (index != -1) { + var model = widget.models.removeAt(index); + widget.models.insert(0, model); + } + } + + return widget.models.isNotEmpty + ? Column( + children: [ + Container( + padding: const EdgeInsets.only(left: 10, right: 10, bottom: 5), + child: TextField( + textAlignVertical: TextAlignVertical.center, + style: TextStyle(color: customColors.dialogDefaultTextColor), + decoration: InputDecoration( + hintText: AppLocale.search.getString(context), + hintStyle: TextStyle( + color: customColors.dialogDefaultTextColor, + ), + prefixIcon: Icon( + Icons.search, + color: customColors.dialogDefaultTextColor, + ), + isDense: true, + border: InputBorder.none, + ), + onChanged: (value) => + setState(() => keyword = value.toLowerCase()), + ), + ), + Expanded( + child: Builder(builder: (context) { + final models = keyword.isEmpty + ? widget.models + : widget.models.where((e) { + var matchText = e.name + + (e.description ?? '') + + (e.shortName ?? '') + + (e.tag ?? '') + + (e.category); + if (e.supportVision) { + matchText += 'vision视觉看图'; + } + + return matchText.toLowerCase().contains(keyword); + }).toList(); + return ListView.separated( + itemCount: models.length, + itemBuilder: (context, i) { + var item = models[i]; + return ListTile( + title: Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 5), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, + children: [ + if (item.avatarUrl != null) ...[ + _buildAvatar( + avatarUrl: item.avatarUrl, size: 40), + const SizedBox(width: 20), + ], + Expanded( + child: Container( + alignment: item.avatarUrl != null + ? Alignment.centerLeft + : Alignment.center, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: Text( + item.name, + overflow: TextOverflow.ellipsis, + ), + ), + if (item.tag != null && + item.tag!.isNotEmpty && + item.avatarUrl != null) + Container( + decoration: BoxDecoration( + color: customColors + .tagsBackgroundHover, + borderRadius: + BorderRadius.circular(8), + ), + margin: + const EdgeInsets.only(left: 5), + padding: const EdgeInsets.symmetric( + horizontal: 5, + vertical: 2, + ), + child: Text( + item.tag!, + style: TextStyle( + fontSize: 10, + color: customColors.tagsText, + ), + ), + ), + ], ), - child: Text( - item.tag!, - style: TextStyle( - fontSize: 10, - color: customColors.tagsText, + ), + ), + if (item.avatarUrl != null) ...[ + if (widget.enableClear) + SizedBox( + width: 60, + child: widget.initValue == item.uid() || + widget.initValue == item.id + ? WeakTextButton( + title: '取消', + fontSize: 14, + onPressed: () { + widget.onSelected(null); + }, + ) + : const SizedBox(), + ) + else + SizedBox( + width: 10, + child: Icon( + Icons.check, + color: widget.initValue == item.uid() || + widget.initValue == item.id + ? customColors.linkColor + : Colors.transparent, ), ), - ), - ]), + ], + ], ), ), - SizedBox( - width: 10, - child: Icon( - Icons.check, - color: - initValue == item.uid() || initValue == item.id - ? customColors.linkColor - : Colors.transparent, - ), - ), - ], - ), - ), - onTap: () { - onSelected(item); - }, - ); - }, - separatorBuilder: (BuildContext context, int index) { - return Divider( - height: 1, - color: customColors.columnBlockDividerColor, - ); - }, - ), + onTap: () { + widget.onSelected(item); + }, + ); + }, + separatorBuilder: (BuildContext context, int index) { + return Divider( + height: 1, + color: customColors.columnBlockDividerColor, + ); + }, + ); + }), + ), + ], ) : const Center( child: Text( diff --git a/lib/page/component/pagination.dart b/lib/page/component/pagination.dart new file mode 100644 index 00000000..f4382ba6 --- /dev/null +++ b/lib/page/component/pagination.dart @@ -0,0 +1,101 @@ +/// 该文件来源于 https://github.com/created-by-varun/flutter_pagination/blob/master/lib/pagination.dart + +import 'package:askaide/page/component/theme/custom_theme.dart'; +import 'package:flutter/material.dart'; + +class Pagination extends StatefulWidget { + const Pagination({ + super.key, + required this.numOfPages, + required this.selectedPage, + this.pagesVisible = 5, + required this.onPageChanged, + }); + + final int numOfPages; + final int selectedPage; + final int pagesVisible; + final Function onPageChanged; + + @override + State createState() => _PaginationState(); +} + +class _PaginationState extends State { + late int _startPage; + late int _endPage; + + @override + void initState() { + super.initState(); + _calculateVisiblePages(); + } + + @override + void didUpdateWidget(Pagination oldWidget) { + super.didUpdateWidget(oldWidget); + _calculateVisiblePages(); + } + + void _calculateVisiblePages() { + /// If the number of pages is less than or equal to the number of pages visible, then show all the pages + if (widget.numOfPages <= widget.pagesVisible) { + _startPage = 1; + _endPage = widget.numOfPages; + } else { + /// If the number of pages is greater than the number of pages visible, then show the pages visible + int middle = (widget.pagesVisible - 1) ~/ 2; + if (widget.selectedPage <= middle + 1) { + _startPage = 1; + _endPage = widget.pagesVisible; + } else if (widget.selectedPage >= widget.numOfPages - middle) { + _startPage = widget.numOfPages - (widget.pagesVisible - 1); + _endPage = widget.numOfPages; + } else { + _startPage = widget.selectedPage - middle; + _endPage = widget.selectedPage + middle; + } + } + } + + @override + Widget build(BuildContext context) { + final customColors = Theme.of(context).extension()!; + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + /// loop through the pages and show the page buttons + for (int i = _startPage; i <= _endPage; i++) + AnimatedContainer( + duration: const Duration(milliseconds: 200), + child: TextButton( + style: i == widget.selectedPage + ? ButtonStyle( + backgroundColor: + MaterialStateProperty.all(Colors.transparent), + ) + : ButtonStyle( + backgroundColor: + MaterialStateProperty.all(Colors.transparent)), + onPressed: () => widget.onPageChanged(i), + child: Text( + '$i', + style: i == widget.selectedPage + ? TextStyle( + color: customColors.linkColor, + fontSize: 14, + fontWeight: FontWeight.w700, + ) + : TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: customColors.weakLinkColor, + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/page/component/random_avatar.dart b/lib/page/component/random_avatar.dart index 532d0953..7a354ba3 100644 --- a/lib/page/component/random_avatar.dart +++ b/lib/page/component/random_avatar.dart @@ -51,7 +51,9 @@ class RandomAvatar extends StatelessWidget { class RemoteAvatar extends StatelessWidget { final String avatarUrl; final int? size; - const RemoteAvatar({super.key, required this.avatarUrl, this.size}); + final double? radius; + const RemoteAvatar( + {super.key, required this.avatarUrl, this.size, this.radius}); @override Widget build(BuildContext context) { @@ -59,7 +61,7 @@ class RemoteAvatar extends StatelessWidget { width: size?.toDouble() ?? 60, height: size?.toDouble() ?? 60, child: ClipRRect( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(radius ?? 8), child: CachedNetworkImage( imageUrl: avatarUrl, fit: BoxFit.fill, diff --git a/lib/page/component/theme/custom_theme.dart b/lib/page/component/theme/custom_theme.dart index 4c7a9e40..0fec2c72 100644 --- a/lib/page/component/theme/custom_theme.dart +++ b/lib/page/component/theme/custom_theme.dart @@ -243,11 +243,11 @@ class CustomColors extends ThemeExtension { static const light = CustomColors( borderRadius: 8, - appBarBackgroundImage: 'assets/background.png', - appBarBackgroundImageForRoom: 'assets/background-team.png', + appBarBackgroundImage: 'assets/background.jpg', + appBarBackgroundImageForRoom: 'assets/background-team.jpg', appBarBackgroundImageForCreativeIsland: - 'assets/background-creative-island.webp', - appBarBackgroundImageDiscovery: 'assets/background-discovery.png', + 'assets/background-creative-island.jpg', + appBarBackgroundImageDiscovery: 'assets/background-light-s1.jpg', chatRoomBackground: Color.fromARGB(255, 239, 239, 239), chatRoomReplyBackground: Colors.white, chatRoomReplyBackgroundSecondary: Color.fromARGB(200, 255, 255, 255), @@ -301,11 +301,10 @@ class CustomColors extends ThemeExtension { static const dark = CustomColors( borderRadius: 8, - appBarBackgroundImage: 'assets/background-dark.png', - appBarBackgroundImageForRoom: 'assets/background-team-dark.png', - appBarBackgroundImageForCreativeIsland: - 'assets/background-creative-island-dark.webp', - appBarBackgroundImageDiscovery: 'assets/background-discovery-dark.webp', + appBarBackgroundImage: 'assets/background-dark.jpg', + appBarBackgroundImageForRoom: 'assets/background-discovery-dark.jpg', + appBarBackgroundImageForCreativeIsland: 'assets/background-dark-s3.jpg', + appBarBackgroundImageDiscovery: 'assets/background-dark-s1.jpg', chatRoomBackground: Color.fromARGB(255, 53, 53, 53), chatRoomReplyBackground: Color.fromARGB(255, 22, 22, 22), chatRoomReplyBackgroundSecondary: Color.fromARGB(200, 39, 39, 39), diff --git a/lib/page/component/weak_text_button.dart b/lib/page/component/weak_text_button.dart index bf241fa7..5f081f4b 100644 --- a/lib/page/component/weak_text_button.dart +++ b/lib/page/component/weak_text_button.dart @@ -5,11 +5,13 @@ class WeakTextButton extends StatelessWidget { final String title; final IconData? icon; final VoidCallback? onPressed; + final double? fontSize; const WeakTextButton({ super.key, required this.title, this.icon, this.onPressed, + this.fontSize, }); @override @@ -23,13 +25,14 @@ class WeakTextButton extends StatelessWidget { Icon( icon, color: customColors.weakLinkColor, + size: (fontSize ?? 15.0) + 1, ), if (icon != null) const SizedBox(width: 5), Text( title, style: TextStyle( color: customColors.weakLinkColor, - fontSize: 15, + fontSize: fontSize ?? 15.0, ), ), ], diff --git a/lib/page/setting/custom_home_models.dart b/lib/page/setting/custom_home_models.dart index 83b6b4e4..23aaca97 100644 --- a/lib/page/setting/custom_home_models.dart +++ b/lib/page/setting/custom_home_models.dart @@ -113,7 +113,7 @@ class _CustomHomeModelsPageState extends State { child: Column( children: [ const MessageBox( - message: '用于设置聊一聊中的常用模型。', + message: '用于设置聊一聊中的常用模型。模型 3 为可选项,长按可重置', type: MessageBoxType.info, ), const SizedBox(height: 10), @@ -359,6 +359,10 @@ class HomeModelItem extends StatelessWidget { itemCount: models.length, itemBuilder: (context, i) { var item = models[i]; + if (item.avatarUrl == null) { + // TODO: remove this debug print + print(item.toJson()); + } return ListTile( title: Container( alignment: Alignment.center, diff --git a/lib/page/setting/setting_screen.dart b/lib/page/setting/setting_screen.dart index 5b0b450e..84606bc6 100644 --- a/lib/page/setting/setting_screen.dart +++ b/lib/page/setting/setting_screen.dart @@ -68,11 +68,28 @@ class _SettingScreenState extends State { ), ), actions: [ + BlocBuilder( + buildWhen: (previous, current) => current is AccountLoaded, + builder: (context, state) { + if (userHasLabPermission(state)) { + return IconButton( + onPressed: () { + context.push('/admin/dashboard'); + }, + icon: const Icon(Icons.developer_board_outlined), + tooltip: '管理后台', + ); + } + + return const SizedBox(); + }, + ), IconButton( onPressed: () { context.push('/notifications'); }, icon: const Icon(Icons.notifications_outlined), + tooltip: '消息通知', ), ], child: BlocBuilder( @@ -281,24 +298,12 @@ class _SettingScreenState extends State { SettingsSection( title: const Text('实验室'), tiles: [ - if (userHasLabPermission(state)) - SettingsTile( - title: const Text('模型 Gallery'), - trailing: Icon( - CupertinoIcons.chevron_forward, - size: MediaQuery.of(context).textScaleFactor * 18, - color: Colors.grey, - ), - onPressed: (context) { - context.push('/creative-island/models'); - }, - ), if (userHasLabPermission(state)) SettingsTile( title: const Text('画板'), - trailing: Icon( + trailing: const Icon( CupertinoIcons.chevron_forward, - size: MediaQuery.of(context).textScaleFactor * 18, + size: 18, color: Colors.grey, ), onPressed: (context) { @@ -311,9 +316,9 @@ class _SettingScreenState extends State { // 诊断 SettingsTile( title: Text(AppLocale.diagnostic.getString(context)), - trailing: Icon( + trailing: const Icon( CupertinoIcons.chevron_forward, - size: MediaQuery.of(context).textScaleFactor * 18, + size: 18, color: Colors.grey, ), onPressed: (context) { @@ -324,6 +329,35 @@ class _SettingScreenState extends State { ), // 社交媒体图标 _buildSocialIcons(context), + // 版权信息 + CustomSettingsSection( + child: Column( + children: [ + Text( + 'Copyright © 2023-${DateTime.now().year}', + style: TextStyle( + color: customColors.weakTextColor, + ), + ), + GestureDetector( + onTap: () { + launchUrlString( + 'https://aidea.aicode.cc', + mode: LaunchMode.externalApplication, + ); + }, + child: Text( + 'Gulu Artificial Intelligence Technology Co., Ltd.', + style: TextStyle( + color: customColors.weakTextColor, + fontSize: 12, + ), + ), + ), + const SizedBox(height: 15), + ], + ), + ), ]); }, ), @@ -462,7 +496,9 @@ class _SettingScreenState extends State { Future>> _defaultServerList() async { return [ SelectorItem(const Text('官方正式服务器'), apiServerURL), - SelectorItem(const Text('本地开发机'), 'http://localhost:8080'), + SelectorItem(const Text('官方预发布服务器'), 'https://uat.aicode.cc'), + SelectorItem(const Text('官方测试服务器'), 'https://test.chatllm.app'), + SelectorItem(const Text('本地开发环境'), 'http://localhost:8080'), ]; } diff --git a/lib/repo/api/admin/channels.dart b/lib/repo/api/admin/channels.dart new file mode 100644 index 00000000..2ce364c6 --- /dev/null +++ b/lib/repo/api/admin/channels.dart @@ -0,0 +1,159 @@ +class AdminChannel { + int? id; + String name; + String type; + String? server; + String? secret; + + AdminChannelMeta? meta; + + String get display { + return name; + } + + AdminChannel({ + this.id, + required this.name, + required this.type, + this.server, + this.secret, + this.meta, + }); + + factory AdminChannel.fromJson(Map json) { + return AdminChannel( + id: json['id'], + name: json['name'], + type: json['type'], + server: json['server'], + secret: json['secret'], + meta: + json['meta'] != null ? AdminChannelMeta.fromJson(json['meta']) : null, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'type': type, + 'server': server, + 'secret': secret, + 'meta': meta?.toJson(), + }; + } +} + +class AdminChannelMeta { + bool? usingProxy; + bool? openaiAzure; + String? openaiAzureAPIVersion; + + AdminChannelMeta({ + this.usingProxy, + this.openaiAzure, + this.openaiAzureAPIVersion, + }); + + factory AdminChannelMeta.fromJson(Map json) { + return AdminChannelMeta( + usingProxy: json['using_proxy'], + openaiAzure: json['openai_azure'], + openaiAzureAPIVersion: json['openai_azure_api_version'], + ); + } + + Map toJson() { + return { + 'using_proxy': usingProxy, + 'openai_azure': openaiAzure, + 'openai_azure_api_version': openaiAzureAPIVersion, + }; + } +} + +class AdminChannelAddReq { + String name; + String type; + String? server; + String? secret; + + AdminChannelMeta? meta; + + AdminChannelAddReq({ + required this.name, + required this.type, + this.server, + this.secret, + this.meta, + }); + + Map toJson() { + return { + 'name': name, + 'type': type, + 'server': server, + 'secret': secret, + 'meta': meta?.toJson(), + }; + } +} + +class AdminChannelUpdateReq { + String? name; + String? type; + String? server; + String? secret; + + AdminChannelMeta? meta; + + AdminChannelUpdateReq({ + this.name, + this.type, + this.server, + this.secret, + this.meta, + }); + + Map toJson() { + return { + 'name': name, + 'type': type, + 'server': server, + 'secret': secret, + 'meta': meta?.toJson(), + }; + } +} + +class AdminChannelType { + String name; + String? display; + bool dynamicType; + + String get text { + return display ?? name; + } + + AdminChannelType({ + required this.name, + this.display, + required this.dynamicType, + }); + + factory AdminChannelType.fromJson(Map json) { + return AdminChannelType( + name: json['name'], + display: json['display'], + dynamicType: json['dynamic'] ?? false, + ); + } + + Map toJson() { + return { + 'name': name, + 'display': display, + 'dynamic': dynamicType, + }; + } +} diff --git a/lib/repo/api/admin/models.dart b/lib/repo/api/admin/models.dart new file mode 100644 index 00000000..a1173772 --- /dev/null +++ b/lib/repo/api/admin/models.dart @@ -0,0 +1,189 @@ +class AdminModel { + String modelId; + String name; + String? shortName; + String? description; + String? avatarUrl; + int status; + AdminModelMeta? meta; + List providers; + + bool get isVision => meta?.vision ?? false; + int get inputPrice => meta?.inputPrice ?? 0; + int get outputPrice => meta?.outputPrice ?? 0; + int get maxContext => meta?.maxContext ?? 0; + + AdminModel({ + required this.modelId, + required this.name, + this.shortName, + this.description, + this.avatarUrl, + required this.status, + this.meta, + required this.providers, + }); + + factory AdminModel.fromJson(Map json) { + return AdminModel( + modelId: json['model_id'], + name: json['name'], + shortName: json['short_name'], + description: json['description'], + avatarUrl: json['avatar_url'], + status: json['status'], + meta: json['meta'] != null ? AdminModelMeta.fromJson(json['meta']) : null, + providers: ((json['providers'] ?? []) as List) + .map((e) => AdminModelProvider.fromJson(e)) + .toList(), + ); + } + + Map toJson() { + return { + 'model_id': modelId, + 'name': name, + 'short_name': shortName, + 'description': description, + 'avatar_url': avatarUrl, + 'status': status, + 'meta': meta?.toJson(), + 'providers': providers.map((e) => e.toJson()).toList(), + }; + } +} + +class AdminModelMeta { + bool? vision; + bool? restricted; + int? maxContext; + int? inputPrice; + int? outputPrice; + String? prompt; + + AdminModelMeta({ + this.vision, + this.restricted, + this.maxContext, + this.inputPrice, + this.outputPrice, + this.prompt, + }); + + factory AdminModelMeta.fromJson(Map json) { + return AdminModelMeta( + vision: json['vision'], + restricted: json['restricted'], + maxContext: json['max_context'], + inputPrice: json['input_price'], + outputPrice: json['output_price'], + prompt: json['prompt'], + ); + } + + Map toJson() { + return { + 'vision': vision, + 'restricted': restricted, + 'max_context': maxContext, + 'input_price': inputPrice, + 'output_price': outputPrice, + 'prompt': prompt, + }; + } +} + +class AdminModelProvider { + int? id; + String? name; + String? modelRewrite; + + AdminModelProvider({ + this.id, + this.name, + this.modelRewrite, + }); + + factory AdminModelProvider.fromJson(Map json) { + return AdminModelProvider( + id: json['id'], + name: json['name'], + modelRewrite: json['model_rewrite'], + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'model_rewrite': modelRewrite, + }; + } +} + +class AdminModelAddReq { + String modelId; + String name; + String? shortName; + String? description; + String? avatarUrl; + int status; + AdminModelMeta? meta; + List? providers; + + AdminModelAddReq({ + required this.modelId, + required this.name, + this.shortName, + this.description, + this.avatarUrl, + required this.status, + this.meta, + this.providers, + }); + + Map toJson() { + return { + 'model_id': modelId, + 'name': name, + 'short_name': shortName, + 'description': description, + 'avatar_url': avatarUrl, + 'status': status, + 'meta': meta?.toJson(), + 'providers': providers?.map((e) => e.toJson()).toList(), + }; + } +} + +class AdminModelUpdateReq { + String name; + String? shortName; + String? description; + String? avatarUrl; + int status; + AdminModelMeta? meta; + List? providers; + + AdminModelUpdateReq({ + required this.name, + this.shortName, + this.description, + this.avatarUrl, + required this.status, + this.meta, + this.providers, + }); + + Map toJson() { + return { + 'name': name, + 'short_name': shortName, + 'description': description, + 'avatar_url': avatarUrl, + 'status': status, + 'meta': meta?.toJson(), + 'providers': providers?.map((e) => e.toJson()).toList(), + }; + } +} diff --git a/lib/repo/api/admin/payment.dart b/lib/repo/api/admin/payment.dart new file mode 100644 index 00000000..7d41f99b --- /dev/null +++ b/lib/repo/api/admin/payment.dart @@ -0,0 +1,47 @@ +class AdminPaymentHistory { + final int id; + final int userId; + final String paymentId; + final String? source; + final int quantity; + final int retailPrice; + final String environment; + final DateTime purchaseAt; + + AdminPaymentHistory({ + required this.id, + required this.userId, + required this.paymentId, + required this.quantity, + required this.retailPrice, + required this.environment, + required this.purchaseAt, + this.source, + }); + + factory AdminPaymentHistory.fromJson(Map json) { + return AdminPaymentHistory( + id: json['id'], + userId: json['user_id'], + paymentId: json['payment_id'], + quantity: json['quantity'], + retailPrice: json['retail_price'], + environment: json['environment'], + purchaseAt: DateTime.parse(json['purchase_at']), + source: json['source'], + ); + } + + Map toJson() { + return { + 'id': id, + 'user_id': userId, + 'payment_id': paymentId, + 'quantity': quantity, + 'retail_price': retailPrice, + 'environment': environment, + 'purchase_at': purchaseAt.toIso8601String(), + 'source': source, + }; + } +} diff --git a/lib/repo/api/admin/users.dart b/lib/repo/api/admin/users.dart new file mode 100644 index 00000000..f22f0fee --- /dev/null +++ b/lib/repo/api/admin/users.dart @@ -0,0 +1,84 @@ +class AdminUser { + final int id; + final String? email; + final String? phone; + final String? realname; + final String? avatar; + final String? unionId; + final String? appleUid; + final int? invitedBy; + final String? inviteCode; + final String? userType; + final String? status; + final String? preferSigninMethod; + final DateTime? createdAt; + + AdminUser({ + required this.id, + this.email, + this.phone, + this.realname, + this.avatar, + this.unionId, + this.appleUid, + this.invitedBy, + this.inviteCode, + required this.userType, + this.preferSigninMethod, + this.createdAt, + this.status, + }); + + String get displayName { + if (realname != null && realname!.isNotEmpty) { + return realname!; + } + + if (email != null && email!.isNotEmpty) { + return email!; + } + + if (phone != null && phone!.isNotEmpty) { + return phone!; + } + + return '-'; + } + + factory AdminUser.fromJson(Map json) { + return AdminUser( + id: json['id'], + email: json['email'], + phone: json['phone'], + realname: json['realname'], + avatar: json['avatar'], + unionId: json['union_id'], + appleUid: json['apple_uid'], + invitedBy: json['invite_by'], + inviteCode: json['invite_code'], + userType: json['user_type'], + preferSigninMethod: json['prefer_signin_method'], + createdAt: + json['CreatedAt'] != null ? DateTime.parse(json['CreatedAt']) : null, + status: json['status'], + ); + } + + Map toJson() { + return { + 'id': id, + 'email': email, + 'phone': phone, + 'realname': realname, + 'avatar': avatar, + 'union_id': unionId, + 'apple_uid': appleUid, + 'invite_by': invitedBy, + 'invite_code': inviteCode, + 'user_type': userType, + 'prefer_signin_method': preferSigninMethod, + 'CreatedAt': createdAt?.toIso8601String(), + 'status': status, + }; + } +} diff --git a/lib/repo/api/info.dart b/lib/repo/api/info.dart index 6df73717..d3abf0d7 100644 --- a/lib/repo/api/info.dart +++ b/lib/repo/api/info.dart @@ -5,6 +5,9 @@ class Capabilities { /// 是否支持 Apple Pay final bool applePayEnabled; + /// 是否支持 Stripe + final bool stripeEnabled; + /// 是否支持微信登录 final bool wechatSigninEnabled; @@ -67,12 +70,14 @@ class Capabilities { this.disableChat = false, this.serviceStatusPage = '', this.wechatSigninEnabled = false, + this.stripeEnabled = false, }); factory Capabilities.fromJson(Map json) { return Capabilities( wechatSigninEnabled: json['wechat_signin_enabled'] ?? false, applePayEnabled: json['apple_pay_enabled'] ?? false, + stripeEnabled: json['stripe_enabled'] ?? false, otherPayEnabled: json['other_pay_enabled'] ?? false, translateEnabled: json['translate_enabled'] ?? false, mailEnabled: json['mail_enabled'] ?? false, @@ -96,6 +101,7 @@ class Capabilities { return { 'wechat_signin_enabled': wechatSigninEnabled, 'apple_pay_enabled': applePayEnabled, + 'stripe_enabled': stripeEnabled, 'other_pay_enabled': otherPayEnabled, 'translate_enabled': translateEnabled, 'mail_enabled': mailEnabled, diff --git a/lib/repo/api/payment.dart b/lib/repo/api/payment.dart index 3dc98338..defd219d 100644 --- a/lib/repo/api/payment.dart +++ b/lib/repo/api/payment.dart @@ -25,10 +25,12 @@ class PaymentProduct { String name; int quota; int retailPrice; + int retailPriceUSD; String expirePolicy; String expirePolicyText; bool recommend; String? description; + List methods; PaymentProduct({ required this.id, @@ -39,19 +41,29 @@ class PaymentProduct { required this.expirePolicyText, this.recommend = false, this.description, + this.retailPriceUSD = 0, + this.methods = const [], }); String get retailPriceText => '¥${(retailPrice / 100).toStringAsFixed(0)}'; + String get retailPriceUSDText => + '\$${(retailPriceUSD / 100).toStringAsFixed(2)}'; + + /// 是否支持 Stripe 支付 + bool get supportStripe => methods.contains('stripe') || methods.isEmpty; + toJson() => { 'id': id, 'name': name, 'quota': quota, 'retail_price': retailPrice, + 'retail_price_usd': retailPriceUSD, 'expire_policy': expirePolicy, 'expire_policy_text': expirePolicyText, 'recommend': recommend, 'description': description, + 'methods': methods, }; static PaymentProduct fromJson(Map json) { @@ -60,10 +72,14 @@ class PaymentProduct { name: json['name'], quota: json['quota'], retailPrice: json['retail_price'], + retailPriceUSD: json['retail_price_usd'] ?? 0, expirePolicy: json['expire_policy'], expirePolicyText: json['expire_policy_text'], recommend: json['recommend'] ?? false, description: json['description'], + methods: ((json['methods'] ?? []) as List) + .map((e) => e.toString()) + .toList(), ); } } @@ -71,12 +87,14 @@ class PaymentProduct { class PaymentProducts { final List consume; final String? note; + final bool preferUSD; - PaymentProducts(this.consume, {this.note}); + PaymentProducts(this.consume, {this.note, this.preferUSD = false}); toJson() => { 'consume': consume, 'note': note, + 'prefer_usd': preferUSD, }; static PaymentProducts fromJson(Map json) { @@ -85,6 +103,7 @@ class PaymentProducts { .map((e) => PaymentProduct.fromJson(e)) .toList(), note: json['note'], + preferUSD: json['prefer_usd'] ?? false, ); } } @@ -107,3 +126,41 @@ class PaymentStatus { ); } } + +class StripePaymentCreatedResponse { + final String paymentId; + final String customer; + final String paymentIntent; + final String ephemeralKey; + final String publishableKey; + final String proxyUrl; + + StripePaymentCreatedResponse( + this.paymentId, + this.customer, + this.paymentIntent, + this.ephemeralKey, + this.publishableKey, + this.proxyUrl, + ); + + toJson() => { + 'payment_id': paymentId, + 'customer': customer, + 'payment_intent': paymentIntent, + 'ephemeral_key': ephemeralKey, + 'publishable_key': publishableKey, + 'proxy_url': proxyUrl, + }; + + static StripePaymentCreatedResponse fromJson(Map json) { + return StripePaymentCreatedResponse( + json['payment_id'], + json['customer'], + json['payment_intent'], + json['ephemeral_key'], + json['publishable_key'], + json['proxy_url'] ?? '', + ); + } +} diff --git a/lib/repo/api_server.dart b/lib/repo/api_server.dart index fe53b0a1..36657e8f 100644 --- a/lib/repo/api_server.dart +++ b/lib/repo/api_server.dart @@ -8,6 +8,10 @@ import 'package:askaide/helper/http.dart'; import 'package:askaide/helper/logger.dart'; import 'package:askaide/helper/platform.dart'; import 'package:askaide/page/component/global_alert.dart'; +import 'package:askaide/repo/api/admin/channels.dart'; +import 'package:askaide/repo/api/admin/models.dart'; +import 'package:askaide/repo/api/admin/payment.dart'; +import 'package:askaide/repo/api/admin/users.dart'; import 'package:askaide/repo/api/article.dart'; import 'package:askaide/repo/api/creative.dart'; import 'package:askaide/repo/api/image_model.dart'; @@ -1968,4 +1972,248 @@ class APIServer { forceRefresh: !cache, ); } + + /// 发起 Stripe 支付 + Future createStripePaymentSheet({ + required String productId, + String? source, + }) async { + return sendPostRequest( + '/v1/payment/stripe/payment-sheet', + (resp) { + return StripePaymentCreatedResponse.fromJson(resp.data); + }, + formData: { + 'product_id': productId, + 'source': source, + }, + ); + } + + /// 管理员接口:渠道类型 + Future> adminChannelTypes() async { + return sendCachedGetRequest('/v1/admin/channel-types', (resp) { + var res = []; + for (var item in resp.data['data']) { + res.add(AdminChannelType.fromJson(item)); + } + + return res; + }); + } + + /// 管理员接口:返回聚合后的渠道列表 + Future> adminChannelsAgg() async { + final channels = await sendGetRequest('/v1/admin/channels', (resp) { + var res = []; + for (var item in resp.data['data']) { + res.add(AdminChannel.fromJson(item)); + } + + return res; + }); + + final channelTypes = await adminChannelTypes(); + channels.addAll( + channelTypes.map((e) => AdminChannel(name: e.text, type: e.name))); + + return channels; + } + + /// 管理员接口:返回所有渠道 + Future> adminChannels() async { + return sendGetRequest('/v1/admin/channels', (resp) { + var res = []; + for (var item in resp.data['data']) { + res.add(AdminChannel.fromJson(item)); + } + + return res; + }); + } + + /// 管理员接口:返回指定渠道 + Future adminChannel({required int id}) async { + return sendGetRequest('/v1/admin/channels/$id', (resp) { + return AdminChannel.fromJson(resp.data['data']); + }); + } + + /// 管理员接口:创建渠道 + Future adminCreateChannel(AdminChannelAddReq req) async { + return sendPostJSONRequest( + '/v1/admin/channels', + (resp) {}, + data: req.toJson(), + ); + } + + /// 管理员接口:更新渠道 + Future adminUpdateChannel( + {required int id, required AdminChannelUpdateReq req}) { + return sendPutJSONRequest( + '/v1/admin/channels/$id', + (resp) {}, + data: req.toJson(), + ); + } + + /// 管理员接口:删除渠道 + Future adminDeleteChannel({required int id}) { + return sendDeleteRequest('/v1/admin/channels/$id', (resp) {}); + } + + /// 管理员接口:返回所有模型 + Future> adminModels() async { + return sendGetRequest( + '/v1/admin/models', + (resp) { + var res = []; + for (var item in resp.data['data']) { + res.add(AdminModel.fromJson(item)); + } + + return res; + }, + queryParameters: { + 'sort': 'id:desc', + }, + ); + } + + /// 管理员接口:返回指定模型 + Future adminModel({required String modelId}) async { + return sendGetRequest('/v1/admin/models/${Uri.encodeComponent(modelId)}', + (resp) { + return AdminModel.fromJson(resp.data['data']); + }); + } + + /// 管理员接口:创建模型 + Future adminCreateModel(AdminModelAddReq req) async { + return sendPostJSONRequest( + '/v1/admin/models', + (resp) {}, + data: req.toJson(), + ); + } + + /// 管理员接口:更新模型 + Future adminUpdateModel( + {required String modelId, required AdminModelUpdateReq req}) { + return sendPutJSONRequest( + '/v1/admin/models/${Uri.encodeComponent(modelId)}', + (resp) {}, + data: req.toJson(), + ); + } + + /// 管理员接口:删除模型 + Future adminDeleteModel({required String modelId}) { + return sendDeleteRequest( + '/v1/admin/models/${Uri.encodeComponent(modelId)}', (resp) {}); + } + + /// 管理员接口:查询用户列表 + Future> adminUsers({ + int page = 1, + int perPage = 20, + String? keyword, + }) async { + return sendGetRequest( + '/v1/admin/users', + (resp) { + var res = []; + for (var item in resp.data['data']) { + res.add(AdminUser.fromJson(item)); + } + + return PagedData( + data: res, + page: resp.data['page'] ?? 1, + perPage: resp.data['per_page'] ?? 20, + total: resp.data['total'], + lastPage: resp.data['last_page'], + ); + }, + queryParameters: { + 'page': page, + 'per_page': perPage, + 'keyword': keyword, + }, + ); + } + + /// 管理员接口:查询用户详情 + Future adminUser({required int id}) async { + return sendGetRequest('/v1/admin/users/$id', (resp) { + return AdminUser.fromJson(resp.data['data']); + }); + } + + /// 管理员接口:为用户分配智慧果 + Future adminUserQuotaAssign({ + required int userId, + required int quota, + int? validPeriod, + String? note, + }) { + return sendPostJSONRequest( + '/v1/admin/quotas/assign', + (resp) {}, + data: { + 'user_id': userId, + 'quota': quota, + 'valid_period': validPeriod, + 'note': note, + }, + ); + } + + /// 管理员接口:查询用户当前额度 + Future adminUserQuota({required int userId}) async { + return sendGetRequest('/v1/admin/quotas/users/$userId', (resp) { + return QuotaResp.fromJson(resp.data); + }); + } + + /// 管理员接口:重新加载配置缓存 + Future adminSettingsReload() async { + return sendPostRequest('/v1/admin/settings/reload', (resp) {}); + } + + /// 管理员接口:重新加载配置缓存 + Future adminSettingReload(String key) async { + return sendPostRequest('/v1/admin/settings/key/$key/reload', (resp) {}); + } + + /// 管理员接口:查询所有支付订单 + Future> adminPaymentHistories({ + int page = 1, + int perPage = 20, + String? keyword, + }) async { + return sendGetRequest( + '/v1/admin/payments/histories', + (resp) { + var res = []; + for (var item in resp.data['data']) { + res.add(AdminPaymentHistory.fromJson(item)); + } + + return PagedData( + data: res, + page: resp.data['page'] ?? 1, + perPage: resp.data['per_page'] ?? 20, + total: resp.data['total'], + lastPage: resp.data['last_page'], + ); + }, + queryParameters: { + 'page': page, + 'per_page': perPage, + 'keyword': keyword, + }, + ); + } } diff --git a/lib/repo/model/misc.dart b/lib/repo/model/misc.dart index abf76ba9..8f3f4dad 100644 --- a/lib/repo/model/misc.dart +++ b/lib/repo/model/misc.dart @@ -343,6 +343,7 @@ class SignInResp { String token; bool isNewUser; int reward; + bool needBindPhone; SignInResp({ required this.id, @@ -352,6 +353,7 @@ class SignInResp { this.phone, this.isNewUser = false, this.reward = 0, + this.needBindPhone = false, }); toJson() => { @@ -362,10 +364,9 @@ class SignInResp { 'token': token, 'is_new_user': isNewUser, 'reward': reward, + 'need_bind_phone': needBindPhone, }; - bool get needBindPhone => phone == null || phone!.isEmpty; - static SignInResp fromJson(Map json) { return SignInResp( id: json['id'], @@ -375,6 +376,7 @@ class SignInResp { token: json['token'], isNewUser: json['is_new_user'] ?? false, reward: json['reward'] ?? 0, + needBindPhone: json['need_bind_phone'] ?? false, ); } } diff --git a/lib/repo/openai_repo.dart b/lib/repo/openai_repo.dart index ec73166a..b5545ee2 100644 --- a/lib/repo/openai_repo.dart +++ b/lib/repo/openai_repo.dart @@ -5,11 +5,13 @@ import 'package:askaide/helper/ability.dart'; import 'package:askaide/helper/constant.dart'; import 'package:askaide/helper/env.dart'; import 'package:askaide/helper/platform.dart'; +import 'package:askaide/helper/queue.dart'; import 'package:askaide/repo/model/chat_message.dart'; import 'package:askaide/repo/model/model.dart' as mm; import 'package:dart_openai/openai.dart'; import 'package:askaide/repo/data/settings_data.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; +import 'package:web_socket_channel/status.dart' as status; class OpenAIRepository { final SettingDataProvider settings; @@ -252,6 +254,7 @@ class OpenAIRepository { String model = defaultChatModel, int? roomId, int? maxTokens, + String? tempModel, }) async { var completer = Completer(); @@ -296,6 +299,8 @@ class OpenAIRepository { }, )); + await channel.ready; + channel.stream.listen( (event) { final evt = jsonDecode(event); @@ -312,10 +317,14 @@ class OpenAIRepository { final res = OpenAIStreamChatCompletionModel.fromMap(evt); for (var element in res.choices) { if (element.delta.content != null) { - onData(ChatStreamRespData( - content: element.delta.content!, - role: element.delta.role, - )); + try { + onData(ChatStreamRespData( + content: element.delta.content!, + role: element.delta.role, + )); + } on QueueFinishedException { + channel.sink.close(status.goingAway); + } } } }, @@ -332,8 +341,9 @@ class OpenAIRepository { completer.completeError(e); }); - channel.sink.add(jsonEncode({ + final data = jsonEncode({ 'model': model, + 'temp_model': tempModel, 'messages': messages.map((e) => e.toMap()).toList(), 'temperature': temperature, 'user': user, @@ -342,7 +352,11 @@ class OpenAIRepository { (model.startsWith('openai:') || model.startsWith('gpt-')) ? null : roomId, // n 参数暂时用不到,复用作为 roomId - })); + }); + + // Logger.instance.d('send chat request: $data'); + + channel.sink.add(data); } else { var chatStream = OpenAI.instance.chat.createStream( model: model, diff --git a/pubspec.lock b/pubspec.lock index 0a16120e..aaf12507 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -703,6 +703,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.5" + flutter_stripe: + dependency: "direct main" + description: + name: flutter_stripe + sha256: bee4046750d813dc77ef1cdc954c8a0d70a21a7e089b86edf3929aebe33921fe + url: "https://pub.dev" + source: hosted + version: "10.1.1" + flutter_stripe_web: + dependency: "direct main" + description: + name: flutter_stripe_web + sha256: bcadbce717cacae1c9ade225394c08a15eda682b3579e50adab7f04fcd16d837 + url: "https://pub.dev" + source: hosted + version: "5.1.0" flutter_svg: dependency: transitive description: @@ -745,6 +761,14 @@ packages: url: "https://pub.dev" source: hosted version: "10.7.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: c3fd9336eb55a38cc1bbd79ab17573113a8deccd0ecbbf926cca3c62803b5c2d + url: "https://pub.dev" + source: hosted + version: "2.4.1" frontend_server_client: dependency: transitive description: @@ -1728,6 +1752,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + stripe_android: + dependency: transitive + description: + name: stripe_android + sha256: "717a541c025f8a8adbb2cbd8e136b424b4910c4812cc08f5c273bef9ca1ee538" + url: "https://pub.dev" + source: hosted + version: "10.1.1" + stripe_ios: + dependency: transitive + description: + name: stripe_ios + sha256: "67994171a29301d12d6a557177d02b12f0d400edd3041ecbdad5fd5c25b43569" + url: "https://pub.dev" + source: hosted + version: "10.1.0" + stripe_js: + dependency: "direct main" + description: + name: stripe_js + sha256: d203b48167a9a533bbf72049b40c8227bf0557d79e28a7f016e77dddcac8b19b + url: "https://pub.dev" + source: hosted + version: "3.4.0" + stripe_platform_interface: + dependency: transitive + description: + name: stripe_platform_interface + sha256: "2f01a6d974ab734ba3824c6f7a2fd01fe7b1f899e7bbace26cb1f66a48bee776" + url: "https://pub.dev" + source: hosted + version: "10.1.1" synchronized: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 495d6db6..63271a15 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. # 应用正式发布时,需要同步修改 lib/helper/constant.dart 中的 VERSION 值 -version: 1.0.13+1 +version: 1.0.14+1 environment: sdk: '>=3.0.0 <4.0.0' @@ -126,6 +126,9 @@ dependencies: media_kit_libs_video: ^1.0.4 path: ^1.8.3 autoscale_tabbarview: ^1.0.2 + flutter_stripe: ^10.1.1 + flutter_stripe_web: ^5.1.0 + stripe_js: ^3.4.0 dev_dependencies: flutter_test: @@ -176,14 +179,14 @@ flutter: - assets/app-256-transparent.png - assets/light-dark-auto.png - assets/openai.png - - assets/background.png - - assets/background-dark.png - - assets/background-team.png - - assets/background-team-dark.png - - assets/background-creative-island.webp - - assets/background-creative-island-dark.webp - - assets/background-discovery.png - - assets/background-discovery-dark.webp + - assets/background.jpg + - assets/background-dark.jpg + - assets/background-team.jpg + - assets/background-creative-island.jpg + - assets/background-discovery-dark.jpg + - assets/background-dark-s1.jpg + - assets/background-dark-s3.jpg + - assets/background-light-s1.jpg - assets/transport.png - assets/weibo.png - assets/github.png @@ -191,6 +194,10 @@ flutter: - assets/xiaohongshu.png - assets/play.png - assets/text-to-video.gif + - assets/wechat-pay.png + - assets/zhifubao.png + - assets/stripe.png + - assets/apple.webp # - images/a_dot_ham.jpeg diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt index 930d2071..903f4899 100644 --- a/windows/flutter/CMakeLists.txt +++ b/windows/flutter/CMakeLists.txt @@ -10,6 +10,11 @@ include(${EPHEMERAL_DIR}/generated_config.cmake) # https://github.com/flutter/flutter/issues/57146. set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + # === Flutter Library === set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") @@ -92,7 +97,7 @@ add_custom_command( COMMAND ${CMAKE_COMMAND} -E env ${FLUTTER_TOOL_ENVIRONMENT} "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" - windows-x64 $ + ${FLUTTER_TARGET_PLATFORM} $ VERBATIM ) add_custom_target(flutter_assemble DEPENDS