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:
2026-09-02 10:04:03 +00:00
commit 76f266645d
507 changed files with 99891 additions and 0 deletions
+46
View File
@@ -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
+36
View File
@@ -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'
+55
View File
@@ -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 StudioDesktop 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。
+9
View File
@@ -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`
+34
View File
@@ -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
+13
View File
@@ -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"}}]}
+68
View File
@@ -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
View File
@@ -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
}
}
}
@@ -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)
}
}
@@ -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

@@ -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>
+27
View File
@@ -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
}
+8
View File
@@ -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
+34
View File
@@ -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.
+5
View File
@@ -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}"
+8
View File
@@ -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
+49
View File
@@ -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 SetupISCC.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"
+118
View File
@@ -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"
+43
View File
@@ -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
+358
View File
@@ -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;
+13
View File
@@ -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;
}
+19
View File
@@ -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;
+369
View File
@@ -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;
+47
View File
@@ -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,
),
);
}
+328
View File
@@ -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};
}
}
+98
View File
@@ -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 (_) {}
}
}
+32
View File
@@ -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));
}
}
+90
View File
@@ -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);
}
}
}
+38
View File
@@ -0,0 +1,38 @@
import 'package:flutter/services.dart';
/// 与官方 MobileIMSDK 对齐的生命周期:登录前 init,退出 release。
/// Android 走 MethodChannelWindows/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;
}
}
+42
View File
@@ -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));
}
}
}
+33
View File
@@ -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);
}
}
+81
View File
@@ -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] ?? '#';
}
+73
View File
@@ -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;
+32
View File
@@ -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);
}
}
+300
View File
@@ -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;
}
}
}
+407
View File
@@ -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)),
);
}
}
+103
View File
@@ -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),
);
}
}
+85
View File
@@ -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 '详情';
}
+45
View File
@@ -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)));
}
+116
View File
@@ -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];
}
+251
View File
@@ -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';
}
}
+95
View File
@@ -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();
},
);
},
),
),
),
],
),
);
}
}
+169
View File
@@ -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)));
}
+62
View File
@@ -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'] ?? '日程'}'),
),
],
),
),
);
}
}
+436
View File
@@ -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)));
}
+344
View File
@@ -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