Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,358 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http_parser/http_parser.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../app_config.dart';
|
||||
import '../session/session.dart';
|
||||
|
||||
class ApiException implements Exception {
|
||||
ApiException(this.message);
|
||||
final String message;
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class OaClient {
|
||||
OaClient(this.session);
|
||||
final SessionStore session;
|
||||
bool _refreshing = false;
|
||||
|
||||
Uri _uri(String path, [Map<String, String>? query]) {
|
||||
final base = session.apiBase.replaceAll(RegExp(r'/$'), '');
|
||||
final p = path.startsWith('/') ? path : '/$path';
|
||||
return Uri.parse('$base$p').replace(queryParameters: query);
|
||||
}
|
||||
|
||||
Map<String, String> _headers({bool auth = true}) {
|
||||
final h = <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
'X-Request-Id': const Uuid().v4(),
|
||||
};
|
||||
if (auth && session.accessToken.isNotEmpty) {
|
||||
h['Authorization'] = 'Bearer ${session.accessToken}';
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
Future<dynamic> get(String path, {Map<String, String>? query}) =>
|
||||
_send('GET', path, query: query);
|
||||
|
||||
Future<dynamic> post(String path, [Map<String, dynamic>? body]) =>
|
||||
_send('POST', path, body: body);
|
||||
|
||||
Future<dynamic> patch(String path, [Map<String, dynamic>? body]) =>
|
||||
_send('PATCH', path, body: body);
|
||||
|
||||
final _fileCache = <String, Uint8List>{};
|
||||
|
||||
Uint8List? peekFile(String id) => _fileCache[id];
|
||||
|
||||
Future<Uint8List?> fileBytes(String id) async {
|
||||
if (id.isEmpty) return null;
|
||||
final hit = _fileCache[id];
|
||||
if (hit != null) return hit;
|
||||
final uri = _uri('/files/$id');
|
||||
final headers = <String, String>{
|
||||
'X-Request-Id': const Uuid().v4(),
|
||||
if (session.accessToken.isNotEmpty) 'Authorization': 'Bearer ${session.accessToken}',
|
||||
};
|
||||
try {
|
||||
var res = await http.get(uri, headers: headers).timeout(const Duration(seconds: 30));
|
||||
if (res.statusCode == 401 && session.refreshToken.isNotEmpty) {
|
||||
final ok = await _refresh();
|
||||
if (ok) {
|
||||
headers['Authorization'] = 'Bearer ${session.accessToken}';
|
||||
res = await http.get(uri, headers: headers).timeout(const Duration(seconds: 30));
|
||||
}
|
||||
}
|
||||
if (res.statusCode >= 400 || res.bodyBytes.isEmpty) return null;
|
||||
if (_fileCache.length > 80) _fileCache.remove(_fileCache.keys.first);
|
||||
_fileCache[id] = res.bodyBytes;
|
||||
return res.bodyBytes;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Uint8List?> getBytes(String path, {Map<String, String>? query}) async {
|
||||
final uri = _uri(path, query);
|
||||
final headers = <String, String>{
|
||||
'X-Request-Id': const Uuid().v4(),
|
||||
if (session.accessToken.isNotEmpty) 'Authorization': 'Bearer ${session.accessToken}',
|
||||
};
|
||||
try {
|
||||
var res = await http.get(uri, headers: headers).timeout(const Duration(seconds: 20));
|
||||
if (res.statusCode == 401 && session.refreshToken.isNotEmpty) {
|
||||
final ok = await _refresh();
|
||||
if (ok) {
|
||||
headers['Authorization'] = 'Bearer ${session.accessToken}';
|
||||
res = await http.get(uri, headers: headers).timeout(const Duration(seconds: 20));
|
||||
}
|
||||
}
|
||||
if (res.statusCode >= 400 || res.bodyBytes.isEmpty) return null;
|
||||
return res.bodyBytes;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> uploadFile({
|
||||
required String filePath,
|
||||
required String filename,
|
||||
required String bizType,
|
||||
required String bizId,
|
||||
String? mime,
|
||||
}) async {
|
||||
final uri = _uri('/files');
|
||||
final type = _mediaType(filename, mime);
|
||||
Future<http.Response> send() async {
|
||||
final req = http.MultipartRequest('POST', uri);
|
||||
req.headers['Authorization'] = 'Bearer ${session.accessToken}';
|
||||
req.headers['X-Request-Id'] = const Uuid().v4();
|
||||
req.fields['bizType'] = bizType;
|
||||
req.fields['bizId'] = bizId;
|
||||
req.files.add(await http.MultipartFile.fromPath('file', filePath, filename: filename, contentType: type));
|
||||
return http.Response.fromStream(await req.send().timeout(const Duration(seconds: 60)));
|
||||
}
|
||||
|
||||
var res = await send();
|
||||
if (res.statusCode == 401 && session.refreshToken.isNotEmpty) {
|
||||
final ok = await _refresh();
|
||||
if (ok) res = await send();
|
||||
}
|
||||
if ((res.statusCode == 502 || res.statusCode == 503) && session.accessToken.isNotEmpty) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 700));
|
||||
res = await send();
|
||||
}
|
||||
dynamic json;
|
||||
try {
|
||||
json = res.body.isEmpty ? null : jsonDecode(res.body);
|
||||
} catch (_) {
|
||||
throw ApiException(res.statusCode >= 400 ? '上传失败 ${res.statusCode}' : '响应无法解析');
|
||||
}
|
||||
if (json is Map && json['code'] != null && json['code'] != 0) {
|
||||
throw ApiException('${json['message'] ?? '上传失败'}');
|
||||
}
|
||||
if (res.statusCode >= 400) {
|
||||
final msg = json is Map ? (json['message'] ?? json['error'] ?? res.statusCode) : res.statusCode;
|
||||
throw ApiException('$msg');
|
||||
}
|
||||
final data = json is Map && json.containsKey('data') ? json['data'] : json;
|
||||
return data is Map ? Map<String, dynamic>.from(data) : {'id': '$data'};
|
||||
}
|
||||
|
||||
Future<dynamic> _send(
|
||||
String method,
|
||||
String path, {
|
||||
Map<String, String>? query,
|
||||
Map<String, dynamic>? body,
|
||||
bool retry = true,
|
||||
}) async {
|
||||
final uri = _uri(path, query);
|
||||
final headers = _headers(
|
||||
auth: !path.startsWith('/auth/login') &&
|
||||
!path.startsWith('/auth/sms-code') &&
|
||||
path != '/auth/login-options' &&
|
||||
path != '/system/app-release' &&
|
||||
!path.startsWith('/legal'),
|
||||
);
|
||||
http.Response res;
|
||||
try {
|
||||
if (method == 'GET') {
|
||||
res = await http.get(uri, headers: headers).timeout(const Duration(seconds: 30));
|
||||
} else if (method == 'PATCH') {
|
||||
res = await http.patch(uri, headers: headers, body: jsonEncode(body ?? {})).timeout(const Duration(seconds: 30));
|
||||
} else {
|
||||
res = await http.post(uri, headers: headers, body: jsonEncode(body ?? {})).timeout(const Duration(seconds: 30));
|
||||
}
|
||||
} catch (e) {
|
||||
throw ApiException('网络不可用:$e');
|
||||
}
|
||||
if ((res.statusCode == 502 || res.statusCode == 503) && retry) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 700));
|
||||
return _send(method, path, query: query, body: body, retry: false);
|
||||
}
|
||||
if (res.statusCode == 401 && retry && session.refreshToken.isNotEmpty && !path.contains('/auth/refresh')) {
|
||||
final ok = await _refresh();
|
||||
if (ok) return _send(method, path, query: query, body: body, retry: false);
|
||||
await session.clear();
|
||||
throw ApiException('登录已过期,请重新登录');
|
||||
}
|
||||
dynamic json;
|
||||
try {
|
||||
json = res.body.isEmpty ? null : jsonDecode(res.body);
|
||||
} catch (_) {
|
||||
throw ApiException(res.statusCode >= 400 ? '请求失败 ${res.statusCode}' : '响应无法解析');
|
||||
}
|
||||
if (json is Map && json['code'] != null && json['code'] != 0) {
|
||||
throw ApiException('${json['message'] ?? '请求失败'}');
|
||||
}
|
||||
if (res.statusCode >= 400) {
|
||||
final msg = json is Map ? (json['message'] ?? json['error'] ?? res.statusCode) : res.statusCode;
|
||||
throw ApiException('$msg');
|
||||
}
|
||||
if (json is Map && json.containsKey('data')) return json['data'];
|
||||
return json;
|
||||
}
|
||||
|
||||
Future<bool> _refresh() async {
|
||||
if (_refreshing) return false;
|
||||
_refreshing = true;
|
||||
try {
|
||||
final uri = _uri('/auth/refresh');
|
||||
final res = await http.post(
|
||||
uri,
|
||||
headers: _headers(auth: false),
|
||||
body: jsonEncode({
|
||||
'refreshToken': session.refreshToken,
|
||||
'clientKind': 'mobile',
|
||||
}),
|
||||
);
|
||||
if (res.statusCode >= 400) return false;
|
||||
final json = jsonDecode(res.body);
|
||||
final data = json is Map && json['data'] is Map ? json['data'] as Map : json;
|
||||
final access = '${data['accessToken'] ?? ''}';
|
||||
final refresh = '${data['refreshToken'] ?? ''}';
|
||||
if (access.isEmpty) return false;
|
||||
await session.setTokens(access, refresh.isEmpty ? session.refreshToken : refresh);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
} finally {
|
||||
_refreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
MediaType _mediaType(String filename, String? mime) {
|
||||
var raw = (mime ?? '').split(';').first.trim().toLowerCase();
|
||||
if (raw == 'image/jpg' || raw == 'image/pjpeg') raw = 'image/jpeg';
|
||||
if (raw.isEmpty || raw == 'application/octet-stream') {
|
||||
final n = filename.toLowerCase();
|
||||
if (n.endsWith('.jpg') || n.endsWith('.jpeg')) {
|
||||
raw = 'image/jpeg';
|
||||
} else if (n.endsWith('.png')) {
|
||||
raw = 'image/png';
|
||||
} else if (n.endsWith('.gif')) {
|
||||
raw = 'image/gif';
|
||||
} else if (n.endsWith('.webp')) {
|
||||
raw = 'image/webp';
|
||||
} else if (n.endsWith('.m4a') || n.endsWith('.mp4')) {
|
||||
raw = 'audio/mp4';
|
||||
} else if (n.endsWith('.pdf')) {
|
||||
raw = 'application/pdf';
|
||||
} else {
|
||||
raw = 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
try {
|
||||
return MediaType.parse(raw);
|
||||
} catch (_) {
|
||||
return MediaType('application', 'octet-stream');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> asMaps(dynamic data) {
|
||||
final list = extractList(data);
|
||||
return list.whereType<Map>().map((e) => Map<String, dynamic>.from(e)).toList();
|
||||
}
|
||||
|
||||
List<dynamic> extractList(dynamic data) {
|
||||
if (data is List) return data;
|
||||
if (data is Map) {
|
||||
for (final k in ['items', 'list', 'rows', 'records', 'data']) {
|
||||
if (data[k] is List) return data[k] as List;
|
||||
}
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
String scalarText(dynamic v) {
|
||||
if (v == null) return '';
|
||||
if (v is Map) {
|
||||
return '${v['displayName'] ?? v['name'] ?? v['title'] ?? v['departmentName'] ?? ''}'.trim();
|
||||
}
|
||||
if (v is List) {
|
||||
return v.map(scalarText).where((e) => e.isNotEmpty).join('、');
|
||||
}
|
||||
final s = '$v'.trim();
|
||||
if (s.startsWith('{') && (s.contains('id:') || s.contains('tenantId'))) return '';
|
||||
return s;
|
||||
}
|
||||
|
||||
String pickTitle(Map<String, dynamic> row) {
|
||||
for (final k in [
|
||||
'title',
|
||||
'name',
|
||||
'displayName',
|
||||
'projectName',
|
||||
'bidNo',
|
||||
'contractNo',
|
||||
'claimNo',
|
||||
'loanNo',
|
||||
'projectNo',
|
||||
'purpose',
|
||||
'reason',
|
||||
'label',
|
||||
]) {
|
||||
final s = scalarText(row[k]);
|
||||
if (s.isNotEmpty) return s;
|
||||
}
|
||||
return row['id']?.toString() ?? '未命名';
|
||||
}
|
||||
|
||||
String pickSub(Map<String, dynamic> row) {
|
||||
for (final k in [
|
||||
'departmentName',
|
||||
'department',
|
||||
'status',
|
||||
'title',
|
||||
'bidNo',
|
||||
'contractNo',
|
||||
'claimNo',
|
||||
'kind',
|
||||
'mobile',
|
||||
'lastText',
|
||||
'employmentStatus',
|
||||
]) {
|
||||
final s = scalarText(row[k]);
|
||||
if (s.isNotEmpty) return s;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
String fmtTime(dynamic v) {
|
||||
if (v == null) return '';
|
||||
final s = '$v';
|
||||
final t = DateTime.tryParse(s);
|
||||
if (t != null) {
|
||||
final local = t.toLocal();
|
||||
final date = '${local.year.toString().padLeft(4, '0')}-${local.month.toString().padLeft(2, '0')}-${local.day.toString().padLeft(2, '0')}';
|
||||
final time = '${local.hour.toString().padLeft(2, '0')}:${local.minute.toString().padLeft(2, '0')}:${local.second.toString().padLeft(2, '0')}';
|
||||
return '$date $time';
|
||||
}
|
||||
if (s.length >= 16) return s.substring(0, 16).replaceFirst('T', ' ');
|
||||
return s;
|
||||
}
|
||||
|
||||
String shortTime(dynamic v) {
|
||||
if (v == null) return '';
|
||||
final t = DateTime.tryParse('$v');
|
||||
if (t == null) return fmtTime(v);
|
||||
final now = DateTime.now();
|
||||
final local = t.toLocal();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final day = DateTime(local.year, local.month, local.day);
|
||||
final hh = local.hour.toString().padLeft(2, '0');
|
||||
final mm = local.minute.toString().padLeft(2, '0');
|
||||
if (day == today) return '$hh:$mm';
|
||||
if (day == today.subtract(const Duration(days: 1))) return '昨天';
|
||||
const week = ['一', '二', '三', '四', '五', '六', '日'];
|
||||
if (today.difference(day).inDays < 7) return '周${week[local.weekday - 1]}';
|
||||
return '${local.month}/${local.day}';
|
||||
}
|
||||
|
||||
const defaultApiHint = AppConfig.defaultApiBase;
|
||||
@@ -0,0 +1,13 @@
|
||||
class AppConfig {
|
||||
static const brand = '风影智慧办公综合平台';
|
||||
static const shortName = '风影办公';
|
||||
static const company = '江苏风影随行科技有限公司';
|
||||
static const icp = '苏ICP备2026017371号-2';
|
||||
static const icpUrl = 'https://beian.miit.gov.cn/';
|
||||
static const phone = '13142801138';
|
||||
static const defaultApiBase = 'https://oa.fysxkj.com/api/v1';
|
||||
static const defaultImHost = '47.96.23.244';
|
||||
static const defaultImPort = 8901;
|
||||
static const version = '3.1.10';
|
||||
static const build = 324;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import 'app_config.dart';
|
||||
|
||||
/// 运行时从安装包读取的真实版本,避免 OTA 误判。
|
||||
class AppRuntime {
|
||||
AppRuntime._();
|
||||
|
||||
static String version = AppConfig.version;
|
||||
static int build = AppConfig.build;
|
||||
|
||||
static Future<void> load() async {
|
||||
try {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
version = info.version.isNotEmpty ? info.version : AppConfig.version;
|
||||
build = int.tryParse(info.buildNumber) ?? AppConfig.build;
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import 'dart:io';
|
||||
|
||||
/// Windows / Linux 桌面端,与安卓 UI 完全分离。
|
||||
bool get isDesktopPlatform => Platform.isWindows || Platform.isLinux;
|
||||
@@ -0,0 +1,369 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../device/push_bridge.dart';
|
||||
import '../nav/notice_route.dart';
|
||||
import '../ota/updater.dart';
|
||||
import '../pages/call_page.dart';
|
||||
import '../session/session.dart';
|
||||
import '../im/im_lifecycle.dart';
|
||||
import 'desk_platform.dart';
|
||||
import 'desk_theme.dart';
|
||||
import 'pages/desk_chat_page.dart';
|
||||
import 'pages/desk_contacts_page.dart';
|
||||
import 'pages/desk_conv_list_page.dart';
|
||||
import 'pages/desk_profile_page.dart';
|
||||
import 'pages/desk_system_notice_page.dart';
|
||||
import 'pages/desk_workbench_page.dart';
|
||||
import 'widgets/desk_widgets.dart';
|
||||
|
||||
/// 桌面端主壳:仅引用 lib/desktop/*,不加载任何移动端页面组件。
|
||||
class DeskShell extends StatefulWidget {
|
||||
const DeskShell({super.key, required this.session, required this.api});
|
||||
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
|
||||
@override
|
||||
State<DeskShell> createState() => _DeskShellState();
|
||||
}
|
||||
|
||||
class _DeskShellState extends State<DeskShell> {
|
||||
int _nav = 0;
|
||||
int _msgBadge = 0;
|
||||
Timer? _badgeDebounce;
|
||||
Timer? _callPoll;
|
||||
String _ringingId = '';
|
||||
bool _checkingCall = false;
|
||||
final _declinedCalls = <String>{};
|
||||
ImLifecycle? _imLife;
|
||||
|
||||
String? _chatConvId;
|
||||
String? _chatPeerId;
|
||||
String _chatPeerName = '';
|
||||
bool _chatGroup = false;
|
||||
String _chatAvatar = '';
|
||||
|
||||
final List<_DeskTab> _tabs = [_DeskTab.home()];
|
||||
int _activeTab = 0;
|
||||
bool _otaOnce = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_imLife = ImLifecycle(widget.session)..start();
|
||||
widget.session.im.onLoggedIn = () => _refreshBadge();
|
||||
widget.session.im.addListener(_onIm);
|
||||
_refreshBadge();
|
||||
PushBridge.listen(_openFromPush);
|
||||
_callPoll = Timer.periodic(const Duration(seconds: 8), (_) => _checkIncoming());
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _checkOta());
|
||||
}
|
||||
|
||||
Future<void> _checkOta() async {
|
||||
if (_otaOnce) return;
|
||||
_otaOnce = true;
|
||||
final rel = await OtaUpdater(widget.api).fetch();
|
||||
if (rel != null && rel.newer && mounted) {
|
||||
await OtaUpdater(widget.api).prompt(context, rel);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_badgeDebounce?.cancel();
|
||||
_callPoll?.cancel();
|
||||
widget.session.im.onLoggedIn = null;
|
||||
_imLife?.stop();
|
||||
widget.session.im.removeListener(_onIm);
|
||||
PushBridge.stop();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onIm() {
|
||||
final inbox = widget.session.im.inbox;
|
||||
if (inbox.isNotEmpty && inbox.first.kind == 'readSync') {
|
||||
_refreshBadge();
|
||||
return;
|
||||
}
|
||||
_badgeDebounce?.cancel();
|
||||
_badgeDebounce = Timer(const Duration(milliseconds: 1200), _refreshBadge);
|
||||
final hit = widget.session.im.inbox.where((e) => e.kind == 'call').toList();
|
||||
if (hit.isNotEmpty) _checkIncoming();
|
||||
}
|
||||
|
||||
Future<void> _refreshBadge() async {
|
||||
var n = 0;
|
||||
try {
|
||||
final data = await widget.api.get('/im/conversations');
|
||||
for (final r in asMaps(data)) {
|
||||
n += (r['unread'] as num?)?.toInt() ?? 0;
|
||||
}
|
||||
} catch (_) {}
|
||||
if (mounted && n != _msgBadge) setState(() => _msgBadge = n);
|
||||
}
|
||||
|
||||
Future<void> _checkIncoming() async {
|
||||
if (!mounted || _ringingId.isNotEmpty || _checkingCall) return;
|
||||
_checkingCall = true;
|
||||
try {
|
||||
final rows = asMaps(await widget.api.get('/im/calls/incoming'));
|
||||
final me = widget.session.userId;
|
||||
Map<String, dynamic>? ring;
|
||||
for (final r in rows) {
|
||||
if ('${r['initiatorId']}' != me && '${r['status']}' == 'ringing') {
|
||||
ring = r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ring == null) return;
|
||||
final id = '${ring['id'] ?? ''}';
|
||||
if (id.isEmpty || id == _ringingId || _declinedCalls.contains(id)) return;
|
||||
final created = DateTime.tryParse('${ring['createdAt'] ?? ''}');
|
||||
if (created != null && DateTime.now().difference(created).inSeconds > 45) {
|
||||
_declinedCalls.add(id);
|
||||
return;
|
||||
}
|
||||
await _showIncoming(ring);
|
||||
} catch (_) {
|
||||
} finally {
|
||||
_checkingCall = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showIncoming(Map<String, dynamic> call, {String fallbackName = '同事'}) async {
|
||||
final id = '${call['id'] ?? ''}';
|
||||
if (!mounted || id.isEmpty || _ringingId.isNotEmpty || _declinedCalls.contains(id)) return;
|
||||
_ringingId = id;
|
||||
await openCallPage(
|
||||
context,
|
||||
session: widget.session,
|
||||
api: widget.api,
|
||||
call: call,
|
||||
peerName: '${call['fromName'] ?? call['title'] ?? fallbackName}',
|
||||
incoming: true,
|
||||
);
|
||||
_declinedCalls.add(id);
|
||||
if (_declinedCalls.length > 40) _declinedCalls.remove(_declinedCalls.first);
|
||||
if (mounted) _ringingId = '';
|
||||
}
|
||||
|
||||
String _openedPush = '';
|
||||
|
||||
void _openFromPush(Map<String, String> extras) {
|
||||
if (extras['kind'] == 'qr-login') {
|
||||
// 桌面端已登录,扫码登录是手机扫电脑二维码,不应在桌面打开扫码页
|
||||
return;
|
||||
}
|
||||
if (extras['kind'] == 'call') {
|
||||
unawaited(_openIncomingCallFromPush(extras));
|
||||
return;
|
||||
}
|
||||
final cid = extras['conversationId'] ?? '';
|
||||
final kind = extras['kind'] ?? '';
|
||||
final key = '$cid|$kind|${extras['callId'] ?? extras['bizId'] ?? ''}';
|
||||
if (key == _openedPush) return;
|
||||
_openedPush = key;
|
||||
if (kind == 'todo' || kind == 'approval' || ((extras['bizType'] ?? '').isNotEmpty && kind != 'chat')) {
|
||||
openNoticeTarget(
|
||||
context,
|
||||
widget.api,
|
||||
session: widget.session,
|
||||
kind: kind,
|
||||
bizType: extras['bizType'] ?? '',
|
||||
bizId: extras['bizId'] ?? '',
|
||||
title: extras['title'] ?? '系统通知',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (cid.isEmpty) {
|
||||
setState(() => _nav = 0);
|
||||
return;
|
||||
}
|
||||
_pickChat({
|
||||
'id': cid,
|
||||
'peerId': extras['peerId'] ?? '',
|
||||
'name': extras['fromName'] ?? extras['title'] ?? '聊天',
|
||||
'type': extras['isGroup'] == '1' ? 'group' : 'direct',
|
||||
'avatarFileId': extras['avatarFileId'] ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _openIncomingCallFromPush(Map<String, String> extras) async {
|
||||
final callId = (extras['callId'] ?? '').isNotEmpty ? extras['callId']! : (extras['bizId'] ?? '');
|
||||
try {
|
||||
Map<String, dynamic>? call;
|
||||
if (callId.isNotEmpty) {
|
||||
final raw = await widget.api.get('/im/calls/$callId');
|
||||
if (raw is Map) {
|
||||
final row = Map<String, dynamic>.from(raw);
|
||||
if ('${row['status'] ?? ''}' == 'ringing' && '${row['memberStatus'] ?? ''}' == 'invited') call = row;
|
||||
}
|
||||
}
|
||||
if (call != null && mounted) {
|
||||
call['fromName'] ??= extras['fromName'];
|
||||
await _showIncoming(call, fallbackName: extras['fromName'] ?? '同事');
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
final cid = extras['conversationId'] ?? '';
|
||||
if (mounted && cid.isNotEmpty) {
|
||||
_pickChat({
|
||||
'id': cid,
|
||||
'peerId': extras['peerId'] ?? '',
|
||||
'name': extras['fromName'] ?? extras['title'] ?? '聊天',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _pickChat(Map<String, dynamic> row) {
|
||||
setState(() {
|
||||
_nav = 0;
|
||||
_chatConvId = '${row['id'] ?? row['conversationId'] ?? ''}';
|
||||
_chatPeerId = '${row['peerId'] ?? row['id'] ?? ''}';
|
||||
_chatPeerName = '${row['name'] ?? row['peerName'] ?? '同事'}';
|
||||
_chatGroup = row['type'] == 'group';
|
||||
_chatAvatar = '${row['avatarFileId'] ?? ''}';
|
||||
});
|
||||
}
|
||||
|
||||
void _openTab(String key, String title, IconData icon, Color color, Widget page) {
|
||||
final idx = _tabs.indexWhere((t) => t.key == key);
|
||||
setState(() {
|
||||
_nav = 1;
|
||||
if (idx >= 0) {
|
||||
_activeTab = idx;
|
||||
} else {
|
||||
_tabs.add(_DeskTab(key: key, title: title, icon: icon, color: color, page: page));
|
||||
_activeTab = _tabs.length - 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _closeTab(int i) {
|
||||
if (i <= 0 || i >= _tabs.length) return;
|
||||
setState(() {
|
||||
_tabs.removeAt(i);
|
||||
if (_activeTab >= _tabs.length) _activeTab = _tabs.length - 1;
|
||||
});
|
||||
}
|
||||
|
||||
Widget _messagesPane() {
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: kDeskConvWidth,
|
||||
child: DeskConvListPage(
|
||||
session: widget.session,
|
||||
api: widget.api,
|
||||
selectedId: _chatConvId,
|
||||
onSelect: _pickChat,
|
||||
),
|
||||
),
|
||||
const VerticalDivider(width: 1, color: kDeskLine),
|
||||
Expanded(
|
||||
child: _chatConvId == null || _chatConvId!.isEmpty
|
||||
? const DeskEmptyChat()
|
||||
: _chatPeerId == '0'
|
||||
? DeskSystemNoticePage(session: widget.session, api: widget.api, conversationId: _chatConvId!)
|
||||
: DeskChatPage(
|
||||
session: widget.session,
|
||||
api: widget.api,
|
||||
conversationId: _chatConvId!,
|
||||
peerId: _chatPeerId ?? '',
|
||||
peerName: _chatPeerName,
|
||||
isGroup: _chatGroup,
|
||||
peerAvatarFileId: _chatAvatar,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _workbenchPane() {
|
||||
return Column(
|
||||
children: [
|
||||
DeskTabStrip(
|
||||
tabs: [for (final t in _tabs) (t.key, t.title, t.icon, t.color)],
|
||||
active: _activeTab,
|
||||
onSelect: (i) => setState(() => _activeTab = i),
|
||||
onClose: _closeTab,
|
||||
),
|
||||
Expanded(
|
||||
child: IndexedStack(
|
||||
index: _activeTab,
|
||||
children: [
|
||||
for (final t in _tabs)
|
||||
t.key == 'home'
|
||||
? DeskWorkbenchPage(session: widget.session, api: widget.api, onOpenTab: _openTab)
|
||||
: t.page,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final navItems = <(IconData, IconData, String, int)>[
|
||||
(Icons.chat_bubble_outline, Icons.chat_bubble, '消息', _msgBadge),
|
||||
(Icons.apps_outlined, Icons.apps, '工作台', 0),
|
||||
(Icons.account_tree_outlined, Icons.account_tree, '通讯录', 0),
|
||||
(Icons.person_outline, Icons.person, '我', 0),
|
||||
];
|
||||
|
||||
Widget body;
|
||||
switch (_nav) {
|
||||
case 0:
|
||||
body = _messagesPane();
|
||||
case 1:
|
||||
body = _workbenchPane();
|
||||
case 2:
|
||||
body = DeskContactsPage(session: widget.session, api: widget.api, onMessage: (r) {
|
||||
_pickChat({
|
||||
'id': '${r['conversationId'] ?? ''}',
|
||||
'peerId': '${r['id'] ?? r['peerId'] ?? ''}',
|
||||
'name': '${r['name'] ?? '同事'}',
|
||||
'avatarFileId': '${r['avatarFileId'] ?? ''}',
|
||||
'type': 'direct',
|
||||
});
|
||||
setState(() => _nav = 0);
|
||||
});
|
||||
default:
|
||||
body = DeskProfilePage(session: widget.session, api: widget.api);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: kDeskBg,
|
||||
body: Row(
|
||||
children: [
|
||||
DeskNavRail(
|
||||
session: widget.session,
|
||||
api: widget.api,
|
||||
index: _nav,
|
||||
onChanged: (i) => setState(() => _nav = i),
|
||||
items: navItems,
|
||||
),
|
||||
const VerticalDivider(width: 1, color: kDeskLine),
|
||||
Expanded(child: body),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeskTab {
|
||||
_DeskTab({required this.key, required this.title, required this.icon, required this.color, required this.page});
|
||||
factory _DeskTab.home() => _DeskTab(key: 'home', title: '工作台', icon: Icons.apps, color: kDeskBind, page: const SizedBox.shrink());
|
||||
|
||||
final String key;
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final Widget page;
|
||||
}
|
||||
|
||||
bool get isDesktopShell => isDesktopPlatform;
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 企业微信桌面端配色与尺寸,不依赖移动端 theme。
|
||||
const kDeskBg = Color(0xFFEDEDED);
|
||||
const kDeskRail = Color(0xFFE7E7E7);
|
||||
const kDeskPane = Colors.white;
|
||||
const kDeskListBg = Color(0xFFF7F7F7);
|
||||
const kDeskActive = Color(0xFFDCEBFF);
|
||||
const kDeskLine = Color(0xFFE3E3E3);
|
||||
const kDeskInk = Color(0xFF191919);
|
||||
const kDeskMute = Color(0xFF8E8E8E);
|
||||
const kDeskBind = Color(0xFF267EF0);
|
||||
const kDeskGreen = Color(0xFF07C160);
|
||||
const kDeskBubbleMine = Color(0xFF95EC69);
|
||||
|
||||
const kDeskRailWidth = 68.0;
|
||||
const kDeskConvWidth = 280.0;
|
||||
|
||||
ThemeData buildDesktopTheme() {
|
||||
return ThemeData(
|
||||
useMaterial3: false,
|
||||
scaffoldBackgroundColor: kDeskBg,
|
||||
fontFamily: 'sans-serif',
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: kDeskBind, brightness: Brightness.light),
|
||||
dividerColor: kDeskLine,
|
||||
textTheme: const TextTheme(
|
||||
bodyMedium: TextStyle(fontSize: 14, color: kDeskInk, height: 1.4),
|
||||
titleMedium: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: kDeskInk),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: const Color(0xFFF3F3F3),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(6), borderSide: BorderSide.none),
|
||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(6), borderSide: BorderSide.none),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: const BorderSide(color: kDeskBind, width: 1),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
hintStyle: const TextStyle(color: Color(0xFFB2B2B2), fontSize: 14),
|
||||
),
|
||||
dataTableTheme: const DataTableThemeData(
|
||||
headingTextStyle: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: kDeskMute),
|
||||
dataTextStyle: TextStyle(fontSize: 14, color: kDeskInk),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../api/oa_client.dart';
|
||||
import '../../device/device_bridge.dart';
|
||||
import '../desk_theme.dart';
|
||||
import '../widgets/desk_widgets.dart';
|
||||
|
||||
class DeskAttendancePage extends StatefulWidget {
|
||||
const DeskAttendancePage({super.key, required this.api});
|
||||
final OaClient api;
|
||||
|
||||
@override
|
||||
State<DeskAttendancePage> createState() => _DeskAttendancePageState();
|
||||
}
|
||||
|
||||
class _DeskAttendancePageState extends State<DeskAttendancePage> {
|
||||
Map<String, dynamic> _status = {};
|
||||
Map<String, double>? _location;
|
||||
bool _loading = true;
|
||||
bool _sending = false;
|
||||
String _mode = 'OFFICE';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
_location = await DeviceBridge.getLocation();
|
||||
final q = <String, String>{
|
||||
if (_location != null) 'lat': '${_location!['lat']}',
|
||||
if (_location != null) 'lng': '${_location!['lng']}',
|
||||
'mode': _mode,
|
||||
};
|
||||
final data = Map<String, dynamic>.from(await widget.api.get('/attendance/punch-status', query: q) as Map);
|
||||
if (mounted) setState(() {
|
||||
_status = data;
|
||||
_loading = false;
|
||||
});
|
||||
if (mounted && _location == null && _mode == 'OFFICE') {
|
||||
deskToast(context, '未获取到定位,请允许位置权限后点刷新', error: true);
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _punch(String kind) async {
|
||||
if (_mode == 'OFFICE' && _status['tripActive'] == true) return;
|
||||
if (_mode == 'FIELD' && _status['fieldApproved'] != true) return;
|
||||
setState(() => _sending = true);
|
||||
try {
|
||||
await widget.api.post('/attendance/punch', {'kind': kind, 'mode': _mode, if (_location != null) ..._location!});
|
||||
if (mounted) deskToast(context, '打卡成功');
|
||||
await _load();
|
||||
} catch (e) {
|
||||
if (mounted) deskToast(context, '$e', error: true);
|
||||
} finally {
|
||||
if (mounted) setState(() => _sending = false);
|
||||
}
|
||||
}
|
||||
|
||||
String _time(dynamic value) {
|
||||
if (value == null || '$value' == 'null' || '$value'.isEmpty) return '未打卡';
|
||||
final d = DateTime.tryParse('$value')?.toLocal();
|
||||
return d == null ? '$value' : '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final inKey = _mode == 'FIELD' ? 'fieldIn' : 'clockIn';
|
||||
final outKey = _mode == 'FIELD' ? 'fieldOut' : 'clockOut';
|
||||
final inAt = _time(_status[inKey]);
|
||||
final outAt = _time(_status[outKey]);
|
||||
final hasIn = inAt != '未打卡';
|
||||
final hasOut = outAt != '未打卡';
|
||||
final trip = _status['tripActive'] == true;
|
||||
final fieldOk = _status['fieldApproved'] == true;
|
||||
final canPunch = _mode == 'FIELD' ? fieldOk : !trip && _status['inRange'] != false;
|
||||
|
||||
return ColoredBox(
|
||||
color: kDeskBg,
|
||||
child: Column(
|
||||
children: [
|
||||
DeskPaneHeader(
|
||||
title: '考勤打卡',
|
||||
actions: [IconButton(onPressed: _load, icon: const Icon(Icons.refresh, size: 20))],
|
||||
),
|
||||
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
SegmentedButton<String>(
|
||||
segments: const [
|
||||
ButtonSegment(value: 'OFFICE', label: Text('上下班打卡'), icon: Icon(Icons.access_time)),
|
||||
ButtonSegment(value: 'FIELD', label: Text('外勤打卡'), icon: Icon(Icons.location_on_outlined)),
|
||||
],
|
||||
selected: {_mode},
|
||||
onSelectionChanged: (v) async {
|
||||
setState(() => _mode = v.first);
|
||||
await _load();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(_today(), style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 12),
|
||||
_banner(canPunch, trip, fieldOk),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_punchBtn(
|
||||
enabled: canPunch && !hasIn && !_sending,
|
||||
label: _mode == 'FIELD' ? '外勤上班' : '上班打卡',
|
||||
onTap: () => _punch('IN'),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_punchBtn(
|
||||
enabled: canPunch && hasIn && !hasOut && !_sending,
|
||||
label: '下班打卡',
|
||||
color: kDeskGreen,
|
||||
onTap: () => _punch('OUT'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_timeBox('上班', inAt, _status['late'] == true),
|
||||
_timeBox('下班', outAt, _status['early'] == true),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.place, color: _status['inRange'] == true ? kDeskGreen : const Color(0xFFFA5151)),
|
||||
title: Text('${_status['rangeText'] ?? '正在获取考勤范围'}'),
|
||||
subtitle: Text(_status['distanceMeters'] == null ? '请开启定位后刷新' : '距离考勤点约 ${_status['distanceMeters']} 米'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _today() {
|
||||
final d = DateTime.now();
|
||||
return '${d.year}年${d.month}月${d.day}日';
|
||||
}
|
||||
|
||||
Widget _banner(bool canPunch, bool trip, bool fieldOk) {
|
||||
final text = _mode == 'FIELD'
|
||||
? (fieldOk ? '外出申请已通过,可进行外勤打卡' : '需先申请并审批通过外出')
|
||||
: (trip ? '出差期间无需打卡' : (canPunch ? '当前可进行上下班打卡' : '未进入考勤范围'));
|
||||
final color = trip || fieldOk || canPunch ? kDeskGreen : const Color(0xFFFA9D3B);
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(color: color.withValues(alpha: .1), borderRadius: BorderRadius.circular(8)),
|
||||
child: Text(text, textAlign: TextAlign.center, style: TextStyle(color: color, fontWeight: FontWeight.w600, fontSize: 13)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _punchBtn({required bool enabled, required String label, required VoidCallback onTap, Color? color}) {
|
||||
final c = color ?? kDeskBind;
|
||||
return SizedBox(
|
||||
width: 140,
|
||||
height: 140,
|
||||
child: Material(
|
||||
color: enabled ? c : const Color(0xFFE8E8E8),
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
onTap: enabled ? onTap : null,
|
||||
customBorder: const CircleBorder(),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(label, style: TextStyle(color: enabled ? Colors.white : kDeskMute, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 4),
|
||||
Text(_nowTime(), style: TextStyle(color: enabled ? Colors.white : kDeskMute, fontSize: 18, fontWeight: FontWeight.w700)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _timeBox(String label, String value, bool flag) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(label, style: const TextStyle(color: kDeskMute, fontSize: 12)),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(value, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
|
||||
if (flag) const Padding(padding: EdgeInsets.only(left: 4), child: Icon(Icons.error, color: Color(0xFFFA9D3B), size: 16)),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _nowTime() {
|
||||
final d = DateTime.now();
|
||||
return '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../api/oa_client.dart';
|
||||
import '../../im/chat_prefs.dart';
|
||||
import '../../session/session.dart';
|
||||
import '../desk_theme.dart';
|
||||
import '../widgets/desk_widgets.dart';
|
||||
import 'desk_pick_people_page.dart';
|
||||
|
||||
class DeskChatDetailPage extends StatefulWidget {
|
||||
const DeskChatDetailPage({
|
||||
super.key,
|
||||
required this.session,
|
||||
required this.api,
|
||||
required this.conversationId,
|
||||
required this.peerId,
|
||||
required this.peerName,
|
||||
required this.isGroup,
|
||||
this.peerAvatarFileId,
|
||||
this.onCall,
|
||||
});
|
||||
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
final String conversationId;
|
||||
final String peerId;
|
||||
final String peerName;
|
||||
final bool isGroup;
|
||||
final String? peerAvatarFileId;
|
||||
final void Function(String kind)? onCall;
|
||||
|
||||
@override
|
||||
State<DeskChatDetailPage> createState() => _DeskChatDetailPageState();
|
||||
}
|
||||
|
||||
class _DeskChatDetailPageState extends State<DeskChatDetailPage> {
|
||||
List<Map<String, dynamic>> _members = [];
|
||||
bool _mute = false;
|
||||
bool _pin = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mute = ChatPrefs.muted(widget.conversationId);
|
||||
_pin = ChatPrefs.pinned(widget.conversationId);
|
||||
_loadMembers();
|
||||
}
|
||||
|
||||
Future<void> _loadMembers() async {
|
||||
if (widget.isGroup && widget.conversationId.isNotEmpty) {
|
||||
try {
|
||||
final data = await widget.api.get('/im/groups/${widget.conversationId}/members');
|
||||
if (!mounted) return;
|
||||
setState(() => _members = asMaps(data));
|
||||
return;
|
||||
} catch (_) {}
|
||||
}
|
||||
setState(() {
|
||||
_members = [
|
||||
{'id': widget.peerId, 'name': widget.peerName, 'avatarFileId': widget.peerAvatarFileId},
|
||||
{'id': widget.session.userId, 'name': widget.session.displayName, 'avatarFileId': widget.session.user['avatarFileId']},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _addMembers() async {
|
||||
final exclude = {widget.session.userId, ..._members.map((e) => '${e['id'] ?? e['userId'] ?? ''}')};
|
||||
final picked = await Navigator.of(context).push<List<Map<String, dynamic>>>(
|
||||
MaterialPageRoute(builder: (_) => DeskPickPeoplePage(api: widget.api, title: '选择同事', exclude: exclude)),
|
||||
);
|
||||
if (picked == null || picked.isEmpty) return;
|
||||
try {
|
||||
if (widget.isGroup && widget.conversationId.isNotEmpty) {
|
||||
await widget.api.post('/im/groups/${widget.conversationId}/members', {
|
||||
'memberIds': picked.map((e) => '${e['id']}').toList(),
|
||||
});
|
||||
await _loadMembers();
|
||||
} else {
|
||||
await widget.api.post('/im/groups', {
|
||||
'name': '${widget.peerName}的群聊',
|
||||
'memberIds': [widget.peerId, ...picked.map((e) => '${e['id']}')],
|
||||
});
|
||||
if (mounted) {
|
||||
deskToast(context, '已创建群聊');
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) deskToast(context, '$e', error: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clear() async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('删除聊天记录'),
|
||||
content: const Text('将从本机清空当前会话的聊天记录,对方不受影响。'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('删除')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true) return;
|
||||
await ChatPrefs.clearHistory(widget.conversationId);
|
||||
if (mounted) {
|
||||
deskToast(context, '已删除本机聊天记录');
|
||||
Navigator.pop(context, 'cleared');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
color: kDeskBg,
|
||||
child: Column(
|
||||
children: [
|
||||
DeskPaneHeader(
|
||||
title: '聊天详情',
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back, size: 20), onPressed: () => Navigator.pop(context)),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
for (final m in _members)
|
||||
SizedBox(
|
||||
width: 56,
|
||||
child: Column(
|
||||
children: [
|
||||
DeskAvatar(
|
||||
label: '${m['name'] ?? m['displayName'] ?? ''}',
|
||||
fileId: '${m['avatarFileId'] ?? ''}',
|
||||
api: widget.api,
|
||||
size: 44,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text('${m['name'] ?? m['displayName'] ?? ''}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11)),
|
||||
],
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: _addMembers,
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(6), border: Border.all(color: kDeskLine)),
|
||||
child: const Icon(Icons.add, color: kDeskMute),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_section([
|
||||
_switchRow('消息免打扰', _mute, (v) async {
|
||||
await ChatPrefs.setMuted(widget.conversationId, v);
|
||||
setState(() => _mute = v);
|
||||
}),
|
||||
_switchRow('置顶聊天', _pin, (v) async {
|
||||
await ChatPrefs.setPinned(widget.conversationId, v);
|
||||
setState(() => _pin = v);
|
||||
}),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
_section([
|
||||
_actionRow('语音通话', () => widget.onCall?.call('voice')),
|
||||
_actionRow('视频通话', () => widget.onCall?.call(widget.isGroup ? 'meeting' : 'video')),
|
||||
_actionRow('删除聊天记录', _clear, danger: true),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _section(List<Widget> children) {
|
||||
return Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(children: children),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _switchRow(String title, bool value, ValueChanged<bool> onChanged) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(title, style: const TextStyle(fontSize: 14))),
|
||||
Switch(value: value, activeTrackColor: kDeskGreen, onChanged: onChanged),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _actionRow(String title, VoidCallback onTap, {bool danger = false}) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(title, style: TextStyle(fontSize: 14, color: danger ? const Color(0xFFFA5151) : kDeskInk))),
|
||||
if (!danger) const Icon(Icons.chevron_right, size: 18, color: kDeskMute),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,279 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../api/oa_client.dart';
|
||||
import '../../im/pinyin.dart';
|
||||
import 'desk_org_browse_page.dart';
|
||||
import '../../session/session.dart';
|
||||
import '../desk_theme.dart';
|
||||
import '../widgets/desk_widgets.dart';
|
||||
|
||||
class DeskContactsPage extends StatefulWidget {
|
||||
const DeskContactsPage({super.key, required this.session, required this.api, this.onMessage});
|
||||
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
final void Function(Map<String, dynamic> person)? onMessage;
|
||||
|
||||
@override
|
||||
State<DeskContactsPage> createState() => _DeskContactsPageState();
|
||||
}
|
||||
|
||||
class _DeskContactsPageState extends State<DeskContactsPage> {
|
||||
List<Map<String, dynamic>> _items = [];
|
||||
Map<String, List<String>> _presence = {};
|
||||
String _q = '';
|
||||
bool _loading = true;
|
||||
String? _selectedId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final data = await widget.api.get('/staff');
|
||||
Map<String, List<String>> presence = {};
|
||||
try {
|
||||
final ids = asMaps(data).map((e) => '${e['id']}').where((e) => e.isNotEmpty).join(',');
|
||||
final raw = await widget.api.get('/im/presence', query: {'userIds': ids});
|
||||
if (raw is Map) {
|
||||
presence = raw.map((k, v) => MapEntry('$k', (v is List ? v : const []).map((x) => '$x').toList()));
|
||||
}
|
||||
} catch (_) {}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = asMaps(data);
|
||||
_presence = presence;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> get _shown {
|
||||
if (_q.isEmpty) return _items;
|
||||
return _items.where((e) => '${e['name']}${e['mobile']}${e['department']}'.contains(_q)).toList();
|
||||
}
|
||||
|
||||
Map<String, List<Map<String, dynamic>>> get _groups {
|
||||
final g = <String, List<Map<String, dynamic>>>{};
|
||||
for (final r in _shown) {
|
||||
final letter = letterOf('${r['name'] ?? ''}');
|
||||
g.putIfAbsent(letter, () => []).add(r);
|
||||
}
|
||||
for (final k in g.keys) {
|
||||
g[k]!.sort((a, b) => '${a['name']}'.compareTo('${b['name']}'));
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
Future<void> _message(Map<String, dynamic> r) async {
|
||||
if (widget.onMessage != null) {
|
||||
widget.onMessage!(r);
|
||||
return;
|
||||
}
|
||||
String conversationId = '';
|
||||
try {
|
||||
final raw = await widget.api.post('/im/conversations/direct', {'peerId': '${r['id']}'});
|
||||
if (raw is Map) conversationId = '${raw['conversationId'] ?? raw['id'] ?? ''}';
|
||||
} catch (_) {}
|
||||
if (!mounted) return;
|
||||
widget.onMessage?.call({
|
||||
...r,
|
||||
'conversationId': conversationId,
|
||||
'peerId': '${r['id']}',
|
||||
'name': '${r['name'] ?? '同事'}',
|
||||
'type': 'direct',
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final groups = _groups;
|
||||
final letters = groups.keys.toList()..sort();
|
||||
final depts = _items.map((e) => '${e['department'] ?? ''}').where((e) => e.isNotEmpty).toSet();
|
||||
final selected = _selectedId == null ? null : _items.cast<Map<String, dynamic>?>().firstWhere(
|
||||
(e) => '${e?['id']}' == _selectedId,
|
||||
orElse: () => null,
|
||||
);
|
||||
|
||||
return ColoredBox(
|
||||
color: kDeskBg,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 300,
|
||||
child: ColoredBox(
|
||||
color: kDeskListBg,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||
child: DeskSearchField(hint: '搜索同事', onChanged: (v) => setState(() => _q = v.trim())),
|
||||
),
|
||||
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
children: [
|
||||
if (_q.isEmpty) ...[
|
||||
_entry(Icons.account_tree, '组织架构', '${depts.length} 个部门', () {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => DeskOrgBrowsePage(
|
||||
session: widget.session,
|
||||
api: widget.api,
|
||||
staff: _items,
|
||||
onMessage: widget.onMessage,
|
||||
),
|
||||
));
|
||||
}),
|
||||
const Divider(height: 1, color: kDeskLine),
|
||||
],
|
||||
for (final letter in letters) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 14, 4),
|
||||
child: Text(letter, style: const TextStyle(fontSize: 12, color: kDeskMute, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
for (final r in groups[letter]!)
|
||||
_personTile(r),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const VerticalDivider(width: 1, color: kDeskLine),
|
||||
Expanded(
|
||||
child: selected == null
|
||||
? const Center(child: Text('选择联系人查看详情', style: TextStyle(color: kDeskMute)))
|
||||
: _detail(selected),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _entry(IconData icon, String title, String sub, VoidCallback onTap) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
DeskAvatar(label: title, api: widget.api, size: 40, color: kDeskBind, icon: icon),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
|
||||
Text(sub, style: const TextStyle(fontSize: 12, color: kDeskMute)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _personTile(Map<String, dynamic> r) {
|
||||
final id = '${r['id']}';
|
||||
final online = (_presence[id] ?? const []).contains('online');
|
||||
return Material(
|
||||
color: _selectedId == id ? kDeskActive : Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () => setState(() => _selectedId = id),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
DeskAvatar(label: '${r['name']}', fileId: '${r['avatarFileId'] ?? ''}', api: widget.api, size: 36),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${r['name']}', style: const TextStyle(fontSize: 14)),
|
||||
Text(
|
||||
[r['department'], r['title']].where((e) => e != null && '$e'.isNotEmpty).join(' · '),
|
||||
style: const TextStyle(fontSize: 12, color: kDeskMute),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: online ? kDeskGreen : const Color(0xFFD0D0D0),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _detail(Map<String, dynamic> r) {
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DeskAvatar(label: '${r['name']}', fileId: '${r['avatarFileId'] ?? ''}', api: widget.api, size: 80),
|
||||
const SizedBox(height: 16),
|
||||
Text('${r['name']}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
[r['department'], r['title']].where((e) => e != null && '$e'.isNotEmpty).join(' · '),
|
||||
style: const TextStyle(color: kDeskMute),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_infoRow('手机', '${r['mobile'] ?? '未填写'}'),
|
||||
_infoRow('邮箱', '${r['email'] ?? '未填写'}'),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: kDeskBind, minimumSize: const Size.fromHeight(40)),
|
||||
onPressed: () => _message(r),
|
||||
child: const Text('发消息'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _infoRow(String k, String v) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8), border: Border.all(color: kDeskLine)),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 56, child: Text(k, style: const TextStyle(color: kDeskMute, fontSize: 13))),
|
||||
Expanded(child: Text(v, style: const TextStyle(fontSize: 14))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../api/oa_client.dart';
|
||||
import '../../im/chat_prefs.dart';
|
||||
import 'desk_pick_people_page.dart';
|
||||
import '../../session/session.dart';
|
||||
import '../desk_theme.dart';
|
||||
import '../widgets/desk_widgets.dart';
|
||||
|
||||
class DeskConvListPage extends StatefulWidget {
|
||||
const DeskConvListPage({
|
||||
super.key,
|
||||
required this.session,
|
||||
required this.api,
|
||||
this.selectedId,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
final String? selectedId;
|
||||
final void Function(Map<String, dynamic> row) onSelect;
|
||||
|
||||
@override
|
||||
State<DeskConvListPage> createState() => _DeskConvListPageState();
|
||||
}
|
||||
|
||||
class _DeskConvListPageState extends State<DeskConvListPage> {
|
||||
List<Map<String, dynamic>> _items = [];
|
||||
bool _loading = true;
|
||||
String _filter = 'all';
|
||||
String _q = '';
|
||||
Timer? _debounce;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.session.im.addListener(_onIm);
|
||||
ChatPrefs.ensure().then((_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
widget.session.im.removeListener(_onIm);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onIm() {
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 300), () {
|
||||
if (mounted) _load();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final data = await widget.api.get('/im/conversations');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = asMaps(data);
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> get _shown {
|
||||
var list = _items.where((e) => !ChatPrefs.hidden('${e['id'] ?? ''}')).toList();
|
||||
if (_filter == 'unread') list = list.where((e) => ((e['unread'] as num?)?.toInt() ?? 0) > 0).toList();
|
||||
if (_filter == 'dm') list = list.where((e) => e['type'] != 'group').toList();
|
||||
if (_filter == 'group') list = list.where((e) => e['type'] == 'group').toList();
|
||||
if (_q.isNotEmpty) {
|
||||
list = list.where((e) => '${e['name']}${e['peerName']}${e['lastText']}'.contains(_q)).toList();
|
||||
}
|
||||
list.sort((a, b) {
|
||||
final ap = ChatPrefs.pinned('${a['id'] ?? ''}');
|
||||
final bp = ChatPrefs.pinned('${b['id'] ?? ''}');
|
||||
if (ap == bp) return 0;
|
||||
return ap ? -1 : 1;
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
Future<void> _menu(Map<String, dynamic> r, Offset pos) async {
|
||||
final id = '${r['id'] ?? ''}';
|
||||
if (id.isEmpty) return;
|
||||
final muted = ChatPrefs.muted(id);
|
||||
final pinned = ChatPrefs.pinned(id);
|
||||
final unread = (r['unread'] as num?)?.toInt() ?? 0;
|
||||
final fake = ChatPrefs.fakeUnread(id);
|
||||
final selected = await showMenu<String>(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(pos.dx, pos.dy, pos.dx + 1, pos.dy + 1),
|
||||
items: [
|
||||
if (unread == 0 && fake == 0) const PopupMenuItem(value: 'unread', child: Text('标为未读')),
|
||||
PopupMenuItem(value: 'mute', child: Text(muted ? '取消免打扰' : '消息免打扰')),
|
||||
PopupMenuItem(value: 'pin', child: Text(pinned ? '取消置顶' : '置顶')),
|
||||
const PopupMenuItem(value: 'hide', child: Text('不显示')),
|
||||
const PopupMenuDivider(),
|
||||
const PopupMenuItem(value: 'clear', child: Text('清空聊天记录')),
|
||||
const PopupMenuItem(value: 'delete', child: Text('删除')),
|
||||
],
|
||||
);
|
||||
if (!mounted || selected == null) return;
|
||||
switch (selected) {
|
||||
case 'unread':
|
||||
await ChatPrefs.setFakeUnread(id, 1);
|
||||
case 'mute':
|
||||
await ChatPrefs.setMuted(id, !muted);
|
||||
case 'pin':
|
||||
await ChatPrefs.setPinned(id, !pinned);
|
||||
case 'hide':
|
||||
case 'delete':
|
||||
await ChatPrefs.setHidden(id, true);
|
||||
case 'clear':
|
||||
await ChatPrefs.clearHistory(id);
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
await _load();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _newChat({required bool group}) async {
|
||||
final picked = await Navigator.of(context).push<List<Map<String, dynamic>>>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => DeskPickPeoplePage(
|
||||
api: widget.api,
|
||||
title: group ? '选择联系人' : '发起单聊',
|
||||
multiple: group,
|
||||
exclude: {widget.session.userId},
|
||||
),
|
||||
),
|
||||
);
|
||||
if (picked == null || picked.isEmpty) return;
|
||||
if (!group) {
|
||||
final p = picked.first;
|
||||
widget.onSelect({
|
||||
'id': '',
|
||||
'peerId': '${p['id']}',
|
||||
'name': '${p['name'] ?? '同事'}',
|
||||
'avatarFileId': '${p['avatarFileId'] ?? ''}',
|
||||
'type': 'direct',
|
||||
});
|
||||
return;
|
||||
}
|
||||
final name = TextEditingController();
|
||||
if (!mounted) return;
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('群名称'),
|
||||
content: TextField(controller: name, decoration: const InputDecoration(hintText: '例如:项目组')),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('创建')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) {
|
||||
await widget.api.post('/im/groups', {
|
||||
'name': name.text.trim().isEmpty ? '群聊' : name.text.trim(),
|
||||
'memberIds': picked.map((e) => '${e['id']}').toList(),
|
||||
});
|
||||
await _load();
|
||||
}
|
||||
}
|
||||
|
||||
void _plusMenu(BuildContext ctx) {
|
||||
final box = ctx.findRenderObject() as RenderBox?;
|
||||
final pos = box?.localToGlobal(Offset.zero) ?? Offset.zero;
|
||||
showMenu<void>(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(pos.dx, pos.dy + 32, pos.dx + 200, pos.dy),
|
||||
items: [
|
||||
PopupMenuItem(onTap: () => Future.microtask(() => _newChat(group: false)), child: const Text('发起单聊')),
|
||||
PopupMenuItem(onTap: () => Future.microtask(() => _newChat(group: true)), child: const Text('发起群聊')),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shown = _shown;
|
||||
return ColoredBox(
|
||||
color: kDeskListBg,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: DeskSearchField(hint: '搜索', onChanged: (v) => setState(() => _q = v.trim()))),
|
||||
const SizedBox(width: 8),
|
||||
Material(
|
||||
color: const Color(0xFFEFEFEF),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
final rb = context.findRenderObject() as RenderBox?;
|
||||
if (rb != null) _plusMenu(context);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: const SizedBox(width: 32, height: 32, child: Icon(Icons.add, size: 18, color: kDeskInk)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||
child: DeskFilterChips(
|
||||
value: _filter,
|
||||
onChanged: (v) => setState(() => _filter = v),
|
||||
items: const [('all', '全部'), ('unread', '未读'), ('dm', '单聊'), ('group', '群聊')],
|
||||
),
|
||||
),
|
||||
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
|
||||
Expanded(
|
||||
child: shown.isEmpty
|
||||
? const Center(child: Text('暂无会话', style: TextStyle(color: kDeskMute)))
|
||||
: ListView.builder(
|
||||
itemCount: shown.length,
|
||||
itemBuilder: (_, i) {
|
||||
final r = shown[i];
|
||||
final id = '${r['id'] ?? ''}';
|
||||
final fake = ChatPrefs.fakeUnread(id);
|
||||
final unread = fake > 0 ? fake : ((r['unread'] as num?)?.toInt() ?? 0);
|
||||
return DeskConvRow(
|
||||
name: '${r['name'] ?? r['peerName'] ?? '会话'}',
|
||||
preview: '${r['lastText'] ?? ''}',
|
||||
time: shortTime(r['lastAt'] ?? r['updatedAt']),
|
||||
unread: unread,
|
||||
selected: widget.selectedId == id,
|
||||
muted: ChatPrefs.muted(id),
|
||||
pinned: ChatPrefs.pinned(id),
|
||||
avatarId: '${r['avatarFileId'] ?? ''}',
|
||||
api: widget.api,
|
||||
onTap: () => widget.onSelect(r),
|
||||
onSecondaryTap: (d) => _menu(r, d.globalPosition),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../api/oa_client.dart';
|
||||
import '../../device/device_bridge.dart';
|
||||
import '../../labels.dart';
|
||||
import '../desk_theme.dart';
|
||||
import '../widgets/desk_data_table.dart';
|
||||
import '../widgets/desk_widgets.dart';
|
||||
import 'desk_record_detail_page.dart';
|
||||
|
||||
const _kinds = [
|
||||
('PERSONAL', '请假'),
|
||||
('OVERTIME', '加班'),
|
||||
('BUSINESS', '出差'),
|
||||
('OUT', '外出'),
|
||||
('REGULARIZE', '转正'),
|
||||
('RESIGN', '离职'),
|
||||
];
|
||||
|
||||
class DeskHrApplyPage extends StatefulWidget {
|
||||
const DeskHrApplyPage({super.key, required this.api});
|
||||
final OaClient api;
|
||||
|
||||
@override
|
||||
State<DeskHrApplyPage> createState() => _DeskHrApplyPageState();
|
||||
}
|
||||
|
||||
class _DeskHrApplyPageState extends State<DeskHrApplyPage> {
|
||||
List<Map<String, dynamic>> _items = [];
|
||||
bool _loading = true;
|
||||
String _bucket = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final q = _bucket.isEmpty ? null : {'bucket': _bucket};
|
||||
final data = await widget.api.get('/leave-requests', query: q);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = asMaps(data);
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _create() async {
|
||||
var kind = 'LEAVE';
|
||||
final reason = TextEditingController();
|
||||
final days = TextEditingController(text: '1');
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('发起人事申请'),
|
||||
content: SizedBox(
|
||||
width: 420,
|
||||
child: StatefulBuilder(
|
||||
builder: (ctx, setSt) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
for (final k in _kinds)
|
||||
FilterChip(
|
||||
label: Text(k.$2),
|
||||
selected: kind == k.$1,
|
||||
onSelected: (_) => setSt(() => kind = k.$1),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(controller: days, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: '天数')),
|
||||
const SizedBox(height: 8),
|
||||
TextField(controller: reason, maxLines: 3, decoration: const InputDecoration(labelText: '事由')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('提交')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true) return;
|
||||
try {
|
||||
final created = Map<String, dynamic>.from(await widget.api.post('/leave-requests', {
|
||||
'kind': kind,
|
||||
'reason': reason.text.trim(),
|
||||
'days': num.tryParse(days.text) ?? 1,
|
||||
}) as Map);
|
||||
final id = '${created['id'] ?? ''}';
|
||||
if (id.isNotEmpty && mounted) {
|
||||
final add = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('添加申请材料'),
|
||||
content: const Text('可以上传病假证明、行程单等附件。'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('暂不添加')),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('选择附件')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (add == true) {
|
||||
final picked = await DeviceBridge.pickFile();
|
||||
if (picked != null && picked['path'] != null) {
|
||||
await widget.api.uploadFile(
|
||||
filePath: picked['path']!,
|
||||
filename: picked['name'] ?? '申请材料',
|
||||
bizType: 'HR_LEAVE',
|
||||
bizId: id,
|
||||
mime: picked['mime'],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mounted) {
|
||||
deskToast(context, '已提交,等待审批');
|
||||
_load();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) deskToast(context, '$e', error: true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DeskListScaffold(
|
||||
title: '人事申请',
|
||||
loading: _loading,
|
||||
actions: [
|
||||
FilledButton.icon(
|
||||
style: FilledButton.styleFrom(backgroundColor: kDeskBind),
|
||||
onPressed: _create,
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text('发起申请'),
|
||||
),
|
||||
],
|
||||
filters: DeskFilterChips(
|
||||
value: _bucket,
|
||||
onChanged: (v) {
|
||||
setState(() => _bucket = v);
|
||||
_load();
|
||||
},
|
||||
items: const [('', '全部'), ('pending', '待审批'), ('approved', '已通过'), ('rejected', '已驳回')],
|
||||
),
|
||||
body: DeskDataTable(
|
||||
columns: const ['类型', '事由', '状态', '时间'],
|
||||
rows: [
|
||||
for (final r in _items)
|
||||
[zh(r['kind']), '${r['reason'] ?? ''}', zh(r['status']), fmtTime(r['createdAt'])],
|
||||
],
|
||||
emptyHint: '还没有人事申请',
|
||||
onRowTap: (i) => deskOpenRecord(context, widget.api, {..._items[i], 'source': 'HR'}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../api/oa_client.dart';
|
||||
import '../desk_theme.dart';
|
||||
import '../widgets/desk_widgets.dart';
|
||||
|
||||
class DeskLegalPage extends StatefulWidget {
|
||||
const DeskLegalPage({super.key, required this.api, required this.kind});
|
||||
final OaClient api;
|
||||
final String kind;
|
||||
|
||||
@override
|
||||
State<DeskLegalPage> createState() => _DeskLegalPageState();
|
||||
}
|
||||
|
||||
class _DeskLegalPageState extends State<DeskLegalPage> {
|
||||
String _title = '';
|
||||
List<Map<String, String>> _sections = [];
|
||||
String _error = '';
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final raw = await widget.api.get('/legal/${widget.kind}');
|
||||
if (!mounted) return;
|
||||
if (raw is Map) {
|
||||
final data = Map<String, dynamic>.from(raw);
|
||||
final paras = asMaps(data['paragraphs']);
|
||||
setState(() {
|
||||
_title = '${data['title'] ?? ''}';
|
||||
_sections = paras.isNotEmpty
|
||||
? paras.map((e) => {'title': '${e['heading'] ?? e['title'] ?? ''}', 'body': '${e['body'] ?? ''}'}).toList()
|
||||
: [{'title': '', 'body': '${data['body'] ?? data['content'] ?? ''}'}];
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) setState(() {
|
||||
_error = '$e';
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
color: kDeskBg,
|
||||
child: Column(
|
||||
children: [
|
||||
DeskPaneHeader(
|
||||
title: _title.isEmpty ? (widget.kind == 'privacy' ? '隐私政策' : '用户协议') : _title,
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back, size: 20), onPressed: () => Navigator.pop(context)),
|
||||
),
|
||||
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
|
||||
Expanded(
|
||||
child: _error.isNotEmpty
|
||||
? Center(child: Text(_error, style: const TextStyle(color: kDeskMute)))
|
||||
: ListView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
children: [
|
||||
for (final s in _sections) ...[
|
||||
Text(s['title'] ?? '', style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
Text(s['body'] ?? '', style: const TextStyle(fontSize: 14, height: 1.6, color: kDeskInk)),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../api/oa_client.dart';
|
||||
import '../../labels.dart';
|
||||
import 'desk_record_detail_page.dart';
|
||||
import '../widgets/desk_data_table.dart';
|
||||
import '../widgets/desk_widgets.dart';
|
||||
|
||||
/// 桌面端办公列表(待办、审批、申请等),表格布局。
|
||||
class DeskOfficeListPage extends StatefulWidget {
|
||||
const DeskOfficeListPage({
|
||||
super.key,
|
||||
required this.api,
|
||||
required this.title,
|
||||
required this.path,
|
||||
this.query,
|
||||
this.buckets = const [],
|
||||
this.defaultBucket = '',
|
||||
this.columns = const ['标题', '类型', '时间', '状态'],
|
||||
this.rowBuilder,
|
||||
});
|
||||
|
||||
final OaClient api;
|
||||
final String title;
|
||||
final String path;
|
||||
final Map<String, String>? query;
|
||||
final List<(String, String)> buckets;
|
||||
final String defaultBucket;
|
||||
final List<String> columns;
|
||||
final List<String> Function(Map<String, dynamic> row)? rowBuilder;
|
||||
|
||||
@override
|
||||
State<DeskOfficeListPage> createState() => _DeskOfficeListPageState();
|
||||
}
|
||||
|
||||
class _DeskOfficeListPageState extends State<DeskOfficeListPage> {
|
||||
List<Map<String, dynamic>> _items = [];
|
||||
String _bucket = '';
|
||||
bool _loading = true;
|
||||
String _q = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bucket = widget.defaultBucket;
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final q = <String, String>{...?widget.query};
|
||||
if (_bucket.isNotEmpty) q['bucket'] = _bucket;
|
||||
if (_bucket.isNotEmpty && widget.buckets.any((b) => b.$1 == _bucket && b.$1 == 'PENDING')) {
|
||||
q.remove('bucket');
|
||||
}
|
||||
final data = await widget.api.get(widget.path, query: q.isEmpty ? null : q);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = asMaps(data);
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> get _shown {
|
||||
var list = _bucket.isEmpty || widget.buckets.isEmpty
|
||||
? _items
|
||||
: _items.where((e) {
|
||||
if (widget.path.contains('approvals')) return '${e['status']}' == _bucket;
|
||||
if (widget.path.contains('todos')) return '${e['status']}' == _bucket;
|
||||
return true;
|
||||
}).toList();
|
||||
if (_q.isNotEmpty) {
|
||||
list = list.where((e) => '${e['title']}${e['bizType']}'.contains(_q)).toList();
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
List<String> _row(Map<String, dynamic> r) {
|
||||
if (widget.rowBuilder != null) return widget.rowBuilder!(r);
|
||||
return [
|
||||
'${r['title'] ?? ''}',
|
||||
zh(r['bizType'] ?? r['type'] ?? ''),
|
||||
fmtTime(r['dueAt'] ?? r['createdAt'] ?? ''),
|
||||
zhStatus(r['status']),
|
||||
];
|
||||
}
|
||||
|
||||
String zhStatus(dynamic s) {
|
||||
final v = '$s';
|
||||
const m = {'PENDING': '待处理', 'OPEN': '待办', 'DONE': '已完成', 'APPROVED': '已通过', 'REJECTED': '已驳回'};
|
||||
return m[v] ?? v;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shown = _shown;
|
||||
return DeskListScaffold(
|
||||
title: widget.title,
|
||||
loading: _loading,
|
||||
actions: [
|
||||
IconButton(onPressed: _load, icon: const Icon(Icons.refresh, size: 20)),
|
||||
],
|
||||
filters: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DeskSearchField(hint: '搜索', onChanged: (v) => setState(() => _q = v.trim())),
|
||||
if (widget.buckets.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
DeskFilterChips(
|
||||
value: _bucket,
|
||||
onChanged: (v) {
|
||||
setState(() => _bucket = v);
|
||||
_load();
|
||||
},
|
||||
items: widget.buckets,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
body: DeskDataTable(
|
||||
columns: widget.columns,
|
||||
rows: [for (final r in shown) _row(r)],
|
||||
emptyHint: '暂无${widget.title}',
|
||||
onRowTap: (i) => deskOpenRecord(context, widget.api, shown[i]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../api/oa_client.dart';
|
||||
import '../../session/session.dart';
|
||||
import '../desk_theme.dart';
|
||||
import '../widgets/desk_widgets.dart';
|
||||
|
||||
class DeskOrgBrowsePage extends StatefulWidget {
|
||||
const DeskOrgBrowsePage({
|
||||
super.key,
|
||||
required this.session,
|
||||
required this.api,
|
||||
required this.staff,
|
||||
this.parentId,
|
||||
this.title = '组织架构',
|
||||
this.deptName,
|
||||
this.onMessage,
|
||||
});
|
||||
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
final List<Map<String, dynamic>> staff;
|
||||
final String? parentId;
|
||||
final String title;
|
||||
final String? deptName;
|
||||
final void Function(Map<String, dynamic> person)? onMessage;
|
||||
|
||||
@override
|
||||
State<DeskOrgBrowsePage> createState() => _DeskOrgBrowsePageState();
|
||||
}
|
||||
|
||||
class _DeskOrgBrowsePageState extends State<DeskOrgBrowsePage> {
|
||||
List<Map<String, dynamic>> _depts = [];
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final data = await widget.api.get('/departments');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_depts = asMaps(data);
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> get _children {
|
||||
if (_depts.isEmpty) return const [];
|
||||
return _depts.where((e) {
|
||||
final pid = '${e['parentId'] ?? ''}';
|
||||
if (widget.parentId == null || widget.parentId!.isEmpty) return pid.isEmpty || pid == 'null';
|
||||
return pid == widget.parentId;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> get _people {
|
||||
final name = widget.deptName;
|
||||
if (name == null || name.isEmpty) return const [];
|
||||
final me = widget.session.userId;
|
||||
return widget.staff.where((e) => '${e['department']}' == name && '${e['id']}' != me).toList();
|
||||
}
|
||||
|
||||
int _countOf(Map<String, dynamic> d) {
|
||||
final c = d['_count'];
|
||||
if (c is Map && c['employees'] != null) return (c['employees'] as num).toInt();
|
||||
return widget.staff.where((e) => '${e['department']}' == '${d['name']}').length;
|
||||
}
|
||||
|
||||
void _openDept(Map<String, dynamic> d) {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => DeskOrgBrowsePage(
|
||||
session: widget.session,
|
||||
api: widget.api,
|
||||
staff: widget.staff,
|
||||
parentId: '${d['id']}',
|
||||
title: '${d['name']}',
|
||||
deptName: '${d['name']}',
|
||||
onMessage: widget.onMessage,
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> _message(Map<String, dynamic> r) async {
|
||||
if (widget.onMessage != null) {
|
||||
widget.onMessage!(r);
|
||||
return;
|
||||
}
|
||||
String conversationId = '';
|
||||
try {
|
||||
final raw = await widget.api.post('/im/conversations/direct', {'peerId': '${r['id']}'});
|
||||
if (raw is Map) conversationId = '${raw['conversationId'] ?? raw['id'] ?? ''}';
|
||||
} catch (_) {}
|
||||
if (mounted) Navigator.pop(context, {...r, 'conversationId': conversationId, 'peerId': '${r['id']}', 'name': '${r['name']}', 'type': 'direct'});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
color: kDeskBg,
|
||||
child: Column(
|
||||
children: [
|
||||
DeskPaneHeader(
|
||||
title: widget.title,
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back, size: 20), onPressed: () => Navigator.pop(context)),
|
||||
),
|
||||
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
for (final d in _children)
|
||||
Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
onTap: () => _openDept(d),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
children: [
|
||||
DeskAvatar(label: '${d['name']}', api: widget.api, size: 40, color: kDeskBind, icon: Icons.folder_outlined),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text('${d['name']}', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500))),
|
||||
Text('${_countOf(d)} 人', style: const TextStyle(fontSize: 12, color: kDeskMute)),
|
||||
const Icon(Icons.chevron_right, size: 18, color: kDeskMute),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_people.isNotEmpty) ...[
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(4, 12, 4, 8),
|
||||
child: Text('部门成员', style: TextStyle(fontSize: 12, color: kDeskMute, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
for (final r in _people)
|
||||
Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
onTap: () => _message(r),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 6),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
DeskAvatar(label: '${r['name']}', fileId: '${r['avatarFileId'] ?? ''}', api: widget.api, size: 36),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${r['name']}', style: const TextStyle(fontSize: 14)),
|
||||
Text('${r['title'] ?? ''}', style: const TextStyle(fontSize: 12, color: kDeskMute)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Text('发消息', style: TextStyle(fontSize: 12, color: kDeskBind)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../api/oa_client.dart';
|
||||
import '../desk_theme.dart';
|
||||
import '../widgets/desk_widgets.dart';
|
||||
|
||||
class DeskPickPeoplePage extends StatefulWidget {
|
||||
const DeskPickPeoplePage({
|
||||
super.key,
|
||||
required this.api,
|
||||
required this.title,
|
||||
this.multiple = true,
|
||||
this.exclude = const {},
|
||||
});
|
||||
|
||||
final OaClient api;
|
||||
final String title;
|
||||
final bool multiple;
|
||||
final Set<String> exclude;
|
||||
|
||||
@override
|
||||
State<DeskPickPeoplePage> createState() => _DeskPickPeoplePageState();
|
||||
}
|
||||
|
||||
class _DeskPickPeoplePageState extends State<DeskPickPeoplePage> {
|
||||
List<Map<String, dynamic>> _items = [];
|
||||
final _selected = <String, String>{};
|
||||
String _q = '';
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final data = await widget.api.get('/staff');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = asMaps(data);
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _confirm() {
|
||||
Navigator.pop(
|
||||
context,
|
||||
_selected.entries.map((e) => {'id': e.key, 'name': e.value}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shown = _items.where((e) {
|
||||
final id = '${e['id']}';
|
||||
if (widget.exclude.contains(id)) return false;
|
||||
if (_q.isEmpty) return true;
|
||||
return '${e['name']}${e['department']}${e['title']}'.contains(_q);
|
||||
}).toList();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: kDeskBg,
|
||||
body: Column(
|
||||
children: [
|
||||
DeskPaneHeader(
|
||||
title: widget.title,
|
||||
leading: IconButton(icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(context)),
|
||||
actions: [
|
||||
if (widget.multiple)
|
||||
TextButton(onPressed: _selected.isEmpty ? null : _confirm, child: Text('确定(${_selected.length})')),
|
||||
],
|
||||
bottom: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 10),
|
||||
child: DeskSearchField(hint: '搜索同事', onChanged: (v) => setState(() => _q = v.trim())),
|
||||
),
|
||||
),
|
||||
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: shown.length,
|
||||
itemBuilder: (_, i) {
|
||||
final r = shown[i];
|
||||
final id = '${r['id']}';
|
||||
final name = '${r['name'] ?? ''}';
|
||||
final on = _selected.containsKey(id);
|
||||
return Material(
|
||||
color: on ? kDeskActive : Colors.white,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
if (!widget.multiple) {
|
||||
Navigator.pop(context, [
|
||||
{'id': id, 'name': name, 'avatarFileId': '${r['avatarFileId'] ?? ''}'},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
if (on) {
|
||||
_selected.remove(id);
|
||||
} else {
|
||||
_selected[id] = name;
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 14, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
DeskAvatar(label: name, fileId: '${r['avatarFileId'] ?? ''}', api: widget.api, size: 36),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
|
||||
Text('${r['department'] ?? ''} ${r['title'] ?? ''}'.trim(),
|
||||
style: const TextStyle(fontSize: 12, color: kDeskMute)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.multiple)
|
||||
Icon(on ? Icons.check_circle : Icons.circle_outlined, color: on ? kDeskBind : kDeskMute, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import '../../api/oa_client.dart';
|
||||
import '../../session/session.dart';
|
||||
import '../desk_theme.dart';
|
||||
import '../widgets/desk_widgets.dart';
|
||||
|
||||
class DeskProfileEditPage extends StatefulWidget {
|
||||
const DeskProfileEditPage({super.key, required this.session, required this.api});
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
|
||||
@override
|
||||
State<DeskProfileEditPage> createState() => _DeskProfileEditPageState();
|
||||
}
|
||||
|
||||
class _DeskProfileEditPageState extends State<DeskProfileEditPage> {
|
||||
bool _busy = false;
|
||||
|
||||
Future<void> _refreshMe() async {
|
||||
final me = await widget.api.get('/auth/me');
|
||||
if (me is Map) await widget.session.patchUser(Map<String, dynamic>.from(me));
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _pickAvatar() async {
|
||||
final x = await ImagePicker().pickImage(source: ImageSource.gallery, imageQuality: 85, maxWidth: 800);
|
||||
if (x == null) return;
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await widget.api.uploadFile(
|
||||
filePath: x.path,
|
||||
filename: x.name.isEmpty ? 'avatar.jpg' : x.name,
|
||||
bizType: 'USER_AVATAR',
|
||||
bizId: widget.session.userId,
|
||||
);
|
||||
await _refreshMe();
|
||||
if (mounted) deskToast(context, '头像已更新');
|
||||
} catch (e) {
|
||||
if (mounted) deskToast(context, '$e', error: true);
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _editName() async {
|
||||
final c = TextEditingController(text: widget.session.displayName);
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('修改姓名'),
|
||||
content: TextField(controller: c, decoration: const InputDecoration(labelText: '显示名')),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('保存')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true || c.text.trim().isEmpty) return;
|
||||
try {
|
||||
final me = await widget.api.patch('/auth/profile', {'displayName': c.text.trim()});
|
||||
if (me is Map) await widget.session.patchUser(Map<String, dynamic>.from(me));
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
deskToast(context, '已保存');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) deskToast(context, '$e', error: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _editPassword() async {
|
||||
final oldC = TextEditingController();
|
||||
final newC = TextEditingController();
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('修改密码'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(controller: oldC, obscureText: true, decoration: const InputDecoration(labelText: '当前密码')),
|
||||
const SizedBox(height: 8),
|
||||
TextField(controller: newC, obscureText: true, decoration: const InputDecoration(labelText: '新密码')),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('保存')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true) return;
|
||||
try {
|
||||
await widget.api.post('/auth/change-password', {'oldPassword': oldC.text, 'newPassword': newC.text});
|
||||
if (mounted) deskToast(context, '密码已修改');
|
||||
} catch (e) {
|
||||
if (mounted) deskToast(context, '$e', error: true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = widget.session;
|
||||
final u = s.user;
|
||||
return ColoredBox(
|
||||
color: kDeskBg,
|
||||
child: Column(
|
||||
children: [
|
||||
DeskPaneHeader(
|
||||
title: '个人资料',
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back, size: 20), onPressed: () => Navigator.pop(context)),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
children: [
|
||||
Center(
|
||||
child: Stack(
|
||||
children: [
|
||||
DeskAvatar(label: s.displayName, fileId: '${u['avatarFileId'] ?? ''}', api: widget.api, size: 80),
|
||||
if (_busy)
|
||||
const Positioned.fill(child: CircularProgressIndicator(strokeWidth: 2, color: kDeskBind)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Center(
|
||||
child: TextButton(onPressed: _busy ? null : _pickAvatar, child: const Text('更换头像')),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_row('姓名', s.displayName, onTap: _editName),
|
||||
_row('手机', '${u['mobile'] ?? '未填写'}'),
|
||||
_row('邮箱', '${u['email'] ?? '未填写'}'),
|
||||
_row('部门', '${u['department'] ?? ''}'),
|
||||
_row('职位', '${u['title'] ?? ''}'),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(onPressed: _editPassword, child: const Text('修改密码')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(String k, String v, {VoidCallback? onTap}) {
|
||||
return Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 64, child: Text(k, style: const TextStyle(color: kDeskMute, fontSize: 13))),
|
||||
Expanded(child: Text(v, style: const TextStyle(fontSize: 14))),
|
||||
if (onTap != null) const Icon(Icons.chevron_right, size: 18, color: kDeskMute),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../api/oa_client.dart';
|
||||
import '../../app_config.dart';
|
||||
import '../../ota/updater.dart';
|
||||
import 'desk_legal_page.dart';
|
||||
import 'desk_profile_edit_page.dart';
|
||||
import '../../session/session.dart';
|
||||
import '../desk_theme.dart';
|
||||
import '../widgets/desk_widgets.dart';
|
||||
|
||||
class DeskProfilePage extends StatefulWidget {
|
||||
const DeskProfilePage({super.key, required this.session, required this.api});
|
||||
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
|
||||
@override
|
||||
State<DeskProfilePage> createState() => _DeskProfilePageState();
|
||||
}
|
||||
|
||||
class _DeskProfilePageState extends State<DeskProfilePage> {
|
||||
AppRelease? _rel;
|
||||
bool _checking = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.session.addListener(() {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
_peek();
|
||||
}
|
||||
|
||||
Future<void> _peek() async {
|
||||
final rel = await OtaUpdater(widget.api).fetch();
|
||||
if (mounted) setState(() => _rel = rel);
|
||||
}
|
||||
|
||||
Future<void> _logout() async {
|
||||
try {
|
||||
await widget.api.post('/auth/logout', {'refreshToken': widget.session.refreshToken});
|
||||
} catch (_) {}
|
||||
await widget.session.clear();
|
||||
}
|
||||
|
||||
Future<void> _checkUpdate() async {
|
||||
setState(() => _checking = true);
|
||||
try {
|
||||
final rel = await OtaUpdater(widget.api).fetch();
|
||||
if (!mounted) return;
|
||||
setState(() => _rel = rel);
|
||||
if (rel != null) await OtaUpdater(widget.api).prompt(context, rel, manual: true);
|
||||
} finally {
|
||||
if (mounted) setState(() => _checking = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = widget.session;
|
||||
return ColoredBox(
|
||||
color: kDeskBg,
|
||||
child: Column(
|
||||
children: [
|
||||
const DeskPaneHeader(title: '我'),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
children: [
|
||||
Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
onTap: () => Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => DeskProfileEditPage(session: s, api: widget.api),
|
||||
)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
DeskAvatar(
|
||||
label: s.displayName,
|
||||
fileId: '${s.user['avatarFileId'] ?? ''}',
|
||||
api: widget.api,
|
||||
size: 56,
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(s.displayName, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 4),
|
||||
Text('${s.user['department'] ?? ''} ${s.user['title'] ?? ''}'.trim(),
|
||||
style: const TextStyle(color: kDeskMute, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right, color: kDeskMute),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_section([
|
||||
_row('检查更新', trailing: _rel?.newer == true ? '有新版本' : '已是最新', onTap: _checking ? null : _checkUpdate),
|
||||
_row('用户协议', onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => DeskLegalPage(api: widget.api, kind: 'terms')))),
|
||||
_row('隐私政策', onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => DeskLegalPage(api: widget.api, kind: 'privacy')))),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
_section([
|
||||
_row('版本', trailing: 'v${AppConfig.version}'),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(minimumSize: const Size.fromHeight(40)),
|
||||
onPressed: _logout,
|
||||
child: const Text('退出登录', style: TextStyle(color: Color(0xFFFA5151))),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _section(List<Widget> children) {
|
||||
return Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(children: children),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(String title, {String? trailing, VoidCallback? onTap}) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(title, style: const TextStyle(fontSize: 14))),
|
||||
if (trailing != null) Text(trailing, style: const TextStyle(fontSize: 13, color: kDeskMute)),
|
||||
if (onTap != null) const Icon(Icons.chevron_right, size: 18, color: kDeskMute),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../api/oa_client.dart';
|
||||
import '../../nav/biz_route.dart';
|
||||
import '../../pages/record_detail_page.dart' show flattenRecord;
|
||||
import '../desk_theme.dart';
|
||||
import '../widgets/desk_widgets.dart';
|
||||
import 'desk_pick_people_page.dart';
|
||||
|
||||
void deskOpenRecord(BuildContext context, OaClient api, Map<String, dynamic> row, {String? title}) {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => DeskRecordDetailPage(api: api, seed: row, title: title ?? detailTitleOf(row)),
|
||||
));
|
||||
}
|
||||
|
||||
class DeskRecordDetailPage extends StatefulWidget {
|
||||
const DeskRecordDetailPage({super.key, required this.api, required this.seed, required this.title});
|
||||
final OaClient api;
|
||||
final Map<String, dynamic> seed;
|
||||
final String title;
|
||||
|
||||
@override
|
||||
State<DeskRecordDetailPage> createState() => _DeskRecordDetailPageState();
|
||||
}
|
||||
|
||||
class _DeskRecordDetailPageState extends State<DeskRecordDetailPage> {
|
||||
Map<String, dynamic> _row = {};
|
||||
bool _loading = true;
|
||||
String _err = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_row = Map<String, dynamic>.from(widget.seed);
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final path = fetchPathOf(widget.seed);
|
||||
if (path == null) {
|
||||
setState(() => _loading = false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final data = await widget.api.get(path);
|
||||
if (!mounted) return;
|
||||
if (data is Map) {
|
||||
setState(() {
|
||||
_row = {...widget.seed, ...Map<String, dynamic>.from(data)};
|
||||
_loading = false;
|
||||
_err = '';
|
||||
});
|
||||
} else {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_err = '$e';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _forward() async {
|
||||
final combined = <String, dynamic>{...widget.seed, ..._row};
|
||||
final path = fetchPathOf(combined);
|
||||
if (path == null || !path.startsWith('/')) {
|
||||
deskToast(context, '当前信息暂不支持转发', error: true);
|
||||
return;
|
||||
}
|
||||
final picked = await Navigator.of(context).push<List<Map<String, dynamic>>>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => DeskPickPeoplePage(api: widget.api, title: '转发给', multiple: true, exclude: {widget.api.session.userId}),
|
||||
),
|
||||
);
|
||||
if (picked == null || picked.isEmpty || !mounted) return;
|
||||
final visible = flattenRecord(combined).where((e) => e.$2.trim().isNotEmpty).take(2).map((e) => '${e.$1}:${e.$2}').join(' · ');
|
||||
try {
|
||||
for (final person in picked) {
|
||||
await widget.api.post('/im/messages', {
|
||||
'peerId': '${person['id'] ?? ''}',
|
||||
'body': widget.title,
|
||||
'contentType': 'business',
|
||||
'meta': {
|
||||
'title': widget.title,
|
||||
'summary': visible,
|
||||
'fetchPath': path,
|
||||
'recordId': '${combined['id'] ?? combined['bizId'] ?? ''}',
|
||||
'bizType': '${combined['bizType'] ?? combined['source'] ?? ''}',
|
||||
},
|
||||
});
|
||||
}
|
||||
if (mounted) deskToast(context, '已转发给 ${picked.length} 人');
|
||||
} catch (e) {
|
||||
if (mounted) deskToast(context, '$e', error: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _act(String label, Future<void> Function() run) async {
|
||||
try {
|
||||
await run();
|
||||
if (mounted) {
|
||||
deskToast(context, label);
|
||||
await _load();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) deskToast(context, '$e', error: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _decide(String path, String result) async {
|
||||
final c = TextEditingController();
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(result == 'APPROVED' ? '通过' : '驳回'),
|
||||
content: TextField(controller: c, decoration: const InputDecoration(hintText: '意见(可选)')),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确认')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true) return;
|
||||
await _act('已提交', () async {
|
||||
await widget.api.post(path, {'result': result, if (c.text.trim().isNotEmpty) 'comment': c.text.trim()});
|
||||
});
|
||||
}
|
||||
|
||||
List<Widget> get _actions {
|
||||
final id = '${_row['id'] ?? widget.seed['id'] ?? ''}';
|
||||
final bizId = '${_row['bizId'] ?? ''}';
|
||||
final status = '${_row['status'] ?? ''}';
|
||||
final source = '${_row['source'] ?? ''}';
|
||||
final kind = '${_row['kind'] ?? ''}';
|
||||
final biz = '${_row['bizType'] ?? ''}';
|
||||
final actions = _row['myActions'] is Map ? Map<String, dynamic>.from(_row['myActions'] as Map) : <String, dynamic>{};
|
||||
final out = <Widget>[];
|
||||
|
||||
if (status == 'DRAFT' && (source == 'EXPENSE' && kind != 'LOAN' || _row.containsKey('claimNo'))) {
|
||||
out.add(FilledButton(onPressed: () => _act('已提交审批', () => widget.api.post('/expenses/$id/submit')), child: const Text('提交报销')));
|
||||
}
|
||||
if (status == 'DRAFT' && (kind == 'LOAN' || _row.containsKey('loanNo'))) {
|
||||
out.add(FilledButton(onPressed: () => _act('已提交审批', () => widget.api.post('/loans/$id/submit')), child: const Text('提交借款')));
|
||||
}
|
||||
if (biz.isNotEmpty && status == 'PENDING' && id.isNotEmpty && widget.seed['assigneeId'] != null) {
|
||||
out.add(FilledButton(onPressed: () => _decide('/office/approvals/$id/decide', 'APPROVED'), child: const Text('通过')));
|
||||
out.add(OutlinedButton(onPressed: () => _decide('/office/approvals/$id/decide', 'REJECTED'), child: const Text('驳回')));
|
||||
}
|
||||
if (source == 'OFFICE' && status == 'PENDING') {
|
||||
out.add(FilledButton(onPressed: () => _decide('/office/applies/$id/decide', 'APPROVED'), child: const Text('通过')));
|
||||
out.add(OutlinedButton(onPressed: () => _decide('/office/applies/$id/decide', 'REJECTED'), child: const Text('驳回')));
|
||||
}
|
||||
if ((biz == 'HR_LEAVE' || source == 'HR') && status == 'PENDING' && (bizId.isNotEmpty || id.isNotEmpty)) {
|
||||
final hid = bizId.isNotEmpty ? bizId : id;
|
||||
out.add(FilledButton(onPressed: () => _decide('/leave-requests/$hid/review', 'APPROVED'), child: const Text('通过')));
|
||||
out.add(OutlinedButton(onPressed: () => _decide('/leave-requests/$hid/review', 'REJECTED'), child: const Text('驳回')));
|
||||
}
|
||||
if (actions['canReview'] == true && id.isNotEmpty) {
|
||||
out.add(FilledButton(onPressed: () => _decide('/bid-cases/$id/reviews', 'APPROVED'), child: const Text('审批通过')));
|
||||
out.add(OutlinedButton(onPressed: () => _decide('/bid-cases/$id/reviews', 'REJECTED'), child: const Text('审批驳回')));
|
||||
}
|
||||
if ('${_row['status']}' == 'OPEN' && _row['assigneeId'] != null && !_row.containsKey('source')) {
|
||||
out.add(FilledButton(
|
||||
onPressed: () => _act('已办结', () => widget.api.patch('/office/todos/$id', {'status': 'DONE'})),
|
||||
child: const Text('标为已办'),
|
||||
));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pairs = flattenRecord(_row);
|
||||
return ColoredBox(
|
||||
color: kDeskBg,
|
||||
child: Column(
|
||||
children: [
|
||||
DeskPaneHeader(
|
||||
title: widget.title,
|
||||
actions: [
|
||||
IconButton(tooltip: '转发', onPressed: _loading ? null : _forward, icon: const Icon(Icons.forward_to_inbox_outlined, size: 20)),
|
||||
IconButton(tooltip: '刷新', onPressed: _load, icon: const Icon(Icons.refresh, size: 20)),
|
||||
],
|
||||
),
|
||||
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 720),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
if (_err.isNotEmpty) Padding(padding: const EdgeInsets.only(bottom: 8), child: Text(_err, style: const TextStyle(color: kDeskMute, fontSize: 12))),
|
||||
Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < pairs.length; i++)
|
||||
_kvRow(pairs[i].$1, pairs[i].$2, i == pairs.length - 1),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_actions.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: Wrap(spacing: 8, runSpacing: 8, children: _actions),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _kvRow(String k, String v, bool last) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(border: last ? null : const Border(bottom: BorderSide(color: kDeskLine))),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(width: 100, child: Text(k, style: const TextStyle(fontSize: 13, color: kDeskMute))),
|
||||
Expanded(child: Text(v, style: const TextStyle(fontSize: 14))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../api/oa_client.dart';
|
||||
import '../../nav/notice_route.dart';
|
||||
import '../../session/session.dart';
|
||||
import '../desk_theme.dart';
|
||||
import '../widgets/desk_widgets.dart';
|
||||
|
||||
class DeskSystemNoticePage extends StatefulWidget {
|
||||
const DeskSystemNoticePage({super.key, required this.session, required this.api, required this.conversationId});
|
||||
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
final String conversationId;
|
||||
|
||||
@override
|
||||
State<DeskSystemNoticePage> createState() => _DeskSystemNoticePageState();
|
||||
}
|
||||
|
||||
class _DeskSystemNoticePageState extends State<DeskSystemNoticePage> {
|
||||
List<Map<String, dynamic>> _items = [];
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final data = await widget.api.get('/im/messages', query: {'conversationId': widget.conversationId});
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = data is Map ? asMaps(data['items'] ?? data) : asMaps(data);
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _meta(Map<String, dynamic> row) {
|
||||
final raw = row['meta'];
|
||||
if (raw is Map) return Map<String, dynamic>.from(raw);
|
||||
if (raw is String && raw.isNotEmpty) {
|
||||
try {
|
||||
final d = jsonDecode(raw);
|
||||
if (d is Map) return Map<String, dynamic>.from(d);
|
||||
} catch (_) {}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
void _open(Map<String, dynamic> row) {
|
||||
final meta = _meta(row);
|
||||
openNoticeTarget(
|
||||
context,
|
||||
widget.api,
|
||||
session: widget.session,
|
||||
kind: '${meta['kind'] ?? row['kind'] ?? ''}',
|
||||
bizType: '${meta['bizType'] ?? ''}',
|
||||
bizId: '${meta['bizId'] ?? ''}',
|
||||
title: '${row['body'] ?? meta['title'] ?? '系统通知'}',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
color: const Color(0xFFF5F5F5),
|
||||
child: Column(
|
||||
children: [
|
||||
const DeskPaneHeader(title: '系统通知'),
|
||||
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
|
||||
Expanded(
|
||||
child: _items.isEmpty
|
||||
? const Center(child: Text('暂无通知', style: TextStyle(color: kDeskMute)))
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _items.length,
|
||||
itemBuilder: (_, i) {
|
||||
final r = _items[i];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
onTap: () => _open(r),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.notifications_none, color: kDeskBind),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${r['body'] ?? '通知'}', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 4),
|
||||
Text(shortTime(r['createdAt']), style: const TextStyle(fontSize: 12, color: kDeskMute)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right, color: kDeskMute, size: 18),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../api/oa_client.dart';
|
||||
import '../../session/session.dart';
|
||||
import '../desk_theme.dart';
|
||||
import '../widgets/desk_data_table.dart';
|
||||
import '../widgets/desk_widgets.dart';
|
||||
import 'desk_record_detail_page.dart';
|
||||
|
||||
class DeskWorkAssignPage extends StatefulWidget {
|
||||
const DeskWorkAssignPage({super.key, required this.session, required this.api});
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
|
||||
@override
|
||||
State<DeskWorkAssignPage> createState() => _DeskWorkAssignPageState();
|
||||
}
|
||||
|
||||
class _DeskWorkAssignPageState extends State<DeskWorkAssignPage> {
|
||||
List<Map<String, dynamic>> _items = [];
|
||||
bool _loading = true;
|
||||
|
||||
bool get _canAssign {
|
||||
const codes = ['admin', 'owner', 'biz_director', 'tech_director', 'rd_director', '3d_director', 'video_director', 'material_director', 'pm'];
|
||||
return widget.session.roles.any(codes.contains);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final data = await widget.api.get('/office/work-assignments');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = asMaps(data);
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _create() async {
|
||||
final staff = asMaps(await widget.api.get('/staff'));
|
||||
if (!mounted) return;
|
||||
final title = TextEditingController();
|
||||
final content = TextEditingController();
|
||||
String? assignee;
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setSt) => AlertDialog(
|
||||
title: const Text('安排工作'),
|
||||
content: SizedBox(
|
||||
width: 440,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(controller: title, decoration: const InputDecoration(labelText: '标题')),
|
||||
const SizedBox(height: 8),
|
||||
TextField(controller: content, decoration: const InputDecoration(labelText: '内容'), maxLines: 3),
|
||||
const SizedBox(height: 8),
|
||||
DropdownButtonFormField<String>(
|
||||
decoration: const InputDecoration(labelText: '执行人'),
|
||||
items: [
|
||||
for (final s in staff)
|
||||
DropdownMenuItem(value: '${s['id']}', child: Text('${s['name'] ?? ''} · ${s['department'] ?? ''}')),
|
||||
],
|
||||
onChanged: (v) => setSt(() => assignee = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('下达')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (ok != true) return;
|
||||
try {
|
||||
await widget.api.post('/office/work-assignments', {
|
||||
'title': title.text.trim(),
|
||||
'content': content.text.trim(),
|
||||
'assigneeId': assignee,
|
||||
});
|
||||
if (mounted) {
|
||||
deskToast(context, '已下达');
|
||||
_load();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) deskToast(context, '$e', error: true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DeskListScaffold(
|
||||
title: '工作安排',
|
||||
loading: _loading,
|
||||
actions: [
|
||||
if (_canAssign)
|
||||
FilledButton.icon(
|
||||
style: FilledButton.styleFrom(backgroundColor: kDeskBind),
|
||||
onPressed: _create,
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text('安排工作'),
|
||||
),
|
||||
IconButton(onPressed: _load, icon: const Icon(Icons.refresh, size: 20)),
|
||||
],
|
||||
body: DeskDataTable(
|
||||
columns: const ['标题', '执行人', '时间', '状态'],
|
||||
rows: [
|
||||
for (final r in _items)
|
||||
[
|
||||
'${r['title'] ?? ''}',
|
||||
'${r['assignee'] is Map ? r['assignee']['displayName'] : ''}',
|
||||
fmtTime(r['dueAt'] ?? r['createdAt']),
|
||||
'${r['status'] ?? ''}',
|
||||
],
|
||||
],
|
||||
emptyHint: '没有工作安排',
|
||||
onRowTap: (i) => deskOpenRecord(context, widget.api, _items[i]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../api/oa_client.dart';
|
||||
import '../../device/device_bridge.dart';
|
||||
import '../../labels.dart';
|
||||
import '../../nav/open_module.dart';
|
||||
import '../../pages/module_list_page.dart' show RestShell, shellFor;
|
||||
import '../../session/session.dart';
|
||||
import '../desk_theme.dart';
|
||||
import '../widgets/desk_data_table.dart';
|
||||
import '../widgets/desk_widgets.dart';
|
||||
import 'desk_attendance_page.dart';
|
||||
import 'desk_hr_apply_page.dart';
|
||||
import 'desk_legal_page.dart';
|
||||
import 'desk_office_list_page.dart';
|
||||
import 'desk_record_detail_page.dart';
|
||||
import 'desk_work_assign_page.dart';
|
||||
|
||||
typedef DeskOpenTab = void Function(String key, String title, IconData icon, Color color, Widget page);
|
||||
|
||||
class DeskWorkbenchPage extends StatefulWidget {
|
||||
const DeskWorkbenchPage({super.key, required this.session, required this.api, required this.onOpenTab});
|
||||
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
final DeskOpenTab onOpenTab;
|
||||
|
||||
@override
|
||||
State<DeskWorkbenchPage> createState() => _DeskWorkbenchPageState();
|
||||
}
|
||||
|
||||
class _DeskWorkbenchPageState extends State<DeskWorkbenchPage> {
|
||||
Map<String, dynamic> _ov = {};
|
||||
String _greet = '';
|
||||
bool _loading = true;
|
||||
String _q = '';
|
||||
String _cat = 'all';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final ov = await widget.api.get('/office/overview');
|
||||
String greet = '';
|
||||
try {
|
||||
final w = await widget.api.get('/office/weather');
|
||||
if (w is Map) greet = '${w['greeting'] ?? w['text'] ?? ''}';
|
||||
} catch (_) {}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_ov = Map<String, dynamic>.from(ov as Map? ?? {});
|
||||
_greet = greet;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
bool _match(String name) => _q.isEmpty || name.contains(_q);
|
||||
|
||||
void _open(String key, String title, IconData icon, Color color, Widget page) {
|
||||
widget.onOpenTab(key, title, icon, color, page);
|
||||
}
|
||||
|
||||
List<_App> _personalApps() {
|
||||
return [
|
||||
if (_match('待办'))
|
||||
_App('todos', '待办', '处理待办事项与提醒', Icons.task_alt, const Color(0xFFFA9D3B),
|
||||
DeskOfficeListPage(api: widget.api, title: '待办', path: '/office/todos', buckets: const [('OPEN', '待办'), ('DONE', '已办'), ('', '全部')], defaultBucket: 'OPEN')),
|
||||
if (_match('审批'))
|
||||
_App('approvals', '待审批', '审批各类办公申请', Icons.fact_check, kDeskBind,
|
||||
DeskOfficeListPage(api: widget.api, title: '待我审批', path: '/office/approvals', buckets: const [('PENDING', '待审批'), ('APPROVED', '已通过'), ('REJECTED', '已驳回'), ('', '全部')], defaultBucket: 'PENDING')),
|
||||
if (_match('申请'))
|
||||
_App('flow', '我的申请', '查看我发起的申请', Icons.assignment_outlined, const Color(0xFF6267F2),
|
||||
DeskOfficeListPage(api: widget.api, title: '我的申请', path: '/office/flow', buckets: const [('', '全部'), ('pending', '待审批'), ('approved', '已通过'), ('rejected', '已驳回')], defaultBucket: '')),
|
||||
if (_match('日程'))
|
||||
_App('calendar', '日程', '会议与日程安排', Icons.calendar_month, const Color(0xFF10AEFF),
|
||||
DeskOfficeListPage(
|
||||
api: widget.api,
|
||||
title: '我的日程',
|
||||
path: '/office/calendar',
|
||||
columns: const ['标题', '时间', '地点'],
|
||||
rowBuilder: (r) => ['${r['title'] ?? ''}', fmtTime(r['startAt'] ?? r['createdAt']), '${r['location'] ?? ''}'],
|
||||
)),
|
||||
if (_match('安排'))
|
||||
_App('work', '工作安排', '任务分配与跟进', Icons.event_note, const Color(0xFF00B578),
|
||||
DeskWorkAssignPage(session: widget.session, api: widget.api)),
|
||||
if (_match('打卡') || _match('考勤'))
|
||||
_App('attendance', '考勤打卡', '上下班打卡记录', Icons.access_time_filled, const Color(0xFF267EF0),
|
||||
DeskAttendancePage(api: widget.api)),
|
||||
if (_match('人事'))
|
||||
_App('hr', '人事申请', '请假、加班、外出等', Icons.beach_access, const Color(0xFF8B5CF6), DeskHrApplyPage(api: widget.api)),
|
||||
];
|
||||
}
|
||||
|
||||
List<_App> _menuApps() {
|
||||
final out = <_App>[];
|
||||
for (final m in widget.session.menus) {
|
||||
if (m.name == '工作台' || m.name == '个人办公') continue;
|
||||
if (m.name == '系统设置' || m.code.startsWith('system')) continue;
|
||||
if (m.children.isNotEmpty) {
|
||||
for (var i = 0; i < m.children.length; i++) {
|
||||
final c = m.children[i];
|
||||
if (_skip(c)) continue;
|
||||
if (!(_q.isEmpty || c.name.contains(_q) || m.name.contains(_q))) continue;
|
||||
out.add(_App(c.code, c.name, m.name, iconFor(c.code, c.name), colorFor(c.code, c.name, i), _pageFor(c)));
|
||||
}
|
||||
} else if (!_skip(m) && _match(m.name)) {
|
||||
out.add(_App(m.code, m.name, '业务应用', iconFor(m.code, m.name), colorFor(m.code, m.name), _pageFor(m)));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool _skip(MenuNode n) {
|
||||
const names = {'待办', '待审批', '我的申请', '日程', '公告', '公司公告', '工作安排', '人事申请', '工作台', '个人办公', '消息', '通讯录', '员工通讯', '发送短信', '系统设置'};
|
||||
if (n.code.startsWith('office:') || n.code.startsWith('system')) return true;
|
||||
if (n.code.contains('sms') || n.name.contains('短信')) return true;
|
||||
if (n.name.contains('公告') || n.name == '员工通讯') return true;
|
||||
return names.contains(n.name);
|
||||
}
|
||||
|
||||
String _legalKind(MenuNode n) {
|
||||
final p = '${n.path ?? ''} ${n.code}'.toLowerCase();
|
||||
if (p.contains('privacy') || p.contains('隐私')) return 'privacy';
|
||||
return 'user-agreement';
|
||||
}
|
||||
|
||||
Widget _pageFor(MenuNode n) {
|
||||
if (n.code.contains('legal') || (n.path ?? '').startsWith('/legal')) {
|
||||
return DeskLegalPage(api: widget.api, kind: _legalKind(n));
|
||||
}
|
||||
if (n.code == 'office:assign' || n.name.contains('工作安排')) {
|
||||
return DeskWorkAssignPage(session: widget.session, api: widget.api);
|
||||
}
|
||||
final shell = shellFor(n.code, n.path) ?? _shellFromPath(n.path);
|
||||
if (shell != null) {
|
||||
return DeskModuleListPage(api: widget.api, title: n.name, shell: shell);
|
||||
}
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('${n.name} 暂未配置桌面入口', style: const TextStyle(color: kDeskMute)),
|
||||
if ((n.path ?? '').startsWith('http'))
|
||||
TextButton(
|
||||
onPressed: () => DeviceBridge.openUrl(n.path!),
|
||||
child: const Text('在浏览器打开'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
RestShell? _shellFromPath(String? path) {
|
||||
final p = (path ?? '').trim();
|
||||
if (p.isEmpty || !p.startsWith('/') || p.contains(':')) return null;
|
||||
const blocked = [
|
||||
'/office/overview',
|
||||
'/office/todos',
|
||||
'/office/approvals',
|
||||
'/office/flow',
|
||||
'/office/calendar',
|
||||
'/office/weather',
|
||||
'/office/geo',
|
||||
'/auth',
|
||||
'/im',
|
||||
'/system',
|
||||
'/push',
|
||||
'/files',
|
||||
'/health',
|
||||
];
|
||||
if (blocked.any((b) => p.startsWith(b))) return null;
|
||||
return RestShell(p);
|
||||
}
|
||||
|
||||
List<_App> get _shown {
|
||||
final personal = _personalApps();
|
||||
final menu = _menuApps();
|
||||
if (_cat == 'personal') return personal;
|
||||
if (_cat == 'biz') return menu;
|
||||
return [...personal, ...menu];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final width = MediaQuery.sizeOf(context).width;
|
||||
final cols = width >= 1100 ? 3 : 2;
|
||||
return ColoredBox(
|
||||
color: kDeskBg,
|
||||
child: Column(
|
||||
children: [
|
||||
DeskPaneHeader(
|
||||
title: '工作台',
|
||||
actions: [
|
||||
SizedBox(width: 200, child: DeskSearchField(hint: '搜索应用', onChanged: (v) => setState(() => _q = v.trim()))),
|
||||
const SizedBox(width: 8),
|
||||
TextButton(onPressed: _load, child: const Text('刷新')),
|
||||
],
|
||||
bottom: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 10),
|
||||
child: DeskFilterChips(
|
||||
value: _cat,
|
||||
onChanged: (v) => setState(() => _cat = v),
|
||||
items: const [('all', '全部应用'), ('personal', '个人办公'), ('biz', '业务模块')],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 24),
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(colors: [Color(0xFFEEF4FF), Color(0xFFF8FBFF)]),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFDCE8FF)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_greet.isEmpty ? '你好,${widget.session.displayName}' : _greet,
|
||||
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text('个人办公与业务入口', style: TextStyle(color: kDeskMute, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
_stat('待办', '${_ov['pendingTodos'] ?? 0}', () => _open('todos', '待办', Icons.task_alt, const Color(0xFFFA9D3B), DeskOfficeListPage(api: widget.api, title: '待办', path: '/office/todos', buckets: const [('OPEN', '待办'), ('DONE', '已办'), ('', '全部')], defaultBucket: 'OPEN'))),
|
||||
const SizedBox(width: 10),
|
||||
_stat('待审批', '${_ov['pendingApprovals'] ?? 0}', () => _open('approvals', '待审批', Icons.fact_check, kDeskBind, DeskOfficeListPage(api: widget.api, title: '待我审批', path: '/office/approvals', buckets: const [('PENDING', '待审批'), ('', '全部')], defaultBucket: 'PENDING'))),
|
||||
const SizedBox(width: 10),
|
||||
_stat('我的申请', '${_ov['myPending'] ?? 0}', () => _open('flow', '我的申请', Icons.assignment_outlined, const Color(0xFF6267F2), DeskOfficeListPage(api: widget.api, title: '我的申请', path: '/office/flow'))),
|
||||
const SizedBox(width: 10),
|
||||
_stat('逾期', '${_ov['overdueTodos'] ?? 0}', () => _open('todos', '待办', Icons.task_alt, const Color(0xFFFA9D3B), DeskOfficeListPage(api: widget.api, title: '待办', path: '/office/todos'))),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
if (_shown.isEmpty)
|
||||
const Padding(padding: EdgeInsets.only(top: 40), child: Center(child: Text('没有匹配的应用', style: TextStyle(color: kDeskMute))))
|
||||
else
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: cols,
|
||||
mainAxisSpacing: 10,
|
||||
crossAxisSpacing: 10,
|
||||
childAspectRatio: 2.9,
|
||||
),
|
||||
itemCount: _shown.length,
|
||||
itemBuilder: (_, i) {
|
||||
final a = _shown[i];
|
||||
return DeskAppCard(
|
||||
title: a.title,
|
||||
desc: a.desc,
|
||||
icon: a.icon,
|
||||
color: a.color,
|
||||
onTap: () => _open(a.key, a.title, a.icon, a.color, a.page),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stat(String label, String value, VoidCallback onTap) {
|
||||
return Expanded(
|
||||
child: Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(value, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: kDeskBind)),
|
||||
const SizedBox(height: 2),
|
||||
Text(label, style: const TextStyle(fontSize: 12, color: kDeskMute)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _App {
|
||||
_App(this.key, this.title, this.desc, this.icon, this.color, this.page);
|
||||
final String key;
|
||||
final String title;
|
||||
final String desc;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final Widget page;
|
||||
}
|
||||
|
||||
/// 桌面端业务模块列表(表格),不沿用移动端 ModuleListPage。
|
||||
class DeskModuleListPage extends StatefulWidget {
|
||||
const DeskModuleListPage({super.key, required this.api, required this.title, required this.shell});
|
||||
final OaClient api;
|
||||
final String title;
|
||||
final RestShell shell;
|
||||
|
||||
@override
|
||||
State<DeskModuleListPage> createState() => _DeskModuleListPageState();
|
||||
}
|
||||
|
||||
class _DeskModuleListPageState extends State<DeskModuleListPage> {
|
||||
List<Map<String, dynamic>> _items = [];
|
||||
bool _loading = true;
|
||||
String _q = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final data = await widget.api.get(widget.shell.path, query: {'page': '1', 'pageSize': '100', ...?widget.shell.query});
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = asMaps(data);
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> get _shown {
|
||||
if (_q.isEmpty) return _items;
|
||||
return _items.where((e) => pickTitle(e).contains(_q) || pickSub(e).contains(_q)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shown = _shown;
|
||||
return DeskListScaffold(
|
||||
title: widget.title,
|
||||
loading: _loading,
|
||||
actions: [IconButton(onPressed: _load, icon: const Icon(Icons.refresh, size: 20))],
|
||||
filters: DeskSearchField(hint: '搜索', onChanged: (v) => setState(() => _q = v.trim())),
|
||||
body: DeskDataTable(
|
||||
columns: const ['名称', '摘要', '状态'],
|
||||
rows: [
|
||||
for (final r in shown)
|
||||
[pickTitle(r), zh(pickSub(r)), '${r['status'] ?? ''}'],
|
||||
],
|
||||
emptyHint: '暂无${widget.title}',
|
||||
onRowTap: (i) {
|
||||
final r = shown[i];
|
||||
final seed = Map<String, dynamic>.from(r);
|
||||
if ('${r['id']}'.length >= 8) seed['_fetch'] = '${widget.shell.path}/${r['id']}';
|
||||
deskOpenRecord(context, widget.api, seed, title: pickTitle(r));
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String pickTitle(Map<String, dynamic> r) {
|
||||
for (final k in ['title', 'name', 'code', 'no']) {
|
||||
final v = r[k];
|
||||
if (v != null && '$v'.isNotEmpty) return '$v';
|
||||
}
|
||||
return '记录';
|
||||
}
|
||||
|
||||
String pickSub(Map<String, dynamic> r) {
|
||||
for (final k in ['summary', 'remark', 'description', 'partyName', 'projectName']) {
|
||||
final v = r[k];
|
||||
if (v != null && '$v'.isNotEmpty) return '$v';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../desk_theme.dart';
|
||||
|
||||
/// 桌面端通用数据表格,替代移动端 KvTile 列表。
|
||||
class DeskDataTable extends StatelessWidget {
|
||||
const DeskDataTable({
|
||||
super.key,
|
||||
required this.columns,
|
||||
required this.rows,
|
||||
this.onRowTap,
|
||||
this.emptyHint = '暂无数据',
|
||||
});
|
||||
|
||||
final List<String> columns;
|
||||
final List<List<String>> rows;
|
||||
final void Function(int index)? onRowTap;
|
||||
final String emptyHint;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (rows.isEmpty) {
|
||||
return Center(child: Text(emptyHint, style: const TextStyle(color: kDeskMute)));
|
||||
}
|
||||
return Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: DataTable(
|
||||
headingRowHeight: 40,
|
||||
dataRowMinHeight: 44,
|
||||
dataRowMaxHeight: 56,
|
||||
columnSpacing: 24,
|
||||
horizontalMargin: 16,
|
||||
columns: [for (final c in columns) DataColumn(label: Text(c))],
|
||||
rows: [
|
||||
for (var i = 0; i < rows.length; i++)
|
||||
DataRow(
|
||||
onSelectChanged: onRowTap == null ? null : (_) => onRowTap!(i),
|
||||
cells: [for (final cell in rows[i]) DataCell(Text(cell, maxLines: 2, overflow: TextOverflow.ellipsis))],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DeskListScaffold extends StatelessWidget {
|
||||
const DeskListScaffold({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.body,
|
||||
this.filters,
|
||||
this.actions = const [],
|
||||
this.loading = false,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final Widget body;
|
||||
final Widget? filters;
|
||||
final List<Widget> actions;
|
||||
final bool loading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
color: kDeskBg,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ColoredBox(
|
||||
color: kDeskPane,
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
const Spacer(),
|
||||
...actions,
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (filters != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 10),
|
||||
child: filters!,
|
||||
),
|
||||
const Divider(height: 1, color: kDeskLine),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: body,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../api/oa_client.dart';
|
||||
import '../../session/session.dart';
|
||||
import '../../widgets/auth_image.dart';
|
||||
import '../desk_theme.dart';
|
||||
|
||||
class DeskNavRail extends StatelessWidget {
|
||||
const DeskNavRail({
|
||||
super.key,
|
||||
required this.session,
|
||||
required this.api,
|
||||
required this.index,
|
||||
required this.onChanged,
|
||||
required this.items,
|
||||
});
|
||||
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
final int index;
|
||||
final ValueChanged<int> onChanged;
|
||||
final List<(IconData, IconData, String, int)> items;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
color: kDeskRail,
|
||||
child: SizedBox(
|
||||
width: kDeskRailWidth,
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 18),
|
||||
DeskAvatar(
|
||||
label: session.displayName,
|
||||
fileId: '${session.user['avatarFileId'] ?? ''}',
|
||||
api: api,
|
||||
size: 36,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
for (var i = 0; i < items.length; i++)
|
||||
_RailItem(
|
||||
active: index == i,
|
||||
icon: index == i ? items[i].$2 : items[i].$1,
|
||||
label: items[i].$3,
|
||||
badge: items[i].$4,
|
||||
onTap: () => onChanged(i),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RailItem extends StatelessWidget {
|
||||
const _RailItem({
|
||||
required this.active,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.badge,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final bool active;
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final int badge;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
child: Material(
|
||||
color: active ? kDeskActive : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
children: [
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Icon(icon, size: 22, color: active ? kDeskBind : const Color(0xFF5C5C5C)),
|
||||
if (badge > 0)
|
||||
Positioned(
|
||||
right: -10,
|
||||
top: -6,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
constraints: const BoxConstraints(minWidth: 16, minHeight: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFA5151),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
badge > 99 ? '99+' : '$badge',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: active ? kDeskBind : const Color(0xFF5C5C5C),
|
||||
fontWeight: active ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DeskAvatar extends StatelessWidget {
|
||||
const DeskAvatar({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.api,
|
||||
this.fileId,
|
||||
this.size = 40,
|
||||
this.color,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final OaClient api;
|
||||
final String? fileId;
|
||||
final double size;
|
||||
final Color? color;
|
||||
final IconData? icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final id = fileId ?? '';
|
||||
if (id.isNotEmpty) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: AuthImage(fileId: id, api: api, width: size, height: size, fit: BoxFit.cover),
|
||||
);
|
||||
}
|
||||
final c = color ?? kDeskBind;
|
||||
final ch = label.isNotEmpty ? label.characters.first : '?';
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(6)),
|
||||
alignment: Alignment.center,
|
||||
child: icon != null
|
||||
? Icon(icon, color: Colors.white, size: size * 0.5)
|
||||
: Text(ch, style: TextStyle(color: Colors.white, fontSize: size * 0.38, fontWeight: FontWeight.w600)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DeskPaneHeader extends StatelessWidget {
|
||||
const DeskPaneHeader({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.actions = const [],
|
||||
this.bottom,
|
||||
this.leading,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final List<Widget> actions;
|
||||
final Widget? bottom;
|
||||
final Widget? leading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
color: kDeskPane,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: Row(
|
||||
children: [
|
||||
if (leading != null) leading!,
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: leading == null ? 14 : 4),
|
||||
child: Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: kDeskInk)),
|
||||
),
|
||||
const Spacer(),
|
||||
...actions,
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (bottom != null) bottom!,
|
||||
const Divider(height: 1, color: kDeskLine),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DeskSearchField extends StatelessWidget {
|
||||
const DeskSearchField({super.key, required this.hint, this.onChanged, this.controller});
|
||||
|
||||
final String hint;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final TextEditingController? controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 32,
|
||||
decoration: BoxDecoration(color: const Color(0xFFEFEFEF), borderRadius: BorderRadius.circular(6)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.search, size: 16, color: kDeskMute),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
onChanged: onChanged,
|
||||
style: const TextStyle(fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
hintStyle: const TextStyle(color: Color(0xFFB2B2B2), fontSize: 13),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DeskConvRow extends StatelessWidget {
|
||||
const DeskConvRow({
|
||||
super.key,
|
||||
required this.name,
|
||||
required this.preview,
|
||||
required this.time,
|
||||
required this.unread,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
this.avatarId,
|
||||
this.api,
|
||||
this.muted = false,
|
||||
this.pinned = false,
|
||||
this.onSecondaryTap,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String preview;
|
||||
final String time;
|
||||
final int unread;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
final String? avatarId;
|
||||
final OaClient? api;
|
||||
final bool muted;
|
||||
final bool pinned;
|
||||
final void Function(TapDownDetails)? onSecondaryTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: selected ? kDeskActive : Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
onSecondaryTapDown: onSecondaryTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
if (api != null)
|
||||
DeskAvatar(label: name, fileId: avatarId, api: api!, size: 40)
|
||||
else
|
||||
DeskAvatar(label: name, api: OaClient(SessionStore()), size: 40),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (pinned)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 4),
|
||||
child: Icon(Icons.push_pin, size: 12, color: kDeskMute),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: kDeskInk),
|
||||
),
|
||||
),
|
||||
Text(time, style: const TextStyle(fontSize: 11, color: kDeskMute)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
preview,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 12, color: muted ? kDeskMute : const Color(0xFF9A9A9A)),
|
||||
),
|
||||
),
|
||||
if (muted)
|
||||
const Icon(Icons.notifications_off_outlined, size: 14, color: kDeskMute),
|
||||
if (unread > 0) ...[
|
||||
const SizedBox(width: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFA5151),
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
child: Text(
|
||||
unread > 99 ? '99+' : '$unread',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 10),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DeskEmptyChat extends StatelessWidget {
|
||||
const DeskEmptyChat({super.key, this.hint = '选择一个会话开始聊天'});
|
||||
|
||||
final String hint;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
color: const Color(0xFFF5F5F5),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Opacity(
|
||||
opacity: 0.1,
|
||||
child: Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(color: kDeskBind, borderRadius: BorderRadius.circular(24)),
|
||||
child: const Icon(Icons.chat_bubble_outline, size: 52, color: Colors.white),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(hint, style: const TextStyle(color: kDeskMute, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DeskTabStrip extends StatelessWidget {
|
||||
const DeskTabStrip({
|
||||
super.key,
|
||||
required this.tabs,
|
||||
required this.active,
|
||||
required this.onSelect,
|
||||
required this.onClose,
|
||||
});
|
||||
|
||||
final List<(String key, String title, IconData icon, Color color)> tabs;
|
||||
final int active;
|
||||
final ValueChanged<int> onSelect;
|
||||
final ValueChanged<int> onClose;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
color: kDeskPane,
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 40,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
children: [
|
||||
for (var i = 0; i < tabs.length; i++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 2, top: 6, bottom: 6),
|
||||
child: Material(
|
||||
color: active == i ? const Color(0xFFF0F4FA) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: InkWell(
|
||||
onTap: () => onSelect(i),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(10, 5, i > 0 ? 4 : 10, 5),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(tabs[i].$3, size: 14, color: tabs[i].$4),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
tabs[i].$2,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: active == i ? kDeskInk : kDeskMute,
|
||||
fontWeight: active == i ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
if (i > 0) ...[
|
||||
const SizedBox(width: 2),
|
||||
InkWell(
|
||||
onTap: () => onClose(i),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(2),
|
||||
child: Icon(Icons.close, size: 12, color: kDeskMute),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, color: kDeskLine),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DeskAppCard extends StatelessWidget {
|
||||
const DeskAppCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.desc,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String desc;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 14, 14, 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(10)),
|
||||
child: Icon(icon, color: Colors.white, size: 22),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: kDeskInk)),
|
||||
const SizedBox(height: 4),
|
||||
Text(desc, maxLines: 2, overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12, color: kDeskMute, height: 1.35)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DeskFilterChips extends StatelessWidget {
|
||||
const DeskFilterChips({super.key, required this.value, required this.onChanged, required this.items});
|
||||
|
||||
final String value;
|
||||
final ValueChanged<String> onChanged;
|
||||
final List<(String, String)> items;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
for (final it in items) ...[
|
||||
_chip(it.$1, it.$2),
|
||||
if (it != items.last) const SizedBox(width: 6),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _chip(String id, String label) {
|
||||
final on = value == id;
|
||||
return GestureDetector(
|
||||
onTap: () => onChanged(id),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: on ? kDeskActive : Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: on ? const Color(0xFFB8D4FF) : kDeskLine),
|
||||
),
|
||||
child: Text(label, style: TextStyle(fontSize: 12, color: on ? kDeskBind : kDeskMute, fontWeight: on ? FontWeight.w600 : FontWeight.w400)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void deskToast(BuildContext context, String msg, {bool error = false}) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(msg),
|
||||
backgroundColor: error ? const Color(0xFFFA5151) : null,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
width: 320,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:record/record.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
|
||||
/// 系统截屏、高刷、录音、选图(保留 GIF)。
|
||||
class DeviceBridge {
|
||||
DeviceBridge._();
|
||||
static const _ch = EventChannel('com.fysxkj.oa/device');
|
||||
static const _m = MethodChannel('com.fysxkj.oa/ota');
|
||||
static StreamSubscription<dynamic>? _sub;
|
||||
static final _cbs = <void Function()>{};
|
||||
|
||||
static final AudioPlayer _player = AudioPlayer();
|
||||
static final AudioRecorder _recorder = AudioRecorder();
|
||||
static String? _recordingPath;
|
||||
static DateTime? _recordingStarted;
|
||||
static String? _playingPath;
|
||||
static OaClient? _api;
|
||||
|
||||
static void bindApi(OaClient api) => _api = api;
|
||||
|
||||
static bool get _useNativeBridge => Platform.isAndroid;
|
||||
|
||||
static Future<void> preferMaxRefresh() async {
|
||||
if (!_useNativeBridge) return;
|
||||
try {
|
||||
await _m.invokeMethod('preferMaxRefresh');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Timer? _clipPoll;
|
||||
static bool _clipHadImage = false;
|
||||
static int _lastClipAt = 0;
|
||||
|
||||
static void watchScreenshot(void Function() onShot) {
|
||||
_cbs.add(onShot);
|
||||
if (Platform.isAndroid || Platform.isWindows) {
|
||||
_sub ??= _ch.receiveBroadcastStream().listen((event) {
|
||||
if (event == 'screenshot') {
|
||||
for (final cb in [..._cbs]) {
|
||||
cb();
|
||||
}
|
||||
}
|
||||
});
|
||||
_m.invokeMethod('startScreenshotWatch').catchError((_) {});
|
||||
} else if (Platform.isLinux) {
|
||||
_startLinuxScreenshotPoll();
|
||||
}
|
||||
}
|
||||
|
||||
static void _startLinuxScreenshotPoll() {
|
||||
_clipPoll ??= Timer.periodic(const Duration(milliseconds: 900), (_) async {
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
if (now - _lastClipAt < 1500) return;
|
||||
final has = await _linuxClipboardHasImage();
|
||||
if (has && !_clipHadImage) {
|
||||
_clipHadImage = true;
|
||||
_lastClipAt = now;
|
||||
for (final cb in [..._cbs]) {
|
||||
cb();
|
||||
}
|
||||
} else if (!has) {
|
||||
_clipHadImage = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static Future<bool> _linuxClipboardHasImage() async {
|
||||
try {
|
||||
final x = await Process.run('bash', [
|
||||
'-c',
|
||||
'command -v xclip >/dev/null && xclip -selection clipboard -t TARGETS -o 2>/dev/null | grep -qi image && echo 1; '
|
||||
'command -v wl-paste >/dev/null && wl-paste --list-types 2>/dev/null | grep -qi image && echo 1',
|
||||
]);
|
||||
return '${x.stdout}'.contains('1');
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static void unwatchScreenshot(void Function() onShot) {
|
||||
_cbs.remove(onShot);
|
||||
if (_cbs.isNotEmpty) return;
|
||||
if (Platform.isAndroid || Platform.isWindows) {
|
||||
_m.invokeMethod('stopScreenshotWatch').catchError((_) {});
|
||||
_sub?.cancel();
|
||||
_sub = null;
|
||||
}
|
||||
_clipPoll?.cancel();
|
||||
_clipPoll = null;
|
||||
_clipHadImage = false;
|
||||
}
|
||||
|
||||
static Future<void> startVoice() async {
|
||||
if (_useNativeBridge) {
|
||||
await _m.invokeMethod('startVoice');
|
||||
return;
|
||||
}
|
||||
if (!await _recorder.hasPermission()) {
|
||||
throw Exception('没有麦克风权限');
|
||||
}
|
||||
final dir = await getTemporaryDirectory();
|
||||
_recordingPath = '${dir.path}/voice_${DateTime.now().millisecondsSinceEpoch}.m4a';
|
||||
_recordingStarted = DateTime.now();
|
||||
await _recorder.start(const RecordConfig(encoder: AudioEncoder.aacLc), path: _recordingPath!);
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>?> stopVoice() async {
|
||||
if (_useNativeBridge) {
|
||||
final raw = await _m.invokeMethod('stopVoice');
|
||||
if (raw is Map) return Map<String, dynamic>.from(raw);
|
||||
return null;
|
||||
}
|
||||
final path = await _recorder.stop();
|
||||
_recordingPath = null;
|
||||
if (path == null || path.isEmpty) return null;
|
||||
final started = _recordingStarted;
|
||||
_recordingStarted = null;
|
||||
final seconds = started == null ? 1 : DateTime.now().difference(started).inSeconds.clamp(1, 60);
|
||||
return {'path': path, 'seconds': seconds};
|
||||
}
|
||||
|
||||
static Future<void> cancelVoice() async {
|
||||
if (_useNativeBridge) {
|
||||
try {
|
||||
await _m.invokeMethod('cancelVoice');
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await _recorder.stop();
|
||||
} catch (_) {}
|
||||
_recordingPath = null;
|
||||
_recordingStarted = null;
|
||||
}
|
||||
|
||||
static Future<void> playVoice(String path) async {
|
||||
if (_useNativeBridge) {
|
||||
await _m.invokeMethod('playVoice', {'path': path});
|
||||
return;
|
||||
}
|
||||
await _player.stop();
|
||||
_playingPath = path;
|
||||
await _player.play(DeviceFileSource(path));
|
||||
}
|
||||
|
||||
static Future<void> stopPlay() async {
|
||||
if (_useNativeBridge) {
|
||||
try {
|
||||
await _m.invokeMethod('stopPlay');
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
await _player.stop();
|
||||
_playingPath = null;
|
||||
}
|
||||
|
||||
static String? get playingPath => _playingPath;
|
||||
|
||||
/// 手机端本地语音转文字(不调 OA 服务端)。
|
||||
static Future<String?> speechToText({required String audioPath}) async {
|
||||
if (!_useNativeBridge) return null;
|
||||
final value = await _m.invokeMethod('speechToText', {'path': audioPath});
|
||||
final text = '$value'.trim();
|
||||
return value == null || text.isEmpty || text == 'null' ? null : text;
|
||||
}
|
||||
|
||||
static final AudioPlayer _tonePlayer = AudioPlayer();
|
||||
|
||||
static Future<void> startCallTone({bool incoming = false}) async {
|
||||
if (_useNativeBridge) {
|
||||
try {
|
||||
await _m.invokeMethod('startCallTone', {'incoming': incoming});
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await _tonePlayer.setReleaseMode(ReleaseMode.loop);
|
||||
await _tonePlayer.play(AssetSource('sounds/ring.wav'));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<void> stopCallTone() async {
|
||||
if (_useNativeBridge) {
|
||||
try {
|
||||
await _m.invokeMethod('stopCallTone');
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await _tonePlayer.stop();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<void> cancelCallNotification() async {
|
||||
if (!_useNativeBridge) return;
|
||||
try {
|
||||
await _m.invokeMethod('cancelCallNotification');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<void> playMessageSent() async {
|
||||
if (!_useNativeBridge) return;
|
||||
try {
|
||||
await _m.invokeMethod('playMessageSent');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<Map<String, double>?> getLocation() async {
|
||||
if (_useNativeBridge) {
|
||||
final raw = await _m.invokeMethod('getLocation');
|
||||
if (raw is Map) {
|
||||
final lat = (raw['lat'] as num?)?.toDouble();
|
||||
final lng = (raw['lng'] as num?)?.toDouble();
|
||||
if (lat != null && lng != null) return {'lat': lat, 'lng': lng};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) return null;
|
||||
var perm = await Geolocator.checkPermission();
|
||||
if (perm == LocationPermission.denied) {
|
||||
perm = await Geolocator.requestPermission();
|
||||
}
|
||||
if (perm == LocationPermission.denied || perm == LocationPermission.deniedForever) {
|
||||
return null;
|
||||
}
|
||||
final pos = await Geolocator.getCurrentPosition(locationSettings: const LocationSettings(accuracy: LocationAccuracy.high));
|
||||
return {'lat': pos.latitude, 'lng': pos.longitude};
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<String, String>?> pickFile() async {
|
||||
if (_useNativeBridge) {
|
||||
final raw = await _m.invokeMethod('pickFile');
|
||||
if (raw is Map) return raw.map((k, v) => MapEntry('$k', '$v'));
|
||||
return null;
|
||||
}
|
||||
final picked = await FilePicker.platform.pickFiles(allowMultiple: false);
|
||||
if (picked == null || picked.files.isEmpty) return null;
|
||||
final f = picked.files.first;
|
||||
final path = f.path;
|
||||
if (path == null || path.isEmpty) return null;
|
||||
return {'path': path, 'name': f.name, 'mime': f.extension ?? ''};
|
||||
}
|
||||
|
||||
static Future<void> openFile(String path, {String? mime}) async {
|
||||
if (_useNativeBridge) {
|
||||
await _m.invokeMethod('openFile', {'path': path, 'mime': mime ?? ''});
|
||||
return;
|
||||
}
|
||||
if (Platform.isLinux) {
|
||||
await Process.run('xdg-open', [path]);
|
||||
} else if (Platform.isWindows) {
|
||||
await Process.start('cmd', ['/c', 'start', '', path], mode: ProcessStartMode.detached);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> saveImage(String path) async {
|
||||
if (_useNativeBridge) {
|
||||
await _m.invokeMethod('saveImage', {'path': path});
|
||||
return;
|
||||
}
|
||||
final name = path.split('/').last;
|
||||
final picked = await FilePicker.platform.saveFile(dialogTitle: '保存图片', fileName: name.isEmpty ? 'image.jpg' : name);
|
||||
if (picked == null || picked.isEmpty) return;
|
||||
await File(path).copy(picked);
|
||||
}
|
||||
|
||||
static Future<void> openUrl(String url) async {
|
||||
if (_useNativeBridge) {
|
||||
await _m.invokeMethod('openUrl', {'url': url});
|
||||
return;
|
||||
}
|
||||
if (Platform.isLinux) {
|
||||
await Process.run('xdg-open', [url]);
|
||||
} else if (Platform.isWindows) {
|
||||
await Process.start('cmd', ['/c', 'start', '', url], mode: ProcessStartMode.detached);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> requestCallMedia() async {
|
||||
if (!_useNativeBridge) return true;
|
||||
try {
|
||||
final ok = await _m.invokeMethod('requestCallMedia');
|
||||
return ok == true;
|
||||
} catch (_) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<String, String>?> pickMedia() async {
|
||||
if (_useNativeBridge) {
|
||||
final raw = await _m.invokeMethod('pickMedia');
|
||||
if (raw is Map) return raw.map((k, v) => MapEntry('$k', '$v'));
|
||||
return null;
|
||||
}
|
||||
final picked = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'],
|
||||
allowMultiple: false,
|
||||
);
|
||||
if (picked == null || picked.files.isEmpty) return null;
|
||||
final f = picked.files.first;
|
||||
final path = f.path;
|
||||
if (path == null || path.isEmpty) return null;
|
||||
final ext = (f.extension ?? 'jpg').toLowerCase();
|
||||
final mime = ext == 'png'
|
||||
? 'image/png'
|
||||
: ext == 'gif'
|
||||
? 'image/gif'
|
||||
: ext == 'webp'
|
||||
? 'image/webp'
|
||||
: 'image/jpeg';
|
||||
return {'path': path, 'name': f.name, 'mime': mime};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
|
||||
class PushBridge {
|
||||
PushBridge._();
|
||||
static const _m = MethodChannel('com.fysxkj.oa/ota');
|
||||
static const _ev = EventChannel('com.fysxkj.oa/push');
|
||||
static StreamSubscription<dynamic>? _sub;
|
||||
|
||||
static Future<void> requestFirstLoginPermissions() async {
|
||||
try {
|
||||
await _m.invokeMethod('requestFirstLoginPermissions');
|
||||
} catch (_) {
|
||||
try {
|
||||
await _m.invokeMethod('requestPushPermission');
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> register(OaClient api) async {
|
||||
await requestFirstLoginPermissions();
|
||||
for (var i = 0; i < 10; i++) {
|
||||
try {
|
||||
final raw = await _m.invokeMethod('getPushToken');
|
||||
if (raw is Map) {
|
||||
final regId = '${raw['regId'] ?? ''}';
|
||||
if (regId.isNotEmpty) {
|
||||
await api.post('/push/devices', {
|
||||
'vendor': '${raw['vendor'] ?? 'vivo'}',
|
||||
'regId': regId,
|
||||
'brand': '${raw['brand'] ?? ''}',
|
||||
'model': '${raw['model'] ?? ''}',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
await Future<void>.delayed(const Duration(seconds: 2));
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> showOnline(String title, String content, {Map<String, String> extras = const {}}) async {
|
||||
try {
|
||||
await _m.invokeMethod('showOnlineNotification', {'title': title, 'content': content, 'extras': extras});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<Map<String, String>?> consumeLaunch() async {
|
||||
try {
|
||||
final raw = await _m.invokeMethod('consumeLaunchPush');
|
||||
if (raw is Map) return raw.map((k, v) => MapEntry('$k', '$v'));
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
static void listen(void Function(Map<String, String>) onClick) {
|
||||
_sub?.cancel();
|
||||
_sub = _ev.receiveBroadcastStream().listen((event) {
|
||||
if (event is Map) {
|
||||
onClick(event.map((k, v) => MapEntry('$k', '$v')));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static void stop() {
|
||||
_sub?.cancel();
|
||||
_sub = null;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>?> oemStatus() async {
|
||||
try {
|
||||
final raw = await _m.invokeMethod('oemStatus');
|
||||
if (raw is Map) return Map<String, dynamic>.from(raw);
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<void> openOemKeepAlive() async {
|
||||
try {
|
||||
await _m.invokeMethod('openOemKeepAlive');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<void> requestBattery() async {
|
||||
try {
|
||||
await _m.invokeMethod('requestBattery');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<void> markOemPrompted() async {
|
||||
try {
|
||||
await _m.invokeMethod('markOemPrompted');
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// 聊天消息的本地副本。云端只保留 7 天,客户端仍可查看本机历史消息。
|
||||
class ChatLocalStore {
|
||||
ChatLocalStore._();
|
||||
|
||||
static Future<List<Map<String, dynamic>>> load(String conversationId) async {
|
||||
if (conversationId.isEmpty) return [];
|
||||
final p = await SharedPreferences.getInstance();
|
||||
final raw = p.getString('im.local.$conversationId');
|
||||
if (raw == null || raw.isEmpty) return [];
|
||||
try {
|
||||
final list = jsonDecode(raw);
|
||||
if (list is! List) return [];
|
||||
return list.whereType<Map>().map((e) => Map<String, dynamic>.from(e)).toList();
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> save(String conversationId, List<Map<String, dynamic>> items) async {
|
||||
if (conversationId.isEmpty) return;
|
||||
final p = await SharedPreferences.getInstance();
|
||||
final durable = items.where((e) => e['_pending'] != true).map((e) {
|
||||
final copy = Map<String, dynamic>.from(e)..remove('_pending');
|
||||
return copy;
|
||||
}).toList();
|
||||
await p.setString('im.local.$conversationId', jsonEncode(durable));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// 会话级本地偏好:免打扰、置顶、背景、本地清空记录。
|
||||
class ChatPrefs {
|
||||
ChatPrefs._();
|
||||
static SharedPreferences? _p;
|
||||
|
||||
static Future<void> ensure() async {
|
||||
_p ??= await SharedPreferences.getInstance();
|
||||
// 旧版本曾在滑动过程中误写 hidden,导致会话读完后看似“自动删除”。
|
||||
// 升级时清理一次旧标记,之后只有确认“不显示/删除”才会再次隐藏。
|
||||
if (_p!.getBool('im.hidden.migration.v3') != true) {
|
||||
for (final key
|
||||
in _p!.getKeys().where((k) => k.startsWith('im.hidden.'))) {
|
||||
await _p!.remove(key);
|
||||
}
|
||||
await _p!.setBool('im.hidden.migration.v3', true);
|
||||
}
|
||||
}
|
||||
|
||||
static bool muted(String id) =>
|
||||
id.isEmpty ? false : (_p?.getBool('im.mute.$id') ?? false);
|
||||
static bool pinned(String id) =>
|
||||
id.isEmpty ? false : (_p?.getBool('im.pin.$id') ?? false);
|
||||
static bool hidden(String id) =>
|
||||
id.isEmpty ? false : (_p?.getBool('im.hidden.$id') ?? false);
|
||||
static int? bgColor(String id) => id.isEmpty ? null : _p?.getInt('im.bg.$id');
|
||||
static DateTime? clearedAt(String id) {
|
||||
if (id.isEmpty) return null;
|
||||
final ms = _p?.getInt('im.cleared.$id');
|
||||
if (ms == null || ms <= 0) return null;
|
||||
return DateTime.fromMillisecondsSinceEpoch(ms);
|
||||
}
|
||||
|
||||
static Future<void> setMuted(String id, bool v) async {
|
||||
await ensure();
|
||||
await _p!.setBool('im.mute.$id', v);
|
||||
}
|
||||
|
||||
static Future<void> setPinned(String id, bool v) async {
|
||||
await ensure();
|
||||
await _p!.setBool('im.pin.$id', v);
|
||||
}
|
||||
|
||||
static Future<void> setHidden(String id, bool v) async {
|
||||
await ensure();
|
||||
await _p!.setBool('im.hidden.$id', v);
|
||||
}
|
||||
|
||||
static Future<void> setBg(String id, int color) async {
|
||||
await ensure();
|
||||
await _p!.setInt('im.bg.$id', color);
|
||||
}
|
||||
|
||||
static Future<void> clearHistory(String id) async {
|
||||
await ensure();
|
||||
await _p!.setInt('im.cleared.$id', DateTime.now().millisecondsSinceEpoch);
|
||||
}
|
||||
|
||||
static int fakeUnread(String id) =>
|
||||
id.isEmpty ? 0 : (_p?.getInt('im.fakeUnread.$id') ?? 0);
|
||||
|
||||
static Future<void> setFakeUnread(String id, int n) async {
|
||||
await ensure();
|
||||
if (n <= 0) {
|
||||
await _p!.remove('im.fakeUnread.$id');
|
||||
} else {
|
||||
await _p!.setInt('im.fakeUnread.$id', n);
|
||||
}
|
||||
}
|
||||
|
||||
/// 语音转文字结果,按消息 id 或 fileId 缓存。
|
||||
static String? voiceText(String key) {
|
||||
if (key.isEmpty) return null;
|
||||
final v = _p?.getString('im.voiceText.$key');
|
||||
if (v == null || v.isEmpty) return null;
|
||||
return v;
|
||||
}
|
||||
|
||||
static Future<void> setVoiceText(String key, String text) async {
|
||||
if (key.isEmpty) return;
|
||||
await ensure();
|
||||
final t = text.trim();
|
||||
if (t.isEmpty) {
|
||||
await _p!.remove('im.voiceText.$key');
|
||||
} else {
|
||||
await _p!.setString('im.voiceText.$key', t);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// 与官方 MobileIMSDK 对齐的生命周期:登录前 init,退出 release。
|
||||
/// Android 走 MethodChannel;Windows/Linux 无原生插件时回落到 Dart TCP。
|
||||
class ImBridge {
|
||||
static const _ch = MethodChannel('com.fysxkj.oa/im');
|
||||
bool nativeReady = false;
|
||||
String engine = 'dart-tcp';
|
||||
|
||||
Future<void> init() async {
|
||||
try {
|
||||
final r = await _ch.invokeMethod<Map>('init');
|
||||
nativeReady = r?['ok'] == true || r?['initialized'] == true;
|
||||
engine = '${r?['engine'] ?? 'dart-tcp'}';
|
||||
} on MissingPluginException {
|
||||
nativeReady = false;
|
||||
engine = 'dart-tcp';
|
||||
} catch (_) {
|
||||
nativeReady = false;
|
||||
engine = 'dart-tcp';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> release() async {
|
||||
try {
|
||||
await _ch.invokeMethod('release');
|
||||
} catch (_) {}
|
||||
nativeReady = false;
|
||||
}
|
||||
|
||||
Future<String> status() async {
|
||||
try {
|
||||
final r = await _ch.invokeMethod<String>('engine');
|
||||
if (r != null && r.isNotEmpty) engine = r;
|
||||
} catch (_) {}
|
||||
return engine;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../session/session.dart';
|
||||
|
||||
/// 监听网络切换与应用恢复,触发 IM 极速重连。
|
||||
class ImLifecycle with WidgetsBindingObserver {
|
||||
ImLifecycle(this.session);
|
||||
final SessionStore session;
|
||||
StreamSubscription<List<ConnectivityResult>>? _netSub;
|
||||
List<ConnectivityResult> _lastNet = const [];
|
||||
|
||||
void start() {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_netSub = Connectivity().onConnectivityChanged.listen(_onNet);
|
||||
Connectivity().checkConnectivity().then((r) => _lastNet = r);
|
||||
}
|
||||
|
||||
void stop() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_netSub?.cancel();
|
||||
_netSub = null;
|
||||
}
|
||||
|
||||
void _onNet(List<ConnectivityResult> next) {
|
||||
final wasOffline = _lastNet.every((e) => e == ConnectivityResult.none);
|
||||
final nowOnline = next.any((e) => e != ConnectivityResult.none);
|
||||
_lastNet = next;
|
||||
if (wasOffline && nowOnline && session.signedIn) {
|
||||
unawaited(session.connectIm(force: true));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed && session.signedIn) {
|
||||
unawaited(session.connectIm(force: true));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// 本地记录各会话已同步到的最大 seq,断线重连后用于 gap fill。
|
||||
class ImSeqStore {
|
||||
ImSeqStore._();
|
||||
static SharedPreferences? _p;
|
||||
|
||||
static Future<void> ensure() async {
|
||||
_p ??= await SharedPreferences.getInstance();
|
||||
}
|
||||
|
||||
static Future<int> lastSeq(String conversationId) async {
|
||||
if (conversationId.isEmpty) return 0;
|
||||
await ensure();
|
||||
return _p?.getInt('im.seq.$conversationId') ?? 0;
|
||||
}
|
||||
|
||||
static Future<void> setLastSeq(String conversationId, int seq) async {
|
||||
if (conversationId.isEmpty || seq <= 0) return;
|
||||
await ensure();
|
||||
final prev = _p?.getInt('im.seq.$conversationId') ?? 0;
|
||||
if (seq > prev) await _p!.setInt('im.seq.$conversationId', seq);
|
||||
}
|
||||
|
||||
static Future<void> applyItems(String conversationId, Iterable<Map<String, dynamic>> items) async {
|
||||
var max = 0;
|
||||
for (final e in items) {
|
||||
final s = (e['seq'] as num?)?.toInt() ?? 0;
|
||||
if (s > max) max = s;
|
||||
}
|
||||
if (max > 0) await setLastSeq(conversationId, max);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/// 姓名首字母。按百家姓,避免大量同事被丢进「#」。
|
||||
const _surnames = {
|
||||
'赵': 'Z', '钱': 'Q', '孙': 'S', '李': 'L', '周': 'Z', '吴': 'W', '郑': 'Z', '王': 'W',
|
||||
'冯': 'F', '陈': 'C', '褚': 'C', '卫': 'W', '蒋': 'J', '沈': 'S', '韩': 'H', '杨': 'Y',
|
||||
'朱': 'Z', '秦': 'Q', '尤': 'Y', '许': 'X', '何': 'H', '吕': 'L', '施': 'S', '张': 'Z',
|
||||
'孔': 'K', '曹': 'C', '严': 'Y', '华': 'H', '金': 'J', '魏': 'W', '陶': 'T', '姜': 'J',
|
||||
'戚': 'Q', '谢': 'X', '邹': 'Z', '喻': 'Y', '柏': 'B', '水': 'S', '窦': 'D', '章': 'Z',
|
||||
'云': 'Y', '苏': 'S', '潘': 'P', '葛': 'G', '奚': 'X', '范': 'F', '彭': 'P', '郎': 'L',
|
||||
'鲁': 'L', '韦': 'W', '昌': 'C', '马': 'M', '苗': 'M', '凤': 'F', '花': 'H', '方': 'F',
|
||||
'俞': 'Y', '任': 'R', '袁': 'Y', '柳': 'L', '酆': 'F', '鲍': 'B', '史': 'S', '唐': 'T',
|
||||
'费': 'F', '廉': 'L', '岑': 'C', '薛': 'X', '雷': 'L', '贺': 'H', '倪': 'N', '汤': 'T',
|
||||
'滕': 'T', '殷': 'Y', '罗': 'L', '毕': 'B', '郝': 'H', '邬': 'W', '安': 'A', '常': 'C',
|
||||
'乐': 'Y', '于': 'Y', '时': 'S', '傅': 'F', '皮': 'P', '卞': 'B', '齐': 'Q', '康': 'K',
|
||||
'伍': 'W', '余': 'Y', '元': 'Y', '卜': 'B', '顾': 'G', '孟': 'M', '平': 'P', '黄': 'H',
|
||||
'和': 'H', '穆': 'M', '萧': 'X', '尹': 'Y', '姚': 'Y', '邵': 'S', '湛': 'Z', '汪': 'W',
|
||||
'祁': 'Q', '毛': 'M', '禹': 'Y', '狄': 'D', '米': 'M', '贝': 'B', '明': 'M', '臧': 'Z',
|
||||
'计': 'J', '伏': 'F', '成': 'C', '戴': 'D', '谈': 'T', '宋': 'S', '茅': 'M', '庞': 'P',
|
||||
'熊': 'X', '纪': 'J', '舒': 'S', '屈': 'Q', '项': 'X', '祝': 'Z', '董': 'D', '梁': 'L',
|
||||
'杜': 'D', '阮': 'R', '蓝': 'L', '闵': 'M', '席': 'X', '季': 'J', '麻': 'M', '强': 'Q',
|
||||
'贾': 'J', '路': 'L', '娄': 'L', '危': 'W', '江': 'J', '童': 'T', '颜': 'Y', '郭': 'G',
|
||||
'梅': 'M', '盛': 'S', '林': 'L', '刁': 'D', '钟': 'Z', '徐': 'X', '邱': 'Q', '骆': 'L',
|
||||
'高': 'G', '夏': 'X', '蔡': 'C', '田': 'T', '樊': 'F', '胡': 'H', '凌': 'L', '霍': 'H',
|
||||
'虞': 'Y', '万': 'W', '支': 'Z', '柯': 'K', '昝': 'Z', '管': 'G', '卢': 'L', '莫': 'M',
|
||||
'经': 'J', '房': 'F', '裘': 'Q', '缪': 'M', '干': 'G', '解': 'X', '应': 'Y', '宗': 'Z',
|
||||
'丁': 'D', '宣': 'X', '贲': 'B', '邓': 'D', '郁': 'Y', '单': 'S', '杭': 'H', '洪': 'H',
|
||||
'包': 'B', '诸': 'Z', '左': 'Z', '石': 'S', '崔': 'C', '吉': 'J', '钮': 'N', '龚': 'G',
|
||||
'程': 'C', '嵇': 'J', '邢': 'X', '滑': 'H', '裴': 'P', '陆': 'L', '荣': 'R', '翁': 'W',
|
||||
'荀': 'X', '羊': 'Y', '於': 'Y', '惠': 'H', '甄': 'Z', '曲': 'Q', '家': 'J', '封': 'F',
|
||||
'芮': 'R', '羿': 'Y', '储': 'C', '靳': 'J', '汲': 'J', '邴': 'B', '糜': 'M', '松': 'S',
|
||||
'井': 'J', '段': 'D', '富': 'F', '巫': 'W', '乌': 'W', '焦': 'J', '巴': 'B', '弓': 'G',
|
||||
'牧': 'M', '隗': 'K', '山': 'S', '谷': 'G', '车': 'C', '侯': 'H', '宓': 'M', '蓬': 'P',
|
||||
'全': 'Q', '郗': 'X', '班': 'B', '仰': 'Y', '秋': 'Q', '仲': 'Z', '伊': 'Y', '宫': 'G',
|
||||
'宁': 'N', '仇': 'Q', '栾': 'L', '暴': 'B', '甘': 'G', '钭': 'T', '厉': 'L', '戎': 'R',
|
||||
'祖': 'Z', '武': 'W', '符': 'F', '刘': 'L', '景': 'J', '詹': 'Z', '束': 'S', '龙': 'L',
|
||||
'叶': 'Y', '幸': 'X', '司': 'S', '韶': 'S', '郜': 'G', '黎': 'L', '蓟': 'J', '薄': 'B',
|
||||
'印': 'Y', '宿': 'S', '白': 'B', '怀': 'H', '蒲': 'P', '邰': 'T', '从': 'C', '鄂': 'E',
|
||||
'索': 'S', '咸': 'X', '籍': 'J', '赖': 'L', '卓': 'Z', '蔺': 'L', '屠': 'T', '蒙': 'M',
|
||||
'池': 'C', '乔': 'Q', '阴': 'Y', '胥': 'X', '能': 'N', '苍': 'C', '双': 'S', '闻': 'W',
|
||||
'莘': 'S', '党': 'D', '翟': 'Z', '谭': 'T', '贡': 'G', '劳': 'L', '逄': 'P', '姬': 'J',
|
||||
'申': 'S', '扶': 'F', '堵': 'D', '冉': 'R', '宰': 'Z', '郦': 'L', '雍': 'Y', '郤': 'X',
|
||||
'璩': 'Q', '桑': 'S', '桂': 'G', '濮': 'P', '牛': 'N', '寿': 'S', '通': 'T', '边': 'B',
|
||||
'扈': 'H', '燕': 'Y', '冀': 'J', '郏': 'J', '浦': 'P', '尚': 'S', '农': 'N', '温': 'W',
|
||||
'别': 'B', '庄': 'Z', '晏': 'Y', '柴': 'C', '瞿': 'Q', '阎': 'Y', '充': 'C', '慕': 'M',
|
||||
'连': 'L', '茹': 'R', '习': 'X', '宦': 'H', '艾': 'A', '鱼': 'Y', '容': 'R', '向': 'X',
|
||||
'古': 'G', '易': 'Y', '慎': 'S', '戈': 'G', '廖': 'L', '庾': 'Y', '终': 'Z', '暨': 'J',
|
||||
'居': 'J', '衡': 'H', '步': 'B', '都': 'D', '耿': 'G', '满': 'M', '弘': 'H', '匡': 'K',
|
||||
'国': 'G', '文': 'W', '寇': 'K', '广': 'G', '禄': 'L', '阙': 'Q', '东': 'D', '欧': 'O',
|
||||
'殳': 'S', '沃': 'W', '利': 'L', '蔚': 'W', '越': 'Y', '夔': 'K', '隆': 'L', '师': 'S',
|
||||
'巩': 'G', '厍': 'S', '聂': 'N', '晁': 'C', '勾': 'G', '敖': 'A', '融': 'R', '冷': 'L',
|
||||
'訾': 'Z', '辛': 'X', '阚': 'K', '那': 'N', '简': 'J', '饶': 'R', '空': 'K', '曾': 'Z',
|
||||
'毋': 'W', '沙': 'S', '乜': 'N', '养': 'Y', '鞠': 'J', '须': 'X', '丰': 'F', '巢': 'C',
|
||||
'关': 'G', '蒯': 'K', '相': 'X', '查': 'Z', '后': 'H', '荆': 'J', '红': 'H', '游': 'Y',
|
||||
'竺': 'Z', '权': 'Q', '逯': 'L', '盖': 'G', '益': 'Y', '桓': 'H', '公': 'G', '万俟': 'M',
|
||||
'司马': 'S', '上官': 'S', '欧阳': 'O', '夏侯': 'X', '诸葛': 'Z', '闻人': 'W', '东方': 'D',
|
||||
'赫连': 'H', '皇甫': 'H', '尉迟': 'Y', '公羊': 'G', '澹台': 'T', '公冶': 'G', '宗政': 'Z',
|
||||
'濮阳': 'P', '淳于': 'C', '单于': 'C', '太叔': 'T', '申屠': 'S', '公孙': 'G', '仲孙': 'Z',
|
||||
'轩辕': 'X', '令狐': 'L', '钟离': 'Z', '宇文': 'Y', '长孙': 'Z', '慕容': 'M', '鲜于': 'X',
|
||||
'闾丘': 'L', '司徒': 'S', '司空': 'S', '丌官': 'Q', '司寇': 'S', '仉': 'Z', '督': 'D',
|
||||
'子车': 'Z', '颛孙': 'Z', '端木': 'D', '巫马': 'W', '公西': 'G', '漆雕': 'Q', '乐正': 'Y',
|
||||
'壤驷': 'R', '公良': 'G', '拓跋': 'T', '夹谷': 'J', '宰父': 'Z', '谷梁': 'G', '晋': 'J',
|
||||
'楚': 'C', '闫': 'Y', '法': 'F', '汝': 'R', '鄢': 'Y', '涂': 'T', '钦': 'Q', '段干': 'D',
|
||||
'百里': 'B', '东郭': 'D', '南门': 'N', '呼延': 'H', '归': 'G', '海': 'H', '羊舌': 'Y',
|
||||
'微生': 'W', '岳': 'Y', '帅': 'S', '缑': 'G', '亢': 'K', '况': 'K', '有': 'Y',
|
||||
'琴': 'Q', '梁丘': 'L', '左丘': 'Z', '东门': 'D', '西门': 'X', '商': 'S', '牟': 'M',
|
||||
'佘': 'S', '佴': 'N', '伯': 'B', '赏': 'S', '南宫': 'N', '墨': 'M', '哈': 'H', '谯': 'Q',
|
||||
'笪': 'D', '年': 'N', '爱': 'A', '阳': 'Y', '佟': 'T', '言': 'Y', '福': 'F', '西': 'X',
|
||||
'南': 'N', '北': 'B',
|
||||
};
|
||||
|
||||
String letterOf(String name) {
|
||||
final n = name.trim();
|
||||
if (n.isEmpty) return '#';
|
||||
if (n.length >= 2) {
|
||||
final two = _surnames[n.substring(0, 2)];
|
||||
if (two != null) return two;
|
||||
}
|
||||
final c = n[0];
|
||||
if (RegExp(r'[A-Za-z]').hasMatch(c)) return c.toUpperCase();
|
||||
if (RegExp(r'[0-9]').hasMatch(c)) return '#';
|
||||
return _surnames[c] ?? '#';
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// MobileIMSDK 官方 TCP 帧:4 字节大端长度 + UTF-8 JSON Protocal。
|
||||
class ClientType {
|
||||
static const login = 0;
|
||||
static const keepAlive = 1;
|
||||
static const commonData = 2;
|
||||
static const logout = 3;
|
||||
static const received = 4;
|
||||
static const echo = 5;
|
||||
}
|
||||
|
||||
class ServerType {
|
||||
static const login = 50;
|
||||
static const keepAlive = 51;
|
||||
static const error = 52;
|
||||
static const echo = 53;
|
||||
static const kickout = 54;
|
||||
}
|
||||
|
||||
class Protocal {
|
||||
Protocal({
|
||||
required this.type,
|
||||
this.dataContent = '',
|
||||
this.from = '0',
|
||||
this.to = '0',
|
||||
this.fp,
|
||||
this.qos = false,
|
||||
this.typeu = -1,
|
||||
this.sm = -1,
|
||||
});
|
||||
|
||||
int type;
|
||||
String dataContent;
|
||||
String from;
|
||||
String to;
|
||||
String? fp;
|
||||
bool qos;
|
||||
int typeu;
|
||||
int sm;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'bridge': false,
|
||||
'type': type,
|
||||
'dataContent': dataContent,
|
||||
'from': from,
|
||||
'to': to,
|
||||
'fp': fp,
|
||||
'QoS': qos,
|
||||
'typeu': typeu,
|
||||
'sm': sm,
|
||||
};
|
||||
|
||||
factory Protocal.fromJson(Map<String, dynamic> j) => Protocal(
|
||||
type: (j['type'] as num?)?.toInt() ?? 0,
|
||||
dataContent: '${j['dataContent'] ?? ''}',
|
||||
from: '${j['from'] ?? '0'}',
|
||||
to: '${j['to'] ?? '0'}',
|
||||
fp: j['fp']?.toString(),
|
||||
qos: j['QoS'] == true || j['qos'] == true,
|
||||
typeu: (j['typeu'] as num?)?.toInt() ?? -1,
|
||||
sm: (j['sm'] as num?)?.toInt() ?? -1,
|
||||
);
|
||||
}
|
||||
|
||||
Uint8List encodeFrame(Protocal p) {
|
||||
final body = utf8.encode(jsonEncode(p.toJson()));
|
||||
final header = ByteData(4)..setUint32(0, body.length);
|
||||
return Uint8List.fromList([...header.buffer.asUint8List(), ...body]);
|
||||
}
|
||||
|
||||
const maxBody = 6 * 1024;
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// 自定义表情:服务端 fileId 列表。GIF / 表情面板发出的都走这里。
|
||||
class StickerStore {
|
||||
StickerStore._();
|
||||
static const _key = 'im.stickers';
|
||||
static SharedPreferences? _p;
|
||||
static List<String> _ids = [];
|
||||
|
||||
static List<String> get ids => List.unmodifiable(_ids);
|
||||
|
||||
static Future<void> ensure() async {
|
||||
_p ??= await SharedPreferences.getInstance();
|
||||
_ids = _p!.getStringList(_key) ?? [];
|
||||
}
|
||||
|
||||
static bool has(String fileId) => fileId.isNotEmpty && _ids.contains(fileId);
|
||||
|
||||
static Future<void> add(String fileId) async {
|
||||
if (fileId.isEmpty) return;
|
||||
await ensure();
|
||||
if (_ids.contains(fileId)) return;
|
||||
_ids = [fileId, ..._ids];
|
||||
await _p!.setStringList(_key, _ids);
|
||||
}
|
||||
|
||||
static Future<void> remove(String fileId) async {
|
||||
await ensure();
|
||||
_ids = _ids.where((e) => e != fileId).toList();
|
||||
await _p!.setStringList(_key, _ids);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import 'protocol.dart';
|
||||
import '../device/device_bridge.dart';
|
||||
import '../device/push_bridge.dart';
|
||||
|
||||
class ImIncoming {
|
||||
ImIncoming({
|
||||
required this.from,
|
||||
required this.text,
|
||||
this.kind = 'chat',
|
||||
this.fp,
|
||||
this.seq = 0,
|
||||
this.raw = const {},
|
||||
});
|
||||
final String from;
|
||||
final String text;
|
||||
final String kind;
|
||||
final String? fp;
|
||||
final int seq;
|
||||
final Map<String, dynamic> raw;
|
||||
}
|
||||
|
||||
class _Pending {
|
||||
_Pending(this.p);
|
||||
final Protocal p;
|
||||
int tries = 0;
|
||||
}
|
||||
|
||||
/// 官方 TCP ClientCoreSDK 等价实现:登录、心跳、通用数据、QoS 应答与重发。
|
||||
class ImTcpClient extends ChangeNotifier {
|
||||
Socket? _socket;
|
||||
Uint8List _buf = Uint8List(0);
|
||||
Timer? _hb;
|
||||
Timer? _qos;
|
||||
Timer? _reconnect;
|
||||
bool connected = false;
|
||||
bool loggedIn = false;
|
||||
String lastError = '';
|
||||
String userId = '';
|
||||
int firstLoginTime = 0;
|
||||
final Map<String, _Pending> _pending = {};
|
||||
final List<ImIncoming> inbox = [];
|
||||
final _uuid = const Uuid();
|
||||
String _host = '';
|
||||
int _port = 0;
|
||||
String _token = '';
|
||||
bool _want = false;
|
||||
int _tries = 0;
|
||||
DateTime _lastHbAck = DateTime.now();
|
||||
VoidCallback? onLoggedIn;
|
||||
|
||||
Future<void> connect({
|
||||
required String host,
|
||||
required int port,
|
||||
required String userId,
|
||||
required String token,
|
||||
}) async {
|
||||
_host = host;
|
||||
_port = port;
|
||||
_token = token;
|
||||
this.userId = userId;
|
||||
_want = true;
|
||||
_tries = 0;
|
||||
await _open();
|
||||
}
|
||||
|
||||
Future<void> _open() async {
|
||||
_tearDownSocket();
|
||||
lastError = '';
|
||||
try {
|
||||
_socket = await Socket.connect(_host, _port,
|
||||
timeout: const Duration(seconds: 8));
|
||||
connected = true;
|
||||
_tries = 0;
|
||||
notifyListeners();
|
||||
_socket!.listen(_onData, onDone: _onDrop, onError: (_) => _onDrop());
|
||||
firstLoginTime = DateTime.now().millisecondsSinceEpoch;
|
||||
_send(Protocal(
|
||||
type: ClientType.login,
|
||||
from: userId,
|
||||
dataContent: jsonEncode({
|
||||
'loginUserId': userId,
|
||||
'loginToken': _token,
|
||||
'clientKind': 'mobile',
|
||||
'extra': '',
|
||||
'firstLoginTime': firstLoginTime,
|
||||
}),
|
||||
));
|
||||
_hb = Timer.periodic(const Duration(seconds: 5), (_) {
|
||||
if (loggedIn) {
|
||||
if (DateTime.now().difference(_lastHbAck).inSeconds > 25) {
|
||||
_onDrop();
|
||||
return;
|
||||
}
|
||||
_send(Protocal(type: ClientType.keepAlive, from: userId));
|
||||
}
|
||||
});
|
||||
_qos = Timer.periodic(const Duration(seconds: 4), (_) => _retryQos());
|
||||
} catch (e) {
|
||||
lastError = 'IM 连接失败:$e';
|
||||
connected = false;
|
||||
loggedIn = false;
|
||||
notifyListeners();
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
void _onDrop() {
|
||||
final was = connected || loggedIn;
|
||||
_tearDownSocket();
|
||||
if (was) notifyListeners();
|
||||
if (_want) _scheduleReconnect();
|
||||
}
|
||||
|
||||
void _scheduleReconnect() {
|
||||
if (!_want) return;
|
||||
_reconnect?.cancel();
|
||||
_tries = (_tries + 1).clamp(1, 12);
|
||||
_reconnect = Timer(Duration(seconds: _tries < 4 ? 2 : 5), () {
|
||||
if (_want && !loggedIn) _open();
|
||||
});
|
||||
}
|
||||
|
||||
void disconnect() {
|
||||
_want = false;
|
||||
_reconnect?.cancel();
|
||||
_reconnect = null;
|
||||
_tearDownSocket();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _tearDownSocket() {
|
||||
_hb?.cancel();
|
||||
_qos?.cancel();
|
||||
_hb = null;
|
||||
_qos = null;
|
||||
try {
|
||||
_socket?.destroy();
|
||||
} catch (_) {}
|
||||
_socket = null;
|
||||
_buf = Uint8List(0);
|
||||
connected = false;
|
||||
loggedIn = false;
|
||||
}
|
||||
|
||||
void bump() {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<String?> sendChat(String toUserId, String text) async {
|
||||
if (!loggedIn) return null;
|
||||
final fp = _uuid.v4();
|
||||
final p = Protocal(
|
||||
type: ClientType.commonData,
|
||||
from: userId,
|
||||
to: toUserId,
|
||||
dataContent:
|
||||
jsonEncode({'kind': 'chat', 'text': text, 'fromUserId': userId}),
|
||||
qos: true,
|
||||
fp: fp,
|
||||
typeu: 0,
|
||||
sm: DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
_pending[fp] = _Pending(p);
|
||||
_send(p);
|
||||
return fp;
|
||||
}
|
||||
|
||||
void _retryQos() {
|
||||
for (final e in [..._pending.entries]) {
|
||||
e.value.tries++;
|
||||
if (e.value.tries > 3) {
|
||||
_pending.remove(e.key);
|
||||
continue;
|
||||
}
|
||||
_send(e.value.p);
|
||||
}
|
||||
}
|
||||
|
||||
void _send(Protocal p) {
|
||||
final s = _socket;
|
||||
if (s == null) return;
|
||||
try {
|
||||
s.add(encodeFrame(p));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _onData(List<int> chunk) {
|
||||
_buf = Uint8List.fromList([..._buf, ...chunk]);
|
||||
while (_buf.length >= 4) {
|
||||
final len = ByteData.sublistView(_buf).getUint32(0);
|
||||
if (len <= 0 || len > maxBody) {
|
||||
disconnect();
|
||||
return;
|
||||
}
|
||||
if (_buf.length < 4 + len) break;
|
||||
final body = utf8.decode(_buf.sublist(4, 4 + len));
|
||||
_buf = Uint8List.fromList(_buf.sublist(4 + len));
|
||||
try {
|
||||
_handle(Protocal.fromJson(jsonDecode(body) as Map<String, dynamic>));
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
void _handle(Protocal p) {
|
||||
switch (p.type) {
|
||||
case ServerType.login:
|
||||
try {
|
||||
final info = jsonDecode(p.dataContent.isEmpty ? '{}' : p.dataContent);
|
||||
loggedIn = info is Map && info['code'] == 0;
|
||||
if (loggedIn && info['firstLoginTime'] != null) {
|
||||
firstLoginTime = (info['firstLoginTime'] as num).toInt();
|
||||
}
|
||||
if (!loggedIn) lastError = 'IM 登录被拒绝';
|
||||
} catch (_) {
|
||||
loggedIn = false;
|
||||
}
|
||||
if (loggedIn) {
|
||||
_lastHbAck = DateTime.now();
|
||||
onLoggedIn?.call();
|
||||
}
|
||||
notifyListeners();
|
||||
break;
|
||||
case ServerType.keepAlive:
|
||||
case ServerType.echo:
|
||||
_lastHbAck = DateTime.now();
|
||||
break;
|
||||
case ServerType.kickout:
|
||||
_tearDownSocket();
|
||||
notifyListeners();
|
||||
if (_want) _scheduleReconnect();
|
||||
break;
|
||||
case ClientType.received:
|
||||
_pending.remove(p.dataContent);
|
||||
break;
|
||||
case ClientType.commonData:
|
||||
if (p.qos && p.fp != null) {
|
||||
_send(Protocal(
|
||||
type: ClientType.received,
|
||||
from: userId,
|
||||
to: p.from,
|
||||
dataContent: p.fp!));
|
||||
}
|
||||
String text = p.dataContent;
|
||||
var kind = 'chat';
|
||||
Map<String, dynamic> raw = {};
|
||||
try {
|
||||
raw = Map<String, dynamic>.from(jsonDecode(p.dataContent) as Map);
|
||||
text = '${raw['text'] ?? raw['title'] ?? raw['body'] ?? text}';
|
||||
kind = '${raw['kind'] ?? 'chat'}';
|
||||
} catch (_) {}
|
||||
final seq = (raw['seq'] as num?)?.toInt() ??
|
||||
(p.sm > 0 ? p.sm : 0);
|
||||
final fp = '${raw['fingerprint'] ?? p.fp ?? ''}';
|
||||
if (fp.isNotEmpty) {
|
||||
final dup = inbox.any((e) => e.fp == fp);
|
||||
if (dup) break;
|
||||
}
|
||||
inbox.insert(
|
||||
0,
|
||||
ImIncoming(
|
||||
from: p.from,
|
||||
text: text,
|
||||
kind: kind,
|
||||
fp: fp.isEmpty ? p.fp : fp,
|
||||
seq: seq,
|
||||
raw: raw));
|
||||
final terminalCall = kind == 'call' &&
|
||||
const {'end', 'reject', 'timeout'}
|
||||
.contains('${raw['action'] ?? ''}');
|
||||
if (terminalCall) {
|
||||
unawaited(DeviceBridge.cancelCallNotification());
|
||||
} else if (p.from != userId && kind != 'receipt' && kind != 'signal') {
|
||||
unawaited(PushBridge.showOnline(
|
||||
'${raw['fromName'] ?? raw['title'] ?? '风影办公'}',
|
||||
text,
|
||||
extras: {
|
||||
if (raw['conversationId'] != null)
|
||||
'conversationId': '${raw['conversationId']}',
|
||||
if (raw['kind'] != null) 'kind': '$kind',
|
||||
if (raw['callId'] != null) 'callId': '${raw['callId']}',
|
||||
if (raw['callKind'] != null) 'callKind': '${raw['callKind']}',
|
||||
if (raw['fromName'] != null) 'fromName': '${raw['fromName']}',
|
||||
if (raw['fromUserId'] != null) 'peerId': '${raw['fromUserId']}',
|
||||
if (raw['action'] != null) 'action': '${raw['action']}',
|
||||
},
|
||||
));
|
||||
}
|
||||
if (inbox.length > 200) inbox.removeLast();
|
||||
notifyListeners();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'theme.dart';
|
||||
|
||||
/// 后端枚举 / 码 → 中文。对齐 web `labels.ts` + 投标状态。
|
||||
const zhMap = <String, String>{
|
||||
'TECH_INITIAL': '技术初审',
|
||||
'FETCH': '取标',
|
||||
'TECH_FINAL': '技术终审',
|
||||
'DRAFTING': '制标',
|
||||
'REVIEW1': '商务一审',
|
||||
'REVIEW2': '商务二审',
|
||||
'PRINT': '印封',
|
||||
'LIST': '投标执行',
|
||||
'PENDING': '待审批',
|
||||
'WON': '中标',
|
||||
'LOST': '未中标',
|
||||
'FAILED': '流标废标',
|
||||
'TERMINATED': '已终止',
|
||||
'DRAFT': '草稿',
|
||||
'APPROVED': '已通过',
|
||||
'REJECTED': '已驳回',
|
||||
'CANCELLED': '已取消',
|
||||
'ACTIVE': '启用',
|
||||
'DISABLED': '停用',
|
||||
'OPEN': '进行中',
|
||||
'OFFSET': '已冲账',
|
||||
'DONE': '已完成',
|
||||
'RETURNED': '已交回',
|
||||
'PAID': '已付',
|
||||
'UNPAID': '待付',
|
||||
'TO_ISSUE': '待开',
|
||||
'ISSUED': '已开',
|
||||
'RECEIVED': '已回',
|
||||
'OVERDUE': '逾期',
|
||||
'INTERNAL_ACCEPTED': '内部已验收',
|
||||
'ACCEPTED': '已验收',
|
||||
'ALL': '全部',
|
||||
'DEPT': '本部门',
|
||||
'SELF': '本人',
|
||||
'BID': '投标',
|
||||
'PROJECT': '正式项目',
|
||||
'ADMIN': '行政',
|
||||
'MARKETING': '营销',
|
||||
'LABOR': '人工',
|
||||
'TRAVEL': '差旅',
|
||||
'PURCHASE': '采购',
|
||||
'TENDER': '招标',
|
||||
'PERFORMANCE': '履约保证金',
|
||||
'WARRANTY': '项目质保金',
|
||||
'EXPENSE': '报销',
|
||||
'BOND': '保证金打出',
|
||||
'LOAN': '借款',
|
||||
'OFFICE': '办公申请',
|
||||
'SEAL': '用章',
|
||||
'HR': '人事',
|
||||
'LEAVE': '请假',
|
||||
'OVERTIME': '加班',
|
||||
'TRIP': '出差',
|
||||
'OUT': '外出',
|
||||
'RESIGN': '离职',
|
||||
'PROMOTE': '晋升',
|
||||
'INTERN': '实习',
|
||||
'PROBATION': '试用',
|
||||
'REGULAR': '正式',
|
||||
'LEFT': '离职',
|
||||
'SINGLE': '单休',
|
||||
'DOUBLE': '双休',
|
||||
'MALE': '男',
|
||||
'FEMALE': '女',
|
||||
'HIGH_SCHOOL': '高中及以下',
|
||||
'COLLEGE': '专科',
|
||||
'BACHELOR': '本科',
|
||||
'MASTER': '硕士',
|
||||
'DOCTOR': '博士',
|
||||
'HIRE': '入职',
|
||||
'REGULARIZE': '转正',
|
||||
'TRANSFER': '调岗',
|
||||
'CREATE': '发起',
|
||||
'SUBMIT': '提交',
|
||||
'APPROVE': '同意',
|
||||
'REJECT': '驳回',
|
||||
'DRAFTER': '制标人',
|
||||
'FETCHER': '取标人',
|
||||
'COORDINATOR': '特殊协调人',
|
||||
'BIDDER': '投标人',
|
||||
'SPECIAL': '特殊筛选',
|
||||
'FLOW': '普通筛选',
|
||||
'BID_TECH_INITIAL': '技术初审',
|
||||
'BID_TECH_FINAL': '技术终审',
|
||||
'BID_FETCH': '取标',
|
||||
'BID_ASSIGN_FETCH': '派取标人',
|
||||
'BID_ASSIGN_DRAFT': '派制标人',
|
||||
'BID_FINAL_PASSED': '终审通过通知',
|
||||
'BID_DRAFT_DONE': '制标完成通知',
|
||||
'BID_TERMINATED': '投标终止通知',
|
||||
'BID_DRAFT': '制标',
|
||||
'BID_REVIEW1': '商务一审',
|
||||
'BID_REVIEW2': '商务二审',
|
||||
'BID_PRINT': '印封',
|
||||
'BID_COMPLETE': '补全投标',
|
||||
'BID_EXECUTION': '投标执行',
|
||||
'BID_ASSIGN_FETCHER': '派取标人',
|
||||
'UPDATE_COMPETITOR_WIN': '登记对方中标信息',
|
||||
'UPDATE_LEGAL_ENTITY': '修改参与主体',
|
||||
'CONFIRMED': '已确认',
|
||||
'INTERNAL': '内部验收',
|
||||
'CUSTOMER': '客户验收',
|
||||
'CONTRACT_APPROVAL': '合同审批',
|
||||
'CONTRACT_DRAFT': '完善合同草稿',
|
||||
'EXPENSE_APPROVAL': '报销确认',
|
||||
'LOAN_APPROVAL': '借款确认',
|
||||
'LOAN_OFFSET': '借款冲账',
|
||||
'PROJECT_TIMESHEET': '工时填报',
|
||||
'PROJECT_ACCEPT': '项目验收',
|
||||
'SEAL_APPROVAL': '用章审批',
|
||||
'SEAL_RETURN': '印章归还',
|
||||
'CREDENTIAL_RETURN': '证照归还',
|
||||
'BID_OPEN': '开标反馈',
|
||||
'WORK_ASSIGN': '工作安排',
|
||||
'OFFICE_APPLY': '办公申请',
|
||||
'HR_LEAVE': '人事申请',
|
||||
'SEAL_REQUEST': '用章申请',
|
||||
'WORK_REPORT': '工作汇报',
|
||||
'NOTICE': '通知公告',
|
||||
'ASSET_BUY': '资产购置',
|
||||
'ASSET_USE': '资产领用',
|
||||
'VEHICLE': '用车',
|
||||
'DIGITAL': '数字资产',
|
||||
'FIXED': '固定资产',
|
||||
'IN_USE': '占用中',
|
||||
'IN_STOCK': '在库',
|
||||
'OUT_STOCK': '已出库',
|
||||
'TAKEN_OUT': '外带',
|
||||
'LENT': '外借',
|
||||
'BORROWED': '已外借',
|
||||
'IDLE': '闲置',
|
||||
'SCRAPPED': '已报废',
|
||||
'AVAILABLE': '可用',
|
||||
'MAINTAIN': '维修中',
|
||||
'OCCUPIED': '占用中',
|
||||
'PENDING_IN': '待入库',
|
||||
'ID_CARD': '身份证',
|
||||
'PASSPORT': '护照',
|
||||
'OTHER_ID': '其他证件',
|
||||
'GENERAL': '通用',
|
||||
'TODO': '待办',
|
||||
'TASK': '任务截止',
|
||||
'BORROW': '证照归还',
|
||||
'LOGIN': '登录',
|
||||
'LOGIN_FAIL': '登录失败',
|
||||
'APPROVAL': '审批',
|
||||
'FORBIDDEN': '越权',
|
||||
'ACL': '权限',
|
||||
'SENT': '已提交',
|
||||
'UPDATE': '修改',
|
||||
'DELETE': '删除',
|
||||
'OK': '成功',
|
||||
'FAIL': '失败',
|
||||
'BUSINESS': '商务',
|
||||
'TECH': '技术',
|
||||
'FINANCE': '财务',
|
||||
'PUBLISHED': '已发布',
|
||||
'ANNOUNCE': '公告',
|
||||
'POLICY': '制度',
|
||||
'direct': '单聊',
|
||||
'group': '群聊',
|
||||
'admin': '管理员',
|
||||
'owner': '负责人',
|
||||
'hr': '人事',
|
||||
'finance': '财务',
|
||||
'finance_manager': '财务经理',
|
||||
'cashier': '出纳',
|
||||
'seal': '用章管理员',
|
||||
'asset': '资产管理员',
|
||||
'project_manager': '项目经理',
|
||||
'tech_director': '技术总监',
|
||||
'marketing': '营销',
|
||||
'bidder': '投标',
|
||||
'staff': '员工',
|
||||
'user': '用户',
|
||||
'true': '是',
|
||||
'false': '否',
|
||||
'INQUIRY': '询价',
|
||||
'OPEN_TENDER': '公开招标',
|
||||
'INVITE': '邀请招标',
|
||||
'COMPETITIVE': '竞争性谈判',
|
||||
'SINGLE_SOURCE': '单一来源',
|
||||
};
|
||||
|
||||
/// 详情页字段名。没有映射的 camelCase 一律不展示,避免露出英文列名。
|
||||
const fieldZh = <String, String>{
|
||||
'title': '标题',
|
||||
'name': '名称',
|
||||
'purpose': '事由',
|
||||
'reason': '事由',
|
||||
'remark': '备注',
|
||||
'status': '状态',
|
||||
'kind': '类型',
|
||||
'source': '来源',
|
||||
'sourceType': '筛选类型',
|
||||
'bizType': '业务',
|
||||
'amount': '金额',
|
||||
'days': '天数',
|
||||
'claimNo': '报销单号',
|
||||
'loanNo': '借款单号',
|
||||
'bidNo': '招标编号',
|
||||
'externalNo': '招标编号',
|
||||
'contractNo': '合同号',
|
||||
'projectNo': '项目编号',
|
||||
'projectType': '项目类型',
|
||||
'sealType': '印章类型',
|
||||
'destination': '目的地',
|
||||
'assetKind': '资产类型',
|
||||
'createdAt': '创建时间',
|
||||
'updatedAt': '更新时间',
|
||||
'dueAt': '截止',
|
||||
'startAt': '开始',
|
||||
'endAt': '结束',
|
||||
'decidedAt': '审批时间',
|
||||
'comment': '意见',
|
||||
'createdBy': '发起人',
|
||||
'bidCase': '关联投标',
|
||||
'legalEntity': '参与主体',
|
||||
'qualificationNeed': '所需资质',
|
||||
'tenderMethod': '招标方式',
|
||||
'applyStartAt': '申领开始',
|
||||
'applyEndAt': '申领截止',
|
||||
'applyMethod': '申领方式',
|
||||
'openAt': '开标时间',
|
||||
'openMethod': '开标方式',
|
||||
'openPlace': '开标地点',
|
||||
'bondMethod': '保证金',
|
||||
'bondStatus': '保证金状态',
|
||||
'bondType': '保证金类型',
|
||||
'delivererName': '递交人',
|
||||
'contactName': '联系人',
|
||||
'contactPhone': '联系电话',
|
||||
'nasPath': '标书路径',
|
||||
'summary': '摘要',
|
||||
'resultNote': '结果说明',
|
||||
'printCost': '印封费用',
|
||||
'printNote': '印封备注',
|
||||
'projectNature': '项目性质',
|
||||
'securityLevel': '密级',
|
||||
'priceCap': '限价',
|
||||
'quoteAmount': '报价',
|
||||
'needSurvey': '需要踏勘',
|
||||
'fetchedAt': '取标时间',
|
||||
'printedAt': '印封时间',
|
||||
'openedResultAt': '开标反馈时间',
|
||||
'actionLogs': '操作记录',
|
||||
'projects': '关联项目',
|
||||
'files': '附件',
|
||||
'reviewers': '审批人',
|
||||
'assignees': '经办人',
|
||||
'versions': '标书版本',
|
||||
'displayName': '姓名',
|
||||
'departmentName': '部门',
|
||||
'department': '部门',
|
||||
'mobile': '手机',
|
||||
'email': '邮箱',
|
||||
'party': '客商',
|
||||
'applicant': '申请人',
|
||||
'assignee': '经办人',
|
||||
'keeper': '保管人',
|
||||
'holder': '占用人',
|
||||
'spec': '规格',
|
||||
'unit': '单位',
|
||||
'qty': '数量',
|
||||
'quantity': '数量',
|
||||
'location': '位置',
|
||||
'address': '地址',
|
||||
'code': '编号',
|
||||
'no': '编号',
|
||||
'type': '类型',
|
||||
'category': '分类',
|
||||
'level': '级别',
|
||||
'priority': '优先级',
|
||||
'progress': '进度',
|
||||
'budget': '预算',
|
||||
'cost': '成本',
|
||||
'owner': '负责人',
|
||||
'manager': '负责人',
|
||||
'description': '说明',
|
||||
'content': '内容',
|
||||
'body': '正文',
|
||||
'note': '备注',
|
||||
'result': '结果',
|
||||
'outcome': '结果',
|
||||
'method': '方式',
|
||||
'channel': '渠道',
|
||||
'scope': '范围',
|
||||
'role': '角色',
|
||||
'roles': '角色',
|
||||
'employmentStatus': '用工状态',
|
||||
'gender': '性别',
|
||||
'idNo': '证件号',
|
||||
'idType': '证件类型',
|
||||
'idNumber': '证件号码',
|
||||
'idCard': '身份证号',
|
||||
'jobTitle': '岗位',
|
||||
'hireDate': '入职日期',
|
||||
'hiredAt': '入职日期',
|
||||
'birthday': '出生日期',
|
||||
'birthDate': '出生日期',
|
||||
'education': '学历',
|
||||
'school': '毕业院校',
|
||||
'major': '专业',
|
||||
'emergencyContact': '紧急联系人',
|
||||
'emergencyPhone': '紧急电话',
|
||||
'bankName': '开户行',
|
||||
'bankAccount': '银行账号',
|
||||
'workCity': '工作城市',
|
||||
'officeLocation': '办公地点',
|
||||
'employeeNo': '工号',
|
||||
'staffNo': '工号',
|
||||
'jobNo': '工号',
|
||||
'realName': '姓名',
|
||||
'username': '账号',
|
||||
'phone': '手机',
|
||||
'wechat': '微信',
|
||||
'nation': '民族',
|
||||
'nativePlace': '籍贯',
|
||||
'maritalStatus': '婚姻状况',
|
||||
'addressHome': '家庭住址',
|
||||
'entryDate': '入职日期',
|
||||
'regularDate': '转正日期',
|
||||
'leaveDate': '离职日期',
|
||||
'contractEnd': '合同到期',
|
||||
'keeperName': '保管人',
|
||||
'holderName': '占用人',
|
||||
'legalEntityName': '参与主体',
|
||||
'winnerName': '中标单位',
|
||||
'winnerAmount': '中标金额',
|
||||
};
|
||||
|
||||
String zh(dynamic v) {
|
||||
if (v == null) return '—';
|
||||
if (v is bool) return v ? '是' : '否';
|
||||
final s = '$v'.trim();
|
||||
if (s.isEmpty) return '—';
|
||||
return zhMap[s] ?? s;
|
||||
}
|
||||
|
||||
/// 字段名转中文。仍是英文驼峰的返回空串,调用方应跳过。
|
||||
String fieldLabel(String k) {
|
||||
final key = k.contains('.') ? k.split('.').last : k;
|
||||
if (fieldZh.containsKey(k)) return fieldZh[k]!;
|
||||
if (fieldZh.containsKey(key)) return fieldZh[key]!;
|
||||
if (zhMap.containsKey(key)) return zhMap[key]!;
|
||||
if (RegExp(r'[\u4e00-\u9fff]').hasMatch(key)) return key;
|
||||
return '';
|
||||
}
|
||||
|
||||
Color statusColor(dynamic v) {
|
||||
switch ('$v') {
|
||||
case 'APPROVED':
|
||||
case 'DONE':
|
||||
case 'OFFSET':
|
||||
case 'RETURNED':
|
||||
case 'PAID':
|
||||
case 'WON':
|
||||
case 'ACCEPTED':
|
||||
case 'ISSUED':
|
||||
case 'RECEIVED':
|
||||
return kWeGreen;
|
||||
case 'PENDING':
|
||||
case 'OPEN':
|
||||
case 'DRAFT':
|
||||
case 'FETCH':
|
||||
case 'DRAFTING':
|
||||
case 'TECH_INITIAL':
|
||||
case 'TECH_FINAL':
|
||||
case 'REVIEW1':
|
||||
case 'REVIEW2':
|
||||
case 'PRINT':
|
||||
case 'LIST':
|
||||
return const Color(0xFFFA9D3B);
|
||||
case 'REJECTED':
|
||||
case 'CANCELLED':
|
||||
case 'OVERDUE':
|
||||
case 'TERMINATED':
|
||||
case 'FAILED':
|
||||
case 'LOST':
|
||||
return kDanger;
|
||||
default:
|
||||
return kMute;
|
||||
}
|
||||
}
|
||||
|
||||
class StatusDot extends StatelessWidget {
|
||||
const StatusDot(this.code, {super.key});
|
||||
final dynamic code;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor(code).withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(zh(code), style: TextStyle(fontSize: 11, color: statusColor(code), fontWeight: FontWeight.w600)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import 'api/oa_client.dart';
|
||||
import 'app_runtime.dart';
|
||||
import 'device/device_bridge.dart';
|
||||
import 'desktop/desk_platform.dart';
|
||||
import 'desktop/desk_theme.dart';
|
||||
import 'pages/desktop_login_page.dart';
|
||||
import 'pages/login_page.dart';
|
||||
import 'session/session.dart';
|
||||
import 'shell/home_shell.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await AppRuntime.load();
|
||||
if (isDesktopPlatform) {
|
||||
await windowManager.ensureInitialized();
|
||||
const windowOptions = WindowOptions(
|
||||
size: Size(1400, 900),
|
||||
center: true,
|
||||
title: '风影办公',
|
||||
);
|
||||
windowManager.waitUntilReadyToShow(windowOptions, () async {
|
||||
await windowManager.show();
|
||||
await windowManager.focus();
|
||||
});
|
||||
}
|
||||
if (!isDesktopPlatform) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values);
|
||||
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.white,
|
||||
statusBarIconBrightness: Brightness.dark,
|
||||
));
|
||||
DeviceBridge.preferMaxRefresh();
|
||||
}
|
||||
runApp(const FengyingApp());
|
||||
}
|
||||
|
||||
class FengyingApp extends StatefulWidget {
|
||||
const FengyingApp({super.key});
|
||||
|
||||
@override
|
||||
State<FengyingApp> createState() => _FengyingAppState();
|
||||
}
|
||||
|
||||
class _FengyingAppState extends State<FengyingApp> {
|
||||
final session = SessionStore();
|
||||
late final OaClient api = OaClient(session);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
DeviceBridge.bindApi(api);
|
||||
session.addListener(_onSession);
|
||||
_boot();
|
||||
}
|
||||
|
||||
Future<void> _boot() async {
|
||||
await session.load();
|
||||
if (session.signedIn) {
|
||||
try {
|
||||
final me = await api.get('/auth/me');
|
||||
if (me is Map) {
|
||||
await session.patchUser(Map<String, dynamic>.from(me));
|
||||
}
|
||||
final menus = await api.get('/system/menus');
|
||||
await session.setMenus(asMaps(menus).map(MenuNode.fromJson).toList());
|
||||
await session.connectIm();
|
||||
} catch (_) {
|
||||
await session.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onSession() => setState(() {});
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
session.removeListener(_onSession);
|
||||
session.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: '风影办公',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: isDesktopPlatform ? buildDesktopTheme() : buildTheme(),
|
||||
home: !session.loaded
|
||||
? const Scaffold(backgroundColor: Colors.white, body: Center(child: CircularProgressIndicator(color: kBind)))
|
||||
: session.signedIn
|
||||
? HomeShell(session: session, api: api)
|
||||
: isDesktopPlatform
|
||||
? DesktopLoginPage(session: session, api: api)
|
||||
: LoginPage(session: session, api: api),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
String? fetchPathOf(Map<String, dynamic> row) {
|
||||
final forced = '${row['_fetch'] ?? ''}';
|
||||
if (forced.startsWith('/')) return forced;
|
||||
final id = '${row['id'] ?? ''}';
|
||||
final bizId = '${row['bizId'] ?? ''}';
|
||||
final source = '${row['source'] ?? ''}';
|
||||
final kind = '${row['kind'] ?? ''}';
|
||||
final biz = '${row['bizType'] ?? ''}';
|
||||
final path = '${row['path'] ?? ''}';
|
||||
|
||||
if (source == 'EXPENSE' && kind == 'LOAN' && id.isNotEmpty)
|
||||
return '/loans/$id';
|
||||
if (source == 'EXPENSE' && id.isNotEmpty) return '/expenses/$id';
|
||||
if (source == 'HR' && id.isNotEmpty) return '/leave-requests/$id';
|
||||
if (source == 'OFFICE' && id.isNotEmpty) return '/office/applies/$id';
|
||||
if (source == 'SEAL' && id.isNotEmpty) return '/seal-requests/$id';
|
||||
|
||||
if (bizId.isNotEmpty) {
|
||||
if (biz == 'EXPENSE_APPROVAL' ||
|
||||
biz == 'EXPENSE_DRAFT' ||
|
||||
biz == 'TRAVEL_EXPENSE') return '/expenses/$bizId';
|
||||
if (biz == 'LOAN_APPROVAL' || biz == 'LOAN_OFFSET') return '/loans/$bizId';
|
||||
if (biz == 'HR_LEAVE') return '/leave-requests/$bizId';
|
||||
if (biz == 'OFFICE_APPLY') return '/office/applies/$bizId';
|
||||
if (biz == 'SEAL_APPROVAL' || biz == 'SEAL_RETURN' || biz == 'SEAL_REQUEST')
|
||||
return '/seal-requests/$bizId';
|
||||
if (biz.startsWith('BID')) return '/bid-cases/$bizId';
|
||||
if (biz.startsWith('CONTRACT')) return '/contracts/$bizId';
|
||||
if (biz == 'WORK_REPORT') return '/office/reports/$bizId';
|
||||
if (biz == 'WORK_ASSIGN') return null;
|
||||
if (biz.startsWith('PROJECT')) return '/projects/$bizId';
|
||||
}
|
||||
|
||||
if (row['expense'] is Map)
|
||||
return '/expenses/${(row['expense'] as Map)['id']}';
|
||||
if (row['loan'] is Map) return '/loans/${(row['loan'] as Map)['id']}';
|
||||
if (row['leaveRequest'] is Map)
|
||||
return '/leave-requests/${(row['leaveRequest'] as Map)['id']}';
|
||||
if (row['officeApply'] is Map)
|
||||
return '/office/applies/${(row['officeApply'] as Map)['id']}';
|
||||
if (row['sealRequest'] is Map)
|
||||
return '/seal-requests/${(row['sealRequest'] as Map)['id']}';
|
||||
if (row['contract'] is Map)
|
||||
return '/contracts/${(row['contract'] as Map)['id']}';
|
||||
if (row['bidCase'] is Map)
|
||||
return '/bid-cases/${(row['bidCase'] as Map)['id']}';
|
||||
if (row['project'] is Map)
|
||||
return '/projects/${(row['project'] as Map)['id']}';
|
||||
|
||||
if (path.startsWith('/office/expenses/') && path.length > 17)
|
||||
return '/expenses/${path.split('/').last}';
|
||||
if (path.startsWith('/office/loans/') && path.length > 14)
|
||||
return '/loans/${path.split('/').last}';
|
||||
|
||||
if (id.length == 36) {
|
||||
if (row.containsKey('claimNo') || row.containsKey('expenseCategory'))
|
||||
return '/expenses/$id';
|
||||
if (row.containsKey('loanNo') || kind == 'LOAN') return '/loans/$id';
|
||||
if (row.containsKey('bidNo') || row.containsKey('externalNo'))
|
||||
return '/bid-cases/$id';
|
||||
if (row.containsKey('contractNo')) return '/contracts/$id';
|
||||
if (row.containsKey('projectNo')) return '/projects/$id';
|
||||
if (row.containsKey('sealType') || row.containsKey('takeOut'))
|
||||
return '/seal-requests/$id';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String detailTitleOf(Map<String, dynamic> row) {
|
||||
for (final k in [
|
||||
'title',
|
||||
'name',
|
||||
'purpose',
|
||||
'reason',
|
||||
'claimNo',
|
||||
'loanNo',
|
||||
'bidNo',
|
||||
'contractNo',
|
||||
'projectName'
|
||||
]) {
|
||||
final v = row[k];
|
||||
if (v != null && '$v'.trim().isNotEmpty) return '$v';
|
||||
}
|
||||
return '详情';
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../pages/approvals_page.dart';
|
||||
import '../pages/record_detail_page.dart';
|
||||
import '../pages/todos_page.dart';
|
||||
import '../pages/work_assign_page.dart';
|
||||
import '../session/session.dart';
|
||||
import 'biz_route.dart';
|
||||
|
||||
/// Opens a system notification at its business destination. System notices are
|
||||
/// workflow entries, never an IM peer conversation.
|
||||
void openNoticeTarget(
|
||||
BuildContext context,
|
||||
OaClient api, {
|
||||
required SessionStore session,
|
||||
required String kind,
|
||||
required String bizType,
|
||||
required String bizId,
|
||||
String title = '系统通知',
|
||||
}) {
|
||||
if (bizType == 'WORK_ASSIGN') {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => WorkAssignPage(session: session, api: api),
|
||||
));
|
||||
return;
|
||||
}
|
||||
final seed = <String, dynamic>{
|
||||
'bizType': bizType,
|
||||
'bizId': bizId,
|
||||
'id': bizId,
|
||||
'title': title,
|
||||
};
|
||||
if (bizId.isNotEmpty && fetchPathOf(seed) != null) {
|
||||
openRecord(context, api, seed, title: title);
|
||||
return;
|
||||
}
|
||||
if (kind == 'approval' || bizType.contains('APPROVAL')) {
|
||||
Navigator.of(context)
|
||||
.push(MaterialPageRoute(builder: (_) => ApprovalsPage(api: api)));
|
||||
return;
|
||||
}
|
||||
Navigator.of(context)
|
||||
.push(MaterialPageRoute(builder: (_) => TodosPage(api: api)));
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../pages/approvals_page.dart';
|
||||
import '../pages/calendar_page.dart';
|
||||
import '../pages/directory_page.dart';
|
||||
import '../pages/flow_page.dart';
|
||||
import '../pages/hr_apply_page.dart';
|
||||
import '../pages/module_list_page.dart';
|
||||
import '../pages/notices_page.dart';
|
||||
import '../pages/todos_page.dart';
|
||||
import '../pages/work_assign_page.dart';
|
||||
import '../session/session.dart';
|
||||
|
||||
void pushPage(BuildContext context, Widget page) {
|
||||
Navigator.of(context).push(MaterialPageRoute(builder: (_) => page));
|
||||
}
|
||||
|
||||
void openMenuNode(BuildContext context, SessionStore session, OaClient api, MenuNode n) {
|
||||
if (n.code.startsWith('system') || n.name == '系统设置') return;
|
||||
if (n.name == '发送短信' || n.code.contains('sms')) return;
|
||||
if (n.name == '消息' || n.code.contains('office:im') || n.code == 'im') return;
|
||||
if (n.name == '公司公告' || n.name == '员工通讯' || n.name == '通讯录') return;
|
||||
switch (n.code) {
|
||||
case 'office:overview':
|
||||
return;
|
||||
case 'office:todos':
|
||||
pushPage(context, TodosPage(api: api));
|
||||
return;
|
||||
case 'office:approvals':
|
||||
pushPage(context, ApprovalsPage(api: api));
|
||||
return;
|
||||
case 'office:flow':
|
||||
pushPage(context, FlowPage(api: api));
|
||||
return;
|
||||
case 'office:calendar':
|
||||
pushPage(context, CalendarPage(api: api));
|
||||
return;
|
||||
case 'office:notices':
|
||||
pushPage(context, NoticesPage(api: api));
|
||||
return;
|
||||
case 'office:assign':
|
||||
pushPage(context, WorkAssignPage(session: session, api: api));
|
||||
return;
|
||||
case 'office:directory':
|
||||
pushPage(context, DirectoryPage(session: session, api: api));
|
||||
return;
|
||||
case 'hr:apply':
|
||||
case 'office:leave':
|
||||
pushPage(context, HrApplyPage(api: api));
|
||||
return;
|
||||
}
|
||||
final shell = shellFor(n.code, n.path);
|
||||
if (shell != null) {
|
||||
pushPage(context, ModuleListPage(api: api, title: n.name, shell: shell));
|
||||
return;
|
||||
}
|
||||
if (n.children.isNotEmpty) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (ctx) => SafeArea(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
for (final c in n.children)
|
||||
ListTile(
|
||||
title: Text(c.name),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
openMenuNode(context, session, api, c);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
IconData iconFor(String code, String name) {
|
||||
final k = '$code $name';
|
||||
if (k.contains('todo') || name.contains('待办')) return Icons.task_alt;
|
||||
if (k.contains('approv') || name.contains('审批')) return Icons.fact_check_outlined;
|
||||
if (name.contains('申请') || k.contains('flow')) return Icons.assignment_outlined;
|
||||
if (name.contains('日程') || k.contains('calendar')) return Icons.calendar_month_outlined;
|
||||
if (name.contains('公告') || k.contains('notice')) return Icons.campaign_outlined;
|
||||
if (name.contains('安排') || k.contains('assign')) return Icons.event_note_outlined;
|
||||
if (name.contains('通讯') || k.contains('directory')) return Icons.contacts_outlined;
|
||||
if (name.contains('人事') || name.contains('员工') || k.contains('hr') || k.contains('employee')) return Icons.badge_outlined;
|
||||
if (name.contains('投标') || k.contains('bid')) return Icons.gavel_outlined;
|
||||
if (name.contains('合同') || k.contains('contract')) return Icons.description_outlined;
|
||||
if (name.contains('用章') || name.contains('证照') || k.contains('seal')) return Icons.verified_outlined;
|
||||
if (name.contains('资产') || k.contains('asset')) return Icons.devices_other_outlined;
|
||||
if (name.contains('项目') || k.contains('project')) return Icons.account_tree_outlined;
|
||||
if (name.contains('财务') || name.contains('报销') || name.contains('付款') || k.contains('finance') || k.contains('expense')) {
|
||||
return Icons.account_balance_wallet_outlined;
|
||||
}
|
||||
if (name.contains('客户') || name.contains('往来') || k.contains('party')) return Icons.handshake_outlined;
|
||||
if (name.contains('报表') || k.contains('report')) return Icons.bar_chart_outlined;
|
||||
if (name.contains('请假') || name.contains('加班')) return Icons.beach_access_outlined;
|
||||
return Icons.apps_outlined;
|
||||
}
|
||||
|
||||
Color colorFor(String code, String name, [int i = 0]) {
|
||||
const palette = [
|
||||
Color(0xFF267EF0),
|
||||
Color(0xFF07C160),
|
||||
Color(0xFFFA9D3B),
|
||||
Color(0xFF6267F2),
|
||||
Color(0xFF10AEFF),
|
||||
Color(0xFFE75D5D),
|
||||
Color(0xFF00B578),
|
||||
Color(0xFF8B5CF6),
|
||||
];
|
||||
return palette[(code.hashCode.abs() + name.hashCode.abs() + i) % palette.length];
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../app_runtime.dart';
|
||||
import '../theme.dart';
|
||||
|
||||
class AppRelease {
|
||||
AppRelease({
|
||||
required this.version,
|
||||
required this.build,
|
||||
required this.url,
|
||||
this.changelog = '',
|
||||
this.force = false,
|
||||
this.windowsUrl = '',
|
||||
this.linuxUrl = '',
|
||||
this.linuxDebUrl = '',
|
||||
});
|
||||
final String version;
|
||||
final int build;
|
||||
final String url;
|
||||
final String changelog;
|
||||
final bool force;
|
||||
final String windowsUrl;
|
||||
final String linuxUrl;
|
||||
final String linuxDebUrl;
|
||||
|
||||
bool get newer => build > AppRuntime.build;
|
||||
|
||||
String get desktopUrl {
|
||||
if (Platform.isWindows) return windowsUrl;
|
||||
if (Platform.isLinux) return linuxDebUrl.isNotEmpty ? linuxDebUrl : linuxUrl;
|
||||
return '';
|
||||
}
|
||||
|
||||
factory AppRelease.fromJson(Map<String, dynamic> j) {
|
||||
return AppRelease(
|
||||
version: '${j['version'] ?? ''}',
|
||||
build: (j['build'] as num?)?.toInt() ?? 0,
|
||||
url: '${j['url'] ?? j['apkUrl'] ?? ''}',
|
||||
changelog: '${j['changelog'] ?? ''}',
|
||||
force: j['force'] == true,
|
||||
windowsUrl: '${j['windowsUrl'] ?? ''}',
|
||||
linuxUrl: '${j['linuxUrl'] ?? ''}',
|
||||
linuxDebUrl: '${j['linuxDebUrl'] ?? ''}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OtaUpdater {
|
||||
OtaUpdater(this.api);
|
||||
final OaClient api;
|
||||
static const _ch = MethodChannel('com.fysxkj.oa/ota');
|
||||
|
||||
Future<AppRelease?> fetch() async {
|
||||
try {
|
||||
final data = await api.get('/system/app-release');
|
||||
if (data is Map) return AppRelease.fromJson(Map<String, dynamic>.from(data));
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> installApk(String path) async {
|
||||
await _ch.invokeMethod('installApk', {'path': path});
|
||||
}
|
||||
|
||||
Future<File> download(String url, void Function(double p) onProgress, {String filename = 'fengying-oa-update'}) async {
|
||||
final dir = await getTemporaryDirectory();
|
||||
final ext = url.contains('.exe')
|
||||
? '.exe'
|
||||
: url.contains('.deb')
|
||||
? '.deb'
|
||||
: url.contains('.tar.gz')
|
||||
? '.tar.gz'
|
||||
: url.contains('.apk')
|
||||
? '.apk'
|
||||
: '';
|
||||
final file = File('${dir.path}/$filename$ext');
|
||||
final client = HttpClient();
|
||||
try {
|
||||
final req = await client.getUrl(Uri.parse(url));
|
||||
final res = await req.close();
|
||||
if (res.statusCode >= 400) {
|
||||
throw ApiException('下载失败 ${res.statusCode}');
|
||||
}
|
||||
final total = res.contentLength;
|
||||
var rec = 0;
|
||||
final sink = file.openWrite();
|
||||
await for (final chunk in res) {
|
||||
rec += chunk.length;
|
||||
sink.add(chunk);
|
||||
if (total > 0) onProgress(rec / total);
|
||||
}
|
||||
await sink.close();
|
||||
return file;
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> prompt(BuildContext context, AppRelease rel, {bool manual = false}) async {
|
||||
if (!rel.newer && !manual) return;
|
||||
if (!rel.newer && manual) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已是最新版本')));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
final go = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: !rel.force,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text('发现新版本 ${rel.version}'),
|
||||
content: Text(rel.changelog.isEmpty ? '请升级后继续使用。' : rel.changelog),
|
||||
actions: [
|
||||
if (!rel.force) TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('稍后')),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('立即升级')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (go == true && context.mounted) await start(context, rel);
|
||||
}
|
||||
|
||||
Future<void> start(BuildContext context, AppRelease rel) async {
|
||||
final desktop = rel.desktopUrl;
|
||||
if (Platform.isWindows || Platform.isLinux) {
|
||||
if (desktop.isEmpty) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('暂无桌面端升级包地址')));
|
||||
}
|
||||
return;
|
||||
}
|
||||
final progress = ValueNotifier<double>(0);
|
||||
if (!context.mounted) return;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('正在下载更新'),
|
||||
content: OtaProgress(progress: progress),
|
||||
),
|
||||
);
|
||||
try {
|
||||
final file = await download(desktop, (p) => progress.value = p, filename: 'fengying-oa-setup');
|
||||
if (context.mounted) Navigator.of(context, rootNavigator: true).pop();
|
||||
if (Platform.isWindows) {
|
||||
await Process.start(file.path, ['/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART'], mode: ProcessStartMode.detached);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('安装程序已启动,请按提示完成升级后重启应用')));
|
||||
}
|
||||
} else if (file.path.endsWith('.deb')) {
|
||||
final r = await Process.run('pkexec', ['env', 'DEBIAN_FRONTEND=noninteractive', 'dpkg', '-i', file.path]);
|
||||
if (context.mounted) {
|
||||
if (r.exitCode == 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('升级完成,请重启应用')));
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('安装失败:${r.stderr}')));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await Process.run('xdg-open', [file.parent.path]);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('已下载到 ${file.path},请解压覆盖后重启应用')));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('升级失败:$e')));
|
||||
}
|
||||
} finally {
|
||||
progress.dispose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!Platform.isAndroid) return;
|
||||
if (rel.url.isEmpty) return;
|
||||
final progress = ValueNotifier<double>(0);
|
||||
if (!context.mounted) return;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('正在下载更新'),
|
||||
content: OtaProgress(progress: progress),
|
||||
),
|
||||
);
|
||||
try {
|
||||
final file = await download(rel.url, (p) => progress.value = p);
|
||||
if (context.mounted) Navigator.of(context, rootNavigator: true).pop();
|
||||
await installApk(file.path);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('升级失败:$e')));
|
||||
}
|
||||
} finally {
|
||||
progress.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class OtaProgress extends StatefulWidget {
|
||||
const OtaProgress({super.key, required this.progress});
|
||||
final ValueListenable<double> progress;
|
||||
@override
|
||||
State<OtaProgress> createState() => _OtaProgressState();
|
||||
}
|
||||
|
||||
class _OtaProgressState extends State<OtaProgress> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.progress.addListener(_on);
|
||||
}
|
||||
|
||||
void _on() => setState(() {});
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.progress.removeListener(_on);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = widget.progress.value;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
LinearProgressIndicator(value: p <= 0 ? null : p, color: kBind),
|
||||
const SizedBox(height: 12),
|
||||
Text('${(p * 100).clamp(0, 100).toStringAsFixed(0)}%'),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String prettyJson(Object v) {
|
||||
try {
|
||||
return const JsonEncoder.withIndent(' ').convert(v);
|
||||
} catch (_) {
|
||||
return '$v';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../labels.dart';
|
||||
import '../pages/record_detail_page.dart';
|
||||
import '../widgets/common.dart';
|
||||
|
||||
class ApprovalsPage extends StatefulWidget {
|
||||
const ApprovalsPage({super.key, required this.api});
|
||||
final OaClient api;
|
||||
|
||||
@override
|
||||
State<ApprovalsPage> createState() => _ApprovalsPageState();
|
||||
}
|
||||
|
||||
class _ApprovalsPageState extends State<ApprovalsPage> {
|
||||
List<Map<String, dynamic>> _items = [];
|
||||
String _bucket = 'PENDING';
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final data = await widget.api.get('/office/approvals');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = asMaps(data);
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shown = _bucket.isEmpty ? _items : _items.where((e) => '${e['status']}' == _bucket).toList();
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('待我审批')),
|
||||
body: Column(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final it in [('PENDING', '待审批'), ('APPROVED', '已通过'), ('REJECTED', '已驳回'), ('', '全部')])
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(it.$2),
|
||||
selected: _bucket == it.$1,
|
||||
showCheckmark: false,
|
||||
visualDensity: VisualDensity.compact,
|
||||
onSelected: (_) => setState(() => _bucket = it.$1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_loading) const LinearProgressIndicator(minHeight: 2),
|
||||
Expanded(
|
||||
child: shown.isEmpty
|
||||
? const EmptyHint('没有审批任务')
|
||||
: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView.builder(
|
||||
itemCount: shown.length,
|
||||
itemBuilder: (_, i) {
|
||||
final r = shown[i];
|
||||
return KvTile(
|
||||
title: '${r['title'] ?? ''}',
|
||||
subtitle: '${zh(r['bizType'])} ${fmtTime(r['createdAt'])}',
|
||||
status: r['status'],
|
||||
onTap: () async {
|
||||
await Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => RecordDetailPage(api: widget.api, seed: r, title: '${r['title'] ?? '审批'}'),
|
||||
));
|
||||
_load();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../device/device_bridge.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/wecom.dart';
|
||||
|
||||
class AttendancePage extends StatefulWidget {
|
||||
const AttendancePage({super.key, required this.api, this.embedded = false});
|
||||
final OaClient api;
|
||||
final bool embedded;
|
||||
@override
|
||||
State<AttendancePage> createState() => _AttendancePageState();
|
||||
}
|
||||
|
||||
class _AttendancePageState extends State<AttendancePage> {
|
||||
Map<String, dynamic> _status = {};
|
||||
Map<String, double>? _location;
|
||||
bool _loading = true;
|
||||
bool _sending = false;
|
||||
String _mode = 'OFFICE';
|
||||
|
||||
@override
|
||||
void initState() { super.initState(); _load(); }
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
_location = await DeviceBridge.getLocation();
|
||||
final q = <String, String>{
|
||||
if (_location != null) 'lat': '${_location!['lat']}',
|
||||
if (_location != null) 'lng': '${_location!['lng']}',
|
||||
'mode': _mode,
|
||||
};
|
||||
final data = Map<String, dynamic>.from(await widget.api.get('/attendance/punch-status', query: q) as Map);
|
||||
if (mounted) setState(() { _status = data; _loading = false; });
|
||||
} catch (_) { if (mounted) setState(() => _loading = false); }
|
||||
}
|
||||
|
||||
Future<void> _punch(String kind) async {
|
||||
if (_mode == 'OFFICE' && _status['tripActive'] == true) return;
|
||||
if (_mode == 'FIELD' && _status['fieldApproved'] != true) return;
|
||||
setState(() => _sending = true);
|
||||
try {
|
||||
await widget.api.post('/attendance/punch', {'kind': kind, 'mode': _mode, if (_location != null) ..._location!});
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('打卡成功')));
|
||||
await _load();
|
||||
} catch (e) { if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e'))); }
|
||||
finally { if (mounted) setState(() => _sending = false); }
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final inKey = _mode == 'FIELD' ? 'fieldIn' : 'clockIn';
|
||||
final outKey = _mode == 'FIELD' ? 'fieldOut' : 'clockOut';
|
||||
final inAt = _time(_status[inKey]);
|
||||
final outAt = _time(_status[outKey]);
|
||||
final hasIn = inAt != '未打卡';
|
||||
final hasOut = outAt != '未打卡';
|
||||
final trip = _status['tripActive'] == true;
|
||||
final fieldOk = _status['fieldApproved'] == true;
|
||||
final canPunch = _mode == 'FIELD' ? fieldOk : !trip && _status['inRange'] != false;
|
||||
final body = _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 28),
|
||||
children: [
|
||||
SegmentedButton<String>(
|
||||
segments: const [
|
||||
ButtonSegment(value: 'OFFICE', label: Text('上下班打卡'), icon: Icon(Icons.access_time)),
|
||||
ButtonSegment(value: 'FIELD', label: Text('外勤打卡'), icon: Icon(Icons.location_on_outlined)),
|
||||
],
|
||||
selected: {_mode},
|
||||
onSelectionChanged: (v) async {
|
||||
setState(() => _mode = v.first);
|
||||
await _load();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 20, 18, 24),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(_today(), style: const TextStyle(fontSize: 25, fontWeight: FontWeight.w800)),
|
||||
const SizedBox(height: 8),
|
||||
_rangeBanner(canPunch, trip, fieldOk),
|
||||
const SizedBox(height: 18),
|
||||
_punchCircle(
|
||||
enabled: canPunch && !hasIn && !_sending,
|
||||
label: _mode == 'FIELD' ? '外勤打卡' : '上班打卡',
|
||||
color: const Color(0xFF2575EA),
|
||||
onTap: () => _punch('IN'),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_timeBox('上班', inAt, _status['late'] == true),
|
||||
_timeBox('下班', outAt, _status['early'] == true),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: canPunch && hasIn && !hasOut && !_sending ? () => _punch('OUT') : null,
|
||||
icon: const Icon(Icons.logout),
|
||||
label: Text(hasOut ? '已打下班卡' : '打下班卡'),
|
||||
),
|
||||
),
|
||||
if (_mode == 'OFFICE' && _status['late'] == true) const _Flag(text: '迟到', color: Colors.orange),
|
||||
if (_mode == 'OFFICE' && _status['early'] == true) const _Flag(text: '早退', color: Colors.deepOrange),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Card(
|
||||
elevation: 0,
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.place, color: _status['inRange'] == true ? Colors.green : Colors.red),
|
||||
title: Text('${_status['rangeText'] ?? '正在获取考勤范围'}', style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
subtitle: Text(_status['distanceMeters'] == null ? '请开启定位后刷新' : '距离考勤点约 ${_status['distanceMeters']} 米'),
|
||||
trailing: IconButton(onPressed: _load, icon: const Icon(Icons.refresh)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (widget.embedded) {
|
||||
return ColoredBox(
|
||||
color: const Color(0xFFF4F6FA),
|
||||
child: Column(
|
||||
children: [
|
||||
const WxHeader(title: '打卡'),
|
||||
Expanded(child: body),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF4F6FA),
|
||||
appBar: AppBar(title: const Text('打卡'), backgroundColor: Colors.transparent),
|
||||
body: body,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _rangeBanner(bool canPunch, bool trip, bool fieldOk) {
|
||||
final text = _mode == 'FIELD' ? (fieldOk ? '外出申请已通过,可进行外勤打卡' : '需先申请并审批通过外出') : (trip ? '出差期间无需打卡' : (canPunch ? '当前可进行上下班打卡' : '未进入考勤范围'));
|
||||
final color = trip || fieldOk || canPunch ? Colors.green : Colors.orange;
|
||||
return Container(width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: color.withValues(alpha: .1), borderRadius: BorderRadius.circular(12)), child: Row(children: [Icon(Icons.info_outline, color: color), const SizedBox(width: 8), Expanded(child: Text(text, style: TextStyle(color: color, fontWeight: FontWeight.w600)))]));
|
||||
}
|
||||
|
||||
Widget _punchCircle({required bool enabled, required String label, required Color color, required VoidCallback onTap}) => GestureDetector(onTap: enabled ? onTap : null, child: AnimatedContainer(duration: const Duration(milliseconds: 180), width: 190, height: 190, decoration: BoxDecoration(shape: BoxShape.circle, border: Border.all(color: enabled ? color : Colors.grey.shade400, width: 10), color: Colors.white), child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Text(label, style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: enabled ? kInk : kMute)), const SizedBox(height: 6), Text(_nowTime(), style: TextStyle(fontSize: 24, fontWeight: FontWeight.w800, color: enabled ? color : kMute))])));
|
||||
Widget _timeBox(String label, String value, bool flag) => Column(children: [Text(label, style: const TextStyle(color: kMute)), const SizedBox(height: 4), Row(children: [Text(value, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)), if (flag) const Padding(padding: EdgeInsets.only(left: 4), child: Icon(Icons.error, color: Colors.orange, size: 16))])]);
|
||||
String _today() { final d = DateTime.now(); return '${d.year}年${d.month}月${d.day}日'; }
|
||||
String _nowTime() { final d = DateTime.now(); return '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; }
|
||||
String _time(dynamic value) { if (value == null || '$value' == 'null' || '$value'.isEmpty) return '未打卡'; final d = DateTime.tryParse('$value')?.toLocal(); return d == null ? '$value' : '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; }
|
||||
}
|
||||
|
||||
class _Flag extends StatelessWidget {
|
||||
const _Flag({required this.text, required this.color});
|
||||
final String text; final Color color;
|
||||
@override Widget build(BuildContext context) => Padding(padding: const EdgeInsets.only(top: 8), child: Text(text, style: TextStyle(color: color, fontWeight: FontWeight.w700)));
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../labels.dart';
|
||||
import '../widgets/common.dart';
|
||||
import 'record_detail_page.dart';
|
||||
|
||||
class CalendarPage extends StatefulWidget {
|
||||
const CalendarPage({super.key, required this.api});
|
||||
final OaClient api;
|
||||
|
||||
@override
|
||||
State<CalendarPage> createState() => _CalendarPageState();
|
||||
}
|
||||
|
||||
class _CalendarPageState extends State<CalendarPage> {
|
||||
List<Map<String, dynamic>> _items = [];
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final data = await widget.api.get('/office/calendar');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = asMaps(data);
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('我的日程')),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _items.isEmpty
|
||||
? const EmptyHint('近期没有日程')
|
||||
: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView(
|
||||
children: [
|
||||
for (final r in _items)
|
||||
KvTile(
|
||||
title: '${r['title'] ?? r['name'] ?? '日程'}',
|
||||
subtitle: '${fmtTime(r['start'] ?? r['beginAt'] ?? r['createdAt'])} ${zh(r['type'] ?? r['kind'])}',
|
||||
onTap: () => openRecord(context, widget.api, r, title: '${r['title'] ?? r['name'] ?? '日程'}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../device/device_bridge.dart';
|
||||
import '../session/session.dart';
|
||||
import '../theme.dart';
|
||||
|
||||
/// LiveKit 音视频房间页面。信令和媒体连接均由 LiveKit 负责,OA 只签发短时房间 Token。
|
||||
class CallPage extends StatefulWidget {
|
||||
const CallPage({
|
||||
super.key,
|
||||
required this.session,
|
||||
required this.api,
|
||||
required this.callId,
|
||||
required this.kind,
|
||||
required this.title,
|
||||
required this.initiatorId,
|
||||
required this.memberIds,
|
||||
this.peerName = '同事',
|
||||
this.incoming = false,
|
||||
});
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
final String callId;
|
||||
final String kind;
|
||||
final String title;
|
||||
final String initiatorId;
|
||||
final List<String> memberIds;
|
||||
final String peerName;
|
||||
final bool incoming;
|
||||
|
||||
@override
|
||||
State<CallPage> createState() => _CallPageState();
|
||||
}
|
||||
|
||||
class _CallPageState extends State<CallPage> {
|
||||
Room? _room;
|
||||
VideoTrack? _remoteVideo;
|
||||
VideoTrack? _localVideo;
|
||||
String _status = '正在连接音视频服务…';
|
||||
bool _ready = false;
|
||||
bool _muted = false;
|
||||
bool _camOff = false;
|
||||
bool _ending = false;
|
||||
bool _remoteEnded = false;
|
||||
bool _tone = false;
|
||||
DateTime? _connectedAt;
|
||||
Duration _elapsed = Duration.zero;
|
||||
Timer? _durationTimer;
|
||||
Timer? _ringTimeoutTimer;
|
||||
Timer? _statePollTimer;
|
||||
bool get _video => widget.kind != 'audio';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.session.im.addListener(_onIm);
|
||||
_statePollTimer = Timer.periodic(
|
||||
const Duration(seconds: 2), (_) => unawaited(_pollCallState()));
|
||||
_boot();
|
||||
}
|
||||
|
||||
void _onIm() {
|
||||
if (_ending || _remoteEnded || widget.session.im.inbox.isEmpty) return;
|
||||
final matches = widget.session.im.inbox.where((item) =>
|
||||
item.kind == 'call' && '${item.raw['callId'] ?? ''}' == widget.callId);
|
||||
if (matches.isEmpty) return;
|
||||
final last = matches.first;
|
||||
final raw = last.raw;
|
||||
if (last.kind != 'call' || '${raw['callId'] ?? ''}' != widget.callId)
|
||||
return;
|
||||
final action = '${raw['action'] ?? ''}';
|
||||
if (action != 'end' && action != 'reject' && action != 'timeout') return;
|
||||
_finishRemote(action == 'timeout'
|
||||
? '无人接听'
|
||||
: action == 'reject'
|
||||
? '对方拒绝接听'
|
||||
: '通话已结束');
|
||||
}
|
||||
|
||||
Future<void> _pollCallState() async {
|
||||
if (_ending || _remoteEnded) return;
|
||||
try {
|
||||
final raw = await widget.api.get('/im/calls/${widget.callId}');
|
||||
if (raw is! Map || !mounted) return;
|
||||
final data = Map<String, dynamic>.from(raw);
|
||||
final status = '${data['status'] ?? ''}';
|
||||
if (status == 'ended') {
|
||||
_finishRemote(data['peerRejected'] == true ? '对方拒绝接听' : '通话已结束');
|
||||
return;
|
||||
}
|
||||
if (data['peerJoined'] == true && _ready) {
|
||||
_stopTone();
|
||||
_startDuration();
|
||||
if (mounted && _status != '通话中') setState(() => _status = '通话中');
|
||||
}
|
||||
} catch (_) {
|
||||
// 短时断网由 LiveKit 重连处理,状态轮询下一轮继续。
|
||||
}
|
||||
}
|
||||
|
||||
void _finishRemote(String text) {
|
||||
if (_remoteEnded || _ending) return;
|
||||
_remoteEnded = true;
|
||||
_ending = true;
|
||||
_stopTone();
|
||||
_stopDuration();
|
||||
_ringTimeoutTimer?.cancel();
|
||||
_statePollTimer?.cancel();
|
||||
unawaited(_room?.disconnect());
|
||||
if (!mounted) return;
|
||||
setState(() => _status = text);
|
||||
Future<void>.delayed(const Duration(milliseconds: 850), () {
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _boot() async {
|
||||
if (widget.incoming) {
|
||||
if (mounted) setState(() => _status = '对方邀请你${_video ? '视频' : '语音'}通话');
|
||||
_startTone();
|
||||
_startRingTimeout();
|
||||
return;
|
||||
}
|
||||
await _start();
|
||||
}
|
||||
|
||||
Future<void> _start() async {
|
||||
if (_ready) return;
|
||||
if (widget.incoming) _stopTone();
|
||||
if (mounted)
|
||||
setState(() {
|
||||
_ready = true;
|
||||
_status = '正在接通…';
|
||||
});
|
||||
_startTone();
|
||||
_startRingTimeout();
|
||||
try {
|
||||
await DeviceBridge.requestCallMedia();
|
||||
await widget.api.post('/im/calls/${widget.callId}/join');
|
||||
final auth =
|
||||
await widget.api.post('/im/calls/${widget.callId}/livekit-token');
|
||||
final data =
|
||||
auth is Map ? Map<String, dynamic>.from(auth) : <String, dynamic>{};
|
||||
final url = '${data['url'] ?? ''}';
|
||||
final token = '${data['token'] ?? ''}';
|
||||
if (url.isEmpty || token.isEmpty) throw Exception('音视频服务未配置');
|
||||
final room = Room();
|
||||
_room = room;
|
||||
room.events
|
||||
..on<ParticipantConnectedEvent>((_) {
|
||||
_stopTone();
|
||||
_startDuration();
|
||||
if (mounted) setState(() => _status = '通话中');
|
||||
})
|
||||
..on<ParticipantDisconnectedEvent>((_) {
|
||||
if (!_ending) _finishRemote('通话已结束');
|
||||
})
|
||||
..on<TrackSubscribedEvent>((event) {
|
||||
_stopTone();
|
||||
_startDuration();
|
||||
if (event.track is VideoTrack && mounted)
|
||||
setState(() => _remoteVideo = event.track as VideoTrack);
|
||||
if (mounted) setState(() => _status = '通话中');
|
||||
})
|
||||
..on<TrackUnsubscribedEvent>((event) {
|
||||
if (event.track is VideoTrack && mounted)
|
||||
setState(() => _remoteVideo = null);
|
||||
})
|
||||
..on<RoomReconnectingEvent>((_) {
|
||||
if (mounted) setState(() => _status = '网络恢复中…');
|
||||
})
|
||||
..on<RoomReconnectedEvent>((_) {
|
||||
if (mounted) setState(() => _status = '通话中');
|
||||
})
|
||||
..on<RoomDisconnectedEvent>((_) {
|
||||
if (!_ending) _finishRemote('通话已断开');
|
||||
});
|
||||
await room.connect(url, token);
|
||||
// 接听方完成信令连接后即可停止响铃;拨打方继续响铃直到对方加入房间。
|
||||
if (widget.incoming) _stopTone();
|
||||
await room.localParticipant?.setMicrophoneEnabled(true);
|
||||
if (_video) {
|
||||
final pub = await room.localParticipant?.setCameraEnabled(true);
|
||||
if (mounted && pub?.track is VideoTrack)
|
||||
setState(() => _localVideo = pub!.track as VideoTrack);
|
||||
}
|
||||
if (room.remoteParticipants.isNotEmpty) {
|
||||
_stopTone();
|
||||
_startDuration();
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() => _status = room.remoteParticipants.isNotEmpty
|
||||
? '通话中'
|
||||
: widget.initiatorId == widget.session.userId
|
||||
? '等待对方接听…'
|
||||
: '正在建立通话…');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _status = '$e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _hangup() async {
|
||||
if (_ending) return;
|
||||
_ending = true;
|
||||
_stopTone();
|
||||
_stopDuration();
|
||||
_ringTimeoutTimer?.cancel();
|
||||
_statePollTimer?.cancel();
|
||||
try {
|
||||
await widget.api.post(
|
||||
'/im/calls/${widget.callId}/${(!_ready && widget.incoming) ? 'reject' : 'end'}');
|
||||
} catch (_) {}
|
||||
await _room?.disconnect();
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
Future<void> _toggleMute() async {
|
||||
final next = !_muted;
|
||||
await _room?.localParticipant?.setMicrophoneEnabled(!next);
|
||||
if (mounted) setState(() => _muted = next);
|
||||
}
|
||||
|
||||
Future<void> _toggleCam() async {
|
||||
final next = !_camOff;
|
||||
final pub = await _room?.localParticipant?.setCameraEnabled(!next);
|
||||
if (mounted)
|
||||
setState(() {
|
||||
_camOff = next;
|
||||
if (!next && pub?.track is VideoTrack)
|
||||
_localVideo = pub!.track as VideoTrack;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.session.im.removeListener(_onIm);
|
||||
_stopTone();
|
||||
_stopDuration();
|
||||
_ringTimeoutTimer?.cancel();
|
||||
_statePollTimer?.cancel();
|
||||
_room?.disconnect();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startTone() {
|
||||
if (_tone) return;
|
||||
_tone = true;
|
||||
DeviceBridge.startCallTone(incoming: widget.incoming);
|
||||
}
|
||||
|
||||
void _stopTone() {
|
||||
if (!_tone) return;
|
||||
_tone = false;
|
||||
DeviceBridge.stopCallTone();
|
||||
}
|
||||
|
||||
void _startDuration() {
|
||||
if (_connectedAt != null) return;
|
||||
_ringTimeoutTimer?.cancel();
|
||||
_ringTimeoutTimer = null;
|
||||
_connectedAt = DateTime.now();
|
||||
_durationTimer?.cancel();
|
||||
_durationTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!mounted || _connectedAt == null) return;
|
||||
setState(() => _elapsed = DateTime.now().difference(_connectedAt!));
|
||||
});
|
||||
if (mounted) setState(() => _elapsed = Duration.zero);
|
||||
}
|
||||
|
||||
void _stopDuration() {
|
||||
_durationTimer?.cancel();
|
||||
_durationTimer = null;
|
||||
}
|
||||
|
||||
void _startRingTimeout() {
|
||||
_ringTimeoutTimer?.cancel();
|
||||
_ringTimeoutTimer = Timer(const Duration(seconds: 45), () {
|
||||
if (!mounted || _ending || _connectedAt != null) return;
|
||||
_ending = true;
|
||||
_remoteEnded = true;
|
||||
_stopTone();
|
||||
unawaited(widget.api
|
||||
.post(
|
||||
'/im/calls/${widget.callId}/${widget.incoming ? 'reject' : 'end'}')
|
||||
.catchError((_) {}));
|
||||
setState(() => _status = '无人接听');
|
||||
Future<void>.delayed(const Duration(milliseconds: 650), () {
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
String get _durationText {
|
||||
final h = _elapsed.inHours;
|
||||
final m = _elapsed.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final s = _elapsed.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
return h > 0 ? '${h.toString().padLeft(2, '0')}:$m:$s' : '$m:$s';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) _hangup();
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: const Color(0xFF1C1C1E),
|
||||
body: SafeArea(
|
||||
child: Stack(children: [
|
||||
if (_video && _remoteVideo != null)
|
||||
Positioned.fill(
|
||||
child: VideoTrackRenderer(_remoteVideo!,
|
||||
fit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitCover))
|
||||
else
|
||||
Center(
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
CircleAvatar(
|
||||
radius: 48,
|
||||
backgroundColor: kBind,
|
||||
child: Text(
|
||||
widget.peerName.trim().isEmpty
|
||||
? '通'
|
||||
: String.fromCharCodes(widget.peerName.runes.take(1)),
|
||||
style:
|
||||
const TextStyle(color: Colors.white, fontSize: 32))),
|
||||
const SizedBox(height: 16),
|
||||
Text(widget.peerName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
Text(_status,
|
||||
style:
|
||||
const TextStyle(color: Color(0xFFBBBBBB), fontSize: 14)),
|
||||
if (_connectedAt != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(_durationText,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFBBBBBB), fontSize: 14)),
|
||||
],
|
||||
])),
|
||||
if (_video && _localVideo != null)
|
||||
Positioned(
|
||||
right: 16,
|
||||
top: 16,
|
||||
width: 110,
|
||||
height: 160,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: VideoTrackRenderer(_localVideo!,
|
||||
mirrorMode: VideoViewMirrorMode.mirror))),
|
||||
if (_video && _remoteVideo != null)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 24,
|
||||
child: Text(_status,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white70))),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 36,
|
||||
child:
|
||||
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
if (!_ready) ...[
|
||||
_round(
|
||||
Icons.call_end, const Color(0xFFE64340), _hangup, '拒绝'),
|
||||
const SizedBox(width: 48),
|
||||
_round(Icons.call, const Color(0xFF07C160), _start, '接听'),
|
||||
] else ...[
|
||||
_round(
|
||||
_muted ? Icons.mic_off : Icons.mic,
|
||||
const Color(0xFF3A3A3C),
|
||||
_toggleMute,
|
||||
_muted ? '开麦' : '静音'),
|
||||
if (_video) ...[
|
||||
const SizedBox(width: 28),
|
||||
_round(_camOff ? Icons.videocam_off : Icons.videocam,
|
||||
const Color(0xFF3A3A3C), _toggleCam, '摄像头')
|
||||
],
|
||||
const SizedBox(width: 28),
|
||||
_round(
|
||||
Icons.call_end, const Color(0xFFE64340), _hangup, '挂断'),
|
||||
],
|
||||
])),
|
||||
])),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _round(IconData icon, Color color, VoidCallback onTap, String label) =>
|
||||
Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
InkWell(
|
||||
onTap: onTap,
|
||||
customBorder: const CircleBorder(),
|
||||
child: CircleAvatar(
|
||||
radius: 28,
|
||||
backgroundColor: color,
|
||||
child: Icon(icon, color: Colors.white, size: 26))),
|
||||
const SizedBox(height: 8),
|
||||
Text(label,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 12)),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> openCallPage(BuildContext context,
|
||||
{required SessionStore session,
|
||||
required OaClient api,
|
||||
required Map<String, dynamic> call,
|
||||
String peerName = '同事',
|
||||
bool incoming = false}) async {
|
||||
final id = '${call['id'] ?? ''}';
|
||||
if (id.isEmpty) return;
|
||||
final members =
|
||||
((call['memberIds'] as List?) ?? []).map((e) => '$e').toList();
|
||||
await Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => CallPage(
|
||||
session: session,
|
||||
api: api,
|
||||
callId: id,
|
||||
kind: '${call['kind'] ?? 'audio'}',
|
||||
title: '${call['title'] ?? '通话'}',
|
||||
initiatorId: '${call['initiatorId'] ?? ''}',
|
||||
memberIds: members,
|
||||
peerName: peerName,
|
||||
incoming: incoming)));
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../im/chat_prefs.dart';
|
||||
import '../session/session.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/wecom.dart';
|
||||
import 'pick_people_page.dart';
|
||||
|
||||
class ChatDetailPage extends StatefulWidget {
|
||||
const ChatDetailPage({
|
||||
super.key,
|
||||
required this.session,
|
||||
required this.api,
|
||||
required this.conversationId,
|
||||
required this.peerId,
|
||||
required this.peerName,
|
||||
required this.isGroup,
|
||||
this.peerAvatarFileId,
|
||||
this.messages = const [],
|
||||
this.onCall,
|
||||
});
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
final String conversationId;
|
||||
final String peerId;
|
||||
final String peerName;
|
||||
final bool isGroup;
|
||||
final String? peerAvatarFileId;
|
||||
final List<Map<String, dynamic>> messages;
|
||||
final void Function(String kind)? onCall;
|
||||
|
||||
@override
|
||||
State<ChatDetailPage> createState() => _ChatDetailPageState();
|
||||
}
|
||||
|
||||
class _ChatDetailPageState extends State<ChatDetailPage> {
|
||||
List<Map<String, dynamic>> _members = [];
|
||||
bool _mute = false;
|
||||
bool _pin = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mute = ChatPrefs.muted(widget.conversationId);
|
||||
_pin = ChatPrefs.pinned(widget.conversationId);
|
||||
_loadMembers();
|
||||
}
|
||||
|
||||
Future<void> _loadMembers() async {
|
||||
if (widget.isGroup && widget.conversationId.isNotEmpty) {
|
||||
try {
|
||||
final data = await widget.api.get('/im/groups/${widget.conversationId}/members');
|
||||
if (!mounted) return;
|
||||
setState(() => _members = asMaps(data));
|
||||
return;
|
||||
} catch (_) {}
|
||||
}
|
||||
setState(() {
|
||||
_members = [
|
||||
{
|
||||
'id': widget.peerId,
|
||||
'name': widget.peerName,
|
||||
'avatarFileId': widget.peerAvatarFileId,
|
||||
},
|
||||
{
|
||||
'id': widget.session.userId,
|
||||
'name': widget.session.displayName,
|
||||
'avatarFileId': widget.session.user['avatarFileId'],
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _addMembers() async {
|
||||
final exclude = {
|
||||
widget.session.userId,
|
||||
..._members.map((e) => '${e['id'] ?? e['userId'] ?? ''}'),
|
||||
};
|
||||
final picked = await Navigator.of(context).push<List<Map<String, dynamic>>>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PickPeoplePage(api: widget.api, title: '选择同事', exclude: exclude),
|
||||
),
|
||||
);
|
||||
if (picked == null || picked.isEmpty) return;
|
||||
try {
|
||||
if (widget.isGroup && widget.conversationId.isNotEmpty) {
|
||||
await widget.api.post('/im/groups/${widget.conversationId}/members', {
|
||||
'memberIds': picked.map((e) => '${e['id']}').toList(),
|
||||
});
|
||||
await _loadMembers();
|
||||
} else {
|
||||
await widget.api.post('/im/groups', {
|
||||
'name': '${widget.peerName}的群聊',
|
||||
'memberIds': [widget.peerId, ...picked.map((e) => '${e['id']}')],
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已创建群聊')));
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _search() async {
|
||||
await Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => _ChatSearchPage(messages: widget.messages, peerName: widget.peerName),
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> _pickBg() async {
|
||||
const colors = [
|
||||
0xFFF5F5F5,
|
||||
0xFFE7F0E4,
|
||||
0xFFE8EEF6,
|
||||
0xFFF6EFE6,
|
||||
0xFFF3E8EE,
|
||||
];
|
||||
final picked = await showModalBottomSheet<int>(
|
||||
context: context,
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
for (final c in colors)
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(ctx, c),
|
||||
child: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: Color(c),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFDDDDDD)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (picked == null) return;
|
||||
await ChatPrefs.setBg(widget.conversationId, picked);
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _clear() async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('删除聊天记录'),
|
||||
content: const Text('将从本机清空当前会话的聊天记录,对方不受影响。'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('删除')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true) return;
|
||||
await ChatPrefs.clearHistory(widget.conversationId);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已删除本机聊天记录')));
|
||||
Navigator.pop(context, 'cleared');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: kPaper,
|
||||
appBar: AppBar(title: const Text('聊天详情')),
|
||||
body: ListView(
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
color: Colors.white,
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
for (final m in _members)
|
||||
SizedBox(
|
||||
width: 56,
|
||||
child: Column(
|
||||
children: [
|
||||
SquareAvatar(
|
||||
label: '${m['name'] ?? m['displayName'] ?? ''}',
|
||||
size: 48,
|
||||
fileId: '${m['avatarFileId'] ?? ''}',
|
||||
api: widget.api,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${m['name'] ?? m['displayName'] ?? ''}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12, color: kInk),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 56,
|
||||
child: Column(
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: _addMembers,
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: const Color(0xFFC8C8C8), style: BorderStyle.solid),
|
||||
),
|
||||
child: const Icon(Icons.add, color: kMute),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(' ', style: TextStyle(fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CellGroup(
|
||||
children: [
|
||||
Cell(title: '查找聊天内容', onTap: _search, showLine: false),
|
||||
],
|
||||
),
|
||||
CellGroup(
|
||||
children: [
|
||||
Cell(
|
||||
title: '消息免打扰',
|
||||
trailing: Switch(
|
||||
value: _mute,
|
||||
activeTrackColor: kWeGreen,
|
||||
onChanged: (v) async {
|
||||
await ChatPrefs.setMuted(widget.conversationId, v);
|
||||
setState(() => _mute = v);
|
||||
},
|
||||
),
|
||||
),
|
||||
Cell(
|
||||
title: '置顶聊天',
|
||||
trailing: Switch(
|
||||
value: _pin,
|
||||
activeTrackColor: kWeGreen,
|
||||
onChanged: (v) async {
|
||||
await ChatPrefs.setPinned(widget.conversationId, v);
|
||||
setState(() => _pin = v);
|
||||
},
|
||||
),
|
||||
showLine: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
CellGroup(
|
||||
children: [
|
||||
Cell(title: '设置当前聊天背景', onTap: _pickBg),
|
||||
Cell(title: '语音通话', onTap: () => widget.onCall?.call('audio')),
|
||||
Cell(
|
||||
title: '视频通话',
|
||||
onTap: () => widget.onCall?.call(widget.isGroup ? 'meeting' : 'video'),
|
||||
showLine: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
CellGroup(
|
||||
children: [
|
||||
Cell(title: '删除聊天记录', onTap: _clear, showLine: false),
|
||||
],
|
||||
),
|
||||
CellGroup(
|
||||
children: [
|
||||
Cell(
|
||||
title: '投诉',
|
||||
onTap: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已记录投诉,我们会尽快处理')));
|
||||
},
|
||||
showLine: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatSearchPage extends StatefulWidget {
|
||||
const _ChatSearchPage({required this.messages, required this.peerName});
|
||||
final List<Map<String, dynamic>> messages;
|
||||
final String peerName;
|
||||
|
||||
@override
|
||||
State<_ChatSearchPage> createState() => _ChatSearchPageState();
|
||||
}
|
||||
|
||||
class _ChatSearchPageState extends State<_ChatSearchPage> {
|
||||
String _q = '';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final q = _q.trim();
|
||||
final hits = q.isEmpty
|
||||
? const <Map<String, dynamic>>[]
|
||||
: widget.messages.where((e) => '${e['body'] ?? ''}'.contains(q)).toList();
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(title: const Text('查找聊天内容')),
|
||||
body: Column(
|
||||
children: [
|
||||
WxSearchBar(hint: '搜索', onChanged: (v) => setState(() => _q = v)),
|
||||
Expanded(
|
||||
child: q.isEmpty
|
||||
? const Center(child: Text('输入关键词查找聊天内容', style: TextStyle(color: kMute)))
|
||||
: hits.isEmpty
|
||||
? const Center(child: Text('没有找到相关内容', style: TextStyle(color: kMute)))
|
||||
: ListView(
|
||||
children: [
|
||||
for (final r in hits)
|
||||
Cell(
|
||||
title: '${r['body']}',
|
||||
subtitle: '${r['fromName'] ?? widget.peerName} ${r['createdAt'] ?? ''}',
|
||||
showLine: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,218 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../session/session.dart';
|
||||
import '../desktop/desk_theme.dart';
|
||||
import '../desktop/widgets/desk_widgets.dart';
|
||||
import '../widgets/beian_footer.dart';
|
||||
|
||||
class DesktopLoginPage extends StatefulWidget {
|
||||
const DesktopLoginPage({super.key, required this.session, required this.api});
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
|
||||
@override
|
||||
State<DesktopLoginPage> createState() => _DesktopLoginPageState();
|
||||
}
|
||||
|
||||
class _DesktopLoginPageState extends State<DesktopLoginPage> {
|
||||
final _codeCtrl = TextEditingController();
|
||||
String _ticket = '';
|
||||
String _payload = '';
|
||||
String _hint = '打开手机端「消息」右上角 + → 扫一扫,扫描此二维码';
|
||||
String _error = '';
|
||||
bool _submitting = false;
|
||||
bool _starting = true;
|
||||
Timer? _poll;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(_start());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_poll?.cancel();
|
||||
_codeCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _start() async {
|
||||
setState(() {
|
||||
_starting = true;
|
||||
_error = '';
|
||||
_codeCtrl.clear();
|
||||
_hint = '打开手机端「消息」右上角 + → 扫一扫,扫描此二维码';
|
||||
});
|
||||
_poll?.cancel();
|
||||
try {
|
||||
final raw = await widget.api.post('/auth/qr/start', {});
|
||||
final data = raw is Map ? Map<String, dynamic>.from(raw) : <String, dynamic>{};
|
||||
final ticket = '${data['ticket'] ?? ''}'.trim();
|
||||
if (ticket.isEmpty) throw Exception('无法生成登录码');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_ticket = ticket;
|
||||
_payload = '${data['payload'] ?? 'fengyingoa://qr?ticket=$ticket'}';
|
||||
_starting = false;
|
||||
});
|
||||
_poll = Timer.periodic(const Duration(milliseconds: 1600), (_) => _pollStatus());
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_starting = false;
|
||||
_error = '$e';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pollStatus() async {
|
||||
if (_ticket.isEmpty || _submitting) return;
|
||||
try {
|
||||
final raw = await widget.api.get('/auth/qr/status', query: {'ticket': _ticket});
|
||||
final data = raw is Map ? Map<String, dynamic>.from(raw) : <String, dynamic>{};
|
||||
final status = '${data['status'] ?? ''}';
|
||||
if (!mounted) return;
|
||||
if (status == 'code_required') {
|
||||
setState(() => _hint = '手机已确认,请输入手机屏幕显示的六位登录码');
|
||||
return;
|
||||
}
|
||||
if (status == 'expired' || status == 'consumed') {
|
||||
_poll?.cancel();
|
||||
setState(() => _hint = '登录码已过期,正在刷新…');
|
||||
await _start();
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = '$e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _complete() async {
|
||||
final code = _codeCtrl.text.trim();
|
||||
if (!RegExp(r'^\d{6}$').hasMatch(code)) {
|
||||
setState(() => _error = '请输入手机端显示的六位登录码');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_submitting = true;
|
||||
_error = '';
|
||||
});
|
||||
try {
|
||||
final raw = await widget.api.post('/auth/qr/complete', {'ticket': _ticket, 'code': code});
|
||||
final data = raw is Map ? Map<String, dynamic>.from(raw) : <String, dynamic>{};
|
||||
if ('${data['accessToken'] ?? ''}'.isEmpty) throw Exception('电脑登录未完成');
|
||||
await widget.session.applyLogin(data);
|
||||
try {
|
||||
final menus = await widget.api.get('/system/menus');
|
||||
await widget.session.setMenus(asMaps(menus).map(MenuNode.fromJson).toList());
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = '$e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: kDeskBg,
|
||||
body: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Material(
|
||||
color: kDeskPane,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
elevation: 0,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: kDeskLine),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(36, 40, 36, 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DeskAvatar(label: '风', api: widget.api, size: 64, color: kDeskBind, icon: Icons.apartment),
|
||||
const SizedBox(height: 14),
|
||||
const Text('风影办公', style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: kDeskInk)),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'请使用手机 App 扫码,并输入六位登录码',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: kDeskMute, fontSize: 13, height: 1.5),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (_error.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(_error, textAlign: TextAlign.center, style: const TextStyle(color: Color(0xFFFA5151))),
|
||||
),
|
||||
if (_starting)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 48),
|
||||
child: CircularProgressIndicator(color: kDeskBind),
|
||||
)
|
||||
else if (_payload.isNotEmpty) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: kDeskLine),
|
||||
),
|
||||
child: QrImageView(data: _payload, size: 200, backgroundColor: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(_hint, textAlign: TextAlign.center, style: const TextStyle(color: kDeskMute, fontSize: 12)),
|
||||
const SizedBox(height: 18),
|
||||
TextField(
|
||||
controller: _codeCtrl,
|
||||
onChanged: (v) {
|
||||
final next = v.replaceAll(RegExp(r'\D'), '');
|
||||
final clipped = next.length > 6 ? next.substring(0, 6) : next;
|
||||
if (clipped != v) {
|
||||
_codeCtrl.value = TextEditingValue(
|
||||
text: clipped,
|
||||
selection: TextSelection.collapsed(offset: clipped.length),
|
||||
);
|
||||
}
|
||||
},
|
||||
keyboardType: TextInputType.number,
|
||||
textAlign: TextAlign.center,
|
||||
maxLength: 6,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6)],
|
||||
decoration: const InputDecoration(hintText: '六位登录码', counterText: ''),
|
||||
style: const TextStyle(fontSize: 26, fontWeight: FontWeight.w700, letterSpacing: 8),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: kDeskBind, minimumSize: const Size.fromHeight(40)),
|
||||
onPressed: _submitting || _ticket.isEmpty ? null : _complete,
|
||||
child: Text(_submitting ? '登录中…' : '确认登录'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton(onPressed: _starting ? null : _start, child: const Text('刷新')),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: const BeianFooter(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../nav/open_module.dart';
|
||||
import '../pages/approvals_page.dart';
|
||||
import '../pages/attendance_page.dart';
|
||||
import '../pages/calendar_page.dart';
|
||||
import '../pages/flow_page.dart';
|
||||
import '../pages/hr_apply_page.dart';
|
||||
import '../pages/module_list_page.dart';
|
||||
import '../pages/todos_page.dart';
|
||||
import '../pages/work_assign_page.dart';
|
||||
import '../session/session.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/desktop_ui.dart';
|
||||
import '../widgets/wecom.dart';
|
||||
|
||||
typedef DesktopOpenTab = void Function(String key, String title, IconData icon, Color color, Widget page);
|
||||
|
||||
class DesktopWorkbenchPage extends StatefulWidget {
|
||||
const DesktopWorkbenchPage({
|
||||
super.key,
|
||||
required this.session,
|
||||
required this.api,
|
||||
required this.onOpenTab,
|
||||
});
|
||||
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
final DesktopOpenTab onOpenTab;
|
||||
|
||||
@override
|
||||
State<DesktopWorkbenchPage> createState() => _DesktopWorkbenchPageState();
|
||||
}
|
||||
|
||||
class _DesktopWorkbenchPageState extends State<DesktopWorkbenchPage> {
|
||||
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<_DeskApp> _personalApps() {
|
||||
return [
|
||||
if (_match('待办'))
|
||||
_DeskApp('todos', '待办', '处理待办事项与提醒', Icons.task_alt, const Color(0xFFFA9D3B), TodosPage(api: widget.api)),
|
||||
if (_match('审批'))
|
||||
_DeskApp('approvals', '待审批', '审批各类办公申请', Icons.fact_check, kBind, ApprovalsPage(api: widget.api)),
|
||||
if (_match('申请'))
|
||||
_DeskApp('flow', '我的申请', '查看我发起的申请', Icons.assignment_outlined, const Color(0xFF6267F2), FlowPage(api: widget.api)),
|
||||
if (_match('日程'))
|
||||
_DeskApp('calendar', '日程', '会议与日程安排', Icons.calendar_month, const Color(0xFF10AEFF), CalendarPage(api: widget.api)),
|
||||
if (_match('安排'))
|
||||
_DeskApp('work', '工作安排', '任务分配与跟进', Icons.event_note, const Color(0xFF00B578), WorkAssignPage(session: widget.session, api: widget.api)),
|
||||
if (_match('打卡') || _match('考勤'))
|
||||
_DeskApp('attendance', '考勤打卡', '上下班打卡记录', Icons.access_time_filled, const Color(0xFF267EF0), AttendancePage(api: widget.api)),
|
||||
if (_match('人事'))
|
||||
_DeskApp('hr', '人事申请', '请假、加班、外出等', Icons.beach_access, const Color(0xFF8B5CF6), HrApplyPage(api: widget.api)),
|
||||
];
|
||||
}
|
||||
|
||||
List<_DeskApp> _menuApps() {
|
||||
final out = <_DeskApp>[];
|
||||
for (final m in widget.session.menus) {
|
||||
if (m.name == '工作台' || m.name == '个人办公' || m.code == 'office:overview') 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 (_skipChild(c)) continue;
|
||||
if (!(_q.isEmpty || c.name.contains(_q) || m.name.contains(_q))) continue;
|
||||
out.add(_DeskApp(
|
||||
c.code,
|
||||
c.name,
|
||||
m.name,
|
||||
iconFor(c.code, c.name),
|
||||
colorFor(c.code, c.name, i),
|
||||
_pageFor(c),
|
||||
));
|
||||
}
|
||||
} else if (!_skipChild(m) && _match(m.name)) {
|
||||
out.add(_DeskApp(m.code, m.name, '业务应用', iconFor(m.code, m.name), colorFor(m.code, m.name), _pageFor(m)));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool _skipChild(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);
|
||||
}
|
||||
|
||||
Widget _pageFor(MenuNode n) {
|
||||
final shell = shellFor(n.code, n.path);
|
||||
if (shell != null) {
|
||||
return ModuleListPage(api: widget.api, title: n.name, shell: shell);
|
||||
}
|
||||
return Center(child: Text('${n.name} 暂未配置桌面入口', style: const TextStyle(color: kMute)));
|
||||
}
|
||||
|
||||
List<_DeskApp> get _shownApps {
|
||||
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 >= 1200 ? 3 : 2;
|
||||
return ColoredBox(
|
||||
color: kDeskBg,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DesktopPaneHeader(
|
||||
title: '工作台',
|
||||
actions: [
|
||||
SizedBox(width: 220, child: DesktopSearchBar(hint: '搜索应用', onChanged: (v) => setState(() => _q = v.trim()))),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton(onPressed: _load, child: const Text('刷新')),
|
||||
],
|
||||
bottom: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
_catChip('all', '全部应用'),
|
||||
const SizedBox(width: 8),
|
||||
_catChip('personal', '个人办公'),
|
||||
const SizedBox(width: 8),
|
||||
_catChip('biz', '业务模块'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kBind),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 18),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(colors: [Color(0xFFEEF4FF), Color(0xFFF8FBFF)]),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFFDCE8FF)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_greet.isEmpty ? '你好,${widget.session.displayName}' : _greet,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: kInk),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text('工作台 · 个人办公与业务入口', style: TextStyle(color: kMute, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
_statCard('待办', '${_ov['pendingTodos'] ?? 0}', () => _open('todos', '待办', Icons.task_alt, const Color(0xFFFA9D3B), TodosPage(api: widget.api))),
|
||||
const SizedBox(width: 12),
|
||||
_statCard('待审批', '${_ov['pendingApprovals'] ?? 0}', () => _open('approvals', '待审批', Icons.fact_check, kBind, ApprovalsPage(api: widget.api))),
|
||||
const SizedBox(width: 12),
|
||||
_statCard('我的申请', '${_ov['myPending'] ?? 0}', () => _open('flow', '我的申请', Icons.assignment_outlined, const Color(0xFF6267F2), FlowPage(api: widget.api))),
|
||||
const SizedBox(width: 12),
|
||||
_statCard('逾期', '${_ov['overdueTodos'] ?? 0}', () => _open('todos', '待办', Icons.task_alt, const Color(0xFFFA9D3B), TodosPage(api: widget.api))),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (_shownApps.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 48),
|
||||
child: Center(child: Text('没有匹配的应用', style: TextStyle(color: kMute))),
|
||||
)
|
||||
else
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: cols,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 2.8,
|
||||
),
|
||||
itemCount: _shownApps.length,
|
||||
itemBuilder: (_, i) {
|
||||
final a = _shownApps[i];
|
||||
return DesktopAppCard(
|
||||
label: a.title,
|
||||
desc: a.desc,
|
||||
icon: a.icon,
|
||||
color: a.color,
|
||||
onTap: () => _open(a.key, a.title, a.icon, a.color, a.page),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _catChip(String id, String label) {
|
||||
final on = _cat == id;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _cat = id),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: on ? kDeskActive : Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: on ? const Color(0xFFBFD7FF) : kLine),
|
||||
),
|
||||
child: Text(label, style: TextStyle(fontSize: 13, color: on ? kBind : kMute, fontWeight: on ? FontWeight.w600 : FontWeight.w400)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _statCard(String label, String value, VoidCallback onTap) {
|
||||
return Expanded(
|
||||
child: Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(value, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: kBind)),
|
||||
const SizedBox(height: 4),
|
||||
Text(label, style: const TextStyle(fontSize: 12, color: kMute)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeskApp {
|
||||
_DeskApp(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;
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../im/pinyin.dart';
|
||||
import '../session/session.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/common.dart';
|
||||
import '../widgets/desktop_ui.dart';
|
||||
import '../widgets/wecom.dart';
|
||||
import 'chat_page.dart';
|
||||
import 'org_browse_page.dart';
|
||||
|
||||
class DirectoryPage extends StatefulWidget {
|
||||
const DirectoryPage({super.key, required this.session, required this.api, this.embedded = false, this.desktopStyle = false});
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
final bool embedded;
|
||||
final bool desktopStyle;
|
||||
|
||||
@override
|
||||
State<DirectoryPage> createState() => _DirectoryPageState();
|
||||
}
|
||||
|
||||
class _DirectoryPageState extends State<DirectoryPage> {
|
||||
List<Map<String, dynamic>> _items = [];
|
||||
Map<String, List<String>> _presence = {};
|
||||
String _q = '';
|
||||
bool _loading = true;
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
void _openPerson(Map<String, dynamic> r) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: kPaper,
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SquareAvatar(
|
||||
label: '${r['name'] ?? ''}',
|
||||
size: 64,
|
||||
fileId: '${r['avatarFileId'] ?? ''}',
|
||||
api: widget.api,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text('${r['name'] ?? ''}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
[r['department'], r['title']].where((e) => e != null && '$e'.isNotEmpty).join(' · '),
|
||||
style: const TextStyle(color: kMute),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CellGroup(
|
||||
children: [
|
||||
Cell(title: '手机', subtitle: '${r['mobile'] ?? '未填'}', showLine: true),
|
||||
Cell(title: '邮箱', subtitle: '${r['email'] ?? '未填'}', showLine: false),
|
||||
],
|
||||
),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(ctx);
|
||||
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 (_) {}
|
||||
await Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => ChatPage(
|
||||
session: widget.session,
|
||||
api: widget.api,
|
||||
peerId: '${r['id']}',
|
||||
conversationId: conversationId,
|
||||
peerName: '${r['name'] ?? '同事'}',
|
||||
peerAvatarFileId: '${r['avatarFileId'] ?? ''}',
|
||||
),
|
||||
));
|
||||
widget.session.im.bump();
|
||||
},
|
||||
child: const Padding(padding: EdgeInsets.symmetric(vertical: 10), child: Text('发消息')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _desktopBody(
|
||||
List<Map<String, dynamic>> shown,
|
||||
Map<String, List<Map<String, dynamic>>> groups,
|
||||
List<String> letters,
|
||||
Set<String> depts,
|
||||
) {
|
||||
if (_loading) return const LinearProgressIndicator(minHeight: 2, color: kBind);
|
||||
if (shown.isEmpty) return const Center(child: EmptyHint('没有匹配的同事'));
|
||||
return RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: Stack(
|
||||
children: [
|
||||
ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 28, 24),
|
||||
children: [
|
||||
if (_q.isEmpty)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10)),
|
||||
child: Column(
|
||||
children: [
|
||||
Cell(
|
||||
title: '组织架构',
|
||||
subtitle: '${depts.length} 个部门',
|
||||
leading: const SquareAvatar(label: '组', color: kBind, icon: Icons.account_tree, size: 36),
|
||||
onTap: () => Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => OrgBrowsePage(session: widget.session, api: widget.api, staff: _items),
|
||||
)),
|
||||
),
|
||||
Cell(
|
||||
title: '企业通讯录',
|
||||
subtitle: '${_items.length} 人',
|
||||
leading: const SquareAvatar(label: '通', color: Color(0xFF07C160), icon: Icons.contacts, size: 36),
|
||||
showLine: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final letter in letters) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 8, 4, 6),
|
||||
child: Text(letter, style: const TextStyle(fontSize: 13, color: kMute, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
Container(
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10)),
|
||||
child: Column(
|
||||
children: [
|
||||
for (final r in groups[letter]!)
|
||||
InkWell(
|
||||
onTap: () => _openPerson(r),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
SquareAvatar(label: '${r['name'] ?? ''}', fileId: '${r['avatarFileId'] ?? ''}', api: widget.api),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: kLine, width: 0.5))),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text('${r['name'] ?? ''}', style: const TextStyle(fontSize: 16, color: kInk)),
|
||||
const SizedBox(width: 6),
|
||||
Container(width: 6, height: 6, decoration: BoxDecoration(shape: BoxShape.circle, color: (_presence['${r['id']}'] ?? const []).isNotEmpty ? const Color(0xFF22C55E) : const Color(0xFFB8BEC8))),
|
||||
const SizedBox(width: 3),
|
||||
Text((_presence['${r['id']}'] ?? const []).isNotEmpty ? '在线' : '离线', style: const TextStyle(fontSize: 11, color: kMute)),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
[r['department'], r['title']].where((e) => e != null && '$e'.isNotEmpty).join(' · '),
|
||||
style: const TextStyle(fontSize: 12, color: kMute),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [for (final l in letters) Text(l, style: const TextStyle(fontSize: 10, color: kBind, height: 1.35))],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final me = widget.session.userId;
|
||||
final shown = _items.where((e) {
|
||||
if ('${e['id']}' == me) return false;
|
||||
if (_q.isEmpty) return true;
|
||||
return '${e['name']}${e['department']}${e['mobile']}${e['email']}${e['title']}'.contains(_q);
|
||||
}).toList()
|
||||
..sort((a, b) {
|
||||
final la = letterOf('${a['name']}');
|
||||
final lb = letterOf('${b['name']}');
|
||||
final c = la.compareTo(lb);
|
||||
if (c != 0) return c;
|
||||
return '${a['name']}'.compareTo('${b['name']}');
|
||||
});
|
||||
|
||||
final groups = <String, List<Map<String, dynamic>>>{};
|
||||
for (final r in shown) {
|
||||
groups.putIfAbsent(letterOf('${r['name']}'), () => []).add(r);
|
||||
}
|
||||
final letters = groups.keys.toList()
|
||||
..sort((a, b) {
|
||||
if (a == '#') return 1;
|
||||
if (b == '#') return -1;
|
||||
return a.compareTo(b);
|
||||
});
|
||||
final depts = <String>{};
|
||||
for (final r in _items) {
|
||||
final d = '${r['department'] ?? ''}';
|
||||
if (d.isNotEmpty) depts.add(d);
|
||||
}
|
||||
|
||||
final body = widget.desktopStyle
|
||||
? _desktopBody(shown, groups, letters, depts)
|
||||
: Column(
|
||||
children: [
|
||||
WxSearchBar(hint: '搜索同事、部门、手机', onChanged: (v) => setState(() => _q = v.trim())),
|
||||
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kBind),
|
||||
Expanded(
|
||||
child: shown.isEmpty
|
||||
? const EmptyHint('没有匹配的同事')
|
||||
: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: Stack(
|
||||
children: [
|
||||
ListView(
|
||||
children: [
|
||||
if (_q.isEmpty) ...[
|
||||
CellGroup(
|
||||
children: [
|
||||
Cell(
|
||||
title: '组织架构',
|
||||
subtitle: '${depts.length} 个部门',
|
||||
leading: const SquareAvatar(label: '组', color: kBind, icon: Icons.account_tree, size: 36),
|
||||
onTap: () => Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => OrgBrowsePage(
|
||||
session: widget.session,
|
||||
api: widget.api,
|
||||
staff: _items,
|
||||
),
|
||||
)),
|
||||
),
|
||||
Cell(
|
||||
title: '企业通讯录',
|
||||
subtitle: '${_items.length} 人',
|
||||
leading: const SquareAvatar(label: '通', color: Color(0xFF07C160), icon: Icons.contacts, size: 36),
|
||||
showLine: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
for (final letter in letters) ...[
|
||||
Container(
|
||||
width: double.infinity,
|
||||
color: kPaper,
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 6),
|
||||
child: Text(letter, style: const TextStyle(fontSize: 13, color: kMute, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
for (final r in groups[letter]!)
|
||||
InkWell(
|
||||
onTap: () => _openPerson(r),
|
||||
child: Container(
|
||||
color: Colors.white,
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 28, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
SquareAvatar(label: '${r['name'] ?? ''}', fileId: '${r['avatarFileId'] ?? ''}', api: widget.api),
|
||||
if ((_presence['${r['id']}'] ?? const []).isNotEmpty)
|
||||
Positioned(
|
||||
right: -3,
|
||||
bottom: -2,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)),
|
||||
child: Icon(
|
||||
(_presence['${r['id']}'] ?? const []).contains('mobile') ? Icons.phone_android : Icons.computer,
|
||||
size: 14,
|
||||
color: const Color(0xFF10AEFF),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: kLine, width: 0.5))),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text('${r['name'] ?? ''}', style: const TextStyle(fontSize: 16, color: kInk)),
|
||||
const SizedBox(width: 6),
|
||||
Container(width: 6, height: 6, decoration: BoxDecoration(shape: BoxShape.circle, color: (_presence['${r['id']}'] ?? const []).isNotEmpty ? const Color(0xFF22C55E) : const Color(0xFFB8BEC8))),
|
||||
const SizedBox(width: 3),
|
||||
Text((_presence['${r['id']}'] ?? const []).isNotEmpty ? '在线' : '离线', style: const TextStyle(fontSize: 11, color: kMute)),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
[r['department'], r['title']].where((e) => e != null && '$e'.isNotEmpty).join(' · '),
|
||||
style: const TextStyle(fontSize: 12, color: kMute),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
for (final l in letters)
|
||||
Text(l, style: const TextStyle(fontSize: 10, color: kBind, height: 1.35)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (widget.embedded) {
|
||||
return ColoredBox(
|
||||
color: widget.desktopStyle ? kDeskBg : kPaper,
|
||||
child: Column(
|
||||
children: [
|
||||
widget.desktopStyle
|
||||
? DesktopPaneHeader(
|
||||
title: '通讯录',
|
||||
bottom: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: DesktopSearchBar(hint: '搜索同事、部门、手机', onChanged: (v) => setState(() => _q = v.trim())),
|
||||
),
|
||||
)
|
||||
: const WxHeader(title: '通讯录'),
|
||||
Expanded(child: widget.desktopStyle ? _desktopBody(shown, groups, letters, depts) : body),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return Scaffold(appBar: AppBar(title: const Text('通讯录')), body: body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../labels.dart';
|
||||
import '../pages/record_detail_page.dart';
|
||||
import '../widgets/common.dart';
|
||||
|
||||
class FlowPage extends StatefulWidget {
|
||||
const FlowPage({super.key, required this.api});
|
||||
final OaClient api;
|
||||
|
||||
@override
|
||||
State<FlowPage> createState() => _FlowPageState();
|
||||
}
|
||||
|
||||
class _FlowPageState extends State<FlowPage> {
|
||||
List<Map<String, dynamic>> _items = [];
|
||||
String _bucket = '';
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final q = <String, String>{};
|
||||
if (_bucket.isNotEmpty) q['bucket'] = _bucket;
|
||||
final data = await widget.api.get('/office/flow', query: q.isEmpty ? null : q);
|
||||
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: Column(
|
||||
children: [
|
||||
BucketBar(
|
||||
value: _bucket,
|
||||
extra: const [('occupying', '占用中')],
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
_bucket = v;
|
||||
_loading = true;
|
||||
});
|
||||
_load();
|
||||
},
|
||||
),
|
||||
if (_loading) const LinearProgressIndicator(minHeight: 2),
|
||||
Expanded(
|
||||
child: _items.isEmpty
|
||||
? const EmptyHint('还没有申请记录')
|
||||
: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView.builder(
|
||||
itemCount: _items.length,
|
||||
itemBuilder: (_, i) {
|
||||
final r = _items[i];
|
||||
return KvTile(
|
||||
title: '${r['title'] ?? ''}',
|
||||
subtitle: '${zh(r['source'])} · ${zh(r['kind'])} ${fmtTime(r['createdAt'])}',
|
||||
status: r['status'],
|
||||
onTap: () => openRecord(context, widget.api, r),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../device/device_bridge.dart';
|
||||
import '../labels.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/common.dart';
|
||||
import '../widgets/wecom.dart';
|
||||
import 'record_detail_page.dart';
|
||||
|
||||
const _kinds = [
|
||||
('PERSONAL', '请假'),
|
||||
('OVERTIME', '加班'),
|
||||
('BUSINESS', '出差'),
|
||||
('OUT', '外出'),
|
||||
('REGULARIZE', '转正'),
|
||||
('RESIGN', '离职'),
|
||||
];
|
||||
|
||||
class HrApplyPage extends StatefulWidget {
|
||||
const HrApplyPage({super.key, required this.api});
|
||||
final OaClient api;
|
||||
|
||||
@override
|
||||
State<HrApplyPage> createState() => _HrApplyPageState();
|
||||
}
|
||||
|
||||
class _HrApplyPageState extends State<HrApplyPage> {
|
||||
List<Map<String, dynamic>> _items = [];
|
||||
bool _loading = true;
|
||||
String _bucket = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
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 showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(ctx).bottom),
|
||||
child: StatefulBuilder(
|
||||
builder: (ctx, setSt) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text('发起人事申请', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final k in _kinds)
|
||||
ChoiceChip(
|
||||
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(hintText: '天数')),
|
||||
const SizedBox(height: 8),
|
||||
TextField(controller: reason, maxLines: 3, decoration: const InputDecoration(hintText: '事由')),
|
||||
const SizedBox(height: 16),
|
||||
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) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已提交,由汇报对象和部门总监审批')));
|
||||
_load();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e')));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: kPaper,
|
||||
appBar: AppBar(
|
||||
title: const Text('人事申请'),
|
||||
actions: [IconButton(onPressed: _create, icon: const Icon(Icons.add))],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 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,
|
||||
onSelected: (_) {
|
||||
setState(() => _bucket = it.$1);
|
||||
_load();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kBind),
|
||||
Expanded(
|
||||
child: _items.isEmpty
|
||||
? const EmptyHint('还没有人事申请')
|
||||
: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView(
|
||||
children: [
|
||||
for (final r in _items)
|
||||
ConvTile(
|
||||
title: '${zh(r['kind'])} · ${r['reason'] ?? ''}',
|
||||
preview: '${zh(r['status'])} ${fmtTime(r['createdAt'])}',
|
||||
avatarIcon: Icons.beach_access_outlined,
|
||||
avatarColor: kBind,
|
||||
avatarLabel: '人',
|
||||
onTap: () => openRecord(context, widget.api, {...r, 'source': 'HR'}),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/beian_footer.dart';
|
||||
|
||||
class LegalPage extends StatefulWidget {
|
||||
const LegalPage({super.key, required this.api, required this.kind});
|
||||
final OaClient api;
|
||||
final String kind;
|
||||
|
||||
@override
|
||||
State<LegalPage> createState() => _LegalPageState();
|
||||
}
|
||||
|
||||
class _LegalPageState extends State<LegalPage> {
|
||||
String _title = '';
|
||||
String _meta = '';
|
||||
List<Map<String, String>> _sections = [];
|
||||
String _error = '';
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_title = widget.kind == 'privacy' ? '隐私政策' : '用户协议';
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final data = await widget.api.get('/legal/${widget.kind}');
|
||||
if (data is! Map) throw ApiException('文档格式错误');
|
||||
final paras = data['paragraphs'];
|
||||
final sections = <Map<String, String>>[];
|
||||
if (paras is List) {
|
||||
for (final row in paras) {
|
||||
if (row is Map) {
|
||||
sections.add({
|
||||
'heading': '${row['heading'] ?? ''}',
|
||||
'body': '${row['body'] ?? ''}',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_title = '${data['title'] ?? _title}';
|
||||
_meta = '${data['operator'] ?? ''} · 更新日期 ${data['updatedAt'] ?? ''}';
|
||||
_sections = sections;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = '$e';
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(title: Text(_title)),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator(color: kBind))
|
||||
: _error.isNotEmpty
|
||||
? Center(child: Padding(padding: const EdgeInsets.all(24), child: Text(_error, style: const TextStyle(color: kDanger))))
|
||||
: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 40),
|
||||
children: [
|
||||
if (_meta.trim().isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Text(_meta, style: const TextStyle(color: kMute, fontSize: 12, height: 1.5)),
|
||||
),
|
||||
for (final s in _sections) ...[
|
||||
if ((s['heading'] ?? '').isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 8),
|
||||
child: Text(s['heading']!, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
Text(s['body'] ?? '', style: const TextStyle(fontSize: 14, height: 1.7, color: Color(0xFF334155))),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
const BeianFooter(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../device/device_bridge.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/tencent_map_thumb.dart';
|
||||
import '../widgets/wecom.dart';
|
||||
|
||||
class GeoPlace {
|
||||
GeoPlace({required this.lat, required this.lng, required this.title, required this.address, this.distance});
|
||||
final double lat;
|
||||
final double lng;
|
||||
final String title;
|
||||
final String address;
|
||||
final num? distance;
|
||||
|
||||
Map<String, dynamic> toMeta() => {
|
||||
'lat': lat,
|
||||
'lng': lng,
|
||||
'title': title,
|
||||
'address': address,
|
||||
};
|
||||
}
|
||||
|
||||
class LocationPickPage extends StatefulWidget {
|
||||
const LocationPickPage({super.key, required this.api});
|
||||
final OaClient api;
|
||||
|
||||
@override
|
||||
State<LocationPickPage> createState() => _LocationPickPageState();
|
||||
}
|
||||
|
||||
class _LocationPickPageState extends State<LocationPickPage> {
|
||||
double? _lat;
|
||||
double? _lng;
|
||||
GeoPlace? _picked;
|
||||
List<GeoPlace> _pois = [];
|
||||
bool _loading = true;
|
||||
String _err = '';
|
||||
String _q = '';
|
||||
Timer? _searchDebounce;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_locate();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchDebounce?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _locate() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_err = '';
|
||||
});
|
||||
try {
|
||||
Map<String, double>? loc;
|
||||
try {
|
||||
loc = await DeviceBridge.getLocation();
|
||||
} catch (_) {}
|
||||
final lat = loc?['lat'];
|
||||
final lng = loc?['lng'];
|
||||
if (lat == null || lng == null) {
|
||||
throw Exception('没有拿到定位,请允许位置权限并打开系统定位');
|
||||
}
|
||||
await _loadAround(lat, lng);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() { _loading = false; _err = '$e'; });
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadAround(double lat, double lng) async {
|
||||
final data = await widget.api.post('/office/geo/reverse', {'lat': lat, 'lng': lng});
|
||||
if (!mounted) return;
|
||||
final map = data is Map ? Map<String, dynamic>.from(data) : <String, dynamic>{};
|
||||
final here = GeoPlace(
|
||||
lat: lat,
|
||||
lng: lng,
|
||||
title: '${map['title'] ?? '当前位置'}',
|
||||
address: '${map['address'] ?? ''}',
|
||||
);
|
||||
final pois = <GeoPlace>[here];
|
||||
final raw = map['pois'];
|
||||
if (raw is List) {
|
||||
for (final e in raw) {
|
||||
if (e is! Map) continue;
|
||||
final plat = (e['lat'] as num?)?.toDouble();
|
||||
final plng = (e['lng'] as num?)?.toDouble();
|
||||
if (plat == null || plng == null) continue;
|
||||
pois.add(GeoPlace(
|
||||
lat: plat,
|
||||
lng: plng,
|
||||
title: '${e['title'] ?? e['address'] ?? '地点'}',
|
||||
address: '${e['address'] ?? ''}',
|
||||
distance: e['distance'] as num?,
|
||||
));
|
||||
}
|
||||
}
|
||||
setState(() {
|
||||
_lat = lat;
|
||||
_lng = lng;
|
||||
_picked = here;
|
||||
_pois = pois;
|
||||
_loading = false;
|
||||
_err = '';
|
||||
});
|
||||
}
|
||||
|
||||
void _onSearch(String q) {
|
||||
_q = q.trim();
|
||||
_searchDebounce?.cancel();
|
||||
if (_q.isEmpty) {
|
||||
if (_lat != null && _lng != null) _loadAround(_lat!, _lng!);
|
||||
return;
|
||||
}
|
||||
_searchDebounce = Timer(const Duration(milliseconds: 350), () async {
|
||||
try {
|
||||
final data = await widget.api.get('/office/geo/suggest', query: {
|
||||
'keyword': _q,
|
||||
if (_lat != null) 'lat': '$_lat',
|
||||
if (_lng != null) 'lng': '$_lng',
|
||||
});
|
||||
if (!mounted) return;
|
||||
final list = data is List ? data : (data is Map ? (data['items'] ?? data['data'] ?? []) : []);
|
||||
final pois = <GeoPlace>[];
|
||||
for (final e in (list is List ? list : <dynamic>[])) {
|
||||
if (e is! Map) continue;
|
||||
final plat = (e['lat'] as num?)?.toDouble();
|
||||
final plng = (e['lng'] as num?)?.toDouble();
|
||||
if (plat == null || plng == null) continue;
|
||||
pois.add(GeoPlace(
|
||||
lat: plat,
|
||||
lng: plng,
|
||||
title: '${e['title'] ?? '地点'}',
|
||||
address: '${e['address'] ?? ''}',
|
||||
distance: e['distance'] as num?,
|
||||
));
|
||||
}
|
||||
setState(() {
|
||||
_pois = pois;
|
||||
if (pois.isNotEmpty) _picked = pois.first;
|
||||
});
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
String _dist(num? d) {
|
||||
if (d == null) return '';
|
||||
if (d < 1) return '当前位置';
|
||||
if (d < 1000) return '${d.round()}m';
|
||||
return '${(d / 1000).toStringAsFixed(1)}km';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final picked = _picked;
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(
|
||||
leading: TextButton(onPressed: () => Navigator.pop(context), child: const Text('取消')),
|
||||
title: const Text('发送位置'),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8, top: 8, bottom: 8),
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: kWeGreen, minimumSize: const Size(64, 32)),
|
||||
onPressed: picked == null ? null : () => Navigator.pop(context, picked),
|
||||
child: const Text('发送'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 220,
|
||||
width: double.infinity,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (picked != null)
|
||||
Positioned.fill(
|
||||
child: TencentMapThumb(api: widget.api, lat: picked.lat, lng: picked.lng, height: 220),
|
||||
)
|
||||
else
|
||||
const ColoredBox(color: Color(0xFFE8F5E9), child: Center(child: CircularProgressIndicator())),
|
||||
Positioned(
|
||||
left: 12,
|
||||
bottom: 12,
|
||||
child: Material(
|
||||
color: Colors.white,
|
||||
shape: const CircleBorder(),
|
||||
elevation: 2,
|
||||
child: IconButton(
|
||||
onPressed: _locate,
|
||||
icon: const Icon(Icons.my_location, color: kBind),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
|
||||
child: WxSearchBar(hint: '搜索地点', onChanged: _onSearch),
|
||||
),
|
||||
if (_err.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(_err, style: const TextStyle(color: kDanger, fontSize: 13)),
|
||||
),
|
||||
Expanded(
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ListView.builder(
|
||||
itemCount: _pois.length,
|
||||
itemBuilder: (_, i) {
|
||||
final p = _pois[i];
|
||||
final on = picked?.lat == p.lat && picked?.lng == p.lng && picked?.title == p.title;
|
||||
return InkWell(
|
||||
onTap: () => setState(() => _picked = p),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: kLine, width: 0.5))),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(p.title, style: const TextStyle(fontSize: 16, color: kInk)),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
[_dist(p.distance), p.address].where((e) => e.isNotEmpty).join(' | '),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12, color: kMute),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (on) const Padding(padding: EdgeInsets.only(left: 8), child: Icon(Icons.check, color: kWeGreen)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../app_config.dart';
|
||||
import '../pages/legal_page.dart';
|
||||
import '../session/session.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/beian_footer.dart';
|
||||
import '../widgets/wecom.dart';
|
||||
|
||||
class LoginPage extends StatefulWidget {
|
||||
const LoginPage({super.key, required this.session, required this.api});
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
|
||||
@override
|
||||
State<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginPage> {
|
||||
final _phone = TextEditingController();
|
||||
final _pass = TextEditingController();
|
||||
final _sms = TextEditingController();
|
||||
bool _loading = false;
|
||||
bool _smsLogin = false;
|
||||
bool _agreed = false;
|
||||
String _error = '';
|
||||
int _wait = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadOptions();
|
||||
}
|
||||
|
||||
Future<void> _loadOptions() async {
|
||||
try {
|
||||
final data = await widget.api.get('/auth/login-options');
|
||||
if (data is Map && mounted) setState(() => _smsLogin = data['smsLogin'] == true);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _sendSms() async {
|
||||
if (_phone.text.trim().isEmpty) {
|
||||
setState(() => _error = '请先填写手机号');
|
||||
return;
|
||||
}
|
||||
setState(() => _error = '');
|
||||
try {
|
||||
await widget.api.post('/auth/sms-code', {'username': _phone.text.trim()});
|
||||
setState(() => _wait = 60);
|
||||
_tick();
|
||||
} catch (e) {
|
||||
setState(() => _error = '$e');
|
||||
}
|
||||
}
|
||||
|
||||
void _tick() {
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
if (!mounted || _wait <= 0) return;
|
||||
setState(() => _wait--);
|
||||
if (_wait > 0) _tick();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _openLegal(String kind) async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => LegalPage(api: widget.api, kind: kind)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_agreed) {
|
||||
setState(() => _error = '请先阅读并同意用户协议和隐私政策');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = '';
|
||||
});
|
||||
try {
|
||||
final data = await widget.api.post('/auth/login', {
|
||||
'username': _phone.text.trim(),
|
||||
'password': _pass.text,
|
||||
'agreeTerms': true,
|
||||
'clientKind': 'mobile',
|
||||
if (_smsLogin && _sms.text.trim().isNotEmpty) 'smsCode': _sms.text.trim(),
|
||||
});
|
||||
await widget.session.applyLogin(Map<String, dynamic>.from(data as Map));
|
||||
try {
|
||||
final menus = await widget.api.get('/system/menus');
|
||||
widget.session.setMenus(asMaps(menus).map(MenuNode.fromJson).toList());
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = '$e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phone.dispose();
|
||||
_pass.dispose();
|
||||
_sms.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(28, 48, 28, 16),
|
||||
children: [
|
||||
const SquareAvatar(label: '风', size: 64, color: kBind, icon: Icons.apartment),
|
||||
const SizedBox(height: 16),
|
||||
const Text(AppConfig.shortName, textAlign: TextAlign.center, style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 4),
|
||||
const Text('手机号登录', textAlign: TextAlign.center, style: TextStyle(color: kMute)),
|
||||
const SizedBox(height: 36),
|
||||
if (_error.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(_error, style: const TextStyle(color: kDanger)),
|
||||
),
|
||||
TextField(
|
||||
controller: _phone,
|
||||
keyboardType: TextInputType.phone,
|
||||
decoration: const InputDecoration(hintText: '手机号'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _pass,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(hintText: '密码'),
|
||||
),
|
||||
if (_smsLogin) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: TextField(controller: _sms, decoration: const InputDecoration(hintText: '短信验证码'))),
|
||||
const SizedBox(width: 8),
|
||||
TextButton(onPressed: _wait > 0 ? null : _sendSms, child: Text(_wait > 0 ? '${_wait}s' : '获取验证码')),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: Checkbox(
|
||||
value: _agreed,
|
||||
onChanged: (v) => setState(() => _agreed = v == true),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
const Text('我已阅读并同意', style: TextStyle(fontSize: 13, color: kMute)),
|
||||
GestureDetector(
|
||||
onTap: () => _openLegal('user-agreement'),
|
||||
child: const Text('《用户协议》', style: TextStyle(fontSize: 13, color: kBind)),
|
||||
),
|
||||
const Text('和', style: TextStyle(fontSize: 13, color: kMute)),
|
||||
GestureDetector(
|
||||
onTap: () => _openLegal('privacy'),
|
||||
child: const Text('《隐私政策》', style: TextStyle(fontSize: 13, color: kBind)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 46,
|
||||
child: FilledButton(
|
||||
onPressed: _loading || !_agreed ? null : _submit,
|
||||
child: Text(_loading ? '登录中…' : '登录', style: const TextStyle(fontSize: 16)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const BeianFooter(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../im/chat_prefs.dart';
|
||||
import '../pages/chat_page.dart';
|
||||
import '../pages/notices_page.dart';
|
||||
import '../pages/pick_people_page.dart';
|
||||
import '../pages/scan_login_page.dart';
|
||||
import '../pages/system_notice_page.dart';
|
||||
import '../session/session.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/common.dart';
|
||||
import '../widgets/desktop_ui.dart';
|
||||
import '../widgets/wecom.dart';
|
||||
|
||||
class MessagesPage extends StatefulWidget {
|
||||
const MessagesPage({
|
||||
super.key,
|
||||
required this.session,
|
||||
required this.api,
|
||||
this.desktopPane = false,
|
||||
this.desktopStyle = false,
|
||||
this.selectedId,
|
||||
this.onSelectConv,
|
||||
});
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
final bool desktopPane;
|
||||
final bool desktopStyle;
|
||||
final String? selectedId;
|
||||
final void Function(Map<String, dynamic> row)? onSelectConv;
|
||||
|
||||
@override
|
||||
State<MessagesPage> createState() => _MessagesPageState();
|
||||
}
|
||||
|
||||
class _MessagesPageState extends State<MessagesPage> {
|
||||
List<Map<String, dynamic>> _items = [];
|
||||
bool _loading = true;
|
||||
String _err = '';
|
||||
String _filter = 'all';
|
||||
String _q = '';
|
||||
bool _showSearch = false;
|
||||
Timer? _imDebounce;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.session.im.addListener(_onIm);
|
||||
ChatPrefs.ensure().then((_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_imDebounce?.cancel();
|
||||
widget.session.im.removeListener(_onIm);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onIm() {
|
||||
_imDebounce?.cancel();
|
||||
_imDebounce = Timer(const Duration(milliseconds: 250), () {
|
||||
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;
|
||||
_err = '';
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
if (_items.isEmpty) _err = '$e';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
int get _unread {
|
||||
var n = 0;
|
||||
for (final r in _items) {
|
||||
if (ChatPrefs.muted('${r['id'] ?? ''}')) continue;
|
||||
final id = '${r['id'] ?? ''}';
|
||||
final fake = ChatPrefs.fakeUnread(id);
|
||||
if (fake > 0) {
|
||||
n += fake;
|
||||
continue;
|
||||
}
|
||||
n += (r['unread'] as num?)?.toInt() ?? 0;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
Future<void> _showDesktopConvMenu(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':
|
||||
await ChatPrefs.setHidden(id, true);
|
||||
case 'clear':
|
||||
await ChatPrefs.clearHistory(id);
|
||||
case 'delete':
|
||||
await ChatPrefs.setHidden(id, true);
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
await _load();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _wrapConv(Map<String, dynamic> r, Widget child) {
|
||||
if (!widget.desktopStyle) return child;
|
||||
return GestureDetector(
|
||||
onSecondaryTapDown: (d) => _showDesktopConvMenu(r, d.globalPosition),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _newChat({required bool group}) async {
|
||||
final picked = await Navigator.of(context).push<List<Map<String, dynamic>>>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PickPeoplePage(
|
||||
api: widget.api,
|
||||
title: group ? '选择联系人' : '发起单聊',
|
||||
multiple: group,
|
||||
exclude: {widget.session.userId},
|
||||
),
|
||||
),
|
||||
);
|
||||
if (picked == null || picked.isEmpty) return;
|
||||
if (!group) {
|
||||
final p = picked.first;
|
||||
if (!mounted) return;
|
||||
await Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => ChatPage(
|
||||
session: widget.session,
|
||||
api: widget.api,
|
||||
peerId: '${p['id']}',
|
||||
peerName: '${p['name'] ?? '同事'}',
|
||||
peerAvatarFileId: '${p['avatarFileId'] ?? ''}',
|
||||
),
|
||||
));
|
||||
_load();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> get _shown {
|
||||
var list = _items;
|
||||
// 只有用户明确执行“不显示/删除”后才隐藏;阅读状态不会改变会话是否存在。
|
||||
list = list.where((e) {
|
||||
final id = '${e['id'] ?? ''}';
|
||||
return !ChatPrefs.hidden(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 = [...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;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final header = widget.desktopStyle
|
||||
? DesktopPaneHeader(
|
||||
title: _unread > 0 ? '消息($_unread)' : '消息',
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => setState(() => _showSearch = !_showSearch),
|
||||
icon: const Icon(Icons.search, color: kInk, size: 20),
|
||||
),
|
||||
Builder(
|
||||
builder: (ctx) => IconButton(
|
||||
onPressed: () => showPlusMenu(ctx, [
|
||||
(Icons.qr_code_scanner_outlined, '扫一扫登录电脑', () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => ScanLoginPage(api: widget.api)))),
|
||||
(Icons.chat_bubble_outline, '发起单聊', () => _newChat(group: false)),
|
||||
(Icons.group_outlined, '发起群聊', () => _newChat(group: true)),
|
||||
]),
|
||||
icon: const Icon(Icons.add, color: kInk, size: 22),
|
||||
),
|
||||
),
|
||||
],
|
||||
bottom: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 10),
|
||||
child: Column(
|
||||
children: [
|
||||
if (_showSearch) ...[
|
||||
DesktopSearchBar(hint: '搜索', onChanged: (v) => setState(() => _q = v.trim())),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
Row(
|
||||
children: [
|
||||
_filterChip('all', '全部'),
|
||||
const SizedBox(width: 8),
|
||||
_filterChip('unread', '未读'),
|
||||
const SizedBox(width: 8),
|
||||
_filterChip('dm', '单聊'),
|
||||
const SizedBox(width: 8),
|
||||
_filterChip('group', '群聊'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
WxHeader(
|
||||
title: _unread > 0 ? '消息($_unread)' : '消息',
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => setState(() => _showSearch = !_showSearch),
|
||||
icon: const Icon(Icons.search, color: kInk),
|
||||
),
|
||||
Builder(
|
||||
builder: (ctx) => IconButton(
|
||||
onPressed: () => showPlusMenu(ctx, [
|
||||
(Icons.qr_code_scanner_outlined, '扫一扫登录电脑', () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => ScanLoginPage(api: widget.api)))),
|
||||
(Icons.chat_bubble_outline, '发起单聊', () => _newChat(group: false)),
|
||||
(Icons.group_outlined, '发起群聊', () => _newChat(group: true)),
|
||||
]),
|
||||
icon: const Icon(Icons.add_circle_outline, color: kInk),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_showSearch) WxSearchBar(hint: '搜索', onChanged: (v) => setState(() => _q = v.trim())),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
_filterChip('all', '全部'),
|
||||
const SizedBox(width: 8),
|
||||
_filterChip('unread', '未读'),
|
||||
const SizedBox(width: 8),
|
||||
_filterChip('dm', '单聊'),
|
||||
const SizedBox(width: 8),
|
||||
_filterChip('group', '群聊'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return ColoredBox(
|
||||
color: Colors.white,
|
||||
child: Column(
|
||||
children: [
|
||||
header,
|
||||
if (_loading)
|
||||
const LinearProgressIndicator(minHeight: 2, color: kBind),
|
||||
if (_err.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(_err, style: const TextStyle(color: kDanger))),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
ConvTile(
|
||||
title: '公告',
|
||||
preview: '公司通知与制度',
|
||||
avatarIcon: Icons.campaign,
|
||||
avatarColor: const Color(0xFFE75D5D),
|
||||
avatarLabel: '告',
|
||||
onTap: () => Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => NoticesPage(api: widget.api),
|
||||
)),
|
||||
),
|
||||
if (_shown.isEmpty && !_loading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 48),
|
||||
child: EmptyHint('还没有会话。点右上角 + 或到通讯录找同事。'),
|
||||
),
|
||||
for (final r in _shown)
|
||||
_wrapConv(
|
||||
r,
|
||||
widget.desktopStyle
|
||||
? ConvTile(
|
||||
title: '${r['name'] ?? r['peerName'] ?? '同事'}',
|
||||
preview: '${r['lastText'] ?? ''}',
|
||||
time: shortTime(r['lastAt']),
|
||||
muted: ChatPrefs.muted('${r['id'] ?? ''}'),
|
||||
unread: ChatPrefs.muted('${r['id'] ?? ''}')
|
||||
? 0
|
||||
: (ChatPrefs.fakeUnread('${r['id'] ?? ''}') > 0
|
||||
? ChatPrefs.fakeUnread('${r['id'] ?? ''}')
|
||||
: ((r['unread'] as num?)?.toInt() ?? 0)),
|
||||
avatarLabel: '${r['peerId']}' == '0' ? '?' : '${r['name'] ?? r['peerName'] ?? '同'}',
|
||||
avatarIcon: '${r['peerId']}' == '0' ? Icons.notifications : (r['type'] == 'group' ? Icons.groups : null),
|
||||
avatarColor: '${r['peerId']}' == '0' ? const Color(0xFF07C160) : (r['type'] == 'group' ? const Color(0xFF07C160) : null),
|
||||
avatarFileId: r['type'] == 'group' ? null : '${r['avatarFileId'] ?? ''}',
|
||||
api: widget.api,
|
||||
selected: widget.desktopPane && widget.selectedId == '${r['id'] ?? ''}',
|
||||
onTap: () async {
|
||||
final id = '${r['id'] ?? ''}';
|
||||
if (id.isNotEmpty) await ChatPrefs.setFakeUnread(id, 0);
|
||||
if (widget.desktopPane && widget.onSelectConv != null) {
|
||||
widget.onSelectConv!(r);
|
||||
return;
|
||||
}
|
||||
await Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => '${r['peerId']}' == '0'
|
||||
? SystemNoticePage(session: widget.session, api: widget.api, conversationId: id)
|
||||
: ChatPage(
|
||||
session: widget.session,
|
||||
api: widget.api,
|
||||
peerId: '${r['peerId'] ?? ''}',
|
||||
conversationId: id,
|
||||
peerName: '${r['name'] ?? r['peerName'] ?? '同事'}',
|
||||
isGroup: r['type'] == 'group',
|
||||
peerAvatarFileId: '${r['avatarFileId'] ?? ''}',
|
||||
),
|
||||
));
|
||||
_load();
|
||||
},
|
||||
)
|
||||
: _SwipeConversation(
|
||||
id: '${r['id'] ?? ''}',
|
||||
api: widget.api,
|
||||
onChanged: _load,
|
||||
child: ConvTile(
|
||||
title: '${r['name'] ?? r['peerName'] ?? '同事'}',
|
||||
preview: '${r['lastText'] ?? ''}',
|
||||
time: shortTime(r['lastAt']),
|
||||
muted: ChatPrefs.muted('${r['id'] ?? ''}'),
|
||||
unread: ChatPrefs.muted('${r['id'] ?? ''}')
|
||||
? 0
|
||||
: ((r['unread'] as num?)?.toInt() ?? 0),
|
||||
avatarLabel: '${r['peerId']}' == '0'
|
||||
? '?'
|
||||
: '${r['name'] ?? r['peerName'] ?? '同'}',
|
||||
avatarIcon: '${r['peerId']}' == '0'
|
||||
? Icons.notifications
|
||||
: (r['type'] == 'group' ? Icons.groups : null),
|
||||
avatarColor: '${r['peerId']}' == '0'
|
||||
? const Color(0xFF07C160)
|
||||
: (r['type'] == 'group'
|
||||
? const Color(0xFF07C160)
|
||||
: null),
|
||||
avatarFileId: r['type'] == 'group'
|
||||
? null
|
||||
: '${r['avatarFileId'] ?? ''}',
|
||||
api: widget.api,
|
||||
selected: widget.desktopPane && widget.selectedId == '${r['id'] ?? ''}',
|
||||
onTap: () async {
|
||||
if (widget.desktopPane && widget.onSelectConv != null) {
|
||||
widget.onSelectConv!(r);
|
||||
return;
|
||||
}
|
||||
await Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => '${r['peerId']}' == '0'
|
||||
? SystemNoticePage(session: widget.session, api: widget.api, conversationId: '${r['id'] ?? ''}')
|
||||
: ChatPage(
|
||||
session: widget.session,
|
||||
api: widget.api,
|
||||
peerId: '${r['peerId'] ?? ''}',
|
||||
conversationId: '${r['id'] ?? ''}',
|
||||
peerName: '${r['name'] ?? r['peerName'] ?? '同事'}',
|
||||
isGroup: r['type'] == 'group',
|
||||
peerAvatarFileId: '${r['avatarFileId'] ?? ''}',
|
||||
),
|
||||
));
|
||||
_load();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _filterChip(String id, String label) {
|
||||
final on = _filter == id;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _filter = id),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: on ? const Color(0xFFE8F3FF) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: on ? kBind : kMute,
|
||||
fontWeight: on ? FontWeight.w600 : FontWeight.w400)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 微信式左滑操作栏:操作不会误删服务器会话,删除仅隐藏本机列表。
|
||||
class _SwipeConversation extends StatefulWidget {
|
||||
const _SwipeConversation(
|
||||
{required this.id,
|
||||
required this.api,
|
||||
required this.child,
|
||||
required this.onChanged});
|
||||
final String id;
|
||||
final OaClient api;
|
||||
final Widget child;
|
||||
final Future<void> Function() onChanged;
|
||||
|
||||
@override
|
||||
State<_SwipeConversation> createState() => _SwipeConversationState();
|
||||
}
|
||||
|
||||
class _SwipeConversationState extends State<_SwipeConversation> {
|
||||
double _offset = 0;
|
||||
static const _width = 216.0;
|
||||
|
||||
Future<void> _hide() async {
|
||||
if (!mounted) return;
|
||||
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) {
|
||||
if (mounted) setState(() => _offset = 0);
|
||||
return;
|
||||
}
|
||||
await ChatPrefs.setHidden(widget.id, true);
|
||||
await widget.onChanged();
|
||||
}
|
||||
|
||||
Future<void> _toggleMute() async {
|
||||
await ChatPrefs.setMuted(widget.id, !ChatPrefs.muted(widget.id));
|
||||
if (mounted) setState(() => _offset = 0);
|
||||
await widget.onChanged();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 76,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onHorizontalDragUpdate: (d) => setState(() => _offset = (_offset + d.delta.dx).clamp(-_width, 0)),
|
||||
onHorizontalDragEnd: (_) => setState(() => _offset = _offset < -80 ? -_width : 0),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_action('免打扰', const Color(0xFFFFA940), _toggleMute),
|
||||
_action('不显示', const Color(0xFF8C8C8C), _hide),
|
||||
_action('删除', const Color(0xFFF5222D), _hide),
|
||||
],
|
||||
),
|
||||
),
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
curve: Curves.easeOut,
|
||||
transform: Matrix4.translationValues(_offset, 0, 0),
|
||||
color: Colors.white,
|
||||
child: widget.child,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _action(String label, Color color, Future<void> Function() onTap) {
|
||||
return SizedBox(
|
||||
width: 72,
|
||||
height: double.infinity,
|
||||
child: Material(
|
||||
color: color,
|
||||
child: InkWell(
|
||||
onTap: () async => onTap(),
|
||||
child: Center(
|
||||
child: Text(label,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 13))),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../device/push_bridge.dart';
|
||||
import '../app_config.dart';
|
||||
import '../labels.dart';
|
||||
import '../ota/updater.dart';
|
||||
import '../pages/legal_page.dart';
|
||||
import '../pages/profile_page.dart';
|
||||
import '../session/session.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/beian_footer.dart';
|
||||
import '../widgets/desktop_ui.dart';
|
||||
import '../widgets/wecom.dart';
|
||||
|
||||
class MinePage extends StatefulWidget {
|
||||
const MinePage({super.key, required this.session, required this.api, this.desktopStyle = false});
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
final bool desktopStyle;
|
||||
|
||||
@override
|
||||
State<MinePage> createState() => _MinePageState();
|
||||
}
|
||||
|
||||
class _MinePageState extends State<MinePage> {
|
||||
AppRelease? _rel;
|
||||
bool _checking = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.session.addListener(_onSession);
|
||||
_peek();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.session.removeListener(_onSession);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSession() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
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) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('暂时无法检查更新')));
|
||||
return;
|
||||
}
|
||||
await OtaUpdater(widget.api).prompt(context, rel, manual: true);
|
||||
} finally {
|
||||
if (mounted) setState(() => _checking = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = widget.session;
|
||||
final avatarId = '${s.user['avatarFileId'] ?? ''}';
|
||||
return ColoredBox(
|
||||
color: widget.desktopStyle ? kDeskBg : kPaper,
|
||||
child: Column(
|
||||
children: [
|
||||
widget.desktopStyle ? const DesktopPaneHeader(title: '我') : const WxHeader(title: '我'),
|
||||
Expanded(
|
||||
child: widget.desktopStyle
|
||||
? Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 720),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
|
||||
children: _mineItems(s, avatarId),
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView(
|
||||
children: _mineItems(s, avatarId),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _mineItems(SessionStore s, String avatarId) {
|
||||
return [
|
||||
const SizedBox(height: 12),
|
||||
Material(
|
||||
color: Colors.white,
|
||||
child: InkWell(
|
||||
onTap: () => Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => ProfilePage(session: s, api: widget.api),
|
||||
)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
SquareAvatar(
|
||||
label: s.displayName,
|
||||
size: 64,
|
||||
fileId: avatarId.isEmpty ? null : avatarId,
|
||||
api: widget.api,
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(s.displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 4),
|
||||
Text('账号 ${s.user['username'] ?? ''}',
|
||||
style: const TextStyle(
|
||||
color: kMute, fontSize: 13)),
|
||||
Text(s.roles.map(zh).join(' · '),
|
||||
style: const TextStyle(
|
||||
color: kMute, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right,
|
||||
color: Color(0xFFC0C0C0)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CellGroup(
|
||||
children: [
|
||||
if (!widget.desktopStyle)
|
||||
Cell(
|
||||
title: '后台运行与通知',
|
||||
subtitle: '允许自启动和后台,消息才能及时送达',
|
||||
leading: const SquareAvatar(
|
||||
label: '通',
|
||||
color: kBind,
|
||||
icon: Icons.notifications_active_outlined,
|
||||
size: 32),
|
||||
onTap: () async {
|
||||
await PushBridge.requestBattery();
|
||||
await PushBridge.openOemKeepAlive();
|
||||
},
|
||||
),
|
||||
if (!widget.desktopStyle)
|
||||
Cell(
|
||||
title: '检查更新',
|
||||
subtitle: _rel != null && _rel!.newer
|
||||
? '有新版本 ${_rel!.version}'
|
||||
: '当前 ${AppConfig.version} (${AppConfig.build})',
|
||||
leading: const SquareAvatar(
|
||||
label: '升',
|
||||
color: kBind,
|
||||
icon: Icons.system_update_alt,
|
||||
size: 32),
|
||||
trailing: _checking
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: (_rel != null && _rel!.newer
|
||||
? const UnreadBadge(1, dot: true)
|
||||
: null),
|
||||
onTap: _checkUpdate,
|
||||
),
|
||||
if (widget.desktopStyle)
|
||||
Cell(
|
||||
title: '检查更新',
|
||||
subtitle: _rel != null && _rel!.newer
|
||||
? '有新版本 ${_rel!.version}'
|
||||
: '当前 ${AppConfig.version} (${AppConfig.build})',
|
||||
leading: const SquareAvatar(
|
||||
label: '升',
|
||||
color: kBind,
|
||||
icon: Icons.system_update_alt,
|
||||
size: 32),
|
||||
trailing: _checking
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: (_rel != null && _rel!.newer
|
||||
? const UnreadBadge(1, dot: true)
|
||||
: null),
|
||||
onTap: _checkUpdate,
|
||||
),
|
||||
Cell(
|
||||
title: '用户协议',
|
||||
leading: const SquareAvatar(
|
||||
label: '协',
|
||||
color: kBind,
|
||||
icon: Icons.article_outlined,
|
||||
size: 32),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => LegalPage(
|
||||
api: widget.api, kind: 'user-agreement')),
|
||||
),
|
||||
),
|
||||
Cell(
|
||||
title: '隐私政策',
|
||||
leading: const SquareAvatar(
|
||||
label: '私',
|
||||
color: kBind,
|
||||
icon: Icons.privacy_tip_outlined,
|
||||
size: 32),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
LegalPage(api: widget.api, kind: 'privacy')),
|
||||
),
|
||||
showLine: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
|
||||
child: Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
onTap: _logout,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: const SizedBox(
|
||||
height: 48,
|
||||
child: Center(
|
||||
child: Text('退出登录',
|
||||
style:
|
||||
TextStyle(fontSize: 16, color: kDanger))),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${AppConfig.brand}\n${AppConfig.version} (${AppConfig.build})',
|
||||
textAlign: TextAlign.center,
|
||||
style:
|
||||
const TextStyle(color: kMute, fontSize: 12, height: 1.5),
|
||||
),
|
||||
const BeianFooter(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../labels.dart';
|
||||
import '../widgets/common.dart';
|
||||
import 'record_detail_page.dart';
|
||||
|
||||
class RestShell {
|
||||
const RestShell(this.path, {this.query});
|
||||
final String path;
|
||||
final Map<String, String>? query;
|
||||
}
|
||||
|
||||
RestShell? shellFor(String code, String? path) {
|
||||
final p = path ?? '';
|
||||
final c = code.toLowerCase();
|
||||
if (c.startsWith('bid') || p.startsWith('/bid')) return const RestShell('/bid-cases');
|
||||
if (c.startsWith('contract:hr') || p.startsWith('/contract/hr') || p.contains('labor-contract')) {
|
||||
return const RestShell('/labor-contracts');
|
||||
}
|
||||
if (c.startsWith('contract') || p.startsWith('/contract')) return const RestShell('/contracts');
|
||||
if (p.startsWith('/party/entities') || c == 'party:entities') return const RestShell('/legal-entities');
|
||||
if (p.startsWith('/party/contacts') || c == 'party:contacts') return const RestShell('/contacts');
|
||||
if (c.startsWith('party') || p.startsWith('/party')) return const RestShell('/parties');
|
||||
if (c == 'seal:borrows' || p.contains('borrow')) return const RestShell('/credential-borrows');
|
||||
if (c == 'seal:requests' || p.contains('seal-apply') || p.contains('/seal/requests')) {
|
||||
return const RestShell('/seal-requests');
|
||||
}
|
||||
if (c == 'seal:registry' || p.contains('/seal/registry')) return const RestShell('/seals');
|
||||
if (c.startsWith('seal') || p.startsWith('/seal')) return const RestShell('/qualifications');
|
||||
if (c.startsWith('asset') || p.startsWith('/asset') || p.contains('registered-asset')) {
|
||||
return const RestShell('/registered-assets');
|
||||
}
|
||||
if (p.contains('asset-purchase')) return const RestShell('/asset-purchases');
|
||||
if (p.contains('purchase') || c.contains('purchase')) return const RestShell('/purchase-requests');
|
||||
if (p.contains('/assets') && !p.contains('registered')) return const RestShell('/assets');
|
||||
if (c.contains('timesheet') || p.contains('timesheet')) return const RestShell('/timesheets');
|
||||
if (c.contains('task') || p.contains('task')) return const RestShell('/project-tasks');
|
||||
if (p.contains('project-change')) return const RestShell('/project-changes');
|
||||
if (p.contains('resource-board')) return const RestShell('/resource-board');
|
||||
if (c.startsWith('project') || p.startsWith('/project')) return const RestShell('/projects');
|
||||
if (p.contains('loan') || c.contains('loan')) return const RestShell('/loans');
|
||||
if (p.contains('bond')) return const RestShell('/bonds');
|
||||
if (p.contains('invoice')) return const RestShell('/invoices');
|
||||
if (p.contains('payment')) return const RestShell('/payments');
|
||||
if (p.contains('expense') || c.contains('expense')) return const RestShell('/expenses');
|
||||
if (p.contains('my-expense')) return const RestShell('/office/my-expenses');
|
||||
if (p.contains('payroll') || c.contains('payroll')) return const RestShell('/payroll');
|
||||
if (p.contains('leave') || c.contains('leave')) return const RestShell('/leave-requests');
|
||||
if (p.contains('employment')) return const RestShell('/employment-events');
|
||||
if (p.contains('departments') || c.contains('dept')) return const RestShell('/departments');
|
||||
if (p.contains('applies') || c.contains('apply')) return const RestShell('/office/applies');
|
||||
if (p.contains('work-assign')) return const RestShell('/office/work-assignments');
|
||||
if (p.contains('cost-target')) return const RestShell('/cost-targets');
|
||||
if (p.contains('attendance/punch')) return const RestShell('/attendance/punches');
|
||||
if (c.contains('attendance') || p.contains('attendance')) return const RestShell('/attendance');
|
||||
if (p.contains('employee') || c.contains('employee') || p.startsWith('/hr')) {
|
||||
return const RestShell('/employees', query: {'page': '1', 'pageSize': '100'});
|
||||
}
|
||||
if (c.startsWith('report') || p.startsWith('/reports') || p.startsWith('/office/reports')) {
|
||||
return const RestShell('/office/reports');
|
||||
}
|
||||
if (c.startsWith('legal') || p.startsWith('/legal')) return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
class ModuleListPage extends StatefulWidget {
|
||||
const ModuleListPage({super.key, required this.api, required this.title, required this.shell});
|
||||
final OaClient api;
|
||||
final String title;
|
||||
final RestShell shell;
|
||||
|
||||
@override
|
||||
State<ModuleListPage> createState() => _ModuleListPageState();
|
||||
}
|
||||
|
||||
class _ModuleListPageState extends State<ModuleListPage> {
|
||||
List<Map<String, dynamic>> _items = [];
|
||||
String _err = '';
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_err = '';
|
||||
});
|
||||
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 (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_err = '$e';
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.title)),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _err.isNotEmpty
|
||||
? EmptyHint(_err)
|
||||
: _items.isEmpty
|
||||
? EmptyHint('暂无${widget.title}')
|
||||
: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView(
|
||||
children: [
|
||||
for (final r in _items)
|
||||
KvTile(
|
||||
title: pickTitle(r),
|
||||
subtitle: zh(pickSub(r)),
|
||||
status: r['status'],
|
||||
onTap: () {
|
||||
final seed = Map<String, dynamic>.from(r);
|
||||
if ('${r['id']}'.length >= 8) seed['_fetch'] = '${widget.shell.path}/${r['id']}';
|
||||
openRecord(context, widget.api, seed, title: pickTitle(r));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/html_body.dart';
|
||||
|
||||
class NoticeDetailPage extends StatefulWidget {
|
||||
const NoticeDetailPage({super.key, required this.api, required this.id, this.title = '公告'});
|
||||
final OaClient api;
|
||||
final String id;
|
||||
final String title;
|
||||
|
||||
@override
|
||||
State<NoticeDetailPage> createState() => _NoticeDetailPageState();
|
||||
}
|
||||
|
||||
class _NoticeDetailPageState extends State<NoticeDetailPage> {
|
||||
Map<String, dynamic> _row = {};
|
||||
bool _loading = true;
|
||||
String _err = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final data = await widget.api.get('/office/notices/${widget.id}');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_row = data is Map ? Map<String, dynamic>.from(data) : {};
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_err = '$e';
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = '${_row['title'] ?? widget.title}';
|
||||
final author = '${(_row['createdBy'] is Map ? (_row['createdBy'] as Map)['displayName'] : '')}';
|
||||
final time = fmtTime(_row['createdAt'] ?? _row['publishedAt']);
|
||||
final content = '${_row['content'] ?? ''}';
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(title: const Text('通知公告'), leading: const BackButton()),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _err.isNotEmpty
|
||||
? Center(child: Padding(padding: const EdgeInsets.all(24), child: Text(_err, style: const TextStyle(color: kMute))))
|
||||
: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 40),
|
||||
children: [
|
||||
Text(title, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: kInk, height: 1.35)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
[if (author.isNotEmpty) author, if (time.isNotEmpty) time].join(' '),
|
||||
style: const TextStyle(fontSize: 13, color: kMute),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
HtmlBody(html: content, api: widget.api),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../widgets/common.dart';
|
||||
import 'notice_detail_page.dart';
|
||||
|
||||
class NoticesPage extends StatefulWidget {
|
||||
const NoticesPage({super.key, required this.api});
|
||||
final OaClient api;
|
||||
|
||||
@override
|
||||
State<NoticesPage> createState() => _NoticesPageState();
|
||||
}
|
||||
|
||||
class _NoticesPageState extends State<NoticesPage> {
|
||||
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/notices');
|
||||
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'] ?? ''}',
|
||||
subtitle: '${r['excerpt'] ?? ''} ${fmtTime(r['createdAt'])}',
|
||||
onTap: () => Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => NoticeDetailPage(
|
||||
api: widget.api,
|
||||
id: '${r['id']}',
|
||||
title: '${r['title'] ?? '公告'}',
|
||||
),
|
||||
)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../session/session.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/wecom.dart';
|
||||
import 'chat_page.dart';
|
||||
|
||||
class OrgBrowsePage extends StatefulWidget {
|
||||
const OrgBrowsePage({
|
||||
super.key,
|
||||
required this.session,
|
||||
required this.api,
|
||||
required this.staff,
|
||||
this.parentId,
|
||||
this.title = '组织架构',
|
||||
this.deptName,
|
||||
});
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
final List<Map<String, dynamic>> staff;
|
||||
final String? parentId;
|
||||
final String title;
|
||||
final String? deptName;
|
||||
|
||||
@override
|
||||
State<OrgBrowsePage> createState() => _OrgBrowsePageState();
|
||||
}
|
||||
|
||||
class _OrgBrowsePageState extends State<OrgBrowsePage> {
|
||||
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 _openPerson(Map<String, dynamic> r) {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => ChatPage(
|
||||
session: widget.session,
|
||||
api: widget.api,
|
||||
peerId: '${r['id']}',
|
||||
peerName: '${r['name'] ?? '同事'}',
|
||||
peerAvatarFileId: '${r['avatarFileId'] ?? ''}',
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final children = _children;
|
||||
final people = _people;
|
||||
final fallbackDepts = <String>{};
|
||||
if (_depts.isEmpty && (widget.parentId == null || widget.parentId!.isEmpty)) {
|
||||
for (final r in widget.staff) {
|
||||
final d = '${r['department'] ?? ''}';
|
||||
if (d.isNotEmpty) fallbackDepts.add(d);
|
||||
}
|
||||
}
|
||||
return Scaffold(
|
||||
backgroundColor: kPaper,
|
||||
appBar: AppBar(title: Text(widget.title), leading: const BackButton()),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ListView(
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
if (children.isNotEmpty)
|
||||
CellGroup(
|
||||
children: [
|
||||
for (var i = 0; i < children.length; i++)
|
||||
Cell(
|
||||
title: '${children[i]['name'] ?? ''}',
|
||||
subtitle: '${_countOf(children[i])} 人',
|
||||
leading: const SquareAvatar(label: '部', color: kBind, icon: Icons.apartment, size: 36),
|
||||
showLine: i != children.length - 1,
|
||||
onTap: () => Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => OrgBrowsePage(
|
||||
session: widget.session,
|
||||
api: widget.api,
|
||||
staff: widget.staff,
|
||||
parentId: '${children[i]['id']}',
|
||||
title: '${children[i]['name'] ?? '部门'}',
|
||||
deptName: '${children[i]['name'] ?? ''}',
|
||||
),
|
||||
)),
|
||||
),
|
||||
],
|
||||
)
|
||||
else if (fallbackDepts.isNotEmpty)
|
||||
CellGroup(
|
||||
children: [
|
||||
for (final d in (fallbackDepts.toList()..sort()))
|
||||
Cell(
|
||||
title: d,
|
||||
subtitle: '${widget.staff.where((e) => '${e['department']}' == d).length} 人',
|
||||
leading: const SquareAvatar(label: '部', color: kBind, icon: Icons.apartment, size: 36),
|
||||
onTap: () => Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => OrgBrowsePage(
|
||||
session: widget.session,
|
||||
api: widget.api,
|
||||
staff: widget.staff,
|
||||
parentId: '__leaf__',
|
||||
title: d,
|
||||
deptName: d,
|
||||
),
|
||||
)),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (people.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text('${widget.deptName} · ${people.length} 人', style: const TextStyle(fontSize: 13, color: kMute)),
|
||||
),
|
||||
CellGroup(
|
||||
children: [
|
||||
for (var i = 0; i < people.length; i++)
|
||||
Cell(
|
||||
title: '${people[i]['name'] ?? ''}',
|
||||
subtitle: '${people[i]['title'] ?? ''}',
|
||||
leading: SquareAvatar(
|
||||
label: '${people[i]['name'] ?? ''}',
|
||||
fileId: '${people[i]['avatarFileId'] ?? ''}',
|
||||
api: widget.api,
|
||||
size: 36,
|
||||
),
|
||||
showLine: i != people.length - 1,
|
||||
onTap: () => _openPerson(people[i]),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (children.isEmpty && fallbackDepts.isEmpty && people.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 48),
|
||||
child: Center(child: Text('这个部门还没有人', style: TextStyle(color: kMute))),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/wecom.dart';
|
||||
|
||||
class PickPeoplePage extends StatefulWidget {
|
||||
const PickPeoplePage({
|
||||
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<PickPeoplePage> createState() => _PickPeoplePageState();
|
||||
}
|
||||
|
||||
class _PickPeoplePageState extends State<PickPeoplePage> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@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: Colors.white,
|
||||
appBar: AppBar(
|
||||
title: Text(widget.title),
|
||||
leading: IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)),
|
||||
actions: [
|
||||
if (widget.multiple)
|
||||
TextButton(
|
||||
onPressed: _selected.isEmpty
|
||||
? null
|
||||
: () => Navigator.pop(context, _selected.entries.map((e) => {'id': e.key, 'name': e.value}).toList()),
|
||||
child: Text('确定(${_selected.length})'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
WxSearchBar(hint: '搜索同事', onChanged: (v) => setState(() => _q = v.trim())),
|
||||
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kBind),
|
||||
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 InkWell(
|
||||
onTap: () {
|
||||
if (!widget.multiple) {
|
||||
Navigator.pop(context, [
|
||||
{'id': id, 'name': name},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
if (on) {
|
||||
_selected.remove(id);
|
||||
} else {
|
||||
_selected[id] = name;
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
if (widget.multiple)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 10),
|
||||
child: Icon(on ? Icons.check_circle : Icons.circle_outlined, color: on ? kBind : kMute, size: 22),
|
||||
),
|
||||
SquareAvatar(
|
||||
label: name,
|
||||
fileId: '${r['avatarFileId'] ?? ''}',
|
||||
api: widget.api,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: kLine, width: 0.5))),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(name, style: const TextStyle(fontSize: 16)),
|
||||
Text(
|
||||
[r['department'], r['title']].where((e) => e != null && '$e'.isNotEmpty).join(' · '),
|
||||
style: const TextStyle(fontSize: 12, color: kMute),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../labels.dart';
|
||||
import '../session/session.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/wecom.dart';
|
||||
|
||||
class ProfilePage extends StatefulWidget {
|
||||
const ProfilePage({super.key, required this.session, required this.api});
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
|
||||
@override
|
||||
State<ProfilePage> createState() => _ProfilePageState();
|
||||
}
|
||||
|
||||
class _ProfilePageState extends State<ProfilePage> {
|
||||
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));
|
||||
}
|
||||
|
||||
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) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('头像已更新')));
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e')));
|
||||
} 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(hintText: '显示名')),
|
||||
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) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已保存')));
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e')));
|
||||
}
|
||||
}
|
||||
|
||||
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(hintText: '当前密码')),
|
||||
const SizedBox(height: 8),
|
||||
TextField(controller: newC, obscureText: true, decoration: const InputDecoration(hintText: '新密码(至少 6 位)')),
|
||||
],
|
||||
),
|
||||
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.patch('/auth/password', {'oldPassword': oldC.text, 'newPassword': newC.text});
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('密码已更新')));
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e')));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = widget.session;
|
||||
final avatarId = '${s.user['avatarFileId'] ?? ''}';
|
||||
return Scaffold(
|
||||
backgroundColor: kPaper,
|
||||
appBar: AppBar(title: const Text('个人资料')),
|
||||
body: ListView(
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
CellGroup(
|
||||
children: [
|
||||
Cell(
|
||||
title: '头像',
|
||||
trailing: SquareAvatar(
|
||||
label: s.displayName,
|
||||
size: 48,
|
||||
fileId: avatarId.isEmpty ? null : avatarId,
|
||||
api: widget.api,
|
||||
),
|
||||
onTap: _busy ? null : _pickAvatar,
|
||||
),
|
||||
Cell(title: '姓名', subtitle: s.displayName, onTap: _editName),
|
||||
Cell(title: '账号', subtitle: '${s.user['username'] ?? ''}'),
|
||||
Cell(
|
||||
title: '角色',
|
||||
subtitle: s.roles.map(zh).join(' · '),
|
||||
showLine: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
CellGroup(
|
||||
children: [
|
||||
Cell(title: '修改密码', onTap: _editPassword, showLine: false),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../labels.dart';
|
||||
import '../nav/biz_route.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/wecom.dart';
|
||||
import 'pick_people_page.dart';
|
||||
|
||||
void openRecord(BuildContext context, OaClient api, Map<String, dynamic> row,
|
||||
{String? title}) {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => RecordDetailPage(
|
||||
api: api, seed: row, title: title ?? detailTitleOf(row)),
|
||||
));
|
||||
}
|
||||
|
||||
class RecordDetailPage extends StatefulWidget {
|
||||
const RecordDetailPage(
|
||||
{super.key, required this.api, required this.seed, required this.title});
|
||||
final OaClient api;
|
||||
final Map<String, dynamic> seed;
|
||||
final String title;
|
||||
|
||||
@override
|
||||
State<RecordDetailPage> createState() => _RecordDetailPageState();
|
||||
}
|
||||
|
||||
class _RecordDetailPageState extends State<RecordDetailPage> {
|
||||
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('/')) {
|
||||
showWxToast(context, '当前信息暂不支持转发', error: true);
|
||||
return;
|
||||
}
|
||||
final picked = await Navigator.of(context)
|
||||
.push<List<Map<String, dynamic>>>(MaterialPageRoute(
|
||||
builder: (_) => PickPeoplePage(
|
||||
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) showWxToast(context, '已转发给 ${picked.length} 人');
|
||||
} catch (e) {
|
||||
if (mounted) showWxToast(context, '$e', error: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _act(String label, Future<void> Function() run) async {
|
||||
try {
|
||||
await run();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(label)));
|
||||
await _load();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted)
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('$e')));
|
||||
}
|
||||
}
|
||||
|
||||
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 (actions['canTerminate'] == true && id.isNotEmpty) {
|
||||
out.add(OutlinedButton(
|
||||
onPressed: () async {
|
||||
final c = TextEditingController();
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('终止投标'),
|
||||
content: TextField(
|
||||
controller: c,
|
||||
maxLines: 3,
|
||||
decoration:
|
||||
const InputDecoration(hintText: '请填写终止原因')),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('取消')),
|
||||
FilledButton(
|
||||
onPressed: () =>
|
||||
Navigator.pop(ctx, c.text.trim().isNotEmpty),
|
||||
child: const Text('确认'))
|
||||
]));
|
||||
if (ok == true)
|
||||
await _act(
|
||||
'已终止',
|
||||
() => widget.api.post(
|
||||
'/bid-cases/$id/terminate', {'comment': c.text.trim()}));
|
||||
},
|
||||
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 Scaffold(
|
||||
backgroundColor: kPaper,
|
||||
appBar: AppBar(
|
||||
title: Text(widget.title, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: '转发给同事',
|
||||
onPressed: _loading ? null : _forward,
|
||||
icon: const Icon(Icons.forward_to_inbox_outlined),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(0, 12, 0, 32),
|
||||
children: [
|
||||
if (_err.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: Text(_err,
|
||||
style: const TextStyle(color: kMute, fontSize: 12)),
|
||||
),
|
||||
CellGroup(
|
||||
children: [
|
||||
for (var i = 0; i < pairs.length; i++)
|
||||
_kv(pairs[i].$1, pairs[i].$2, i == pairs.length - 1),
|
||||
],
|
||||
),
|
||||
if (_actions.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Wrap(spacing: 8, runSpacing: 8, children: _actions),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _kv(String k, String v, bool last) {
|
||||
final isStatus = k == '状态';
|
||||
return Cell(
|
||||
title: k,
|
||||
subtitle: isStatus ? null : v,
|
||||
trailing:
|
||||
isStatus ? StatusDot(widget.seed['status'] ?? _row['status']) : null,
|
||||
showLine: !last,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final _uuid = RegExp(
|
||||
r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$');
|
||||
|
||||
const _skip = {
|
||||
'id',
|
||||
'tenantId',
|
||||
'applicantId',
|
||||
'assigneeId',
|
||||
'createdById',
|
||||
'decidedById',
|
||||
'managerId',
|
||||
'employeeId',
|
||||
'bizId',
|
||||
'path',
|
||||
'bucket',
|
||||
'_fetch',
|
||||
'parentId',
|
||||
'departmentId',
|
||||
'userId',
|
||||
'roleIds',
|
||||
'managerIds',
|
||||
'accounts',
|
||||
'users',
|
||||
'excerpt',
|
||||
'legalEntityId',
|
||||
'bidCaseId',
|
||||
'partyId',
|
||||
'fingerprint',
|
||||
'meta',
|
||||
'actionLogs',
|
||||
'projects',
|
||||
'reviewers',
|
||||
'assignees',
|
||||
'versions',
|
||||
'files',
|
||||
'logs',
|
||||
};
|
||||
|
||||
bool _skipKey(String k, String key, dynamic v) {
|
||||
if (_skip.contains(k) || _skip.contains(key)) return true;
|
||||
if (key.startsWith('can') && (v is bool || v == 'true' || v == 'false'))
|
||||
return true;
|
||||
if (key.endsWith('Id') || key.endsWith('Ids')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
List<(String, String)> flattenRecord(Map<String, dynamic> row) {
|
||||
final out = <(String, String)>[];
|
||||
void add(String k, dynamic v, [int depth = 0]) {
|
||||
if (v == null) return;
|
||||
final key = k.contains('.') ? k.split('.').last : k;
|
||||
if (_skipKey(k, key, v)) return;
|
||||
if (k == 'content' && '$v'.contains('<')) return;
|
||||
final label = fieldLabel(k);
|
||||
if (label.isEmpty) return;
|
||||
if (v is Map) {
|
||||
final name =
|
||||
v['displayName'] ?? v['name'] ?? v['title'] ?? v['departmentName'];
|
||||
if (name != null &&
|
||||
'$name'.trim().isNotEmpty &&
|
||||
!'$name'.startsWith('{')) {
|
||||
out.add((label, zh(name)));
|
||||
return;
|
||||
}
|
||||
if (depth > 0) return;
|
||||
v.forEach((ck, cv) => add('$k.$ck', cv, depth + 1));
|
||||
return;
|
||||
}
|
||||
if (v is List) {
|
||||
if (v.isEmpty) return;
|
||||
out.add((label, '${v.length} 项'));
|
||||
return;
|
||||
}
|
||||
final raw = '$v'.trim();
|
||||
if (raw.isEmpty || raw == 'null') return;
|
||||
if (raw.startsWith('{') && raw.contains('id:')) return;
|
||||
if (_uuid.hasMatch(raw)) return;
|
||||
final s = v is DateTime
|
||||
? fmtTime(v.toIso8601String())
|
||||
: (k.endsWith('At') || k.contains('Time') ? fmtTime(v) : zh(v));
|
||||
if (s == '—' || s.isEmpty) return;
|
||||
if (RegExp(r'^[A-Z][A-Z0-9_]+$').hasMatch(s) && zh(s) == s) return;
|
||||
out.add((label, s));
|
||||
}
|
||||
|
||||
row.forEach(add);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../theme.dart';
|
||||
|
||||
class ScanLoginPage extends StatefulWidget {
|
||||
const ScanLoginPage({super.key, required this.api, this.ticket = ''});
|
||||
final OaClient api;
|
||||
final String ticket;
|
||||
|
||||
@override
|
||||
State<ScanLoginPage> createState() => _ScanLoginPageState();
|
||||
}
|
||||
|
||||
class _ScanLoginPageState extends State<ScanLoginPage> {
|
||||
late final TextEditingController _ticket;
|
||||
final MobileScannerController _scanner = MobileScannerController();
|
||||
bool _loading = false;
|
||||
bool _scanned = false;
|
||||
String _err = '';
|
||||
String _code = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ticket = TextEditingController(text: widget.ticket);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ticket.dispose();
|
||||
_scanner.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _ticketFrom(String raw) {
|
||||
final value = raw.trim();
|
||||
final uri = Uri.tryParse(value);
|
||||
if (uri?.scheme == 'fengyingoa' && uri?.host == 'qr') {
|
||||
return uri?.queryParameters['ticket']?.trim() ?? '';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
Future<void> _ok([String? scanned]) async {
|
||||
final t = _ticketFrom(scanned ?? _ticket.text);
|
||||
if (t.isEmpty) {
|
||||
setState(() => _err = '请扫描电脑端二维码');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_err = '';
|
||||
});
|
||||
try {
|
||||
final raw = await widget.api.post('/auth/qr/confirm', {'ticket': t});
|
||||
final map =
|
||||
raw is Map ? Map<String, dynamic>.from(raw) : <String, dynamic>{};
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_ticket.text = t;
|
||||
_code = '${map['code'] ?? ''}';
|
||||
_scanned = true;
|
||||
});
|
||||
await _scanner.stop();
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _err = '$e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onDetect(BarcodeCapture capture) async {
|
||||
if (_loading || _scanned) return;
|
||||
final raw = capture.barcodes
|
||||
.map((b) => b.rawValue?.trim() ?? '')
|
||||
.firstWhere((v) => v.isNotEmpty, orElse: () => '');
|
||||
if (raw.isEmpty) return;
|
||||
await _ok(raw);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(title: const Text('扫一扫登录电脑')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (_code.isEmpty) ...[
|
||||
const Text('扫描电脑端二维码后,手机会生成一次性六位登录码。请把该六码填写到电脑端,电脑才会完成登录。',
|
||||
style: TextStyle(color: kMute, height: 1.5)),
|
||||
const SizedBox(height: 16),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child:
|
||||
MobileScanner(controller: _scanner, onDetect: _onDetect),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
const Text('请在电脑端输入以下一次性六位登录码:',
|
||||
style: TextStyle(color: kMute, height: 1.5)),
|
||||
const SizedBox(height: 24),
|
||||
Center(
|
||||
child: SelectableText(
|
||||
_code,
|
||||
style: const TextStyle(
|
||||
fontSize: 42,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 12,
|
||||
color: kInk),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Center(
|
||||
child: Text('两分钟内有效;不要把此码发给他人。',
|
||||
style: TextStyle(color: kMute))),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('我已在电脑端输入'),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
if (_code.isEmpty)
|
||||
TextField(
|
||||
controller: _ticket,
|
||||
decoration: const InputDecoration(hintText: '扫码异常时,可粘贴二维码内容'),
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
),
|
||||
if (_err.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(_err, style: const TextStyle(color: kDanger)),
|
||||
],
|
||||
if (_code.isEmpty) ...[
|
||||
const SizedBox(height: 20),
|
||||
FilledButton(
|
||||
onPressed: _loading ? null : _ok,
|
||||
child: Text(_loading ? '确认中…' : '确认扫码'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../im/chat_local_store.dart';
|
||||
import '../nav/notice_route.dart';
|
||||
import '../session/session.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/wecom.dart';
|
||||
|
||||
class SystemNoticePage extends StatefulWidget {
|
||||
const SystemNoticePage(
|
||||
{super.key,
|
||||
required this.session,
|
||||
required this.api,
|
||||
this.conversationId});
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
final String? conversationId;
|
||||
@override
|
||||
State<SystemNoticePage> createState() => _SystemNoticePageState();
|
||||
}
|
||||
|
||||
class _SystemNoticePageState extends State<SystemNoticePage> {
|
||||
List<Map<String, dynamic>> _items = [];
|
||||
bool _loading = true;
|
||||
|
||||
Map<String, dynamic> _metaOf(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 decoded = jsonDecode(raw);
|
||||
if (decoded is Map) return Map<String, dynamic>.from(decoded);
|
||||
} catch (_) {}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final raw = await widget.api.get('/im/messages', query: {
|
||||
'peerId': '0',
|
||||
if (widget.conversationId != null)
|
||||
'conversationId': widget.conversationId!,
|
||||
});
|
||||
final remote = asMaps(raw);
|
||||
final cid = widget.conversationId ??
|
||||
(raw is Map ? '${raw['conversationId'] ?? ''}' : '');
|
||||
final local = await ChatLocalStore.load(cid);
|
||||
final byKey = <String, Map<String, dynamic>>{};
|
||||
for (final e in [...local, ...remote]) {
|
||||
final fp = '${e['fingerprint'] ?? ''}';
|
||||
byKey[fp.isNotEmpty ? 'fp:$fp' : 'id:${e['id'] ?? ''}'] = e;
|
||||
}
|
||||
final merged = byKey.values.toList()
|
||||
..sort((a, b) =>
|
||||
'${a['createdAt'] ?? ''}'.compareTo('${b['createdAt'] ?? ''}'));
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = merged;
|
||||
_loading = false;
|
||||
});
|
||||
await ChatLocalStore.save(cid, merged);
|
||||
if (widget.conversationId != null) {
|
||||
await widget.api
|
||||
.post('/im/read', {'conversationId': widget.conversationId});
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F6F8),
|
||||
appBar: AppBar(title: const Text('系统通知'), backgroundColor: Colors.white),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(14, 14, 14, 28),
|
||||
itemCount: _items.isEmpty ? 1 : _items.length,
|
||||
itemBuilder: (context, index) {
|
||||
if (_items.isEmpty)
|
||||
return const Padding(
|
||||
padding: EdgeInsets.only(top: 100),
|
||||
child: Center(
|
||||
child: Text('暂无系统通知',
|
||||
style: TextStyle(color: kMute))));
|
||||
final r = _items[index];
|
||||
final title = '${r['title'] ?? r['body'] ?? '系统通知'}';
|
||||
final body = '${r['body'] ?? ''}';
|
||||
final time = '${r['createdAt'] ?? ''}'
|
||||
.replaceFirst('T', ' ')
|
||||
.replaceFirst(RegExp(r'\.\d+Z$'), '');
|
||||
return Card(
|
||||
elevation: 0,
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
final meta = _metaOf(r);
|
||||
openNoticeTarget(
|
||||
context,
|
||||
widget.api,
|
||||
session: widget.session,
|
||||
kind: '${meta['kind'] ?? r['kind'] ?? 'todo'}',
|
||||
bizType: '${meta['bizType'] ?? ''}',
|
||||
bizId: '${meta['bizId'] ?? ''}',
|
||||
title:
|
||||
'${meta['title'] ?? r['title'] ?? r['body'] ?? '系统通知'}',
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SquareAvatar(
|
||||
label: '系',
|
||||
color: Color(0xFF07C160),
|
||||
icon: Icons.notifications,
|
||||
size: 40),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600)),
|
||||
if (body.isNotEmpty && body != title) ...[
|
||||
const SizedBox(height: 5),
|
||||
Text(body,
|
||||
style: const TextStyle(color: kMute))
|
||||
],
|
||||
const SizedBox(height: 7),
|
||||
Text(time,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: kMute)),
|
||||
])),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../labels.dart';
|
||||
import '../pages/record_detail_page.dart';
|
||||
import '../widgets/common.dart';
|
||||
|
||||
class TodosPage extends StatefulWidget {
|
||||
const TodosPage({super.key, required this.api, this.embedded = false});
|
||||
final OaClient api;
|
||||
final bool embedded;
|
||||
|
||||
@override
|
||||
State<TodosPage> createState() => _TodosPageState();
|
||||
}
|
||||
|
||||
class _TodosPageState extends State<TodosPage> {
|
||||
List<Map<String, dynamic>> _items = [];
|
||||
String _bucket = 'OPEN';
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final data = await widget.api.get('/office/todos');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = asMaps(data);
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final filtered = _bucket.isEmpty ? _items : _items.where((e) => '${e['status']}' == _bucket).toList();
|
||||
final body = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final it in [('OPEN', '待办'), ('DONE', '已办'), ('', '全部')])
|
||||
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: filtered.isEmpty
|
||||
? const EmptyHint('没有待办')
|
||||
: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView.builder(
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (_, i) {
|
||||
final r = filtered[i];
|
||||
return KvTile(
|
||||
title: '${r['title'] ?? ''}',
|
||||
subtitle: '${zh(r['bizType'])} ${fmtTime(r['dueAt'] ?? r['createdAt'])}',
|
||||
status: r['status'],
|
||||
onTap: () => openRecord(context, widget.api, r),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (widget.embedded) return body;
|
||||
return Scaffold(appBar: AppBar(title: const Text('待办事项')), body: body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../session/session.dart';
|
||||
import '../widgets/common.dart';
|
||||
import 'record_detail_page.dart';
|
||||
|
||||
class WorkAssignPage extends StatefulWidget {
|
||||
const WorkAssignPage({super.key, required this.session, required this.api});
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
|
||||
@override
|
||||
State<WorkAssignPage> createState() => _WorkAssignPageState();
|
||||
}
|
||||
|
||||
class _WorkAssignPageState extends State<WorkAssignPage> {
|
||||
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 {
|
||||
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: 420,
|
||||
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,
|
||||
});
|
||||
await _load();
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e')));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('工作安排'),
|
||||
actions: [
|
||||
if (_canAssign) IconButton(onPressed: _create, icon: const Icon(Icons.add)),
|
||||
],
|
||||
),
|
||||
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'] ?? ''}',
|
||||
subtitle: '${r['assignee'] is Map ? r['assignee']['displayName'] : ''} ${fmtTime(r['dueAt'] ?? r['createdAt'])}',
|
||||
status: r['status'],
|
||||
onTap: () => openRecord(context, widget.api, r),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../nav/open_module.dart';
|
||||
import '../ota/updater.dart';
|
||||
import '../pages/approvals_page.dart';
|
||||
import '../pages/attendance_page.dart';
|
||||
import '../pages/calendar_page.dart';
|
||||
import '../pages/flow_page.dart';
|
||||
import '../pages/hr_apply_page.dart';
|
||||
import '../pages/todos_page.dart';
|
||||
import '../pages/work_assign_page.dart';
|
||||
import '../session/session.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/wecom.dart';
|
||||
|
||||
class WorkbenchPage extends StatefulWidget {
|
||||
const WorkbenchPage({super.key, required this.session, required this.api, this.embedded = true});
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
final bool embedded;
|
||||
|
||||
@override
|
||||
State<WorkbenchPage> createState() => _WorkbenchPageState();
|
||||
}
|
||||
|
||||
class _WorkbenchPageState extends State<WorkbenchPage> {
|
||||
Map<String, dynamic> _ov = {};
|
||||
String _greet = '';
|
||||
AppRelease? _rel;
|
||||
bool _loading = true;
|
||||
String _q = '';
|
||||
|
||||
@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 (_) {}
|
||||
AppRelease? rel;
|
||||
try {
|
||||
rel = await OtaUpdater(widget.api).fetch();
|
||||
} catch (_) {}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_ov = Map<String, dynamic>.from(ov as Map? ?? {});
|
||||
_greet = greet;
|
||||
_rel = rel;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
bool _match(String name) => _q.isEmpty || name.contains(_q);
|
||||
|
||||
bool _skipGroup(MenuNode m) {
|
||||
if (m.name == '工作台' || m.name == '个人办公' || m.code == 'office:overview') return true;
|
||||
if (m.name == '系统设置' || m.code.startsWith('system')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _skipChild(MenuNode n) {
|
||||
const names = {
|
||||
'待办',
|
||||
'待审批',
|
||||
'我的申请',
|
||||
'日程',
|
||||
'公告',
|
||||
'公司公告',
|
||||
'工作安排',
|
||||
'人事申请',
|
||||
'工作台',
|
||||
'个人办公',
|
||||
'消息',
|
||||
'通讯录',
|
||||
'员工通讯',
|
||||
'发送短信',
|
||||
'人事设置',
|
||||
'数据字典',
|
||||
'权限设置',
|
||||
'编号规则',
|
||||
'短信服务',
|
||||
'消息通道',
|
||||
'手机端升级',
|
||||
'操作日志',
|
||||
'系统设置',
|
||||
};
|
||||
if (n.code.startsWith('office:')) return true;
|
||||
if (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);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final body = RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
children: [
|
||||
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kBind),
|
||||
if (_rel != null && _rel!.newer)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
|
||||
child: Material(
|
||||
color: const Color(0xFFE8F3FF),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
onTap: () => OtaUpdater(widget.api).prompt(context, _rel!, manual: true),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.system_update_alt, color: kBind),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('发现新版本 ${_rel!.version}', style: const TextStyle(fontWeight: FontWeight.w600, color: kInk)),
|
||||
Text(_rel!.changelog.isEmpty ? '点击升级到最新手机版' : _rel!.changelog, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: kMute)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Text('升级', style: TextStyle(color: kBind, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(_greet.isEmpty ? '你好,${widget.session.displayName}' : _greet, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: kInk)),
|
||||
const SizedBox(height: 4),
|
||||
const Text('工作台', style: TextStyle(color: kMute, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
),
|
||||
GroupCard(
|
||||
title: '待处理',
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 14),
|
||||
child: Row(
|
||||
children: [
|
||||
_stat('待办', '${_ov['pendingTodos'] ?? 0}', () => pushPage(context, TodosPage(api: widget.api))),
|
||||
_stat('待审批', '${_ov['pendingApprovals'] ?? 0}', () => pushPage(context, ApprovalsPage(api: widget.api))),
|
||||
_stat('我的申请', '${_ov['myPending'] ?? 0}', () => pushPage(context, FlowPage(api: widget.api))),
|
||||
_stat('逾期', '${_ov['overdueTodos'] ?? 0}', () => pushPage(context, TodosPage(api: widget.api))),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_match('待办') || _match('审批') || _match('申请') || _match('日程') || _match('人事'))
|
||||
GroupCard(
|
||||
title: '个人办公',
|
||||
child: AppGrid(
|
||||
items: [
|
||||
if (_match('待办'))
|
||||
AppGridItem(label: '待办', icon: Icons.task_alt, color: const Color(0xFFFA9D3B), onTap: () => pushPage(context, TodosPage(api: widget.api))),
|
||||
if (_match('审批'))
|
||||
AppGridItem(label: '待审批', icon: Icons.fact_check, color: kBind, onTap: () => pushPage(context, ApprovalsPage(api: widget.api))),
|
||||
if (_match('申请'))
|
||||
AppGridItem(label: '我的申请', icon: Icons.assignment_outlined, color: const Color(0xFF6267F2), onTap: () => pushPage(context, FlowPage(api: widget.api))),
|
||||
if (_match('日程'))
|
||||
AppGridItem(label: '日程', icon: Icons.calendar_month, color: const Color(0xFF10AEFF), onTap: () => pushPage(context, CalendarPage(api: widget.api))),
|
||||
if (_match('安排'))
|
||||
AppGridItem(label: '工作安排', icon: Icons.event_note, color: const Color(0xFF00B578), onTap: () => pushPage(context, WorkAssignPage(session: widget.session, api: widget.api))),
|
||||
if (_match('打卡') || _match('考勤'))
|
||||
AppGridItem(label: '考勤打卡', icon: Icons.access_time_filled, color: const Color(0xFF267EF0), onTap: () => pushPage(context, AttendancePage(api: widget.api))),
|
||||
if (_match('人事') || _match('请假') || _match('加班'))
|
||||
AppGridItem(label: '人事申请', icon: Icons.beach_access, color: const Color(0xFF8B5CF6), onTap: () => pushPage(context, HrApplyPage(api: widget.api))),
|
||||
],
|
||||
),
|
||||
),
|
||||
..._menuGroups(),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (!widget.embedded) {
|
||||
return Scaffold(appBar: AppBar(title: const Text('工作台')), body: body);
|
||||
}
|
||||
return ColoredBox(
|
||||
color: kPaper,
|
||||
child: Column(
|
||||
children: [
|
||||
WxHeader(
|
||||
title: '工作台',
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
final c = TextEditingController(text: _q);
|
||||
final v = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('搜索应用'),
|
||||
content: TextField(controller: c, autofocus: true, decoration: const InputDecoration(hintText: '待办、投标、报销…')),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, ''), child: const Text('清除')),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, c.text.trim()), child: const Text('搜索')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (v != null) setState(() => _q = v);
|
||||
},
|
||||
icon: const Icon(Icons.search, color: kInk),
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(child: body),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _menuGroups() {
|
||||
final out = <Widget>[];
|
||||
for (final m in widget.session.menus) {
|
||||
if (_skipGroup(m)) continue;
|
||||
if (m.children.isNotEmpty) {
|
||||
final items = <AppGridItem>[];
|
||||
for (var i = 0; i < m.children.length; i++) {
|
||||
final c = m.children[i];
|
||||
if (_skipChild(c)) continue;
|
||||
if (!(_q.isEmpty || c.name.contains(_q) || m.name.contains(_q))) continue;
|
||||
items.add(AppGridItem(
|
||||
label: c.name,
|
||||
icon: iconFor(c.code, c.name),
|
||||
color: colorFor(c.code, c.name, i),
|
||||
onTap: () => openMenuNode(context, widget.session, widget.api, c),
|
||||
));
|
||||
}
|
||||
if (items.isEmpty) continue;
|
||||
out.add(GroupCard(title: m.name, child: AppGrid(items: items)));
|
||||
} else if (!_skipChild(m) && _match(m.name)) {
|
||||
out.add(GroupCard(
|
||||
title: m.name,
|
||||
child: AppGrid(items: [
|
||||
AppGridItem(
|
||||
label: m.name,
|
||||
icon: iconFor(m.code, m.name),
|
||||
color: colorFor(m.code, m.name),
|
||||
onTap: () => openMenuNode(context, widget.session, widget.api, m),
|
||||
),
|
||||
]),
|
||||
));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Widget _stat(String label, String value, VoidCallback onTap) {
|
||||
return Expanded(
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
children: [
|
||||
Text(value, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: kBind)),
|
||||
const SizedBox(height: 2),
|
||||
Text(label, style: const TextStyle(fontSize: 12, color: kMute)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../app_config.dart';
|
||||
import '../im/im_bridge.dart';
|
||||
import '../im/tcp_client.dart';
|
||||
|
||||
class MenuNode {
|
||||
MenuNode({required this.name, required this.code, this.path, this.icon, this.children = const []});
|
||||
final String name;
|
||||
final String code;
|
||||
final String? path;
|
||||
final String? icon;
|
||||
final List<MenuNode> children;
|
||||
|
||||
factory MenuNode.fromJson(Map<String, dynamic> j) {
|
||||
final kids = (j['children'] as List? ?? []).whereType<Map>().map((e) => MenuNode.fromJson(Map<String, dynamic>.from(e))).toList();
|
||||
return MenuNode(
|
||||
name: '${j['name'] ?? ''}',
|
||||
code: '${j['code'] ?? ''}',
|
||||
path: j['path']?.toString(),
|
||||
icon: j['icon']?.toString(),
|
||||
children: kids,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SessionStore extends ChangeNotifier {
|
||||
String apiBase = AppConfig.defaultApiBase;
|
||||
String imHost = AppConfig.defaultImHost;
|
||||
int imPort = AppConfig.defaultImPort;
|
||||
String accessToken = '';
|
||||
String refreshToken = '';
|
||||
Map<String, dynamic> user = {};
|
||||
List<MenuNode> menus = [];
|
||||
bool loaded = false;
|
||||
final ImTcpClient im = ImTcpClient();
|
||||
final ImBridge bridge = ImBridge();
|
||||
|
||||
String get userId => '${user['id'] ?? ''}';
|
||||
String get displayName => '${user['displayName'] ?? user['username'] ?? ''}';
|
||||
List<String> get roles => (user['roles'] as List? ?? []).map((e) => '$e').toList();
|
||||
List<String> get permissions => (user['permissions'] as List? ?? []).map((e) => '$e').toList();
|
||||
bool get signedIn => accessToken.isNotEmpty && userId.isNotEmpty;
|
||||
|
||||
bool canSee(String code) {
|
||||
if (roles.contains('admin') || roles.contains('owner')) return true;
|
||||
return permissions.contains(code) || permissions.any((p) => p.startsWith('$code:'));
|
||||
}
|
||||
|
||||
Future<void> load() async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
apiBase = p.getString('apiBase') ?? AppConfig.defaultApiBase;
|
||||
imHost = p.getString('imHost') ?? AppConfig.defaultImHost;
|
||||
imPort = p.getInt('imPort') ?? AppConfig.defaultImPort;
|
||||
accessToken = p.getString('accessToken') ?? '';
|
||||
refreshToken = p.getString('refreshToken') ?? '';
|
||||
final u = p.getString('user');
|
||||
if (u != null && u.isNotEmpty) {
|
||||
try {
|
||||
user = Map<String, dynamic>.from(jsonDecode(u) as Map);
|
||||
} catch (_) {
|
||||
user = {};
|
||||
}
|
||||
}
|
||||
loaded = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> saveEndpoints({required String api, required String host, required int port}) async {
|
||||
apiBase = api.trim().isEmpty ? AppConfig.defaultApiBase : api.trim();
|
||||
imHost = host.trim().isEmpty ? AppConfig.defaultImHost : host.trim();
|
||||
imPort = port <= 0 ? AppConfig.defaultImPort : port;
|
||||
final p = await SharedPreferences.getInstance();
|
||||
await p.setString('apiBase', apiBase);
|
||||
await p.setString('imHost', imHost);
|
||||
await p.setInt('imPort', imPort);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> applyLogin(Map<String, dynamic> data) async {
|
||||
accessToken = '${data['accessToken'] ?? ''}';
|
||||
refreshToken = '${data['refreshToken'] ?? ''}';
|
||||
user = Map<String, dynamic>.from(data['user'] as Map? ?? {});
|
||||
final p = await SharedPreferences.getInstance();
|
||||
await p.setString('accessToken', accessToken);
|
||||
await p.setString('refreshToken', refreshToken);
|
||||
await p.setString('user', jsonEncode(user));
|
||||
notifyListeners();
|
||||
await connectIm();
|
||||
}
|
||||
|
||||
Future<void> setTokens(String access, String refresh) async {
|
||||
final changed = access != accessToken;
|
||||
accessToken = access;
|
||||
refreshToken = refresh;
|
||||
final p = await SharedPreferences.getInstance();
|
||||
await p.setString('accessToken', access);
|
||||
await p.setString('refreshToken', refresh);
|
||||
notifyListeners();
|
||||
if (changed && signedIn) await connectIm(force: true);
|
||||
}
|
||||
|
||||
Future<void> patchUser(Map<String, dynamic> next) async {
|
||||
user = next;
|
||||
final p = await SharedPreferences.getInstance();
|
||||
await p.setString('user', jsonEncode(user));
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setMenus(List<MenuNode> items) async {
|
||||
menus = items;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> connectIm({bool force = false}) async {
|
||||
if (!signedIn) return;
|
||||
if (force) im.disconnect();
|
||||
await bridge.init();
|
||||
await im.connect(
|
||||
host: imHost,
|
||||
port: imPort,
|
||||
userId: userId,
|
||||
token: accessToken,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
im.disconnect();
|
||||
await bridge.release();
|
||||
accessToken = '';
|
||||
refreshToken = '';
|
||||
user = {};
|
||||
menus = [];
|
||||
final p = await SharedPreferences.getInstance();
|
||||
await p.remove('accessToken');
|
||||
await p.remove('refreshToken');
|
||||
await p.remove('user');
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
im.disconnect();
|
||||
im.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export '../desktop/desk_shell.dart';
|
||||
@@ -0,0 +1,405 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../device/push_bridge.dart';
|
||||
import '../ota/updater.dart';
|
||||
import '../nav/notice_route.dart';
|
||||
import '../pages/call_page.dart';
|
||||
import '../pages/attendance_page.dart';
|
||||
import '../pages/chat_page.dart';
|
||||
import '../pages/directory_page.dart';
|
||||
import '../pages/messages_page.dart';
|
||||
import '../pages/mine_page.dart';
|
||||
import '../pages/scan_login_page.dart';
|
||||
import '../pages/system_notice_page.dart';
|
||||
import '../pages/workbench_page.dart';
|
||||
import '../session/session.dart';
|
||||
import '../im/im_lifecycle.dart';
|
||||
import '../desktop/desk_platform.dart';
|
||||
import '../desktop/desk_shell.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/wecom.dart';
|
||||
|
||||
class HomeShell extends StatefulWidget {
|
||||
const HomeShell({super.key, required this.session, required this.api});
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
|
||||
@override
|
||||
State<HomeShell> createState() => _HomeShellState();
|
||||
}
|
||||
|
||||
class _HomeShellState extends State<HomeShell> {
|
||||
int _tab = 0;
|
||||
int _msgBadge = 0;
|
||||
bool _otaOnce = false;
|
||||
Timer? _badgeDebounce;
|
||||
Timer? _callPoll;
|
||||
String _ringingId = '';
|
||||
bool _checkingCall = false;
|
||||
final _declinedCalls = <String>{};
|
||||
late List<Widget> _pages;
|
||||
ImLifecycle? _imLife;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_imLife = ImLifecycle(widget.session)..start();
|
||||
widget.session.im.onLoggedIn = () {
|
||||
_refreshBadge();
|
||||
};
|
||||
_pages = [
|
||||
MessagesPage(session: widget.session, api: widget.api),
|
||||
WorkbenchPage(session: widget.session, api: widget.api),
|
||||
AttendancePage(api: widget.api),
|
||||
DirectoryPage(session: widget.session, api: widget.api, embedded: true),
|
||||
MinePage(session: widget.session, api: widget.api),
|
||||
];
|
||||
widget.session.im.addListener(_onIm);
|
||||
_refreshBadge();
|
||||
PushBridge.listen(_openFromPush);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_checkOta();
|
||||
_bootPush();
|
||||
_checkIncoming();
|
||||
});
|
||||
_callPoll =
|
||||
Timer.periodic(const Duration(seconds: 8), (_) => _checkIncoming());
|
||||
}
|
||||
|
||||
@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) {
|
||||
final last = inbox.first;
|
||||
if (last.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> _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 = '';
|
||||
}
|
||||
|
||||
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> _bootPush() async {
|
||||
if (Platform.isWindows) return;
|
||||
await PushBridge.requestFirstLoginPermissions();
|
||||
final launch = await PushBridge.consumeLaunch();
|
||||
if (launch != null && mounted) _openFromPush(launch);
|
||||
// 通知点击先进入来电页,厂商 token 注册放到后台,不能阻塞接听。
|
||||
unawaited(PushBridge.register(widget.api));
|
||||
await _promptOem();
|
||||
}
|
||||
|
||||
Future<void> _promptOem() async {
|
||||
final st = await PushBridge.oemStatus();
|
||||
if (st == null || !mounted) return;
|
||||
if (st['prompted'] == true) return;
|
||||
await PushBridge.requestBattery();
|
||||
final title = '${st['title'] ?? '后台运行与通知'}';
|
||||
final hint = '${st['hint'] ?? '请允许通知和后台运行,以便及时收到消息。'}';
|
||||
final go = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(hint),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('稍后')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('去设置')),
|
||||
],
|
||||
),
|
||||
);
|
||||
await PushBridge.markOemPrompted();
|
||||
if (go == true) {
|
||||
await PushBridge.requestBattery();
|
||||
await PushBridge.openOemKeepAlive();
|
||||
}
|
||||
}
|
||||
|
||||
String _openedPush = '';
|
||||
|
||||
void _openFromPush(Map<String, String> extras) {
|
||||
if (extras['kind'] == 'qr-login') {
|
||||
final ticket = extras['ticket'] ?? '';
|
||||
if (ticket.isEmpty) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ScanLoginPage(api: widget.api, ticket: ticket),
|
||||
),
|
||||
);
|
||||
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 == 'call') {
|
||||
unawaited(_openIncomingCallFromPush(extras));
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
if (mounted) setState(() => _tab = 0);
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => extras['peerId'] == '0'
|
||||
? SystemNoticePage(
|
||||
session: widget.session, api: widget.api, conversationId: cid)
|
||||
: ChatPage(
|
||||
session: widget.session,
|
||||
api: widget.api,
|
||||
conversationId: cid,
|
||||
peerName: extras['fromName'] ?? extras['title'] ?? '聊天'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
final rows = asMaps(await widget.api.get('/im/calls/incoming'));
|
||||
for (final row in rows) {
|
||||
if ('${row['status'] ?? ''}' == 'ringing') {
|
||||
call = row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (call != null && mounted) {
|
||||
call['fromName'] ??= extras['fromName'];
|
||||
await _showIncoming(call, fallbackName: extras['fromName'] ?? '同事');
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
// 通话已被接听、拒绝或超时,点击历史通知只回到对应聊天,不再显示错误的接听页。
|
||||
if (mounted && cidOrEmpty(extras).isNotEmpty) {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => ChatPage(
|
||||
session: widget.session,
|
||||
api: widget.api,
|
||||
conversationId: cidOrEmpty(extras),
|
||||
peerName: extras['fromName'] ?? extras['title'] ?? '聊天',
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
String cidOrEmpty(Map<String, String> extras) =>
|
||||
extras['conversationId'] ?? '';
|
||||
|
||||
Future<void> _checkOta() async {
|
||||
if (_otaOnce) return;
|
||||
_otaOnce = true;
|
||||
if (isDesktopPlatform) return;
|
||||
final rel = await OtaUpdater(widget.api).fetch();
|
||||
if (rel != null && rel.newer && mounted) {
|
||||
await OtaUpdater(widget.api).prompt(context, rel);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isDesktopPlatform) {
|
||||
return DeskShell(session: widget.session, api: widget.api);
|
||||
}
|
||||
final wide = MediaQuery.sizeOf(context).width >= 960;
|
||||
final barItems = <(IconData, IconData, String, int)>[
|
||||
(Icons.chat_bubble_outline, Icons.chat_bubble, '消息', _msgBadge),
|
||||
(Icons.apps_outlined, Icons.apps, '工作台', 0),
|
||||
(Icons.location_on_outlined, Icons.location_on, '打卡', 0),
|
||||
(Icons.account_tree_outlined, Icons.account_tree, '通讯录', 0),
|
||||
(Icons.person_outline, Icons.person, '我', 0),
|
||||
];
|
||||
final body = IndexedStack(index: _tab, children: _pages);
|
||||
if (wide) {
|
||||
return Scaffold(
|
||||
backgroundColor: kPaper,
|
||||
body: Row(
|
||||
children: [
|
||||
ColoredBox(
|
||||
color: const Color(0xFFF7F7F7),
|
||||
child: SafeArea(
|
||||
child: SizedBox(
|
||||
width: 88,
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
SquareAvatar(
|
||||
label: widget.session.displayName,
|
||||
size: 48,
|
||||
fileId: '${widget.session.user['avatarFileId'] ?? ''}',
|
||||
api: widget.api,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
for (var i = 0; i < barItems.length; i++)
|
||||
InkWell(
|
||||
onTap: () => setState(() {
|
||||
_tab = i;
|
||||
if (i == 0) widget.session.im.bump();
|
||||
}),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Column(
|
||||
children: [
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Icon(
|
||||
_tab == i
|
||||
? barItems[i].$2
|
||||
: barItems[i].$1,
|
||||
color: _tab == i
|
||||
? kBind
|
||||
: const Color(0xFF646464)),
|
||||
if (barItems[i].$4 > 0)
|
||||
Positioned(
|
||||
right: -10,
|
||||
top: -4,
|
||||
child: UnreadBadge(barItems[i].$4)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(barItems[i].$3,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: _tab == i
|
||||
? kBind
|
||||
: const Color(0xFF646464))),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const VerticalDivider(width: 1, color: kLine),
|
||||
Expanded(child: body),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return Scaffold(
|
||||
backgroundColor: kPaper,
|
||||
body: body,
|
||||
bottomNavigationBar: WxTabBar(
|
||||
index: _tab,
|
||||
onChanged: (i) {
|
||||
setState(() => _tab = i);
|
||||
if (i == 0) widget.session.im.bump();
|
||||
},
|
||||
items: barItems,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// 企业微信 / 微信手机端配色。
|
||||
const kInk = Color(0xFF191919);
|
||||
const kPaper = Color(0xFFEDEDED);
|
||||
const kBind = Color(0xFF267EF0);
|
||||
const kBind2 = Color(0xFF1A6FD4);
|
||||
const kWeGreen = Color(0xFF07C160);
|
||||
const kMute = Color(0xFF888888);
|
||||
const kLine = Color(0xFFE5E5E5);
|
||||
const kBubbleMine = Color(0xFFC9E7FF);
|
||||
const kDanger = Color(0xFFFA5151);
|
||||
const kHeader = Colors.white;
|
||||
|
||||
ThemeData buildTheme() {
|
||||
const scheme = ColorScheme.light(
|
||||
primary: kBind,
|
||||
secondary: kBind2,
|
||||
surface: Colors.white,
|
||||
onPrimary: Colors.white,
|
||||
onSurface: kInk,
|
||||
);
|
||||
return ThemeData(
|
||||
colorScheme: scheme,
|
||||
useMaterial3: true,
|
||||
scaffoldBackgroundColor: kPaper,
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
highlightColor: const Color(0x14267EF0),
|
||||
pageTransitionsTheme: const PageTransitionsTheme(
|
||||
builders: {
|
||||
TargetPlatform.android: CupertinoPageTransitionsBuilder(),
|
||||
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
|
||||
TargetPlatform.linux: CupertinoPageTransitionsBuilder(),
|
||||
TargetPlatform.windows: CupertinoPageTransitionsBuilder(),
|
||||
},
|
||||
),
|
||||
fontFamily: null,
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: kHeader,
|
||||
foregroundColor: kInk,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
centerTitle: true,
|
||||
titleTextStyle: TextStyle(color: kInk, fontSize: 17, fontWeight: FontWeight.w600),
|
||||
systemOverlayStyle: SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.dark,
|
||||
),
|
||||
),
|
||||
dividerColor: kLine,
|
||||
cardTheme: const CardThemeData(
|
||||
color: Colors.white,
|
||||
elevation: 0,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(8))),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
hintStyle: const TextStyle(color: Color(0xFFB2B2B2), fontSize: 15),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
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: kBind)),
|
||||
),
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: kBind,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../theme.dart';
|
||||
|
||||
class AuthImage extends StatefulWidget {
|
||||
const AuthImage({
|
||||
super.key,
|
||||
required this.api,
|
||||
required this.fileId,
|
||||
this.width,
|
||||
this.height,
|
||||
this.fit = BoxFit.cover,
|
||||
this.placeholder,
|
||||
});
|
||||
final OaClient api;
|
||||
final String fileId;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final BoxFit fit;
|
||||
final Widget? placeholder;
|
||||
|
||||
@override
|
||||
State<AuthImage> createState() => _AuthImageState();
|
||||
}
|
||||
|
||||
class _AuthImageState extends State<AuthImage> {
|
||||
Uint8List? _bytes;
|
||||
bool _fail = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bytes = widget.api.peekFile(widget.fileId);
|
||||
if (_bytes == null) _load();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AuthImage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.fileId != widget.fileId) {
|
||||
_bytes = widget.api.peekFile(widget.fileId);
|
||||
_fail = false;
|
||||
if (_bytes == null) _load();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final b = await widget.api.fileBytes(widget.fileId);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_bytes = b;
|
||||
_fail = b == null;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _fail = true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_bytes != null) {
|
||||
return Image.memory(
|
||||
_bytes!,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
fit: widget.fit,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.medium,
|
||||
);
|
||||
}
|
||||
return SizedBox(
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
child: widget.placeholder ??
|
||||
ColoredBox(
|
||||
color: const Color(0xFFEDEDED),
|
||||
child: _fail ? const Icon(Icons.broken_image_outlined, color: kMute, size: 18) : const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app_config.dart';
|
||||
import '../device/device_bridge.dart';
|
||||
import '../theme.dart';
|
||||
|
||||
class BeianFooter extends StatelessWidget {
|
||||
const BeianFooter({super.key});
|
||||
|
||||
Future<void> _open(String url) async {
|
||||
try {
|
||||
await DeviceBridge.openUrl(url);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const style = TextStyle(color: kMute, fontSize: 11, height: 1.6);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Column(
|
||||
children: [
|
||||
const Text(AppConfig.company, textAlign: TextAlign.center, style: style),
|
||||
Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => _open(AppConfig.icpUrl),
|
||||
child: const Text(AppConfig.icp, style: style),
|
||||
),
|
||||
const Text(' | ', style: style),
|
||||
GestureDetector(
|
||||
onTap: () => _open('tel:${AppConfig.phone}'),
|
||||
child: const Text('联系电话 ${AppConfig.phone}', style: style),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme.dart';
|
||||
import '../labels.dart';
|
||||
|
||||
/// 与 Web `ApplyBucketBar` 对齐:Segmented + 选中态。
|
||||
class BucketBar extends StatelessWidget {
|
||||
const BucketBar({super.key, required this.value, required this.onChanged, this.extra = const []});
|
||||
|
||||
final String value;
|
||||
final ValueChanged<String> onChanged;
|
||||
final List<(String, String)> extra;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final items = <(String, String)>[
|
||||
('', '全部'),
|
||||
('pending', '待审批'),
|
||||
('approved', '已通过'),
|
||||
('rejected', '已驳回'),
|
||||
...extra,
|
||||
];
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final it in items)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(it.$2),
|
||||
selected: value == it.$1,
|
||||
showCheckmark: false,
|
||||
visualDensity: VisualDensity.compact,
|
||||
selectedColor: kBind.withValues(alpha: 0.16),
|
||||
onSelected: (_) => onChanged(it.$1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class EmptyHint extends StatelessWidget {
|
||||
const EmptyHint(this.text, {super.key});
|
||||
final String text;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Text(text, style: const TextStyle(color: kMute)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class KvTile extends StatelessWidget {
|
||||
const KvTile({super.key, required this.title, this.subtitle, this.trailing, this.onTap, this.badge, this.status});
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final Widget? trailing;
|
||||
final VoidCallback? onTap;
|
||||
final int? badge;
|
||||
final dynamic status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.white,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 12, 0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: kLine, width: 0.5))),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 16, color: kInk)),
|
||||
if (subtitle != null && subtitle!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(subtitle!, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13, color: kMute)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Column(
|
||||
children: [
|
||||
if (status != null) StatusDot(status),
|
||||
if (badge != null && badge! > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: CircleAvatar(
|
||||
radius: 10,
|
||||
backgroundColor: kDanger,
|
||||
child: Text('$badge', style: const TextStyle(color: Colors.white, fontSize: 10)),
|
||||
),
|
||||
),
|
||||
if (trailing != null) trailing!,
|
||||
if (onTap != null) const Icon(Icons.chevron_right, color: Color(0xFFC0C0C0), size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../session/session.dart';
|
||||
import '../theme.dart';
|
||||
import 'wecom.dart';
|
||||
|
||||
const kDeskBg = Color(0xFFEDEDED);
|
||||
const kDeskSide = Color(0xFFF7F7F7);
|
||||
const kDeskPane = Colors.white;
|
||||
const kDeskActive = Color(0xFFE8F3FF);
|
||||
|
||||
class DesktopSidebar extends StatelessWidget {
|
||||
const DesktopSidebar({
|
||||
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: kDeskSide,
|
||||
child: SafeArea(
|
||||
child: SizedBox(
|
||||
width: 210,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
SquareAvatar(
|
||||
label: session.displayName,
|
||||
size: 36,
|
||||
fileId: '${session.user['avatarFileId'] ?? ''}',
|
||||
api: api,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
session.displayName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: kInk),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, color: kLine),
|
||||
const SizedBox(height: 6),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
children: [
|
||||
for (var i = 0; i < items.length; i++)
|
||||
_NavItem(
|
||||
active: index == i,
|
||||
icon: index == i ? items[i].$2 : items[i].$1,
|
||||
label: items[i].$3,
|
||||
badge: items[i].$4,
|
||||
onTap: () => onChanged(i),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavItem extends StatelessWidget {
|
||||
const _NavItem({
|
||||
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.only(bottom: 4),
|
||||
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(horizontal: 12, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Icon(icon, size: 20, color: active ? kBind : const Color(0xFF646464)),
|
||||
if (badge > 0)
|
||||
Positioned(right: -8, top: -4, child: UnreadBadge(badge)),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: active ? kBind : const Color(0xFF323232),
|
||||
fontWeight: active ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DesktopPaneHeader extends StatelessWidget {
|
||||
const DesktopPaneHeader({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.actions = const [],
|
||||
this.leading,
|
||||
this.bottom,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final List<Widget> actions;
|
||||
final Widget? leading;
|
||||
final Widget? bottom;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
color: kDeskPane,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 52,
|
||||
child: Row(
|
||||
children: [
|
||||
if (leading != null) leading!,
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: leading == null ? 16 : 4),
|
||||
child: Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: kInk)),
|
||||
),
|
||||
const Spacer(),
|
||||
...actions,
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (bottom != null) bottom!,
|
||||
const Divider(height: 1, color: kLine),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DesktopSearchBar extends StatelessWidget {
|
||||
const DesktopSearchBar({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: 34,
|
||||
decoration: BoxDecoration(color: const Color(0xFFF3F3F3), borderRadius: BorderRadius.circular(6)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.search, size: 18, color: kMute),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
onChanged: onChanged,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
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: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DesktopAppCard extends StatelessWidget {
|
||||
const DesktopAppCard({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.desc,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
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(10),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 14, 14, 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
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(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: kInk)),
|
||||
const SizedBox(height: 4),
|
||||
Text(desc, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: kMute, height: 1.35)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DesktopEmptyPane extends StatelessWidget {
|
||||
const DesktopEmptyPane({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.12,
|
||||
child: Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(color: kBind, borderRadius: BorderRadius.circular(28)),
|
||||
child: const Icon(Icons.apartment, size: 64, color: Colors.white),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(hint, style: const TextStyle(color: kMute, fontSize: 14)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DesktopTabBar extends StatelessWidget {
|
||||
const DesktopTabBar({
|
||||
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: 44,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
children: [
|
||||
for (var i = 0; i < tabs.length; i++)
|
||||
_TabChip(
|
||||
title: tabs[i].$2,
|
||||
icon: tabs[i].$3,
|
||||
color: tabs[i].$4,
|
||||
active: active == i,
|
||||
closable: i > 0,
|
||||
onTap: () => onSelect(i),
|
||||
onClose: () => onClose(i),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, color: kLine),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TabChip extends StatelessWidget {
|
||||
const _TabChip({
|
||||
required this.title,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.active,
|
||||
required this.closable,
|
||||
required this.onTap,
|
||||
required this.onClose,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final bool active;
|
||||
final bool closable;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onClose;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 4, top: 6, bottom: 6),
|
||||
child: Material(
|
||||
color: active ? const Color(0xFFF3F6FB) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(10, 6, closable ? 4 : 10, 6),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 16, color: color),
|
||||
const SizedBox(width: 6),
|
||||
Text(title, style: TextStyle(fontSize: 13, color: active ? kInk : kMute, fontWeight: active ? FontWeight.w600 : FontWeight.w400)),
|
||||
if (closable) ...[
|
||||
const SizedBox(width: 4),
|
||||
InkWell(
|
||||
onTap: onClose,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(2),
|
||||
child: Icon(Icons.close, size: 14, color: kMute),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../theme.dart';
|
||||
import 'auth_image.dart';
|
||||
|
||||
/// 公告 HTML:按块渲染标题、段落、列表、加粗,不依赖 flutter_html。
|
||||
class HtmlBody extends StatelessWidget {
|
||||
const HtmlBody({super.key, required this.html, this.api});
|
||||
final String html;
|
||||
final OaClient? api;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final blocks = parseHtmlBlocks(html);
|
||||
if (blocks.isEmpty) {
|
||||
return const Text('暂无正文', style: TextStyle(color: kMute, fontSize: 15));
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final b in blocks) ...[
|
||||
if (b.kind == 'img' && b.src != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _img(b.src!),
|
||||
)
|
||||
else
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: b.kind.startsWith('h') ? 10 : 12, left: b.kind == 'li' ? 12 : 0),
|
||||
child: Text.rich(
|
||||
TextSpan(children: b.spans),
|
||||
style: _style(b.kind),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
TextStyle _style(String kind) {
|
||||
switch (kind) {
|
||||
case 'h1':
|
||||
return const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: kInk, height: 1.4);
|
||||
case 'h2':
|
||||
return const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: kInk, height: 1.4);
|
||||
case 'h3':
|
||||
return const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: kInk, height: 1.45);
|
||||
case 'li':
|
||||
return const TextStyle(fontSize: 16, color: kInk, height: 1.7);
|
||||
default:
|
||||
return const TextStyle(fontSize: 16, color: kInk, height: 1.7);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _img(String src) {
|
||||
final m = RegExp(r'/files/([0-9a-f-]{36})', caseSensitive: false).firstMatch(src);
|
||||
if (m != null && api != null) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: AuthImage(api: api!, fileId: m.group(1)!, fit: BoxFit.contain),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
class HtmlBlock {
|
||||
HtmlBlock(this.kind, this.spans, {this.src});
|
||||
final String kind;
|
||||
final List<InlineSpan> spans;
|
||||
final String? src;
|
||||
}
|
||||
|
||||
List<HtmlBlock> parseHtmlBlocks(String raw) {
|
||||
var s = raw.trim();
|
||||
if (s.isEmpty) return [];
|
||||
s = s.replaceAll(RegExp(r'<(script|style)[^>]*>[\s\S]*?</\1>', caseSensitive: false), '');
|
||||
if (!RegExp(r'<[a-z][\s\S]*>', caseSensitive: false).hasMatch(s)) {
|
||||
return s
|
||||
.split(RegExp(r'\n+'))
|
||||
.where((e) => e.trim().isNotEmpty)
|
||||
.map((e) => HtmlBlock('p', _inline(e.trim())))
|
||||
.toList();
|
||||
}
|
||||
s = s
|
||||
.replaceAll(RegExp(r'<br\s*/?>', caseSensitive: false), '\n')
|
||||
.replaceAll(RegExp(r'</(p|div|h1|h2|h3|li|tr|blockquote)>', caseSensitive: false), '\n')
|
||||
.replaceAll(RegExp(r'<(p|div|blockquote)[^>]*>', caseSensitive: false), '\n')
|
||||
.replaceAll(RegExp(r'<h1[^>]*>', caseSensitive: false), '\n#h1#')
|
||||
.replaceAll(RegExp(r'<h2[^>]*>', caseSensitive: false), '\n#h2#')
|
||||
.replaceAll(RegExp(r'<h3[^>]*>', caseSensitive: false), '\n#h3#')
|
||||
.replaceAll(RegExp(r'<li[^>]*>', caseSensitive: false), '\n#li#')
|
||||
.replaceAll(RegExp(r'</?(ul|ol)[^>]*>', caseSensitive: false), '\n')
|
||||
.replaceAll(RegExp(r'<(strong|b)[^>]*>', caseSensitive: false), '«b»')
|
||||
.replaceAll(RegExp(r'</(strong|b)>', caseSensitive: false), '«/b»');
|
||||
final imgRe = RegExp(r'<img[^>]*src=["'']([^"'']+)["''][^>]*>', caseSensitive: false);
|
||||
s = s.replaceAllMapped(imgRe, (m) => '\n#img#${m.group(1)}#\n');
|
||||
s = s.replaceAll(RegExp(r'<[^>]+>'), '');
|
||||
s = decodeEntities(s);
|
||||
final out = <HtmlBlock>[];
|
||||
for (final line in s.split('\n')) {
|
||||
final t = line.trim();
|
||||
if (t.isEmpty) continue;
|
||||
if (t.startsWith('#img#') && t.endsWith('#')) {
|
||||
out.add(HtmlBlock('img', const [], src: t.substring(5, t.length - 1)));
|
||||
continue;
|
||||
}
|
||||
var kind = 'p';
|
||||
var text = t;
|
||||
if (t.startsWith('#h1#')) {
|
||||
kind = 'h1';
|
||||
text = t.substring(4);
|
||||
} else if (t.startsWith('#h2#')) {
|
||||
kind = 'h2';
|
||||
text = t.substring(4);
|
||||
} else if (t.startsWith('#h3#')) {
|
||||
kind = 'h3';
|
||||
text = t.substring(4);
|
||||
} else if (t.startsWith('#li#')) {
|
||||
kind = 'li';
|
||||
text = '• ${t.substring(4)}';
|
||||
}
|
||||
text = text.trim();
|
||||
if (text.isEmpty) continue;
|
||||
out.add(HtmlBlock(kind, _inline(text)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
List<InlineSpan> _inline(String text) {
|
||||
final out = <InlineSpan>[];
|
||||
final re = RegExp(r'«b»(.*?)«/b»');
|
||||
var i = 0;
|
||||
for (final m in re.allMatches(text)) {
|
||||
if (m.start > i) out.add(TextSpan(text: text.substring(i, m.start)));
|
||||
out.add(TextSpan(text: m.group(1), style: const TextStyle(fontWeight: FontWeight.w700)));
|
||||
i = m.end;
|
||||
}
|
||||
if (i < text.length) out.add(TextSpan(text: text.substring(i)));
|
||||
if (out.isEmpty) out.add(TextSpan(text: text));
|
||||
return out;
|
||||
}
|
||||
|
||||
String decodeEntities(String s) {
|
||||
return s
|
||||
.replaceAll(' ', ' ')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAllMapped(RegExp(r'&#(\d+);'), (m) {
|
||||
final n = int.tryParse(m.group(1)!);
|
||||
return n == null ? m.group(0)! : String.fromCharCode(n);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../device/device_bridge.dart';
|
||||
import '../theme.dart';
|
||||
import 'tencent_map_thumb.dart';
|
||||
|
||||
class LocationViewPage extends StatelessWidget {
|
||||
const LocationViewPage({
|
||||
super.key,
|
||||
required this.api,
|
||||
required this.title,
|
||||
required this.address,
|
||||
this.lat,
|
||||
this.lng,
|
||||
});
|
||||
|
||||
final OaClient api;
|
||||
final String title;
|
||||
final String address;
|
||||
final double? lat;
|
||||
final double? lng;
|
||||
|
||||
Future<void> _openMaps(BuildContext context) async {
|
||||
if (lat == null || lng == null) return;
|
||||
final la = lat!;
|
||||
final ln = lng!;
|
||||
final q = Uri.encodeComponent(title.isNotEmpty ? title : '$la,$ln');
|
||||
final urls = [
|
||||
'https://uri.amap.com/marker?position=$ln,$la&name=$q',
|
||||
'geo:$la,$ln?q=$la,$ln($q)',
|
||||
];
|
||||
for (final u in urls) {
|
||||
try {
|
||||
await DeviceBridge.openUrl(u);
|
||||
return;
|
||||
} catch (_) {}
|
||||
}
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('无法打开地图')));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(title: const Text('位置')),
|
||||
body: ListView(
|
||||
children: [
|
||||
if (lat != null && lng != null)
|
||||
TencentMapThumb(api: api, lat: lat!, lng: lng!, width: MediaQuery.sizeOf(context).width, height: 220),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(title.isEmpty ? '位置' : title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
if (address.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Text(address, style: const TextStyle(fontSize: 14, color: kMute)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: FilledButton.icon(
|
||||
onPressed: lat == null ? null : () => _openMaps(context),
|
||||
icon: const Icon(Icons.map_outlined),
|
||||
label: const Text('用地图打开'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../theme.dart';
|
||||
|
||||
class TencentMapThumb extends StatefulWidget {
|
||||
const TencentMapThumb({
|
||||
super.key,
|
||||
required this.api,
|
||||
required this.lat,
|
||||
required this.lng,
|
||||
this.height = 110,
|
||||
this.width,
|
||||
});
|
||||
final OaClient api;
|
||||
final double lat;
|
||||
final double lng;
|
||||
final double height;
|
||||
final double? width;
|
||||
|
||||
@override
|
||||
State<TencentMapThumb> createState() => _TencentMapThumbState();
|
||||
}
|
||||
|
||||
class _TencentMapThumbState extends State<TencentMapThumb> {
|
||||
Uint8List? _bytes;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant TencentMapThumb oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.lat != widget.lat || oldWidget.lng != widget.lng) _load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final b = await widget.api.getBytes('/office/geo/static-map', query: {
|
||||
'lat': '${widget.lat}',
|
||||
'lng': '${widget.lng}',
|
||||
});
|
||||
if (!mounted) return;
|
||||
setState(() => _bytes = b);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_bytes != null) {
|
||||
return Image.memory(_bytes!, width: widget.width, height: widget.height, fit: BoxFit.cover);
|
||||
}
|
||||
return SizedBox(
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
child: const ColoredBox(
|
||||
color: Color(0xFFE8F5E9),
|
||||
child: Center(child: Icon(Icons.place, color: kWeGreen, size: 32)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme.dart';
|
||||
|
||||
/// 微信式语音消息:气泡 + 下方独立白底转写框。
|
||||
class VoiceMessageBlock extends StatelessWidget {
|
||||
const VoiceMessageBlock({
|
||||
super.key,
|
||||
required this.mine,
|
||||
required this.seconds,
|
||||
required this.playing,
|
||||
this.transcript,
|
||||
this.loading = false,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
this.bubbleColor,
|
||||
this.iconColor,
|
||||
this.readLabel,
|
||||
this.bottomMargin = 8,
|
||||
});
|
||||
|
||||
final bool mine;
|
||||
final int seconds;
|
||||
final bool playing;
|
||||
final String? transcript;
|
||||
final bool loading;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onLongPress;
|
||||
final Color? bubbleColor;
|
||||
final Color? iconColor;
|
||||
final String? readLabel;
|
||||
final double bottomMargin;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bubble = mine ? (bubbleColor ?? kBubbleMine) : Colors.white;
|
||||
final ink = iconColor ?? kInk;
|
||||
final width = (80 + seconds * 4).clamp(80, 180).toDouble();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: mine ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
child: Container(
|
||||
margin: EdgeInsets.only(bottom: bottomMargin),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||
constraints: BoxConstraints(maxWidth: 280, minWidth: width),
|
||||
decoration: BoxDecoration(
|
||||
color: bubble,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
mine ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: width,
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
mine ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
children: [
|
||||
if (!mine) ...[
|
||||
Icon(
|
||||
playing
|
||||
? Icons.stop_circle_outlined
|
||||
: Icons.volume_up_outlined,
|
||||
size: 20,
|
||||
color: ink,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text('$seconds″',
|
||||
style: TextStyle(fontSize: 16, color: ink)),
|
||||
] else ...[
|
||||
Text('$seconds″',
|
||||
style: TextStyle(fontSize: 16, color: ink)),
|
||||
const SizedBox(width: 6),
|
||||
Icon(
|
||||
playing
|
||||
? Icons.stop_circle_outlined
|
||||
: Icons.volume_up_outlined,
|
||||
size: 20,
|
||||
color: ink,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (readLabel != null)
|
||||
Text(readLabel!,
|
||||
style: const TextStyle(fontSize: 10, color: kMute)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (loading) _transcriptBox(const Text('转文字中…', style: TextStyle(fontSize: 14, color: kMute)))
|
||||
else if (transcript != null && transcript!.isNotEmpty)
|
||||
_transcriptBox(Text(
|
||||
transcript!,
|
||||
style: const TextStyle(fontSize: 15, color: kInk, height: 1.35),
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _transcriptBox(Widget child) {
|
||||
return Container(
|
||||
margin: EdgeInsets.only(bottom: bottomMargin),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
constraints: const BoxConstraints(maxWidth: 280),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: const Color(0xFFE5E5E5)),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/auth_image.dart';
|
||||
|
||||
class WxHeader extends StatelessWidget {
|
||||
const WxHeader({super.key, required this.title, this.leading, this.actions = const []});
|
||||
final String title;
|
||||
final Widget? leading;
|
||||
final List<Widget> actions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
color: kHeader,
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 48, child: leading ?? const SizedBox.shrink()),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: kInk),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: actions.isEmpty ? 48 : null,
|
||||
child: Row(mainAxisAlignment: MainAxisAlignment.end, children: actions),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WxSearchBar extends StatelessWidget {
|
||||
const WxSearchBar({super.key, this.hint = '搜索', this.onChanged, this.onTap, this.readonly = false});
|
||||
final String hint;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final VoidCallback? onTap;
|
||||
final bool readonly;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final field = Container(
|
||||
height: 36,
|
||||
decoration: BoxDecoration(color: const Color(0xFFF3F3F3), borderRadius: BorderRadius.circular(6)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.search, size: 18, color: kMute),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: readonly
|
||||
? Text(hint, style: const TextStyle(color: Color(0xFFB2B2B2), fontSize: 15))
|
||||
: TextField(
|
||||
onChanged: onChanged,
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
isDense: true,
|
||||
filled: true,
|
||||
fillColor: Colors.transparent,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return ColoredBox(
|
||||
color: Colors.white,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 10),
|
||||
child: onTap == null ? field : GestureDetector(onTap: onTap, child: field),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SquareAvatar extends StatelessWidget {
|
||||
const SquareAvatar({super.key, required this.label, this.size = 44, this.color, this.icon, this.fileId, this.api});
|
||||
final String label;
|
||||
final double size;
|
||||
final Color? color;
|
||||
final IconData? icon;
|
||||
final String? fileId;
|
||||
final OaClient? api;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bg = color ?? _colorOf(label);
|
||||
final id = (fileId ?? '').trim();
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)),
|
||||
alignment: Alignment.center,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: id.isNotEmpty && api != null
|
||||
? AuthImage(api: api!, fileId: id, width: size, height: size, fit: BoxFit.cover)
|
||||
: icon != null
|
||||
? Icon(icon, color: Colors.white, size: size * 0.5)
|
||||
: Text(
|
||||
label.isEmpty ? '?' : String.fromCharCodes(label.runes.take(1)),
|
||||
style: TextStyle(color: Colors.white, fontSize: size * 0.38, fontWeight: FontWeight.w600),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Color _colorOf(String s) {
|
||||
const palette = [
|
||||
Color(0xFF267EF0),
|
||||
Color(0xFF07C160),
|
||||
Color(0xFFFA9D3B),
|
||||
Color(0xFF6267F2),
|
||||
Color(0xFF10AEFF),
|
||||
Color(0xFFE75D5D),
|
||||
Color(0xFF00B578),
|
||||
Color(0xFF8B5CF6),
|
||||
];
|
||||
return palette[s.hashCode.abs() % palette.length];
|
||||
}
|
||||
|
||||
class UnreadBadge extends StatelessWidget {
|
||||
const UnreadBadge(this.count, {super.key, this.dot = false});
|
||||
final int count;
|
||||
final bool dot;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (count <= 0 && !dot) return const SizedBox.shrink();
|
||||
if (dot || count <= 0) {
|
||||
return Container(width: 8, height: 8, decoration: const BoxDecoration(color: kDanger, shape: BoxShape.circle));
|
||||
}
|
||||
final text = count > 99 ? '99+' : '$count';
|
||||
return Container(
|
||||
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5),
|
||||
decoration: BoxDecoration(color: kDanger, borderRadius: BorderRadius.circular(9)),
|
||||
alignment: Alignment.center,
|
||||
child: Text(text, style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w600)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ConvTile extends StatelessWidget {
|
||||
const ConvTile({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.preview,
|
||||
this.time,
|
||||
this.unread = 0,
|
||||
this.muted = false,
|
||||
this.avatarLabel,
|
||||
this.avatarColor,
|
||||
this.avatarIcon,
|
||||
this.avatarFileId,
|
||||
this.api,
|
||||
this.onTap,
|
||||
this.selected = false,
|
||||
});
|
||||
final String title;
|
||||
final String? preview;
|
||||
final String? time;
|
||||
final int unread;
|
||||
final bool muted;
|
||||
final String? avatarLabel;
|
||||
final Color? avatarColor;
|
||||
final IconData? avatarIcon;
|
||||
final String? avatarFileId;
|
||||
final OaClient? api;
|
||||
final VoidCallback? onTap;
|
||||
final bool selected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
color: selected ? const Color(0xFFE8F3FF) : Colors.white,
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SquareAvatar(label: avatarLabel ?? title, color: avatarColor, icon: avatarIcon, fileId: avatarFileId, api: api),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: kLine, width: 0.5))),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 16, color: kInk)),
|
||||
),
|
||||
if (time != null && time!.isNotEmpty)
|
||||
Text(time!, style: const TextStyle(fontSize: 12, color: Color(0xFFB2B2B2))),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
preview ?? '',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 13, color: kMute),
|
||||
),
|
||||
),
|
||||
if (muted) const Padding(padding: EdgeInsets.only(left: 6), child: Icon(Icons.notifications_off_outlined, size: 14, color: kMute)),
|
||||
if (unread > 0) Padding(padding: const EdgeInsets.only(left: 6), child: UnreadBadge(unread)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GroupCard extends StatelessWidget {
|
||||
const GroupCard({super.key, required this.title, required this.child, this.trailing});
|
||||
final String title;
|
||||
final Widget child;
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 12, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: kInk))),
|
||||
if (trailing != null) trailing!,
|
||||
],
|
||||
),
|
||||
),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AppGrid extends StatelessWidget {
|
||||
const AppGrid({super.key, required this.items});
|
||||
final List<AppGridItem> items;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GridView.count(
|
||||
crossAxisCount: 4,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(4, 4, 4, 12),
|
||||
childAspectRatio: 0.92,
|
||||
children: [
|
||||
for (final it in items)
|
||||
InkWell(
|
||||
onTap: it.onTap,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(color: it.color, borderRadius: BorderRadius.circular(10)),
|
||||
child: Icon(it.icon, color: Colors.white, size: 24),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
it.label,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12, color: kInk),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AppGridItem {
|
||||
const AppGridItem({required this.label, required this.icon, required this.color, required this.onTap});
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
}
|
||||
|
||||
class CellGroup extends StatelessWidget {
|
||||
const CellGroup({super.key, required this.children});
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)),
|
||||
child: Column(children: children),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Cell extends StatelessWidget {
|
||||
const Cell({super.key, required this.title, this.subtitle, this.leading, this.trailing, this.onTap, this.showLine = true});
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final Widget? leading;
|
||||
final Widget? trailing;
|
||||
final VoidCallback? onTap;
|
||||
final bool showLine;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(0, 14, 12, 14),
|
||||
decoration: showLine ? const BoxDecoration(border: Border(bottom: BorderSide(color: kLine, width: 0.5))) : null,
|
||||
child: Row(
|
||||
children: [
|
||||
if (leading != null) ...[leading!, const SizedBox(width: 12)],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: const TextStyle(fontSize: 16, color: kInk)),
|
||||
if (subtitle != null && subtitle!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(subtitle!, style: const TextStyle(fontSize: 12, color: kMute)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) trailing!,
|
||||
if (onTap != null) const Icon(Icons.chevron_right, color: Color(0xFFC0C0C0), size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WxTabBar extends StatelessWidget {
|
||||
const WxTabBar({super.key, required this.index, required this.onChanged, required this.items});
|
||||
final int index;
|
||||
final ValueChanged<int> onChanged;
|
||||
final List<(IconData, IconData, String, int)> items;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
color: const Color(0xFFF7F7F7),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: SizedBox(
|
||||
height: 54,
|
||||
child: Row(
|
||||
children: [
|
||||
for (var i = 0; i < items.length; i++)
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: () => onChanged(i),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Icon(index == i ? items[i].$2 : items[i].$1, size: 24, color: index == i ? kBind : const Color(0xFF646464)),
|
||||
if (items[i].$4 > 0)
|
||||
Positioned(right: -10, top: -4, child: UnreadBadge(items[i].$4)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(items[i].$3, style: TextStyle(fontSize: 10, color: index == i ? kBind : const Color(0xFF646464))),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<T?> showWxSheet<T>(BuildContext context, List<(String, T)> actions) {
|
||||
return showModalBottomSheet<T>(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (ctx) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < actions.length; i++)
|
||||
InkWell(
|
||||
onTap: () => Navigator.pop(ctx, actions[i].$2),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
decoration: i == 0 ? null : const BoxDecoration(border: Border(top: BorderSide(color: kLine, width: 0.5))),
|
||||
alignment: Alignment.center,
|
||||
child: Text(actions[i].$1, style: const TextStyle(fontSize: 17, color: kInk)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: () => Navigator.pop(ctx),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: const SizedBox(
|
||||
width: double.infinity,
|
||||
height: 52,
|
||||
child: Center(child: Text('取消', style: TextStyle(fontSize: 17, color: kBind, fontWeight: FontWeight.w500))),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 微信风格的页内提示:不遮挡输入框和系统导航区域。
|
||||
void showWxToast(BuildContext context, String message, {bool error = false}) {
|
||||
final overlay = Overlay.maybeOf(context);
|
||||
if (overlay == null || message.trim().isEmpty) return;
|
||||
late final OverlayEntry entry;
|
||||
entry = OverlayEntry(
|
||||
builder: (ctx) => Positioned(
|
||||
top: MediaQuery.paddingOf(ctx).top + 54,
|
||||
left: 32,
|
||||
right: 32,
|
||||
child: IgnorePointer(
|
||||
child: Material(
|
||||
color: Colors.white,
|
||||
elevation: 8,
|
||||
shadowColor: Colors.black26,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(error ? Icons.error_outline : Icons.check_circle_outline,
|
||||
size: 20, color: error ? kDanger : kWeGreen),
|
||||
const SizedBox(width: 9),
|
||||
Expanded(
|
||||
child: Text(message,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 14, color: kInk)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
overlay.insert(entry);
|
||||
Future<void>.delayed(const Duration(milliseconds: 1800), () {
|
||||
if (entry.mounted) entry.remove();
|
||||
});
|
||||
}
|
||||
|
||||
void showPlusMenu(BuildContext context, List<(IconData, String, VoidCallback)> items) {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
final overlay = Overlay.of(context).context.findRenderObject() as RenderBox?;
|
||||
final pos = box != null && overlay != null
|
||||
? RelativeRect.fromRect(
|
||||
Rect.fromPoints(box.localToGlobal(Offset(box.size.width - 8, box.size.height), ancestor: overlay), box.localToGlobal(box.size.bottomRight(Offset.zero), ancestor: overlay)),
|
||||
Offset.zero & overlay.size,
|
||||
)
|
||||
: const RelativeRect.fromLTRB(80, 80, 16, 0);
|
||||
showMenu<int>(
|
||||
context: context,
|
||||
position: pos,
|
||||
color: const Color(0xFF4C4C4C),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
items: [
|
||||
for (var i = 0; i < items.length; i++)
|
||||
PopupMenuItem(
|
||||
value: i,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(items[i].$1, color: Colors.white, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Text(items[i].$2, style: const TextStyle(color: Colors.white, fontSize: 15)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
).then((i) {
|
||||
if (i != null) items[i].$3();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user