Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
/dist/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
@@ -0,0 +1,36 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "d8a9f9a52e5af486f80d932e838ee93861ffd863"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863
|
||||
base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863
|
||||
- platform: android
|
||||
create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863
|
||||
base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863
|
||||
- platform: linux
|
||||
create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863
|
||||
base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863
|
||||
- platform: windows
|
||||
create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863
|
||||
base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
@@ -0,0 +1,55 @@
|
||||
# 风影办公 · 原生客户端
|
||||
|
||||
一套 Flutter 代码,Android APK + Windows exe。界面按微信 / 企业微信手机端重做,不嵌 OA 网页。
|
||||
|
||||
底栏:消息 · 工作台 · 通讯录 · 我。
|
||||
|
||||
- 消息:会话列表、未读红点、全部/未读/单聊/群聊筛选,待办和待审批钉在列表顶部
|
||||
- 工作台:应用宫格 + 待处理数字 + 新版本横幅
|
||||
- 通讯录:组织架构、按姓氏排序、点人发消息
|
||||
- 我:检查更新(OTA)、连接设置、退出
|
||||
|
||||
默认连接:
|
||||
|
||||
- REST:`https://oa.fysxkj.com/api/v1`
|
||||
- IM:`47.96.23.244:8901`(MobileIMSDK TCP)
|
||||
|
||||
当前版本:`1.1.0`(build `2`)。
|
||||
|
||||
## Android
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
flutter build apk --release
|
||||
```
|
||||
|
||||
产物:`build/app/outputs/flutter-apk/app-release.apk`
|
||||
|
||||
发布到服务器后,用户打开 App 会自动检查升级;也可在「我 → 检查更新」手动检查。
|
||||
|
||||
```bash
|
||||
# 把 APK 放到下载地址,并登记版本
|
||||
mkdir -p /data/oa-releases
|
||||
cp build/app/outputs/flutter-apk/app-release.apk /data/oa-releases/fengying-oa.apk
|
||||
# 管理员登录后:
|
||||
# PATCH /api/v1/system/app-release
|
||||
# { "version": "1.1.0", "build": 2, "url": "https://oa.fysxkj.com/fengying-oa.apk", "changelog": "..." }
|
||||
```
|
||||
|
||||
公开接口 `GET /api/v1/system/app-release` 返回最新包信息。客户端比较 `build`,更大则提示下载并安装。
|
||||
|
||||
## Windows
|
||||
|
||||
在 Windows 安装 Flutter 与 Visual Studio(Desktop development with C++)后:
|
||||
|
||||
```bat
|
||||
build-windows.bat
|
||||
```
|
||||
|
||||
exe 在 `build\windows\x64\runner\Release\fengying_oa.exe`。
|
||||
|
||||
本 Linux 构建机不能交叉编译 Windows 桌面包。
|
||||
|
||||
## 登录前 init
|
||||
|
||||
Android `MainActivity` 通过 `com.fysxkj.oa/im` 暴露 `init` / `release`,通过 `com.fysxkj.oa/ota` 安装下载好的 APK。
|
||||
@@ -0,0 +1,9 @@
|
||||
# Windows 桌面端打包
|
||||
|
||||
打包机 `10.1.1.202`,SSH **端口 22022**,用户 `administrator`。
|
||||
|
||||
1. 安装工具链(只需一次):`installer\bootstrap-windows.ps1`
|
||||
2. 在 `C:\fengying-oa-native` 执行 `.\build-windows.ps1`
|
||||
|
||||
产物必须是可安装的 **`build\fengying-oa-setup.exe`**(Inno Setup),不要只发 zip。
|
||||
上传到 `https://oa.fysxkj.com/fengying-oa-setup.exe`,并写入 `apps/api/data/app-release.json` 的 `windowsUrl`。
|
||||
@@ -0,0 +1,34 @@
|
||||
# This file configures the analyzer, which statically analyzes Dart code to
|
||||
# check for errors, warnings, and lints.
|
||||
#
|
||||
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||
# invoked from the command line by running `flutter analyze`.
|
||||
|
||||
# The following line activates a set of recommended lints for Flutter apps,
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
analyzer:
|
||||
exclude:
|
||||
- build/**
|
||||
- android/**
|
||||
- windows/**
|
||||
- linux/**
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
# included above or to enable additional rules. A list of all available lints
|
||||
# and their documentation is published at https://dart.dev/lints.
|
||||
#
|
||||
# Instead of disabling a lint rule for the entire project in the
|
||||
# section below, it can also be suppressed for a single line of code
|
||||
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||
# producing the lint.
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
@@ -0,0 +1,13 @@
|
||||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
@@ -0,0 +1 @@
|
||||
{"agcgw_all":{"SG":"connect-dra.dbankcloud.cn","SG_back":"connect-dra.hispace.hicloud.com","CN":"connect-drcn.dbankcloud.cn","CN_back":"connect-drcn.hispace.dbankcloud.cn","RU":"connect-drru.hispace.dbankcloud.ru","RU_back":"connect-drru.hispace.dbankcloud.cn","DE":"connect-dre.dbankcloud.cn","DE_back":"connect-dre.hispace.hicloud.com"},"websocketgw_all":{"SG":"connect-ws-dra.hispace.dbankcloud.cn","SG_back":"connect-ws-dra.hispace.dbankcloud.com","CN":"connect-ws-drcn.dbankcloud.cn","CN_back":"connect-ws-drcn.hispace.dbankcloud.com","RU":"connect-ws-drru.hispace.dbankcloud.ru","RU_back":"connect-ws-drru.hispace.dbankcloud.cn","DE":"connect-dre.hispace.hicloud.com","DE_back":"connect-dre.hispace.hicloud.com"},"client":{"cp_id":"10086000847744494","product_id":"101653523864901451","client_id":"2029248924690199744","client_secret":"2540EB569E83BF5271BC689A63FB0540832E4849A9608BB814D463EFA6754FA4","project_id":"101653523864901451","app_id":"118825899","api_key":"DgEDALOHkJa4k9Q93H74q1jSF7G3GWcDUQIqrJBqwJFC3Bcyrg+TEK1K5FdhJHh++SBQQ/QxRTdQXOO+XOq/8r5qdZ6rGi7ISmKJlw==","package_name":"com.fengying.oa.mobile"},"oauth_client":{"client_id":"118825899","client_type":1},"app_info":{"app_id":"118825899","package_name":"com.fengying.oa.mobile"},"configuration_version":"3.0","appInfos":[{"package_name":"com.fengying.oa.mobile","client":{"app_id":"118825899"},"oauth_client":{"client_id":"118825899","client_type":1},"app_info":{"app_id":"118825899","package_name":"com.fengying.oa.mobile"}}]}
|
||||
@@ -0,0 +1,68 @@
|
||||
plugins {
|
||||
id "com.android.application"
|
||||
id "kotlin-android"
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id "dev.flutter.flutter-gradle-plugin"
|
||||
}
|
||||
|
||||
def keystoreProperties = new Properties()
|
||||
def keystorePropertiesFile = rootProject.file("key.properties")
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.fysxkj.fengying_oa"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
// LiveKit/WebRTC and the current Android plugins require NDK 27+.
|
||||
// 28.2 is installed in the build environment and is backwards compatible.
|
||||
ndkVersion = "28.2.13676358"
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.fengying.oa.mobile"
|
||||
minSdkVersion = 23
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
release {
|
||||
keyAlias = keystoreProperties["keyAlias"]
|
||||
keyPassword = keystoreProperties["keyPassword"]
|
||||
storeFile = keystoreProperties["storeFile"] ? file(keystoreProperties["storeFile"]) : null
|
||||
storePassword = keystoreProperties["storePassword"]
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig = signingConfigs.release
|
||||
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "vivo:vpush:4.1.5.0"
|
||||
implementation "com.heytap.msp:push:3.0.0"
|
||||
implementation files("libs/push-internal-5.0.5.aar")
|
||||
implementation "com.google.code.gson:gson:2.10.1"
|
||||
implementation "com.huawei.agconnect:agconnect-core:1.9.0.300"
|
||||
implementation "com.huawei.hms:push:6.13.0.300"
|
||||
implementation "com.alphacephei:vosk-android:0.3.75@aar"
|
||||
implementation "net.java.dev.jna:jna:5.13.0@aar"
|
||||
}
|
||||
Binary file not shown.
+19
@@ -0,0 +1,19 @@
|
||||
-dontwarn com.vivo.push.**
|
||||
-keep class com.vivo.push.** { *; }
|
||||
-keep class com.vivo.vms.** { *; }
|
||||
-dontwarn com.heytap.**
|
||||
-keep class com.heytap.** { *; }
|
||||
-dontwarn com.meizu.**
|
||||
-keep class com.meizu.** { *; }
|
||||
-keep class com.fysxkj.fengying_oa.VivoPushReceiver { *; }
|
||||
-keep class com.fysxkj.fengying_oa.OppoPushCallback { *; }
|
||||
-keep class com.fysxkj.fengying_oa.OppoDataMessageService { *; }
|
||||
-keep class com.fysxkj.fengying_oa.OppoCompatPushService { *; }
|
||||
-keep class com.fysxkj.fengying_oa.MeizuPushReceiver { *; }
|
||||
-keep class com.fysxkj.fengying_oa.HuaweiPushService { *; }
|
||||
-dontwarn com.huawei.**
|
||||
-keep class com.huawei.** { *; }
|
||||
-keep class com.huawei.hianalytics.** { *; }
|
||||
-keep class com.huawei.updatesdk.** { *; }
|
||||
-keep class org.webrtc.** { *; }
|
||||
-dontwarn org.webrtc.**
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,176 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
|
||||
<uses-permission android:name="android.permission.DETECT_SCREEN_CAPTURE"/>
|
||||
<uses-permission android:name="com.coloros.mcs.permission.RECIEVE_MCS_MESSAGE"/>
|
||||
<uses-permission android:name="com.heytap.mcs.permission.RECIEVE_MCS_MESSAGE"/>
|
||||
<uses-permission android:name="com.meizu.flyme.push.permission.RECEIVE"/>
|
||||
<uses-permission android:name="com.meizu.flyme.permission.PUSH"/>
|
||||
<uses-permission android:name="com.meizu.c2dm.permission.RECEIVE"/>
|
||||
<permission android:name="${applicationId}.push.permission.MESSAGE" android:protectionLevel="signature"/>
|
||||
<uses-permission android:name="${applicationId}.push.permission.MESSAGE"/>
|
||||
<permission android:name="${applicationId}.permission.C2D_MESSAGE" android:protectionLevel="signature"/>
|
||||
<uses-permission android:name="${applicationId}.permission.C2D_MESSAGE"/>
|
||||
<application
|
||||
android:label="风影智慧办公"
|
||||
android:name=".FengyingApp"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:resizeableActivity="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="fengyingoa" android:host="chat"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="fengyingoa" android:host="qr"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
<meta-data
|
||||
android:name="api_key"
|
||||
android:value="b521c805229a00e85a53d4b535a0cafe"/>
|
||||
<meta-data
|
||||
android:name="app_id"
|
||||
android:value="106114371"/>
|
||||
<receiver
|
||||
android:name=".VivoPushReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.vivo.pushclient.action.RECEIVE"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<service
|
||||
android:name="com.vivo.push.sdk.service.CommandClientService"
|
||||
android:permission="com.push.permission.UPSTAGESERVICE"
|
||||
android:exported="true"/>
|
||||
<service
|
||||
android:name=".OppoDataMessageService"
|
||||
android:permission="com.heytap.mcs.permission.SEND_PUSH_MESSAGE"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="com.heytap.mcs.action.RECEIVE_MCS_MESSAGE"/>
|
||||
<action android:name="com.heytap.msp.push.RECEIVE_MCS_MESSAGE"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
<service
|
||||
android:name=".OppoCompatPushService"
|
||||
android:permission="com.coloros.mcs.permission.SEND_MCS_MESSAGE"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="com.coloros.mcs.action.RECEIVE_MCS_MESSAGE"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
<receiver
|
||||
android:name=".MeizuPushReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="com.meizu.flyme.push.intent.MESSAGE"/>
|
||||
<action android:name="com.meizu.flyme.push.intent.REGISTER.FEEDBACK"/>
|
||||
<action android:name="com.meizu.flyme.push.intent.UNREGISTER.FEEDBACK"/>
|
||||
<action android:name="com.meizu.c2dm.intent.REGISTRATION"/>
|
||||
<action android:name="com.meizu.c2dm.intent.RECEIVE"/>
|
||||
<category android:name="${applicationId}"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<service
|
||||
android:name=".HuaweiPushService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.huawei.push.action.MESSAGING_EVENT"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
<meta-data
|
||||
android:name="com.huawei.hms.client.appid"
|
||||
android:value="appid=118825899"/>
|
||||
<meta-data
|
||||
android:name="push_kit_auto_init_enabled"
|
||||
android:value="true"/>
|
||||
</application>
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<data android:mimeType="*/*"/>
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.SEND"/>
|
||||
<data android:mimeType="*/*"/>
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<data android:scheme="geo"/>
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<data android:scheme="amapuri"/>
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<data android:scheme="qqmap"/>
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<data android:mimeType="application/vnd.android.package-archive"/>
|
||||
</intent>
|
||||
<package android:name="com.coloros.safecenter"/>
|
||||
<package android:name="com.oplus.safecenter"/>
|
||||
<package android:name="com.oppo.safe"/>
|
||||
<package android:name="com.vivo.permissionmanager"/>
|
||||
<package android:name="com.iqoo.secure"/>
|
||||
<package android:name="com.huawei.hwid"/>
|
||||
<package android:name="com.huawei.android.pushagent"/>
|
||||
<package android:name="com.meizu.safe"/>
|
||||
<package android:name="com.meizu.cloud"/>
|
||||
</queries>
|
||||
</manifest>
|
||||
@@ -0,0 +1 @@
|
||||
{"agcgw_all":{"SG":"connect-dra.dbankcloud.cn","SG_back":"connect-dra.hispace.hicloud.com","CN":"connect-drcn.dbankcloud.cn","CN_back":"connect-drcn.hispace.dbankcloud.cn","RU":"connect-drru.hispace.dbankcloud.ru","RU_back":"connect-drru.hispace.dbankcloud.cn","DE":"connect-dre.dbankcloud.cn","DE_back":"connect-dre.hispace.hicloud.com"},"websocketgw_all":{"SG":"connect-ws-dra.hispace.dbankcloud.cn","SG_back":"connect-ws-dra.hispace.dbankcloud.com","CN":"connect-ws-drcn.dbankcloud.cn","CN_back":"connect-ws-drcn.hispace.dbankcloud.com","RU":"connect-ws-drru.hispace.dbankcloud.ru","RU_back":"connect-ws-drru.hispace.dbankcloud.cn","DE":"connect-dre.hispace.hicloud.com","DE_back":"connect-dre.hispace.hicloud.com"},"client":{"cp_id":"10086000847744494","product_id":"101653523864901451","client_id":"2029248924690199744","client_secret":"2540EB569E83BF5271BC689A63FB0540832E4849A9608BB814D463EFA6754FA4","project_id":"101653523864901451","app_id":"118825899","api_key":"DgEDALOHkJa4k9Q93H74q1jSF7G3GWcDUQIqrJBqwJFC3Bcyrg+TEK1K5FdhJHh++SBQQ/QxRTdQXOO+XOq/8r5qdZ6rGi7ISmKJlw==","package_name":"com.fengying.oa.mobile"},"oauth_client":{"client_id":"118825899","client_type":1},"app_info":{"app_id":"118825899","package_name":"com.fengying.oa.mobile"},"configuration_version":"3.0","appInfos":[{"package_name":"com.fengying.oa.mobile","client":{"app_id":"118825899"},"oauth_client":{"client_id":"118825899","client_type":1},"app_info":{"app_id":"118825899","package_name":"com.fengying.oa.mobile"}}]}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.fysxkj.fengying_oa
|
||||
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/** 国内地图(腾讯/高德)用 GCJ-02。卫星 GPS 是 WGS-84,不转换会偏三四百米。 */
|
||||
object Coord {
|
||||
private const val A = 6378245.0
|
||||
private const val EE = 0.00669342162296594323
|
||||
|
||||
fun outOfChina(lat: Double, lng: Double): Boolean {
|
||||
return lng < 72.004 || lng > 137.8347 || lat < 0.8293 || lat > 55.8271
|
||||
}
|
||||
|
||||
fun wgs84ToGcj02(lat: Double, lng: Double): Pair<Double, Double> {
|
||||
if (outOfChina(lat, lng)) return lat to lng
|
||||
var dLat = transformLat(lng - 105.0, lat - 35.0)
|
||||
var dLng = transformLng(lng - 105.0, lat - 35.0)
|
||||
val radLat = lat / 180.0 * PI
|
||||
var magic = 1 - EE * sin(radLat) * sin(radLat)
|
||||
val sqrtMagic = sqrt(magic)
|
||||
dLat = dLat * 180.0 / ((A * (1 - EE)) / (magic * sqrtMagic) * PI)
|
||||
dLng = dLng * 180.0 / (A / sqrtMagic * Math.cos(radLat) * PI)
|
||||
return (lat + dLat) to (lng + dLng)
|
||||
}
|
||||
|
||||
private fun transformLat(x: Double, y: Double): Double {
|
||||
var ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * sqrt(Math.abs(x))
|
||||
ret += (20.0 * sin(6.0 * x * PI) + 20.0 * sin(2.0 * x * PI)) * 2.0 / 3.0
|
||||
ret += (20.0 * sin(y * PI) + 40.0 * sin(y / 3.0 * PI)) * 2.0 / 3.0
|
||||
ret += (160.0 * sin(y / 12.0 * PI) + 320 * sin(y * PI / 30.0)) * 2.0 / 3.0
|
||||
return ret
|
||||
}
|
||||
|
||||
private fun transformLng(x: Double, y: Double): Double {
|
||||
var ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * sqrt(Math.abs(x))
|
||||
ret += (20.0 * sin(6.0 * x * PI) + 20.0 * sin(2.0 * x * PI)) * 2.0 / 3.0
|
||||
ret += (20.0 * sin(x * PI) + 40.0 * sin(x / 3.0 * PI)) * 2.0 / 3.0
|
||||
ret += (150.0 * sin(x / 12.0 * PI) + 300.0 * sin(x / 30.0 * PI)) * 2.0 / 3.0
|
||||
return ret
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.fysxkj.fengying_oa
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import com.heytap.msp.push.HeytapPushManager
|
||||
import com.vivo.push.IPushActionListener
|
||||
import com.vivo.push.PushClient
|
||||
import com.vivo.push.PushConfig
|
||||
import com.vivo.push.listener.IPushQueryActionListener
|
||||
import com.vivo.push.util.VivoPushException
|
||||
|
||||
class FengyingApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
OemAdapt.ensureChannels(this)
|
||||
OppoPushCallback.appCtx = applicationContext
|
||||
initVivo()
|
||||
initOppo()
|
||||
initMeizu()
|
||||
initHuawei()
|
||||
}
|
||||
|
||||
private fun initVivo() {
|
||||
try {
|
||||
val config = PushConfig.Builder().agreePrivacyStatement(true).build()
|
||||
PushClient.getInstance(this).initialize(config)
|
||||
PushClient.getInstance(applicationContext).turnOnPush(
|
||||
object : IPushActionListener {
|
||||
override fun onStateChanged(state: Int) {
|
||||
if (state != 0) {
|
||||
Log.w(TAG, "vivo turnOnPush $state")
|
||||
return
|
||||
}
|
||||
PushClient.getInstance(applicationContext).getRegId(
|
||||
object : IPushQueryActionListener {
|
||||
override fun onSuccess(regid: String?) {
|
||||
if (!regid.isNullOrBlank()) PushStore.saveReg(this@FengyingApp, regid, "vivo")
|
||||
}
|
||||
|
||||
override fun onFail(errorCode: Int?) {
|
||||
Log.w(TAG, "vivo getRegId $errorCode")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
} catch (e: VivoPushException) {
|
||||
Log.e(TAG, "vivo initialize", e)
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "vivo initialize", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initOppo() {
|
||||
try {
|
||||
HeytapPushManager.init(this, false)
|
||||
if (!HeytapPushManager.isSupportPush(this)) return
|
||||
HeytapPushManager.register(this, OPPO_APP_KEY, OPPO_APP_SECRET, OppoPushCallback())
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "oppo initialize", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initMeizu() {
|
||||
if (PushStore.detectVendor() != "meizu") return
|
||||
try {
|
||||
com.meizu.cloud.pushsdk.PushManager.register(this, MEIZU_APP_ID, MEIZU_APP_KEY)
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "meizu initialize", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initHuawei() {
|
||||
if (!PushStore.isHuaweiFamily()) return
|
||||
Thread {
|
||||
try {
|
||||
runCatching {
|
||||
if (com.huawei.agconnect.AGConnectInstance.getInstance() == null) {
|
||||
com.huawei.agconnect.AGConnectInstance.initialize(this)
|
||||
}
|
||||
}
|
||||
val token = com.huawei.hms.aaid.HmsInstanceId.getInstance(this).getToken(HUAWEI_APP_ID, "HCM")
|
||||
if (!token.isNullOrBlank()) {
|
||||
PushStore.saveReg(this, token, "huawei")
|
||||
Log.i(TAG, "huawei token ok")
|
||||
} else {
|
||||
Log.w(TAG, "huawei token empty")
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "huawei initialize", e)
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "FengyingPush"
|
||||
private const val OPPO_APP_KEY = "e23832b52fbb46e18de6eef5affda2e9"
|
||||
private const val OPPO_APP_SECRET = "e9cf0bf35052484ebcaf15d56576468b"
|
||||
private const val MEIZU_APP_ID = "157643"
|
||||
private const val MEIZU_APP_KEY = "4263a8d18de442b6a17203866ce1546d"
|
||||
private const val HUAWEI_APP_ID = "118825899"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.fysxkj.fengying_oa
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import com.huawei.hms.push.HmsMessageService
|
||||
import com.huawei.hms.push.RemoteMessage
|
||||
|
||||
class HuaweiPushService : HmsMessageService() {
|
||||
override fun onNewToken(token: String?) {
|
||||
saveToken(token)
|
||||
}
|
||||
|
||||
override fun onNewToken(token: String?, bundle: Bundle?) {
|
||||
saveToken(token)
|
||||
}
|
||||
|
||||
override fun onMessageReceived(message: RemoteMessage?) {
|
||||
val data = message?.dataOfMap ?: return
|
||||
if (data.isEmpty()) return
|
||||
Log.i(TAG, "huawei data ${data.keys}")
|
||||
}
|
||||
|
||||
private fun saveToken(token: String?) {
|
||||
if (token.isNullOrBlank()) return
|
||||
PushStore.saveReg(this, token, "huawei")
|
||||
Log.i(TAG, "huawei token ok")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "FengyingPush"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,926 @@
|
||||
package com.fysxkj.fengying_oa
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.ClipData
|
||||
import android.content.ContentValues
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.database.ContentObserver
|
||||
import android.location.LocationManager
|
||||
import android.media.MediaPlayer
|
||||
import android.media.AudioAttributes
|
||||
import android.media.MediaRecorder
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.MediaStore
|
||||
import android.provider.OpenableColumns
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
private val imChannel = "com.fysxkj.oa/im"
|
||||
private val otaChannel = "com.fysxkj.oa/ota"
|
||||
private val deviceEvents = "com.fysxkj.oa/device"
|
||||
private val pushEvents = "com.fysxkj.oa/push"
|
||||
private var initialized = false
|
||||
private var screenshotSink: EventChannel.EventSink? = null
|
||||
private var pushSink: EventChannel.EventSink? = null
|
||||
private var pendingPush: Map<String, String>? = null
|
||||
private var watching = false
|
||||
private var lastShotAt = 0L
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private var mediaObserver: ContentObserver? = null
|
||||
private var captureCallback: (() -> Unit)? = null
|
||||
|
||||
private var recorder: MediaRecorder? = null
|
||||
private var recordFile: File? = null
|
||||
private var player: MediaPlayer? = null
|
||||
private var callTone: MediaPlayer? = null
|
||||
private var pickResult: MethodChannel.Result? = null
|
||||
private var locResult: MethodChannel.Result? = null
|
||||
private var voiceStartResult: MethodChannel.Result? = null
|
||||
private var locListener: android.location.LocationListener? = null
|
||||
private var locTimeout: Runnable? = null
|
||||
private var callMediaResult: MethodChannel.Result? = null
|
||||
private var bestGps: android.location.Location? = null
|
||||
private var bestNet: android.location.Location? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
prepareIncomingCallWindow(intent)
|
||||
stashPush(intent)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
prepareIncomingCallWindow(intent)
|
||||
stashPush(intent)
|
||||
emitPush(intent)
|
||||
}
|
||||
|
||||
override fun onPostResume() {
|
||||
super.onPostResume()
|
||||
preferMaxRefresh()
|
||||
}
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, imChannel)
|
||||
.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"init" -> {
|
||||
initialized = true
|
||||
result.success(
|
||||
mapOf(
|
||||
"ok" to true,
|
||||
"initialized" to true,
|
||||
"engine" to "dart-tcp",
|
||||
),
|
||||
)
|
||||
}
|
||||
"release" -> {
|
||||
initialized = false
|
||||
result.success(mapOf("ok" to true))
|
||||
}
|
||||
"engine" -> result.success(if (initialized) "dart-tcp" else "idle")
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, otaChannel)
|
||||
.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"installApk" -> {
|
||||
val path = call.argument<String>("path")
|
||||
if (path.isNullOrBlank()) {
|
||||
result.error("bad_path", "缺少安装包路径", null)
|
||||
return@setMethodCallHandler
|
||||
}
|
||||
try {
|
||||
installApk(path)
|
||||
result.success(true)
|
||||
} catch (e: Exception) {
|
||||
result.error("install_failed", e.message, null)
|
||||
}
|
||||
}
|
||||
"preferMaxRefresh" -> {
|
||||
preferMaxRefresh()
|
||||
result.success(true)
|
||||
}
|
||||
"startScreenshotWatch" -> {
|
||||
startScreenshotWatch()
|
||||
result.success(true)
|
||||
}
|
||||
"stopScreenshotWatch" -> {
|
||||
stopScreenshotWatch()
|
||||
result.success(true)
|
||||
}
|
||||
"startVoice" -> startVoice(result)
|
||||
"stopVoice" -> stopVoice(result, cancel = false)
|
||||
"cancelVoice" -> stopVoice(result, cancel = true)
|
||||
"playVoice" -> {
|
||||
val path = call.argument<String>("path")
|
||||
if (path.isNullOrBlank()) {
|
||||
result.error("bad_path", "缺少音频路径", null)
|
||||
return@setMethodCallHandler
|
||||
}
|
||||
playVoice(path, result)
|
||||
}
|
||||
"stopPlay" -> {
|
||||
stopPlay()
|
||||
result.success(true)
|
||||
}
|
||||
"startCallTone" -> {
|
||||
startCallTone(call.argument<Boolean>("incoming") == true)
|
||||
result.success(true)
|
||||
}
|
||||
"stopCallTone" -> {
|
||||
stopCallTone()
|
||||
result.success(true)
|
||||
}
|
||||
"cancelCallNotification" -> {
|
||||
cancelCallNotification()
|
||||
result.success(true)
|
||||
}
|
||||
"playMessageSent" -> {
|
||||
playBundledSound(R.raw.fengying_send)
|
||||
result.success(true)
|
||||
}
|
||||
"showOnlineNotification" -> {
|
||||
val title = call.argument<String>("title") ?: "风影办公"
|
||||
val content = call.argument<String>("content") ?: "您有一条新消息"
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val extras = (call.argument<Map<String, String>>("extras") ?: emptyMap()).mapValues { "${it.value}" }
|
||||
showOnlineNotification(title, content, extras)
|
||||
result.success(true)
|
||||
}
|
||||
"speechToText" -> {
|
||||
val path = call.argument<String>("path")
|
||||
if (path.isNullOrBlank()) {
|
||||
result.success(null)
|
||||
return@setMethodCallHandler
|
||||
}
|
||||
Thread {
|
||||
val text =
|
||||
try {
|
||||
VoiceAsr.recognize(applicationContext, path)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
mainHandler.post { result.success(text) }
|
||||
}.start()
|
||||
}
|
||||
"pickMedia" -> pickContent(result, "image/*", arrayOf("image/gif", "image/png", "image/jpeg", "image/webp"))
|
||||
"pickFile" -> pickContent(result, "*/*", null)
|
||||
"getLocation" -> getLocation(result)
|
||||
"openFile" -> {
|
||||
val path = call.argument<String>("path")
|
||||
val mime = call.argument<String>("mime")
|
||||
if (path.isNullOrBlank()) {
|
||||
result.error("bad_path", "缺少文件路径", null)
|
||||
return@setMethodCallHandler
|
||||
}
|
||||
try {
|
||||
openFile(path, mime)
|
||||
result.success(true)
|
||||
} catch (e: Exception) {
|
||||
result.error("open_failed", e.message, null)
|
||||
}
|
||||
}
|
||||
"saveImage" -> {
|
||||
val path = call.argument<String>("path")
|
||||
if (path.isNullOrBlank()) {
|
||||
result.error("bad_path", "缺少图片路径", null)
|
||||
return@setMethodCallHandler
|
||||
}
|
||||
try {
|
||||
saveImage(path)
|
||||
result.success(true)
|
||||
} catch (e: Exception) {
|
||||
result.error("save_failed", e.message, null)
|
||||
}
|
||||
}
|
||||
"openUrl" -> {
|
||||
val url = call.argument<String>("url")
|
||||
if (url.isNullOrBlank()) {
|
||||
result.error("bad_url", "缺少链接", null)
|
||||
return@setMethodCallHandler
|
||||
}
|
||||
try {
|
||||
openUrl(url)
|
||||
result.success(true)
|
||||
} catch (e: Exception) {
|
||||
result.error("open_failed", e.message, null)
|
||||
}
|
||||
}
|
||||
"getPushToken" -> result.success(PushStore.token(this))
|
||||
"consumeLaunchPush" -> {
|
||||
val hit = pendingPush
|
||||
pendingPush = null
|
||||
result.success(hit)
|
||||
}
|
||||
"requestPushPermission", "requestFirstLoginPermissions" -> {
|
||||
requestNotifyPermission()
|
||||
result.success(true)
|
||||
}
|
||||
"requestCallMedia" -> requestCallMedia(result)
|
||||
"oemStatus" -> {
|
||||
val map = OemAdapt.status().toMutableMap()
|
||||
map["batteryIgnored"] = OemAdapt.batteryIgnored(this)
|
||||
map["prompted"] = PushStore.oemPrompted(this)
|
||||
result.success(map)
|
||||
}
|
||||
"openOemKeepAlive" -> {
|
||||
OemAdapt.openKeepAlive(this)
|
||||
result.success(true)
|
||||
}
|
||||
"requestBattery" -> {
|
||||
OemAdapt.requestBattery(this)
|
||||
result.success(true)
|
||||
}
|
||||
"markOemPrompted" -> {
|
||||
PushStore.markOemPrompted(this)
|
||||
result.success(true)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
EventChannel(flutterEngine.dartExecutor.binaryMessenger, deviceEvents)
|
||||
.setStreamHandler(
|
||||
object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
||||
screenshotSink = events
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
screenshotSink = null
|
||||
}
|
||||
},
|
||||
)
|
||||
EventChannel(flutterEngine.dartExecutor.binaryMessenger, pushEvents)
|
||||
.setStreamHandler(
|
||||
object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
||||
pushSink = events
|
||||
pendingPush?.let { events?.success(it) }
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
pushSink = null
|
||||
}
|
||||
},
|
||||
)
|
||||
preferMaxRefresh()
|
||||
}
|
||||
|
||||
private fun requestNotifyPermission() {
|
||||
if (Build.VERSION.SDK_INT < 33) return
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return
|
||||
}
|
||||
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), REQ_NOTIFY)
|
||||
}
|
||||
|
||||
private fun requestCallMedia(result: MethodChannel.Result) {
|
||||
val need = mutableListOf<String>()
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
|
||||
need.add(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
|
||||
need.add(Manifest.permission.CAMERA)
|
||||
}
|
||||
if (need.isEmpty()) {
|
||||
result.success(true)
|
||||
return
|
||||
}
|
||||
callMediaResult = result
|
||||
ActivityCompat.requestPermissions(this, need.toTypedArray(), REQ_CALL)
|
||||
}
|
||||
|
||||
private fun stashPush(intent: Intent?) {
|
||||
val extras = PushStore.extrasOf(intent)
|
||||
if (extras.isNotEmpty()) pendingPush = extras
|
||||
}
|
||||
|
||||
private fun emitPush(intent: Intent?) {
|
||||
val extras = PushStore.extrasOf(intent)
|
||||
if (extras.isEmpty()) return
|
||||
mainHandler.post { pushSink?.success(extras) }
|
||||
}
|
||||
|
||||
private fun preferMaxRefresh() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
|
||||
try {
|
||||
val display = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) display else windowManager.defaultDisplay
|
||||
val modes = display?.supportedModes ?: return
|
||||
val best = modes.maxByOrNull { it.refreshRate } ?: return
|
||||
val params = window.attributes
|
||||
if (params.preferredDisplayModeId != best.modeId) {
|
||||
params.preferredDisplayModeId = best.modeId
|
||||
window.attributes = params
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun emitScreenshot() {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastShotAt < 1500) return
|
||||
lastShotAt = now
|
||||
mainHandler.post { screenshotSink?.success("screenshot") }
|
||||
}
|
||||
|
||||
private fun startScreenshotWatch() {
|
||||
if (watching) return
|
||||
watching = true
|
||||
requestMediaRead()
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
val cb = android.app.Activity.ScreenCaptureCallback { emitScreenshot() }
|
||||
captureCallback = { unregisterScreenCaptureCallback(cb) }
|
||||
registerScreenCaptureCallback(mainExecutor, cb)
|
||||
}
|
||||
val observer =
|
||||
object : ContentObserver(mainHandler) {
|
||||
override fun onChange(selfChange: Boolean, uri: Uri?) {
|
||||
super.onChange(selfChange, uri)
|
||||
mainHandler.post { inspectScreenshot(uri) }
|
||||
}
|
||||
}
|
||||
mediaObserver = observer
|
||||
try {
|
||||
contentResolver.registerContentObserver(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true, observer)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestMediaRead() {
|
||||
val perm =
|
||||
if (Build.VERSION.SDK_INT >= 33) Manifest.permission.READ_MEDIA_IMAGES
|
||||
else Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
if (ContextCompat.checkSelfPermission(this, perm) == PackageManager.PERMISSION_GRANTED) return
|
||||
ActivityCompat.requestPermissions(this, arrayOf(perm), REQ_MEDIA)
|
||||
}
|
||||
|
||||
private fun inspectScreenshot(uri: Uri?) {
|
||||
val uriText = uri?.toString()?.lowercase() ?: ""
|
||||
if (uriText.contains("screenshot") || uriText.contains("screen_shot") || uriText.contains("截屏")) {
|
||||
emitScreenshot()
|
||||
return
|
||||
}
|
||||
try {
|
||||
val projection =
|
||||
arrayOf(
|
||||
MediaStore.Images.Media.DISPLAY_NAME,
|
||||
MediaStore.Images.Media.RELATIVE_PATH,
|
||||
MediaStore.Images.Media.DATA,
|
||||
MediaStore.Images.Media.DATE_ADDED,
|
||||
)
|
||||
val target = uri ?: MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
contentResolver.query(target, projection, null, null, "${MediaStore.Images.Media.DATE_ADDED} DESC")?.use { c ->
|
||||
if (!c.moveToFirst()) return
|
||||
val added = if (c.columnCount > 3) c.getLong(3) else 0L
|
||||
if (added > 0 && System.currentTimeMillis() / 1000 - added > 8) return
|
||||
val blob = "${c.getString(0) ?: ""} ${c.getString(1) ?: ""} ${if (c.columnCount > 2) c.getString(2) ?: "" else ""}".lowercase()
|
||||
if (
|
||||
blob.contains("screenshot") ||
|
||||
blob.contains("screen_shot") ||
|
||||
blob.contains("截屏") ||
|
||||
blob.contains("截圖") ||
|
||||
blob.contains("screen capture")
|
||||
) {
|
||||
emitScreenshot()
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopScreenshotWatch() {
|
||||
watching = false
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
try {
|
||||
captureCallback?.invoke()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
captureCallback = null
|
||||
}
|
||||
mediaObserver?.let {
|
||||
try {
|
||||
contentResolver.unregisterContentObserver(it)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
mediaObserver = null
|
||||
}
|
||||
|
||||
/** Keep a call visible over the lock screen when the user enters through an OEM push. */
|
||||
private fun prepareIncomingCallWindow(intent: Intent?) {
|
||||
val extras = PushStore.extrasOf(intent)
|
||||
if (extras["kind"] != "call" || extras["action"] in setOf("end", "reject", "timeout")) return
|
||||
try {
|
||||
setShowWhenLocked(true)
|
||||
setTurnScreenOn(true)
|
||||
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun startCallTone(incoming: Boolean) {
|
||||
if (callTone?.isPlaying == true) return
|
||||
stopCallTone()
|
||||
try {
|
||||
val res = if (incoming) R.raw.fengying_incoming else R.raw.fengying_dial
|
||||
callTone = MediaPlayer.create(this, res)?.apply {
|
||||
isLooping = true
|
||||
setAudioAttributes(
|
||||
AudioAttributes.Builder().setUsage(
|
||||
if (incoming) AudioAttributes.USAGE_NOTIFICATION_RINGTONE else AudioAttributes.USAGE_VOICE_COMMUNICATION_SIGNALLING,
|
||||
).setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION).build(),
|
||||
)
|
||||
start()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
stopCallTone()
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopCallTone() {
|
||||
try { callTone?.stop() } catch (_: Exception) {}
|
||||
try { callTone?.release() } catch (_: Exception) {}
|
||||
callTone = null
|
||||
}
|
||||
|
||||
private fun playBundledSound(res: Int) {
|
||||
try {
|
||||
MediaPlayer.create(this, res)?.apply {
|
||||
setOnCompletionListener { it.release() }
|
||||
start()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun notificationIntent(extras: Map<String, String>, requestCode: Int): PendingIntent {
|
||||
val i = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
extras.forEach { (k, v) -> putExtra(k, v) }
|
||||
data = Uri.parse("fengyingoa://chat/open?$requestCode")
|
||||
}
|
||||
return PendingIntent.getActivity(
|
||||
this,
|
||||
requestCode,
|
||||
i,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun showOnlineNotification(title: String, content: String, extras: Map<String, String>) {
|
||||
val isCall = extras["kind"] == "call" && extras["action"] !in setOf("end", "reject", "timeout")
|
||||
val id = if (isCall) CALL_NOTICE_ID else (System.currentTimeMillis() % 100000).toInt()
|
||||
val intent = notificationIntent(extras, id)
|
||||
val b = NotificationCompat.Builder(this, if (isCall) PushStore.CHANNEL_CALL else PushStore.CHANNEL_IM)
|
||||
.setSmallIcon(android.R.drawable.sym_action_chat)
|
||||
.setContentTitle(title)
|
||||
.setContentText(content)
|
||||
.setAutoCancel(!isCall)
|
||||
.setContentIntent(intent)
|
||||
.setPriority(if (isCall) NotificationCompat.PRIORITY_MAX else NotificationCompat.PRIORITY_HIGH)
|
||||
.setCategory(if (isCall) NotificationCompat.CATEGORY_CALL else NotificationCompat.CATEGORY_MESSAGE)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
if (isCall) {
|
||||
b.setOngoing(true).setFullScreenIntent(intent, true)
|
||||
}
|
||||
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
nm.notify(id, b.build())
|
||||
}
|
||||
|
||||
private fun cancelCallNotification() {
|
||||
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
nm.cancel(CALL_NOTICE_ID)
|
||||
}
|
||||
|
||||
private fun hasMic(): Boolean {
|
||||
return ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun startVoice(result: MethodChannel.Result) {
|
||||
if (!hasMic()) {
|
||||
voiceStartResult = result
|
||||
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.RECORD_AUDIO), REQ_MIC)
|
||||
return
|
||||
}
|
||||
beginRecord(result)
|
||||
}
|
||||
|
||||
private fun beginRecord(result: MethodChannel.Result) {
|
||||
try {
|
||||
stopPlay()
|
||||
recorder?.release()
|
||||
val file = File(cacheDir, "voice_${System.currentTimeMillis()}.m4a")
|
||||
recordFile = file
|
||||
val rec =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaRecorder(this)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
MediaRecorder()
|
||||
}
|
||||
rec.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
rec.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||
rec.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||
rec.setAudioSamplingRate(16000)
|
||||
rec.setAudioEncodingBitRate(64000)
|
||||
rec.setAudioChannels(1)
|
||||
rec.setOutputFile(file.absolutePath)
|
||||
rec.prepare()
|
||||
rec.start()
|
||||
recorder = rec
|
||||
result.success(true)
|
||||
} catch (e: Exception) {
|
||||
recorder?.release()
|
||||
recorder = null
|
||||
recordFile?.delete()
|
||||
recordFile = null
|
||||
result.error("record_failed", e.message ?: "无法开始录音", null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopVoice(result: MethodChannel.Result, cancel: Boolean) {
|
||||
val file = recordFile
|
||||
try {
|
||||
recorder?.stop()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
try {
|
||||
recorder?.release()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
recorder = null
|
||||
recordFile = null
|
||||
if (cancel || file == null || !file.exists() || file.length() < 200) {
|
||||
file?.delete()
|
||||
result.success(null)
|
||||
return
|
||||
}
|
||||
result.success(mapOf("path" to file.absolutePath, "name" to file.name))
|
||||
}
|
||||
|
||||
private fun playVoice(path: String, result: MethodChannel.Result) {
|
||||
try {
|
||||
stopPlay()
|
||||
val p = MediaPlayer()
|
||||
p.setDataSource(path)
|
||||
p.setOnCompletionListener {
|
||||
stopPlay()
|
||||
}
|
||||
p.prepare()
|
||||
p.start()
|
||||
player = p
|
||||
result.success(true)
|
||||
} catch (e: Exception) {
|
||||
stopPlay()
|
||||
result.error("play_failed", e.message ?: "无法播放", null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopPlay() {
|
||||
try {
|
||||
player?.stop()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
try {
|
||||
player?.release()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
player = null
|
||||
}
|
||||
|
||||
private fun pickContent(result: MethodChannel.Result, type: String, extras: Array<String>?) {
|
||||
pickResult = result
|
||||
val intent =
|
||||
Intent(Intent.ACTION_GET_CONTENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
this.type = type
|
||||
if (extras != null) putExtra(Intent.EXTRA_MIME_TYPES, extras)
|
||||
}
|
||||
startActivityForResult(Intent.createChooser(intent, "选择文件"), REQ_PICK)
|
||||
}
|
||||
|
||||
private fun hasLoc(): Boolean {
|
||||
return ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED ||
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun getLocation(result: MethodChannel.Result) {
|
||||
if (!hasLoc()) {
|
||||
locResult = result
|
||||
ActivityCompat.requestPermissions(
|
||||
this,
|
||||
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION),
|
||||
REQ_LOC,
|
||||
)
|
||||
return
|
||||
}
|
||||
emitLocation(result)
|
||||
}
|
||||
|
||||
@Suppress("MissingPermission")
|
||||
private fun emitLocation(result: MethodChannel.Result) {
|
||||
try {
|
||||
val lm = getSystemService(LOCATION_SERVICE) as LocationManager
|
||||
stopLocUpdates(lm)
|
||||
locResult = result
|
||||
bestGps = null
|
||||
bestNet = null
|
||||
val listener =
|
||||
android.location.LocationListener { loc ->
|
||||
if (loc.provider == LocationManager.GPS_PROVIDER) {
|
||||
bestGps = loc
|
||||
if (loc.accuracy <= 50f) finishLoc(lm, loc)
|
||||
} else {
|
||||
val cur = bestNet
|
||||
if (cur == null || loc.accuracy < cur.accuracy) bestNet = loc
|
||||
}
|
||||
}
|
||||
locListener = listener
|
||||
if (lm.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
|
||||
lm.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0L, 0f, listener, Looper.getMainLooper())
|
||||
}
|
||||
if (lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) {
|
||||
lm.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 0L, 0f, listener, Looper.getMainLooper())
|
||||
}
|
||||
val timeout =
|
||||
Runnable {
|
||||
val gps = bestGps
|
||||
val net = bestNet
|
||||
val last = bestLastLocation(lm)
|
||||
val chosen =
|
||||
when {
|
||||
gps != null -> gps
|
||||
net != null -> net
|
||||
last != null -> last
|
||||
else -> null
|
||||
}
|
||||
if (chosen != null) finishLoc(lm, chosen)
|
||||
else {
|
||||
stopLocUpdates(lm)
|
||||
val cb = locResult
|
||||
locResult = null
|
||||
cb?.error("no_location", "没有拿到定位,请到开阔处打开系统定位后再试", null)
|
||||
}
|
||||
}
|
||||
locTimeout = timeout
|
||||
mainHandler.postDelayed(timeout, 16000)
|
||||
} catch (e: Exception) {
|
||||
result.error("no_location", e.message ?: "定位失败", null)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MissingPermission")
|
||||
private fun bestLastLocation(lm: LocationManager): android.location.Location? {
|
||||
val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER, LocationManager.PASSIVE_PROVIDER)
|
||||
var best: android.location.Location? = null
|
||||
for (p in providers) {
|
||||
val loc =
|
||||
try {
|
||||
lm.getLastKnownLocation(p)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: continue
|
||||
if (best == null) {
|
||||
best = loc
|
||||
continue
|
||||
}
|
||||
val newer = loc.time - best.time > 15_000
|
||||
val moreAccurate = loc.accuracy + 8f < best.accuracy
|
||||
if (newer || moreAccurate) best = loc
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
@Suppress("MissingPermission")
|
||||
private fun finishLoc(lm: LocationManager, loc: android.location.Location) {
|
||||
stopLocUpdates(lm)
|
||||
val cb = locResult
|
||||
locResult = null
|
||||
cb?.success(locMap(loc))
|
||||
}
|
||||
|
||||
@Suppress("MissingPermission")
|
||||
private fun stopLocUpdates(lm: LocationManager) {
|
||||
locTimeout?.let { mainHandler.removeCallbacks(it) }
|
||||
locTimeout = null
|
||||
locListener?.let {
|
||||
try {
|
||||
lm.removeUpdates(it)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
locListener = null
|
||||
}
|
||||
|
||||
private fun locMap(loc: android.location.Location): Map<String, Double> {
|
||||
var lat = loc.latitude
|
||||
var lng = loc.longitude
|
||||
val p = loc.provider ?: ""
|
||||
if (p != LocationManager.NETWORK_PROVIDER) {
|
||||
val gcj = Coord.wgs84ToGcj02(lat, lng)
|
||||
lat = gcj.first
|
||||
lng = gcj.second
|
||||
}
|
||||
return mapOf(
|
||||
"lat" to lat,
|
||||
"lng" to lng,
|
||||
"accuracy" to loc.accuracy.toDouble(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun openFile(path: String, mime: String?) {
|
||||
val file = File(path)
|
||||
if (!file.exists()) throw IllegalStateException("文件不存在")
|
||||
val uri = FileProvider.getUriForFile(this, "$packageName.fileprovider", file)
|
||||
val mimeType = if (mime.isNullOrBlank()) "*/*" else mime
|
||||
val view =
|
||||
Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, mimeType)
|
||||
clipData = ClipData.newRawUri("", uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
val matches = packageManager.queryIntentActivities(view, PackageManager.MATCH_DEFAULT_ONLY)
|
||||
for (ri in matches) {
|
||||
grantUriPermission(ri.activityInfo.packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
if (matches.isNotEmpty()) {
|
||||
startActivity(Intent.createChooser(view, "打开文件").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
return
|
||||
}
|
||||
val send =
|
||||
Intent(Intent.ACTION_SEND).apply {
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
setType(mimeType)
|
||||
clipData = ClipData.newRawUri("", uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
val sendMatches = packageManager.queryIntentActivities(send, PackageManager.MATCH_DEFAULT_ONLY)
|
||||
for (ri in sendMatches) {
|
||||
grantUriPermission(ri.activityInfo.packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
if (sendMatches.isEmpty()) {
|
||||
throw IllegalStateException("手机上没有能打开此文件的应用,请先安装 WPS 或 Office")
|
||||
}
|
||||
startActivity(Intent.createChooser(send, "打开文件").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
|
||||
private fun saveImage(path: String) {
|
||||
val file = File(path)
|
||||
if (!file.exists()) throw IllegalStateException("图片不存在")
|
||||
val values =
|
||||
ContentValues().apply {
|
||||
put(MediaStore.Images.Media.DISPLAY_NAME, "fy_${System.currentTimeMillis()}.jpg")
|
||||
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
|
||||
}
|
||||
val uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
|
||||
?: throw IllegalStateException("无法写入相册")
|
||||
contentResolver.openOutputStream(uri)?.use { out ->
|
||||
file.inputStream().use { it.copyTo(out) }
|
||||
} ?: throw IllegalStateException("无法写入相册")
|
||||
}
|
||||
|
||||
private fun openUrl(url: String) {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (requestCode != REQ_PICK) return
|
||||
val cb = pickResult
|
||||
pickResult = null
|
||||
if (cb == null) return
|
||||
if (resultCode != RESULT_OK || data?.data == null) {
|
||||
cb.success(null)
|
||||
return
|
||||
}
|
||||
try {
|
||||
cb.success(copyUri(data.data!!))
|
||||
} catch (e: Exception) {
|
||||
cb.error("pick_failed", e.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyUri(uri: Uri): Map<String, String> {
|
||||
var name = "sticker.gif"
|
||||
contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { c ->
|
||||
if (c.moveToFirst()) {
|
||||
val i = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (i >= 0) name = c.getString(i) ?: name
|
||||
}
|
||||
}
|
||||
val mime = contentResolver.getType(uri) ?: ""
|
||||
val safe = name.replace(Regex("[^a-zA-Z0-9._-]"), "_")
|
||||
val out = File(cacheDir, "pick_${System.currentTimeMillis()}_$safe")
|
||||
contentResolver.openInputStream(uri)?.use { input ->
|
||||
FileOutputStream(out).use { input.copyTo(it) }
|
||||
} ?: throw IllegalStateException("无法读取所选文件")
|
||||
return mapOf("path" to out.absolutePath, "name" to name, "mime" to mime)
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode == REQ_MIC) {
|
||||
val cb = voiceStartResult
|
||||
voiceStartResult = null
|
||||
if (cb == null) return
|
||||
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
beginRecord(cb)
|
||||
} else {
|
||||
cb.error("need_permission", "请允许麦克风权限后再按住说话", null)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (requestCode == REQ_CALL) {
|
||||
val cb = callMediaResult
|
||||
callMediaResult = null
|
||||
if (cb == null) return
|
||||
cb.success(grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED })
|
||||
return
|
||||
}
|
||||
if (requestCode == REQ_LOC) {
|
||||
val cb = locResult
|
||||
locResult = null
|
||||
if (cb == null) return
|
||||
if (grantResults.any { it == PackageManager.PERMISSION_GRANTED }) {
|
||||
emitLocation(cb)
|
||||
} else {
|
||||
cb.error("need_permission", "请允许位置权限后再发送位置", null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
stopScreenshotWatch()
|
||||
try {
|
||||
recorder?.release()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
stopPlay()
|
||||
stopCallTone()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun installApk(path: String) {
|
||||
val file = File(path)
|
||||
if (!file.exists()) throw IllegalStateException("安装包不存在")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
||||
!packageManager.canRequestPackageInstalls()
|
||||
) {
|
||||
startActivity(
|
||||
Intent(
|
||||
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||
Uri.parse("package:$packageName"),
|
||||
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
}
|
||||
val uri = FileProvider.getUriForFile(this, "$packageName.fileprovider", file)
|
||||
val intent =
|
||||
Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val REQ_MIC = 91
|
||||
private const val REQ_PICK = 88
|
||||
private const val REQ_LOC = 93
|
||||
private const val REQ_NOTIFY = 94
|
||||
private const val REQ_CALL = 95
|
||||
private const val REQ_MEDIA = 96
|
||||
private const val CALL_NOTICE_ID = 73101
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.fysxkj.fengying_oa
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import com.meizu.cloud.pushsdk.MzPushMessageReceiver
|
||||
import com.meizu.cloud.pushsdk.handler.MzPushMessage
|
||||
import com.meizu.cloud.pushsdk.platform.message.PushSwitchStatus
|
||||
import com.meizu.cloud.pushsdk.platform.message.RegisterStatus
|
||||
import com.meizu.cloud.pushsdk.platform.message.SubAliasStatus
|
||||
import com.meizu.cloud.pushsdk.platform.message.SubTagsStatus
|
||||
import com.meizu.cloud.pushsdk.platform.message.UnRegisterStatus
|
||||
import org.json.JSONObject
|
||||
|
||||
class MeizuPushReceiver : MzPushMessageReceiver() {
|
||||
override fun onRegisterStatus(context: Context?, registerStatus: RegisterStatus?) {
|
||||
val pushId = registerStatus?.pushId
|
||||
if (context == null || pushId.isNullOrBlank()) {
|
||||
Log.w(TAG, "meizu register ${registerStatus?.code} ${registerStatus?.message}")
|
||||
return
|
||||
}
|
||||
PushStore.saveReg(context, pushId, "meizu")
|
||||
Log.i(TAG, "meizu register ok")
|
||||
}
|
||||
|
||||
override fun onUnRegisterStatus(context: Context?, unRegisterStatus: UnRegisterStatus?) {}
|
||||
|
||||
override fun onPushStatus(context: Context?, pushSwitchStatus: PushSwitchStatus?) {}
|
||||
|
||||
override fun onSubTagsStatus(context: Context?, subTagsStatus: SubTagsStatus?) {}
|
||||
|
||||
override fun onSubAliasStatus(context: Context?, subAliasStatus: SubAliasStatus?) {}
|
||||
|
||||
override fun onMessage(context: Context?, message: String?, platformExtra: String?) {}
|
||||
|
||||
override fun onNotificationClicked(context: Context?, mzPushMessage: MzPushMessage?) {
|
||||
val ctx = context ?: return
|
||||
val intent =
|
||||
Intent(ctx, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
putExtras(parseExtras(mzPushMessage?.selfDefineContentString))
|
||||
}
|
||||
runCatching { ctx.startActivity(intent) }
|
||||
.onFailure { Log.w(TAG, "meizu click", it) }
|
||||
}
|
||||
|
||||
override fun onNotificationArrived(context: Context?, mzPushMessage: MzPushMessage?) {}
|
||||
|
||||
override fun onNotificationDeleted(context: Context?, mzPushMessage: MzPushMessage?) {}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "FengyingPush"
|
||||
|
||||
private fun parseExtras(raw: String?): android.os.Bundle {
|
||||
val extras = android.os.Bundle()
|
||||
if (raw.isNullOrBlank()) return extras
|
||||
runCatching {
|
||||
val obj = JSONObject(raw)
|
||||
val src = if (obj.has("parameters")) obj.getJSONObject("parameters") else obj
|
||||
for (key in listOf(
|
||||
"conversationId", "kind", "fromName", "peerId", "title", "bizType", "bizId",
|
||||
"callId", "callKind", "action", "fromUserId",
|
||||
)) {
|
||||
val v = src.optString(key)
|
||||
if (v.isNotBlank()) extras.putString(key, v)
|
||||
}
|
||||
}
|
||||
return extras
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package com.fysxkj.fengying_oa
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.AudioAttributes
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
|
||||
object OemAdapt {
|
||||
private const val TAG = "FengyingOem"
|
||||
|
||||
fun ensureChannels(ctx: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val nm = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val im =
|
||||
NotificationChannel(PushStore.CHANNEL_IM, "聊天消息", NotificationManager.IMPORTANCE_HIGH).apply {
|
||||
description = "同事发来的即时消息和通话"
|
||||
enableVibration(true)
|
||||
enableLights(true)
|
||||
setShowBadge(true)
|
||||
val sound = Uri.parse("android.resource://${ctx.packageName}/raw/fengying_notification")
|
||||
setSound(sound, AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_NOTIFICATION).build())
|
||||
}
|
||||
val call =
|
||||
NotificationChannel(PushStore.CHANNEL_CALL, "音视频来电", NotificationManager.IMPORTANCE_HIGH).apply {
|
||||
description = "语音和视频来电提醒"
|
||||
enableVibration(true)
|
||||
enableLights(true)
|
||||
setShowBadge(true)
|
||||
val sound = Uri.parse("android.resource://${ctx.packageName}/raw/fengying_incoming")
|
||||
setSound(sound, AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE).build())
|
||||
lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC
|
||||
}
|
||||
val todo =
|
||||
NotificationChannel(PushStore.CHANNEL_TODO, "待办与审批", NotificationManager.IMPORTANCE_DEFAULT).apply {
|
||||
description = "审批、待办和办公提醒"
|
||||
setShowBadge(true)
|
||||
}
|
||||
nm.createNotificationChannel(im)
|
||||
nm.createNotificationChannel(call)
|
||||
nm.createNotificationChannel(todo)
|
||||
}
|
||||
|
||||
fun batteryIgnored(ctx: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
|
||||
val pm = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
return pm.isIgnoringBatteryOptimizations(ctx.packageName)
|
||||
}
|
||||
|
||||
fun requestBattery(ctx: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
|
||||
if (batteryIgnored(ctx)) return
|
||||
try {
|
||||
val intent =
|
||||
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||
data = Uri.parse("package:${ctx.packageName}")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
ctx.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "battery intent", e)
|
||||
openAppDetails(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
fun status(): Map<String, Any> {
|
||||
val vendor = PushStore.detectVendor()
|
||||
val (title, hint) =
|
||||
when (vendor) {
|
||||
"oppo" ->
|
||||
"后台运行与通知" to "请允许自启动、允许后台运行,并把电池优化设为不优化,否则 ColorOS 会杀掉办公消息。"
|
||||
"vivo" ->
|
||||
"后台运行与通知" to "请在 i 管家 / 权限管理里允许自启动、允许后台高耗电,并关闭省电限制,OriginOS 才会及时弹出消息。"
|
||||
"meizu" ->
|
||||
"后台运行与通知" to "请在手机管家里允许自启动、允许后台运行,并关闭电池优化,Flyme 才会及时弹出消息。"
|
||||
"huawei" ->
|
||||
"后台运行与通知" to "请允许自启动、允许关联启动,并把电池优化设为不允许,否则鸿蒙/EMUI 会杀掉办公消息。"
|
||||
else ->
|
||||
"后台运行与通知" to "请允许通知,并把本应用加入电池优化白名单,以便及时收到消息。"
|
||||
}
|
||||
return mapOf(
|
||||
"vendor" to vendor,
|
||||
"title" to title,
|
||||
"hint" to hint,
|
||||
)
|
||||
}
|
||||
|
||||
fun openKeepAlive(ctx: Context) {
|
||||
val vendor = PushStore.detectVendor()
|
||||
val intents =
|
||||
when (vendor) {
|
||||
"oppo" -> oppoIntents(ctx)
|
||||
"vivo" -> vivoIntents(ctx)
|
||||
"meizu" -> meizuIntents(ctx)
|
||||
"huawei" -> huaweiIntents()
|
||||
else -> emptyList()
|
||||
}
|
||||
for (intent in intents) {
|
||||
if (launch(ctx, intent)) return
|
||||
}
|
||||
openAppDetails(ctx)
|
||||
}
|
||||
|
||||
private fun oppoIntents(ctx: Context): List<Intent> {
|
||||
val pkg = ctx.packageName
|
||||
return listOf(
|
||||
component("com.oplus.safecenter", "com.oplus.safecenter.startupapp.view.StartupAppListActivity"),
|
||||
component("com.coloros.safecenter", "com.coloros.safecenter.startupapp.StartupAppListActivity"),
|
||||
component("com.coloros.safecenter", "com.coloros.safecenter.permission.startup.StartupAppListActivity"),
|
||||
component("com.oppo.safe", "com.oppo.safe.permission.startup.StartupAppListActivity"),
|
||||
component("com.coloros.oppoguardelf", "com.coloros.powermanager.fuelgaue.PowerConsumptionActivity"),
|
||||
Intent().setClassName("com.coloros.safecenter", "com.coloros.privacypermissionsentry.PermissionTopActivity"),
|
||||
Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS),
|
||||
).onEach { it.putExtra("packageName", pkg) }
|
||||
}
|
||||
|
||||
private fun vivoIntents(ctx: Context): List<Intent> {
|
||||
val pkg = ctx.packageName
|
||||
return listOf(
|
||||
component("com.vivo.permissionmanager", "com.vivo.permissionmanager.activity.BgStartUpManagerActivity"),
|
||||
component("com.iqoo.secure", "com.iqoo.secure.ui.phoneoptimize.BgStartUpManager"),
|
||||
component("com.iqoo.secure", "com.iqoo.secure.ui.phoneoptimize.AddWhiteListActivity"),
|
||||
component("com.vivo.abe", "com.vivo.applicationbehaviorengine.ui.ExcessivePowerManagerActivity"),
|
||||
Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS),
|
||||
).onEach { it.putExtra("packageName", pkg) }
|
||||
}
|
||||
|
||||
private fun huaweiIntents(): List<Intent> {
|
||||
return listOf(
|
||||
component("com.huawei.systemmanager", "com.huawei.systemmanager.startupmgr.ui.StartupNormalAppListActivity"),
|
||||
component("com.huawei.systemmanager", "com.huawei.systemmanager.appcontrol.activity.StartupAppControlActivity"),
|
||||
component("com.huawei.systemmanager", "com.huawei.systemmanager.optimize.process.ProtectActivity"),
|
||||
component("com.huawei.systemmanager", "com.huawei.systemmanager.power.ui.HwPowerManagerActivity"),
|
||||
Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS),
|
||||
)
|
||||
}
|
||||
|
||||
private fun meizuIntents(ctx: Context): List<Intent> {
|
||||
val pkg = ctx.packageName
|
||||
return listOf(
|
||||
Intent("com.meizu.safe.security.SHOW_APPSEC").apply {
|
||||
setClassName("com.meizu.safe", "com.meizu.safe.security.AppSecActivity")
|
||||
putExtra("packageName", pkg)
|
||||
},
|
||||
component("com.meizu.safe", "com.meizu.safe.permission.PermissionMainActivity"),
|
||||
component("com.meizu.safe", "com.meizu.safe.powerui.PowerAppPermissionActivity"),
|
||||
Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS),
|
||||
)
|
||||
}
|
||||
|
||||
private fun component(pkg: String, cls: String) =
|
||||
Intent().setComponent(ComponentName(pkg, cls)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
private fun openAppDetails(ctx: Context) {
|
||||
try {
|
||||
ctx.startActivity(
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.parse("package:${ctx.packageName}")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
},
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "details", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun launch(ctx: Context, intent: Intent): Boolean {
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
return try {
|
||||
if (intent.resolveActivity(ctx.packageManager) == null) return false
|
||||
ctx.startActivity(intent)
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.fysxkj.fengying_oa
|
||||
|
||||
import android.content.Context
|
||||
import com.heytap.msp.push.mode.DataMessage
|
||||
import com.heytap.msp.push.service.CompatibleDataMessageCallbackService
|
||||
|
||||
class OppoCompatPushService : CompatibleDataMessageCallbackService() {
|
||||
override fun processMessage(context: Context?, message: DataMessage?) {
|
||||
super.processMessage(context, message)
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.fysxkj.fengying_oa
|
||||
|
||||
import android.content.Context
|
||||
import com.heytap.msp.push.mode.DataMessage
|
||||
import com.heytap.msp.push.service.DataMessageCallbackService
|
||||
|
||||
class OppoDataMessageService : DataMessageCallbackService() {
|
||||
override fun processMessage(context: Context?, message: DataMessage?) {
|
||||
super.processMessage(context, message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.fysxkj.fengying_oa
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.heytap.msp.push.callback.ICallBackResultService
|
||||
|
||||
class OppoPushCallback : ICallBackResultService {
|
||||
override fun onRegister(responseCode: Int, registerID: String?) {
|
||||
if (responseCode == 0 && !registerID.isNullOrBlank()) {
|
||||
val ctx = appCtx ?: return
|
||||
PushStore.saveReg(ctx, registerID, "oppo")
|
||||
Log.i(TAG, "oppo register ok")
|
||||
} else {
|
||||
Log.w(TAG, "oppo register $responseCode")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUnRegister(responseCode: Int) {}
|
||||
|
||||
override fun onSetPushTime(responseCode: Int, pushTime: String?) {}
|
||||
|
||||
override fun onGetPushStatus(responseCode: Int, status: Int) {}
|
||||
|
||||
override fun onGetNotificationStatus(responseCode: Int, status: Int) {}
|
||||
|
||||
override fun onError(errorCode: Int, message: String?) {
|
||||
Log.w(TAG, "oppo error $errorCode $message")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "FengyingPush"
|
||||
@Volatile var appCtx: Context? = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.fysxkj.fengying_oa
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
|
||||
object PushStore {
|
||||
const val PREFS = "fy_push"
|
||||
const val KEY_REG = "regId"
|
||||
const val KEY_VENDOR = "vendor"
|
||||
// New id makes the bundled custom sound take effect even on phones that
|
||||
// already created the legacy fy_im notification channel.
|
||||
const val CHANNEL_IM = "fy_im_custom"
|
||||
const val CHANNEL_CALL = "fy_call_v2"
|
||||
const val CHANNEL_TODO = "fy_todo"
|
||||
const val KEY_OEM_PROMPT = "oem_prompted"
|
||||
|
||||
fun saveReg(ctx: Context, id: String, vendor: String = detectVendor()) {
|
||||
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(KEY_REG, id)
|
||||
.putString(KEY_VENDOR, vendor)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun token(ctx: Context): Map<String, String> {
|
||||
val p = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
val reg = p.getString(KEY_REG, "") ?: ""
|
||||
val vendor = p.getString(KEY_VENDOR, detectVendor()) ?: detectVendor()
|
||||
return mapOf(
|
||||
"vendor" to vendor,
|
||||
"regId" to reg,
|
||||
"brand" to Build.BRAND,
|
||||
"model" to Build.MODEL,
|
||||
)
|
||||
}
|
||||
|
||||
fun detectVendor(): String {
|
||||
val m = "${Build.MANUFACTURER} ${Build.BRAND} ${Build.FINGERPRINT}".lowercase()
|
||||
return when {
|
||||
m.contains("vivo") || m.contains("iqoo") -> "vivo"
|
||||
m.contains("oppo") || m.contains("realme") || m.contains("oneplus") || m.contains("oplus") -> "oppo"
|
||||
m.contains("meizu") -> "meizu"
|
||||
isHuaweiFamily(m) -> "huawei"
|
||||
m.contains("xiaomi") || m.contains("redmi") || m.contains("blackshark") -> "xiaomi"
|
||||
else -> "android"
|
||||
}
|
||||
}
|
||||
|
||||
fun isHuaweiFamily(raw: String = "${Build.MANUFACTURER} ${Build.BRAND} ${Build.FINGERPRINT}".lowercase()): Boolean {
|
||||
return listOf("huawei", "honor", "harmony", "hmos", "ohos", "harmonyos").any { raw.contains(it) }
|
||||
}
|
||||
|
||||
fun extrasOf(intent: Intent?): Map<String, String> {
|
||||
val out = linkedMapOf<String, String>()
|
||||
val data = intent?.data
|
||||
if (data != null && data.scheme == "fengyingoa") {
|
||||
when (data.host) {
|
||||
"qr" -> {
|
||||
val ticket = data.getQueryParameter("ticket")
|
||||
if (!ticket.isNullOrBlank()) {
|
||||
out["kind"] = "qr-login"
|
||||
out["ticket"] = ticket
|
||||
}
|
||||
}
|
||||
"chat" -> {
|
||||
val cid = data.getQueryParameter("conversationId")
|
||||
if (!cid.isNullOrBlank()) out["conversationId"] = cid
|
||||
}
|
||||
}
|
||||
}
|
||||
val b = intent?.extras ?: return out
|
||||
for (key in listOf(
|
||||
"conversationId", "kind", "fromName", "peerId", "title", "bizType", "bizId", "ticket",
|
||||
"callId", "callKind", "action", "fromUserId",
|
||||
)) {
|
||||
val v = runCatching { b.getString(key) }.getOrNull()
|
||||
if (!v.isNullOrBlank() && !out.containsKey(key)) out[key] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
fun oemPrompted(ctx: Context) =
|
||||
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(KEY_OEM_PROMPT, false)
|
||||
|
||||
fun markOemPrompted(ctx: Context) {
|
||||
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putBoolean(KEY_OEM_PROMPT, true).apply()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.fysxkj.fengying_oa
|
||||
|
||||
import android.content.Context
|
||||
import com.vivo.push.sdk.OpenClientPushMessageReceiver
|
||||
|
||||
class VivoPushReceiver : OpenClientPushMessageReceiver() {
|
||||
override fun onReceiveRegId(context: Context?, regId: String?) {
|
||||
if (context == null || regId.isNullOrBlank()) return
|
||||
PushStore.saveReg(context, regId, "vivo")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package com.fysxkj.fengying_oa
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import org.json.JSONObject
|
||||
import org.vosk.LibVosk
|
||||
import org.vosk.LogLevel
|
||||
import org.vosk.Model
|
||||
import org.vosk.Recognizer
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
/** 手机端离线语音转文字(Vosk,首次使用自动下载中文小模型到应用目录)。 */
|
||||
object VoiceAsr {
|
||||
private const val MODEL_DIR = "vosk-model-small-cn-0.22"
|
||||
private const val MODEL_URL =
|
||||
"https://alphacephei.com/vosk/models/vosk-model-small-cn-0.22.zip"
|
||||
|
||||
@Volatile
|
||||
private var model: Model? = null
|
||||
|
||||
@Synchronized
|
||||
fun recognize(context: Context, audioPath: String): String? {
|
||||
LibVosk.setLogLevel(LogLevel.WARN)
|
||||
val m = model ?: loadModel(context.applicationContext).also { model = it }
|
||||
val pcm = decodeToPcm16Mono16k(audioPath) ?: return null
|
||||
if (pcm.isEmpty()) return null
|
||||
val recognizer = Recognizer(m, 16000f)
|
||||
try {
|
||||
recognizer.acceptWaveForm(pcm, pcm.size)
|
||||
val json = JSONObject(recognizer.finalResult)
|
||||
val text = json.optString("text", "").trim()
|
||||
return text.ifEmpty { null }
|
||||
} finally {
|
||||
recognizer.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadModel(context: Context): Model {
|
||||
val dir = File(context.filesDir, MODEL_DIR)
|
||||
if (!File(dir, "am/final.mdl").exists()) {
|
||||
downloadAndUnzip(dir)
|
||||
}
|
||||
return Model(dir.absolutePath)
|
||||
}
|
||||
|
||||
private fun downloadAndUnzip(targetDir: File) {
|
||||
if (targetDir.exists()) {
|
||||
targetDir.deleteRecursively()
|
||||
}
|
||||
targetDir.mkdirs()
|
||||
val zipFile = File(targetDir.parentFile, "$MODEL_DIR.zip")
|
||||
val conn = (URL(MODEL_URL).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 30_000
|
||||
readTimeout = 120_000
|
||||
requestMethod = "GET"
|
||||
}
|
||||
conn.inputStream.use { input ->
|
||||
FileOutputStream(zipFile).use { output -> input.copyTo(output) }
|
||||
}
|
||||
conn.disconnect()
|
||||
unzip(zipFile, targetDir.parentFile!!)
|
||||
zipFile.delete()
|
||||
val nested = File(targetDir.parentFile, MODEL_DIR)
|
||||
if (!nested.exists()) {
|
||||
targetDir.parentFile?.listFiles()?.firstOrNull {
|
||||
it.isDirectory && File(it, "am/final.mdl").exists()
|
||||
}?.renameTo(nested)
|
||||
}
|
||||
}
|
||||
|
||||
private fun unzip(zipFile: File, destDir: File) {
|
||||
ZipInputStream(BufferedInputStream(zipFile.inputStream())).use { zis ->
|
||||
var entry = zis.nextEntry
|
||||
while (entry != null) {
|
||||
val outFile = File(destDir, entry.name)
|
||||
if (entry.isDirectory) {
|
||||
outFile.mkdirs()
|
||||
} else {
|
||||
outFile.parentFile?.mkdirs()
|
||||
FileOutputStream(outFile).use { fos -> zis.copyTo(fos) }
|
||||
}
|
||||
zis.closeEntry()
|
||||
entry = zis.nextEntry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeToPcm16Mono16k(path: String): ByteArray? {
|
||||
val extractor = MediaExtractor()
|
||||
try {
|
||||
extractor.setDataSource(path)
|
||||
var track = -1
|
||||
var format: MediaFormat? = null
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
val f = extractor.getTrackFormat(i)
|
||||
val mime = f.getString(MediaFormat.KEY_MIME) ?: continue
|
||||
if (mime.startsWith("audio/")) {
|
||||
track = i
|
||||
format = f
|
||||
break
|
||||
}
|
||||
}
|
||||
if (track < 0 || format == null) return null
|
||||
extractor.selectTrack(track)
|
||||
val mime = format.getString(MediaFormat.KEY_MIME) ?: return null
|
||||
val codec = MediaCodec.createDecoderByType(mime)
|
||||
codec.configure(format, null, null, 0)
|
||||
codec.start()
|
||||
val pcmOut = ByteArrayOutputStream()
|
||||
var sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
||||
var channels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
||||
val bufferInfo = MediaCodec.BufferInfo()
|
||||
var inputDone = false
|
||||
while (true) {
|
||||
if (!inputDone) {
|
||||
val inIndex = codec.dequeueInputBuffer(10_000)
|
||||
if (inIndex >= 0) {
|
||||
val buf = codec.getInputBuffer(inIndex) ?: continue
|
||||
val size = extractor.readSampleData(buf, 0)
|
||||
if (size < 0) {
|
||||
codec.queueInputBuffer(
|
||||
inIndex,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
MediaCodec.BUFFER_FLAG_END_OF_STREAM,
|
||||
)
|
||||
inputDone = true
|
||||
} else {
|
||||
codec.queueInputBuffer(inIndex, 0, size, extractor.sampleTime, 0)
|
||||
extractor.advance()
|
||||
}
|
||||
}
|
||||
}
|
||||
val outIndex = codec.dequeueOutputBuffer(bufferInfo, 10_000)
|
||||
if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
|
||||
val outFormat = codec.outputFormat
|
||||
sampleRate = outFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
||||
channels = outFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
||||
} else if (outIndex >= 0) {
|
||||
val buf = codec.getOutputBuffer(outIndex) ?: continue
|
||||
if (bufferInfo.size > 0) {
|
||||
val chunk = ByteArray(bufferInfo.size)
|
||||
buf.position(bufferInfo.offset)
|
||||
buf.limit(bufferInfo.offset + bufferInfo.size)
|
||||
buf.get(chunk)
|
||||
pcmOut.write(chunk)
|
||||
}
|
||||
codec.releaseOutputBuffer(outIndex, false)
|
||||
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) break
|
||||
}
|
||||
}
|
||||
codec.stop()
|
||||
codec.release()
|
||||
var samples = pcmOut.toByteArray()
|
||||
if (samples.isEmpty()) return null
|
||||
var shorts = bytesToShorts(samples)
|
||||
if (channels > 1) {
|
||||
shorts = downmix(shorts, channels)
|
||||
}
|
||||
if (sampleRate != 16000) {
|
||||
shorts = resample(shorts, sampleRate, 16000)
|
||||
}
|
||||
return shortsToBytes(shorts)
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
} finally {
|
||||
try {
|
||||
extractor.release()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bytesToShorts(bytes: ByteArray): ShortArray {
|
||||
val bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
|
||||
val count = bytes.size / 2
|
||||
val out = ShortArray(count)
|
||||
for (i in 0 until count) out[i] = bb.short
|
||||
return out
|
||||
}
|
||||
|
||||
private fun shortsToBytes(samples: ShortArray): ByteArray {
|
||||
val bb = ByteBuffer.allocate(samples.size * 2).order(ByteOrder.LITTLE_ENDIAN)
|
||||
for (s in samples) bb.putShort(s)
|
||||
return bb.array()
|
||||
}
|
||||
|
||||
private fun downmix(samples: ShortArray, channels: Int): ShortArray {
|
||||
if (channels <= 1) return samples
|
||||
val frames = samples.size / channels
|
||||
val out = ShortArray(frames)
|
||||
for (i in 0 until frames) {
|
||||
var sum = 0
|
||||
for (c in 0 until channels) sum += samples[i * channels + c]
|
||||
out[i] = (sum / channels).toShort()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun resample(samples: ShortArray, fromRate: Int, toRate: Int): ShortArray {
|
||||
if (fromRate == toRate) return samples
|
||||
val ratio = fromRate.toDouble() / toRate.toDouble()
|
||||
val outLen = (samples.size / ratio).toInt().coerceAtLeast(1)
|
||||
val out = ShortArray(outLen)
|
||||
for (i in 0 until outLen) {
|
||||
val src = (i * ratio).toInt().coerceIn(0, samples.size - 1)
|
||||
out[i] = samples[src]
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>
|
||||
</style>
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
<item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>
|
||||
</style>
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
<item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="cache" path="." />
|
||||
<external-cache-path name="ext_cache" path="." />
|
||||
<files-path name="files" path="." />
|
||||
</paths>
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,27 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url "https://developer.huawei.com/repo/" }
|
||||
maven { url "https://repos.vivo.com.cn/maven/repository/external-lib/" }
|
||||
maven {
|
||||
url "https://maven.columbus.heytapmobi.com/repository/releases/"
|
||||
credentials {
|
||||
username "nexus"
|
||||
password "c0b08da17e3ec36c3870fed674a0bcb36abc2e23"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.buildDir = "../build"
|
||||
subprojects {
|
||||
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register("clean", Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
apmsInstrumentationEnabled=false
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
# This newDsl flag was added automatically by Flutter migrator
|
||||
android.newDsl=false
|
||||
@@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
|
||||
@@ -0,0 +1,34 @@
|
||||
pluginManagement {
|
||||
def flutterSdkPath = {
|
||||
def properties = new Properties()
|
||||
file("local.properties").withInputStream { properties.load(it) }
|
||||
def flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
|
||||
return flutterSdkPath
|
||||
}()
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
maven { url "https://developer.huawei.com/repo/" }
|
||||
maven { url "https://repos.vivo.com.cn/maven/repository/external-lib/" }
|
||||
maven {
|
||||
url "https://maven.columbus.heytapmobi.com/repository/releases/"
|
||||
credentials {
|
||||
username "nexus"
|
||||
password "c0b08da17e3ec36c3870fed674a0bcb36abc2e23"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version "8.9.1" apply false
|
||||
id "org.jetbrains.kotlin.android" version "1.9.24" apply false
|
||||
}
|
||||
|
||||
include ":app"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Linux 发布:deb(推荐)+ tar.gz(备用)
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
bash packaging/build-deb.sh "${1:-3.1.10}" "${2:-324}"
|
||||
@@ -0,0 +1,8 @@
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d %~dp0
|
||||
flutter pub get
|
||||
flutter build windows --release
|
||||
echo.
|
||||
echo exe: build\windows\x64\runner\Release\fengying_oa.exe
|
||||
endlocal
|
||||
@@ -0,0 +1,49 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-Location $PSScriptRoot
|
||||
|
||||
$env:PUB_HOSTED_URL = 'https://pub.dev'
|
||||
$env:FLUTTER_STORAGE_BASE_URL = 'https://storage.googleapis.com'
|
||||
|
||||
$flutter = $null
|
||||
foreach ($c in @(
|
||||
'D:\flutter\bin\flutter.bat',
|
||||
'C:\flutter\bin\flutter.bat',
|
||||
(Join-Path $env:LOCALAPPDATA 'flutter\bin\flutter.bat')
|
||||
)) {
|
||||
if (Test-Path $c) { $flutter = $c; break }
|
||||
}
|
||||
if (-not $flutter) {
|
||||
$cmd = Get-Command flutter -ErrorAction SilentlyContinue
|
||||
if ($cmd) { $flutter = $cmd.Source }
|
||||
}
|
||||
if (-not $flutter) {
|
||||
throw '未找到 Flutter。请先运行 installer\bootstrap-windows.ps1'
|
||||
}
|
||||
|
||||
& $flutter pub get
|
||||
if ($LASTEXITCODE -ne 0) { throw "flutter pub get failed: $LASTEXITCODE" }
|
||||
& $flutter build windows --release --build-name=3.1.10 --build-number=324
|
||||
if ($LASTEXITCODE -ne 0) { throw "flutter build windows failed: $LASTEXITCODE" }
|
||||
|
||||
$release = Join-Path $PSScriptRoot 'build\windows\x64\runner\Release'
|
||||
$exe = Join-Path $release 'fengying_oa.exe'
|
||||
if (-not (Test-Path $exe)) { throw "missing $exe" }
|
||||
|
||||
$iscc = @(
|
||||
"${env:ProgramFiles}\Inno Setup 7\ISCC.exe",
|
||||
"${env:ProgramFiles(x86)}\Inno Setup 7\ISCC.exe",
|
||||
"${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe",
|
||||
"${env:ProgramFiles}\Inno Setup 6\ISCC.exe",
|
||||
'C:\Program Files\Inno Setup 7\ISCC.exe',
|
||||
'C:\Program Files (x86)\Inno Setup 6\ISCC.exe'
|
||||
) | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||
if (-not $iscc) { throw '未找到 Inno Setup(ISCC.exe)。请先运行 installer\bootstrap-windows.ps1' }
|
||||
|
||||
$iss = Join-Path $PSScriptRoot 'installer\fengying-oa.iss'
|
||||
& $iscc $iss
|
||||
if ($LASTEXITCODE -ne 0) { throw "ISCC failed: $LASTEXITCODE" }
|
||||
|
||||
$setup = Join-Path $PSScriptRoot 'build\fengying-oa-setup.exe'
|
||||
if (-not (Test-Path $setup)) { throw "missing $setup" }
|
||||
Write-Host "Windows setup: $setup"
|
||||
Write-Host "Windows exe: $exe"
|
||||
@@ -0,0 +1,118 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
|
||||
$env:PUB_HOSTED_URL = 'https://pub.flutter-io.cn'
|
||||
$env:FLUTTER_STORAGE_BASE_URL = 'https://storage.flutter-io.cn'
|
||||
|
||||
function Get-FreeGb([string]$letter) {
|
||||
$d = Get-PSDrive -Name $letter -ErrorAction SilentlyContinue
|
||||
if (-not $d) { return 0 }
|
||||
return [math]::Round($d.Free / 1GB, 1)
|
||||
}
|
||||
|
||||
$sdkRoot = if ((Get-FreeGb 'D') -gt 8) { 'D:\' } else { 'C:\' }
|
||||
$flutterDir = Join-Path $sdkRoot 'flutter'
|
||||
$work = Join-Path $sdkRoot 'oa-build-cache'
|
||||
New-Item -ItemType Directory -Force -Path $work | Out-Null
|
||||
|
||||
function Test-Flutter {
|
||||
$bat = Join-Path $flutterDir 'bin\flutter.bat'
|
||||
return (Test-Path $bat)
|
||||
}
|
||||
|
||||
function Ensure-Flutter {
|
||||
if (Test-Flutter) {
|
||||
Write-Host "Flutter already at $flutterDir"
|
||||
return
|
||||
}
|
||||
$zip = Join-Path $work 'flutter_windows_3.27.4-stable.zip'
|
||||
if (-not (Test-Path $zip) -or ((Get-Item $zip).Length -lt 100MB)) {
|
||||
$urls = @(
|
||||
'https://storage.flutter-io.cn/flutter_infra_release/releases/stable/windows/flutter_windows_3.27.4-stable.zip',
|
||||
'https://fast-mirror.isrc.ac.cn/flutter/flutter_infra/releases/stable/windows/flutter_windows_3.27.4-stable.zip'
|
||||
)
|
||||
$ok = $false
|
||||
foreach ($u in $urls) {
|
||||
Write-Host "Downloading Flutter from $u"
|
||||
try {
|
||||
Invoke-WebRequest -Uri $u -OutFile $zip -UseBasicParsing
|
||||
$ok = $true
|
||||
break
|
||||
} catch {
|
||||
Write-Host "download failed: $_"
|
||||
}
|
||||
}
|
||||
if (-not $ok) { throw 'Flutter SDK 下载失败' }
|
||||
}
|
||||
Write-Host "Extracting Flutter to $flutterDir"
|
||||
if (Test-Path $flutterDir) { Remove-Item $flutterDir -Recurse -Force }
|
||||
Expand-Archive -Path $zip -DestinationPath $sdkRoot -Force
|
||||
if (-not (Test-Flutter)) { throw "unzip 后仍没有 $flutterDir\bin\flutter.bat" }
|
||||
}
|
||||
|
||||
function Ensure-VsBuildTools {
|
||||
$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
|
||||
$ok = $false
|
||||
if (Test-Path $vswhere) {
|
||||
$path = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
|
||||
if ($path) { $ok = $true }
|
||||
}
|
||||
if ($ok) {
|
||||
Write-Host "VS C++ tools already installed"
|
||||
return
|
||||
}
|
||||
$boot = Join-Path $work 'vs_BuildTools.exe'
|
||||
if (-not (Test-Path $boot)) {
|
||||
Write-Host 'Downloading VS 2022 Build Tools bootstrapper'
|
||||
$urls = @(
|
||||
'https://aka.ms/vs/17/release/vs_BuildTools.exe'
|
||||
)
|
||||
$got = $false
|
||||
foreach ($u in $urls) {
|
||||
try {
|
||||
Invoke-WebRequest -Uri $u -OutFile $boot -UseBasicParsing
|
||||
$got = $true
|
||||
break
|
||||
} catch {
|
||||
Write-Host "bootstrapper download failed: $_"
|
||||
}
|
||||
}
|
||||
if (-not $got) { throw 'VS Build Tools 引导程序下载失败' }
|
||||
}
|
||||
$installPath = Join-Path $sdkRoot 'BuildTools'
|
||||
Write-Host "Installing VS Build Tools to $installPath (this takes a while)"
|
||||
$p = Start-Process -FilePath $boot -ArgumentList @(
|
||||
'--quiet', '--wait', '--norestart', '--nocache',
|
||||
'--installPath', $installPath,
|
||||
'--add', 'Microsoft.VisualStudio.Workload.VCTools',
|
||||
'--includeRecommended',
|
||||
'--add', 'Microsoft.VisualStudio.Component.Windows11SDK.22621'
|
||||
) -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) {
|
||||
throw "VS Build Tools install failed: $($p.ExitCode)"
|
||||
}
|
||||
}
|
||||
|
||||
function Ensure-Inno {
|
||||
$iscc = @(
|
||||
"${env:ProgramFiles}\Inno Setup 7\ISCC.exe",
|
||||
"${env:ProgramFiles(x86)}\Inno Setup 7\ISCC.exe",
|
||||
"${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe",
|
||||
"${env:ProgramFiles}\Inno Setup 6\ISCC.exe"
|
||||
) | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||
if ($iscc) {
|
||||
Write-Host "Inno Setup already at $iscc"
|
||||
return
|
||||
}
|
||||
$setup = Join-Path $work 'innosetup.exe'
|
||||
Write-Host 'Downloading Inno Setup'
|
||||
Invoke-WebRequest -Uri 'https://jrsoftware.org/download.php/is.exe' -OutFile $setup -UseBasicParsing
|
||||
$p = Start-Process -FilePath $setup -ArgumentList '/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { throw "Inno Setup install failed: $($p.ExitCode)" }
|
||||
}
|
||||
|
||||
Ensure-Flutter
|
||||
Ensure-VsBuildTools
|
||||
Ensure-Inno
|
||||
Write-Host 'toolchain ready'
|
||||
Write-Host "flutter=$flutterDir\bin\flutter.bat"
|
||||
@@ -0,0 +1,43 @@
|
||||
#define MyAppName "风影办公"
|
||||
#define MyAppVersion "3.1.8"
|
||||
#define MyAppPublisher "江苏风影随行科技有限公司"
|
||||
#define MyAppExeName "fengying_oa.exe"
|
||||
#define MyAppURL "https://oa.fysxkj.com"
|
||||
|
||||
[Setup]
|
||||
AppId={{8E3F2A11-6C4B-4D91-9E27-FENGINGOA3180}
|
||||
AppName={#MyAppName}
|
||||
AppVersion={#MyAppVersion}
|
||||
AppPublisher={#MyAppPublisher}
|
||||
AppPublisherURL={#MyAppURL}
|
||||
AppSupportURL={#MyAppURL}
|
||||
DefaultDirName={autopf}\FengyingOA
|
||||
DefaultGroupName={#MyAppName}
|
||||
DisableProgramGroupPage=yes
|
||||
OutputDir=..\build
|
||||
OutputBaseFilename=fengying-oa-setup
|
||||
Compression=lzma2
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
PrivilegesRequired=lowest
|
||||
PrivilegesRequiredOverridesAllowed=dialog
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
UninstallDisplayIcon={app}\{#MyAppExeName}
|
||||
SetupIconFile=..\windows\runner\resources\app_icon.ico
|
||||
|
||||
[Languages]
|
||||
Name: "chinesesimplified"; MessagesFile: "compiler:Languages\ChineseSimplified.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "创建桌面快捷方式"; GroupDescription: "附加选项:"; Flags: unchecked
|
||||
|
||||
[Files]
|
||||
Source: "..\build\windows\x64\runner\Release\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
|
||||
[Icons]
|
||||
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
||||
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\{#MyAppExeName}"; Description: "启动 {#MyAppName}"; Flags: nowait postinstall skipifsilent
|
||||
@@ -0,0 +1,358 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http_parser/http_parser.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../app_config.dart';
|
||||
import '../session/session.dart';
|
||||
|
||||
class ApiException implements Exception {
|
||||
ApiException(this.message);
|
||||
final String message;
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class OaClient {
|
||||
OaClient(this.session);
|
||||
final SessionStore session;
|
||||
bool _refreshing = false;
|
||||
|
||||
Uri _uri(String path, [Map<String, String>? query]) {
|
||||
final base = session.apiBase.replaceAll(RegExp(r'/$'), '');
|
||||
final p = path.startsWith('/') ? path : '/$path';
|
||||
return Uri.parse('$base$p').replace(queryParameters: query);
|
||||
}
|
||||
|
||||
Map<String, String> _headers({bool auth = true}) {
|
||||
final h = <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
'X-Request-Id': const Uuid().v4(),
|
||||
};
|
||||
if (auth && session.accessToken.isNotEmpty) {
|
||||
h['Authorization'] = 'Bearer ${session.accessToken}';
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
Future<dynamic> get(String path, {Map<String, String>? query}) =>
|
||||
_send('GET', path, query: query);
|
||||
|
||||
Future<dynamic> post(String path, [Map<String, dynamic>? body]) =>
|
||||
_send('POST', path, body: body);
|
||||
|
||||
Future<dynamic> patch(String path, [Map<String, dynamic>? body]) =>
|
||||
_send('PATCH', path, body: body);
|
||||
|
||||
final _fileCache = <String, Uint8List>{};
|
||||
|
||||
Uint8List? peekFile(String id) => _fileCache[id];
|
||||
|
||||
Future<Uint8List?> fileBytes(String id) async {
|
||||
if (id.isEmpty) return null;
|
||||
final hit = _fileCache[id];
|
||||
if (hit != null) return hit;
|
||||
final uri = _uri('/files/$id');
|
||||
final headers = <String, String>{
|
||||
'X-Request-Id': const Uuid().v4(),
|
||||
if (session.accessToken.isNotEmpty) 'Authorization': 'Bearer ${session.accessToken}',
|
||||
};
|
||||
try {
|
||||
var res = await http.get(uri, headers: headers).timeout(const Duration(seconds: 30));
|
||||
if (res.statusCode == 401 && session.refreshToken.isNotEmpty) {
|
||||
final ok = await _refresh();
|
||||
if (ok) {
|
||||
headers['Authorization'] = 'Bearer ${session.accessToken}';
|
||||
res = await http.get(uri, headers: headers).timeout(const Duration(seconds: 30));
|
||||
}
|
||||
}
|
||||
if (res.statusCode >= 400 || res.bodyBytes.isEmpty) return null;
|
||||
if (_fileCache.length > 80) _fileCache.remove(_fileCache.keys.first);
|
||||
_fileCache[id] = res.bodyBytes;
|
||||
return res.bodyBytes;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Uint8List?> getBytes(String path, {Map<String, String>? query}) async {
|
||||
final uri = _uri(path, query);
|
||||
final headers = <String, String>{
|
||||
'X-Request-Id': const Uuid().v4(),
|
||||
if (session.accessToken.isNotEmpty) 'Authorization': 'Bearer ${session.accessToken}',
|
||||
};
|
||||
try {
|
||||
var res = await http.get(uri, headers: headers).timeout(const Duration(seconds: 20));
|
||||
if (res.statusCode == 401 && session.refreshToken.isNotEmpty) {
|
||||
final ok = await _refresh();
|
||||
if (ok) {
|
||||
headers['Authorization'] = 'Bearer ${session.accessToken}';
|
||||
res = await http.get(uri, headers: headers).timeout(const Duration(seconds: 20));
|
||||
}
|
||||
}
|
||||
if (res.statusCode >= 400 || res.bodyBytes.isEmpty) return null;
|
||||
return res.bodyBytes;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> uploadFile({
|
||||
required String filePath,
|
||||
required String filename,
|
||||
required String bizType,
|
||||
required String bizId,
|
||||
String? mime,
|
||||
}) async {
|
||||
final uri = _uri('/files');
|
||||
final type = _mediaType(filename, mime);
|
||||
Future<http.Response> send() async {
|
||||
final req = http.MultipartRequest('POST', uri);
|
||||
req.headers['Authorization'] = 'Bearer ${session.accessToken}';
|
||||
req.headers['X-Request-Id'] = const Uuid().v4();
|
||||
req.fields['bizType'] = bizType;
|
||||
req.fields['bizId'] = bizId;
|
||||
req.files.add(await http.MultipartFile.fromPath('file', filePath, filename: filename, contentType: type));
|
||||
return http.Response.fromStream(await req.send().timeout(const Duration(seconds: 60)));
|
||||
}
|
||||
|
||||
var res = await send();
|
||||
if (res.statusCode == 401 && session.refreshToken.isNotEmpty) {
|
||||
final ok = await _refresh();
|
||||
if (ok) res = await send();
|
||||
}
|
||||
if ((res.statusCode == 502 || res.statusCode == 503) && session.accessToken.isNotEmpty) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 700));
|
||||
res = await send();
|
||||
}
|
||||
dynamic json;
|
||||
try {
|
||||
json = res.body.isEmpty ? null : jsonDecode(res.body);
|
||||
} catch (_) {
|
||||
throw ApiException(res.statusCode >= 400 ? '上传失败 ${res.statusCode}' : '响应无法解析');
|
||||
}
|
||||
if (json is Map && json['code'] != null && json['code'] != 0) {
|
||||
throw ApiException('${json['message'] ?? '上传失败'}');
|
||||
}
|
||||
if (res.statusCode >= 400) {
|
||||
final msg = json is Map ? (json['message'] ?? json['error'] ?? res.statusCode) : res.statusCode;
|
||||
throw ApiException('$msg');
|
||||
}
|
||||
final data = json is Map && json.containsKey('data') ? json['data'] : json;
|
||||
return data is Map ? Map<String, dynamic>.from(data) : {'id': '$data'};
|
||||
}
|
||||
|
||||
Future<dynamic> _send(
|
||||
String method,
|
||||
String path, {
|
||||
Map<String, String>? query,
|
||||
Map<String, dynamic>? body,
|
||||
bool retry = true,
|
||||
}) async {
|
||||
final uri = _uri(path, query);
|
||||
final headers = _headers(
|
||||
auth: !path.startsWith('/auth/login') &&
|
||||
!path.startsWith('/auth/sms-code') &&
|
||||
path != '/auth/login-options' &&
|
||||
path != '/system/app-release' &&
|
||||
!path.startsWith('/legal'),
|
||||
);
|
||||
http.Response res;
|
||||
try {
|
||||
if (method == 'GET') {
|
||||
res = await http.get(uri, headers: headers).timeout(const Duration(seconds: 30));
|
||||
} else if (method == 'PATCH') {
|
||||
res = await http.patch(uri, headers: headers, body: jsonEncode(body ?? {})).timeout(const Duration(seconds: 30));
|
||||
} else {
|
||||
res = await http.post(uri, headers: headers, body: jsonEncode(body ?? {})).timeout(const Duration(seconds: 30));
|
||||
}
|
||||
} catch (e) {
|
||||
throw ApiException('网络不可用:$e');
|
||||
}
|
||||
if ((res.statusCode == 502 || res.statusCode == 503) && retry) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 700));
|
||||
return _send(method, path, query: query, body: body, retry: false);
|
||||
}
|
||||
if (res.statusCode == 401 && retry && session.refreshToken.isNotEmpty && !path.contains('/auth/refresh')) {
|
||||
final ok = await _refresh();
|
||||
if (ok) return _send(method, path, query: query, body: body, retry: false);
|
||||
await session.clear();
|
||||
throw ApiException('登录已过期,请重新登录');
|
||||
}
|
||||
dynamic json;
|
||||
try {
|
||||
json = res.body.isEmpty ? null : jsonDecode(res.body);
|
||||
} catch (_) {
|
||||
throw ApiException(res.statusCode >= 400 ? '请求失败 ${res.statusCode}' : '响应无法解析');
|
||||
}
|
||||
if (json is Map && json['code'] != null && json['code'] != 0) {
|
||||
throw ApiException('${json['message'] ?? '请求失败'}');
|
||||
}
|
||||
if (res.statusCode >= 400) {
|
||||
final msg = json is Map ? (json['message'] ?? json['error'] ?? res.statusCode) : res.statusCode;
|
||||
throw ApiException('$msg');
|
||||
}
|
||||
if (json is Map && json.containsKey('data')) return json['data'];
|
||||
return json;
|
||||
}
|
||||
|
||||
Future<bool> _refresh() async {
|
||||
if (_refreshing) return false;
|
||||
_refreshing = true;
|
||||
try {
|
||||
final uri = _uri('/auth/refresh');
|
||||
final res = await http.post(
|
||||
uri,
|
||||
headers: _headers(auth: false),
|
||||
body: jsonEncode({
|
||||
'refreshToken': session.refreshToken,
|
||||
'clientKind': 'mobile',
|
||||
}),
|
||||
);
|
||||
if (res.statusCode >= 400) return false;
|
||||
final json = jsonDecode(res.body);
|
||||
final data = json is Map && json['data'] is Map ? json['data'] as Map : json;
|
||||
final access = '${data['accessToken'] ?? ''}';
|
||||
final refresh = '${data['refreshToken'] ?? ''}';
|
||||
if (access.isEmpty) return false;
|
||||
await session.setTokens(access, refresh.isEmpty ? session.refreshToken : refresh);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
} finally {
|
||||
_refreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
MediaType _mediaType(String filename, String? mime) {
|
||||
var raw = (mime ?? '').split(';').first.trim().toLowerCase();
|
||||
if (raw == 'image/jpg' || raw == 'image/pjpeg') raw = 'image/jpeg';
|
||||
if (raw.isEmpty || raw == 'application/octet-stream') {
|
||||
final n = filename.toLowerCase();
|
||||
if (n.endsWith('.jpg') || n.endsWith('.jpeg')) {
|
||||
raw = 'image/jpeg';
|
||||
} else if (n.endsWith('.png')) {
|
||||
raw = 'image/png';
|
||||
} else if (n.endsWith('.gif')) {
|
||||
raw = 'image/gif';
|
||||
} else if (n.endsWith('.webp')) {
|
||||
raw = 'image/webp';
|
||||
} else if (n.endsWith('.m4a') || n.endsWith('.mp4')) {
|
||||
raw = 'audio/mp4';
|
||||
} else if (n.endsWith('.pdf')) {
|
||||
raw = 'application/pdf';
|
||||
} else {
|
||||
raw = 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
try {
|
||||
return MediaType.parse(raw);
|
||||
} catch (_) {
|
||||
return MediaType('application', 'octet-stream');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> asMaps(dynamic data) {
|
||||
final list = extractList(data);
|
||||
return list.whereType<Map>().map((e) => Map<String, dynamic>.from(e)).toList();
|
||||
}
|
||||
|
||||
List<dynamic> extractList(dynamic data) {
|
||||
if (data is List) return data;
|
||||
if (data is Map) {
|
||||
for (final k in ['items', 'list', 'rows', 'records', 'data']) {
|
||||
if (data[k] is List) return data[k] as List;
|
||||
}
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
String scalarText(dynamic v) {
|
||||
if (v == null) return '';
|
||||
if (v is Map) {
|
||||
return '${v['displayName'] ?? v['name'] ?? v['title'] ?? v['departmentName'] ?? ''}'.trim();
|
||||
}
|
||||
if (v is List) {
|
||||
return v.map(scalarText).where((e) => e.isNotEmpty).join('、');
|
||||
}
|
||||
final s = '$v'.trim();
|
||||
if (s.startsWith('{') && (s.contains('id:') || s.contains('tenantId'))) return '';
|
||||
return s;
|
||||
}
|
||||
|
||||
String pickTitle(Map<String, dynamic> row) {
|
||||
for (final k in [
|
||||
'title',
|
||||
'name',
|
||||
'displayName',
|
||||
'projectName',
|
||||
'bidNo',
|
||||
'contractNo',
|
||||
'claimNo',
|
||||
'loanNo',
|
||||
'projectNo',
|
||||
'purpose',
|
||||
'reason',
|
||||
'label',
|
||||
]) {
|
||||
final s = scalarText(row[k]);
|
||||
if (s.isNotEmpty) return s;
|
||||
}
|
||||
return row['id']?.toString() ?? '未命名';
|
||||
}
|
||||
|
||||
String pickSub(Map<String, dynamic> row) {
|
||||
for (final k in [
|
||||
'departmentName',
|
||||
'department',
|
||||
'status',
|
||||
'title',
|
||||
'bidNo',
|
||||
'contractNo',
|
||||
'claimNo',
|
||||
'kind',
|
||||
'mobile',
|
||||
'lastText',
|
||||
'employmentStatus',
|
||||
]) {
|
||||
final s = scalarText(row[k]);
|
||||
if (s.isNotEmpty) return s;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
String fmtTime(dynamic v) {
|
||||
if (v == null) return '';
|
||||
final s = '$v';
|
||||
final t = DateTime.tryParse(s);
|
||||
if (t != null) {
|
||||
final local = t.toLocal();
|
||||
final date = '${local.year.toString().padLeft(4, '0')}-${local.month.toString().padLeft(2, '0')}-${local.day.toString().padLeft(2, '0')}';
|
||||
final time = '${local.hour.toString().padLeft(2, '0')}:${local.minute.toString().padLeft(2, '0')}:${local.second.toString().padLeft(2, '0')}';
|
||||
return '$date $time';
|
||||
}
|
||||
if (s.length >= 16) return s.substring(0, 16).replaceFirst('T', ' ');
|
||||
return s;
|
||||
}
|
||||
|
||||
String shortTime(dynamic v) {
|
||||
if (v == null) return '';
|
||||
final t = DateTime.tryParse('$v');
|
||||
if (t == null) return fmtTime(v);
|
||||
final now = DateTime.now();
|
||||
final local = t.toLocal();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final day = DateTime(local.year, local.month, local.day);
|
||||
final hh = local.hour.toString().padLeft(2, '0');
|
||||
final mm = local.minute.toString().padLeft(2, '0');
|
||||
if (day == today) return '$hh:$mm';
|
||||
if (day == today.subtract(const Duration(days: 1))) return '昨天';
|
||||
const week = ['一', '二', '三', '四', '五', '六', '日'];
|
||||
if (today.difference(day).inDays < 7) return '周${week[local.weekday - 1]}';
|
||||
return '${local.month}/${local.day}';
|
||||
}
|
||||
|
||||
const defaultApiHint = AppConfig.defaultApiBase;
|
||||
@@ -0,0 +1,13 @@
|
||||
class AppConfig {
|
||||
static const brand = '风影智慧办公综合平台';
|
||||
static const shortName = '风影办公';
|
||||
static const company = '江苏风影随行科技有限公司';
|
||||
static const icp = '苏ICP备2026017371号-2';
|
||||
static const icpUrl = 'https://beian.miit.gov.cn/';
|
||||
static const phone = '13142801138';
|
||||
static const defaultApiBase = 'https://oa.fysxkj.com/api/v1';
|
||||
static const defaultImHost = '47.96.23.244';
|
||||
static const defaultImPort = 8901;
|
||||
static const version = '3.1.10';
|
||||
static const build = 324;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import 'app_config.dart';
|
||||
|
||||
/// 运行时从安装包读取的真实版本,避免 OTA 误判。
|
||||
class AppRuntime {
|
||||
AppRuntime._();
|
||||
|
||||
static String version = AppConfig.version;
|
||||
static int build = AppConfig.build;
|
||||
|
||||
static Future<void> load() async {
|
||||
try {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
version = info.version.isNotEmpty ? info.version : AppConfig.version;
|
||||
build = int.tryParse(info.buildNumber) ?? AppConfig.build;
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import 'dart:io';
|
||||
|
||||
/// Windows / Linux 桌面端,与安卓 UI 完全分离。
|
||||
bool get isDesktopPlatform => Platform.isWindows || Platform.isLinux;
|
||||
@@ -0,0 +1,369 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../device/push_bridge.dart';
|
||||
import '../nav/notice_route.dart';
|
||||
import '../ota/updater.dart';
|
||||
import '../pages/call_page.dart';
|
||||
import '../session/session.dart';
|
||||
import '../im/im_lifecycle.dart';
|
||||
import 'desk_platform.dart';
|
||||
import 'desk_theme.dart';
|
||||
import 'pages/desk_chat_page.dart';
|
||||
import 'pages/desk_contacts_page.dart';
|
||||
import 'pages/desk_conv_list_page.dart';
|
||||
import 'pages/desk_profile_page.dart';
|
||||
import 'pages/desk_system_notice_page.dart';
|
||||
import 'pages/desk_workbench_page.dart';
|
||||
import 'widgets/desk_widgets.dart';
|
||||
|
||||
/// 桌面端主壳:仅引用 lib/desktop/*,不加载任何移动端页面组件。
|
||||
class DeskShell extends StatefulWidget {
|
||||
const DeskShell({super.key, required this.session, required this.api});
|
||||
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
|
||||
@override
|
||||
State<DeskShell> createState() => _DeskShellState();
|
||||
}
|
||||
|
||||
class _DeskShellState extends State<DeskShell> {
|
||||
int _nav = 0;
|
||||
int _msgBadge = 0;
|
||||
Timer? _badgeDebounce;
|
||||
Timer? _callPoll;
|
||||
String _ringingId = '';
|
||||
bool _checkingCall = false;
|
||||
final _declinedCalls = <String>{};
|
||||
ImLifecycle? _imLife;
|
||||
|
||||
String? _chatConvId;
|
||||
String? _chatPeerId;
|
||||
String _chatPeerName = '';
|
||||
bool _chatGroup = false;
|
||||
String _chatAvatar = '';
|
||||
|
||||
final List<_DeskTab> _tabs = [_DeskTab.home()];
|
||||
int _activeTab = 0;
|
||||
bool _otaOnce = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_imLife = ImLifecycle(widget.session)..start();
|
||||
widget.session.im.onLoggedIn = () => _refreshBadge();
|
||||
widget.session.im.addListener(_onIm);
|
||||
_refreshBadge();
|
||||
PushBridge.listen(_openFromPush);
|
||||
_callPoll = Timer.periodic(const Duration(seconds: 8), (_) => _checkIncoming());
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _checkOta());
|
||||
}
|
||||
|
||||
Future<void> _checkOta() async {
|
||||
if (_otaOnce) return;
|
||||
_otaOnce = true;
|
||||
final rel = await OtaUpdater(widget.api).fetch();
|
||||
if (rel != null && rel.newer && mounted) {
|
||||
await OtaUpdater(widget.api).prompt(context, rel);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_badgeDebounce?.cancel();
|
||||
_callPoll?.cancel();
|
||||
widget.session.im.onLoggedIn = null;
|
||||
_imLife?.stop();
|
||||
widget.session.im.removeListener(_onIm);
|
||||
PushBridge.stop();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onIm() {
|
||||
final inbox = widget.session.im.inbox;
|
||||
if (inbox.isNotEmpty && inbox.first.kind == 'readSync') {
|
||||
_refreshBadge();
|
||||
return;
|
||||
}
|
||||
_badgeDebounce?.cancel();
|
||||
_badgeDebounce = Timer(const Duration(milliseconds: 1200), _refreshBadge);
|
||||
final hit = widget.session.im.inbox.where((e) => e.kind == 'call').toList();
|
||||
if (hit.isNotEmpty) _checkIncoming();
|
||||
}
|
||||
|
||||
Future<void> _refreshBadge() async {
|
||||
var n = 0;
|
||||
try {
|
||||
final data = await widget.api.get('/im/conversations');
|
||||
for (final r in asMaps(data)) {
|
||||
n += (r['unread'] as num?)?.toInt() ?? 0;
|
||||
}
|
||||
} catch (_) {}
|
||||
if (mounted && n != _msgBadge) setState(() => _msgBadge = n);
|
||||
}
|
||||
|
||||
Future<void> _checkIncoming() async {
|
||||
if (!mounted || _ringingId.isNotEmpty || _checkingCall) return;
|
||||
_checkingCall = true;
|
||||
try {
|
||||
final rows = asMaps(await widget.api.get('/im/calls/incoming'));
|
||||
final me = widget.session.userId;
|
||||
Map<String, dynamic>? ring;
|
||||
for (final r in rows) {
|
||||
if ('${r['initiatorId']}' != me && '${r['status']}' == 'ringing') {
|
||||
ring = r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ring == null) return;
|
||||
final id = '${ring['id'] ?? ''}';
|
||||
if (id.isEmpty || id == _ringingId || _declinedCalls.contains(id)) return;
|
||||
final created = DateTime.tryParse('${ring['createdAt'] ?? ''}');
|
||||
if (created != null && DateTime.now().difference(created).inSeconds > 45) {
|
||||
_declinedCalls.add(id);
|
||||
return;
|
||||
}
|
||||
await _showIncoming(ring);
|
||||
} catch (_) {
|
||||
} finally {
|
||||
_checkingCall = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showIncoming(Map<String, dynamic> call, {String fallbackName = '同事'}) async {
|
||||
final id = '${call['id'] ?? ''}';
|
||||
if (!mounted || id.isEmpty || _ringingId.isNotEmpty || _declinedCalls.contains(id)) return;
|
||||
_ringingId = id;
|
||||
await openCallPage(
|
||||
context,
|
||||
session: widget.session,
|
||||
api: widget.api,
|
||||
call: call,
|
||||
peerName: '${call['fromName'] ?? call['title'] ?? fallbackName}',
|
||||
incoming: true,
|
||||
);
|
||||
_declinedCalls.add(id);
|
||||
if (_declinedCalls.length > 40) _declinedCalls.remove(_declinedCalls.first);
|
||||
if (mounted) _ringingId = '';
|
||||
}
|
||||
|
||||
String _openedPush = '';
|
||||
|
||||
void _openFromPush(Map<String, String> extras) {
|
||||
if (extras['kind'] == 'qr-login') {
|
||||
// 桌面端已登录,扫码登录是手机扫电脑二维码,不应在桌面打开扫码页
|
||||
return;
|
||||
}
|
||||
if (extras['kind'] == 'call') {
|
||||
unawaited(_openIncomingCallFromPush(extras));
|
||||
return;
|
||||
}
|
||||
final cid = extras['conversationId'] ?? '';
|
||||
final kind = extras['kind'] ?? '';
|
||||
final key = '$cid|$kind|${extras['callId'] ?? extras['bizId'] ?? ''}';
|
||||
if (key == _openedPush) return;
|
||||
_openedPush = key;
|
||||
if (kind == 'todo' || kind == 'approval' || ((extras['bizType'] ?? '').isNotEmpty && kind != 'chat')) {
|
||||
openNoticeTarget(
|
||||
context,
|
||||
widget.api,
|
||||
session: widget.session,
|
||||
kind: kind,
|
||||
bizType: extras['bizType'] ?? '',
|
||||
bizId: extras['bizId'] ?? '',
|
||||
title: extras['title'] ?? '系统通知',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (cid.isEmpty) {
|
||||
setState(() => _nav = 0);
|
||||
return;
|
||||
}
|
||||
_pickChat({
|
||||
'id': cid,
|
||||
'peerId': extras['peerId'] ?? '',
|
||||
'name': extras['fromName'] ?? extras['title'] ?? '聊天',
|
||||
'type': extras['isGroup'] == '1' ? 'group' : 'direct',
|
||||
'avatarFileId': extras['avatarFileId'] ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _openIncomingCallFromPush(Map<String, String> extras) async {
|
||||
final callId = (extras['callId'] ?? '').isNotEmpty ? extras['callId']! : (extras['bizId'] ?? '');
|
||||
try {
|
||||
Map<String, dynamic>? call;
|
||||
if (callId.isNotEmpty) {
|
||||
final raw = await widget.api.get('/im/calls/$callId');
|
||||
if (raw is Map) {
|
||||
final row = Map<String, dynamic>.from(raw);
|
||||
if ('${row['status'] ?? ''}' == 'ringing' && '${row['memberStatus'] ?? ''}' == 'invited') call = row;
|
||||
}
|
||||
}
|
||||
if (call != null && mounted) {
|
||||
call['fromName'] ??= extras['fromName'];
|
||||
await _showIncoming(call, fallbackName: extras['fromName'] ?? '同事');
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
final cid = extras['conversationId'] ?? '';
|
||||
if (mounted && cid.isNotEmpty) {
|
||||
_pickChat({
|
||||
'id': cid,
|
||||
'peerId': extras['peerId'] ?? '',
|
||||
'name': extras['fromName'] ?? extras['title'] ?? '聊天',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _pickChat(Map<String, dynamic> row) {
|
||||
setState(() {
|
||||
_nav = 0;
|
||||
_chatConvId = '${row['id'] ?? row['conversationId'] ?? ''}';
|
||||
_chatPeerId = '${row['peerId'] ?? row['id'] ?? ''}';
|
||||
_chatPeerName = '${row['name'] ?? row['peerName'] ?? '同事'}';
|
||||
_chatGroup = row['type'] == 'group';
|
||||
_chatAvatar = '${row['avatarFileId'] ?? ''}';
|
||||
});
|
||||
}
|
||||
|
||||
void _openTab(String key, String title, IconData icon, Color color, Widget page) {
|
||||
final idx = _tabs.indexWhere((t) => t.key == key);
|
||||
setState(() {
|
||||
_nav = 1;
|
||||
if (idx >= 0) {
|
||||
_activeTab = idx;
|
||||
} else {
|
||||
_tabs.add(_DeskTab(key: key, title: title, icon: icon, color: color, page: page));
|
||||
_activeTab = _tabs.length - 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _closeTab(int i) {
|
||||
if (i <= 0 || i >= _tabs.length) return;
|
||||
setState(() {
|
||||
_tabs.removeAt(i);
|
||||
if (_activeTab >= _tabs.length) _activeTab = _tabs.length - 1;
|
||||
});
|
||||
}
|
||||
|
||||
Widget _messagesPane() {
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: kDeskConvWidth,
|
||||
child: DeskConvListPage(
|
||||
session: widget.session,
|
||||
api: widget.api,
|
||||
selectedId: _chatConvId,
|
||||
onSelect: _pickChat,
|
||||
),
|
||||
),
|
||||
const VerticalDivider(width: 1, color: kDeskLine),
|
||||
Expanded(
|
||||
child: _chatConvId == null || _chatConvId!.isEmpty
|
||||
? const DeskEmptyChat()
|
||||
: _chatPeerId == '0'
|
||||
? DeskSystemNoticePage(session: widget.session, api: widget.api, conversationId: _chatConvId!)
|
||||
: DeskChatPage(
|
||||
session: widget.session,
|
||||
api: widget.api,
|
||||
conversationId: _chatConvId!,
|
||||
peerId: _chatPeerId ?? '',
|
||||
peerName: _chatPeerName,
|
||||
isGroup: _chatGroup,
|
||||
peerAvatarFileId: _chatAvatar,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _workbenchPane() {
|
||||
return Column(
|
||||
children: [
|
||||
DeskTabStrip(
|
||||
tabs: [for (final t in _tabs) (t.key, t.title, t.icon, t.color)],
|
||||
active: _activeTab,
|
||||
onSelect: (i) => setState(() => _activeTab = i),
|
||||
onClose: _closeTab,
|
||||
),
|
||||
Expanded(
|
||||
child: IndexedStack(
|
||||
index: _activeTab,
|
||||
children: [
|
||||
for (final t in _tabs)
|
||||
t.key == 'home'
|
||||
? DeskWorkbenchPage(session: widget.session, api: widget.api, onOpenTab: _openTab)
|
||||
: t.page,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final navItems = <(IconData, IconData, String, int)>[
|
||||
(Icons.chat_bubble_outline, Icons.chat_bubble, '消息', _msgBadge),
|
||||
(Icons.apps_outlined, Icons.apps, '工作台', 0),
|
||||
(Icons.account_tree_outlined, Icons.account_tree, '通讯录', 0),
|
||||
(Icons.person_outline, Icons.person, '我', 0),
|
||||
];
|
||||
|
||||
Widget body;
|
||||
switch (_nav) {
|
||||
case 0:
|
||||
body = _messagesPane();
|
||||
case 1:
|
||||
body = _workbenchPane();
|
||||
case 2:
|
||||
body = DeskContactsPage(session: widget.session, api: widget.api, onMessage: (r) {
|
||||
_pickChat({
|
||||
'id': '${r['conversationId'] ?? ''}',
|
||||
'peerId': '${r['id'] ?? r['peerId'] ?? ''}',
|
||||
'name': '${r['name'] ?? '同事'}',
|
||||
'avatarFileId': '${r['avatarFileId'] ?? ''}',
|
||||
'type': 'direct',
|
||||
});
|
||||
setState(() => _nav = 0);
|
||||
});
|
||||
default:
|
||||
body = DeskProfilePage(session: widget.session, api: widget.api);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: kDeskBg,
|
||||
body: Row(
|
||||
children: [
|
||||
DeskNavRail(
|
||||
session: widget.session,
|
||||
api: widget.api,
|
||||
index: _nav,
|
||||
onChanged: (i) => setState(() => _nav = i),
|
||||
items: navItems,
|
||||
),
|
||||
const VerticalDivider(width: 1, color: kDeskLine),
|
||||
Expanded(child: body),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeskTab {
|
||||
_DeskTab({required this.key, required this.title, required this.icon, required this.color, required this.page});
|
||||
factory _DeskTab.home() => _DeskTab(key: 'home', title: '工作台', icon: Icons.apps, color: kDeskBind, page: const SizedBox.shrink());
|
||||
|
||||
final String key;
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final Widget page;
|
||||
}
|
||||
|
||||
bool get isDesktopShell => isDesktopPlatform;
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 企业微信桌面端配色与尺寸,不依赖移动端 theme。
|
||||
const kDeskBg = Color(0xFFEDEDED);
|
||||
const kDeskRail = Color(0xFFE7E7E7);
|
||||
const kDeskPane = Colors.white;
|
||||
const kDeskListBg = Color(0xFFF7F7F7);
|
||||
const kDeskActive = Color(0xFFDCEBFF);
|
||||
const kDeskLine = Color(0xFFE3E3E3);
|
||||
const kDeskInk = Color(0xFF191919);
|
||||
const kDeskMute = Color(0xFF8E8E8E);
|
||||
const kDeskBind = Color(0xFF267EF0);
|
||||
const kDeskGreen = Color(0xFF07C160);
|
||||
const kDeskBubbleMine = Color(0xFF95EC69);
|
||||
|
||||
const kDeskRailWidth = 68.0;
|
||||
const kDeskConvWidth = 280.0;
|
||||
|
||||
ThemeData buildDesktopTheme() {
|
||||
return ThemeData(
|
||||
useMaterial3: false,
|
||||
scaffoldBackgroundColor: kDeskBg,
|
||||
fontFamily: 'sans-serif',
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: kDeskBind, brightness: Brightness.light),
|
||||
dividerColor: kDeskLine,
|
||||
textTheme: const TextTheme(
|
||||
bodyMedium: TextStyle(fontSize: 14, color: kDeskInk, height: 1.4),
|
||||
titleMedium: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: kDeskInk),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: const Color(0xFFF3F3F3),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(6), borderSide: BorderSide.none),
|
||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(6), borderSide: BorderSide.none),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: const BorderSide(color: kDeskBind, width: 1),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
hintStyle: const TextStyle(color: Color(0xFFB2B2B2), fontSize: 14),
|
||||
),
|
||||
dataTableTheme: const DataTableThemeData(
|
||||
headingTextStyle: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: kDeskMute),
|
||||
dataTextStyle: TextStyle(fontSize: 14, color: kDeskInk),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../api/oa_client.dart';
|
||||
import '../../device/device_bridge.dart';
|
||||
import '../desk_theme.dart';
|
||||
import '../widgets/desk_widgets.dart';
|
||||
|
||||
class DeskAttendancePage extends StatefulWidget {
|
||||
const DeskAttendancePage({super.key, required this.api});
|
||||
final OaClient api;
|
||||
|
||||
@override
|
||||
State<DeskAttendancePage> createState() => _DeskAttendancePageState();
|
||||
}
|
||||
|
||||
class _DeskAttendancePageState extends State<DeskAttendancePage> {
|
||||
Map<String, dynamic> _status = {};
|
||||
Map<String, double>? _location;
|
||||
bool _loading = true;
|
||||
bool _sending = false;
|
||||
String _mode = 'OFFICE';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
_location = await DeviceBridge.getLocation();
|
||||
final q = <String, String>{
|
||||
if (_location != null) 'lat': '${_location!['lat']}',
|
||||
if (_location != null) 'lng': '${_location!['lng']}',
|
||||
'mode': _mode,
|
||||
};
|
||||
final data = Map<String, dynamic>.from(await widget.api.get('/attendance/punch-status', query: q) as Map);
|
||||
if (mounted) setState(() {
|
||||
_status = data;
|
||||
_loading = false;
|
||||
});
|
||||
if (mounted && _location == null && _mode == 'OFFICE') {
|
||||
deskToast(context, '未获取到定位,请允许位置权限后点刷新', error: true);
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _punch(String kind) async {
|
||||
if (_mode == 'OFFICE' && _status['tripActive'] == true) return;
|
||||
if (_mode == 'FIELD' && _status['fieldApproved'] != true) return;
|
||||
setState(() => _sending = true);
|
||||
try {
|
||||
await widget.api.post('/attendance/punch', {'kind': kind, 'mode': _mode, if (_location != null) ..._location!});
|
||||
if (mounted) deskToast(context, '打卡成功');
|
||||
await _load();
|
||||
} catch (e) {
|
||||
if (mounted) deskToast(context, '$e', error: true);
|
||||
} finally {
|
||||
if (mounted) setState(() => _sending = false);
|
||||
}
|
||||
}
|
||||
|
||||
String _time(dynamic value) {
|
||||
if (value == null || '$value' == 'null' || '$value'.isEmpty) return '未打卡';
|
||||
final d = DateTime.tryParse('$value')?.toLocal();
|
||||
return d == null ? '$value' : '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final inKey = _mode == 'FIELD' ? 'fieldIn' : 'clockIn';
|
||||
final outKey = _mode == 'FIELD' ? 'fieldOut' : 'clockOut';
|
||||
final inAt = _time(_status[inKey]);
|
||||
final outAt = _time(_status[outKey]);
|
||||
final hasIn = inAt != '未打卡';
|
||||
final hasOut = outAt != '未打卡';
|
||||
final trip = _status['tripActive'] == true;
|
||||
final fieldOk = _status['fieldApproved'] == true;
|
||||
final canPunch = _mode == 'FIELD' ? fieldOk : !trip && _status['inRange'] != false;
|
||||
|
||||
return ColoredBox(
|
||||
color: kDeskBg,
|
||||
child: Column(
|
||||
children: [
|
||||
DeskPaneHeader(
|
||||
title: '考勤打卡',
|
||||
actions: [IconButton(onPressed: _load, icon: const Icon(Icons.refresh, size: 20))],
|
||||
),
|
||||
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
SegmentedButton<String>(
|
||||
segments: const [
|
||||
ButtonSegment(value: 'OFFICE', label: Text('上下班打卡'), icon: Icon(Icons.access_time)),
|
||||
ButtonSegment(value: 'FIELD', label: Text('外勤打卡'), icon: Icon(Icons.location_on_outlined)),
|
||||
],
|
||||
selected: {_mode},
|
||||
onSelectionChanged: (v) async {
|
||||
setState(() => _mode = v.first);
|
||||
await _load();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(_today(), style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 12),
|
||||
_banner(canPunch, trip, fieldOk),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_punchBtn(
|
||||
enabled: canPunch && !hasIn && !_sending,
|
||||
label: _mode == 'FIELD' ? '外勤上班' : '上班打卡',
|
||||
onTap: () => _punch('IN'),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_punchBtn(
|
||||
enabled: canPunch && hasIn && !hasOut && !_sending,
|
||||
label: '下班打卡',
|
||||
color: kDeskGreen,
|
||||
onTap: () => _punch('OUT'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_timeBox('上班', inAt, _status['late'] == true),
|
||||
_timeBox('下班', outAt, _status['early'] == true),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.place, color: _status['inRange'] == true ? kDeskGreen : const Color(0xFFFA5151)),
|
||||
title: Text('${_status['rangeText'] ?? '正在获取考勤范围'}'),
|
||||
subtitle: Text(_status['distanceMeters'] == null ? '请开启定位后刷新' : '距离考勤点约 ${_status['distanceMeters']} 米'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _today() {
|
||||
final d = DateTime.now();
|
||||
return '${d.year}年${d.month}月${d.day}日';
|
||||
}
|
||||
|
||||
Widget _banner(bool canPunch, bool trip, bool fieldOk) {
|
||||
final text = _mode == 'FIELD'
|
||||
? (fieldOk ? '外出申请已通过,可进行外勤打卡' : '需先申请并审批通过外出')
|
||||
: (trip ? '出差期间无需打卡' : (canPunch ? '当前可进行上下班打卡' : '未进入考勤范围'));
|
||||
final color = trip || fieldOk || canPunch ? kDeskGreen : const Color(0xFFFA9D3B);
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(color: color.withValues(alpha: .1), borderRadius: BorderRadius.circular(8)),
|
||||
child: Text(text, textAlign: TextAlign.center, style: TextStyle(color: color, fontWeight: FontWeight.w600, fontSize: 13)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _punchBtn({required bool enabled, required String label, required VoidCallback onTap, Color? color}) {
|
||||
final c = color ?? kDeskBind;
|
||||
return SizedBox(
|
||||
width: 140,
|
||||
height: 140,
|
||||
child: Material(
|
||||
color: enabled ? c : const Color(0xFFE8E8E8),
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
onTap: enabled ? onTap : null,
|
||||
customBorder: const CircleBorder(),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(label, style: TextStyle(color: enabled ? Colors.white : kDeskMute, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 4),
|
||||
Text(_nowTime(), style: TextStyle(color: enabled ? Colors.white : kDeskMute, fontSize: 18, fontWeight: FontWeight.w700)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _timeBox(String label, String value, bool flag) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(label, style: const TextStyle(color: kDeskMute, fontSize: 12)),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(value, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
|
||||
if (flag) const Padding(padding: EdgeInsets.only(left: 4), child: Icon(Icons.error, color: Color(0xFFFA9D3B), size: 16)),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _nowTime() {
|
||||
final d = DateTime.now();
|
||||
return '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../api/oa_client.dart';
|
||||
import '../../im/chat_prefs.dart';
|
||||
import '../../session/session.dart';
|
||||
import '../desk_theme.dart';
|
||||
import '../widgets/desk_widgets.dart';
|
||||
import 'desk_pick_people_page.dart';
|
||||
|
||||
class DeskChatDetailPage extends StatefulWidget {
|
||||
const DeskChatDetailPage({
|
||||
super.key,
|
||||
required this.session,
|
||||
required this.api,
|
||||
required this.conversationId,
|
||||
required this.peerId,
|
||||
required this.peerName,
|
||||
required this.isGroup,
|
||||
this.peerAvatarFileId,
|
||||
this.onCall,
|
||||
});
|
||||
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
final String conversationId;
|
||||
final String peerId;
|
||||
final String peerName;
|
||||
final bool isGroup;
|
||||
final String? peerAvatarFileId;
|
||||
final void Function(String kind)? onCall;
|
||||
|
||||
@override
|
||||
State<DeskChatDetailPage> createState() => _DeskChatDetailPageState();
|
||||
}
|
||||
|
||||
class _DeskChatDetailPageState extends State<DeskChatDetailPage> {
|
||||
List<Map<String, dynamic>> _members = [];
|
||||
bool _mute = false;
|
||||
bool _pin = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mute = ChatPrefs.muted(widget.conversationId);
|
||||
_pin = ChatPrefs.pinned(widget.conversationId);
|
||||
_loadMembers();
|
||||
}
|
||||
|
||||
Future<void> _loadMembers() async {
|
||||
if (widget.isGroup && widget.conversationId.isNotEmpty) {
|
||||
try {
|
||||
final data = await widget.api.get('/im/groups/${widget.conversationId}/members');
|
||||
if (!mounted) return;
|
||||
setState(() => _members = asMaps(data));
|
||||
return;
|
||||
} catch (_) {}
|
||||
}
|
||||
setState(() {
|
||||
_members = [
|
||||
{'id': widget.peerId, 'name': widget.peerName, 'avatarFileId': widget.peerAvatarFileId},
|
||||
{'id': widget.session.userId, 'name': widget.session.displayName, 'avatarFileId': widget.session.user['avatarFileId']},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _addMembers() async {
|
||||
final exclude = {widget.session.userId, ..._members.map((e) => '${e['id'] ?? e['userId'] ?? ''}')};
|
||||
final picked = await Navigator.of(context).push<List<Map<String, dynamic>>>(
|
||||
MaterialPageRoute(builder: (_) => DeskPickPeoplePage(api: widget.api, title: '选择同事', exclude: exclude)),
|
||||
);
|
||||
if (picked == null || picked.isEmpty) return;
|
||||
try {
|
||||
if (widget.isGroup && widget.conversationId.isNotEmpty) {
|
||||
await widget.api.post('/im/groups/${widget.conversationId}/members', {
|
||||
'memberIds': picked.map((e) => '${e['id']}').toList(),
|
||||
});
|
||||
await _loadMembers();
|
||||
} else {
|
||||
await widget.api.post('/im/groups', {
|
||||
'name': '${widget.peerName}的群聊',
|
||||
'memberIds': [widget.peerId, ...picked.map((e) => '${e['id']}')],
|
||||
});
|
||||
if (mounted) {
|
||||
deskToast(context, '已创建群聊');
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) deskToast(context, '$e', error: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clear() async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('删除聊天记录'),
|
||||
content: const Text('将从本机清空当前会话的聊天记录,对方不受影响。'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('删除')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true) return;
|
||||
await ChatPrefs.clearHistory(widget.conversationId);
|
||||
if (mounted) {
|
||||
deskToast(context, '已删除本机聊天记录');
|
||||
Navigator.pop(context, 'cleared');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
color: kDeskBg,
|
||||
child: Column(
|
||||
children: [
|
||||
DeskPaneHeader(
|
||||
title: '聊天详情',
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back, size: 20), onPressed: () => Navigator.pop(context)),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
for (final m in _members)
|
||||
SizedBox(
|
||||
width: 56,
|
||||
child: Column(
|
||||
children: [
|
||||
DeskAvatar(
|
||||
label: '${m['name'] ?? m['displayName'] ?? ''}',
|
||||
fileId: '${m['avatarFileId'] ?? ''}',
|
||||
api: widget.api,
|
||||
size: 44,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text('${m['name'] ?? m['displayName'] ?? ''}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11)),
|
||||
],
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: _addMembers,
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(6), border: Border.all(color: kDeskLine)),
|
||||
child: const Icon(Icons.add, color: kDeskMute),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_section([
|
||||
_switchRow('消息免打扰', _mute, (v) async {
|
||||
await ChatPrefs.setMuted(widget.conversationId, v);
|
||||
setState(() => _mute = v);
|
||||
}),
|
||||
_switchRow('置顶聊天', _pin, (v) async {
|
||||
await ChatPrefs.setPinned(widget.conversationId, v);
|
||||
setState(() => _pin = v);
|
||||
}),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
_section([
|
||||
_actionRow('语音通话', () => widget.onCall?.call('voice')),
|
||||
_actionRow('视频通话', () => widget.onCall?.call(widget.isGroup ? 'meeting' : 'video')),
|
||||
_actionRow('删除聊天记录', _clear, danger: true),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _section(List<Widget> children) {
|
||||
return Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(children: children),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _switchRow(String title, bool value, ValueChanged<bool> onChanged) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(title, style: const TextStyle(fontSize: 14))),
|
||||
Switch(value: value, activeTrackColor: kDeskGreen, onChanged: onChanged),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _actionRow(String title, VoidCallback onTap, {bool danger = false}) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(title, style: TextStyle(fontSize: 14, color: danger ? const Color(0xFFFA5151) : kDeskInk))),
|
||||
if (!danger) const Icon(Icons.chevron_right, size: 18, color: kDeskMute),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,279 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../api/oa_client.dart';
|
||||
import '../../im/pinyin.dart';
|
||||
import 'desk_org_browse_page.dart';
|
||||
import '../../session/session.dart';
|
||||
import '../desk_theme.dart';
|
||||
import '../widgets/desk_widgets.dart';
|
||||
|
||||
class DeskContactsPage extends StatefulWidget {
|
||||
const DeskContactsPage({super.key, required this.session, required this.api, this.onMessage});
|
||||
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
final void Function(Map<String, dynamic> person)? onMessage;
|
||||
|
||||
@override
|
||||
State<DeskContactsPage> createState() => _DeskContactsPageState();
|
||||
}
|
||||
|
||||
class _DeskContactsPageState extends State<DeskContactsPage> {
|
||||
List<Map<String, dynamic>> _items = [];
|
||||
Map<String, List<String>> _presence = {};
|
||||
String _q = '';
|
||||
bool _loading = true;
|
||||
String? _selectedId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final data = await widget.api.get('/staff');
|
||||
Map<String, List<String>> presence = {};
|
||||
try {
|
||||
final ids = asMaps(data).map((e) => '${e['id']}').where((e) => e.isNotEmpty).join(',');
|
||||
final raw = await widget.api.get('/im/presence', query: {'userIds': ids});
|
||||
if (raw is Map) {
|
||||
presence = raw.map((k, v) => MapEntry('$k', (v is List ? v : const []).map((x) => '$x').toList()));
|
||||
}
|
||||
} catch (_) {}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = asMaps(data);
|
||||
_presence = presence;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> get _shown {
|
||||
if (_q.isEmpty) return _items;
|
||||
return _items.where((e) => '${e['name']}${e['mobile']}${e['department']}'.contains(_q)).toList();
|
||||
}
|
||||
|
||||
Map<String, List<Map<String, dynamic>>> get _groups {
|
||||
final g = <String, List<Map<String, dynamic>>>{};
|
||||
for (final r in _shown) {
|
||||
final letter = letterOf('${r['name'] ?? ''}');
|
||||
g.putIfAbsent(letter, () => []).add(r);
|
||||
}
|
||||
for (final k in g.keys) {
|
||||
g[k]!.sort((a, b) => '${a['name']}'.compareTo('${b['name']}'));
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
Future<void> _message(Map<String, dynamic> r) async {
|
||||
if (widget.onMessage != null) {
|
||||
widget.onMessage!(r);
|
||||
return;
|
||||
}
|
||||
String conversationId = '';
|
||||
try {
|
||||
final raw = await widget.api.post('/im/conversations/direct', {'peerId': '${r['id']}'});
|
||||
if (raw is Map) conversationId = '${raw['conversationId'] ?? raw['id'] ?? ''}';
|
||||
} catch (_) {}
|
||||
if (!mounted) return;
|
||||
widget.onMessage?.call({
|
||||
...r,
|
||||
'conversationId': conversationId,
|
||||
'peerId': '${r['id']}',
|
||||
'name': '${r['name'] ?? '同事'}',
|
||||
'type': 'direct',
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final groups = _groups;
|
||||
final letters = groups.keys.toList()..sort();
|
||||
final depts = _items.map((e) => '${e['department'] ?? ''}').where((e) => e.isNotEmpty).toSet();
|
||||
final selected = _selectedId == null ? null : _items.cast<Map<String, dynamic>?>().firstWhere(
|
||||
(e) => '${e?['id']}' == _selectedId,
|
||||
orElse: () => null,
|
||||
);
|
||||
|
||||
return ColoredBox(
|
||||
color: kDeskBg,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 300,
|
||||
child: ColoredBox(
|
||||
color: kDeskListBg,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||
child: DeskSearchField(hint: '搜索同事', onChanged: (v) => setState(() => _q = v.trim())),
|
||||
),
|
||||
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
children: [
|
||||
if (_q.isEmpty) ...[
|
||||
_entry(Icons.account_tree, '组织架构', '${depts.length} 个部门', () {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => DeskOrgBrowsePage(
|
||||
session: widget.session,
|
||||
api: widget.api,
|
||||
staff: _items,
|
||||
onMessage: widget.onMessage,
|
||||
),
|
||||
));
|
||||
}),
|
||||
const Divider(height: 1, color: kDeskLine),
|
||||
],
|
||||
for (final letter in letters) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 14, 4),
|
||||
child: Text(letter, style: const TextStyle(fontSize: 12, color: kDeskMute, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
for (final r in groups[letter]!)
|
||||
_personTile(r),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const VerticalDivider(width: 1, color: kDeskLine),
|
||||
Expanded(
|
||||
child: selected == null
|
||||
? const Center(child: Text('选择联系人查看详情', style: TextStyle(color: kDeskMute)))
|
||||
: _detail(selected),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _entry(IconData icon, String title, String sub, VoidCallback onTap) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
DeskAvatar(label: title, api: widget.api, size: 40, color: kDeskBind, icon: icon),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
|
||||
Text(sub, style: const TextStyle(fontSize: 12, color: kDeskMute)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _personTile(Map<String, dynamic> r) {
|
||||
final id = '${r['id']}';
|
||||
final online = (_presence[id] ?? const []).contains('online');
|
||||
return Material(
|
||||
color: _selectedId == id ? kDeskActive : Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () => setState(() => _selectedId = id),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
DeskAvatar(label: '${r['name']}', fileId: '${r['avatarFileId'] ?? ''}', api: widget.api, size: 36),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${r['name']}', style: const TextStyle(fontSize: 14)),
|
||||
Text(
|
||||
[r['department'], r['title']].where((e) => e != null && '$e'.isNotEmpty).join(' · '),
|
||||
style: const TextStyle(fontSize: 12, color: kDeskMute),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: online ? kDeskGreen : const Color(0xFFD0D0D0),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _detail(Map<String, dynamic> r) {
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DeskAvatar(label: '${r['name']}', fileId: '${r['avatarFileId'] ?? ''}', api: widget.api, size: 80),
|
||||
const SizedBox(height: 16),
|
||||
Text('${r['name']}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
[r['department'], r['title']].where((e) => e != null && '$e'.isNotEmpty).join(' · '),
|
||||
style: const TextStyle(color: kDeskMute),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_infoRow('手机', '${r['mobile'] ?? '未填写'}'),
|
||||
_infoRow('邮箱', '${r['email'] ?? '未填写'}'),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: kDeskBind, minimumSize: const Size.fromHeight(40)),
|
||||
onPressed: () => _message(r),
|
||||
child: const Text('发消息'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _infoRow(String k, String v) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8), border: Border.all(color: kDeskLine)),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 56, child: Text(k, style: const TextStyle(color: kDeskMute, fontSize: 13))),
|
||||
Expanded(child: Text(v, style: const TextStyle(fontSize: 14))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../api/oa_client.dart';
|
||||
import '../../im/chat_prefs.dart';
|
||||
import 'desk_pick_people_page.dart';
|
||||
import '../../session/session.dart';
|
||||
import '../desk_theme.dart';
|
||||
import '../widgets/desk_widgets.dart';
|
||||
|
||||
class DeskConvListPage extends StatefulWidget {
|
||||
const DeskConvListPage({
|
||||
super.key,
|
||||
required this.session,
|
||||
required this.api,
|
||||
this.selectedId,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
final String? selectedId;
|
||||
final void Function(Map<String, dynamic> row) onSelect;
|
||||
|
||||
@override
|
||||
State<DeskConvListPage> createState() => _DeskConvListPageState();
|
||||
}
|
||||
|
||||
class _DeskConvListPageState extends State<DeskConvListPage> {
|
||||
List<Map<String, dynamic>> _items = [];
|
||||
bool _loading = true;
|
||||
String _filter = 'all';
|
||||
String _q = '';
|
||||
Timer? _debounce;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.session.im.addListener(_onIm);
|
||||
ChatPrefs.ensure().then((_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
widget.session.im.removeListener(_onIm);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onIm() {
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 300), () {
|
||||
if (mounted) _load();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final data = await widget.api.get('/im/conversations');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = asMaps(data);
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> get _shown {
|
||||
var list = _items.where((e) => !ChatPrefs.hidden('${e['id'] ?? ''}')).toList();
|
||||
if (_filter == 'unread') list = list.where((e) => ((e['unread'] as num?)?.toInt() ?? 0) > 0).toList();
|
||||
if (_filter == 'dm') list = list.where((e) => e['type'] != 'group').toList();
|
||||
if (_filter == 'group') list = list.where((e) => e['type'] == 'group').toList();
|
||||
if (_q.isNotEmpty) {
|
||||
list = list.where((e) => '${e['name']}${e['peerName']}${e['lastText']}'.contains(_q)).toList();
|
||||
}
|
||||
list.sort((a, b) {
|
||||
final ap = ChatPrefs.pinned('${a['id'] ?? ''}');
|
||||
final bp = ChatPrefs.pinned('${b['id'] ?? ''}');
|
||||
if (ap == bp) return 0;
|
||||
return ap ? -1 : 1;
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
Future<void> _menu(Map<String, dynamic> r, Offset pos) async {
|
||||
final id = '${r['id'] ?? ''}';
|
||||
if (id.isEmpty) return;
|
||||
final muted = ChatPrefs.muted(id);
|
||||
final pinned = ChatPrefs.pinned(id);
|
||||
final unread = (r['unread'] as num?)?.toInt() ?? 0;
|
||||
final fake = ChatPrefs.fakeUnread(id);
|
||||
final selected = await showMenu<String>(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(pos.dx, pos.dy, pos.dx + 1, pos.dy + 1),
|
||||
items: [
|
||||
if (unread == 0 && fake == 0) const PopupMenuItem(value: 'unread', child: Text('标为未读')),
|
||||
PopupMenuItem(value: 'mute', child: Text(muted ? '取消免打扰' : '消息免打扰')),
|
||||
PopupMenuItem(value: 'pin', child: Text(pinned ? '取消置顶' : '置顶')),
|
||||
const PopupMenuItem(value: 'hide', child: Text('不显示')),
|
||||
const PopupMenuDivider(),
|
||||
const PopupMenuItem(value: 'clear', child: Text('清空聊天记录')),
|
||||
const PopupMenuItem(value: 'delete', child: Text('删除')),
|
||||
],
|
||||
);
|
||||
if (!mounted || selected == null) return;
|
||||
switch (selected) {
|
||||
case 'unread':
|
||||
await ChatPrefs.setFakeUnread(id, 1);
|
||||
case 'mute':
|
||||
await ChatPrefs.setMuted(id, !muted);
|
||||
case 'pin':
|
||||
await ChatPrefs.setPinned(id, !pinned);
|
||||
case 'hide':
|
||||
case 'delete':
|
||||
await ChatPrefs.setHidden(id, true);
|
||||
case 'clear':
|
||||
await ChatPrefs.clearHistory(id);
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
await _load();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _newChat({required bool group}) async {
|
||||
final picked = await Navigator.of(context).push<List<Map<String, dynamic>>>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => DeskPickPeoplePage(
|
||||
api: widget.api,
|
||||
title: group ? '选择联系人' : '发起单聊',
|
||||
multiple: group,
|
||||
exclude: {widget.session.userId},
|
||||
),
|
||||
),
|
||||
);
|
||||
if (picked == null || picked.isEmpty) return;
|
||||
if (!group) {
|
||||
final p = picked.first;
|
||||
widget.onSelect({
|
||||
'id': '',
|
||||
'peerId': '${p['id']}',
|
||||
'name': '${p['name'] ?? '同事'}',
|
||||
'avatarFileId': '${p['avatarFileId'] ?? ''}',
|
||||
'type': 'direct',
|
||||
});
|
||||
return;
|
||||
}
|
||||
final name = TextEditingController();
|
||||
if (!mounted) return;
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('群名称'),
|
||||
content: TextField(controller: name, decoration: const InputDecoration(hintText: '例如:项目组')),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('创建')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) {
|
||||
await widget.api.post('/im/groups', {
|
||||
'name': name.text.trim().isEmpty ? '群聊' : name.text.trim(),
|
||||
'memberIds': picked.map((e) => '${e['id']}').toList(),
|
||||
});
|
||||
await _load();
|
||||
}
|
||||
}
|
||||
|
||||
void _plusMenu(BuildContext ctx) {
|
||||
final box = ctx.findRenderObject() as RenderBox?;
|
||||
final pos = box?.localToGlobal(Offset.zero) ?? Offset.zero;
|
||||
showMenu<void>(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(pos.dx, pos.dy + 32, pos.dx + 200, pos.dy),
|
||||
items: [
|
||||
PopupMenuItem(onTap: () => Future.microtask(() => _newChat(group: false)), child: const Text('发起单聊')),
|
||||
PopupMenuItem(onTap: () => Future.microtask(() => _newChat(group: true)), child: const Text('发起群聊')),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shown = _shown;
|
||||
return ColoredBox(
|
||||
color: kDeskListBg,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: DeskSearchField(hint: '搜索', onChanged: (v) => setState(() => _q = v.trim()))),
|
||||
const SizedBox(width: 8),
|
||||
Material(
|
||||
color: const Color(0xFFEFEFEF),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
final rb = context.findRenderObject() as RenderBox?;
|
||||
if (rb != null) _plusMenu(context);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: const SizedBox(width: 32, height: 32, child: Icon(Icons.add, size: 18, color: kDeskInk)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||
child: DeskFilterChips(
|
||||
value: _filter,
|
||||
onChanged: (v) => setState(() => _filter = v),
|
||||
items: const [('all', '全部'), ('unread', '未读'), ('dm', '单聊'), ('group', '群聊')],
|
||||
),
|
||||
),
|
||||
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
|
||||
Expanded(
|
||||
child: shown.isEmpty
|
||||
? const Center(child: Text('暂无会话', style: TextStyle(color: kDeskMute)))
|
||||
: ListView.builder(
|
||||
itemCount: shown.length,
|
||||
itemBuilder: (_, i) {
|
||||
final r = shown[i];
|
||||
final id = '${r['id'] ?? ''}';
|
||||
final fake = ChatPrefs.fakeUnread(id);
|
||||
final unread = fake > 0 ? fake : ((r['unread'] as num?)?.toInt() ?? 0);
|
||||
return DeskConvRow(
|
||||
name: '${r['name'] ?? r['peerName'] ?? '会话'}',
|
||||
preview: '${r['lastText'] ?? ''}',
|
||||
time: shortTime(r['lastAt'] ?? r['updatedAt']),
|
||||
unread: unread,
|
||||
selected: widget.selectedId == id,
|
||||
muted: ChatPrefs.muted(id),
|
||||
pinned: ChatPrefs.pinned(id),
|
||||
avatarId: '${r['avatarFileId'] ?? ''}',
|
||||
api: widget.api,
|
||||
onTap: () => widget.onSelect(r),
|
||||
onSecondaryTap: (d) => _menu(r, d.globalPosition),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../api/oa_client.dart';
|
||||
import '../../device/device_bridge.dart';
|
||||
import '../../labels.dart';
|
||||
import '../desk_theme.dart';
|
||||
import '../widgets/desk_data_table.dart';
|
||||
import '../widgets/desk_widgets.dart';
|
||||
import 'desk_record_detail_page.dart';
|
||||
|
||||
const _kinds = [
|
||||
('PERSONAL', '请假'),
|
||||
('OVERTIME', '加班'),
|
||||
('BUSINESS', '出差'),
|
||||
('OUT', '外出'),
|
||||
('REGULARIZE', '转正'),
|
||||
('RESIGN', '离职'),
|
||||
];
|
||||
|
||||
class DeskHrApplyPage extends StatefulWidget {
|
||||
const DeskHrApplyPage({super.key, required this.api});
|
||||
final OaClient api;
|
||||
|
||||
@override
|
||||
State<DeskHrApplyPage> createState() => _DeskHrApplyPageState();
|
||||
}
|
||||
|
||||
class _DeskHrApplyPageState extends State<DeskHrApplyPage> {
|
||||
List<Map<String, dynamic>> _items = [];
|
||||
bool _loading = true;
|
||||
String _bucket = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final q = _bucket.isEmpty ? null : {'bucket': _bucket};
|
||||
final data = await widget.api.get('/leave-requests', query: q);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = asMaps(data);
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _create() async {
|
||||
var kind = 'LEAVE';
|
||||
final reason = TextEditingController();
|
||||
final days = TextEditingController(text: '1');
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('发起人事申请'),
|
||||
content: SizedBox(
|
||||
width: 420,
|
||||
child: StatefulBuilder(
|
||||
builder: (ctx, setSt) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
for (final k in _kinds)
|
||||
FilterChip(
|
||||
label: Text(k.$2),
|
||||
selected: kind == k.$1,
|
||||
onSelected: (_) => setSt(() => kind = k.$1),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(controller: days, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: '天数')),
|
||||
const SizedBox(height: 8),
|
||||
TextField(controller: reason, maxLines: 3, decoration: const InputDecoration(labelText: '事由')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('提交')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true) return;
|
||||
try {
|
||||
final created = Map<String, dynamic>.from(await widget.api.post('/leave-requests', {
|
||||
'kind': kind,
|
||||
'reason': reason.text.trim(),
|
||||
'days': num.tryParse(days.text) ?? 1,
|
||||
}) as Map);
|
||||
final id = '${created['id'] ?? ''}';
|
||||
if (id.isNotEmpty && mounted) {
|
||||
final add = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('添加申请材料'),
|
||||
content: const Text('可以上传病假证明、行程单等附件。'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('暂不添加')),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('选择附件')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (add == true) {
|
||||
final picked = await DeviceBridge.pickFile();
|
||||
if (picked != null && picked['path'] != null) {
|
||||
await widget.api.uploadFile(
|
||||
filePath: picked['path']!,
|
||||
filename: picked['name'] ?? '申请材料',
|
||||
bizType: 'HR_LEAVE',
|
||||
bizId: id,
|
||||
mime: picked['mime'],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mounted) {
|
||||
deskToast(context, '已提交,等待审批');
|
||||
_load();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) deskToast(context, '$e', error: true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DeskListScaffold(
|
||||
title: '人事申请',
|
||||
loading: _loading,
|
||||
actions: [
|
||||
FilledButton.icon(
|
||||
style: FilledButton.styleFrom(backgroundColor: kDeskBind),
|
||||
onPressed: _create,
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text('发起申请'),
|
||||
),
|
||||
],
|
||||
filters: DeskFilterChips(
|
||||
value: _bucket,
|
||||
onChanged: (v) {
|
||||
setState(() => _bucket = v);
|
||||
_load();
|
||||
},
|
||||
items: const [('', '全部'), ('pending', '待审批'), ('approved', '已通过'), ('rejected', '已驳回')],
|
||||
),
|
||||
body: DeskDataTable(
|
||||
columns: const ['类型', '事由', '状态', '时间'],
|
||||
rows: [
|
||||
for (final r in _items)
|
||||
[zh(r['kind']), '${r['reason'] ?? ''}', zh(r['status']), fmtTime(r['createdAt'])],
|
||||
],
|
||||
emptyHint: '还没有人事申请',
|
||||
onRowTap: (i) => deskOpenRecord(context, widget.api, {..._items[i], 'source': 'HR'}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../api/oa_client.dart';
|
||||
import '../desk_theme.dart';
|
||||
import '../widgets/desk_widgets.dart';
|
||||
|
||||
class DeskLegalPage extends StatefulWidget {
|
||||
const DeskLegalPage({super.key, required this.api, required this.kind});
|
||||
final OaClient api;
|
||||
final String kind;
|
||||
|
||||
@override
|
||||
State<DeskLegalPage> createState() => _DeskLegalPageState();
|
||||
}
|
||||
|
||||
class _DeskLegalPageState extends State<DeskLegalPage> {
|
||||
String _title = '';
|
||||
List<Map<String, String>> _sections = [];
|
||||
String _error = '';
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final raw = await widget.api.get('/legal/${widget.kind}');
|
||||
if (!mounted) return;
|
||||
if (raw is Map) {
|
||||
final data = Map<String, dynamic>.from(raw);
|
||||
final paras = asMaps(data['paragraphs']);
|
||||
setState(() {
|
||||
_title = '${data['title'] ?? ''}';
|
||||
_sections = paras.isNotEmpty
|
||||
? paras.map((e) => {'title': '${e['heading'] ?? e['title'] ?? ''}', 'body': '${e['body'] ?? ''}'}).toList()
|
||||
: [{'title': '', 'body': '${data['body'] ?? data['content'] ?? ''}'}];
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) setState(() {
|
||||
_error = '$e';
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
color: kDeskBg,
|
||||
child: Column(
|
||||
children: [
|
||||
DeskPaneHeader(
|
||||
title: _title.isEmpty ? (widget.kind == 'privacy' ? '隐私政策' : '用户协议') : _title,
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back, size: 20), onPressed: () => Navigator.pop(context)),
|
||||
),
|
||||
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
|
||||
Expanded(
|
||||
child: _error.isNotEmpty
|
||||
? Center(child: Text(_error, style: const TextStyle(color: kDeskMute)))
|
||||
: ListView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
children: [
|
||||
for (final s in _sections) ...[
|
||||
Text(s['title'] ?? '', style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
Text(s['body'] ?? '', style: const TextStyle(fontSize: 14, height: 1.6, color: kDeskInk)),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../api/oa_client.dart';
|
||||
import '../../labels.dart';
|
||||
import 'desk_record_detail_page.dart';
|
||||
import '../widgets/desk_data_table.dart';
|
||||
import '../widgets/desk_widgets.dart';
|
||||
|
||||
/// 桌面端办公列表(待办、审批、申请等),表格布局。
|
||||
class DeskOfficeListPage extends StatefulWidget {
|
||||
const DeskOfficeListPage({
|
||||
super.key,
|
||||
required this.api,
|
||||
required this.title,
|
||||
required this.path,
|
||||
this.query,
|
||||
this.buckets = const [],
|
||||
this.defaultBucket = '',
|
||||
this.columns = const ['标题', '类型', '时间', '状态'],
|
||||
this.rowBuilder,
|
||||
});
|
||||
|
||||
final OaClient api;
|
||||
final String title;
|
||||
final String path;
|
||||
final Map<String, String>? query;
|
||||
final List<(String, String)> buckets;
|
||||
final String defaultBucket;
|
||||
final List<String> columns;
|
||||
final List<String> Function(Map<String, dynamic> row)? rowBuilder;
|
||||
|
||||
@override
|
||||
State<DeskOfficeListPage> createState() => _DeskOfficeListPageState();
|
||||
}
|
||||
|
||||
class _DeskOfficeListPageState extends State<DeskOfficeListPage> {
|
||||
List<Map<String, dynamic>> _items = [];
|
||||
String _bucket = '';
|
||||
bool _loading = true;
|
||||
String _q = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bucket = widget.defaultBucket;
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final q = <String, String>{...?widget.query};
|
||||
if (_bucket.isNotEmpty) q['bucket'] = _bucket;
|
||||
if (_bucket.isNotEmpty && widget.buckets.any((b) => b.$1 == _bucket && b.$1 == 'PENDING')) {
|
||||
q.remove('bucket');
|
||||
}
|
||||
final data = await widget.api.get(widget.path, query: q.isEmpty ? null : q);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = asMaps(data);
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> get _shown {
|
||||
var list = _bucket.isEmpty || widget.buckets.isEmpty
|
||||
? _items
|
||||
: _items.where((e) {
|
||||
if (widget.path.contains('approvals')) return '${e['status']}' == _bucket;
|
||||
if (widget.path.contains('todos')) return '${e['status']}' == _bucket;
|
||||
return true;
|
||||
}).toList();
|
||||
if (_q.isNotEmpty) {
|
||||
list = list.where((e) => '${e['title']}${e['bizType']}'.contains(_q)).toList();
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
List<String> _row(Map<String, dynamic> r) {
|
||||
if (widget.rowBuilder != null) return widget.rowBuilder!(r);
|
||||
return [
|
||||
'${r['title'] ?? ''}',
|
||||
zh(r['bizType'] ?? r['type'] ?? ''),
|
||||
fmtTime(r['dueAt'] ?? r['createdAt'] ?? ''),
|
||||
zhStatus(r['status']),
|
||||
];
|
||||
}
|
||||
|
||||
String zhStatus(dynamic s) {
|
||||
final v = '$s';
|
||||
const m = {'PENDING': '待处理', 'OPEN': '待办', 'DONE': '已完成', 'APPROVED': '已通过', 'REJECTED': '已驳回'};
|
||||
return m[v] ?? v;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shown = _shown;
|
||||
return DeskListScaffold(
|
||||
title: widget.title,
|
||||
loading: _loading,
|
||||
actions: [
|
||||
IconButton(onPressed: _load, icon: const Icon(Icons.refresh, size: 20)),
|
||||
],
|
||||
filters: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DeskSearchField(hint: '搜索', onChanged: (v) => setState(() => _q = v.trim())),
|
||||
if (widget.buckets.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
DeskFilterChips(
|
||||
value: _bucket,
|
||||
onChanged: (v) {
|
||||
setState(() => _bucket = v);
|
||||
_load();
|
||||
},
|
||||
items: widget.buckets,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
body: DeskDataTable(
|
||||
columns: widget.columns,
|
||||
rows: [for (final r in shown) _row(r)],
|
||||
emptyHint: '暂无${widget.title}',
|
||||
onRowTap: (i) => deskOpenRecord(context, widget.api, shown[i]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../api/oa_client.dart';
|
||||
import '../../session/session.dart';
|
||||
import '../desk_theme.dart';
|
||||
import '../widgets/desk_widgets.dart';
|
||||
|
||||
class DeskOrgBrowsePage extends StatefulWidget {
|
||||
const DeskOrgBrowsePage({
|
||||
super.key,
|
||||
required this.session,
|
||||
required this.api,
|
||||
required this.staff,
|
||||
this.parentId,
|
||||
this.title = '组织架构',
|
||||
this.deptName,
|
||||
this.onMessage,
|
||||
});
|
||||
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
final List<Map<String, dynamic>> staff;
|
||||
final String? parentId;
|
||||
final String title;
|
||||
final String? deptName;
|
||||
final void Function(Map<String, dynamic> person)? onMessage;
|
||||
|
||||
@override
|
||||
State<DeskOrgBrowsePage> createState() => _DeskOrgBrowsePageState();
|
||||
}
|
||||
|
||||
class _DeskOrgBrowsePageState extends State<DeskOrgBrowsePage> {
|
||||
List<Map<String, dynamic>> _depts = [];
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final data = await widget.api.get('/departments');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_depts = asMaps(data);
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> get _children {
|
||||
if (_depts.isEmpty) return const [];
|
||||
return _depts.where((e) {
|
||||
final pid = '${e['parentId'] ?? ''}';
|
||||
if (widget.parentId == null || widget.parentId!.isEmpty) return pid.isEmpty || pid == 'null';
|
||||
return pid == widget.parentId;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> get _people {
|
||||
final name = widget.deptName;
|
||||
if (name == null || name.isEmpty) return const [];
|
||||
final me = widget.session.userId;
|
||||
return widget.staff.where((e) => '${e['department']}' == name && '${e['id']}' != me).toList();
|
||||
}
|
||||
|
||||
int _countOf(Map<String, dynamic> d) {
|
||||
final c = d['_count'];
|
||||
if (c is Map && c['employees'] != null) return (c['employees'] as num).toInt();
|
||||
return widget.staff.where((e) => '${e['department']}' == '${d['name']}').length;
|
||||
}
|
||||
|
||||
void _openDept(Map<String, dynamic> d) {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => DeskOrgBrowsePage(
|
||||
session: widget.session,
|
||||
api: widget.api,
|
||||
staff: widget.staff,
|
||||
parentId: '${d['id']}',
|
||||
title: '${d['name']}',
|
||||
deptName: '${d['name']}',
|
||||
onMessage: widget.onMessage,
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> _message(Map<String, dynamic> r) async {
|
||||
if (widget.onMessage != null) {
|
||||
widget.onMessage!(r);
|
||||
return;
|
||||
}
|
||||
String conversationId = '';
|
||||
try {
|
||||
final raw = await widget.api.post('/im/conversations/direct', {'peerId': '${r['id']}'});
|
||||
if (raw is Map) conversationId = '${raw['conversationId'] ?? raw['id'] ?? ''}';
|
||||
} catch (_) {}
|
||||
if (mounted) Navigator.pop(context, {...r, 'conversationId': conversationId, 'peerId': '${r['id']}', 'name': '${r['name']}', 'type': 'direct'});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
color: kDeskBg,
|
||||
child: Column(
|
||||
children: [
|
||||
DeskPaneHeader(
|
||||
title: widget.title,
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back, size: 20), onPressed: () => Navigator.pop(context)),
|
||||
),
|
||||
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
for (final d in _children)
|
||||
Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
onTap: () => _openDept(d),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
children: [
|
||||
DeskAvatar(label: '${d['name']}', api: widget.api, size: 40, color: kDeskBind, icon: Icons.folder_outlined),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text('${d['name']}', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500))),
|
||||
Text('${_countOf(d)} 人', style: const TextStyle(fontSize: 12, color: kDeskMute)),
|
||||
const Icon(Icons.chevron_right, size: 18, color: kDeskMute),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_people.isNotEmpty) ...[
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(4, 12, 4, 8),
|
||||
child: Text('部门成员', style: TextStyle(fontSize: 12, color: kDeskMute, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
for (final r in _people)
|
||||
Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
onTap: () => _message(r),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 6),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
DeskAvatar(label: '${r['name']}', fileId: '${r['avatarFileId'] ?? ''}', api: widget.api, size: 36),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${r['name']}', style: const TextStyle(fontSize: 14)),
|
||||
Text('${r['title'] ?? ''}', style: const TextStyle(fontSize: 12, color: kDeskMute)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Text('发消息', style: TextStyle(fontSize: 12, color: kDeskBind)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../api/oa_client.dart';
|
||||
import '../desk_theme.dart';
|
||||
import '../widgets/desk_widgets.dart';
|
||||
|
||||
class DeskPickPeoplePage extends StatefulWidget {
|
||||
const DeskPickPeoplePage({
|
||||
super.key,
|
||||
required this.api,
|
||||
required this.title,
|
||||
this.multiple = true,
|
||||
this.exclude = const {},
|
||||
});
|
||||
|
||||
final OaClient api;
|
||||
final String title;
|
||||
final bool multiple;
|
||||
final Set<String> exclude;
|
||||
|
||||
@override
|
||||
State<DeskPickPeoplePage> createState() => _DeskPickPeoplePageState();
|
||||
}
|
||||
|
||||
class _DeskPickPeoplePageState extends State<DeskPickPeoplePage> {
|
||||
List<Map<String, dynamic>> _items = [];
|
||||
final _selected = <String, String>{};
|
||||
String _q = '';
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final data = await widget.api.get('/staff');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = asMaps(data);
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _confirm() {
|
||||
Navigator.pop(
|
||||
context,
|
||||
_selected.entries.map((e) => {'id': e.key, 'name': e.value}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shown = _items.where((e) {
|
||||
final id = '${e['id']}';
|
||||
if (widget.exclude.contains(id)) return false;
|
||||
if (_q.isEmpty) return true;
|
||||
return '${e['name']}${e['department']}${e['title']}'.contains(_q);
|
||||
}).toList();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: kDeskBg,
|
||||
body: Column(
|
||||
children: [
|
||||
DeskPaneHeader(
|
||||
title: widget.title,
|
||||
leading: IconButton(icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(context)),
|
||||
actions: [
|
||||
if (widget.multiple)
|
||||
TextButton(onPressed: _selected.isEmpty ? null : _confirm, child: Text('确定(${_selected.length})')),
|
||||
],
|
||||
bottom: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 10),
|
||||
child: DeskSearchField(hint: '搜索同事', onChanged: (v) => setState(() => _q = v.trim())),
|
||||
),
|
||||
),
|
||||
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: shown.length,
|
||||
itemBuilder: (_, i) {
|
||||
final r = shown[i];
|
||||
final id = '${r['id']}';
|
||||
final name = '${r['name'] ?? ''}';
|
||||
final on = _selected.containsKey(id);
|
||||
return Material(
|
||||
color: on ? kDeskActive : Colors.white,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
if (!widget.multiple) {
|
||||
Navigator.pop(context, [
|
||||
{'id': id, 'name': name, 'avatarFileId': '${r['avatarFileId'] ?? ''}'},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
if (on) {
|
||||
_selected.remove(id);
|
||||
} else {
|
||||
_selected[id] = name;
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 14, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
DeskAvatar(label: name, fileId: '${r['avatarFileId'] ?? ''}', api: widget.api, size: 36),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
|
||||
Text('${r['department'] ?? ''} ${r['title'] ?? ''}'.trim(),
|
||||
style: const TextStyle(fontSize: 12, color: kDeskMute)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.multiple)
|
||||
Icon(on ? Icons.check_circle : Icons.circle_outlined, color: on ? kDeskBind : kDeskMute, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import '../../api/oa_client.dart';
|
||||
import '../../session/session.dart';
|
||||
import '../desk_theme.dart';
|
||||
import '../widgets/desk_widgets.dart';
|
||||
|
||||
class DeskProfileEditPage extends StatefulWidget {
|
||||
const DeskProfileEditPage({super.key, required this.session, required this.api});
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
|
||||
@override
|
||||
State<DeskProfileEditPage> createState() => _DeskProfileEditPageState();
|
||||
}
|
||||
|
||||
class _DeskProfileEditPageState extends State<DeskProfileEditPage> {
|
||||
bool _busy = false;
|
||||
|
||||
Future<void> _refreshMe() async {
|
||||
final me = await widget.api.get('/auth/me');
|
||||
if (me is Map) await widget.session.patchUser(Map<String, dynamic>.from(me));
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _pickAvatar() async {
|
||||
final x = await ImagePicker().pickImage(source: ImageSource.gallery, imageQuality: 85, maxWidth: 800);
|
||||
if (x == null) return;
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await widget.api.uploadFile(
|
||||
filePath: x.path,
|
||||
filename: x.name.isEmpty ? 'avatar.jpg' : x.name,
|
||||
bizType: 'USER_AVATAR',
|
||||
bizId: widget.session.userId,
|
||||
);
|
||||
await _refreshMe();
|
||||
if (mounted) deskToast(context, '头像已更新');
|
||||
} catch (e) {
|
||||
if (mounted) deskToast(context, '$e', error: true);
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _editName() async {
|
||||
final c = TextEditingController(text: widget.session.displayName);
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('修改姓名'),
|
||||
content: TextField(controller: c, decoration: const InputDecoration(labelText: '显示名')),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('保存')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true || c.text.trim().isEmpty) return;
|
||||
try {
|
||||
final me = await widget.api.patch('/auth/profile', {'displayName': c.text.trim()});
|
||||
if (me is Map) await widget.session.patchUser(Map<String, dynamic>.from(me));
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
deskToast(context, '已保存');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) deskToast(context, '$e', error: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _editPassword() async {
|
||||
final oldC = TextEditingController();
|
||||
final newC = TextEditingController();
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('修改密码'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(controller: oldC, obscureText: true, decoration: const InputDecoration(labelText: '当前密码')),
|
||||
const SizedBox(height: 8),
|
||||
TextField(controller: newC, obscureText: true, decoration: const InputDecoration(labelText: '新密码')),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('保存')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true) return;
|
||||
try {
|
||||
await widget.api.post('/auth/change-password', {'oldPassword': oldC.text, 'newPassword': newC.text});
|
||||
if (mounted) deskToast(context, '密码已修改');
|
||||
} catch (e) {
|
||||
if (mounted) deskToast(context, '$e', error: true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = widget.session;
|
||||
final u = s.user;
|
||||
return ColoredBox(
|
||||
color: kDeskBg,
|
||||
child: Column(
|
||||
children: [
|
||||
DeskPaneHeader(
|
||||
title: '个人资料',
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back, size: 20), onPressed: () => Navigator.pop(context)),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
children: [
|
||||
Center(
|
||||
child: Stack(
|
||||
children: [
|
||||
DeskAvatar(label: s.displayName, fileId: '${u['avatarFileId'] ?? ''}', api: widget.api, size: 80),
|
||||
if (_busy)
|
||||
const Positioned.fill(child: CircularProgressIndicator(strokeWidth: 2, color: kDeskBind)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Center(
|
||||
child: TextButton(onPressed: _busy ? null : _pickAvatar, child: const Text('更换头像')),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_row('姓名', s.displayName, onTap: _editName),
|
||||
_row('手机', '${u['mobile'] ?? '未填写'}'),
|
||||
_row('邮箱', '${u['email'] ?? '未填写'}'),
|
||||
_row('部门', '${u['department'] ?? ''}'),
|
||||
_row('职位', '${u['title'] ?? ''}'),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(onPressed: _editPassword, child: const Text('修改密码')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(String k, String v, {VoidCallback? onTap}) {
|
||||
return Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 64, child: Text(k, style: const TextStyle(color: kDeskMute, fontSize: 13))),
|
||||
Expanded(child: Text(v, style: const TextStyle(fontSize: 14))),
|
||||
if (onTap != null) const Icon(Icons.chevron_right, size: 18, color: kDeskMute),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../api/oa_client.dart';
|
||||
import '../../app_config.dart';
|
||||
import '../../ota/updater.dart';
|
||||
import 'desk_legal_page.dart';
|
||||
import 'desk_profile_edit_page.dart';
|
||||
import '../../session/session.dart';
|
||||
import '../desk_theme.dart';
|
||||
import '../widgets/desk_widgets.dart';
|
||||
|
||||
class DeskProfilePage extends StatefulWidget {
|
||||
const DeskProfilePage({super.key, required this.session, required this.api});
|
||||
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
|
||||
@override
|
||||
State<DeskProfilePage> createState() => _DeskProfilePageState();
|
||||
}
|
||||
|
||||
class _DeskProfilePageState extends State<DeskProfilePage> {
|
||||
AppRelease? _rel;
|
||||
bool _checking = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.session.addListener(() {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
_peek();
|
||||
}
|
||||
|
||||
Future<void> _peek() async {
|
||||
final rel = await OtaUpdater(widget.api).fetch();
|
||||
if (mounted) setState(() => _rel = rel);
|
||||
}
|
||||
|
||||
Future<void> _logout() async {
|
||||
try {
|
||||
await widget.api.post('/auth/logout', {'refreshToken': widget.session.refreshToken});
|
||||
} catch (_) {}
|
||||
await widget.session.clear();
|
||||
}
|
||||
|
||||
Future<void> _checkUpdate() async {
|
||||
setState(() => _checking = true);
|
||||
try {
|
||||
final rel = await OtaUpdater(widget.api).fetch();
|
||||
if (!mounted) return;
|
||||
setState(() => _rel = rel);
|
||||
if (rel != null) await OtaUpdater(widget.api).prompt(context, rel, manual: true);
|
||||
} finally {
|
||||
if (mounted) setState(() => _checking = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = widget.session;
|
||||
return ColoredBox(
|
||||
color: kDeskBg,
|
||||
child: Column(
|
||||
children: [
|
||||
const DeskPaneHeader(title: '我'),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
children: [
|
||||
Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
onTap: () => Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => DeskProfileEditPage(session: s, api: widget.api),
|
||||
)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
DeskAvatar(
|
||||
label: s.displayName,
|
||||
fileId: '${s.user['avatarFileId'] ?? ''}',
|
||||
api: widget.api,
|
||||
size: 56,
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(s.displayName, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 4),
|
||||
Text('${s.user['department'] ?? ''} ${s.user['title'] ?? ''}'.trim(),
|
||||
style: const TextStyle(color: kDeskMute, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right, color: kDeskMute),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_section([
|
||||
_row('检查更新', trailing: _rel?.newer == true ? '有新版本' : '已是最新', onTap: _checking ? null : _checkUpdate),
|
||||
_row('用户协议', onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => DeskLegalPage(api: widget.api, kind: 'terms')))),
|
||||
_row('隐私政策', onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => DeskLegalPage(api: widget.api, kind: 'privacy')))),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
_section([
|
||||
_row('版本', trailing: 'v${AppConfig.version}'),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(minimumSize: const Size.fromHeight(40)),
|
||||
onPressed: _logout,
|
||||
child: const Text('退出登录', style: TextStyle(color: Color(0xFFFA5151))),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _section(List<Widget> children) {
|
||||
return Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(children: children),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(String title, {String? trailing, VoidCallback? onTap}) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(title, style: const TextStyle(fontSize: 14))),
|
||||
if (trailing != null) Text(trailing, style: const TextStyle(fontSize: 13, color: kDeskMute)),
|
||||
if (onTap != null) const Icon(Icons.chevron_right, size: 18, color: kDeskMute),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../api/oa_client.dart';
|
||||
import '../../nav/biz_route.dart';
|
||||
import '../../pages/record_detail_page.dart' show flattenRecord;
|
||||
import '../desk_theme.dart';
|
||||
import '../widgets/desk_widgets.dart';
|
||||
import 'desk_pick_people_page.dart';
|
||||
|
||||
void deskOpenRecord(BuildContext context, OaClient api, Map<String, dynamic> row, {String? title}) {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => DeskRecordDetailPage(api: api, seed: row, title: title ?? detailTitleOf(row)),
|
||||
));
|
||||
}
|
||||
|
||||
class DeskRecordDetailPage extends StatefulWidget {
|
||||
const DeskRecordDetailPage({super.key, required this.api, required this.seed, required this.title});
|
||||
final OaClient api;
|
||||
final Map<String, dynamic> seed;
|
||||
final String title;
|
||||
|
||||
@override
|
||||
State<DeskRecordDetailPage> createState() => _DeskRecordDetailPageState();
|
||||
}
|
||||
|
||||
class _DeskRecordDetailPageState extends State<DeskRecordDetailPage> {
|
||||
Map<String, dynamic> _row = {};
|
||||
bool _loading = true;
|
||||
String _err = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_row = Map<String, dynamic>.from(widget.seed);
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final path = fetchPathOf(widget.seed);
|
||||
if (path == null) {
|
||||
setState(() => _loading = false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final data = await widget.api.get(path);
|
||||
if (!mounted) return;
|
||||
if (data is Map) {
|
||||
setState(() {
|
||||
_row = {...widget.seed, ...Map<String, dynamic>.from(data)};
|
||||
_loading = false;
|
||||
_err = '';
|
||||
});
|
||||
} else {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_err = '$e';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _forward() async {
|
||||
final combined = <String, dynamic>{...widget.seed, ..._row};
|
||||
final path = fetchPathOf(combined);
|
||||
if (path == null || !path.startsWith('/')) {
|
||||
deskToast(context, '当前信息暂不支持转发', error: true);
|
||||
return;
|
||||
}
|
||||
final picked = await Navigator.of(context).push<List<Map<String, dynamic>>>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => DeskPickPeoplePage(api: widget.api, title: '转发给', multiple: true, exclude: {widget.api.session.userId}),
|
||||
),
|
||||
);
|
||||
if (picked == null || picked.isEmpty || !mounted) return;
|
||||
final visible = flattenRecord(combined).where((e) => e.$2.trim().isNotEmpty).take(2).map((e) => '${e.$1}:${e.$2}').join(' · ');
|
||||
try {
|
||||
for (final person in picked) {
|
||||
await widget.api.post('/im/messages', {
|
||||
'peerId': '${person['id'] ?? ''}',
|
||||
'body': widget.title,
|
||||
'contentType': 'business',
|
||||
'meta': {
|
||||
'title': widget.title,
|
||||
'summary': visible,
|
||||
'fetchPath': path,
|
||||
'recordId': '${combined['id'] ?? combined['bizId'] ?? ''}',
|
||||
'bizType': '${combined['bizType'] ?? combined['source'] ?? ''}',
|
||||
},
|
||||
});
|
||||
}
|
||||
if (mounted) deskToast(context, '已转发给 ${picked.length} 人');
|
||||
} catch (e) {
|
||||
if (mounted) deskToast(context, '$e', error: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _act(String label, Future<void> Function() run) async {
|
||||
try {
|
||||
await run();
|
||||
if (mounted) {
|
||||
deskToast(context, label);
|
||||
await _load();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) deskToast(context, '$e', error: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _decide(String path, String result) async {
|
||||
final c = TextEditingController();
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(result == 'APPROVED' ? '通过' : '驳回'),
|
||||
content: TextField(controller: c, decoration: const InputDecoration(hintText: '意见(可选)')),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确认')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true) return;
|
||||
await _act('已提交', () async {
|
||||
await widget.api.post(path, {'result': result, if (c.text.trim().isNotEmpty) 'comment': c.text.trim()});
|
||||
});
|
||||
}
|
||||
|
||||
List<Widget> get _actions {
|
||||
final id = '${_row['id'] ?? widget.seed['id'] ?? ''}';
|
||||
final bizId = '${_row['bizId'] ?? ''}';
|
||||
final status = '${_row['status'] ?? ''}';
|
||||
final source = '${_row['source'] ?? ''}';
|
||||
final kind = '${_row['kind'] ?? ''}';
|
||||
final biz = '${_row['bizType'] ?? ''}';
|
||||
final actions = _row['myActions'] is Map ? Map<String, dynamic>.from(_row['myActions'] as Map) : <String, dynamic>{};
|
||||
final out = <Widget>[];
|
||||
|
||||
if (status == 'DRAFT' && (source == 'EXPENSE' && kind != 'LOAN' || _row.containsKey('claimNo'))) {
|
||||
out.add(FilledButton(onPressed: () => _act('已提交审批', () => widget.api.post('/expenses/$id/submit')), child: const Text('提交报销')));
|
||||
}
|
||||
if (status == 'DRAFT' && (kind == 'LOAN' || _row.containsKey('loanNo'))) {
|
||||
out.add(FilledButton(onPressed: () => _act('已提交审批', () => widget.api.post('/loans/$id/submit')), child: const Text('提交借款')));
|
||||
}
|
||||
if (biz.isNotEmpty && status == 'PENDING' && id.isNotEmpty && widget.seed['assigneeId'] != null) {
|
||||
out.add(FilledButton(onPressed: () => _decide('/office/approvals/$id/decide', 'APPROVED'), child: const Text('通过')));
|
||||
out.add(OutlinedButton(onPressed: () => _decide('/office/approvals/$id/decide', 'REJECTED'), child: const Text('驳回')));
|
||||
}
|
||||
if (source == 'OFFICE' && status == 'PENDING') {
|
||||
out.add(FilledButton(onPressed: () => _decide('/office/applies/$id/decide', 'APPROVED'), child: const Text('通过')));
|
||||
out.add(OutlinedButton(onPressed: () => _decide('/office/applies/$id/decide', 'REJECTED'), child: const Text('驳回')));
|
||||
}
|
||||
if ((biz == 'HR_LEAVE' || source == 'HR') && status == 'PENDING' && (bizId.isNotEmpty || id.isNotEmpty)) {
|
||||
final hid = bizId.isNotEmpty ? bizId : id;
|
||||
out.add(FilledButton(onPressed: () => _decide('/leave-requests/$hid/review', 'APPROVED'), child: const Text('通过')));
|
||||
out.add(OutlinedButton(onPressed: () => _decide('/leave-requests/$hid/review', 'REJECTED'), child: const Text('驳回')));
|
||||
}
|
||||
if (actions['canReview'] == true && id.isNotEmpty) {
|
||||
out.add(FilledButton(onPressed: () => _decide('/bid-cases/$id/reviews', 'APPROVED'), child: const Text('审批通过')));
|
||||
out.add(OutlinedButton(onPressed: () => _decide('/bid-cases/$id/reviews', 'REJECTED'), child: const Text('审批驳回')));
|
||||
}
|
||||
if ('${_row['status']}' == 'OPEN' && _row['assigneeId'] != null && !_row.containsKey('source')) {
|
||||
out.add(FilledButton(
|
||||
onPressed: () => _act('已办结', () => widget.api.patch('/office/todos/$id', {'status': 'DONE'})),
|
||||
child: const Text('标为已办'),
|
||||
));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pairs = flattenRecord(_row);
|
||||
return ColoredBox(
|
||||
color: kDeskBg,
|
||||
child: Column(
|
||||
children: [
|
||||
DeskPaneHeader(
|
||||
title: widget.title,
|
||||
actions: [
|
||||
IconButton(tooltip: '转发', onPressed: _loading ? null : _forward, icon: const Icon(Icons.forward_to_inbox_outlined, size: 20)),
|
||||
IconButton(tooltip: '刷新', onPressed: _load, icon: const Icon(Icons.refresh, size: 20)),
|
||||
],
|
||||
),
|
||||
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 720),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
if (_err.isNotEmpty) Padding(padding: const EdgeInsets.only(bottom: 8), child: Text(_err, style: const TextStyle(color: kDeskMute, fontSize: 12))),
|
||||
Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < pairs.length; i++)
|
||||
_kvRow(pairs[i].$1, pairs[i].$2, i == pairs.length - 1),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_actions.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: Wrap(spacing: 8, runSpacing: 8, children: _actions),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _kvRow(String k, String v, bool last) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(border: last ? null : const Border(bottom: BorderSide(color: kDeskLine))),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(width: 100, child: Text(k, style: const TextStyle(fontSize: 13, color: kDeskMute))),
|
||||
Expanded(child: Text(v, style: const TextStyle(fontSize: 14))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../api/oa_client.dart';
|
||||
import '../../nav/notice_route.dart';
|
||||
import '../../session/session.dart';
|
||||
import '../desk_theme.dart';
|
||||
import '../widgets/desk_widgets.dart';
|
||||
|
||||
class DeskSystemNoticePage extends StatefulWidget {
|
||||
const DeskSystemNoticePage({super.key, required this.session, required this.api, required this.conversationId});
|
||||
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
final String conversationId;
|
||||
|
||||
@override
|
||||
State<DeskSystemNoticePage> createState() => _DeskSystemNoticePageState();
|
||||
}
|
||||
|
||||
class _DeskSystemNoticePageState extends State<DeskSystemNoticePage> {
|
||||
List<Map<String, dynamic>> _items = [];
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final data = await widget.api.get('/im/messages', query: {'conversationId': widget.conversationId});
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = data is Map ? asMaps(data['items'] ?? data) : asMaps(data);
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _meta(Map<String, dynamic> row) {
|
||||
final raw = row['meta'];
|
||||
if (raw is Map) return Map<String, dynamic>.from(raw);
|
||||
if (raw is String && raw.isNotEmpty) {
|
||||
try {
|
||||
final d = jsonDecode(raw);
|
||||
if (d is Map) return Map<String, dynamic>.from(d);
|
||||
} catch (_) {}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
void _open(Map<String, dynamic> row) {
|
||||
final meta = _meta(row);
|
||||
openNoticeTarget(
|
||||
context,
|
||||
widget.api,
|
||||
session: widget.session,
|
||||
kind: '${meta['kind'] ?? row['kind'] ?? ''}',
|
||||
bizType: '${meta['bizType'] ?? ''}',
|
||||
bizId: '${meta['bizId'] ?? ''}',
|
||||
title: '${row['body'] ?? meta['title'] ?? '系统通知'}',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
color: const Color(0xFFF5F5F5),
|
||||
child: Column(
|
||||
children: [
|
||||
const DeskPaneHeader(title: '系统通知'),
|
||||
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
|
||||
Expanded(
|
||||
child: _items.isEmpty
|
||||
? const Center(child: Text('暂无通知', style: TextStyle(color: kDeskMute)))
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _items.length,
|
||||
itemBuilder: (_, i) {
|
||||
final r = _items[i];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
onTap: () => _open(r),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.notifications_none, color: kDeskBind),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${r['body'] ?? '通知'}', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 4),
|
||||
Text(shortTime(r['createdAt']), style: const TextStyle(fontSize: 12, color: kDeskMute)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right, color: kDeskMute, size: 18),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../api/oa_client.dart';
|
||||
import '../../session/session.dart';
|
||||
import '../desk_theme.dart';
|
||||
import '../widgets/desk_data_table.dart';
|
||||
import '../widgets/desk_widgets.dart';
|
||||
import 'desk_record_detail_page.dart';
|
||||
|
||||
class DeskWorkAssignPage extends StatefulWidget {
|
||||
const DeskWorkAssignPage({super.key, required this.session, required this.api});
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
|
||||
@override
|
||||
State<DeskWorkAssignPage> createState() => _DeskWorkAssignPageState();
|
||||
}
|
||||
|
||||
class _DeskWorkAssignPageState extends State<DeskWorkAssignPage> {
|
||||
List<Map<String, dynamic>> _items = [];
|
||||
bool _loading = true;
|
||||
|
||||
bool get _canAssign {
|
||||
const codes = ['admin', 'owner', 'biz_director', 'tech_director', 'rd_director', '3d_director', 'video_director', 'material_director', 'pm'];
|
||||
return widget.session.roles.any(codes.contains);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final data = await widget.api.get('/office/work-assignments');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = asMaps(data);
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _create() async {
|
||||
final staff = asMaps(await widget.api.get('/staff'));
|
||||
if (!mounted) return;
|
||||
final title = TextEditingController();
|
||||
final content = TextEditingController();
|
||||
String? assignee;
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setSt) => AlertDialog(
|
||||
title: const Text('安排工作'),
|
||||
content: SizedBox(
|
||||
width: 440,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(controller: title, decoration: const InputDecoration(labelText: '标题')),
|
||||
const SizedBox(height: 8),
|
||||
TextField(controller: content, decoration: const InputDecoration(labelText: '内容'), maxLines: 3),
|
||||
const SizedBox(height: 8),
|
||||
DropdownButtonFormField<String>(
|
||||
decoration: const InputDecoration(labelText: '执行人'),
|
||||
items: [
|
||||
for (final s in staff)
|
||||
DropdownMenuItem(value: '${s['id']}', child: Text('${s['name'] ?? ''} · ${s['department'] ?? ''}')),
|
||||
],
|
||||
onChanged: (v) => setSt(() => assignee = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('下达')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (ok != true) return;
|
||||
try {
|
||||
await widget.api.post('/office/work-assignments', {
|
||||
'title': title.text.trim(),
|
||||
'content': content.text.trim(),
|
||||
'assigneeId': assignee,
|
||||
});
|
||||
if (mounted) {
|
||||
deskToast(context, '已下达');
|
||||
_load();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) deskToast(context, '$e', error: true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DeskListScaffold(
|
||||
title: '工作安排',
|
||||
loading: _loading,
|
||||
actions: [
|
||||
if (_canAssign)
|
||||
FilledButton.icon(
|
||||
style: FilledButton.styleFrom(backgroundColor: kDeskBind),
|
||||
onPressed: _create,
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text('安排工作'),
|
||||
),
|
||||
IconButton(onPressed: _load, icon: const Icon(Icons.refresh, size: 20)),
|
||||
],
|
||||
body: DeskDataTable(
|
||||
columns: const ['标题', '执行人', '时间', '状态'],
|
||||
rows: [
|
||||
for (final r in _items)
|
||||
[
|
||||
'${r['title'] ?? ''}',
|
||||
'${r['assignee'] is Map ? r['assignee']['displayName'] : ''}',
|
||||
fmtTime(r['dueAt'] ?? r['createdAt']),
|
||||
'${r['status'] ?? ''}',
|
||||
],
|
||||
],
|
||||
emptyHint: '没有工作安排',
|
||||
onRowTap: (i) => deskOpenRecord(context, widget.api, _items[i]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../api/oa_client.dart';
|
||||
import '../../device/device_bridge.dart';
|
||||
import '../../labels.dart';
|
||||
import '../../nav/open_module.dart';
|
||||
import '../../pages/module_list_page.dart' show RestShell, shellFor;
|
||||
import '../../session/session.dart';
|
||||
import '../desk_theme.dart';
|
||||
import '../widgets/desk_data_table.dart';
|
||||
import '../widgets/desk_widgets.dart';
|
||||
import 'desk_attendance_page.dart';
|
||||
import 'desk_hr_apply_page.dart';
|
||||
import 'desk_legal_page.dart';
|
||||
import 'desk_office_list_page.dart';
|
||||
import 'desk_record_detail_page.dart';
|
||||
import 'desk_work_assign_page.dart';
|
||||
|
||||
typedef DeskOpenTab = void Function(String key, String title, IconData icon, Color color, Widget page);
|
||||
|
||||
class DeskWorkbenchPage extends StatefulWidget {
|
||||
const DeskWorkbenchPage({super.key, required this.session, required this.api, required this.onOpenTab});
|
||||
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
final DeskOpenTab onOpenTab;
|
||||
|
||||
@override
|
||||
State<DeskWorkbenchPage> createState() => _DeskWorkbenchPageState();
|
||||
}
|
||||
|
||||
class _DeskWorkbenchPageState extends State<DeskWorkbenchPage> {
|
||||
Map<String, dynamic> _ov = {};
|
||||
String _greet = '';
|
||||
bool _loading = true;
|
||||
String _q = '';
|
||||
String _cat = 'all';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final ov = await widget.api.get('/office/overview');
|
||||
String greet = '';
|
||||
try {
|
||||
final w = await widget.api.get('/office/weather');
|
||||
if (w is Map) greet = '${w['greeting'] ?? w['text'] ?? ''}';
|
||||
} catch (_) {}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_ov = Map<String, dynamic>.from(ov as Map? ?? {});
|
||||
_greet = greet;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
bool _match(String name) => _q.isEmpty || name.contains(_q);
|
||||
|
||||
void _open(String key, String title, IconData icon, Color color, Widget page) {
|
||||
widget.onOpenTab(key, title, icon, color, page);
|
||||
}
|
||||
|
||||
List<_App> _personalApps() {
|
||||
return [
|
||||
if (_match('待办'))
|
||||
_App('todos', '待办', '处理待办事项与提醒', Icons.task_alt, const Color(0xFFFA9D3B),
|
||||
DeskOfficeListPage(api: widget.api, title: '待办', path: '/office/todos', buckets: const [('OPEN', '待办'), ('DONE', '已办'), ('', '全部')], defaultBucket: 'OPEN')),
|
||||
if (_match('审批'))
|
||||
_App('approvals', '待审批', '审批各类办公申请', Icons.fact_check, kDeskBind,
|
||||
DeskOfficeListPage(api: widget.api, title: '待我审批', path: '/office/approvals', buckets: const [('PENDING', '待审批'), ('APPROVED', '已通过'), ('REJECTED', '已驳回'), ('', '全部')], defaultBucket: 'PENDING')),
|
||||
if (_match('申请'))
|
||||
_App('flow', '我的申请', '查看我发起的申请', Icons.assignment_outlined, const Color(0xFF6267F2),
|
||||
DeskOfficeListPage(api: widget.api, title: '我的申请', path: '/office/flow', buckets: const [('', '全部'), ('pending', '待审批'), ('approved', '已通过'), ('rejected', '已驳回')], defaultBucket: '')),
|
||||
if (_match('日程'))
|
||||
_App('calendar', '日程', '会议与日程安排', Icons.calendar_month, const Color(0xFF10AEFF),
|
||||
DeskOfficeListPage(
|
||||
api: widget.api,
|
||||
title: '我的日程',
|
||||
path: '/office/calendar',
|
||||
columns: const ['标题', '时间', '地点'],
|
||||
rowBuilder: (r) => ['${r['title'] ?? ''}', fmtTime(r['startAt'] ?? r['createdAt']), '${r['location'] ?? ''}'],
|
||||
)),
|
||||
if (_match('安排'))
|
||||
_App('work', '工作安排', '任务分配与跟进', Icons.event_note, const Color(0xFF00B578),
|
||||
DeskWorkAssignPage(session: widget.session, api: widget.api)),
|
||||
if (_match('打卡') || _match('考勤'))
|
||||
_App('attendance', '考勤打卡', '上下班打卡记录', Icons.access_time_filled, const Color(0xFF267EF0),
|
||||
DeskAttendancePage(api: widget.api)),
|
||||
if (_match('人事'))
|
||||
_App('hr', '人事申请', '请假、加班、外出等', Icons.beach_access, const Color(0xFF8B5CF6), DeskHrApplyPage(api: widget.api)),
|
||||
];
|
||||
}
|
||||
|
||||
List<_App> _menuApps() {
|
||||
final out = <_App>[];
|
||||
for (final m in widget.session.menus) {
|
||||
if (m.name == '工作台' || m.name == '个人办公') continue;
|
||||
if (m.name == '系统设置' || m.code.startsWith('system')) continue;
|
||||
if (m.children.isNotEmpty) {
|
||||
for (var i = 0; i < m.children.length; i++) {
|
||||
final c = m.children[i];
|
||||
if (_skip(c)) continue;
|
||||
if (!(_q.isEmpty || c.name.contains(_q) || m.name.contains(_q))) continue;
|
||||
out.add(_App(c.code, c.name, m.name, iconFor(c.code, c.name), colorFor(c.code, c.name, i), _pageFor(c)));
|
||||
}
|
||||
} else if (!_skip(m) && _match(m.name)) {
|
||||
out.add(_App(m.code, m.name, '业务应用', iconFor(m.code, m.name), colorFor(m.code, m.name), _pageFor(m)));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool _skip(MenuNode n) {
|
||||
const names = {'待办', '待审批', '我的申请', '日程', '公告', '公司公告', '工作安排', '人事申请', '工作台', '个人办公', '消息', '通讯录', '员工通讯', '发送短信', '系统设置'};
|
||||
if (n.code.startsWith('office:') || n.code.startsWith('system')) return true;
|
||||
if (n.code.contains('sms') || n.name.contains('短信')) return true;
|
||||
if (n.name.contains('公告') || n.name == '员工通讯') return true;
|
||||
return names.contains(n.name);
|
||||
}
|
||||
|
||||
String _legalKind(MenuNode n) {
|
||||
final p = '${n.path ?? ''} ${n.code}'.toLowerCase();
|
||||
if (p.contains('privacy') || p.contains('隐私')) return 'privacy';
|
||||
return 'user-agreement';
|
||||
}
|
||||
|
||||
Widget _pageFor(MenuNode n) {
|
||||
if (n.code.contains('legal') || (n.path ?? '').startsWith('/legal')) {
|
||||
return DeskLegalPage(api: widget.api, kind: _legalKind(n));
|
||||
}
|
||||
if (n.code == 'office:assign' || n.name.contains('工作安排')) {
|
||||
return DeskWorkAssignPage(session: widget.session, api: widget.api);
|
||||
}
|
||||
final shell = shellFor(n.code, n.path) ?? _shellFromPath(n.path);
|
||||
if (shell != null) {
|
||||
return DeskModuleListPage(api: widget.api, title: n.name, shell: shell);
|
||||
}
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('${n.name} 暂未配置桌面入口', style: const TextStyle(color: kDeskMute)),
|
||||
if ((n.path ?? '').startsWith('http'))
|
||||
TextButton(
|
||||
onPressed: () => DeviceBridge.openUrl(n.path!),
|
||||
child: const Text('在浏览器打开'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
RestShell? _shellFromPath(String? path) {
|
||||
final p = (path ?? '').trim();
|
||||
if (p.isEmpty || !p.startsWith('/') || p.contains(':')) return null;
|
||||
const blocked = [
|
||||
'/office/overview',
|
||||
'/office/todos',
|
||||
'/office/approvals',
|
||||
'/office/flow',
|
||||
'/office/calendar',
|
||||
'/office/weather',
|
||||
'/office/geo',
|
||||
'/auth',
|
||||
'/im',
|
||||
'/system',
|
||||
'/push',
|
||||
'/files',
|
||||
'/health',
|
||||
];
|
||||
if (blocked.any((b) => p.startsWith(b))) return null;
|
||||
return RestShell(p);
|
||||
}
|
||||
|
||||
List<_App> get _shown {
|
||||
final personal = _personalApps();
|
||||
final menu = _menuApps();
|
||||
if (_cat == 'personal') return personal;
|
||||
if (_cat == 'biz') return menu;
|
||||
return [...personal, ...menu];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final width = MediaQuery.sizeOf(context).width;
|
||||
final cols = width >= 1100 ? 3 : 2;
|
||||
return ColoredBox(
|
||||
color: kDeskBg,
|
||||
child: Column(
|
||||
children: [
|
||||
DeskPaneHeader(
|
||||
title: '工作台',
|
||||
actions: [
|
||||
SizedBox(width: 200, child: DeskSearchField(hint: '搜索应用', onChanged: (v) => setState(() => _q = v.trim()))),
|
||||
const SizedBox(width: 8),
|
||||
TextButton(onPressed: _load, child: const Text('刷新')),
|
||||
],
|
||||
bottom: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 10),
|
||||
child: DeskFilterChips(
|
||||
value: _cat,
|
||||
onChanged: (v) => setState(() => _cat = v),
|
||||
items: const [('all', '全部应用'), ('personal', '个人办公'), ('biz', '业务模块')],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 24),
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(colors: [Color(0xFFEEF4FF), Color(0xFFF8FBFF)]),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFDCE8FF)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_greet.isEmpty ? '你好,${widget.session.displayName}' : _greet,
|
||||
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text('个人办公与业务入口', style: TextStyle(color: kDeskMute, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
_stat('待办', '${_ov['pendingTodos'] ?? 0}', () => _open('todos', '待办', Icons.task_alt, const Color(0xFFFA9D3B), DeskOfficeListPage(api: widget.api, title: '待办', path: '/office/todos', buckets: const [('OPEN', '待办'), ('DONE', '已办'), ('', '全部')], defaultBucket: 'OPEN'))),
|
||||
const SizedBox(width: 10),
|
||||
_stat('待审批', '${_ov['pendingApprovals'] ?? 0}', () => _open('approvals', '待审批', Icons.fact_check, kDeskBind, DeskOfficeListPage(api: widget.api, title: '待我审批', path: '/office/approvals', buckets: const [('PENDING', '待审批'), ('', '全部')], defaultBucket: 'PENDING'))),
|
||||
const SizedBox(width: 10),
|
||||
_stat('我的申请', '${_ov['myPending'] ?? 0}', () => _open('flow', '我的申请', Icons.assignment_outlined, const Color(0xFF6267F2), DeskOfficeListPage(api: widget.api, title: '我的申请', path: '/office/flow'))),
|
||||
const SizedBox(width: 10),
|
||||
_stat('逾期', '${_ov['overdueTodos'] ?? 0}', () => _open('todos', '待办', Icons.task_alt, const Color(0xFFFA9D3B), DeskOfficeListPage(api: widget.api, title: '待办', path: '/office/todos'))),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
if (_shown.isEmpty)
|
||||
const Padding(padding: EdgeInsets.only(top: 40), child: Center(child: Text('没有匹配的应用', style: TextStyle(color: kDeskMute))))
|
||||
else
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: cols,
|
||||
mainAxisSpacing: 10,
|
||||
crossAxisSpacing: 10,
|
||||
childAspectRatio: 2.9,
|
||||
),
|
||||
itemCount: _shown.length,
|
||||
itemBuilder: (_, i) {
|
||||
final a = _shown[i];
|
||||
return DeskAppCard(
|
||||
title: a.title,
|
||||
desc: a.desc,
|
||||
icon: a.icon,
|
||||
color: a.color,
|
||||
onTap: () => _open(a.key, a.title, a.icon, a.color, a.page),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stat(String label, String value, VoidCallback onTap) {
|
||||
return Expanded(
|
||||
child: Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(value, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: kDeskBind)),
|
||||
const SizedBox(height: 2),
|
||||
Text(label, style: const TextStyle(fontSize: 12, color: kDeskMute)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _App {
|
||||
_App(this.key, this.title, this.desc, this.icon, this.color, this.page);
|
||||
final String key;
|
||||
final String title;
|
||||
final String desc;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final Widget page;
|
||||
}
|
||||
|
||||
/// 桌面端业务模块列表(表格),不沿用移动端 ModuleListPage。
|
||||
class DeskModuleListPage extends StatefulWidget {
|
||||
const DeskModuleListPage({super.key, required this.api, required this.title, required this.shell});
|
||||
final OaClient api;
|
||||
final String title;
|
||||
final RestShell shell;
|
||||
|
||||
@override
|
||||
State<DeskModuleListPage> createState() => _DeskModuleListPageState();
|
||||
}
|
||||
|
||||
class _DeskModuleListPageState extends State<DeskModuleListPage> {
|
||||
List<Map<String, dynamic>> _items = [];
|
||||
bool _loading = true;
|
||||
String _q = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final data = await widget.api.get(widget.shell.path, query: {'page': '1', 'pageSize': '100', ...?widget.shell.query});
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = asMaps(data);
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> get _shown {
|
||||
if (_q.isEmpty) return _items;
|
||||
return _items.where((e) => pickTitle(e).contains(_q) || pickSub(e).contains(_q)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shown = _shown;
|
||||
return DeskListScaffold(
|
||||
title: widget.title,
|
||||
loading: _loading,
|
||||
actions: [IconButton(onPressed: _load, icon: const Icon(Icons.refresh, size: 20))],
|
||||
filters: DeskSearchField(hint: '搜索', onChanged: (v) => setState(() => _q = v.trim())),
|
||||
body: DeskDataTable(
|
||||
columns: const ['名称', '摘要', '状态'],
|
||||
rows: [
|
||||
for (final r in shown)
|
||||
[pickTitle(r), zh(pickSub(r)), '${r['status'] ?? ''}'],
|
||||
],
|
||||
emptyHint: '暂无${widget.title}',
|
||||
onRowTap: (i) {
|
||||
final r = shown[i];
|
||||
final seed = Map<String, dynamic>.from(r);
|
||||
if ('${r['id']}'.length >= 8) seed['_fetch'] = '${widget.shell.path}/${r['id']}';
|
||||
deskOpenRecord(context, widget.api, seed, title: pickTitle(r));
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String pickTitle(Map<String, dynamic> r) {
|
||||
for (final k in ['title', 'name', 'code', 'no']) {
|
||||
final v = r[k];
|
||||
if (v != null && '$v'.isNotEmpty) return '$v';
|
||||
}
|
||||
return '记录';
|
||||
}
|
||||
|
||||
String pickSub(Map<String, dynamic> r) {
|
||||
for (final k in ['summary', 'remark', 'description', 'partyName', 'projectName']) {
|
||||
final v = r[k];
|
||||
if (v != null && '$v'.isNotEmpty) return '$v';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../desk_theme.dart';
|
||||
|
||||
/// 桌面端通用数据表格,替代移动端 KvTile 列表。
|
||||
class DeskDataTable extends StatelessWidget {
|
||||
const DeskDataTable({
|
||||
super.key,
|
||||
required this.columns,
|
||||
required this.rows,
|
||||
this.onRowTap,
|
||||
this.emptyHint = '暂无数据',
|
||||
});
|
||||
|
||||
final List<String> columns;
|
||||
final List<List<String>> rows;
|
||||
final void Function(int index)? onRowTap;
|
||||
final String emptyHint;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (rows.isEmpty) {
|
||||
return Center(child: Text(emptyHint, style: const TextStyle(color: kDeskMute)));
|
||||
}
|
||||
return Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: DataTable(
|
||||
headingRowHeight: 40,
|
||||
dataRowMinHeight: 44,
|
||||
dataRowMaxHeight: 56,
|
||||
columnSpacing: 24,
|
||||
horizontalMargin: 16,
|
||||
columns: [for (final c in columns) DataColumn(label: Text(c))],
|
||||
rows: [
|
||||
for (var i = 0; i < rows.length; i++)
|
||||
DataRow(
|
||||
onSelectChanged: onRowTap == null ? null : (_) => onRowTap!(i),
|
||||
cells: [for (final cell in rows[i]) DataCell(Text(cell, maxLines: 2, overflow: TextOverflow.ellipsis))],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DeskListScaffold extends StatelessWidget {
|
||||
const DeskListScaffold({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.body,
|
||||
this.filters,
|
||||
this.actions = const [],
|
||||
this.loading = false,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final Widget body;
|
||||
final Widget? filters;
|
||||
final List<Widget> actions;
|
||||
final bool loading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
color: kDeskBg,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ColoredBox(
|
||||
color: kDeskPane,
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
const Spacer(),
|
||||
...actions,
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (filters != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 10),
|
||||
child: filters!,
|
||||
),
|
||||
const Divider(height: 1, color: kDeskLine),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: body,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../api/oa_client.dart';
|
||||
import '../../session/session.dart';
|
||||
import '../../widgets/auth_image.dart';
|
||||
import '../desk_theme.dart';
|
||||
|
||||
class DeskNavRail extends StatelessWidget {
|
||||
const DeskNavRail({
|
||||
super.key,
|
||||
required this.session,
|
||||
required this.api,
|
||||
required this.index,
|
||||
required this.onChanged,
|
||||
required this.items,
|
||||
});
|
||||
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
final int index;
|
||||
final ValueChanged<int> onChanged;
|
||||
final List<(IconData, IconData, String, int)> items;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
color: kDeskRail,
|
||||
child: SizedBox(
|
||||
width: kDeskRailWidth,
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 18),
|
||||
DeskAvatar(
|
||||
label: session.displayName,
|
||||
fileId: '${session.user['avatarFileId'] ?? ''}',
|
||||
api: api,
|
||||
size: 36,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
for (var i = 0; i < items.length; i++)
|
||||
_RailItem(
|
||||
active: index == i,
|
||||
icon: index == i ? items[i].$2 : items[i].$1,
|
||||
label: items[i].$3,
|
||||
badge: items[i].$4,
|
||||
onTap: () => onChanged(i),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RailItem extends StatelessWidget {
|
||||
const _RailItem({
|
||||
required this.active,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.badge,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final bool active;
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final int badge;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
child: Material(
|
||||
color: active ? kDeskActive : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
children: [
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Icon(icon, size: 22, color: active ? kDeskBind : const Color(0xFF5C5C5C)),
|
||||
if (badge > 0)
|
||||
Positioned(
|
||||
right: -10,
|
||||
top: -6,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
constraints: const BoxConstraints(minWidth: 16, minHeight: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFA5151),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
badge > 99 ? '99+' : '$badge',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: active ? kDeskBind : const Color(0xFF5C5C5C),
|
||||
fontWeight: active ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DeskAvatar extends StatelessWidget {
|
||||
const DeskAvatar({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.api,
|
||||
this.fileId,
|
||||
this.size = 40,
|
||||
this.color,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final OaClient api;
|
||||
final String? fileId;
|
||||
final double size;
|
||||
final Color? color;
|
||||
final IconData? icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final id = fileId ?? '';
|
||||
if (id.isNotEmpty) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: AuthImage(fileId: id, api: api, width: size, height: size, fit: BoxFit.cover),
|
||||
);
|
||||
}
|
||||
final c = color ?? kDeskBind;
|
||||
final ch = label.isNotEmpty ? label.characters.first : '?';
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(6)),
|
||||
alignment: Alignment.center,
|
||||
child: icon != null
|
||||
? Icon(icon, color: Colors.white, size: size * 0.5)
|
||||
: Text(ch, style: TextStyle(color: Colors.white, fontSize: size * 0.38, fontWeight: FontWeight.w600)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DeskPaneHeader extends StatelessWidget {
|
||||
const DeskPaneHeader({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.actions = const [],
|
||||
this.bottom,
|
||||
this.leading,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final List<Widget> actions;
|
||||
final Widget? bottom;
|
||||
final Widget? leading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
color: kDeskPane,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: Row(
|
||||
children: [
|
||||
if (leading != null) leading!,
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: leading == null ? 14 : 4),
|
||||
child: Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: kDeskInk)),
|
||||
),
|
||||
const Spacer(),
|
||||
...actions,
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (bottom != null) bottom!,
|
||||
const Divider(height: 1, color: kDeskLine),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DeskSearchField extends StatelessWidget {
|
||||
const DeskSearchField({super.key, required this.hint, this.onChanged, this.controller});
|
||||
|
||||
final String hint;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final TextEditingController? controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 32,
|
||||
decoration: BoxDecoration(color: const Color(0xFFEFEFEF), borderRadius: BorderRadius.circular(6)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.search, size: 16, color: kDeskMute),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
onChanged: onChanged,
|
||||
style: const TextStyle(fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
hintStyle: const TextStyle(color: Color(0xFFB2B2B2), fontSize: 13),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DeskConvRow extends StatelessWidget {
|
||||
const DeskConvRow({
|
||||
super.key,
|
||||
required this.name,
|
||||
required this.preview,
|
||||
required this.time,
|
||||
required this.unread,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
this.avatarId,
|
||||
this.api,
|
||||
this.muted = false,
|
||||
this.pinned = false,
|
||||
this.onSecondaryTap,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String preview;
|
||||
final String time;
|
||||
final int unread;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
final String? avatarId;
|
||||
final OaClient? api;
|
||||
final bool muted;
|
||||
final bool pinned;
|
||||
final void Function(TapDownDetails)? onSecondaryTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: selected ? kDeskActive : Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
onSecondaryTapDown: onSecondaryTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
if (api != null)
|
||||
DeskAvatar(label: name, fileId: avatarId, api: api!, size: 40)
|
||||
else
|
||||
DeskAvatar(label: name, api: OaClient(SessionStore()), size: 40),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (pinned)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 4),
|
||||
child: Icon(Icons.push_pin, size: 12, color: kDeskMute),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: kDeskInk),
|
||||
),
|
||||
),
|
||||
Text(time, style: const TextStyle(fontSize: 11, color: kDeskMute)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
preview,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 12, color: muted ? kDeskMute : const Color(0xFF9A9A9A)),
|
||||
),
|
||||
),
|
||||
if (muted)
|
||||
const Icon(Icons.notifications_off_outlined, size: 14, color: kDeskMute),
|
||||
if (unread > 0) ...[
|
||||
const SizedBox(width: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFA5151),
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
child: Text(
|
||||
unread > 99 ? '99+' : '$unread',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 10),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DeskEmptyChat extends StatelessWidget {
|
||||
const DeskEmptyChat({super.key, this.hint = '选择一个会话开始聊天'});
|
||||
|
||||
final String hint;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
color: const Color(0xFFF5F5F5),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Opacity(
|
||||
opacity: 0.1,
|
||||
child: Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(color: kDeskBind, borderRadius: BorderRadius.circular(24)),
|
||||
child: const Icon(Icons.chat_bubble_outline, size: 52, color: Colors.white),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(hint, style: const TextStyle(color: kDeskMute, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DeskTabStrip extends StatelessWidget {
|
||||
const DeskTabStrip({
|
||||
super.key,
|
||||
required this.tabs,
|
||||
required this.active,
|
||||
required this.onSelect,
|
||||
required this.onClose,
|
||||
});
|
||||
|
||||
final List<(String key, String title, IconData icon, Color color)> tabs;
|
||||
final int active;
|
||||
final ValueChanged<int> onSelect;
|
||||
final ValueChanged<int> onClose;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
color: kDeskPane,
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 40,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
children: [
|
||||
for (var i = 0; i < tabs.length; i++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 2, top: 6, bottom: 6),
|
||||
child: Material(
|
||||
color: active == i ? const Color(0xFFF0F4FA) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: InkWell(
|
||||
onTap: () => onSelect(i),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(10, 5, i > 0 ? 4 : 10, 5),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(tabs[i].$3, size: 14, color: tabs[i].$4),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
tabs[i].$2,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: active == i ? kDeskInk : kDeskMute,
|
||||
fontWeight: active == i ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
if (i > 0) ...[
|
||||
const SizedBox(width: 2),
|
||||
InkWell(
|
||||
onTap: () => onClose(i),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(2),
|
||||
child: Icon(Icons.close, size: 12, color: kDeskMute),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, color: kDeskLine),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DeskAppCard extends StatelessWidget {
|
||||
const DeskAppCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.desc,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String desc;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 14, 14, 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(10)),
|
||||
child: Icon(icon, color: Colors.white, size: 22),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: kDeskInk)),
|
||||
const SizedBox(height: 4),
|
||||
Text(desc, maxLines: 2, overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12, color: kDeskMute, height: 1.35)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DeskFilterChips extends StatelessWidget {
|
||||
const DeskFilterChips({super.key, required this.value, required this.onChanged, required this.items});
|
||||
|
||||
final String value;
|
||||
final ValueChanged<String> onChanged;
|
||||
final List<(String, String)> items;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
for (final it in items) ...[
|
||||
_chip(it.$1, it.$2),
|
||||
if (it != items.last) const SizedBox(width: 6),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _chip(String id, String label) {
|
||||
final on = value == id;
|
||||
return GestureDetector(
|
||||
onTap: () => onChanged(id),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: on ? kDeskActive : Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: on ? const Color(0xFFB8D4FF) : kDeskLine),
|
||||
),
|
||||
child: Text(label, style: TextStyle(fontSize: 12, color: on ? kDeskBind : kDeskMute, fontWeight: on ? FontWeight.w600 : FontWeight.w400)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void deskToast(BuildContext context, String msg, {bool error = false}) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(msg),
|
||||
backgroundColor: error ? const Color(0xFFFA5151) : null,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
width: 320,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:record/record.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
|
||||
/// 系统截屏、高刷、录音、选图(保留 GIF)。
|
||||
class DeviceBridge {
|
||||
DeviceBridge._();
|
||||
static const _ch = EventChannel('com.fysxkj.oa/device');
|
||||
static const _m = MethodChannel('com.fysxkj.oa/ota');
|
||||
static StreamSubscription<dynamic>? _sub;
|
||||
static final _cbs = <void Function()>{};
|
||||
|
||||
static final AudioPlayer _player = AudioPlayer();
|
||||
static final AudioRecorder _recorder = AudioRecorder();
|
||||
static String? _recordingPath;
|
||||
static DateTime? _recordingStarted;
|
||||
static String? _playingPath;
|
||||
static OaClient? _api;
|
||||
|
||||
static void bindApi(OaClient api) => _api = api;
|
||||
|
||||
static bool get _useNativeBridge => Platform.isAndroid;
|
||||
|
||||
static Future<void> preferMaxRefresh() async {
|
||||
if (!_useNativeBridge) return;
|
||||
try {
|
||||
await _m.invokeMethod('preferMaxRefresh');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Timer? _clipPoll;
|
||||
static bool _clipHadImage = false;
|
||||
static int _lastClipAt = 0;
|
||||
|
||||
static void watchScreenshot(void Function() onShot) {
|
||||
_cbs.add(onShot);
|
||||
if (Platform.isAndroid || Platform.isWindows) {
|
||||
_sub ??= _ch.receiveBroadcastStream().listen((event) {
|
||||
if (event == 'screenshot') {
|
||||
for (final cb in [..._cbs]) {
|
||||
cb();
|
||||
}
|
||||
}
|
||||
});
|
||||
_m.invokeMethod('startScreenshotWatch').catchError((_) {});
|
||||
} else if (Platform.isLinux) {
|
||||
_startLinuxScreenshotPoll();
|
||||
}
|
||||
}
|
||||
|
||||
static void _startLinuxScreenshotPoll() {
|
||||
_clipPoll ??= Timer.periodic(const Duration(milliseconds: 900), (_) async {
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
if (now - _lastClipAt < 1500) return;
|
||||
final has = await _linuxClipboardHasImage();
|
||||
if (has && !_clipHadImage) {
|
||||
_clipHadImage = true;
|
||||
_lastClipAt = now;
|
||||
for (final cb in [..._cbs]) {
|
||||
cb();
|
||||
}
|
||||
} else if (!has) {
|
||||
_clipHadImage = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static Future<bool> _linuxClipboardHasImage() async {
|
||||
try {
|
||||
final x = await Process.run('bash', [
|
||||
'-c',
|
||||
'command -v xclip >/dev/null && xclip -selection clipboard -t TARGETS -o 2>/dev/null | grep -qi image && echo 1; '
|
||||
'command -v wl-paste >/dev/null && wl-paste --list-types 2>/dev/null | grep -qi image && echo 1',
|
||||
]);
|
||||
return '${x.stdout}'.contains('1');
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static void unwatchScreenshot(void Function() onShot) {
|
||||
_cbs.remove(onShot);
|
||||
if (_cbs.isNotEmpty) return;
|
||||
if (Platform.isAndroid || Platform.isWindows) {
|
||||
_m.invokeMethod('stopScreenshotWatch').catchError((_) {});
|
||||
_sub?.cancel();
|
||||
_sub = null;
|
||||
}
|
||||
_clipPoll?.cancel();
|
||||
_clipPoll = null;
|
||||
_clipHadImage = false;
|
||||
}
|
||||
|
||||
static Future<void> startVoice() async {
|
||||
if (_useNativeBridge) {
|
||||
await _m.invokeMethod('startVoice');
|
||||
return;
|
||||
}
|
||||
if (!await _recorder.hasPermission()) {
|
||||
throw Exception('没有麦克风权限');
|
||||
}
|
||||
final dir = await getTemporaryDirectory();
|
||||
_recordingPath = '${dir.path}/voice_${DateTime.now().millisecondsSinceEpoch}.m4a';
|
||||
_recordingStarted = DateTime.now();
|
||||
await _recorder.start(const RecordConfig(encoder: AudioEncoder.aacLc), path: _recordingPath!);
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>?> stopVoice() async {
|
||||
if (_useNativeBridge) {
|
||||
final raw = await _m.invokeMethod('stopVoice');
|
||||
if (raw is Map) return Map<String, dynamic>.from(raw);
|
||||
return null;
|
||||
}
|
||||
final path = await _recorder.stop();
|
||||
_recordingPath = null;
|
||||
if (path == null || path.isEmpty) return null;
|
||||
final started = _recordingStarted;
|
||||
_recordingStarted = null;
|
||||
final seconds = started == null ? 1 : DateTime.now().difference(started).inSeconds.clamp(1, 60);
|
||||
return {'path': path, 'seconds': seconds};
|
||||
}
|
||||
|
||||
static Future<void> cancelVoice() async {
|
||||
if (_useNativeBridge) {
|
||||
try {
|
||||
await _m.invokeMethod('cancelVoice');
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await _recorder.stop();
|
||||
} catch (_) {}
|
||||
_recordingPath = null;
|
||||
_recordingStarted = null;
|
||||
}
|
||||
|
||||
static Future<void> playVoice(String path) async {
|
||||
if (_useNativeBridge) {
|
||||
await _m.invokeMethod('playVoice', {'path': path});
|
||||
return;
|
||||
}
|
||||
await _player.stop();
|
||||
_playingPath = path;
|
||||
await _player.play(DeviceFileSource(path));
|
||||
}
|
||||
|
||||
static Future<void> stopPlay() async {
|
||||
if (_useNativeBridge) {
|
||||
try {
|
||||
await _m.invokeMethod('stopPlay');
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
await _player.stop();
|
||||
_playingPath = null;
|
||||
}
|
||||
|
||||
static String? get playingPath => _playingPath;
|
||||
|
||||
/// 手机端本地语音转文字(不调 OA 服务端)。
|
||||
static Future<String?> speechToText({required String audioPath}) async {
|
||||
if (!_useNativeBridge) return null;
|
||||
final value = await _m.invokeMethod('speechToText', {'path': audioPath});
|
||||
final text = '$value'.trim();
|
||||
return value == null || text.isEmpty || text == 'null' ? null : text;
|
||||
}
|
||||
|
||||
static final AudioPlayer _tonePlayer = AudioPlayer();
|
||||
|
||||
static Future<void> startCallTone({bool incoming = false}) async {
|
||||
if (_useNativeBridge) {
|
||||
try {
|
||||
await _m.invokeMethod('startCallTone', {'incoming': incoming});
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await _tonePlayer.setReleaseMode(ReleaseMode.loop);
|
||||
await _tonePlayer.play(AssetSource('sounds/ring.wav'));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<void> stopCallTone() async {
|
||||
if (_useNativeBridge) {
|
||||
try {
|
||||
await _m.invokeMethod('stopCallTone');
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await _tonePlayer.stop();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<void> cancelCallNotification() async {
|
||||
if (!_useNativeBridge) return;
|
||||
try {
|
||||
await _m.invokeMethod('cancelCallNotification');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<void> playMessageSent() async {
|
||||
if (!_useNativeBridge) return;
|
||||
try {
|
||||
await _m.invokeMethod('playMessageSent');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<Map<String, double>?> getLocation() async {
|
||||
if (_useNativeBridge) {
|
||||
final raw = await _m.invokeMethod('getLocation');
|
||||
if (raw is Map) {
|
||||
final lat = (raw['lat'] as num?)?.toDouble();
|
||||
final lng = (raw['lng'] as num?)?.toDouble();
|
||||
if (lat != null && lng != null) return {'lat': lat, 'lng': lng};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) return null;
|
||||
var perm = await Geolocator.checkPermission();
|
||||
if (perm == LocationPermission.denied) {
|
||||
perm = await Geolocator.requestPermission();
|
||||
}
|
||||
if (perm == LocationPermission.denied || perm == LocationPermission.deniedForever) {
|
||||
return null;
|
||||
}
|
||||
final pos = await Geolocator.getCurrentPosition(locationSettings: const LocationSettings(accuracy: LocationAccuracy.high));
|
||||
return {'lat': pos.latitude, 'lng': pos.longitude};
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<String, String>?> pickFile() async {
|
||||
if (_useNativeBridge) {
|
||||
final raw = await _m.invokeMethod('pickFile');
|
||||
if (raw is Map) return raw.map((k, v) => MapEntry('$k', '$v'));
|
||||
return null;
|
||||
}
|
||||
final picked = await FilePicker.platform.pickFiles(allowMultiple: false);
|
||||
if (picked == null || picked.files.isEmpty) return null;
|
||||
final f = picked.files.first;
|
||||
final path = f.path;
|
||||
if (path == null || path.isEmpty) return null;
|
||||
return {'path': path, 'name': f.name, 'mime': f.extension ?? ''};
|
||||
}
|
||||
|
||||
static Future<void> openFile(String path, {String? mime}) async {
|
||||
if (_useNativeBridge) {
|
||||
await _m.invokeMethod('openFile', {'path': path, 'mime': mime ?? ''});
|
||||
return;
|
||||
}
|
||||
if (Platform.isLinux) {
|
||||
await Process.run('xdg-open', [path]);
|
||||
} else if (Platform.isWindows) {
|
||||
await Process.start('cmd', ['/c', 'start', '', path], mode: ProcessStartMode.detached);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> saveImage(String path) async {
|
||||
if (_useNativeBridge) {
|
||||
await _m.invokeMethod('saveImage', {'path': path});
|
||||
return;
|
||||
}
|
||||
final name = path.split('/').last;
|
||||
final picked = await FilePicker.platform.saveFile(dialogTitle: '保存图片', fileName: name.isEmpty ? 'image.jpg' : name);
|
||||
if (picked == null || picked.isEmpty) return;
|
||||
await File(path).copy(picked);
|
||||
}
|
||||
|
||||
static Future<void> openUrl(String url) async {
|
||||
if (_useNativeBridge) {
|
||||
await _m.invokeMethod('openUrl', {'url': url});
|
||||
return;
|
||||
}
|
||||
if (Platform.isLinux) {
|
||||
await Process.run('xdg-open', [url]);
|
||||
} else if (Platform.isWindows) {
|
||||
await Process.start('cmd', ['/c', 'start', '', url], mode: ProcessStartMode.detached);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> requestCallMedia() async {
|
||||
if (!_useNativeBridge) return true;
|
||||
try {
|
||||
final ok = await _m.invokeMethod('requestCallMedia');
|
||||
return ok == true;
|
||||
} catch (_) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<String, String>?> pickMedia() async {
|
||||
if (_useNativeBridge) {
|
||||
final raw = await _m.invokeMethod('pickMedia');
|
||||
if (raw is Map) return raw.map((k, v) => MapEntry('$k', '$v'));
|
||||
return null;
|
||||
}
|
||||
final picked = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'],
|
||||
allowMultiple: false,
|
||||
);
|
||||
if (picked == null || picked.files.isEmpty) return null;
|
||||
final f = picked.files.first;
|
||||
final path = f.path;
|
||||
if (path == null || path.isEmpty) return null;
|
||||
final ext = (f.extension ?? 'jpg').toLowerCase();
|
||||
final mime = ext == 'png'
|
||||
? 'image/png'
|
||||
: ext == 'gif'
|
||||
? 'image/gif'
|
||||
: ext == 'webp'
|
||||
? 'image/webp'
|
||||
: 'image/jpeg';
|
||||
return {'path': path, 'name': f.name, 'mime': mime};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
|
||||
class PushBridge {
|
||||
PushBridge._();
|
||||
static const _m = MethodChannel('com.fysxkj.oa/ota');
|
||||
static const _ev = EventChannel('com.fysxkj.oa/push');
|
||||
static StreamSubscription<dynamic>? _sub;
|
||||
|
||||
static Future<void> requestFirstLoginPermissions() async {
|
||||
try {
|
||||
await _m.invokeMethod('requestFirstLoginPermissions');
|
||||
} catch (_) {
|
||||
try {
|
||||
await _m.invokeMethod('requestPushPermission');
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> register(OaClient api) async {
|
||||
await requestFirstLoginPermissions();
|
||||
for (var i = 0; i < 10; i++) {
|
||||
try {
|
||||
final raw = await _m.invokeMethod('getPushToken');
|
||||
if (raw is Map) {
|
||||
final regId = '${raw['regId'] ?? ''}';
|
||||
if (regId.isNotEmpty) {
|
||||
await api.post('/push/devices', {
|
||||
'vendor': '${raw['vendor'] ?? 'vivo'}',
|
||||
'regId': regId,
|
||||
'brand': '${raw['brand'] ?? ''}',
|
||||
'model': '${raw['model'] ?? ''}',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
await Future<void>.delayed(const Duration(seconds: 2));
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> showOnline(String title, String content, {Map<String, String> extras = const {}}) async {
|
||||
try {
|
||||
await _m.invokeMethod('showOnlineNotification', {'title': title, 'content': content, 'extras': extras});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<Map<String, String>?> consumeLaunch() async {
|
||||
try {
|
||||
final raw = await _m.invokeMethod('consumeLaunchPush');
|
||||
if (raw is Map) return raw.map((k, v) => MapEntry('$k', '$v'));
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
static void listen(void Function(Map<String, String>) onClick) {
|
||||
_sub?.cancel();
|
||||
_sub = _ev.receiveBroadcastStream().listen((event) {
|
||||
if (event is Map) {
|
||||
onClick(event.map((k, v) => MapEntry('$k', '$v')));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static void stop() {
|
||||
_sub?.cancel();
|
||||
_sub = null;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>?> oemStatus() async {
|
||||
try {
|
||||
final raw = await _m.invokeMethod('oemStatus');
|
||||
if (raw is Map) return Map<String, dynamic>.from(raw);
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<void> openOemKeepAlive() async {
|
||||
try {
|
||||
await _m.invokeMethod('openOemKeepAlive');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<void> requestBattery() async {
|
||||
try {
|
||||
await _m.invokeMethod('requestBattery');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<void> markOemPrompted() async {
|
||||
try {
|
||||
await _m.invokeMethod('markOemPrompted');
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// 聊天消息的本地副本。云端只保留 7 天,客户端仍可查看本机历史消息。
|
||||
class ChatLocalStore {
|
||||
ChatLocalStore._();
|
||||
|
||||
static Future<List<Map<String, dynamic>>> load(String conversationId) async {
|
||||
if (conversationId.isEmpty) return [];
|
||||
final p = await SharedPreferences.getInstance();
|
||||
final raw = p.getString('im.local.$conversationId');
|
||||
if (raw == null || raw.isEmpty) return [];
|
||||
try {
|
||||
final list = jsonDecode(raw);
|
||||
if (list is! List) return [];
|
||||
return list.whereType<Map>().map((e) => Map<String, dynamic>.from(e)).toList();
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> save(String conversationId, List<Map<String, dynamic>> items) async {
|
||||
if (conversationId.isEmpty) return;
|
||||
final p = await SharedPreferences.getInstance();
|
||||
final durable = items.where((e) => e['_pending'] != true).map((e) {
|
||||
final copy = Map<String, dynamic>.from(e)..remove('_pending');
|
||||
return copy;
|
||||
}).toList();
|
||||
await p.setString('im.local.$conversationId', jsonEncode(durable));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// 会话级本地偏好:免打扰、置顶、背景、本地清空记录。
|
||||
class ChatPrefs {
|
||||
ChatPrefs._();
|
||||
static SharedPreferences? _p;
|
||||
|
||||
static Future<void> ensure() async {
|
||||
_p ??= await SharedPreferences.getInstance();
|
||||
// 旧版本曾在滑动过程中误写 hidden,导致会话读完后看似“自动删除”。
|
||||
// 升级时清理一次旧标记,之后只有确认“不显示/删除”才会再次隐藏。
|
||||
if (_p!.getBool('im.hidden.migration.v3') != true) {
|
||||
for (final key
|
||||
in _p!.getKeys().where((k) => k.startsWith('im.hidden.'))) {
|
||||
await _p!.remove(key);
|
||||
}
|
||||
await _p!.setBool('im.hidden.migration.v3', true);
|
||||
}
|
||||
}
|
||||
|
||||
static bool muted(String id) =>
|
||||
id.isEmpty ? false : (_p?.getBool('im.mute.$id') ?? false);
|
||||
static bool pinned(String id) =>
|
||||
id.isEmpty ? false : (_p?.getBool('im.pin.$id') ?? false);
|
||||
static bool hidden(String id) =>
|
||||
id.isEmpty ? false : (_p?.getBool('im.hidden.$id') ?? false);
|
||||
static int? bgColor(String id) => id.isEmpty ? null : _p?.getInt('im.bg.$id');
|
||||
static DateTime? clearedAt(String id) {
|
||||
if (id.isEmpty) return null;
|
||||
final ms = _p?.getInt('im.cleared.$id');
|
||||
if (ms == null || ms <= 0) return null;
|
||||
return DateTime.fromMillisecondsSinceEpoch(ms);
|
||||
}
|
||||
|
||||
static Future<void> setMuted(String id, bool v) async {
|
||||
await ensure();
|
||||
await _p!.setBool('im.mute.$id', v);
|
||||
}
|
||||
|
||||
static Future<void> setPinned(String id, bool v) async {
|
||||
await ensure();
|
||||
await _p!.setBool('im.pin.$id', v);
|
||||
}
|
||||
|
||||
static Future<void> setHidden(String id, bool v) async {
|
||||
await ensure();
|
||||
await _p!.setBool('im.hidden.$id', v);
|
||||
}
|
||||
|
||||
static Future<void> setBg(String id, int color) async {
|
||||
await ensure();
|
||||
await _p!.setInt('im.bg.$id', color);
|
||||
}
|
||||
|
||||
static Future<void> clearHistory(String id) async {
|
||||
await ensure();
|
||||
await _p!.setInt('im.cleared.$id', DateTime.now().millisecondsSinceEpoch);
|
||||
}
|
||||
|
||||
static int fakeUnread(String id) =>
|
||||
id.isEmpty ? 0 : (_p?.getInt('im.fakeUnread.$id') ?? 0);
|
||||
|
||||
static Future<void> setFakeUnread(String id, int n) async {
|
||||
await ensure();
|
||||
if (n <= 0) {
|
||||
await _p!.remove('im.fakeUnread.$id');
|
||||
} else {
|
||||
await _p!.setInt('im.fakeUnread.$id', n);
|
||||
}
|
||||
}
|
||||
|
||||
/// 语音转文字结果,按消息 id 或 fileId 缓存。
|
||||
static String? voiceText(String key) {
|
||||
if (key.isEmpty) return null;
|
||||
final v = _p?.getString('im.voiceText.$key');
|
||||
if (v == null || v.isEmpty) return null;
|
||||
return v;
|
||||
}
|
||||
|
||||
static Future<void> setVoiceText(String key, String text) async {
|
||||
if (key.isEmpty) return;
|
||||
await ensure();
|
||||
final t = text.trim();
|
||||
if (t.isEmpty) {
|
||||
await _p!.remove('im.voiceText.$key');
|
||||
} else {
|
||||
await _p!.setString('im.voiceText.$key', t);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// 与官方 MobileIMSDK 对齐的生命周期:登录前 init,退出 release。
|
||||
/// Android 走 MethodChannel;Windows/Linux 无原生插件时回落到 Dart TCP。
|
||||
class ImBridge {
|
||||
static const _ch = MethodChannel('com.fysxkj.oa/im');
|
||||
bool nativeReady = false;
|
||||
String engine = 'dart-tcp';
|
||||
|
||||
Future<void> init() async {
|
||||
try {
|
||||
final r = await _ch.invokeMethod<Map>('init');
|
||||
nativeReady = r?['ok'] == true || r?['initialized'] == true;
|
||||
engine = '${r?['engine'] ?? 'dart-tcp'}';
|
||||
} on MissingPluginException {
|
||||
nativeReady = false;
|
||||
engine = 'dart-tcp';
|
||||
} catch (_) {
|
||||
nativeReady = false;
|
||||
engine = 'dart-tcp';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> release() async {
|
||||
try {
|
||||
await _ch.invokeMethod('release');
|
||||
} catch (_) {}
|
||||
nativeReady = false;
|
||||
}
|
||||
|
||||
Future<String> status() async {
|
||||
try {
|
||||
final r = await _ch.invokeMethod<String>('engine');
|
||||
if (r != null && r.isNotEmpty) engine = r;
|
||||
} catch (_) {}
|
||||
return engine;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../session/session.dart';
|
||||
|
||||
/// 监听网络切换与应用恢复,触发 IM 极速重连。
|
||||
class ImLifecycle with WidgetsBindingObserver {
|
||||
ImLifecycle(this.session);
|
||||
final SessionStore session;
|
||||
StreamSubscription<List<ConnectivityResult>>? _netSub;
|
||||
List<ConnectivityResult> _lastNet = const [];
|
||||
|
||||
void start() {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_netSub = Connectivity().onConnectivityChanged.listen(_onNet);
|
||||
Connectivity().checkConnectivity().then((r) => _lastNet = r);
|
||||
}
|
||||
|
||||
void stop() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_netSub?.cancel();
|
||||
_netSub = null;
|
||||
}
|
||||
|
||||
void _onNet(List<ConnectivityResult> next) {
|
||||
final wasOffline = _lastNet.every((e) => e == ConnectivityResult.none);
|
||||
final nowOnline = next.any((e) => e != ConnectivityResult.none);
|
||||
_lastNet = next;
|
||||
if (wasOffline && nowOnline && session.signedIn) {
|
||||
unawaited(session.connectIm(force: true));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed && session.signedIn) {
|
||||
unawaited(session.connectIm(force: true));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// 本地记录各会话已同步到的最大 seq,断线重连后用于 gap fill。
|
||||
class ImSeqStore {
|
||||
ImSeqStore._();
|
||||
static SharedPreferences? _p;
|
||||
|
||||
static Future<void> ensure() async {
|
||||
_p ??= await SharedPreferences.getInstance();
|
||||
}
|
||||
|
||||
static Future<int> lastSeq(String conversationId) async {
|
||||
if (conversationId.isEmpty) return 0;
|
||||
await ensure();
|
||||
return _p?.getInt('im.seq.$conversationId') ?? 0;
|
||||
}
|
||||
|
||||
static Future<void> setLastSeq(String conversationId, int seq) async {
|
||||
if (conversationId.isEmpty || seq <= 0) return;
|
||||
await ensure();
|
||||
final prev = _p?.getInt('im.seq.$conversationId') ?? 0;
|
||||
if (seq > prev) await _p!.setInt('im.seq.$conversationId', seq);
|
||||
}
|
||||
|
||||
static Future<void> applyItems(String conversationId, Iterable<Map<String, dynamic>> items) async {
|
||||
var max = 0;
|
||||
for (final e in items) {
|
||||
final s = (e['seq'] as num?)?.toInt() ?? 0;
|
||||
if (s > max) max = s;
|
||||
}
|
||||
if (max > 0) await setLastSeq(conversationId, max);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/// 姓名首字母。按百家姓,避免大量同事被丢进「#」。
|
||||
const _surnames = {
|
||||
'赵': 'Z', '钱': 'Q', '孙': 'S', '李': 'L', '周': 'Z', '吴': 'W', '郑': 'Z', '王': 'W',
|
||||
'冯': 'F', '陈': 'C', '褚': 'C', '卫': 'W', '蒋': 'J', '沈': 'S', '韩': 'H', '杨': 'Y',
|
||||
'朱': 'Z', '秦': 'Q', '尤': 'Y', '许': 'X', '何': 'H', '吕': 'L', '施': 'S', '张': 'Z',
|
||||
'孔': 'K', '曹': 'C', '严': 'Y', '华': 'H', '金': 'J', '魏': 'W', '陶': 'T', '姜': 'J',
|
||||
'戚': 'Q', '谢': 'X', '邹': 'Z', '喻': 'Y', '柏': 'B', '水': 'S', '窦': 'D', '章': 'Z',
|
||||
'云': 'Y', '苏': 'S', '潘': 'P', '葛': 'G', '奚': 'X', '范': 'F', '彭': 'P', '郎': 'L',
|
||||
'鲁': 'L', '韦': 'W', '昌': 'C', '马': 'M', '苗': 'M', '凤': 'F', '花': 'H', '方': 'F',
|
||||
'俞': 'Y', '任': 'R', '袁': 'Y', '柳': 'L', '酆': 'F', '鲍': 'B', '史': 'S', '唐': 'T',
|
||||
'费': 'F', '廉': 'L', '岑': 'C', '薛': 'X', '雷': 'L', '贺': 'H', '倪': 'N', '汤': 'T',
|
||||
'滕': 'T', '殷': 'Y', '罗': 'L', '毕': 'B', '郝': 'H', '邬': 'W', '安': 'A', '常': 'C',
|
||||
'乐': 'Y', '于': 'Y', '时': 'S', '傅': 'F', '皮': 'P', '卞': 'B', '齐': 'Q', '康': 'K',
|
||||
'伍': 'W', '余': 'Y', '元': 'Y', '卜': 'B', '顾': 'G', '孟': 'M', '平': 'P', '黄': 'H',
|
||||
'和': 'H', '穆': 'M', '萧': 'X', '尹': 'Y', '姚': 'Y', '邵': 'S', '湛': 'Z', '汪': 'W',
|
||||
'祁': 'Q', '毛': 'M', '禹': 'Y', '狄': 'D', '米': 'M', '贝': 'B', '明': 'M', '臧': 'Z',
|
||||
'计': 'J', '伏': 'F', '成': 'C', '戴': 'D', '谈': 'T', '宋': 'S', '茅': 'M', '庞': 'P',
|
||||
'熊': 'X', '纪': 'J', '舒': 'S', '屈': 'Q', '项': 'X', '祝': 'Z', '董': 'D', '梁': 'L',
|
||||
'杜': 'D', '阮': 'R', '蓝': 'L', '闵': 'M', '席': 'X', '季': 'J', '麻': 'M', '强': 'Q',
|
||||
'贾': 'J', '路': 'L', '娄': 'L', '危': 'W', '江': 'J', '童': 'T', '颜': 'Y', '郭': 'G',
|
||||
'梅': 'M', '盛': 'S', '林': 'L', '刁': 'D', '钟': 'Z', '徐': 'X', '邱': 'Q', '骆': 'L',
|
||||
'高': 'G', '夏': 'X', '蔡': 'C', '田': 'T', '樊': 'F', '胡': 'H', '凌': 'L', '霍': 'H',
|
||||
'虞': 'Y', '万': 'W', '支': 'Z', '柯': 'K', '昝': 'Z', '管': 'G', '卢': 'L', '莫': 'M',
|
||||
'经': 'J', '房': 'F', '裘': 'Q', '缪': 'M', '干': 'G', '解': 'X', '应': 'Y', '宗': 'Z',
|
||||
'丁': 'D', '宣': 'X', '贲': 'B', '邓': 'D', '郁': 'Y', '单': 'S', '杭': 'H', '洪': 'H',
|
||||
'包': 'B', '诸': 'Z', '左': 'Z', '石': 'S', '崔': 'C', '吉': 'J', '钮': 'N', '龚': 'G',
|
||||
'程': 'C', '嵇': 'J', '邢': 'X', '滑': 'H', '裴': 'P', '陆': 'L', '荣': 'R', '翁': 'W',
|
||||
'荀': 'X', '羊': 'Y', '於': 'Y', '惠': 'H', '甄': 'Z', '曲': 'Q', '家': 'J', '封': 'F',
|
||||
'芮': 'R', '羿': 'Y', '储': 'C', '靳': 'J', '汲': 'J', '邴': 'B', '糜': 'M', '松': 'S',
|
||||
'井': 'J', '段': 'D', '富': 'F', '巫': 'W', '乌': 'W', '焦': 'J', '巴': 'B', '弓': 'G',
|
||||
'牧': 'M', '隗': 'K', '山': 'S', '谷': 'G', '车': 'C', '侯': 'H', '宓': 'M', '蓬': 'P',
|
||||
'全': 'Q', '郗': 'X', '班': 'B', '仰': 'Y', '秋': 'Q', '仲': 'Z', '伊': 'Y', '宫': 'G',
|
||||
'宁': 'N', '仇': 'Q', '栾': 'L', '暴': 'B', '甘': 'G', '钭': 'T', '厉': 'L', '戎': 'R',
|
||||
'祖': 'Z', '武': 'W', '符': 'F', '刘': 'L', '景': 'J', '詹': 'Z', '束': 'S', '龙': 'L',
|
||||
'叶': 'Y', '幸': 'X', '司': 'S', '韶': 'S', '郜': 'G', '黎': 'L', '蓟': 'J', '薄': 'B',
|
||||
'印': 'Y', '宿': 'S', '白': 'B', '怀': 'H', '蒲': 'P', '邰': 'T', '从': 'C', '鄂': 'E',
|
||||
'索': 'S', '咸': 'X', '籍': 'J', '赖': 'L', '卓': 'Z', '蔺': 'L', '屠': 'T', '蒙': 'M',
|
||||
'池': 'C', '乔': 'Q', '阴': 'Y', '胥': 'X', '能': 'N', '苍': 'C', '双': 'S', '闻': 'W',
|
||||
'莘': 'S', '党': 'D', '翟': 'Z', '谭': 'T', '贡': 'G', '劳': 'L', '逄': 'P', '姬': 'J',
|
||||
'申': 'S', '扶': 'F', '堵': 'D', '冉': 'R', '宰': 'Z', '郦': 'L', '雍': 'Y', '郤': 'X',
|
||||
'璩': 'Q', '桑': 'S', '桂': 'G', '濮': 'P', '牛': 'N', '寿': 'S', '通': 'T', '边': 'B',
|
||||
'扈': 'H', '燕': 'Y', '冀': 'J', '郏': 'J', '浦': 'P', '尚': 'S', '农': 'N', '温': 'W',
|
||||
'别': 'B', '庄': 'Z', '晏': 'Y', '柴': 'C', '瞿': 'Q', '阎': 'Y', '充': 'C', '慕': 'M',
|
||||
'连': 'L', '茹': 'R', '习': 'X', '宦': 'H', '艾': 'A', '鱼': 'Y', '容': 'R', '向': 'X',
|
||||
'古': 'G', '易': 'Y', '慎': 'S', '戈': 'G', '廖': 'L', '庾': 'Y', '终': 'Z', '暨': 'J',
|
||||
'居': 'J', '衡': 'H', '步': 'B', '都': 'D', '耿': 'G', '满': 'M', '弘': 'H', '匡': 'K',
|
||||
'国': 'G', '文': 'W', '寇': 'K', '广': 'G', '禄': 'L', '阙': 'Q', '东': 'D', '欧': 'O',
|
||||
'殳': 'S', '沃': 'W', '利': 'L', '蔚': 'W', '越': 'Y', '夔': 'K', '隆': 'L', '师': 'S',
|
||||
'巩': 'G', '厍': 'S', '聂': 'N', '晁': 'C', '勾': 'G', '敖': 'A', '融': 'R', '冷': 'L',
|
||||
'訾': 'Z', '辛': 'X', '阚': 'K', '那': 'N', '简': 'J', '饶': 'R', '空': 'K', '曾': 'Z',
|
||||
'毋': 'W', '沙': 'S', '乜': 'N', '养': 'Y', '鞠': 'J', '须': 'X', '丰': 'F', '巢': 'C',
|
||||
'关': 'G', '蒯': 'K', '相': 'X', '查': 'Z', '后': 'H', '荆': 'J', '红': 'H', '游': 'Y',
|
||||
'竺': 'Z', '权': 'Q', '逯': 'L', '盖': 'G', '益': 'Y', '桓': 'H', '公': 'G', '万俟': 'M',
|
||||
'司马': 'S', '上官': 'S', '欧阳': 'O', '夏侯': 'X', '诸葛': 'Z', '闻人': 'W', '东方': 'D',
|
||||
'赫连': 'H', '皇甫': 'H', '尉迟': 'Y', '公羊': 'G', '澹台': 'T', '公冶': 'G', '宗政': 'Z',
|
||||
'濮阳': 'P', '淳于': 'C', '单于': 'C', '太叔': 'T', '申屠': 'S', '公孙': 'G', '仲孙': 'Z',
|
||||
'轩辕': 'X', '令狐': 'L', '钟离': 'Z', '宇文': 'Y', '长孙': 'Z', '慕容': 'M', '鲜于': 'X',
|
||||
'闾丘': 'L', '司徒': 'S', '司空': 'S', '丌官': 'Q', '司寇': 'S', '仉': 'Z', '督': 'D',
|
||||
'子车': 'Z', '颛孙': 'Z', '端木': 'D', '巫马': 'W', '公西': 'G', '漆雕': 'Q', '乐正': 'Y',
|
||||
'壤驷': 'R', '公良': 'G', '拓跋': 'T', '夹谷': 'J', '宰父': 'Z', '谷梁': 'G', '晋': 'J',
|
||||
'楚': 'C', '闫': 'Y', '法': 'F', '汝': 'R', '鄢': 'Y', '涂': 'T', '钦': 'Q', '段干': 'D',
|
||||
'百里': 'B', '东郭': 'D', '南门': 'N', '呼延': 'H', '归': 'G', '海': 'H', '羊舌': 'Y',
|
||||
'微生': 'W', '岳': 'Y', '帅': 'S', '缑': 'G', '亢': 'K', '况': 'K', '有': 'Y',
|
||||
'琴': 'Q', '梁丘': 'L', '左丘': 'Z', '东门': 'D', '西门': 'X', '商': 'S', '牟': 'M',
|
||||
'佘': 'S', '佴': 'N', '伯': 'B', '赏': 'S', '南宫': 'N', '墨': 'M', '哈': 'H', '谯': 'Q',
|
||||
'笪': 'D', '年': 'N', '爱': 'A', '阳': 'Y', '佟': 'T', '言': 'Y', '福': 'F', '西': 'X',
|
||||
'南': 'N', '北': 'B',
|
||||
};
|
||||
|
||||
String letterOf(String name) {
|
||||
final n = name.trim();
|
||||
if (n.isEmpty) return '#';
|
||||
if (n.length >= 2) {
|
||||
final two = _surnames[n.substring(0, 2)];
|
||||
if (two != null) return two;
|
||||
}
|
||||
final c = n[0];
|
||||
if (RegExp(r'[A-Za-z]').hasMatch(c)) return c.toUpperCase();
|
||||
if (RegExp(r'[0-9]').hasMatch(c)) return '#';
|
||||
return _surnames[c] ?? '#';
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// MobileIMSDK 官方 TCP 帧:4 字节大端长度 + UTF-8 JSON Protocal。
|
||||
class ClientType {
|
||||
static const login = 0;
|
||||
static const keepAlive = 1;
|
||||
static const commonData = 2;
|
||||
static const logout = 3;
|
||||
static const received = 4;
|
||||
static const echo = 5;
|
||||
}
|
||||
|
||||
class ServerType {
|
||||
static const login = 50;
|
||||
static const keepAlive = 51;
|
||||
static const error = 52;
|
||||
static const echo = 53;
|
||||
static const kickout = 54;
|
||||
}
|
||||
|
||||
class Protocal {
|
||||
Protocal({
|
||||
required this.type,
|
||||
this.dataContent = '',
|
||||
this.from = '0',
|
||||
this.to = '0',
|
||||
this.fp,
|
||||
this.qos = false,
|
||||
this.typeu = -1,
|
||||
this.sm = -1,
|
||||
});
|
||||
|
||||
int type;
|
||||
String dataContent;
|
||||
String from;
|
||||
String to;
|
||||
String? fp;
|
||||
bool qos;
|
||||
int typeu;
|
||||
int sm;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'bridge': false,
|
||||
'type': type,
|
||||
'dataContent': dataContent,
|
||||
'from': from,
|
||||
'to': to,
|
||||
'fp': fp,
|
||||
'QoS': qos,
|
||||
'typeu': typeu,
|
||||
'sm': sm,
|
||||
};
|
||||
|
||||
factory Protocal.fromJson(Map<String, dynamic> j) => Protocal(
|
||||
type: (j['type'] as num?)?.toInt() ?? 0,
|
||||
dataContent: '${j['dataContent'] ?? ''}',
|
||||
from: '${j['from'] ?? '0'}',
|
||||
to: '${j['to'] ?? '0'}',
|
||||
fp: j['fp']?.toString(),
|
||||
qos: j['QoS'] == true || j['qos'] == true,
|
||||
typeu: (j['typeu'] as num?)?.toInt() ?? -1,
|
||||
sm: (j['sm'] as num?)?.toInt() ?? -1,
|
||||
);
|
||||
}
|
||||
|
||||
Uint8List encodeFrame(Protocal p) {
|
||||
final body = utf8.encode(jsonEncode(p.toJson()));
|
||||
final header = ByteData(4)..setUint32(0, body.length);
|
||||
return Uint8List.fromList([...header.buffer.asUint8List(), ...body]);
|
||||
}
|
||||
|
||||
const maxBody = 6 * 1024;
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// 自定义表情:服务端 fileId 列表。GIF / 表情面板发出的都走这里。
|
||||
class StickerStore {
|
||||
StickerStore._();
|
||||
static const _key = 'im.stickers';
|
||||
static SharedPreferences? _p;
|
||||
static List<String> _ids = [];
|
||||
|
||||
static List<String> get ids => List.unmodifiable(_ids);
|
||||
|
||||
static Future<void> ensure() async {
|
||||
_p ??= await SharedPreferences.getInstance();
|
||||
_ids = _p!.getStringList(_key) ?? [];
|
||||
}
|
||||
|
||||
static bool has(String fileId) => fileId.isNotEmpty && _ids.contains(fileId);
|
||||
|
||||
static Future<void> add(String fileId) async {
|
||||
if (fileId.isEmpty) return;
|
||||
await ensure();
|
||||
if (_ids.contains(fileId)) return;
|
||||
_ids = [fileId, ..._ids];
|
||||
await _p!.setStringList(_key, _ids);
|
||||
}
|
||||
|
||||
static Future<void> remove(String fileId) async {
|
||||
await ensure();
|
||||
_ids = _ids.where((e) => e != fileId).toList();
|
||||
await _p!.setStringList(_key, _ids);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import 'protocol.dart';
|
||||
import '../device/device_bridge.dart';
|
||||
import '../device/push_bridge.dart';
|
||||
|
||||
class ImIncoming {
|
||||
ImIncoming({
|
||||
required this.from,
|
||||
required this.text,
|
||||
this.kind = 'chat',
|
||||
this.fp,
|
||||
this.seq = 0,
|
||||
this.raw = const {},
|
||||
});
|
||||
final String from;
|
||||
final String text;
|
||||
final String kind;
|
||||
final String? fp;
|
||||
final int seq;
|
||||
final Map<String, dynamic> raw;
|
||||
}
|
||||
|
||||
class _Pending {
|
||||
_Pending(this.p);
|
||||
final Protocal p;
|
||||
int tries = 0;
|
||||
}
|
||||
|
||||
/// 官方 TCP ClientCoreSDK 等价实现:登录、心跳、通用数据、QoS 应答与重发。
|
||||
class ImTcpClient extends ChangeNotifier {
|
||||
Socket? _socket;
|
||||
Uint8List _buf = Uint8List(0);
|
||||
Timer? _hb;
|
||||
Timer? _qos;
|
||||
Timer? _reconnect;
|
||||
bool connected = false;
|
||||
bool loggedIn = false;
|
||||
String lastError = '';
|
||||
String userId = '';
|
||||
int firstLoginTime = 0;
|
||||
final Map<String, _Pending> _pending = {};
|
||||
final List<ImIncoming> inbox = [];
|
||||
final _uuid = const Uuid();
|
||||
String _host = '';
|
||||
int _port = 0;
|
||||
String _token = '';
|
||||
bool _want = false;
|
||||
int _tries = 0;
|
||||
DateTime _lastHbAck = DateTime.now();
|
||||
VoidCallback? onLoggedIn;
|
||||
|
||||
Future<void> connect({
|
||||
required String host,
|
||||
required int port,
|
||||
required String userId,
|
||||
required String token,
|
||||
}) async {
|
||||
_host = host;
|
||||
_port = port;
|
||||
_token = token;
|
||||
this.userId = userId;
|
||||
_want = true;
|
||||
_tries = 0;
|
||||
await _open();
|
||||
}
|
||||
|
||||
Future<void> _open() async {
|
||||
_tearDownSocket();
|
||||
lastError = '';
|
||||
try {
|
||||
_socket = await Socket.connect(_host, _port,
|
||||
timeout: const Duration(seconds: 8));
|
||||
connected = true;
|
||||
_tries = 0;
|
||||
notifyListeners();
|
||||
_socket!.listen(_onData, onDone: _onDrop, onError: (_) => _onDrop());
|
||||
firstLoginTime = DateTime.now().millisecondsSinceEpoch;
|
||||
_send(Protocal(
|
||||
type: ClientType.login,
|
||||
from: userId,
|
||||
dataContent: jsonEncode({
|
||||
'loginUserId': userId,
|
||||
'loginToken': _token,
|
||||
'clientKind': 'mobile',
|
||||
'extra': '',
|
||||
'firstLoginTime': firstLoginTime,
|
||||
}),
|
||||
));
|
||||
_hb = Timer.periodic(const Duration(seconds: 5), (_) {
|
||||
if (loggedIn) {
|
||||
if (DateTime.now().difference(_lastHbAck).inSeconds > 25) {
|
||||
_onDrop();
|
||||
return;
|
||||
}
|
||||
_send(Protocal(type: ClientType.keepAlive, from: userId));
|
||||
}
|
||||
});
|
||||
_qos = Timer.periodic(const Duration(seconds: 4), (_) => _retryQos());
|
||||
} catch (e) {
|
||||
lastError = 'IM 连接失败:$e';
|
||||
connected = false;
|
||||
loggedIn = false;
|
||||
notifyListeners();
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
void _onDrop() {
|
||||
final was = connected || loggedIn;
|
||||
_tearDownSocket();
|
||||
if (was) notifyListeners();
|
||||
if (_want) _scheduleReconnect();
|
||||
}
|
||||
|
||||
void _scheduleReconnect() {
|
||||
if (!_want) return;
|
||||
_reconnect?.cancel();
|
||||
_tries = (_tries + 1).clamp(1, 12);
|
||||
_reconnect = Timer(Duration(seconds: _tries < 4 ? 2 : 5), () {
|
||||
if (_want && !loggedIn) _open();
|
||||
});
|
||||
}
|
||||
|
||||
void disconnect() {
|
||||
_want = false;
|
||||
_reconnect?.cancel();
|
||||
_reconnect = null;
|
||||
_tearDownSocket();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _tearDownSocket() {
|
||||
_hb?.cancel();
|
||||
_qos?.cancel();
|
||||
_hb = null;
|
||||
_qos = null;
|
||||
try {
|
||||
_socket?.destroy();
|
||||
} catch (_) {}
|
||||
_socket = null;
|
||||
_buf = Uint8List(0);
|
||||
connected = false;
|
||||
loggedIn = false;
|
||||
}
|
||||
|
||||
void bump() {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<String?> sendChat(String toUserId, String text) async {
|
||||
if (!loggedIn) return null;
|
||||
final fp = _uuid.v4();
|
||||
final p = Protocal(
|
||||
type: ClientType.commonData,
|
||||
from: userId,
|
||||
to: toUserId,
|
||||
dataContent:
|
||||
jsonEncode({'kind': 'chat', 'text': text, 'fromUserId': userId}),
|
||||
qos: true,
|
||||
fp: fp,
|
||||
typeu: 0,
|
||||
sm: DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
_pending[fp] = _Pending(p);
|
||||
_send(p);
|
||||
return fp;
|
||||
}
|
||||
|
||||
void _retryQos() {
|
||||
for (final e in [..._pending.entries]) {
|
||||
e.value.tries++;
|
||||
if (e.value.tries > 3) {
|
||||
_pending.remove(e.key);
|
||||
continue;
|
||||
}
|
||||
_send(e.value.p);
|
||||
}
|
||||
}
|
||||
|
||||
void _send(Protocal p) {
|
||||
final s = _socket;
|
||||
if (s == null) return;
|
||||
try {
|
||||
s.add(encodeFrame(p));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _onData(List<int> chunk) {
|
||||
_buf = Uint8List.fromList([..._buf, ...chunk]);
|
||||
while (_buf.length >= 4) {
|
||||
final len = ByteData.sublistView(_buf).getUint32(0);
|
||||
if (len <= 0 || len > maxBody) {
|
||||
disconnect();
|
||||
return;
|
||||
}
|
||||
if (_buf.length < 4 + len) break;
|
||||
final body = utf8.decode(_buf.sublist(4, 4 + len));
|
||||
_buf = Uint8List.fromList(_buf.sublist(4 + len));
|
||||
try {
|
||||
_handle(Protocal.fromJson(jsonDecode(body) as Map<String, dynamic>));
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
void _handle(Protocal p) {
|
||||
switch (p.type) {
|
||||
case ServerType.login:
|
||||
try {
|
||||
final info = jsonDecode(p.dataContent.isEmpty ? '{}' : p.dataContent);
|
||||
loggedIn = info is Map && info['code'] == 0;
|
||||
if (loggedIn && info['firstLoginTime'] != null) {
|
||||
firstLoginTime = (info['firstLoginTime'] as num).toInt();
|
||||
}
|
||||
if (!loggedIn) lastError = 'IM 登录被拒绝';
|
||||
} catch (_) {
|
||||
loggedIn = false;
|
||||
}
|
||||
if (loggedIn) {
|
||||
_lastHbAck = DateTime.now();
|
||||
onLoggedIn?.call();
|
||||
}
|
||||
notifyListeners();
|
||||
break;
|
||||
case ServerType.keepAlive:
|
||||
case ServerType.echo:
|
||||
_lastHbAck = DateTime.now();
|
||||
break;
|
||||
case ServerType.kickout:
|
||||
_tearDownSocket();
|
||||
notifyListeners();
|
||||
if (_want) _scheduleReconnect();
|
||||
break;
|
||||
case ClientType.received:
|
||||
_pending.remove(p.dataContent);
|
||||
break;
|
||||
case ClientType.commonData:
|
||||
if (p.qos && p.fp != null) {
|
||||
_send(Protocal(
|
||||
type: ClientType.received,
|
||||
from: userId,
|
||||
to: p.from,
|
||||
dataContent: p.fp!));
|
||||
}
|
||||
String text = p.dataContent;
|
||||
var kind = 'chat';
|
||||
Map<String, dynamic> raw = {};
|
||||
try {
|
||||
raw = Map<String, dynamic>.from(jsonDecode(p.dataContent) as Map);
|
||||
text = '${raw['text'] ?? raw['title'] ?? raw['body'] ?? text}';
|
||||
kind = '${raw['kind'] ?? 'chat'}';
|
||||
} catch (_) {}
|
||||
final seq = (raw['seq'] as num?)?.toInt() ??
|
||||
(p.sm > 0 ? p.sm : 0);
|
||||
final fp = '${raw['fingerprint'] ?? p.fp ?? ''}';
|
||||
if (fp.isNotEmpty) {
|
||||
final dup = inbox.any((e) => e.fp == fp);
|
||||
if (dup) break;
|
||||
}
|
||||
inbox.insert(
|
||||
0,
|
||||
ImIncoming(
|
||||
from: p.from,
|
||||
text: text,
|
||||
kind: kind,
|
||||
fp: fp.isEmpty ? p.fp : fp,
|
||||
seq: seq,
|
||||
raw: raw));
|
||||
final terminalCall = kind == 'call' &&
|
||||
const {'end', 'reject', 'timeout'}
|
||||
.contains('${raw['action'] ?? ''}');
|
||||
if (terminalCall) {
|
||||
unawaited(DeviceBridge.cancelCallNotification());
|
||||
} else if (p.from != userId && kind != 'receipt' && kind != 'signal') {
|
||||
unawaited(PushBridge.showOnline(
|
||||
'${raw['fromName'] ?? raw['title'] ?? '风影办公'}',
|
||||
text,
|
||||
extras: {
|
||||
if (raw['conversationId'] != null)
|
||||
'conversationId': '${raw['conversationId']}',
|
||||
if (raw['kind'] != null) 'kind': '$kind',
|
||||
if (raw['callId'] != null) 'callId': '${raw['callId']}',
|
||||
if (raw['callKind'] != null) 'callKind': '${raw['callKind']}',
|
||||
if (raw['fromName'] != null) 'fromName': '${raw['fromName']}',
|
||||
if (raw['fromUserId'] != null) 'peerId': '${raw['fromUserId']}',
|
||||
if (raw['action'] != null) 'action': '${raw['action']}',
|
||||
},
|
||||
));
|
||||
}
|
||||
if (inbox.length > 200) inbox.removeLast();
|
||||
notifyListeners();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'theme.dart';
|
||||
|
||||
/// 后端枚举 / 码 → 中文。对齐 web `labels.ts` + 投标状态。
|
||||
const zhMap = <String, String>{
|
||||
'TECH_INITIAL': '技术初审',
|
||||
'FETCH': '取标',
|
||||
'TECH_FINAL': '技术终审',
|
||||
'DRAFTING': '制标',
|
||||
'REVIEW1': '商务一审',
|
||||
'REVIEW2': '商务二审',
|
||||
'PRINT': '印封',
|
||||
'LIST': '投标执行',
|
||||
'PENDING': '待审批',
|
||||
'WON': '中标',
|
||||
'LOST': '未中标',
|
||||
'FAILED': '流标废标',
|
||||
'TERMINATED': '已终止',
|
||||
'DRAFT': '草稿',
|
||||
'APPROVED': '已通过',
|
||||
'REJECTED': '已驳回',
|
||||
'CANCELLED': '已取消',
|
||||
'ACTIVE': '启用',
|
||||
'DISABLED': '停用',
|
||||
'OPEN': '进行中',
|
||||
'OFFSET': '已冲账',
|
||||
'DONE': '已完成',
|
||||
'RETURNED': '已交回',
|
||||
'PAID': '已付',
|
||||
'UNPAID': '待付',
|
||||
'TO_ISSUE': '待开',
|
||||
'ISSUED': '已开',
|
||||
'RECEIVED': '已回',
|
||||
'OVERDUE': '逾期',
|
||||
'INTERNAL_ACCEPTED': '内部已验收',
|
||||
'ACCEPTED': '已验收',
|
||||
'ALL': '全部',
|
||||
'DEPT': '本部门',
|
||||
'SELF': '本人',
|
||||
'BID': '投标',
|
||||
'PROJECT': '正式项目',
|
||||
'ADMIN': '行政',
|
||||
'MARKETING': '营销',
|
||||
'LABOR': '人工',
|
||||
'TRAVEL': '差旅',
|
||||
'PURCHASE': '采购',
|
||||
'TENDER': '招标',
|
||||
'PERFORMANCE': '履约保证金',
|
||||
'WARRANTY': '项目质保金',
|
||||
'EXPENSE': '报销',
|
||||
'BOND': '保证金打出',
|
||||
'LOAN': '借款',
|
||||
'OFFICE': '办公申请',
|
||||
'SEAL': '用章',
|
||||
'HR': '人事',
|
||||
'LEAVE': '请假',
|
||||
'OVERTIME': '加班',
|
||||
'TRIP': '出差',
|
||||
'OUT': '外出',
|
||||
'RESIGN': '离职',
|
||||
'PROMOTE': '晋升',
|
||||
'INTERN': '实习',
|
||||
'PROBATION': '试用',
|
||||
'REGULAR': '正式',
|
||||
'LEFT': '离职',
|
||||
'SINGLE': '单休',
|
||||
'DOUBLE': '双休',
|
||||
'MALE': '男',
|
||||
'FEMALE': '女',
|
||||
'HIGH_SCHOOL': '高中及以下',
|
||||
'COLLEGE': '专科',
|
||||
'BACHELOR': '本科',
|
||||
'MASTER': '硕士',
|
||||
'DOCTOR': '博士',
|
||||
'HIRE': '入职',
|
||||
'REGULARIZE': '转正',
|
||||
'TRANSFER': '调岗',
|
||||
'CREATE': '发起',
|
||||
'SUBMIT': '提交',
|
||||
'APPROVE': '同意',
|
||||
'REJECT': '驳回',
|
||||
'DRAFTER': '制标人',
|
||||
'FETCHER': '取标人',
|
||||
'COORDINATOR': '特殊协调人',
|
||||
'BIDDER': '投标人',
|
||||
'SPECIAL': '特殊筛选',
|
||||
'FLOW': '普通筛选',
|
||||
'BID_TECH_INITIAL': '技术初审',
|
||||
'BID_TECH_FINAL': '技术终审',
|
||||
'BID_FETCH': '取标',
|
||||
'BID_ASSIGN_FETCH': '派取标人',
|
||||
'BID_ASSIGN_DRAFT': '派制标人',
|
||||
'BID_FINAL_PASSED': '终审通过通知',
|
||||
'BID_DRAFT_DONE': '制标完成通知',
|
||||
'BID_TERMINATED': '投标终止通知',
|
||||
'BID_DRAFT': '制标',
|
||||
'BID_REVIEW1': '商务一审',
|
||||
'BID_REVIEW2': '商务二审',
|
||||
'BID_PRINT': '印封',
|
||||
'BID_COMPLETE': '补全投标',
|
||||
'BID_EXECUTION': '投标执行',
|
||||
'BID_ASSIGN_FETCHER': '派取标人',
|
||||
'UPDATE_COMPETITOR_WIN': '登记对方中标信息',
|
||||
'UPDATE_LEGAL_ENTITY': '修改参与主体',
|
||||
'CONFIRMED': '已确认',
|
||||
'INTERNAL': '内部验收',
|
||||
'CUSTOMER': '客户验收',
|
||||
'CONTRACT_APPROVAL': '合同审批',
|
||||
'CONTRACT_DRAFT': '完善合同草稿',
|
||||
'EXPENSE_APPROVAL': '报销确认',
|
||||
'LOAN_APPROVAL': '借款确认',
|
||||
'LOAN_OFFSET': '借款冲账',
|
||||
'PROJECT_TIMESHEET': '工时填报',
|
||||
'PROJECT_ACCEPT': '项目验收',
|
||||
'SEAL_APPROVAL': '用章审批',
|
||||
'SEAL_RETURN': '印章归还',
|
||||
'CREDENTIAL_RETURN': '证照归还',
|
||||
'BID_OPEN': '开标反馈',
|
||||
'WORK_ASSIGN': '工作安排',
|
||||
'OFFICE_APPLY': '办公申请',
|
||||
'HR_LEAVE': '人事申请',
|
||||
'SEAL_REQUEST': '用章申请',
|
||||
'WORK_REPORT': '工作汇报',
|
||||
'NOTICE': '通知公告',
|
||||
'ASSET_BUY': '资产购置',
|
||||
'ASSET_USE': '资产领用',
|
||||
'VEHICLE': '用车',
|
||||
'DIGITAL': '数字资产',
|
||||
'FIXED': '固定资产',
|
||||
'IN_USE': '占用中',
|
||||
'IN_STOCK': '在库',
|
||||
'OUT_STOCK': '已出库',
|
||||
'TAKEN_OUT': '外带',
|
||||
'LENT': '外借',
|
||||
'BORROWED': '已外借',
|
||||
'IDLE': '闲置',
|
||||
'SCRAPPED': '已报废',
|
||||
'AVAILABLE': '可用',
|
||||
'MAINTAIN': '维修中',
|
||||
'OCCUPIED': '占用中',
|
||||
'PENDING_IN': '待入库',
|
||||
'ID_CARD': '身份证',
|
||||
'PASSPORT': '护照',
|
||||
'OTHER_ID': '其他证件',
|
||||
'GENERAL': '通用',
|
||||
'TODO': '待办',
|
||||
'TASK': '任务截止',
|
||||
'BORROW': '证照归还',
|
||||
'LOGIN': '登录',
|
||||
'LOGIN_FAIL': '登录失败',
|
||||
'APPROVAL': '审批',
|
||||
'FORBIDDEN': '越权',
|
||||
'ACL': '权限',
|
||||
'SENT': '已提交',
|
||||
'UPDATE': '修改',
|
||||
'DELETE': '删除',
|
||||
'OK': '成功',
|
||||
'FAIL': '失败',
|
||||
'BUSINESS': '商务',
|
||||
'TECH': '技术',
|
||||
'FINANCE': '财务',
|
||||
'PUBLISHED': '已发布',
|
||||
'ANNOUNCE': '公告',
|
||||
'POLICY': '制度',
|
||||
'direct': '单聊',
|
||||
'group': '群聊',
|
||||
'admin': '管理员',
|
||||
'owner': '负责人',
|
||||
'hr': '人事',
|
||||
'finance': '财务',
|
||||
'finance_manager': '财务经理',
|
||||
'cashier': '出纳',
|
||||
'seal': '用章管理员',
|
||||
'asset': '资产管理员',
|
||||
'project_manager': '项目经理',
|
||||
'tech_director': '技术总监',
|
||||
'marketing': '营销',
|
||||
'bidder': '投标',
|
||||
'staff': '员工',
|
||||
'user': '用户',
|
||||
'true': '是',
|
||||
'false': '否',
|
||||
'INQUIRY': '询价',
|
||||
'OPEN_TENDER': '公开招标',
|
||||
'INVITE': '邀请招标',
|
||||
'COMPETITIVE': '竞争性谈判',
|
||||
'SINGLE_SOURCE': '单一来源',
|
||||
};
|
||||
|
||||
/// 详情页字段名。没有映射的 camelCase 一律不展示,避免露出英文列名。
|
||||
const fieldZh = <String, String>{
|
||||
'title': '标题',
|
||||
'name': '名称',
|
||||
'purpose': '事由',
|
||||
'reason': '事由',
|
||||
'remark': '备注',
|
||||
'status': '状态',
|
||||
'kind': '类型',
|
||||
'source': '来源',
|
||||
'sourceType': '筛选类型',
|
||||
'bizType': '业务',
|
||||
'amount': '金额',
|
||||
'days': '天数',
|
||||
'claimNo': '报销单号',
|
||||
'loanNo': '借款单号',
|
||||
'bidNo': '招标编号',
|
||||
'externalNo': '招标编号',
|
||||
'contractNo': '合同号',
|
||||
'projectNo': '项目编号',
|
||||
'projectType': '项目类型',
|
||||
'sealType': '印章类型',
|
||||
'destination': '目的地',
|
||||
'assetKind': '资产类型',
|
||||
'createdAt': '创建时间',
|
||||
'updatedAt': '更新时间',
|
||||
'dueAt': '截止',
|
||||
'startAt': '开始',
|
||||
'endAt': '结束',
|
||||
'decidedAt': '审批时间',
|
||||
'comment': '意见',
|
||||
'createdBy': '发起人',
|
||||
'bidCase': '关联投标',
|
||||
'legalEntity': '参与主体',
|
||||
'qualificationNeed': '所需资质',
|
||||
'tenderMethod': '招标方式',
|
||||
'applyStartAt': '申领开始',
|
||||
'applyEndAt': '申领截止',
|
||||
'applyMethod': '申领方式',
|
||||
'openAt': '开标时间',
|
||||
'openMethod': '开标方式',
|
||||
'openPlace': '开标地点',
|
||||
'bondMethod': '保证金',
|
||||
'bondStatus': '保证金状态',
|
||||
'bondType': '保证金类型',
|
||||
'delivererName': '递交人',
|
||||
'contactName': '联系人',
|
||||
'contactPhone': '联系电话',
|
||||
'nasPath': '标书路径',
|
||||
'summary': '摘要',
|
||||
'resultNote': '结果说明',
|
||||
'printCost': '印封费用',
|
||||
'printNote': '印封备注',
|
||||
'projectNature': '项目性质',
|
||||
'securityLevel': '密级',
|
||||
'priceCap': '限价',
|
||||
'quoteAmount': '报价',
|
||||
'needSurvey': '需要踏勘',
|
||||
'fetchedAt': '取标时间',
|
||||
'printedAt': '印封时间',
|
||||
'openedResultAt': '开标反馈时间',
|
||||
'actionLogs': '操作记录',
|
||||
'projects': '关联项目',
|
||||
'files': '附件',
|
||||
'reviewers': '审批人',
|
||||
'assignees': '经办人',
|
||||
'versions': '标书版本',
|
||||
'displayName': '姓名',
|
||||
'departmentName': '部门',
|
||||
'department': '部门',
|
||||
'mobile': '手机',
|
||||
'email': '邮箱',
|
||||
'party': '客商',
|
||||
'applicant': '申请人',
|
||||
'assignee': '经办人',
|
||||
'keeper': '保管人',
|
||||
'holder': '占用人',
|
||||
'spec': '规格',
|
||||
'unit': '单位',
|
||||
'qty': '数量',
|
||||
'quantity': '数量',
|
||||
'location': '位置',
|
||||
'address': '地址',
|
||||
'code': '编号',
|
||||
'no': '编号',
|
||||
'type': '类型',
|
||||
'category': '分类',
|
||||
'level': '级别',
|
||||
'priority': '优先级',
|
||||
'progress': '进度',
|
||||
'budget': '预算',
|
||||
'cost': '成本',
|
||||
'owner': '负责人',
|
||||
'manager': '负责人',
|
||||
'description': '说明',
|
||||
'content': '内容',
|
||||
'body': '正文',
|
||||
'note': '备注',
|
||||
'result': '结果',
|
||||
'outcome': '结果',
|
||||
'method': '方式',
|
||||
'channel': '渠道',
|
||||
'scope': '范围',
|
||||
'role': '角色',
|
||||
'roles': '角色',
|
||||
'employmentStatus': '用工状态',
|
||||
'gender': '性别',
|
||||
'idNo': '证件号',
|
||||
'idType': '证件类型',
|
||||
'idNumber': '证件号码',
|
||||
'idCard': '身份证号',
|
||||
'jobTitle': '岗位',
|
||||
'hireDate': '入职日期',
|
||||
'hiredAt': '入职日期',
|
||||
'birthday': '出生日期',
|
||||
'birthDate': '出生日期',
|
||||
'education': '学历',
|
||||
'school': '毕业院校',
|
||||
'major': '专业',
|
||||
'emergencyContact': '紧急联系人',
|
||||
'emergencyPhone': '紧急电话',
|
||||
'bankName': '开户行',
|
||||
'bankAccount': '银行账号',
|
||||
'workCity': '工作城市',
|
||||
'officeLocation': '办公地点',
|
||||
'employeeNo': '工号',
|
||||
'staffNo': '工号',
|
||||
'jobNo': '工号',
|
||||
'realName': '姓名',
|
||||
'username': '账号',
|
||||
'phone': '手机',
|
||||
'wechat': '微信',
|
||||
'nation': '民族',
|
||||
'nativePlace': '籍贯',
|
||||
'maritalStatus': '婚姻状况',
|
||||
'addressHome': '家庭住址',
|
||||
'entryDate': '入职日期',
|
||||
'regularDate': '转正日期',
|
||||
'leaveDate': '离职日期',
|
||||
'contractEnd': '合同到期',
|
||||
'keeperName': '保管人',
|
||||
'holderName': '占用人',
|
||||
'legalEntityName': '参与主体',
|
||||
'winnerName': '中标单位',
|
||||
'winnerAmount': '中标金额',
|
||||
};
|
||||
|
||||
String zh(dynamic v) {
|
||||
if (v == null) return '—';
|
||||
if (v is bool) return v ? '是' : '否';
|
||||
final s = '$v'.trim();
|
||||
if (s.isEmpty) return '—';
|
||||
return zhMap[s] ?? s;
|
||||
}
|
||||
|
||||
/// 字段名转中文。仍是英文驼峰的返回空串,调用方应跳过。
|
||||
String fieldLabel(String k) {
|
||||
final key = k.contains('.') ? k.split('.').last : k;
|
||||
if (fieldZh.containsKey(k)) return fieldZh[k]!;
|
||||
if (fieldZh.containsKey(key)) return fieldZh[key]!;
|
||||
if (zhMap.containsKey(key)) return zhMap[key]!;
|
||||
if (RegExp(r'[\u4e00-\u9fff]').hasMatch(key)) return key;
|
||||
return '';
|
||||
}
|
||||
|
||||
Color statusColor(dynamic v) {
|
||||
switch ('$v') {
|
||||
case 'APPROVED':
|
||||
case 'DONE':
|
||||
case 'OFFSET':
|
||||
case 'RETURNED':
|
||||
case 'PAID':
|
||||
case 'WON':
|
||||
case 'ACCEPTED':
|
||||
case 'ISSUED':
|
||||
case 'RECEIVED':
|
||||
return kWeGreen;
|
||||
case 'PENDING':
|
||||
case 'OPEN':
|
||||
case 'DRAFT':
|
||||
case 'FETCH':
|
||||
case 'DRAFTING':
|
||||
case 'TECH_INITIAL':
|
||||
case 'TECH_FINAL':
|
||||
case 'REVIEW1':
|
||||
case 'REVIEW2':
|
||||
case 'PRINT':
|
||||
case 'LIST':
|
||||
return const Color(0xFFFA9D3B);
|
||||
case 'REJECTED':
|
||||
case 'CANCELLED':
|
||||
case 'OVERDUE':
|
||||
case 'TERMINATED':
|
||||
case 'FAILED':
|
||||
case 'LOST':
|
||||
return kDanger;
|
||||
default:
|
||||
return kMute;
|
||||
}
|
||||
}
|
||||
|
||||
class StatusDot extends StatelessWidget {
|
||||
const StatusDot(this.code, {super.key});
|
||||
final dynamic code;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor(code).withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(zh(code), style: TextStyle(fontSize: 11, color: statusColor(code), fontWeight: FontWeight.w600)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import 'api/oa_client.dart';
|
||||
import 'app_runtime.dart';
|
||||
import 'device/device_bridge.dart';
|
||||
import 'desktop/desk_platform.dart';
|
||||
import 'desktop/desk_theme.dart';
|
||||
import 'pages/desktop_login_page.dart';
|
||||
import 'pages/login_page.dart';
|
||||
import 'session/session.dart';
|
||||
import 'shell/home_shell.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await AppRuntime.load();
|
||||
if (isDesktopPlatform) {
|
||||
await windowManager.ensureInitialized();
|
||||
const windowOptions = WindowOptions(
|
||||
size: Size(1400, 900),
|
||||
center: true,
|
||||
title: '风影办公',
|
||||
);
|
||||
windowManager.waitUntilReadyToShow(windowOptions, () async {
|
||||
await windowManager.show();
|
||||
await windowManager.focus();
|
||||
});
|
||||
}
|
||||
if (!isDesktopPlatform) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values);
|
||||
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.white,
|
||||
statusBarIconBrightness: Brightness.dark,
|
||||
));
|
||||
DeviceBridge.preferMaxRefresh();
|
||||
}
|
||||
runApp(const FengyingApp());
|
||||
}
|
||||
|
||||
class FengyingApp extends StatefulWidget {
|
||||
const FengyingApp({super.key});
|
||||
|
||||
@override
|
||||
State<FengyingApp> createState() => _FengyingAppState();
|
||||
}
|
||||
|
||||
class _FengyingAppState extends State<FengyingApp> {
|
||||
final session = SessionStore();
|
||||
late final OaClient api = OaClient(session);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
DeviceBridge.bindApi(api);
|
||||
session.addListener(_onSession);
|
||||
_boot();
|
||||
}
|
||||
|
||||
Future<void> _boot() async {
|
||||
await session.load();
|
||||
if (session.signedIn) {
|
||||
try {
|
||||
final me = await api.get('/auth/me');
|
||||
if (me is Map) {
|
||||
await session.patchUser(Map<String, dynamic>.from(me));
|
||||
}
|
||||
final menus = await api.get('/system/menus');
|
||||
await session.setMenus(asMaps(menus).map(MenuNode.fromJson).toList());
|
||||
await session.connectIm();
|
||||
} catch (_) {
|
||||
await session.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onSession() => setState(() {});
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
session.removeListener(_onSession);
|
||||
session.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: '风影办公',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: isDesktopPlatform ? buildDesktopTheme() : buildTheme(),
|
||||
home: !session.loaded
|
||||
? const Scaffold(backgroundColor: Colors.white, body: Center(child: CircularProgressIndicator(color: kBind)))
|
||||
: session.signedIn
|
||||
? HomeShell(session: session, api: api)
|
||||
: isDesktopPlatform
|
||||
? DesktopLoginPage(session: session, api: api)
|
||||
: LoginPage(session: session, api: api),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
String? fetchPathOf(Map<String, dynamic> row) {
|
||||
final forced = '${row['_fetch'] ?? ''}';
|
||||
if (forced.startsWith('/')) return forced;
|
||||
final id = '${row['id'] ?? ''}';
|
||||
final bizId = '${row['bizId'] ?? ''}';
|
||||
final source = '${row['source'] ?? ''}';
|
||||
final kind = '${row['kind'] ?? ''}';
|
||||
final biz = '${row['bizType'] ?? ''}';
|
||||
final path = '${row['path'] ?? ''}';
|
||||
|
||||
if (source == 'EXPENSE' && kind == 'LOAN' && id.isNotEmpty)
|
||||
return '/loans/$id';
|
||||
if (source == 'EXPENSE' && id.isNotEmpty) return '/expenses/$id';
|
||||
if (source == 'HR' && id.isNotEmpty) return '/leave-requests/$id';
|
||||
if (source == 'OFFICE' && id.isNotEmpty) return '/office/applies/$id';
|
||||
if (source == 'SEAL' && id.isNotEmpty) return '/seal-requests/$id';
|
||||
|
||||
if (bizId.isNotEmpty) {
|
||||
if (biz == 'EXPENSE_APPROVAL' ||
|
||||
biz == 'EXPENSE_DRAFT' ||
|
||||
biz == 'TRAVEL_EXPENSE') return '/expenses/$bizId';
|
||||
if (biz == 'LOAN_APPROVAL' || biz == 'LOAN_OFFSET') return '/loans/$bizId';
|
||||
if (biz == 'HR_LEAVE') return '/leave-requests/$bizId';
|
||||
if (biz == 'OFFICE_APPLY') return '/office/applies/$bizId';
|
||||
if (biz == 'SEAL_APPROVAL' || biz == 'SEAL_RETURN' || biz == 'SEAL_REQUEST')
|
||||
return '/seal-requests/$bizId';
|
||||
if (biz.startsWith('BID')) return '/bid-cases/$bizId';
|
||||
if (biz.startsWith('CONTRACT')) return '/contracts/$bizId';
|
||||
if (biz == 'WORK_REPORT') return '/office/reports/$bizId';
|
||||
if (biz == 'WORK_ASSIGN') return null;
|
||||
if (biz.startsWith('PROJECT')) return '/projects/$bizId';
|
||||
}
|
||||
|
||||
if (row['expense'] is Map)
|
||||
return '/expenses/${(row['expense'] as Map)['id']}';
|
||||
if (row['loan'] is Map) return '/loans/${(row['loan'] as Map)['id']}';
|
||||
if (row['leaveRequest'] is Map)
|
||||
return '/leave-requests/${(row['leaveRequest'] as Map)['id']}';
|
||||
if (row['officeApply'] is Map)
|
||||
return '/office/applies/${(row['officeApply'] as Map)['id']}';
|
||||
if (row['sealRequest'] is Map)
|
||||
return '/seal-requests/${(row['sealRequest'] as Map)['id']}';
|
||||
if (row['contract'] is Map)
|
||||
return '/contracts/${(row['contract'] as Map)['id']}';
|
||||
if (row['bidCase'] is Map)
|
||||
return '/bid-cases/${(row['bidCase'] as Map)['id']}';
|
||||
if (row['project'] is Map)
|
||||
return '/projects/${(row['project'] as Map)['id']}';
|
||||
|
||||
if (path.startsWith('/office/expenses/') && path.length > 17)
|
||||
return '/expenses/${path.split('/').last}';
|
||||
if (path.startsWith('/office/loans/') && path.length > 14)
|
||||
return '/loans/${path.split('/').last}';
|
||||
|
||||
if (id.length == 36) {
|
||||
if (row.containsKey('claimNo') || row.containsKey('expenseCategory'))
|
||||
return '/expenses/$id';
|
||||
if (row.containsKey('loanNo') || kind == 'LOAN') return '/loans/$id';
|
||||
if (row.containsKey('bidNo') || row.containsKey('externalNo'))
|
||||
return '/bid-cases/$id';
|
||||
if (row.containsKey('contractNo')) return '/contracts/$id';
|
||||
if (row.containsKey('projectNo')) return '/projects/$id';
|
||||
if (row.containsKey('sealType') || row.containsKey('takeOut'))
|
||||
return '/seal-requests/$id';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String detailTitleOf(Map<String, dynamic> row) {
|
||||
for (final k in [
|
||||
'title',
|
||||
'name',
|
||||
'purpose',
|
||||
'reason',
|
||||
'claimNo',
|
||||
'loanNo',
|
||||
'bidNo',
|
||||
'contractNo',
|
||||
'projectName'
|
||||
]) {
|
||||
final v = row[k];
|
||||
if (v != null && '$v'.trim().isNotEmpty) return '$v';
|
||||
}
|
||||
return '详情';
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../pages/approvals_page.dart';
|
||||
import '../pages/record_detail_page.dart';
|
||||
import '../pages/todos_page.dart';
|
||||
import '../pages/work_assign_page.dart';
|
||||
import '../session/session.dart';
|
||||
import 'biz_route.dart';
|
||||
|
||||
/// Opens a system notification at its business destination. System notices are
|
||||
/// workflow entries, never an IM peer conversation.
|
||||
void openNoticeTarget(
|
||||
BuildContext context,
|
||||
OaClient api, {
|
||||
required SessionStore session,
|
||||
required String kind,
|
||||
required String bizType,
|
||||
required String bizId,
|
||||
String title = '系统通知',
|
||||
}) {
|
||||
if (bizType == 'WORK_ASSIGN') {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => WorkAssignPage(session: session, api: api),
|
||||
));
|
||||
return;
|
||||
}
|
||||
final seed = <String, dynamic>{
|
||||
'bizType': bizType,
|
||||
'bizId': bizId,
|
||||
'id': bizId,
|
||||
'title': title,
|
||||
};
|
||||
if (bizId.isNotEmpty && fetchPathOf(seed) != null) {
|
||||
openRecord(context, api, seed, title: title);
|
||||
return;
|
||||
}
|
||||
if (kind == 'approval' || bizType.contains('APPROVAL')) {
|
||||
Navigator.of(context)
|
||||
.push(MaterialPageRoute(builder: (_) => ApprovalsPage(api: api)));
|
||||
return;
|
||||
}
|
||||
Navigator.of(context)
|
||||
.push(MaterialPageRoute(builder: (_) => TodosPage(api: api)));
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../pages/approvals_page.dart';
|
||||
import '../pages/calendar_page.dart';
|
||||
import '../pages/directory_page.dart';
|
||||
import '../pages/flow_page.dart';
|
||||
import '../pages/hr_apply_page.dart';
|
||||
import '../pages/module_list_page.dart';
|
||||
import '../pages/notices_page.dart';
|
||||
import '../pages/todos_page.dart';
|
||||
import '../pages/work_assign_page.dart';
|
||||
import '../session/session.dart';
|
||||
|
||||
void pushPage(BuildContext context, Widget page) {
|
||||
Navigator.of(context).push(MaterialPageRoute(builder: (_) => page));
|
||||
}
|
||||
|
||||
void openMenuNode(BuildContext context, SessionStore session, OaClient api, MenuNode n) {
|
||||
if (n.code.startsWith('system') || n.name == '系统设置') return;
|
||||
if (n.name == '发送短信' || n.code.contains('sms')) return;
|
||||
if (n.name == '消息' || n.code.contains('office:im') || n.code == 'im') return;
|
||||
if (n.name == '公司公告' || n.name == '员工通讯' || n.name == '通讯录') return;
|
||||
switch (n.code) {
|
||||
case 'office:overview':
|
||||
return;
|
||||
case 'office:todos':
|
||||
pushPage(context, TodosPage(api: api));
|
||||
return;
|
||||
case 'office:approvals':
|
||||
pushPage(context, ApprovalsPage(api: api));
|
||||
return;
|
||||
case 'office:flow':
|
||||
pushPage(context, FlowPage(api: api));
|
||||
return;
|
||||
case 'office:calendar':
|
||||
pushPage(context, CalendarPage(api: api));
|
||||
return;
|
||||
case 'office:notices':
|
||||
pushPage(context, NoticesPage(api: api));
|
||||
return;
|
||||
case 'office:assign':
|
||||
pushPage(context, WorkAssignPage(session: session, api: api));
|
||||
return;
|
||||
case 'office:directory':
|
||||
pushPage(context, DirectoryPage(session: session, api: api));
|
||||
return;
|
||||
case 'hr:apply':
|
||||
case 'office:leave':
|
||||
pushPage(context, HrApplyPage(api: api));
|
||||
return;
|
||||
}
|
||||
final shell = shellFor(n.code, n.path);
|
||||
if (shell != null) {
|
||||
pushPage(context, ModuleListPage(api: api, title: n.name, shell: shell));
|
||||
return;
|
||||
}
|
||||
if (n.children.isNotEmpty) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (ctx) => SafeArea(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
for (final c in n.children)
|
||||
ListTile(
|
||||
title: Text(c.name),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
openMenuNode(context, session, api, c);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
IconData iconFor(String code, String name) {
|
||||
final k = '$code $name';
|
||||
if (k.contains('todo') || name.contains('待办')) return Icons.task_alt;
|
||||
if (k.contains('approv') || name.contains('审批')) return Icons.fact_check_outlined;
|
||||
if (name.contains('申请') || k.contains('flow')) return Icons.assignment_outlined;
|
||||
if (name.contains('日程') || k.contains('calendar')) return Icons.calendar_month_outlined;
|
||||
if (name.contains('公告') || k.contains('notice')) return Icons.campaign_outlined;
|
||||
if (name.contains('安排') || k.contains('assign')) return Icons.event_note_outlined;
|
||||
if (name.contains('通讯') || k.contains('directory')) return Icons.contacts_outlined;
|
||||
if (name.contains('人事') || name.contains('员工') || k.contains('hr') || k.contains('employee')) return Icons.badge_outlined;
|
||||
if (name.contains('投标') || k.contains('bid')) return Icons.gavel_outlined;
|
||||
if (name.contains('合同') || k.contains('contract')) return Icons.description_outlined;
|
||||
if (name.contains('用章') || name.contains('证照') || k.contains('seal')) return Icons.verified_outlined;
|
||||
if (name.contains('资产') || k.contains('asset')) return Icons.devices_other_outlined;
|
||||
if (name.contains('项目') || k.contains('project')) return Icons.account_tree_outlined;
|
||||
if (name.contains('财务') || name.contains('报销') || name.contains('付款') || k.contains('finance') || k.contains('expense')) {
|
||||
return Icons.account_balance_wallet_outlined;
|
||||
}
|
||||
if (name.contains('客户') || name.contains('往来') || k.contains('party')) return Icons.handshake_outlined;
|
||||
if (name.contains('报表') || k.contains('report')) return Icons.bar_chart_outlined;
|
||||
if (name.contains('请假') || name.contains('加班')) return Icons.beach_access_outlined;
|
||||
return Icons.apps_outlined;
|
||||
}
|
||||
|
||||
Color colorFor(String code, String name, [int i = 0]) {
|
||||
const palette = [
|
||||
Color(0xFF267EF0),
|
||||
Color(0xFF07C160),
|
||||
Color(0xFFFA9D3B),
|
||||
Color(0xFF6267F2),
|
||||
Color(0xFF10AEFF),
|
||||
Color(0xFFE75D5D),
|
||||
Color(0xFF00B578),
|
||||
Color(0xFF8B5CF6),
|
||||
];
|
||||
return palette[(code.hashCode.abs() + name.hashCode.abs() + i) % palette.length];
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../app_runtime.dart';
|
||||
import '../theme.dart';
|
||||
|
||||
class AppRelease {
|
||||
AppRelease({
|
||||
required this.version,
|
||||
required this.build,
|
||||
required this.url,
|
||||
this.changelog = '',
|
||||
this.force = false,
|
||||
this.windowsUrl = '',
|
||||
this.linuxUrl = '',
|
||||
this.linuxDebUrl = '',
|
||||
});
|
||||
final String version;
|
||||
final int build;
|
||||
final String url;
|
||||
final String changelog;
|
||||
final bool force;
|
||||
final String windowsUrl;
|
||||
final String linuxUrl;
|
||||
final String linuxDebUrl;
|
||||
|
||||
bool get newer => build > AppRuntime.build;
|
||||
|
||||
String get desktopUrl {
|
||||
if (Platform.isWindows) return windowsUrl;
|
||||
if (Platform.isLinux) return linuxDebUrl.isNotEmpty ? linuxDebUrl : linuxUrl;
|
||||
return '';
|
||||
}
|
||||
|
||||
factory AppRelease.fromJson(Map<String, dynamic> j) {
|
||||
return AppRelease(
|
||||
version: '${j['version'] ?? ''}',
|
||||
build: (j['build'] as num?)?.toInt() ?? 0,
|
||||
url: '${j['url'] ?? j['apkUrl'] ?? ''}',
|
||||
changelog: '${j['changelog'] ?? ''}',
|
||||
force: j['force'] == true,
|
||||
windowsUrl: '${j['windowsUrl'] ?? ''}',
|
||||
linuxUrl: '${j['linuxUrl'] ?? ''}',
|
||||
linuxDebUrl: '${j['linuxDebUrl'] ?? ''}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OtaUpdater {
|
||||
OtaUpdater(this.api);
|
||||
final OaClient api;
|
||||
static const _ch = MethodChannel('com.fysxkj.oa/ota');
|
||||
|
||||
Future<AppRelease?> fetch() async {
|
||||
try {
|
||||
final data = await api.get('/system/app-release');
|
||||
if (data is Map) return AppRelease.fromJson(Map<String, dynamic>.from(data));
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> installApk(String path) async {
|
||||
await _ch.invokeMethod('installApk', {'path': path});
|
||||
}
|
||||
|
||||
Future<File> download(String url, void Function(double p) onProgress, {String filename = 'fengying-oa-update'}) async {
|
||||
final dir = await getTemporaryDirectory();
|
||||
final ext = url.contains('.exe')
|
||||
? '.exe'
|
||||
: url.contains('.deb')
|
||||
? '.deb'
|
||||
: url.contains('.tar.gz')
|
||||
? '.tar.gz'
|
||||
: url.contains('.apk')
|
||||
? '.apk'
|
||||
: '';
|
||||
final file = File('${dir.path}/$filename$ext');
|
||||
final client = HttpClient();
|
||||
try {
|
||||
final req = await client.getUrl(Uri.parse(url));
|
||||
final res = await req.close();
|
||||
if (res.statusCode >= 400) {
|
||||
throw ApiException('下载失败 ${res.statusCode}');
|
||||
}
|
||||
final total = res.contentLength;
|
||||
var rec = 0;
|
||||
final sink = file.openWrite();
|
||||
await for (final chunk in res) {
|
||||
rec += chunk.length;
|
||||
sink.add(chunk);
|
||||
if (total > 0) onProgress(rec / total);
|
||||
}
|
||||
await sink.close();
|
||||
return file;
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> prompt(BuildContext context, AppRelease rel, {bool manual = false}) async {
|
||||
if (!rel.newer && !manual) return;
|
||||
if (!rel.newer && manual) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已是最新版本')));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
final go = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: !rel.force,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text('发现新版本 ${rel.version}'),
|
||||
content: Text(rel.changelog.isEmpty ? '请升级后继续使用。' : rel.changelog),
|
||||
actions: [
|
||||
if (!rel.force) TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('稍后')),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('立即升级')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (go == true && context.mounted) await start(context, rel);
|
||||
}
|
||||
|
||||
Future<void> start(BuildContext context, AppRelease rel) async {
|
||||
final desktop = rel.desktopUrl;
|
||||
if (Platform.isWindows || Platform.isLinux) {
|
||||
if (desktop.isEmpty) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('暂无桌面端升级包地址')));
|
||||
}
|
||||
return;
|
||||
}
|
||||
final progress = ValueNotifier<double>(0);
|
||||
if (!context.mounted) return;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('正在下载更新'),
|
||||
content: OtaProgress(progress: progress),
|
||||
),
|
||||
);
|
||||
try {
|
||||
final file = await download(desktop, (p) => progress.value = p, filename: 'fengying-oa-setup');
|
||||
if (context.mounted) Navigator.of(context, rootNavigator: true).pop();
|
||||
if (Platform.isWindows) {
|
||||
await Process.start(file.path, ['/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART'], mode: ProcessStartMode.detached);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('安装程序已启动,请按提示完成升级后重启应用')));
|
||||
}
|
||||
} else if (file.path.endsWith('.deb')) {
|
||||
final r = await Process.run('pkexec', ['env', 'DEBIAN_FRONTEND=noninteractive', 'dpkg', '-i', file.path]);
|
||||
if (context.mounted) {
|
||||
if (r.exitCode == 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('升级完成,请重启应用')));
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('安装失败:${r.stderr}')));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await Process.run('xdg-open', [file.parent.path]);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('已下载到 ${file.path},请解压覆盖后重启应用')));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('升级失败:$e')));
|
||||
}
|
||||
} finally {
|
||||
progress.dispose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!Platform.isAndroid) return;
|
||||
if (rel.url.isEmpty) return;
|
||||
final progress = ValueNotifier<double>(0);
|
||||
if (!context.mounted) return;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('正在下载更新'),
|
||||
content: OtaProgress(progress: progress),
|
||||
),
|
||||
);
|
||||
try {
|
||||
final file = await download(rel.url, (p) => progress.value = p);
|
||||
if (context.mounted) Navigator.of(context, rootNavigator: true).pop();
|
||||
await installApk(file.path);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('升级失败:$e')));
|
||||
}
|
||||
} finally {
|
||||
progress.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class OtaProgress extends StatefulWidget {
|
||||
const OtaProgress({super.key, required this.progress});
|
||||
final ValueListenable<double> progress;
|
||||
@override
|
||||
State<OtaProgress> createState() => _OtaProgressState();
|
||||
}
|
||||
|
||||
class _OtaProgressState extends State<OtaProgress> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.progress.addListener(_on);
|
||||
}
|
||||
|
||||
void _on() => setState(() {});
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.progress.removeListener(_on);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = widget.progress.value;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
LinearProgressIndicator(value: p <= 0 ? null : p, color: kBind),
|
||||
const SizedBox(height: 12),
|
||||
Text('${(p * 100).clamp(0, 100).toStringAsFixed(0)}%'),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String prettyJson(Object v) {
|
||||
try {
|
||||
return const JsonEncoder.withIndent(' ').convert(v);
|
||||
} catch (_) {
|
||||
return '$v';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../labels.dart';
|
||||
import '../pages/record_detail_page.dart';
|
||||
import '../widgets/common.dart';
|
||||
|
||||
class ApprovalsPage extends StatefulWidget {
|
||||
const ApprovalsPage({super.key, required this.api});
|
||||
final OaClient api;
|
||||
|
||||
@override
|
||||
State<ApprovalsPage> createState() => _ApprovalsPageState();
|
||||
}
|
||||
|
||||
class _ApprovalsPageState extends State<ApprovalsPage> {
|
||||
List<Map<String, dynamic>> _items = [];
|
||||
String _bucket = 'PENDING';
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final data = await widget.api.get('/office/approvals');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = asMaps(data);
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shown = _bucket.isEmpty ? _items : _items.where((e) => '${e['status']}' == _bucket).toList();
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('待我审批')),
|
||||
body: Column(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final it in [('PENDING', '待审批'), ('APPROVED', '已通过'), ('REJECTED', '已驳回'), ('', '全部')])
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(it.$2),
|
||||
selected: _bucket == it.$1,
|
||||
showCheckmark: false,
|
||||
visualDensity: VisualDensity.compact,
|
||||
onSelected: (_) => setState(() => _bucket = it.$1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_loading) const LinearProgressIndicator(minHeight: 2),
|
||||
Expanded(
|
||||
child: shown.isEmpty
|
||||
? const EmptyHint('没有审批任务')
|
||||
: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView.builder(
|
||||
itemCount: shown.length,
|
||||
itemBuilder: (_, i) {
|
||||
final r = shown[i];
|
||||
return KvTile(
|
||||
title: '${r['title'] ?? ''}',
|
||||
subtitle: '${zh(r['bizType'])} ${fmtTime(r['createdAt'])}',
|
||||
status: r['status'],
|
||||
onTap: () async {
|
||||
await Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => RecordDetailPage(api: widget.api, seed: r, title: '${r['title'] ?? '审批'}'),
|
||||
));
|
||||
_load();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../device/device_bridge.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/wecom.dart';
|
||||
|
||||
class AttendancePage extends StatefulWidget {
|
||||
const AttendancePage({super.key, required this.api, this.embedded = false});
|
||||
final OaClient api;
|
||||
final bool embedded;
|
||||
@override
|
||||
State<AttendancePage> createState() => _AttendancePageState();
|
||||
}
|
||||
|
||||
class _AttendancePageState extends State<AttendancePage> {
|
||||
Map<String, dynamic> _status = {};
|
||||
Map<String, double>? _location;
|
||||
bool _loading = true;
|
||||
bool _sending = false;
|
||||
String _mode = 'OFFICE';
|
||||
|
||||
@override
|
||||
void initState() { super.initState(); _load(); }
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
_location = await DeviceBridge.getLocation();
|
||||
final q = <String, String>{
|
||||
if (_location != null) 'lat': '${_location!['lat']}',
|
||||
if (_location != null) 'lng': '${_location!['lng']}',
|
||||
'mode': _mode,
|
||||
};
|
||||
final data = Map<String, dynamic>.from(await widget.api.get('/attendance/punch-status', query: q) as Map);
|
||||
if (mounted) setState(() { _status = data; _loading = false; });
|
||||
} catch (_) { if (mounted) setState(() => _loading = false); }
|
||||
}
|
||||
|
||||
Future<void> _punch(String kind) async {
|
||||
if (_mode == 'OFFICE' && _status['tripActive'] == true) return;
|
||||
if (_mode == 'FIELD' && _status['fieldApproved'] != true) return;
|
||||
setState(() => _sending = true);
|
||||
try {
|
||||
await widget.api.post('/attendance/punch', {'kind': kind, 'mode': _mode, if (_location != null) ..._location!});
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('打卡成功')));
|
||||
await _load();
|
||||
} catch (e) { if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e'))); }
|
||||
finally { if (mounted) setState(() => _sending = false); }
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final inKey = _mode == 'FIELD' ? 'fieldIn' : 'clockIn';
|
||||
final outKey = _mode == 'FIELD' ? 'fieldOut' : 'clockOut';
|
||||
final inAt = _time(_status[inKey]);
|
||||
final outAt = _time(_status[outKey]);
|
||||
final hasIn = inAt != '未打卡';
|
||||
final hasOut = outAt != '未打卡';
|
||||
final trip = _status['tripActive'] == true;
|
||||
final fieldOk = _status['fieldApproved'] == true;
|
||||
final canPunch = _mode == 'FIELD' ? fieldOk : !trip && _status['inRange'] != false;
|
||||
final body = _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 28),
|
||||
children: [
|
||||
SegmentedButton<String>(
|
||||
segments: const [
|
||||
ButtonSegment(value: 'OFFICE', label: Text('上下班打卡'), icon: Icon(Icons.access_time)),
|
||||
ButtonSegment(value: 'FIELD', label: Text('外勤打卡'), icon: Icon(Icons.location_on_outlined)),
|
||||
],
|
||||
selected: {_mode},
|
||||
onSelectionChanged: (v) async {
|
||||
setState(() => _mode = v.first);
|
||||
await _load();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 20, 18, 24),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(_today(), style: const TextStyle(fontSize: 25, fontWeight: FontWeight.w800)),
|
||||
const SizedBox(height: 8),
|
||||
_rangeBanner(canPunch, trip, fieldOk),
|
||||
const SizedBox(height: 18),
|
||||
_punchCircle(
|
||||
enabled: canPunch && !hasIn && !_sending,
|
||||
label: _mode == 'FIELD' ? '外勤打卡' : '上班打卡',
|
||||
color: const Color(0xFF2575EA),
|
||||
onTap: () => _punch('IN'),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_timeBox('上班', inAt, _status['late'] == true),
|
||||
_timeBox('下班', outAt, _status['early'] == true),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: canPunch && hasIn && !hasOut && !_sending ? () => _punch('OUT') : null,
|
||||
icon: const Icon(Icons.logout),
|
||||
label: Text(hasOut ? '已打下班卡' : '打下班卡'),
|
||||
),
|
||||
),
|
||||
if (_mode == 'OFFICE' && _status['late'] == true) const _Flag(text: '迟到', color: Colors.orange),
|
||||
if (_mode == 'OFFICE' && _status['early'] == true) const _Flag(text: '早退', color: Colors.deepOrange),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Card(
|
||||
elevation: 0,
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.place, color: _status['inRange'] == true ? Colors.green : Colors.red),
|
||||
title: Text('${_status['rangeText'] ?? '正在获取考勤范围'}', style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
subtitle: Text(_status['distanceMeters'] == null ? '请开启定位后刷新' : '距离考勤点约 ${_status['distanceMeters']} 米'),
|
||||
trailing: IconButton(onPressed: _load, icon: const Icon(Icons.refresh)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (widget.embedded) {
|
||||
return ColoredBox(
|
||||
color: const Color(0xFFF4F6FA),
|
||||
child: Column(
|
||||
children: [
|
||||
const WxHeader(title: '打卡'),
|
||||
Expanded(child: body),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF4F6FA),
|
||||
appBar: AppBar(title: const Text('打卡'), backgroundColor: Colors.transparent),
|
||||
body: body,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _rangeBanner(bool canPunch, bool trip, bool fieldOk) {
|
||||
final text = _mode == 'FIELD' ? (fieldOk ? '外出申请已通过,可进行外勤打卡' : '需先申请并审批通过外出') : (trip ? '出差期间无需打卡' : (canPunch ? '当前可进行上下班打卡' : '未进入考勤范围'));
|
||||
final color = trip || fieldOk || canPunch ? Colors.green : Colors.orange;
|
||||
return Container(width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: color.withValues(alpha: .1), borderRadius: BorderRadius.circular(12)), child: Row(children: [Icon(Icons.info_outline, color: color), const SizedBox(width: 8), Expanded(child: Text(text, style: TextStyle(color: color, fontWeight: FontWeight.w600)))]));
|
||||
}
|
||||
|
||||
Widget _punchCircle({required bool enabled, required String label, required Color color, required VoidCallback onTap}) => GestureDetector(onTap: enabled ? onTap : null, child: AnimatedContainer(duration: const Duration(milliseconds: 180), width: 190, height: 190, decoration: BoxDecoration(shape: BoxShape.circle, border: Border.all(color: enabled ? color : Colors.grey.shade400, width: 10), color: Colors.white), child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Text(label, style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: enabled ? kInk : kMute)), const SizedBox(height: 6), Text(_nowTime(), style: TextStyle(fontSize: 24, fontWeight: FontWeight.w800, color: enabled ? color : kMute))])));
|
||||
Widget _timeBox(String label, String value, bool flag) => Column(children: [Text(label, style: const TextStyle(color: kMute)), const SizedBox(height: 4), Row(children: [Text(value, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)), if (flag) const Padding(padding: EdgeInsets.only(left: 4), child: Icon(Icons.error, color: Colors.orange, size: 16))])]);
|
||||
String _today() { final d = DateTime.now(); return '${d.year}年${d.month}月${d.day}日'; }
|
||||
String _nowTime() { final d = DateTime.now(); return '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; }
|
||||
String _time(dynamic value) { if (value == null || '$value' == 'null' || '$value'.isEmpty) return '未打卡'; final d = DateTime.tryParse('$value')?.toLocal(); return d == null ? '$value' : '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; }
|
||||
}
|
||||
|
||||
class _Flag extends StatelessWidget {
|
||||
const _Flag({required this.text, required this.color});
|
||||
final String text; final Color color;
|
||||
@override Widget build(BuildContext context) => Padding(padding: const EdgeInsets.only(top: 8), child: Text(text, style: TextStyle(color: color, fontWeight: FontWeight.w700)));
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../labels.dart';
|
||||
import '../widgets/common.dart';
|
||||
import 'record_detail_page.dart';
|
||||
|
||||
class CalendarPage extends StatefulWidget {
|
||||
const CalendarPage({super.key, required this.api});
|
||||
final OaClient api;
|
||||
|
||||
@override
|
||||
State<CalendarPage> createState() => _CalendarPageState();
|
||||
}
|
||||
|
||||
class _CalendarPageState extends State<CalendarPage> {
|
||||
List<Map<String, dynamic>> _items = [];
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final data = await widget.api.get('/office/calendar');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = asMaps(data);
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('我的日程')),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _items.isEmpty
|
||||
? const EmptyHint('近期没有日程')
|
||||
: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView(
|
||||
children: [
|
||||
for (final r in _items)
|
||||
KvTile(
|
||||
title: '${r['title'] ?? r['name'] ?? '日程'}',
|
||||
subtitle: '${fmtTime(r['start'] ?? r['beginAt'] ?? r['createdAt'])} ${zh(r['type'] ?? r['kind'])}',
|
||||
onTap: () => openRecord(context, widget.api, r, title: '${r['title'] ?? r['name'] ?? '日程'}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../device/device_bridge.dart';
|
||||
import '../session/session.dart';
|
||||
import '../theme.dart';
|
||||
|
||||
/// LiveKit 音视频房间页面。信令和媒体连接均由 LiveKit 负责,OA 只签发短时房间 Token。
|
||||
class CallPage extends StatefulWidget {
|
||||
const CallPage({
|
||||
super.key,
|
||||
required this.session,
|
||||
required this.api,
|
||||
required this.callId,
|
||||
required this.kind,
|
||||
required this.title,
|
||||
required this.initiatorId,
|
||||
required this.memberIds,
|
||||
this.peerName = '同事',
|
||||
this.incoming = false,
|
||||
});
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
final String callId;
|
||||
final String kind;
|
||||
final String title;
|
||||
final String initiatorId;
|
||||
final List<String> memberIds;
|
||||
final String peerName;
|
||||
final bool incoming;
|
||||
|
||||
@override
|
||||
State<CallPage> createState() => _CallPageState();
|
||||
}
|
||||
|
||||
class _CallPageState extends State<CallPage> {
|
||||
Room? _room;
|
||||
VideoTrack? _remoteVideo;
|
||||
VideoTrack? _localVideo;
|
||||
String _status = '正在连接音视频服务…';
|
||||
bool _ready = false;
|
||||
bool _muted = false;
|
||||
bool _camOff = false;
|
||||
bool _ending = false;
|
||||
bool _remoteEnded = false;
|
||||
bool _tone = false;
|
||||
DateTime? _connectedAt;
|
||||
Duration _elapsed = Duration.zero;
|
||||
Timer? _durationTimer;
|
||||
Timer? _ringTimeoutTimer;
|
||||
Timer? _statePollTimer;
|
||||
bool get _video => widget.kind != 'audio';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.session.im.addListener(_onIm);
|
||||
_statePollTimer = Timer.periodic(
|
||||
const Duration(seconds: 2), (_) => unawaited(_pollCallState()));
|
||||
_boot();
|
||||
}
|
||||
|
||||
void _onIm() {
|
||||
if (_ending || _remoteEnded || widget.session.im.inbox.isEmpty) return;
|
||||
final matches = widget.session.im.inbox.where((item) =>
|
||||
item.kind == 'call' && '${item.raw['callId'] ?? ''}' == widget.callId);
|
||||
if (matches.isEmpty) return;
|
||||
final last = matches.first;
|
||||
final raw = last.raw;
|
||||
if (last.kind != 'call' || '${raw['callId'] ?? ''}' != widget.callId)
|
||||
return;
|
||||
final action = '${raw['action'] ?? ''}';
|
||||
if (action != 'end' && action != 'reject' && action != 'timeout') return;
|
||||
_finishRemote(action == 'timeout'
|
||||
? '无人接听'
|
||||
: action == 'reject'
|
||||
? '对方拒绝接听'
|
||||
: '通话已结束');
|
||||
}
|
||||
|
||||
Future<void> _pollCallState() async {
|
||||
if (_ending || _remoteEnded) return;
|
||||
try {
|
||||
final raw = await widget.api.get('/im/calls/${widget.callId}');
|
||||
if (raw is! Map || !mounted) return;
|
||||
final data = Map<String, dynamic>.from(raw);
|
||||
final status = '${data['status'] ?? ''}';
|
||||
if (status == 'ended') {
|
||||
_finishRemote(data['peerRejected'] == true ? '对方拒绝接听' : '通话已结束');
|
||||
return;
|
||||
}
|
||||
if (data['peerJoined'] == true && _ready) {
|
||||
_stopTone();
|
||||
_startDuration();
|
||||
if (mounted && _status != '通话中') setState(() => _status = '通话中');
|
||||
}
|
||||
} catch (_) {
|
||||
// 短时断网由 LiveKit 重连处理,状态轮询下一轮继续。
|
||||
}
|
||||
}
|
||||
|
||||
void _finishRemote(String text) {
|
||||
if (_remoteEnded || _ending) return;
|
||||
_remoteEnded = true;
|
||||
_ending = true;
|
||||
_stopTone();
|
||||
_stopDuration();
|
||||
_ringTimeoutTimer?.cancel();
|
||||
_statePollTimer?.cancel();
|
||||
unawaited(_room?.disconnect());
|
||||
if (!mounted) return;
|
||||
setState(() => _status = text);
|
||||
Future<void>.delayed(const Duration(milliseconds: 850), () {
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _boot() async {
|
||||
if (widget.incoming) {
|
||||
if (mounted) setState(() => _status = '对方邀请你${_video ? '视频' : '语音'}通话');
|
||||
_startTone();
|
||||
_startRingTimeout();
|
||||
return;
|
||||
}
|
||||
await _start();
|
||||
}
|
||||
|
||||
Future<void> _start() async {
|
||||
if (_ready) return;
|
||||
if (widget.incoming) _stopTone();
|
||||
if (mounted)
|
||||
setState(() {
|
||||
_ready = true;
|
||||
_status = '正在接通…';
|
||||
});
|
||||
_startTone();
|
||||
_startRingTimeout();
|
||||
try {
|
||||
await DeviceBridge.requestCallMedia();
|
||||
await widget.api.post('/im/calls/${widget.callId}/join');
|
||||
final auth =
|
||||
await widget.api.post('/im/calls/${widget.callId}/livekit-token');
|
||||
final data =
|
||||
auth is Map ? Map<String, dynamic>.from(auth) : <String, dynamic>{};
|
||||
final url = '${data['url'] ?? ''}';
|
||||
final token = '${data['token'] ?? ''}';
|
||||
if (url.isEmpty || token.isEmpty) throw Exception('音视频服务未配置');
|
||||
final room = Room();
|
||||
_room = room;
|
||||
room.events
|
||||
..on<ParticipantConnectedEvent>((_) {
|
||||
_stopTone();
|
||||
_startDuration();
|
||||
if (mounted) setState(() => _status = '通话中');
|
||||
})
|
||||
..on<ParticipantDisconnectedEvent>((_) {
|
||||
if (!_ending) _finishRemote('通话已结束');
|
||||
})
|
||||
..on<TrackSubscribedEvent>((event) {
|
||||
_stopTone();
|
||||
_startDuration();
|
||||
if (event.track is VideoTrack && mounted)
|
||||
setState(() => _remoteVideo = event.track as VideoTrack);
|
||||
if (mounted) setState(() => _status = '通话中');
|
||||
})
|
||||
..on<TrackUnsubscribedEvent>((event) {
|
||||
if (event.track is VideoTrack && mounted)
|
||||
setState(() => _remoteVideo = null);
|
||||
})
|
||||
..on<RoomReconnectingEvent>((_) {
|
||||
if (mounted) setState(() => _status = '网络恢复中…');
|
||||
})
|
||||
..on<RoomReconnectedEvent>((_) {
|
||||
if (mounted) setState(() => _status = '通话中');
|
||||
})
|
||||
..on<RoomDisconnectedEvent>((_) {
|
||||
if (!_ending) _finishRemote('通话已断开');
|
||||
});
|
||||
await room.connect(url, token);
|
||||
// 接听方完成信令连接后即可停止响铃;拨打方继续响铃直到对方加入房间。
|
||||
if (widget.incoming) _stopTone();
|
||||
await room.localParticipant?.setMicrophoneEnabled(true);
|
||||
if (_video) {
|
||||
final pub = await room.localParticipant?.setCameraEnabled(true);
|
||||
if (mounted && pub?.track is VideoTrack)
|
||||
setState(() => _localVideo = pub!.track as VideoTrack);
|
||||
}
|
||||
if (room.remoteParticipants.isNotEmpty) {
|
||||
_stopTone();
|
||||
_startDuration();
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() => _status = room.remoteParticipants.isNotEmpty
|
||||
? '通话中'
|
||||
: widget.initiatorId == widget.session.userId
|
||||
? '等待对方接听…'
|
||||
: '正在建立通话…');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _status = '$e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _hangup() async {
|
||||
if (_ending) return;
|
||||
_ending = true;
|
||||
_stopTone();
|
||||
_stopDuration();
|
||||
_ringTimeoutTimer?.cancel();
|
||||
_statePollTimer?.cancel();
|
||||
try {
|
||||
await widget.api.post(
|
||||
'/im/calls/${widget.callId}/${(!_ready && widget.incoming) ? 'reject' : 'end'}');
|
||||
} catch (_) {}
|
||||
await _room?.disconnect();
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
Future<void> _toggleMute() async {
|
||||
final next = !_muted;
|
||||
await _room?.localParticipant?.setMicrophoneEnabled(!next);
|
||||
if (mounted) setState(() => _muted = next);
|
||||
}
|
||||
|
||||
Future<void> _toggleCam() async {
|
||||
final next = !_camOff;
|
||||
final pub = await _room?.localParticipant?.setCameraEnabled(!next);
|
||||
if (mounted)
|
||||
setState(() {
|
||||
_camOff = next;
|
||||
if (!next && pub?.track is VideoTrack)
|
||||
_localVideo = pub!.track as VideoTrack;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.session.im.removeListener(_onIm);
|
||||
_stopTone();
|
||||
_stopDuration();
|
||||
_ringTimeoutTimer?.cancel();
|
||||
_statePollTimer?.cancel();
|
||||
_room?.disconnect();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startTone() {
|
||||
if (_tone) return;
|
||||
_tone = true;
|
||||
DeviceBridge.startCallTone(incoming: widget.incoming);
|
||||
}
|
||||
|
||||
void _stopTone() {
|
||||
if (!_tone) return;
|
||||
_tone = false;
|
||||
DeviceBridge.stopCallTone();
|
||||
}
|
||||
|
||||
void _startDuration() {
|
||||
if (_connectedAt != null) return;
|
||||
_ringTimeoutTimer?.cancel();
|
||||
_ringTimeoutTimer = null;
|
||||
_connectedAt = DateTime.now();
|
||||
_durationTimer?.cancel();
|
||||
_durationTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!mounted || _connectedAt == null) return;
|
||||
setState(() => _elapsed = DateTime.now().difference(_connectedAt!));
|
||||
});
|
||||
if (mounted) setState(() => _elapsed = Duration.zero);
|
||||
}
|
||||
|
||||
void _stopDuration() {
|
||||
_durationTimer?.cancel();
|
||||
_durationTimer = null;
|
||||
}
|
||||
|
||||
void _startRingTimeout() {
|
||||
_ringTimeoutTimer?.cancel();
|
||||
_ringTimeoutTimer = Timer(const Duration(seconds: 45), () {
|
||||
if (!mounted || _ending || _connectedAt != null) return;
|
||||
_ending = true;
|
||||
_remoteEnded = true;
|
||||
_stopTone();
|
||||
unawaited(widget.api
|
||||
.post(
|
||||
'/im/calls/${widget.callId}/${widget.incoming ? 'reject' : 'end'}')
|
||||
.catchError((_) {}));
|
||||
setState(() => _status = '无人接听');
|
||||
Future<void>.delayed(const Duration(milliseconds: 650), () {
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
String get _durationText {
|
||||
final h = _elapsed.inHours;
|
||||
final m = _elapsed.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final s = _elapsed.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
return h > 0 ? '${h.toString().padLeft(2, '0')}:$m:$s' : '$m:$s';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) _hangup();
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: const Color(0xFF1C1C1E),
|
||||
body: SafeArea(
|
||||
child: Stack(children: [
|
||||
if (_video && _remoteVideo != null)
|
||||
Positioned.fill(
|
||||
child: VideoTrackRenderer(_remoteVideo!,
|
||||
fit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitCover))
|
||||
else
|
||||
Center(
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
CircleAvatar(
|
||||
radius: 48,
|
||||
backgroundColor: kBind,
|
||||
child: Text(
|
||||
widget.peerName.trim().isEmpty
|
||||
? '通'
|
||||
: String.fromCharCodes(widget.peerName.runes.take(1)),
|
||||
style:
|
||||
const TextStyle(color: Colors.white, fontSize: 32))),
|
||||
const SizedBox(height: 16),
|
||||
Text(widget.peerName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
Text(_status,
|
||||
style:
|
||||
const TextStyle(color: Color(0xFFBBBBBB), fontSize: 14)),
|
||||
if (_connectedAt != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(_durationText,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFBBBBBB), fontSize: 14)),
|
||||
],
|
||||
])),
|
||||
if (_video && _localVideo != null)
|
||||
Positioned(
|
||||
right: 16,
|
||||
top: 16,
|
||||
width: 110,
|
||||
height: 160,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: VideoTrackRenderer(_localVideo!,
|
||||
mirrorMode: VideoViewMirrorMode.mirror))),
|
||||
if (_video && _remoteVideo != null)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 24,
|
||||
child: Text(_status,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white70))),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 36,
|
||||
child:
|
||||
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
if (!_ready) ...[
|
||||
_round(
|
||||
Icons.call_end, const Color(0xFFE64340), _hangup, '拒绝'),
|
||||
const SizedBox(width: 48),
|
||||
_round(Icons.call, const Color(0xFF07C160), _start, '接听'),
|
||||
] else ...[
|
||||
_round(
|
||||
_muted ? Icons.mic_off : Icons.mic,
|
||||
const Color(0xFF3A3A3C),
|
||||
_toggleMute,
|
||||
_muted ? '开麦' : '静音'),
|
||||
if (_video) ...[
|
||||
const SizedBox(width: 28),
|
||||
_round(_camOff ? Icons.videocam_off : Icons.videocam,
|
||||
const Color(0xFF3A3A3C), _toggleCam, '摄像头')
|
||||
],
|
||||
const SizedBox(width: 28),
|
||||
_round(
|
||||
Icons.call_end, const Color(0xFFE64340), _hangup, '挂断'),
|
||||
],
|
||||
])),
|
||||
])),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _round(IconData icon, Color color, VoidCallback onTap, String label) =>
|
||||
Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
InkWell(
|
||||
onTap: onTap,
|
||||
customBorder: const CircleBorder(),
|
||||
child: CircleAvatar(
|
||||
radius: 28,
|
||||
backgroundColor: color,
|
||||
child: Icon(icon, color: Colors.white, size: 26))),
|
||||
const SizedBox(height: 8),
|
||||
Text(label,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 12)),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> openCallPage(BuildContext context,
|
||||
{required SessionStore session,
|
||||
required OaClient api,
|
||||
required Map<String, dynamic> call,
|
||||
String peerName = '同事',
|
||||
bool incoming = false}) async {
|
||||
final id = '${call['id'] ?? ''}';
|
||||
if (id.isEmpty) return;
|
||||
final members =
|
||||
((call['memberIds'] as List?) ?? []).map((e) => '$e').toList();
|
||||
await Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => CallPage(
|
||||
session: session,
|
||||
api: api,
|
||||
callId: id,
|
||||
kind: '${call['kind'] ?? 'audio'}',
|
||||
title: '${call['title'] ?? '通话'}',
|
||||
initiatorId: '${call['initiatorId'] ?? ''}',
|
||||
memberIds: members,
|
||||
peerName: peerName,
|
||||
incoming: incoming)));
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../im/chat_prefs.dart';
|
||||
import '../session/session.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/wecom.dart';
|
||||
import 'pick_people_page.dart';
|
||||
|
||||
class ChatDetailPage extends StatefulWidget {
|
||||
const ChatDetailPage({
|
||||
super.key,
|
||||
required this.session,
|
||||
required this.api,
|
||||
required this.conversationId,
|
||||
required this.peerId,
|
||||
required this.peerName,
|
||||
required this.isGroup,
|
||||
this.peerAvatarFileId,
|
||||
this.messages = const [],
|
||||
this.onCall,
|
||||
});
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
final String conversationId;
|
||||
final String peerId;
|
||||
final String peerName;
|
||||
final bool isGroup;
|
||||
final String? peerAvatarFileId;
|
||||
final List<Map<String, dynamic>> messages;
|
||||
final void Function(String kind)? onCall;
|
||||
|
||||
@override
|
||||
State<ChatDetailPage> createState() => _ChatDetailPageState();
|
||||
}
|
||||
|
||||
class _ChatDetailPageState extends State<ChatDetailPage> {
|
||||
List<Map<String, dynamic>> _members = [];
|
||||
bool _mute = false;
|
||||
bool _pin = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mute = ChatPrefs.muted(widget.conversationId);
|
||||
_pin = ChatPrefs.pinned(widget.conversationId);
|
||||
_loadMembers();
|
||||
}
|
||||
|
||||
Future<void> _loadMembers() async {
|
||||
if (widget.isGroup && widget.conversationId.isNotEmpty) {
|
||||
try {
|
||||
final data = await widget.api.get('/im/groups/${widget.conversationId}/members');
|
||||
if (!mounted) return;
|
||||
setState(() => _members = asMaps(data));
|
||||
return;
|
||||
} catch (_) {}
|
||||
}
|
||||
setState(() {
|
||||
_members = [
|
||||
{
|
||||
'id': widget.peerId,
|
||||
'name': widget.peerName,
|
||||
'avatarFileId': widget.peerAvatarFileId,
|
||||
},
|
||||
{
|
||||
'id': widget.session.userId,
|
||||
'name': widget.session.displayName,
|
||||
'avatarFileId': widget.session.user['avatarFileId'],
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _addMembers() async {
|
||||
final exclude = {
|
||||
widget.session.userId,
|
||||
..._members.map((e) => '${e['id'] ?? e['userId'] ?? ''}'),
|
||||
};
|
||||
final picked = await Navigator.of(context).push<List<Map<String, dynamic>>>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PickPeoplePage(api: widget.api, title: '选择同事', exclude: exclude),
|
||||
),
|
||||
);
|
||||
if (picked == null || picked.isEmpty) return;
|
||||
try {
|
||||
if (widget.isGroup && widget.conversationId.isNotEmpty) {
|
||||
await widget.api.post('/im/groups/${widget.conversationId}/members', {
|
||||
'memberIds': picked.map((e) => '${e['id']}').toList(),
|
||||
});
|
||||
await _loadMembers();
|
||||
} else {
|
||||
await widget.api.post('/im/groups', {
|
||||
'name': '${widget.peerName}的群聊',
|
||||
'memberIds': [widget.peerId, ...picked.map((e) => '${e['id']}')],
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已创建群聊')));
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _search() async {
|
||||
await Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => _ChatSearchPage(messages: widget.messages, peerName: widget.peerName),
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> _pickBg() async {
|
||||
const colors = [
|
||||
0xFFF5F5F5,
|
||||
0xFFE7F0E4,
|
||||
0xFFE8EEF6,
|
||||
0xFFF6EFE6,
|
||||
0xFFF3E8EE,
|
||||
];
|
||||
final picked = await showModalBottomSheet<int>(
|
||||
context: context,
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
for (final c in colors)
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(ctx, c),
|
||||
child: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: Color(c),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFDDDDDD)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (picked == null) return;
|
||||
await ChatPrefs.setBg(widget.conversationId, picked);
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _clear() async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('删除聊天记录'),
|
||||
content: const Text('将从本机清空当前会话的聊天记录,对方不受影响。'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('删除')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true) return;
|
||||
await ChatPrefs.clearHistory(widget.conversationId);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已删除本机聊天记录')));
|
||||
Navigator.pop(context, 'cleared');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: kPaper,
|
||||
appBar: AppBar(title: const Text('聊天详情')),
|
||||
body: ListView(
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
color: Colors.white,
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
for (final m in _members)
|
||||
SizedBox(
|
||||
width: 56,
|
||||
child: Column(
|
||||
children: [
|
||||
SquareAvatar(
|
||||
label: '${m['name'] ?? m['displayName'] ?? ''}',
|
||||
size: 48,
|
||||
fileId: '${m['avatarFileId'] ?? ''}',
|
||||
api: widget.api,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${m['name'] ?? m['displayName'] ?? ''}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12, color: kInk),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 56,
|
||||
child: Column(
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: _addMembers,
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: const Color(0xFFC8C8C8), style: BorderStyle.solid),
|
||||
),
|
||||
child: const Icon(Icons.add, color: kMute),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(' ', style: TextStyle(fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CellGroup(
|
||||
children: [
|
||||
Cell(title: '查找聊天内容', onTap: _search, showLine: false),
|
||||
],
|
||||
),
|
||||
CellGroup(
|
||||
children: [
|
||||
Cell(
|
||||
title: '消息免打扰',
|
||||
trailing: Switch(
|
||||
value: _mute,
|
||||
activeTrackColor: kWeGreen,
|
||||
onChanged: (v) async {
|
||||
await ChatPrefs.setMuted(widget.conversationId, v);
|
||||
setState(() => _mute = v);
|
||||
},
|
||||
),
|
||||
),
|
||||
Cell(
|
||||
title: '置顶聊天',
|
||||
trailing: Switch(
|
||||
value: _pin,
|
||||
activeTrackColor: kWeGreen,
|
||||
onChanged: (v) async {
|
||||
await ChatPrefs.setPinned(widget.conversationId, v);
|
||||
setState(() => _pin = v);
|
||||
},
|
||||
),
|
||||
showLine: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
CellGroup(
|
||||
children: [
|
||||
Cell(title: '设置当前聊天背景', onTap: _pickBg),
|
||||
Cell(title: '语音通话', onTap: () => widget.onCall?.call('audio')),
|
||||
Cell(
|
||||
title: '视频通话',
|
||||
onTap: () => widget.onCall?.call(widget.isGroup ? 'meeting' : 'video'),
|
||||
showLine: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
CellGroup(
|
||||
children: [
|
||||
Cell(title: '删除聊天记录', onTap: _clear, showLine: false),
|
||||
],
|
||||
),
|
||||
CellGroup(
|
||||
children: [
|
||||
Cell(
|
||||
title: '投诉',
|
||||
onTap: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已记录投诉,我们会尽快处理')));
|
||||
},
|
||||
showLine: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatSearchPage extends StatefulWidget {
|
||||
const _ChatSearchPage({required this.messages, required this.peerName});
|
||||
final List<Map<String, dynamic>> messages;
|
||||
final String peerName;
|
||||
|
||||
@override
|
||||
State<_ChatSearchPage> createState() => _ChatSearchPageState();
|
||||
}
|
||||
|
||||
class _ChatSearchPageState extends State<_ChatSearchPage> {
|
||||
String _q = '';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final q = _q.trim();
|
||||
final hits = q.isEmpty
|
||||
? const <Map<String, dynamic>>[]
|
||||
: widget.messages.where((e) => '${e['body'] ?? ''}'.contains(q)).toList();
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(title: const Text('查找聊天内容')),
|
||||
body: Column(
|
||||
children: [
|
||||
WxSearchBar(hint: '搜索', onChanged: (v) => setState(() => _q = v)),
|
||||
Expanded(
|
||||
child: q.isEmpty
|
||||
? const Center(child: Text('输入关键词查找聊天内容', style: TextStyle(color: kMute)))
|
||||
: hits.isEmpty
|
||||
? const Center(child: Text('没有找到相关内容', style: TextStyle(color: kMute)))
|
||||
: ListView(
|
||||
children: [
|
||||
for (final r in hits)
|
||||
Cell(
|
||||
title: '${r['body']}',
|
||||
subtitle: '${r['fromName'] ?? widget.peerName} ${r['createdAt'] ?? ''}',
|
||||
showLine: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user