Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)

含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。
排除 node_modules、构建产物、安装包与 .env 密钥。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-02 10:04:03 +00:00
commit 76f266645d
507 changed files with 99891 additions and 0 deletions
+358
View File
@@ -0,0 +1,358 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import 'package:http_parser/http_parser.dart';
import 'package:uuid/uuid.dart';
import '../app_config.dart';
import '../session/session.dart';
class ApiException implements Exception {
ApiException(this.message);
final String message;
@override
String toString() => message;
}
class OaClient {
OaClient(this.session);
final SessionStore session;
bool _refreshing = false;
Uri _uri(String path, [Map<String, String>? query]) {
final base = session.apiBase.replaceAll(RegExp(r'/$'), '');
final p = path.startsWith('/') ? path : '/$path';
return Uri.parse('$base$p').replace(queryParameters: query);
}
Map<String, String> _headers({bool auth = true}) {
final h = <String, String>{
'Content-Type': 'application/json',
'X-Request-Id': const Uuid().v4(),
};
if (auth && session.accessToken.isNotEmpty) {
h['Authorization'] = 'Bearer ${session.accessToken}';
}
return h;
}
Future<dynamic> get(String path, {Map<String, String>? query}) =>
_send('GET', path, query: query);
Future<dynamic> post(String path, [Map<String, dynamic>? body]) =>
_send('POST', path, body: body);
Future<dynamic> patch(String path, [Map<String, dynamic>? body]) =>
_send('PATCH', path, body: body);
final _fileCache = <String, Uint8List>{};
Uint8List? peekFile(String id) => _fileCache[id];
Future<Uint8List?> fileBytes(String id) async {
if (id.isEmpty) return null;
final hit = _fileCache[id];
if (hit != null) return hit;
final uri = _uri('/files/$id');
final headers = <String, String>{
'X-Request-Id': const Uuid().v4(),
if (session.accessToken.isNotEmpty) 'Authorization': 'Bearer ${session.accessToken}',
};
try {
var res = await http.get(uri, headers: headers).timeout(const Duration(seconds: 30));
if (res.statusCode == 401 && session.refreshToken.isNotEmpty) {
final ok = await _refresh();
if (ok) {
headers['Authorization'] = 'Bearer ${session.accessToken}';
res = await http.get(uri, headers: headers).timeout(const Duration(seconds: 30));
}
}
if (res.statusCode >= 400 || res.bodyBytes.isEmpty) return null;
if (_fileCache.length > 80) _fileCache.remove(_fileCache.keys.first);
_fileCache[id] = res.bodyBytes;
return res.bodyBytes;
} catch (_) {
return null;
}
}
Future<Uint8List?> getBytes(String path, {Map<String, String>? query}) async {
final uri = _uri(path, query);
final headers = <String, String>{
'X-Request-Id': const Uuid().v4(),
if (session.accessToken.isNotEmpty) 'Authorization': 'Bearer ${session.accessToken}',
};
try {
var res = await http.get(uri, headers: headers).timeout(const Duration(seconds: 20));
if (res.statusCode == 401 && session.refreshToken.isNotEmpty) {
final ok = await _refresh();
if (ok) {
headers['Authorization'] = 'Bearer ${session.accessToken}';
res = await http.get(uri, headers: headers).timeout(const Duration(seconds: 20));
}
}
if (res.statusCode >= 400 || res.bodyBytes.isEmpty) return null;
return res.bodyBytes;
} catch (_) {
return null;
}
}
Future<Map<String, dynamic>> uploadFile({
required String filePath,
required String filename,
required String bizType,
required String bizId,
String? mime,
}) async {
final uri = _uri('/files');
final type = _mediaType(filename, mime);
Future<http.Response> send() async {
final req = http.MultipartRequest('POST', uri);
req.headers['Authorization'] = 'Bearer ${session.accessToken}';
req.headers['X-Request-Id'] = const Uuid().v4();
req.fields['bizType'] = bizType;
req.fields['bizId'] = bizId;
req.files.add(await http.MultipartFile.fromPath('file', filePath, filename: filename, contentType: type));
return http.Response.fromStream(await req.send().timeout(const Duration(seconds: 60)));
}
var res = await send();
if (res.statusCode == 401 && session.refreshToken.isNotEmpty) {
final ok = await _refresh();
if (ok) res = await send();
}
if ((res.statusCode == 502 || res.statusCode == 503) && session.accessToken.isNotEmpty) {
await Future<void>.delayed(const Duration(milliseconds: 700));
res = await send();
}
dynamic json;
try {
json = res.body.isEmpty ? null : jsonDecode(res.body);
} catch (_) {
throw ApiException(res.statusCode >= 400 ? '上传失败 ${res.statusCode}' : '响应无法解析');
}
if (json is Map && json['code'] != null && json['code'] != 0) {
throw ApiException('${json['message'] ?? '上传失败'}');
}
if (res.statusCode >= 400) {
final msg = json is Map ? (json['message'] ?? json['error'] ?? res.statusCode) : res.statusCode;
throw ApiException('$msg');
}
final data = json is Map && json.containsKey('data') ? json['data'] : json;
return data is Map ? Map<String, dynamic>.from(data) : {'id': '$data'};
}
Future<dynamic> _send(
String method,
String path, {
Map<String, String>? query,
Map<String, dynamic>? body,
bool retry = true,
}) async {
final uri = _uri(path, query);
final headers = _headers(
auth: !path.startsWith('/auth/login') &&
!path.startsWith('/auth/sms-code') &&
path != '/auth/login-options' &&
path != '/system/app-release' &&
!path.startsWith('/legal'),
);
http.Response res;
try {
if (method == 'GET') {
res = await http.get(uri, headers: headers).timeout(const Duration(seconds: 30));
} else if (method == 'PATCH') {
res = await http.patch(uri, headers: headers, body: jsonEncode(body ?? {})).timeout(const Duration(seconds: 30));
} else {
res = await http.post(uri, headers: headers, body: jsonEncode(body ?? {})).timeout(const Duration(seconds: 30));
}
} catch (e) {
throw ApiException('网络不可用:$e');
}
if ((res.statusCode == 502 || res.statusCode == 503) && retry) {
await Future<void>.delayed(const Duration(milliseconds: 700));
return _send(method, path, query: query, body: body, retry: false);
}
if (res.statusCode == 401 && retry && session.refreshToken.isNotEmpty && !path.contains('/auth/refresh')) {
final ok = await _refresh();
if (ok) return _send(method, path, query: query, body: body, retry: false);
await session.clear();
throw ApiException('登录已过期,请重新登录');
}
dynamic json;
try {
json = res.body.isEmpty ? null : jsonDecode(res.body);
} catch (_) {
throw ApiException(res.statusCode >= 400 ? '请求失败 ${res.statusCode}' : '响应无法解析');
}
if (json is Map && json['code'] != null && json['code'] != 0) {
throw ApiException('${json['message'] ?? '请求失败'}');
}
if (res.statusCode >= 400) {
final msg = json is Map ? (json['message'] ?? json['error'] ?? res.statusCode) : res.statusCode;
throw ApiException('$msg');
}
if (json is Map && json.containsKey('data')) return json['data'];
return json;
}
Future<bool> _refresh() async {
if (_refreshing) return false;
_refreshing = true;
try {
final uri = _uri('/auth/refresh');
final res = await http.post(
uri,
headers: _headers(auth: false),
body: jsonEncode({
'refreshToken': session.refreshToken,
'clientKind': 'mobile',
}),
);
if (res.statusCode >= 400) return false;
final json = jsonDecode(res.body);
final data = json is Map && json['data'] is Map ? json['data'] as Map : json;
final access = '${data['accessToken'] ?? ''}';
final refresh = '${data['refreshToken'] ?? ''}';
if (access.isEmpty) return false;
await session.setTokens(access, refresh.isEmpty ? session.refreshToken : refresh);
return true;
} catch (_) {
return false;
} finally {
_refreshing = false;
}
}
MediaType _mediaType(String filename, String? mime) {
var raw = (mime ?? '').split(';').first.trim().toLowerCase();
if (raw == 'image/jpg' || raw == 'image/pjpeg') raw = 'image/jpeg';
if (raw.isEmpty || raw == 'application/octet-stream') {
final n = filename.toLowerCase();
if (n.endsWith('.jpg') || n.endsWith('.jpeg')) {
raw = 'image/jpeg';
} else if (n.endsWith('.png')) {
raw = 'image/png';
} else if (n.endsWith('.gif')) {
raw = 'image/gif';
} else if (n.endsWith('.webp')) {
raw = 'image/webp';
} else if (n.endsWith('.m4a') || n.endsWith('.mp4')) {
raw = 'audio/mp4';
} else if (n.endsWith('.pdf')) {
raw = 'application/pdf';
} else {
raw = 'application/octet-stream';
}
}
try {
return MediaType.parse(raw);
} catch (_) {
return MediaType('application', 'octet-stream');
}
}
}
List<Map<String, dynamic>> asMaps(dynamic data) {
final list = extractList(data);
return list.whereType<Map>().map((e) => Map<String, dynamic>.from(e)).toList();
}
List<dynamic> extractList(dynamic data) {
if (data is List) return data;
if (data is Map) {
for (final k in ['items', 'list', 'rows', 'records', 'data']) {
if (data[k] is List) return data[k] as List;
}
}
return const [];
}
String scalarText(dynamic v) {
if (v == null) return '';
if (v is Map) {
return '${v['displayName'] ?? v['name'] ?? v['title'] ?? v['departmentName'] ?? ''}'.trim();
}
if (v is List) {
return v.map(scalarText).where((e) => e.isNotEmpty).join('、');
}
final s = '$v'.trim();
if (s.startsWith('{') && (s.contains('id:') || s.contains('tenantId'))) return '';
return s;
}
String pickTitle(Map<String, dynamic> row) {
for (final k in [
'title',
'name',
'displayName',
'projectName',
'bidNo',
'contractNo',
'claimNo',
'loanNo',
'projectNo',
'purpose',
'reason',
'label',
]) {
final s = scalarText(row[k]);
if (s.isNotEmpty) return s;
}
return row['id']?.toString() ?? '未命名';
}
String pickSub(Map<String, dynamic> row) {
for (final k in [
'departmentName',
'department',
'status',
'title',
'bidNo',
'contractNo',
'claimNo',
'kind',
'mobile',
'lastText',
'employmentStatus',
]) {
final s = scalarText(row[k]);
if (s.isNotEmpty) return s;
}
return '';
}
String fmtTime(dynamic v) {
if (v == null) return '';
final s = '$v';
final t = DateTime.tryParse(s);
if (t != null) {
final local = t.toLocal();
final date = '${local.year.toString().padLeft(4, '0')}-${local.month.toString().padLeft(2, '0')}-${local.day.toString().padLeft(2, '0')}';
final time = '${local.hour.toString().padLeft(2, '0')}:${local.minute.toString().padLeft(2, '0')}:${local.second.toString().padLeft(2, '0')}';
return '$date $time';
}
if (s.length >= 16) return s.substring(0, 16).replaceFirst('T', ' ');
return s;
}
String shortTime(dynamic v) {
if (v == null) return '';
final t = DateTime.tryParse('$v');
if (t == null) return fmtTime(v);
final now = DateTime.now();
final local = t.toLocal();
final today = DateTime(now.year, now.month, now.day);
final day = DateTime(local.year, local.month, local.day);
final hh = local.hour.toString().padLeft(2, '0');
final mm = local.minute.toString().padLeft(2, '0');
if (day == today) return '$hh:$mm';
if (day == today.subtract(const Duration(days: 1))) return '昨天';
const week = ['一', '二', '三', '四', '五', '六', '日'];
if (today.difference(day).inDays < 7) return '周${week[local.weekday - 1]}';
return '${local.month}/${local.day}';
}
const defaultApiHint = AppConfig.defaultApiBase;
+13
View File
@@ -0,0 +1,13 @@
class AppConfig {
static const brand = '风影智慧办公综合平台';
static const shortName = '风影办公';
static const company = '江苏风影随行科技有限公司';
static const icp = '苏ICP备2026017371号-2';
static const icpUrl = 'https://beian.miit.gov.cn/';
static const phone = '13142801138';
static const defaultApiBase = 'https://oa.fysxkj.com/api/v1';
static const defaultImHost = '47.96.23.244';
static const defaultImPort = 8901;
static const version = '3.1.10';
static const build = 324;
}
+19
View File
@@ -0,0 +1,19 @@
import 'package:package_info_plus/package_info_plus.dart';
import 'app_config.dart';
/// 运行时从安装包读取的真实版本,避免 OTA 误判。
class AppRuntime {
AppRuntime._();
static String version = AppConfig.version;
static int build = AppConfig.build;
static Future<void> load() async {
try {
final info = await PackageInfo.fromPlatform();
version = info.version.isNotEmpty ? info.version : AppConfig.version;
build = int.tryParse(info.buildNumber) ?? AppConfig.build;
} catch (_) {}
}
}
@@ -0,0 +1,4 @@
import 'dart:io';
/// Windows / Linux 桌面端,与安卓 UI 完全分离。
bool get isDesktopPlatform => Platform.isWindows || Platform.isLinux;
+369
View File
@@ -0,0 +1,369 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../device/push_bridge.dart';
import '../nav/notice_route.dart';
import '../ota/updater.dart';
import '../pages/call_page.dart';
import '../session/session.dart';
import '../im/im_lifecycle.dart';
import 'desk_platform.dart';
import 'desk_theme.dart';
import 'pages/desk_chat_page.dart';
import 'pages/desk_contacts_page.dart';
import 'pages/desk_conv_list_page.dart';
import 'pages/desk_profile_page.dart';
import 'pages/desk_system_notice_page.dart';
import 'pages/desk_workbench_page.dart';
import 'widgets/desk_widgets.dart';
/// 桌面端主壳:仅引用 lib/desktop/*,不加载任何移动端页面组件。
class DeskShell extends StatefulWidget {
const DeskShell({super.key, required this.session, required this.api});
final SessionStore session;
final OaClient api;
@override
State<DeskShell> createState() => _DeskShellState();
}
class _DeskShellState extends State<DeskShell> {
int _nav = 0;
int _msgBadge = 0;
Timer? _badgeDebounce;
Timer? _callPoll;
String _ringingId = '';
bool _checkingCall = false;
final _declinedCalls = <String>{};
ImLifecycle? _imLife;
String? _chatConvId;
String? _chatPeerId;
String _chatPeerName = '';
bool _chatGroup = false;
String _chatAvatar = '';
final List<_DeskTab> _tabs = [_DeskTab.home()];
int _activeTab = 0;
bool _otaOnce = false;
@override
void initState() {
super.initState();
_imLife = ImLifecycle(widget.session)..start();
widget.session.im.onLoggedIn = () => _refreshBadge();
widget.session.im.addListener(_onIm);
_refreshBadge();
PushBridge.listen(_openFromPush);
_callPoll = Timer.periodic(const Duration(seconds: 8), (_) => _checkIncoming());
WidgetsBinding.instance.addPostFrameCallback((_) => _checkOta());
}
Future<void> _checkOta() async {
if (_otaOnce) return;
_otaOnce = true;
final rel = await OtaUpdater(widget.api).fetch();
if (rel != null && rel.newer && mounted) {
await OtaUpdater(widget.api).prompt(context, rel);
}
}
@override
void dispose() {
_badgeDebounce?.cancel();
_callPoll?.cancel();
widget.session.im.onLoggedIn = null;
_imLife?.stop();
widget.session.im.removeListener(_onIm);
PushBridge.stop();
super.dispose();
}
void _onIm() {
final inbox = widget.session.im.inbox;
if (inbox.isNotEmpty && inbox.first.kind == 'readSync') {
_refreshBadge();
return;
}
_badgeDebounce?.cancel();
_badgeDebounce = Timer(const Duration(milliseconds: 1200), _refreshBadge);
final hit = widget.session.im.inbox.where((e) => e.kind == 'call').toList();
if (hit.isNotEmpty) _checkIncoming();
}
Future<void> _refreshBadge() async {
var n = 0;
try {
final data = await widget.api.get('/im/conversations');
for (final r in asMaps(data)) {
n += (r['unread'] as num?)?.toInt() ?? 0;
}
} catch (_) {}
if (mounted && n != _msgBadge) setState(() => _msgBadge = n);
}
Future<void> _checkIncoming() async {
if (!mounted || _ringingId.isNotEmpty || _checkingCall) return;
_checkingCall = true;
try {
final rows = asMaps(await widget.api.get('/im/calls/incoming'));
final me = widget.session.userId;
Map<String, dynamic>? ring;
for (final r in rows) {
if ('${r['initiatorId']}' != me && '${r['status']}' == 'ringing') {
ring = r;
break;
}
}
if (ring == null) return;
final id = '${ring['id'] ?? ''}';
if (id.isEmpty || id == _ringingId || _declinedCalls.contains(id)) return;
final created = DateTime.tryParse('${ring['createdAt'] ?? ''}');
if (created != null && DateTime.now().difference(created).inSeconds > 45) {
_declinedCalls.add(id);
return;
}
await _showIncoming(ring);
} catch (_) {
} finally {
_checkingCall = false;
}
}
Future<void> _showIncoming(Map<String, dynamic> call, {String fallbackName = '同事'}) async {
final id = '${call['id'] ?? ''}';
if (!mounted || id.isEmpty || _ringingId.isNotEmpty || _declinedCalls.contains(id)) return;
_ringingId = id;
await openCallPage(
context,
session: widget.session,
api: widget.api,
call: call,
peerName: '${call['fromName'] ?? call['title'] ?? fallbackName}',
incoming: true,
);
_declinedCalls.add(id);
if (_declinedCalls.length > 40) _declinedCalls.remove(_declinedCalls.first);
if (mounted) _ringingId = '';
}
String _openedPush = '';
void _openFromPush(Map<String, String> extras) {
if (extras['kind'] == 'qr-login') {
// 桌面端已登录,扫码登录是手机扫电脑二维码,不应在桌面打开扫码页
return;
}
if (extras['kind'] == 'call') {
unawaited(_openIncomingCallFromPush(extras));
return;
}
final cid = extras['conversationId'] ?? '';
final kind = extras['kind'] ?? '';
final key = '$cid|$kind|${extras['callId'] ?? extras['bizId'] ?? ''}';
if (key == _openedPush) return;
_openedPush = key;
if (kind == 'todo' || kind == 'approval' || ((extras['bizType'] ?? '').isNotEmpty && kind != 'chat')) {
openNoticeTarget(
context,
widget.api,
session: widget.session,
kind: kind,
bizType: extras['bizType'] ?? '',
bizId: extras['bizId'] ?? '',
title: extras['title'] ?? '系统通知',
);
return;
}
if (cid.isEmpty) {
setState(() => _nav = 0);
return;
}
_pickChat({
'id': cid,
'peerId': extras['peerId'] ?? '',
'name': extras['fromName'] ?? extras['title'] ?? '聊天',
'type': extras['isGroup'] == '1' ? 'group' : 'direct',
'avatarFileId': extras['avatarFileId'] ?? '',
});
}
Future<void> _openIncomingCallFromPush(Map<String, String> extras) async {
final callId = (extras['callId'] ?? '').isNotEmpty ? extras['callId']! : (extras['bizId'] ?? '');
try {
Map<String, dynamic>? call;
if (callId.isNotEmpty) {
final raw = await widget.api.get('/im/calls/$callId');
if (raw is Map) {
final row = Map<String, dynamic>.from(raw);
if ('${row['status'] ?? ''}' == 'ringing' && '${row['memberStatus'] ?? ''}' == 'invited') call = row;
}
}
if (call != null && mounted) {
call['fromName'] ??= extras['fromName'];
await _showIncoming(call, fallbackName: extras['fromName'] ?? '同事');
return;
}
} catch (_) {}
final cid = extras['conversationId'] ?? '';
if (mounted && cid.isNotEmpty) {
_pickChat({
'id': cid,
'peerId': extras['peerId'] ?? '',
'name': extras['fromName'] ?? extras['title'] ?? '聊天',
});
}
}
void _pickChat(Map<String, dynamic> row) {
setState(() {
_nav = 0;
_chatConvId = '${row['id'] ?? row['conversationId'] ?? ''}';
_chatPeerId = '${row['peerId'] ?? row['id'] ?? ''}';
_chatPeerName = '${row['name'] ?? row['peerName'] ?? '同事'}';
_chatGroup = row['type'] == 'group';
_chatAvatar = '${row['avatarFileId'] ?? ''}';
});
}
void _openTab(String key, String title, IconData icon, Color color, Widget page) {
final idx = _tabs.indexWhere((t) => t.key == key);
setState(() {
_nav = 1;
if (idx >= 0) {
_activeTab = idx;
} else {
_tabs.add(_DeskTab(key: key, title: title, icon: icon, color: color, page: page));
_activeTab = _tabs.length - 1;
}
});
}
void _closeTab(int i) {
if (i <= 0 || i >= _tabs.length) return;
setState(() {
_tabs.removeAt(i);
if (_activeTab >= _tabs.length) _activeTab = _tabs.length - 1;
});
}
Widget _messagesPane() {
return Row(
children: [
SizedBox(
width: kDeskConvWidth,
child: DeskConvListPage(
session: widget.session,
api: widget.api,
selectedId: _chatConvId,
onSelect: _pickChat,
),
),
const VerticalDivider(width: 1, color: kDeskLine),
Expanded(
child: _chatConvId == null || _chatConvId!.isEmpty
? const DeskEmptyChat()
: _chatPeerId == '0'
? DeskSystemNoticePage(session: widget.session, api: widget.api, conversationId: _chatConvId!)
: DeskChatPage(
session: widget.session,
api: widget.api,
conversationId: _chatConvId!,
peerId: _chatPeerId ?? '',
peerName: _chatPeerName,
isGroup: _chatGroup,
peerAvatarFileId: _chatAvatar,
),
),
],
);
}
Widget _workbenchPane() {
return Column(
children: [
DeskTabStrip(
tabs: [for (final t in _tabs) (t.key, t.title, t.icon, t.color)],
active: _activeTab,
onSelect: (i) => setState(() => _activeTab = i),
onClose: _closeTab,
),
Expanded(
child: IndexedStack(
index: _activeTab,
children: [
for (final t in _tabs)
t.key == 'home'
? DeskWorkbenchPage(session: widget.session, api: widget.api, onOpenTab: _openTab)
: t.page,
],
),
),
],
);
}
@override
Widget build(BuildContext context) {
final navItems = <(IconData, IconData, String, int)>[
(Icons.chat_bubble_outline, Icons.chat_bubble, '消息', _msgBadge),
(Icons.apps_outlined, Icons.apps, '工作台', 0),
(Icons.account_tree_outlined, Icons.account_tree, '通讯录', 0),
(Icons.person_outline, Icons.person, '我', 0),
];
Widget body;
switch (_nav) {
case 0:
body = _messagesPane();
case 1:
body = _workbenchPane();
case 2:
body = DeskContactsPage(session: widget.session, api: widget.api, onMessage: (r) {
_pickChat({
'id': '${r['conversationId'] ?? ''}',
'peerId': '${r['id'] ?? r['peerId'] ?? ''}',
'name': '${r['name'] ?? '同事'}',
'avatarFileId': '${r['avatarFileId'] ?? ''}',
'type': 'direct',
});
setState(() => _nav = 0);
});
default:
body = DeskProfilePage(session: widget.session, api: widget.api);
}
return Scaffold(
backgroundColor: kDeskBg,
body: Row(
children: [
DeskNavRail(
session: widget.session,
api: widget.api,
index: _nav,
onChanged: (i) => setState(() => _nav = i),
items: navItems,
),
const VerticalDivider(width: 1, color: kDeskLine),
Expanded(child: body),
],
),
);
}
}
class _DeskTab {
_DeskTab({required this.key, required this.title, required this.icon, required this.color, required this.page});
factory _DeskTab.home() => _DeskTab(key: 'home', title: '工作台', icon: Icons.apps, color: kDeskBind, page: const SizedBox.shrink());
final String key;
final String title;
final IconData icon;
final Color color;
final Widget page;
}
bool get isDesktopShell => isDesktopPlatform;
+47
View File
@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
/// 企业微信桌面端配色与尺寸,不依赖移动端 theme。
const kDeskBg = Color(0xFFEDEDED);
const kDeskRail = Color(0xFFE7E7E7);
const kDeskPane = Colors.white;
const kDeskListBg = Color(0xFFF7F7F7);
const kDeskActive = Color(0xFFDCEBFF);
const kDeskLine = Color(0xFFE3E3E3);
const kDeskInk = Color(0xFF191919);
const kDeskMute = Color(0xFF8E8E8E);
const kDeskBind = Color(0xFF267EF0);
const kDeskGreen = Color(0xFF07C160);
const kDeskBubbleMine = Color(0xFF95EC69);
const kDeskRailWidth = 68.0;
const kDeskConvWidth = 280.0;
ThemeData buildDesktopTheme() {
return ThemeData(
useMaterial3: false,
scaffoldBackgroundColor: kDeskBg,
fontFamily: 'sans-serif',
colorScheme: ColorScheme.fromSeed(seedColor: kDeskBind, brightness: Brightness.light),
dividerColor: kDeskLine,
textTheme: const TextTheme(
bodyMedium: TextStyle(fontSize: 14, color: kDeskInk, height: 1.4),
titleMedium: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: kDeskInk),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: const Color(0xFFF3F3F3),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(6), borderSide: BorderSide.none),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(6), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(color: kDeskBind, width: 1),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
hintStyle: const TextStyle(color: Color(0xFFB2B2B2), fontSize: 14),
),
dataTableTheme: const DataTableThemeData(
headingTextStyle: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: kDeskMute),
dataTextStyle: TextStyle(fontSize: 14, color: kDeskInk),
),
);
}
@@ -0,0 +1,238 @@
import 'package:flutter/material.dart';
import '../../api/oa_client.dart';
import '../../device/device_bridge.dart';
import '../desk_theme.dart';
import '../widgets/desk_widgets.dart';
class DeskAttendancePage extends StatefulWidget {
const DeskAttendancePage({super.key, required this.api});
final OaClient api;
@override
State<DeskAttendancePage> createState() => _DeskAttendancePageState();
}
class _DeskAttendancePageState extends State<DeskAttendancePage> {
Map<String, dynamic> _status = {};
Map<String, double>? _location;
bool _loading = true;
bool _sending = false;
String _mode = 'OFFICE';
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() => _loading = true);
try {
_location = await DeviceBridge.getLocation();
final q = <String, String>{
if (_location != null) 'lat': '${_location!['lat']}',
if (_location != null) 'lng': '${_location!['lng']}',
'mode': _mode,
};
final data = Map<String, dynamic>.from(await widget.api.get('/attendance/punch-status', query: q) as Map);
if (mounted) setState(() {
_status = data;
_loading = false;
});
if (mounted && _location == null && _mode == 'OFFICE') {
deskToast(context, '未获取到定位,请允许位置权限后点刷新', error: true);
}
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _punch(String kind) async {
if (_mode == 'OFFICE' && _status['tripActive'] == true) return;
if (_mode == 'FIELD' && _status['fieldApproved'] != true) return;
setState(() => _sending = true);
try {
await widget.api.post('/attendance/punch', {'kind': kind, 'mode': _mode, if (_location != null) ..._location!});
if (mounted) deskToast(context, '打卡成功');
await _load();
} catch (e) {
if (mounted) deskToast(context, '$e', error: true);
} finally {
if (mounted) setState(() => _sending = false);
}
}
String _time(dynamic value) {
if (value == null || '$value' == 'null' || '$value'.isEmpty) return '未打卡';
final d = DateTime.tryParse('$value')?.toLocal();
return d == null ? '$value' : '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
final inKey = _mode == 'FIELD' ? 'fieldIn' : 'clockIn';
final outKey = _mode == 'FIELD' ? 'fieldOut' : 'clockOut';
final inAt = _time(_status[inKey]);
final outAt = _time(_status[outKey]);
final hasIn = inAt != '未打卡';
final hasOut = outAt != '未打卡';
final trip = _status['tripActive'] == true;
final fieldOk = _status['fieldApproved'] == true;
final canPunch = _mode == 'FIELD' ? fieldOk : !trip && _status['inRange'] != false;
return ColoredBox(
color: kDeskBg,
child: Column(
children: [
DeskPaneHeader(
title: '考勤打卡',
actions: [IconButton(onPressed: _load, icon: const Icon(Icons.refresh, size: 20))],
),
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
Expanded(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
SegmentedButton<String>(
segments: const [
ButtonSegment(value: 'OFFICE', label: Text('上下班打卡'), icon: Icon(Icons.access_time)),
ButtonSegment(value: 'FIELD', label: Text('外勤打卡'), icon: Icon(Icons.location_on_outlined)),
],
selected: {_mode},
onSelectionChanged: (v) async {
setState(() => _mode = v.first);
await _load();
},
),
const SizedBox(height: 20),
Material(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
Text(_today(), style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700)),
const SizedBox(height: 12),
_banner(canPunch, trip, fieldOk),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_punchBtn(
enabled: canPunch && !hasIn && !_sending,
label: _mode == 'FIELD' ? '外勤上班' : '上班打卡',
onTap: () => _punch('IN'),
),
const SizedBox(width: 16),
_punchBtn(
enabled: canPunch && hasIn && !hasOut && !_sending,
label: '下班打卡',
color: kDeskGreen,
onTap: () => _punch('OUT'),
),
],
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_timeBox('上班', inAt, _status['late'] == true),
_timeBox('下班', outAt, _status['early'] == true),
],
),
],
),
),
),
const SizedBox(height: 12),
Material(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
child: ListTile(
leading: Icon(Icons.place, color: _status['inRange'] == true ? kDeskGreen : const Color(0xFFFA5151)),
title: Text('${_status['rangeText'] ?? '正在获取考勤范围'}'),
subtitle: Text(_status['distanceMeters'] == null ? '请开启定位后刷新' : '距离考勤点约 ${_status['distanceMeters']} 米'),
),
),
],
),
),
),
),
),
],
),
);
}
String _today() {
final d = DateTime.now();
return '${d.year}年${d.month}月${d.day}日';
}
Widget _banner(bool canPunch, bool trip, bool fieldOk) {
final text = _mode == 'FIELD'
? (fieldOk ? '外出申请已通过,可进行外勤打卡' : '需先申请并审批通过外出')
: (trip ? '出差期间无需打卡' : (canPunch ? '当前可进行上下班打卡' : '未进入考勤范围'));
final color = trip || fieldOk || canPunch ? kDeskGreen : const Color(0xFFFA9D3B);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(color: color.withValues(alpha: .1), borderRadius: BorderRadius.circular(8)),
child: Text(text, textAlign: TextAlign.center, style: TextStyle(color: color, fontWeight: FontWeight.w600, fontSize: 13)),
);
}
Widget _punchBtn({required bool enabled, required String label, required VoidCallback onTap, Color? color}) {
final c = color ?? kDeskBind;
return SizedBox(
width: 140,
height: 140,
child: Material(
color: enabled ? c : const Color(0xFFE8E8E8),
shape: const CircleBorder(),
child: InkWell(
onTap: enabled ? onTap : null,
customBorder: const CircleBorder(),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: TextStyle(color: enabled ? Colors.white : kDeskMute, fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Text(_nowTime(), style: TextStyle(color: enabled ? Colors.white : kDeskMute, fontSize: 18, fontWeight: FontWeight.w700)),
],
),
),
),
),
);
}
Widget _timeBox(String label, String value, bool flag) {
return Column(
children: [
Text(label, style: const TextStyle(color: kDeskMute, fontSize: 12)),
const SizedBox(height: 4),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(value, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
if (flag) const Padding(padding: EdgeInsets.only(left: 4), child: Icon(Icons.error, color: Color(0xFFFA9D3B), size: 16)),
],
),
],
);
}
String _nowTime() {
final d = DateTime.now();
return '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
}
}
@@ -0,0 +1,233 @@
import 'package:flutter/material.dart';
import '../../api/oa_client.dart';
import '../../im/chat_prefs.dart';
import '../../session/session.dart';
import '../desk_theme.dart';
import '../widgets/desk_widgets.dart';
import 'desk_pick_people_page.dart';
class DeskChatDetailPage extends StatefulWidget {
const DeskChatDetailPage({
super.key,
required this.session,
required this.api,
required this.conversationId,
required this.peerId,
required this.peerName,
required this.isGroup,
this.peerAvatarFileId,
this.onCall,
});
final SessionStore session;
final OaClient api;
final String conversationId;
final String peerId;
final String peerName;
final bool isGroup;
final String? peerAvatarFileId;
final void Function(String kind)? onCall;
@override
State<DeskChatDetailPage> createState() => _DeskChatDetailPageState();
}
class _DeskChatDetailPageState extends State<DeskChatDetailPage> {
List<Map<String, dynamic>> _members = [];
bool _mute = false;
bool _pin = false;
@override
void initState() {
super.initState();
_mute = ChatPrefs.muted(widget.conversationId);
_pin = ChatPrefs.pinned(widget.conversationId);
_loadMembers();
}
Future<void> _loadMembers() async {
if (widget.isGroup && widget.conversationId.isNotEmpty) {
try {
final data = await widget.api.get('/im/groups/${widget.conversationId}/members');
if (!mounted) return;
setState(() => _members = asMaps(data));
return;
} catch (_) {}
}
setState(() {
_members = [
{'id': widget.peerId, 'name': widget.peerName, 'avatarFileId': widget.peerAvatarFileId},
{'id': widget.session.userId, 'name': widget.session.displayName, 'avatarFileId': widget.session.user['avatarFileId']},
];
});
}
Future<void> _addMembers() async {
final exclude = {widget.session.userId, ..._members.map((e) => '${e['id'] ?? e['userId'] ?? ''}')};
final picked = await Navigator.of(context).push<List<Map<String, dynamic>>>(
MaterialPageRoute(builder: (_) => DeskPickPeoplePage(api: widget.api, title: '选择同事', exclude: exclude)),
);
if (picked == null || picked.isEmpty) return;
try {
if (widget.isGroup && widget.conversationId.isNotEmpty) {
await widget.api.post('/im/groups/${widget.conversationId}/members', {
'memberIds': picked.map((e) => '${e['id']}').toList(),
});
await _loadMembers();
} else {
await widget.api.post('/im/groups', {
'name': '${widget.peerName}的群聊',
'memberIds': [widget.peerId, ...picked.map((e) => '${e['id']}')],
});
if (mounted) {
deskToast(context, '已创建群聊');
Navigator.pop(context);
}
}
} catch (e) {
if (mounted) deskToast(context, '$e', error: true);
}
}
Future<void> _clear() async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('删除聊天记录'),
content: const Text('将从本机清空当前会话的聊天记录,对方不受影响。'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('删除')),
],
),
);
if (ok != true) return;
await ChatPrefs.clearHistory(widget.conversationId);
if (mounted) {
deskToast(context, '已删除本机聊天记录');
Navigator.pop(context, 'cleared');
}
}
@override
Widget build(BuildContext context) {
return ColoredBox(
color: kDeskBg,
child: Column(
children: [
DeskPaneHeader(
title: '聊天详情',
leading: IconButton(icon: const Icon(Icons.arrow_back, size: 20), onPressed: () => Navigator.pop(context)),
),
Expanded(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: ListView(
padding: const EdgeInsets.all(20),
children: [
Material(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Wrap(
spacing: 16,
runSpacing: 12,
children: [
for (final m in _members)
SizedBox(
width: 56,
child: Column(
children: [
DeskAvatar(
label: '${m['name'] ?? m['displayName'] ?? ''}',
fileId: '${m['avatarFileId'] ?? ''}',
api: widget.api,
size: 44,
),
const SizedBox(height: 4),
Text('${m['name'] ?? m['displayName'] ?? ''}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11)),
],
),
),
InkWell(
onTap: _addMembers,
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(6), border: Border.all(color: kDeskLine)),
child: const Icon(Icons.add, color: kDeskMute),
),
),
],
),
),
),
const SizedBox(height: 12),
_section([
_switchRow('消息免打扰', _mute, (v) async {
await ChatPrefs.setMuted(widget.conversationId, v);
setState(() => _mute = v);
}),
_switchRow('置顶聊天', _pin, (v) async {
await ChatPrefs.setPinned(widget.conversationId, v);
setState(() => _pin = v);
}),
]),
const SizedBox(height: 12),
_section([
_actionRow('语音通话', () => widget.onCall?.call('voice')),
_actionRow('视频通话', () => widget.onCall?.call(widget.isGroup ? 'meeting' : 'video')),
_actionRow('删除聊天记录', _clear, danger: true),
]),
],
),
),
),
),
],
),
);
}
Widget _section(List<Widget> children) {
return Material(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
clipBehavior: Clip.antiAlias,
child: Column(children: children),
);
}
Widget _switchRow(String title, bool value, ValueChanged<bool> onChanged) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
Expanded(child: Text(title, style: const TextStyle(fontSize: 14))),
Switch(value: value, activeTrackColor: kDeskGreen, onChanged: onChanged),
],
),
);
}
Widget _actionRow(String title, VoidCallback onTap, {bool danger = false}) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Expanded(child: Text(title, style: TextStyle(fontSize: 14, color: danger ? const Color(0xFFFA5151) : kDeskInk))),
if (!danger) const Icon(Icons.chevron_right, size: 18, color: kDeskMute),
],
),
),
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,279 @@
import 'package:flutter/material.dart';
import '../../api/oa_client.dart';
import '../../im/pinyin.dart';
import 'desk_org_browse_page.dart';
import '../../session/session.dart';
import '../desk_theme.dart';
import '../widgets/desk_widgets.dart';
class DeskContactsPage extends StatefulWidget {
const DeskContactsPage({super.key, required this.session, required this.api, this.onMessage});
final SessionStore session;
final OaClient api;
final void Function(Map<String, dynamic> person)? onMessage;
@override
State<DeskContactsPage> createState() => _DeskContactsPageState();
}
class _DeskContactsPageState extends State<DeskContactsPage> {
List<Map<String, dynamic>> _items = [];
Map<String, List<String>> _presence = {};
String _q = '';
bool _loading = true;
String? _selectedId;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final data = await widget.api.get('/staff');
Map<String, List<String>> presence = {};
try {
final ids = asMaps(data).map((e) => '${e['id']}').where((e) => e.isNotEmpty).join(',');
final raw = await widget.api.get('/im/presence', query: {'userIds': ids});
if (raw is Map) {
presence = raw.map((k, v) => MapEntry('$k', (v is List ? v : const []).map((x) => '$x').toList()));
}
} catch (_) {}
if (!mounted) return;
setState(() {
_items = asMaps(data);
_presence = presence;
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
List<Map<String, dynamic>> get _shown {
if (_q.isEmpty) return _items;
return _items.where((e) => '${e['name']}${e['mobile']}${e['department']}'.contains(_q)).toList();
}
Map<String, List<Map<String, dynamic>>> get _groups {
final g = <String, List<Map<String, dynamic>>>{};
for (final r in _shown) {
final letter = letterOf('${r['name'] ?? ''}');
g.putIfAbsent(letter, () => []).add(r);
}
for (final k in g.keys) {
g[k]!.sort((a, b) => '${a['name']}'.compareTo('${b['name']}'));
}
return g;
}
Future<void> _message(Map<String, dynamic> r) async {
if (widget.onMessage != null) {
widget.onMessage!(r);
return;
}
String conversationId = '';
try {
final raw = await widget.api.post('/im/conversations/direct', {'peerId': '${r['id']}'});
if (raw is Map) conversationId = '${raw['conversationId'] ?? raw['id'] ?? ''}';
} catch (_) {}
if (!mounted) return;
widget.onMessage?.call({
...r,
'conversationId': conversationId,
'peerId': '${r['id']}',
'name': '${r['name'] ?? '同事'}',
'type': 'direct',
});
}
@override
Widget build(BuildContext context) {
final groups = _groups;
final letters = groups.keys.toList()..sort();
final depts = _items.map((e) => '${e['department'] ?? ''}').where((e) => e.isNotEmpty).toSet();
final selected = _selectedId == null ? null : _items.cast<Map<String, dynamic>?>().firstWhere(
(e) => '${e?['id']}' == _selectedId,
orElse: () => null,
);
return ColoredBox(
color: kDeskBg,
child: Row(
children: [
SizedBox(
width: 300,
child: ColoredBox(
color: kDeskListBg,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
child: DeskSearchField(hint: '搜索同事', onChanged: (v) => setState(() => _q = v.trim())),
),
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
Expanded(
child: ListView(
children: [
if (_q.isEmpty) ...[
_entry(Icons.account_tree, '组织架构', '${depts.length} 个部门', () {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => DeskOrgBrowsePage(
session: widget.session,
api: widget.api,
staff: _items,
onMessage: widget.onMessage,
),
));
}),
const Divider(height: 1, color: kDeskLine),
],
for (final letter in letters) ...[
Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 4),
child: Text(letter, style: const TextStyle(fontSize: 12, color: kDeskMute, fontWeight: FontWeight.w600)),
),
for (final r in groups[letter]!)
_personTile(r),
],
],
),
),
],
),
),
),
const VerticalDivider(width: 1, color: kDeskLine),
Expanded(
child: selected == null
? const Center(child: Text('选择联系人查看详情', style: TextStyle(color: kDeskMute)))
: _detail(selected),
),
],
),
);
}
Widget _entry(IconData icon, String title, String sub, VoidCallback onTap) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
child: Row(
children: [
DeskAvatar(label: title, api: widget.api, size: 40, color: kDeskBind, icon: icon),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
Text(sub, style: const TextStyle(fontSize: 12, color: kDeskMute)),
],
),
),
],
),
),
),
);
}
Widget _personTile(Map<String, dynamic> r) {
final id = '${r['id']}';
final online = (_presence[id] ?? const []).contains('online');
return Material(
color: _selectedId == id ? kDeskActive : Colors.transparent,
child: InkWell(
onTap: () => setState(() => _selectedId = id),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
child: Row(
children: [
DeskAvatar(label: '${r['name']}', fileId: '${r['avatarFileId'] ?? ''}', api: widget.api, size: 36),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${r['name']}', style: const TextStyle(fontSize: 14)),
Text(
[r['department'], r['title']].where((e) => e != null && '$e'.isNotEmpty).join(' · '),
style: const TextStyle(fontSize: 12, color: kDeskMute),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: online ? kDeskGreen : const Color(0xFFD0D0D0),
shape: BoxShape.circle,
),
),
],
),
),
),
);
}
Widget _detail(Map<String, dynamic> r) {
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
DeskAvatar(label: '${r['name']}', fileId: '${r['avatarFileId'] ?? ''}', api: widget.api, size: 80),
const SizedBox(height: 16),
Text('${r['name']}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)),
const SizedBox(height: 6),
Text(
[r['department'], r['title']].where((e) => e != null && '$e'.isNotEmpty).join(' · '),
style: const TextStyle(color: kDeskMute),
),
const SizedBox(height: 24),
_infoRow('手机', '${r['mobile'] ?? '未填写'}'),
_infoRow('邮箱', '${r['email'] ?? '未填写'}'),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
style: FilledButton.styleFrom(backgroundColor: kDeskBind, minimumSize: const Size.fromHeight(40)),
onPressed: () => _message(r),
child: const Text('发消息'),
),
),
],
),
),
),
);
}
Widget _infoRow(String k, String v) {
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8), border: Border.all(color: kDeskLine)),
child: Row(
children: [
SizedBox(width: 56, child: Text(k, style: const TextStyle(color: kDeskMute, fontSize: 13))),
Expanded(child: Text(v, style: const TextStyle(fontSize: 14))),
],
),
);
}
}
@@ -0,0 +1,256 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../api/oa_client.dart';
import '../../im/chat_prefs.dart';
import 'desk_pick_people_page.dart';
import '../../session/session.dart';
import '../desk_theme.dart';
import '../widgets/desk_widgets.dart';
class DeskConvListPage extends StatefulWidget {
const DeskConvListPage({
super.key,
required this.session,
required this.api,
this.selectedId,
required this.onSelect,
});
final SessionStore session;
final OaClient api;
final String? selectedId;
final void Function(Map<String, dynamic> row) onSelect;
@override
State<DeskConvListPage> createState() => _DeskConvListPageState();
}
class _DeskConvListPageState extends State<DeskConvListPage> {
List<Map<String, dynamic>> _items = [];
bool _loading = true;
String _filter = 'all';
String _q = '';
Timer? _debounce;
@override
void initState() {
super.initState();
widget.session.im.addListener(_onIm);
ChatPrefs.ensure().then((_) {
if (mounted) setState(() {});
});
_load();
}
@override
void dispose() {
_debounce?.cancel();
widget.session.im.removeListener(_onIm);
super.dispose();
}
void _onIm() {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 300), () {
if (mounted) _load();
});
}
Future<void> _load() async {
try {
final data = await widget.api.get('/im/conversations');
if (!mounted) return;
setState(() {
_items = asMaps(data);
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
List<Map<String, dynamic>> get _shown {
var list = _items.where((e) => !ChatPrefs.hidden('${e['id'] ?? ''}')).toList();
if (_filter == 'unread') list = list.where((e) => ((e['unread'] as num?)?.toInt() ?? 0) > 0).toList();
if (_filter == 'dm') list = list.where((e) => e['type'] != 'group').toList();
if (_filter == 'group') list = list.where((e) => e['type'] == 'group').toList();
if (_q.isNotEmpty) {
list = list.where((e) => '${e['name']}${e['peerName']}${e['lastText']}'.contains(_q)).toList();
}
list.sort((a, b) {
final ap = ChatPrefs.pinned('${a['id'] ?? ''}');
final bp = ChatPrefs.pinned('${b['id'] ?? ''}');
if (ap == bp) return 0;
return ap ? -1 : 1;
});
return list;
}
Future<void> _menu(Map<String, dynamic> r, Offset pos) async {
final id = '${r['id'] ?? ''}';
if (id.isEmpty) return;
final muted = ChatPrefs.muted(id);
final pinned = ChatPrefs.pinned(id);
final unread = (r['unread'] as num?)?.toInt() ?? 0;
final fake = ChatPrefs.fakeUnread(id);
final selected = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(pos.dx, pos.dy, pos.dx + 1, pos.dy + 1),
items: [
if (unread == 0 && fake == 0) const PopupMenuItem(value: 'unread', child: Text('标为未读')),
PopupMenuItem(value: 'mute', child: Text(muted ? '取消免打扰' : '消息免打扰')),
PopupMenuItem(value: 'pin', child: Text(pinned ? '取消置顶' : '置顶')),
const PopupMenuItem(value: 'hide', child: Text('不显示')),
const PopupMenuDivider(),
const PopupMenuItem(value: 'clear', child: Text('清空聊天记录')),
const PopupMenuItem(value: 'delete', child: Text('删除')),
],
);
if (!mounted || selected == null) return;
switch (selected) {
case 'unread':
await ChatPrefs.setFakeUnread(id, 1);
case 'mute':
await ChatPrefs.setMuted(id, !muted);
case 'pin':
await ChatPrefs.setPinned(id, !pinned);
case 'hide':
case 'delete':
await ChatPrefs.setHidden(id, true);
case 'clear':
await ChatPrefs.clearHistory(id);
}
if (mounted) {
setState(() {});
await _load();
}
}
Future<void> _newChat({required bool group}) async {
final picked = await Navigator.of(context).push<List<Map<String, dynamic>>>(
MaterialPageRoute(
builder: (_) => DeskPickPeoplePage(
api: widget.api,
title: group ? '选择联系人' : '发起单聊',
multiple: group,
exclude: {widget.session.userId},
),
),
);
if (picked == null || picked.isEmpty) return;
if (!group) {
final p = picked.first;
widget.onSelect({
'id': '',
'peerId': '${p['id']}',
'name': '${p['name'] ?? '同事'}',
'avatarFileId': '${p['avatarFileId'] ?? ''}',
'type': 'direct',
});
return;
}
final name = TextEditingController();
if (!mounted) return;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('群名称'),
content: TextField(controller: name, decoration: const InputDecoration(hintText: '例如:项目组')),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('创建')),
],
),
);
if (ok == true) {
await widget.api.post('/im/groups', {
'name': name.text.trim().isEmpty ? '群聊' : name.text.trim(),
'memberIds': picked.map((e) => '${e['id']}').toList(),
});
await _load();
}
}
void _plusMenu(BuildContext ctx) {
final box = ctx.findRenderObject() as RenderBox?;
final pos = box?.localToGlobal(Offset.zero) ?? Offset.zero;
showMenu<void>(
context: context,
position: RelativeRect.fromLTRB(pos.dx, pos.dy + 32, pos.dx + 200, pos.dy),
items: [
PopupMenuItem(onTap: () => Future.microtask(() => _newChat(group: false)), child: const Text('发起单聊')),
PopupMenuItem(onTap: () => Future.microtask(() => _newChat(group: true)), child: const Text('发起群聊')),
],
);
}
@override
Widget build(BuildContext context) {
final shown = _shown;
return ColoredBox(
color: kDeskListBg,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
child: Row(
children: [
Expanded(child: DeskSearchField(hint: '搜索', onChanged: (v) => setState(() => _q = v.trim()))),
const SizedBox(width: 8),
Material(
color: const Color(0xFFEFEFEF),
borderRadius: BorderRadius.circular(6),
child: InkWell(
onTap: () {
final rb = context.findRenderObject() as RenderBox?;
if (rb != null) _plusMenu(context);
},
borderRadius: BorderRadius.circular(6),
child: const SizedBox(width: 32, height: 32, child: Icon(Icons.add, size: 18, color: kDeskInk)),
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: DeskFilterChips(
value: _filter,
onChanged: (v) => setState(() => _filter = v),
items: const [('all', '全部'), ('unread', '未读'), ('dm', '单聊'), ('group', '群聊')],
),
),
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
Expanded(
child: shown.isEmpty
? const Center(child: Text('暂无会话', style: TextStyle(color: kDeskMute)))
: ListView.builder(
itemCount: shown.length,
itemBuilder: (_, i) {
final r = shown[i];
final id = '${r['id'] ?? ''}';
final fake = ChatPrefs.fakeUnread(id);
final unread = fake > 0 ? fake : ((r['unread'] as num?)?.toInt() ?? 0);
return DeskConvRow(
name: '${r['name'] ?? r['peerName'] ?? '会话'}',
preview: '${r['lastText'] ?? ''}',
time: shortTime(r['lastAt'] ?? r['updatedAt']),
unread: unread,
selected: widget.selectedId == id,
muted: ChatPrefs.muted(id),
pinned: ChatPrefs.pinned(id),
avatarId: '${r['avatarFileId'] ?? ''}',
api: widget.api,
onTap: () => widget.onSelect(r),
onSecondaryTap: (d) => _menu(r, d.globalPosition),
);
},
),
),
],
),
);
}
}
@@ -0,0 +1,169 @@
import 'package:flutter/material.dart';
import '../../api/oa_client.dart';
import '../../device/device_bridge.dart';
import '../../labels.dart';
import '../desk_theme.dart';
import '../widgets/desk_data_table.dart';
import '../widgets/desk_widgets.dart';
import 'desk_record_detail_page.dart';
const _kinds = [
('PERSONAL', '请假'),
('OVERTIME', '加班'),
('BUSINESS', '出差'),
('OUT', '外出'),
('REGULARIZE', '转正'),
('RESIGN', '离职'),
];
class DeskHrApplyPage extends StatefulWidget {
const DeskHrApplyPage({super.key, required this.api});
final OaClient api;
@override
State<DeskHrApplyPage> createState() => _DeskHrApplyPageState();
}
class _DeskHrApplyPageState extends State<DeskHrApplyPage> {
List<Map<String, dynamic>> _items = [];
bool _loading = true;
String _bucket = '';
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() => _loading = true);
try {
final q = _bucket.isEmpty ? null : {'bucket': _bucket};
final data = await widget.api.get('/leave-requests', query: q);
if (!mounted) return;
setState(() {
_items = asMaps(data);
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _create() async {
var kind = 'LEAVE';
final reason = TextEditingController();
final days = TextEditingController(text: '1');
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('发起人事申请'),
content: SizedBox(
width: 420,
child: StatefulBuilder(
builder: (ctx, setSt) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final k in _kinds)
FilterChip(
label: Text(k.$2),
selected: kind == k.$1,
onSelected: (_) => setSt(() => kind = k.$1),
),
],
),
const SizedBox(height: 12),
TextField(controller: days, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: '天数')),
const SizedBox(height: 8),
TextField(controller: reason, maxLines: 3, decoration: const InputDecoration(labelText: '事由')),
],
),
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('提交')),
],
),
);
if (ok != true) return;
try {
final created = Map<String, dynamic>.from(await widget.api.post('/leave-requests', {
'kind': kind,
'reason': reason.text.trim(),
'days': num.tryParse(days.text) ?? 1,
}) as Map);
final id = '${created['id'] ?? ''}';
if (id.isNotEmpty && mounted) {
final add = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('添加申请材料'),
content: const Text('可以上传病假证明、行程单等附件。'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('暂不添加')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('选择附件')),
],
),
);
if (add == true) {
final picked = await DeviceBridge.pickFile();
if (picked != null && picked['path'] != null) {
await widget.api.uploadFile(
filePath: picked['path']!,
filename: picked['name'] ?? '申请材料',
bizType: 'HR_LEAVE',
bizId: id,
mime: picked['mime'],
);
}
}
}
if (mounted) {
deskToast(context, '已提交,等待审批');
_load();
}
} catch (e) {
if (mounted) deskToast(context, '$e', error: true);
}
}
@override
Widget build(BuildContext context) {
return DeskListScaffold(
title: '人事申请',
loading: _loading,
actions: [
FilledButton.icon(
style: FilledButton.styleFrom(backgroundColor: kDeskBind),
onPressed: _create,
icon: const Icon(Icons.add, size: 18),
label: const Text('发起申请'),
),
],
filters: DeskFilterChips(
value: _bucket,
onChanged: (v) {
setState(() => _bucket = v);
_load();
},
items: const [('', '全部'), ('pending', '待审批'), ('approved', '已通过'), ('rejected', '已驳回')],
),
body: DeskDataTable(
columns: const ['类型', '事由', '状态', '时间'],
rows: [
for (final r in _items)
[zh(r['kind']), '${r['reason'] ?? ''}', zh(r['status']), fmtTime(r['createdAt'])],
],
emptyHint: '还没有人事申请',
onRowTap: (i) => deskOpenRecord(context, widget.api, {..._items[i], 'source': 'HR'}),
),
);
}
}
@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import '../../api/oa_client.dart';
import '../desk_theme.dart';
import '../widgets/desk_widgets.dart';
class DeskLegalPage extends StatefulWidget {
const DeskLegalPage({super.key, required this.api, required this.kind});
final OaClient api;
final String kind;
@override
State<DeskLegalPage> createState() => _DeskLegalPageState();
}
class _DeskLegalPageState extends State<DeskLegalPage> {
String _title = '';
List<Map<String, String>> _sections = [];
String _error = '';
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final raw = await widget.api.get('/legal/${widget.kind}');
if (!mounted) return;
if (raw is Map) {
final data = Map<String, dynamic>.from(raw);
final paras = asMaps(data['paragraphs']);
setState(() {
_title = '${data['title'] ?? ''}';
_sections = paras.isNotEmpty
? paras.map((e) => {'title': '${e['heading'] ?? e['title'] ?? ''}', 'body': '${e['body'] ?? ''}'}).toList()
: [{'title': '', 'body': '${data['body'] ?? data['content'] ?? ''}'}];
_loading = false;
});
}
} catch (e) {
if (mounted) setState(() {
_error = '$e';
_loading = false;
});
}
}
@override
Widget build(BuildContext context) {
return ColoredBox(
color: kDeskBg,
child: Column(
children: [
DeskPaneHeader(
title: _title.isEmpty ? (widget.kind == 'privacy' ? '隐私政策' : '用户协议') : _title,
leading: IconButton(icon: const Icon(Icons.arrow_back, size: 20), onPressed: () => Navigator.pop(context)),
),
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
Expanded(
child: _error.isNotEmpty
? Center(child: Text(_error, style: const TextStyle(color: kDeskMute)))
: ListView(
padding: const EdgeInsets.all(24),
children: [
for (final s in _sections) ...[
Text(s['title'] ?? '', style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
Text(s['body'] ?? '', style: const TextStyle(fontSize: 14, height: 1.6, color: kDeskInk)),
const SizedBox(height: 20),
],
],
),
),
],
),
);
}
}
@@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import '../../api/oa_client.dart';
import '../../labels.dart';
import 'desk_record_detail_page.dart';
import '../widgets/desk_data_table.dart';
import '../widgets/desk_widgets.dart';
/// 桌面端办公列表(待办、审批、申请等),表格布局。
class DeskOfficeListPage extends StatefulWidget {
const DeskOfficeListPage({
super.key,
required this.api,
required this.title,
required this.path,
this.query,
this.buckets = const [],
this.defaultBucket = '',
this.columns = const ['标题', '类型', '时间', '状态'],
this.rowBuilder,
});
final OaClient api;
final String title;
final String path;
final Map<String, String>? query;
final List<(String, String)> buckets;
final String defaultBucket;
final List<String> columns;
final List<String> Function(Map<String, dynamic> row)? rowBuilder;
@override
State<DeskOfficeListPage> createState() => _DeskOfficeListPageState();
}
class _DeskOfficeListPageState extends State<DeskOfficeListPage> {
List<Map<String, dynamic>> _items = [];
String _bucket = '';
bool _loading = true;
String _q = '';
@override
void initState() {
super.initState();
_bucket = widget.defaultBucket;
_load();
}
Future<void> _load() async {
setState(() => _loading = true);
try {
final q = <String, String>{...?widget.query};
if (_bucket.isNotEmpty) q['bucket'] = _bucket;
if (_bucket.isNotEmpty && widget.buckets.any((b) => b.$1 == _bucket && b.$1 == 'PENDING')) {
q.remove('bucket');
}
final data = await widget.api.get(widget.path, query: q.isEmpty ? null : q);
if (!mounted) return;
setState(() {
_items = asMaps(data);
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
List<Map<String, dynamic>> get _shown {
var list = _bucket.isEmpty || widget.buckets.isEmpty
? _items
: _items.where((e) {
if (widget.path.contains('approvals')) return '${e['status']}' == _bucket;
if (widget.path.contains('todos')) return '${e['status']}' == _bucket;
return true;
}).toList();
if (_q.isNotEmpty) {
list = list.where((e) => '${e['title']}${e['bizType']}'.contains(_q)).toList();
}
return list;
}
List<String> _row(Map<String, dynamic> r) {
if (widget.rowBuilder != null) return widget.rowBuilder!(r);
return [
'${r['title'] ?? ''}',
zh(r['bizType'] ?? r['type'] ?? ''),
fmtTime(r['dueAt'] ?? r['createdAt'] ?? ''),
zhStatus(r['status']),
];
}
String zhStatus(dynamic s) {
final v = '$s';
const m = {'PENDING': '待处理', 'OPEN': '待办', 'DONE': '已完成', 'APPROVED': '已通过', 'REJECTED': '已驳回'};
return m[v] ?? v;
}
@override
Widget build(BuildContext context) {
final shown = _shown;
return DeskListScaffold(
title: widget.title,
loading: _loading,
actions: [
IconButton(onPressed: _load, icon: const Icon(Icons.refresh, size: 20)),
],
filters: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DeskSearchField(hint: '搜索', onChanged: (v) => setState(() => _q = v.trim())),
if (widget.buckets.isNotEmpty) ...[
const SizedBox(height: 8),
DeskFilterChips(
value: _bucket,
onChanged: (v) {
setState(() => _bucket = v);
_load();
},
items: widget.buckets,
),
],
],
),
body: DeskDataTable(
columns: widget.columns,
rows: [for (final r in shown) _row(r)],
emptyHint: '暂无${widget.title}',
onRowTap: (i) => deskOpenRecord(context, widget.api, shown[i]),
),
);
}
}
@@ -0,0 +1,183 @@
import 'package:flutter/material.dart';
import '../../api/oa_client.dart';
import '../../session/session.dart';
import '../desk_theme.dart';
import '../widgets/desk_widgets.dart';
class DeskOrgBrowsePage extends StatefulWidget {
const DeskOrgBrowsePage({
super.key,
required this.session,
required this.api,
required this.staff,
this.parentId,
this.title = '组织架构',
this.deptName,
this.onMessage,
});
final SessionStore session;
final OaClient api;
final List<Map<String, dynamic>> staff;
final String? parentId;
final String title;
final String? deptName;
final void Function(Map<String, dynamic> person)? onMessage;
@override
State<DeskOrgBrowsePage> createState() => _DeskOrgBrowsePageState();
}
class _DeskOrgBrowsePageState extends State<DeskOrgBrowsePage> {
List<Map<String, dynamic>> _depts = [];
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final data = await widget.api.get('/departments');
if (!mounted) return;
setState(() {
_depts = asMaps(data);
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
List<Map<String, dynamic>> get _children {
if (_depts.isEmpty) return const [];
return _depts.where((e) {
final pid = '${e['parentId'] ?? ''}';
if (widget.parentId == null || widget.parentId!.isEmpty) return pid.isEmpty || pid == 'null';
return pid == widget.parentId;
}).toList();
}
List<Map<String, dynamic>> get _people {
final name = widget.deptName;
if (name == null || name.isEmpty) return const [];
final me = widget.session.userId;
return widget.staff.where((e) => '${e['department']}' == name && '${e['id']}' != me).toList();
}
int _countOf(Map<String, dynamic> d) {
final c = d['_count'];
if (c is Map && c['employees'] != null) return (c['employees'] as num).toInt();
return widget.staff.where((e) => '${e['department']}' == '${d['name']}').length;
}
void _openDept(Map<String, dynamic> d) {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => DeskOrgBrowsePage(
session: widget.session,
api: widget.api,
staff: widget.staff,
parentId: '${d['id']}',
title: '${d['name']}',
deptName: '${d['name']}',
onMessage: widget.onMessage,
),
));
}
Future<void> _message(Map<String, dynamic> r) async {
if (widget.onMessage != null) {
widget.onMessage!(r);
return;
}
String conversationId = '';
try {
final raw = await widget.api.post('/im/conversations/direct', {'peerId': '${r['id']}'});
if (raw is Map) conversationId = '${raw['conversationId'] ?? raw['id'] ?? ''}';
} catch (_) {}
if (mounted) Navigator.pop(context, {...r, 'conversationId': conversationId, 'peerId': '${r['id']}', 'name': '${r['name']}', 'type': 'direct'});
}
@override
Widget build(BuildContext context) {
return ColoredBox(
color: kDeskBg,
child: Column(
children: [
DeskPaneHeader(
title: widget.title,
leading: IconButton(icon: const Icon(Icons.arrow_back, size: 20), onPressed: () => Navigator.pop(context)),
),
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
Expanded(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
for (final d in _children)
Material(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: () => _openDept(d),
borderRadius: BorderRadius.circular(8),
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(14),
child: Row(
children: [
DeskAvatar(label: '${d['name']}', api: widget.api, size: 40, color: kDeskBind, icon: Icons.folder_outlined),
const SizedBox(width: 12),
Expanded(child: Text('${d['name']}', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500))),
Text('${_countOf(d)} 人', style: const TextStyle(fontSize: 12, color: kDeskMute)),
const Icon(Icons.chevron_right, size: 18, color: kDeskMute),
],
),
),
),
),
if (_people.isNotEmpty) ...[
const Padding(
padding: EdgeInsets.fromLTRB(4, 12, 4, 8),
child: Text('部门成员', style: TextStyle(fontSize: 12, color: kDeskMute, fontWeight: FontWeight.w600)),
),
for (final r in _people)
Material(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: () => _message(r),
borderRadius: BorderRadius.circular(8),
child: Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.all(12),
child: Row(
children: [
DeskAvatar(label: '${r['name']}', fileId: '${r['avatarFileId'] ?? ''}', api: widget.api, size: 36),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${r['name']}', style: const TextStyle(fontSize: 14)),
Text('${r['title'] ?? ''}', style: const TextStyle(fontSize: 12, color: kDeskMute)),
],
),
),
const Text('发消息', style: TextStyle(fontSize: 12, color: kDeskBind)),
],
),
),
),
),
],
],
),
),
],
),
);
}
}
@@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import '../../api/oa_client.dart';
import '../desk_theme.dart';
import '../widgets/desk_widgets.dart';
class DeskPickPeoplePage extends StatefulWidget {
const DeskPickPeoplePage({
super.key,
required this.api,
required this.title,
this.multiple = true,
this.exclude = const {},
});
final OaClient api;
final String title;
final bool multiple;
final Set<String> exclude;
@override
State<DeskPickPeoplePage> createState() => _DeskPickPeoplePageState();
}
class _DeskPickPeoplePageState extends State<DeskPickPeoplePage> {
List<Map<String, dynamic>> _items = [];
final _selected = <String, String>{};
String _q = '';
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final data = await widget.api.get('/staff');
if (!mounted) return;
setState(() {
_items = asMaps(data);
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
void _confirm() {
Navigator.pop(
context,
_selected.entries.map((e) => {'id': e.key, 'name': e.value}).toList(),
);
}
@override
Widget build(BuildContext context) {
final shown = _items.where((e) {
final id = '${e['id']}';
if (widget.exclude.contains(id)) return false;
if (_q.isEmpty) return true;
return '${e['name']}${e['department']}${e['title']}'.contains(_q);
}).toList();
return Scaffold(
backgroundColor: kDeskBg,
body: Column(
children: [
DeskPaneHeader(
title: widget.title,
leading: IconButton(icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(context)),
actions: [
if (widget.multiple)
TextButton(onPressed: _selected.isEmpty ? null : _confirm, child: Text('确定(${_selected.length})')),
],
bottom: Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 10),
child: DeskSearchField(hint: '搜索同事', onChanged: (v) => setState(() => _q = v.trim())),
),
),
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
Expanded(
child: ListView.builder(
itemCount: shown.length,
itemBuilder: (_, i) {
final r = shown[i];
final id = '${r['id']}';
final name = '${r['name'] ?? ''}';
final on = _selected.containsKey(id);
return Material(
color: on ? kDeskActive : Colors.white,
child: InkWell(
onTap: () {
if (!widget.multiple) {
Navigator.pop(context, [
{'id': id, 'name': name, 'avatarFileId': '${r['avatarFileId'] ?? ''}'},
]);
return;
}
setState(() {
if (on) {
_selected.remove(id);
} else {
_selected[id] = name;
}
});
},
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 10),
child: Row(
children: [
DeskAvatar(label: name, fileId: '${r['avatarFileId'] ?? ''}', api: widget.api, size: 36),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
Text('${r['department'] ?? ''} ${r['title'] ?? ''}'.trim(),
style: const TextStyle(fontSize: 12, color: kDeskMute)),
],
),
),
if (widget.multiple)
Icon(on ? Icons.check_circle : Icons.circle_outlined, color: on ? kDeskBind : kDeskMute, size: 20),
],
),
),
),
);
},
),
),
],
),
);
}
}
@@ -0,0 +1,177 @@
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../../api/oa_client.dart';
import '../../session/session.dart';
import '../desk_theme.dart';
import '../widgets/desk_widgets.dart';
class DeskProfileEditPage extends StatefulWidget {
const DeskProfileEditPage({super.key, required this.session, required this.api});
final SessionStore session;
final OaClient api;
@override
State<DeskProfileEditPage> createState() => _DeskProfileEditPageState();
}
class _DeskProfileEditPageState extends State<DeskProfileEditPage> {
bool _busy = false;
Future<void> _refreshMe() async {
final me = await widget.api.get('/auth/me');
if (me is Map) await widget.session.patchUser(Map<String, dynamic>.from(me));
if (mounted) setState(() {});
}
Future<void> _pickAvatar() async {
final x = await ImagePicker().pickImage(source: ImageSource.gallery, imageQuality: 85, maxWidth: 800);
if (x == null) return;
setState(() => _busy = true);
try {
await widget.api.uploadFile(
filePath: x.path,
filename: x.name.isEmpty ? 'avatar.jpg' : x.name,
bizType: 'USER_AVATAR',
bizId: widget.session.userId,
);
await _refreshMe();
if (mounted) deskToast(context, '头像已更新');
} catch (e) {
if (mounted) deskToast(context, '$e', error: true);
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _editName() async {
final c = TextEditingController(text: widget.session.displayName);
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('修改姓名'),
content: TextField(controller: c, decoration: const InputDecoration(labelText: '显示名')),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('保存')),
],
),
);
if (ok != true || c.text.trim().isEmpty) return;
try {
final me = await widget.api.patch('/auth/profile', {'displayName': c.text.trim()});
if (me is Map) await widget.session.patchUser(Map<String, dynamic>.from(me));
if (mounted) {
setState(() {});
deskToast(context, '已保存');
}
} catch (e) {
if (mounted) deskToast(context, '$e', error: true);
}
}
Future<void> _editPassword() async {
final oldC = TextEditingController();
final newC = TextEditingController();
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('修改密码'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: oldC, obscureText: true, decoration: const InputDecoration(labelText: '当前密码')),
const SizedBox(height: 8),
TextField(controller: newC, obscureText: true, decoration: const InputDecoration(labelText: '新密码')),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('保存')),
],
),
);
if (ok != true) return;
try {
await widget.api.post('/auth/change-password', {'oldPassword': oldC.text, 'newPassword': newC.text});
if (mounted) deskToast(context, '密码已修改');
} catch (e) {
if (mounted) deskToast(context, '$e', error: true);
}
}
@override
Widget build(BuildContext context) {
final s = widget.session;
final u = s.user;
return ColoredBox(
color: kDeskBg,
child: Column(
children: [
DeskPaneHeader(
title: '个人资料',
leading: IconButton(icon: const Icon(Icons.arrow_back, size: 20), onPressed: () => Navigator.pop(context)),
),
Expanded(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: ListView(
padding: const EdgeInsets.all(24),
children: [
Center(
child: Stack(
children: [
DeskAvatar(label: s.displayName, fileId: '${u['avatarFileId'] ?? ''}', api: widget.api, size: 80),
if (_busy)
const Positioned.fill(child: CircularProgressIndicator(strokeWidth: 2, color: kDeskBind)),
],
),
),
const SizedBox(height: 12),
Center(
child: TextButton(onPressed: _busy ? null : _pickAvatar, child: const Text('更换头像')),
),
const SizedBox(height: 16),
_row('姓名', s.displayName, onTap: _editName),
_row('手机', '${u['mobile'] ?? '未填写'}'),
_row('邮箱', '${u['email'] ?? '未填写'}'),
_row('部门', '${u['department'] ?? ''}'),
_row('职位', '${u['title'] ?? ''}'),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton(onPressed: _editPassword, child: const Text('修改密码')),
),
],
),
),
),
),
],
),
);
}
Widget _row(String k, String v, {VoidCallback? onTap}) {
return Material(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
children: [
SizedBox(width: 64, child: Text(k, style: const TextStyle(color: kDeskMute, fontSize: 13))),
Expanded(child: Text(v, style: const TextStyle(fontSize: 14))),
if (onTap != null) const Icon(Icons.chevron_right, size: 18, color: kDeskMute),
],
),
),
),
);
}
}
@@ -0,0 +1,166 @@
import 'package:flutter/material.dart';
import '../../api/oa_client.dart';
import '../../app_config.dart';
import '../../ota/updater.dart';
import 'desk_legal_page.dart';
import 'desk_profile_edit_page.dart';
import '../../session/session.dart';
import '../desk_theme.dart';
import '../widgets/desk_widgets.dart';
class DeskProfilePage extends StatefulWidget {
const DeskProfilePage({super.key, required this.session, required this.api});
final SessionStore session;
final OaClient api;
@override
State<DeskProfilePage> createState() => _DeskProfilePageState();
}
class _DeskProfilePageState extends State<DeskProfilePage> {
AppRelease? _rel;
bool _checking = false;
@override
void initState() {
super.initState();
widget.session.addListener(() {
if (mounted) setState(() {});
});
_peek();
}
Future<void> _peek() async {
final rel = await OtaUpdater(widget.api).fetch();
if (mounted) setState(() => _rel = rel);
}
Future<void> _logout() async {
try {
await widget.api.post('/auth/logout', {'refreshToken': widget.session.refreshToken});
} catch (_) {}
await widget.session.clear();
}
Future<void> _checkUpdate() async {
setState(() => _checking = true);
try {
final rel = await OtaUpdater(widget.api).fetch();
if (!mounted) return;
setState(() => _rel = rel);
if (rel != null) await OtaUpdater(widget.api).prompt(context, rel, manual: true);
} finally {
if (mounted) setState(() => _checking = false);
}
}
@override
Widget build(BuildContext context) {
final s = widget.session;
return ColoredBox(
color: kDeskBg,
child: Column(
children: [
const DeskPaneHeader(title: '我'),
Expanded(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: ListView(
padding: const EdgeInsets.all(24),
children: [
Material(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => DeskProfileEditPage(session: s, api: widget.api),
)),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
DeskAvatar(
label: s.displayName,
fileId: '${s.user['avatarFileId'] ?? ''}',
api: widget.api,
size: 56,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(s.displayName, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Text('${s.user['department'] ?? ''} ${s.user['title'] ?? ''}'.trim(),
style: const TextStyle(color: kDeskMute, fontSize: 13)),
],
),
),
const Icon(Icons.chevron_right, color: kDeskMute),
],
),
),
),
),
const SizedBox(height: 12),
_section([
_row('检查更新', trailing: _rel?.newer == true ? '有新版本' : '已是最新', onTap: _checking ? null : _checkUpdate),
_row('用户协议', onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => DeskLegalPage(api: widget.api, kind: 'terms')))),
_row('隐私政策', onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => DeskLegalPage(api: widget.api, kind: 'privacy')))),
]),
const SizedBox(height: 12),
_section([
_row('版本', trailing: 'v${AppConfig.version}'),
]),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: OutlinedButton(
style: OutlinedButton.styleFrom(minimumSize: const Size.fromHeight(40)),
onPressed: _logout,
child: const Text('退出登录', style: TextStyle(color: Color(0xFFFA5151))),
),
),
],
),
),
),
),
],
),
);
}
Widget _section(List<Widget> children) {
return Material(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
clipBehavior: Clip.antiAlias,
child: Column(children: children),
);
}
Widget _row(String title, {String? trailing, VoidCallback? onTap}) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Expanded(child: Text(title, style: const TextStyle(fontSize: 14))),
if (trailing != null) Text(trailing, style: const TextStyle(fontSize: 13, color: kDeskMute)),
if (onTap != null) const Icon(Icons.chevron_right, size: 18, color: kDeskMute),
],
),
),
),
);
}
}
@@ -0,0 +1,235 @@
import 'package:flutter/material.dart';
import '../../api/oa_client.dart';
import '../../nav/biz_route.dart';
import '../../pages/record_detail_page.dart' show flattenRecord;
import '../desk_theme.dart';
import '../widgets/desk_widgets.dart';
import 'desk_pick_people_page.dart';
void deskOpenRecord(BuildContext context, OaClient api, Map<String, dynamic> row, {String? title}) {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => DeskRecordDetailPage(api: api, seed: row, title: title ?? detailTitleOf(row)),
));
}
class DeskRecordDetailPage extends StatefulWidget {
const DeskRecordDetailPage({super.key, required this.api, required this.seed, required this.title});
final OaClient api;
final Map<String, dynamic> seed;
final String title;
@override
State<DeskRecordDetailPage> createState() => _DeskRecordDetailPageState();
}
class _DeskRecordDetailPageState extends State<DeskRecordDetailPage> {
Map<String, dynamic> _row = {};
bool _loading = true;
String _err = '';
@override
void initState() {
super.initState();
_row = Map<String, dynamic>.from(widget.seed);
_load();
}
Future<void> _load() async {
final path = fetchPathOf(widget.seed);
if (path == null) {
setState(() => _loading = false);
return;
}
try {
final data = await widget.api.get(path);
if (!mounted) return;
if (data is Map) {
setState(() {
_row = {...widget.seed, ...Map<String, dynamic>.from(data)};
_loading = false;
_err = '';
});
} else {
setState(() => _loading = false);
}
} catch (e) {
if (!mounted) return;
setState(() {
_loading = false;
_err = '$e';
});
}
}
Future<void> _forward() async {
final combined = <String, dynamic>{...widget.seed, ..._row};
final path = fetchPathOf(combined);
if (path == null || !path.startsWith('/')) {
deskToast(context, '当前信息暂不支持转发', error: true);
return;
}
final picked = await Navigator.of(context).push<List<Map<String, dynamic>>>(
MaterialPageRoute(
builder: (_) => DeskPickPeoplePage(api: widget.api, title: '转发给', multiple: true, exclude: {widget.api.session.userId}),
),
);
if (picked == null || picked.isEmpty || !mounted) return;
final visible = flattenRecord(combined).where((e) => e.$2.trim().isNotEmpty).take(2).map((e) => '${e.$1}:${e.$2}').join(' · ');
try {
for (final person in picked) {
await widget.api.post('/im/messages', {
'peerId': '${person['id'] ?? ''}',
'body': widget.title,
'contentType': 'business',
'meta': {
'title': widget.title,
'summary': visible,
'fetchPath': path,
'recordId': '${combined['id'] ?? combined['bizId'] ?? ''}',
'bizType': '${combined['bizType'] ?? combined['source'] ?? ''}',
},
});
}
if (mounted) deskToast(context, '已转发给 ${picked.length} 人');
} catch (e) {
if (mounted) deskToast(context, '$e', error: true);
}
}
Future<void> _act(String label, Future<void> Function() run) async {
try {
await run();
if (mounted) {
deskToast(context, label);
await _load();
}
} catch (e) {
if (mounted) deskToast(context, '$e', error: true);
}
}
Future<void> _decide(String path, String result) async {
final c = TextEditingController();
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(result == 'APPROVED' ? '通过' : '驳回'),
content: TextField(controller: c, decoration: const InputDecoration(hintText: '意见(可选)')),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确认')),
],
),
);
if (ok != true) return;
await _act('已提交', () async {
await widget.api.post(path, {'result': result, if (c.text.trim().isNotEmpty) 'comment': c.text.trim()});
});
}
List<Widget> get _actions {
final id = '${_row['id'] ?? widget.seed['id'] ?? ''}';
final bizId = '${_row['bizId'] ?? ''}';
final status = '${_row['status'] ?? ''}';
final source = '${_row['source'] ?? ''}';
final kind = '${_row['kind'] ?? ''}';
final biz = '${_row['bizType'] ?? ''}';
final actions = _row['myActions'] is Map ? Map<String, dynamic>.from(_row['myActions'] as Map) : <String, dynamic>{};
final out = <Widget>[];
if (status == 'DRAFT' && (source == 'EXPENSE' && kind != 'LOAN' || _row.containsKey('claimNo'))) {
out.add(FilledButton(onPressed: () => _act('已提交审批', () => widget.api.post('/expenses/$id/submit')), child: const Text('提交报销')));
}
if (status == 'DRAFT' && (kind == 'LOAN' || _row.containsKey('loanNo'))) {
out.add(FilledButton(onPressed: () => _act('已提交审批', () => widget.api.post('/loans/$id/submit')), child: const Text('提交借款')));
}
if (biz.isNotEmpty && status == 'PENDING' && id.isNotEmpty && widget.seed['assigneeId'] != null) {
out.add(FilledButton(onPressed: () => _decide('/office/approvals/$id/decide', 'APPROVED'), child: const Text('通过')));
out.add(OutlinedButton(onPressed: () => _decide('/office/approvals/$id/decide', 'REJECTED'), child: const Text('驳回')));
}
if (source == 'OFFICE' && status == 'PENDING') {
out.add(FilledButton(onPressed: () => _decide('/office/applies/$id/decide', 'APPROVED'), child: const Text('通过')));
out.add(OutlinedButton(onPressed: () => _decide('/office/applies/$id/decide', 'REJECTED'), child: const Text('驳回')));
}
if ((biz == 'HR_LEAVE' || source == 'HR') && status == 'PENDING' && (bizId.isNotEmpty || id.isNotEmpty)) {
final hid = bizId.isNotEmpty ? bizId : id;
out.add(FilledButton(onPressed: () => _decide('/leave-requests/$hid/review', 'APPROVED'), child: const Text('通过')));
out.add(OutlinedButton(onPressed: () => _decide('/leave-requests/$hid/review', 'REJECTED'), child: const Text('驳回')));
}
if (actions['canReview'] == true && id.isNotEmpty) {
out.add(FilledButton(onPressed: () => _decide('/bid-cases/$id/reviews', 'APPROVED'), child: const Text('审批通过')));
out.add(OutlinedButton(onPressed: () => _decide('/bid-cases/$id/reviews', 'REJECTED'), child: const Text('审批驳回')));
}
if ('${_row['status']}' == 'OPEN' && _row['assigneeId'] != null && !_row.containsKey('source')) {
out.add(FilledButton(
onPressed: () => _act('已办结', () => widget.api.patch('/office/todos/$id', {'status': 'DONE'})),
child: const Text('标为已办'),
));
}
return out;
}
@override
Widget build(BuildContext context) {
final pairs = flattenRecord(_row);
return ColoredBox(
color: kDeskBg,
child: Column(
children: [
DeskPaneHeader(
title: widget.title,
actions: [
IconButton(tooltip: '转发', onPressed: _loading ? null : _forward, icon: const Icon(Icons.forward_to_inbox_outlined, size: 20)),
IconButton(tooltip: '刷新', onPressed: _load, icon: const Icon(Icons.refresh, size: 20)),
],
),
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
Expanded(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: ListView(
padding: const EdgeInsets.all(20),
children: [
if (_err.isNotEmpty) Padding(padding: const EdgeInsets.only(bottom: 8), child: Text(_err, style: const TextStyle(color: kDeskMute, fontSize: 12))),
Material(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
for (var i = 0; i < pairs.length; i++)
_kvRow(pairs[i].$1, pairs[i].$2, i == pairs.length - 1),
],
),
),
if (_actions.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 16),
child: Wrap(spacing: 8, runSpacing: 8, children: _actions),
),
],
),
),
),
),
],
),
);
}
Widget _kvRow(String k, String v, bool last) {
return Container(
decoration: BoxDecoration(border: last ? null : const Border(bottom: BorderSide(color: kDeskLine))),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 100, child: Text(k, style: const TextStyle(fontSize: 13, color: kDeskMute))),
Expanded(child: Text(v, style: const TextStyle(fontSize: 14))),
],
),
);
}
}
@@ -0,0 +1,125 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import '../../api/oa_client.dart';
import '../../nav/notice_route.dart';
import '../../session/session.dart';
import '../desk_theme.dart';
import '../widgets/desk_widgets.dart';
class DeskSystemNoticePage extends StatefulWidget {
const DeskSystemNoticePage({super.key, required this.session, required this.api, required this.conversationId});
final SessionStore session;
final OaClient api;
final String conversationId;
@override
State<DeskSystemNoticePage> createState() => _DeskSystemNoticePageState();
}
class _DeskSystemNoticePageState extends State<DeskSystemNoticePage> {
List<Map<String, dynamic>> _items = [];
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final data = await widget.api.get('/im/messages', query: {'conversationId': widget.conversationId});
if (!mounted) return;
setState(() {
_items = data is Map ? asMaps(data['items'] ?? data) : asMaps(data);
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
Map<String, dynamic> _meta(Map<String, dynamic> row) {
final raw = row['meta'];
if (raw is Map) return Map<String, dynamic>.from(raw);
if (raw is String && raw.isNotEmpty) {
try {
final d = jsonDecode(raw);
if (d is Map) return Map<String, dynamic>.from(d);
} catch (_) {}
}
return {};
}
void _open(Map<String, dynamic> row) {
final meta = _meta(row);
openNoticeTarget(
context,
widget.api,
session: widget.session,
kind: '${meta['kind'] ?? row['kind'] ?? ''}',
bizType: '${meta['bizType'] ?? ''}',
bizId: '${meta['bizId'] ?? ''}',
title: '${row['body'] ?? meta['title'] ?? '系统通知'}',
);
}
@override
Widget build(BuildContext context) {
return ColoredBox(
color: const Color(0xFFF5F5F5),
child: Column(
children: [
const DeskPaneHeader(title: '系统通知'),
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
Expanded(
child: _items.isEmpty
? const Center(child: Text('暂无通知', style: TextStyle(color: kDeskMute)))
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _items.length,
itemBuilder: (_, i) {
final r = _items[i];
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Material(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: () => _open(r),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
const Icon(Icons.notifications_none, color: kDeskBind),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${r['body'] ?? '通知'}', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
const SizedBox(height: 4),
Text(shortTime(r['createdAt']), style: const TextStyle(fontSize: 12, color: kDeskMute)),
],
),
),
const Icon(Icons.chevron_right, color: kDeskMute, size: 18),
],
),
),
),
),
);
},
),
),
],
),
);
}
}
@@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import '../../api/oa_client.dart';
import '../../session/session.dart';
import '../desk_theme.dart';
import '../widgets/desk_data_table.dart';
import '../widgets/desk_widgets.dart';
import 'desk_record_detail_page.dart';
class DeskWorkAssignPage extends StatefulWidget {
const DeskWorkAssignPage({super.key, required this.session, required this.api});
final SessionStore session;
final OaClient api;
@override
State<DeskWorkAssignPage> createState() => _DeskWorkAssignPageState();
}
class _DeskWorkAssignPageState extends State<DeskWorkAssignPage> {
List<Map<String, dynamic>> _items = [];
bool _loading = true;
bool get _canAssign {
const codes = ['admin', 'owner', 'biz_director', 'tech_director', 'rd_director', '3d_director', 'video_director', 'material_director', 'pm'];
return widget.session.roles.any(codes.contains);
}
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() => _loading = true);
try {
final data = await widget.api.get('/office/work-assignments');
if (!mounted) return;
setState(() {
_items = asMaps(data);
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _create() async {
final staff = asMaps(await widget.api.get('/staff'));
if (!mounted) return;
final title = TextEditingController();
final content = TextEditingController();
String? assignee;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setSt) => AlertDialog(
title: const Text('安排工作'),
content: SizedBox(
width: 440,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: title, decoration: const InputDecoration(labelText: '标题')),
const SizedBox(height: 8),
TextField(controller: content, decoration: const InputDecoration(labelText: '内容'), maxLines: 3),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
decoration: const InputDecoration(labelText: '执行人'),
items: [
for (final s in staff)
DropdownMenuItem(value: '${s['id']}', child: Text('${s['name'] ?? ''} · ${s['department'] ?? ''}')),
],
onChanged: (v) => setSt(() => assignee = v),
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('下达')),
],
),
),
);
if (ok != true) return;
try {
await widget.api.post('/office/work-assignments', {
'title': title.text.trim(),
'content': content.text.trim(),
'assigneeId': assignee,
});
if (mounted) {
deskToast(context, '已下达');
_load();
}
} catch (e) {
if (mounted) deskToast(context, '$e', error: true);
}
}
@override
Widget build(BuildContext context) {
return DeskListScaffold(
title: '工作安排',
loading: _loading,
actions: [
if (_canAssign)
FilledButton.icon(
style: FilledButton.styleFrom(backgroundColor: kDeskBind),
onPressed: _create,
icon: const Icon(Icons.add, size: 18),
label: const Text('安排工作'),
),
IconButton(onPressed: _load, icon: const Icon(Icons.refresh, size: 20)),
],
body: DeskDataTable(
columns: const ['标题', '执行人', '时间', '状态'],
rows: [
for (final r in _items)
[
'${r['title'] ?? ''}',
'${r['assignee'] is Map ? r['assignee']['displayName'] : ''}',
fmtTime(r['dueAt'] ?? r['createdAt']),
'${r['status'] ?? ''}',
],
],
emptyHint: '没有工作安排',
onRowTap: (i) => deskOpenRecord(context, widget.api, _items[i]),
),
);
}
}
@@ -0,0 +1,398 @@
import 'package:flutter/material.dart';
import '../../api/oa_client.dart';
import '../../device/device_bridge.dart';
import '../../labels.dart';
import '../../nav/open_module.dart';
import '../../pages/module_list_page.dart' show RestShell, shellFor;
import '../../session/session.dart';
import '../desk_theme.dart';
import '../widgets/desk_data_table.dart';
import '../widgets/desk_widgets.dart';
import 'desk_attendance_page.dart';
import 'desk_hr_apply_page.dart';
import 'desk_legal_page.dart';
import 'desk_office_list_page.dart';
import 'desk_record_detail_page.dart';
import 'desk_work_assign_page.dart';
typedef DeskOpenTab = void Function(String key, String title, IconData icon, Color color, Widget page);
class DeskWorkbenchPage extends StatefulWidget {
const DeskWorkbenchPage({super.key, required this.session, required this.api, required this.onOpenTab});
final SessionStore session;
final OaClient api;
final DeskOpenTab onOpenTab;
@override
State<DeskWorkbenchPage> createState() => _DeskWorkbenchPageState();
}
class _DeskWorkbenchPageState extends State<DeskWorkbenchPage> {
Map<String, dynamic> _ov = {};
String _greet = '';
bool _loading = true;
String _q = '';
String _cat = 'all';
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final ov = await widget.api.get('/office/overview');
String greet = '';
try {
final w = await widget.api.get('/office/weather');
if (w is Map) greet = '${w['greeting'] ?? w['text'] ?? ''}';
} catch (_) {}
if (!mounted) return;
setState(() {
_ov = Map<String, dynamic>.from(ov as Map? ?? {});
_greet = greet;
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
bool _match(String name) => _q.isEmpty || name.contains(_q);
void _open(String key, String title, IconData icon, Color color, Widget page) {
widget.onOpenTab(key, title, icon, color, page);
}
List<_App> _personalApps() {
return [
if (_match('待办'))
_App('todos', '待办', '处理待办事项与提醒', Icons.task_alt, const Color(0xFFFA9D3B),
DeskOfficeListPage(api: widget.api, title: '待办', path: '/office/todos', buckets: const [('OPEN', '待办'), ('DONE', '已办'), ('', '全部')], defaultBucket: 'OPEN')),
if (_match('审批'))
_App('approvals', '待审批', '审批各类办公申请', Icons.fact_check, kDeskBind,
DeskOfficeListPage(api: widget.api, title: '待我审批', path: '/office/approvals', buckets: const [('PENDING', '待审批'), ('APPROVED', '已通过'), ('REJECTED', '已驳回'), ('', '全部')], defaultBucket: 'PENDING')),
if (_match('申请'))
_App('flow', '我的申请', '查看我发起的申请', Icons.assignment_outlined, const Color(0xFF6267F2),
DeskOfficeListPage(api: widget.api, title: '我的申请', path: '/office/flow', buckets: const [('', '全部'), ('pending', '待审批'), ('approved', '已通过'), ('rejected', '已驳回')], defaultBucket: '')),
if (_match('日程'))
_App('calendar', '日程', '会议与日程安排', Icons.calendar_month, const Color(0xFF10AEFF),
DeskOfficeListPage(
api: widget.api,
title: '我的日程',
path: '/office/calendar',
columns: const ['标题', '时间', '地点'],
rowBuilder: (r) => ['${r['title'] ?? ''}', fmtTime(r['startAt'] ?? r['createdAt']), '${r['location'] ?? ''}'],
)),
if (_match('安排'))
_App('work', '工作安排', '任务分配与跟进', Icons.event_note, const Color(0xFF00B578),
DeskWorkAssignPage(session: widget.session, api: widget.api)),
if (_match('打卡') || _match('考勤'))
_App('attendance', '考勤打卡', '上下班打卡记录', Icons.access_time_filled, const Color(0xFF267EF0),
DeskAttendancePage(api: widget.api)),
if (_match('人事'))
_App('hr', '人事申请', '请假、加班、外出等', Icons.beach_access, const Color(0xFF8B5CF6), DeskHrApplyPage(api: widget.api)),
];
}
List<_App> _menuApps() {
final out = <_App>[];
for (final m in widget.session.menus) {
if (m.name == '工作台' || m.name == '个人办公') continue;
if (m.name == '系统设置' || m.code.startsWith('system')) continue;
if (m.children.isNotEmpty) {
for (var i = 0; i < m.children.length; i++) {
final c = m.children[i];
if (_skip(c)) continue;
if (!(_q.isEmpty || c.name.contains(_q) || m.name.contains(_q))) continue;
out.add(_App(c.code, c.name, m.name, iconFor(c.code, c.name), colorFor(c.code, c.name, i), _pageFor(c)));
}
} else if (!_skip(m) && _match(m.name)) {
out.add(_App(m.code, m.name, '业务应用', iconFor(m.code, m.name), colorFor(m.code, m.name), _pageFor(m)));
}
}
return out;
}
bool _skip(MenuNode n) {
const names = {'待办', '待审批', '我的申请', '日程', '公告', '公司公告', '工作安排', '人事申请', '工作台', '个人办公', '消息', '通讯录', '员工通讯', '发送短信', '系统设置'};
if (n.code.startsWith('office:') || n.code.startsWith('system')) return true;
if (n.code.contains('sms') || n.name.contains('短信')) return true;
if (n.name.contains('公告') || n.name == '员工通讯') return true;
return names.contains(n.name);
}
String _legalKind(MenuNode n) {
final p = '${n.path ?? ''} ${n.code}'.toLowerCase();
if (p.contains('privacy') || p.contains('隐私')) return 'privacy';
return 'user-agreement';
}
Widget _pageFor(MenuNode n) {
if (n.code.contains('legal') || (n.path ?? '').startsWith('/legal')) {
return DeskLegalPage(api: widget.api, kind: _legalKind(n));
}
if (n.code == 'office:assign' || n.name.contains('工作安排')) {
return DeskWorkAssignPage(session: widget.session, api: widget.api);
}
final shell = shellFor(n.code, n.path) ?? _shellFromPath(n.path);
if (shell != null) {
return DeskModuleListPage(api: widget.api, title: n.name, shell: shell);
}
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('${n.name} 暂未配置桌面入口', style: const TextStyle(color: kDeskMute)),
if ((n.path ?? '').startsWith('http'))
TextButton(
onPressed: () => DeviceBridge.openUrl(n.path!),
child: const Text('在浏览器打开'),
),
],
),
);
}
RestShell? _shellFromPath(String? path) {
final p = (path ?? '').trim();
if (p.isEmpty || !p.startsWith('/') || p.contains(':')) return null;
const blocked = [
'/office/overview',
'/office/todos',
'/office/approvals',
'/office/flow',
'/office/calendar',
'/office/weather',
'/office/geo',
'/auth',
'/im',
'/system',
'/push',
'/files',
'/health',
];
if (blocked.any((b) => p.startsWith(b))) return null;
return RestShell(p);
}
List<_App> get _shown {
final personal = _personalApps();
final menu = _menuApps();
if (_cat == 'personal') return personal;
if (_cat == 'biz') return menu;
return [...personal, ...menu];
}
@override
Widget build(BuildContext context) {
final width = MediaQuery.sizeOf(context).width;
final cols = width >= 1100 ? 3 : 2;
return ColoredBox(
color: kDeskBg,
child: Column(
children: [
DeskPaneHeader(
title: '工作台',
actions: [
SizedBox(width: 200, child: DeskSearchField(hint: '搜索应用', onChanged: (v) => setState(() => _q = v.trim()))),
const SizedBox(width: 8),
TextButton(onPressed: _load, child: const Text('刷新')),
],
bottom: Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 10),
child: DeskFilterChips(
value: _cat,
onChanged: (v) => setState(() => _cat = v),
items: const [('all', '全部应用'), ('personal', '个人办公'), ('biz', '业务模块')],
),
),
),
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 24),
children: [
Container(
padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
decoration: BoxDecoration(
gradient: const LinearGradient(colors: [Color(0xFFEEF4FF), Color(0xFFF8FBFF)]),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFDCE8FF)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_greet.isEmpty ? '你好,${widget.session.displayName}' : _greet,
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700),
),
const SizedBox(height: 4),
const Text('个人办公与业务入口', style: TextStyle(color: kDeskMute, fontSize: 13)),
],
),
),
const SizedBox(height: 14),
Row(
children: [
_stat('待办', '${_ov['pendingTodos'] ?? 0}', () => _open('todos', '待办', Icons.task_alt, const Color(0xFFFA9D3B), DeskOfficeListPage(api: widget.api, title: '待办', path: '/office/todos', buckets: const [('OPEN', '待办'), ('DONE', '已办'), ('', '全部')], defaultBucket: 'OPEN'))),
const SizedBox(width: 10),
_stat('待审批', '${_ov['pendingApprovals'] ?? 0}', () => _open('approvals', '待审批', Icons.fact_check, kDeskBind, DeskOfficeListPage(api: widget.api, title: '待我审批', path: '/office/approvals', buckets: const [('PENDING', '待审批'), ('', '全部')], defaultBucket: 'PENDING'))),
const SizedBox(width: 10),
_stat('我的申请', '${_ov['myPending'] ?? 0}', () => _open('flow', '我的申请', Icons.assignment_outlined, const Color(0xFF6267F2), DeskOfficeListPage(api: widget.api, title: '我的申请', path: '/office/flow'))),
const SizedBox(width: 10),
_stat('逾期', '${_ov['overdueTodos'] ?? 0}', () => _open('todos', '待办', Icons.task_alt, const Color(0xFFFA9D3B), DeskOfficeListPage(api: widget.api, title: '待办', path: '/office/todos'))),
],
),
const SizedBox(height: 18),
if (_shown.isEmpty)
const Padding(padding: EdgeInsets.only(top: 40), child: Center(child: Text('没有匹配的应用', style: TextStyle(color: kDeskMute))))
else
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: cols,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 2.9,
),
itemCount: _shown.length,
itemBuilder: (_, i) {
final a = _shown[i];
return DeskAppCard(
title: a.title,
desc: a.desc,
icon: a.icon,
color: a.color,
onTap: () => _open(a.key, a.title, a.icon, a.color, a.page),
);
},
),
],
),
),
],
),
);
}
Widget _stat(String label, String value, VoidCallback onTap) {
return Expanded(
child: Material(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(
children: [
Text(value, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: kDeskBind)),
const SizedBox(height: 2),
Text(label, style: const TextStyle(fontSize: 12, color: kDeskMute)),
],
),
),
),
),
);
}
}
class _App {
_App(this.key, this.title, this.desc, this.icon, this.color, this.page);
final String key;
final String title;
final String desc;
final IconData icon;
final Color color;
final Widget page;
}
/// 桌面端业务模块列表(表格),不沿用移动端 ModuleListPage。
class DeskModuleListPage extends StatefulWidget {
const DeskModuleListPage({super.key, required this.api, required this.title, required this.shell});
final OaClient api;
final String title;
final RestShell shell;
@override
State<DeskModuleListPage> createState() => _DeskModuleListPageState();
}
class _DeskModuleListPageState extends State<DeskModuleListPage> {
List<Map<String, dynamic>> _items = [];
bool _loading = true;
String _q = '';
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() => _loading = true);
try {
final data = await widget.api.get(widget.shell.path, query: {'page': '1', 'pageSize': '100', ...?widget.shell.query});
if (!mounted) return;
setState(() {
_items = asMaps(data);
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
List<Map<String, dynamic>> get _shown {
if (_q.isEmpty) return _items;
return _items.where((e) => pickTitle(e).contains(_q) || pickSub(e).contains(_q)).toList();
}
@override
Widget build(BuildContext context) {
final shown = _shown;
return DeskListScaffold(
title: widget.title,
loading: _loading,
actions: [IconButton(onPressed: _load, icon: const Icon(Icons.refresh, size: 20))],
filters: DeskSearchField(hint: '搜索', onChanged: (v) => setState(() => _q = v.trim())),
body: DeskDataTable(
columns: const ['名称', '摘要', '状态'],
rows: [
for (final r in shown)
[pickTitle(r), zh(pickSub(r)), '${r['status'] ?? ''}'],
],
emptyHint: '暂无${widget.title}',
onRowTap: (i) {
final r = shown[i];
final seed = Map<String, dynamic>.from(r);
if ('${r['id']}'.length >= 8) seed['_fetch'] = '${widget.shell.path}/${r['id']}';
deskOpenRecord(context, widget.api, seed, title: pickTitle(r));
},
),
);
}
}
String pickTitle(Map<String, dynamic> r) {
for (final k in ['title', 'name', 'code', 'no']) {
final v = r[k];
if (v != null && '$v'.isNotEmpty) return '$v';
}
return '记录';
}
String pickSub(Map<String, dynamic> r) {
for (final k in ['summary', 'remark', 'description', 'partyName', 'projectName']) {
final v = r[k];
if (v != null && '$v'.isNotEmpty) return '$v';
}
return '';
}
@@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import '../desk_theme.dart';
/// 桌面端通用数据表格,替代移动端 KvTile 列表。
class DeskDataTable extends StatelessWidget {
const DeskDataTable({
super.key,
required this.columns,
required this.rows,
this.onRowTap,
this.emptyHint = '暂无数据',
});
final List<String> columns;
final List<List<String>> rows;
final void Function(int index)? onRowTap;
final String emptyHint;
@override
Widget build(BuildContext context) {
if (rows.isEmpty) {
return Center(child: Text(emptyHint, style: const TextStyle(color: kDeskMute)));
}
return Material(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
clipBehavior: Clip.antiAlias,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
headingRowHeight: 40,
dataRowMinHeight: 44,
dataRowMaxHeight: 56,
columnSpacing: 24,
horizontalMargin: 16,
columns: [for (final c in columns) DataColumn(label: Text(c))],
rows: [
for (var i = 0; i < rows.length; i++)
DataRow(
onSelectChanged: onRowTap == null ? null : (_) => onRowTap!(i),
cells: [for (final cell in rows[i]) DataCell(Text(cell, maxLines: 2, overflow: TextOverflow.ellipsis))],
),
],
),
),
);
}
}
class DeskListScaffold extends StatelessWidget {
const DeskListScaffold({
super.key,
required this.title,
required this.body,
this.filters,
this.actions = const [],
this.loading = false,
});
final String title;
final Widget body;
final Widget? filters;
final List<Widget> actions;
final bool loading;
@override
Widget build(BuildContext context) {
return ColoredBox(
color: kDeskBg,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ColoredBox(
color: kDeskPane,
child: Column(
children: [
SizedBox(
height: 48,
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(left: 16),
child: Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
),
const Spacer(),
...actions,
const SizedBox(width: 8),
],
),
),
if (filters != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 10),
child: filters!,
),
const Divider(height: 1, color: kDeskLine),
],
),
),
if (loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
Expanded(
child: Padding(
padding: const EdgeInsets.all(16),
child: body,
),
),
],
),
);
}
}
@@ -0,0 +1,573 @@
import 'package:flutter/material.dart';
import '../../api/oa_client.dart';
import '../../session/session.dart';
import '../../widgets/auth_image.dart';
import '../desk_theme.dart';
class DeskNavRail extends StatelessWidget {
const DeskNavRail({
super.key,
required this.session,
required this.api,
required this.index,
required this.onChanged,
required this.items,
});
final SessionStore session;
final OaClient api;
final int index;
final ValueChanged<int> onChanged;
final List<(IconData, IconData, String, int)> items;
@override
Widget build(BuildContext context) {
return ColoredBox(
color: kDeskRail,
child: SizedBox(
width: kDeskRailWidth,
child: Column(
children: [
const SizedBox(height: 18),
DeskAvatar(
label: session.displayName,
fileId: '${session.user['avatarFileId'] ?? ''}',
api: api,
size: 36,
),
const SizedBox(height: 18),
Expanded(
child: ListView(
padding: EdgeInsets.zero,
children: [
for (var i = 0; i < items.length; i++)
_RailItem(
active: index == i,
icon: index == i ? items[i].$2 : items[i].$1,
label: items[i].$3,
badge: items[i].$4,
onTap: () => onChanged(i),
),
],
),
),
const SizedBox(height: 12),
],
),
),
);
}
}
class _RailItem extends StatelessWidget {
const _RailItem({
required this.active,
required this.icon,
required this.label,
required this.badge,
required this.onTap,
});
final bool active;
final IconData icon;
final String label;
final int badge;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
child: Material(
color: active ? kDeskActive : Colors.transparent,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
children: [
Stack(
clipBehavior: Clip.none,
children: [
Icon(icon, size: 22, color: active ? kDeskBind : const Color(0xFF5C5C5C)),
if (badge > 0)
Positioned(
right: -10,
top: -6,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
constraints: const BoxConstraints(minWidth: 16, minHeight: 16),
decoration: BoxDecoration(
color: const Color(0xFFFA5151),
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: Text(
badge > 99 ? '99+' : '$badge',
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w600),
),
),
),
],
),
const SizedBox(height: 3),
Text(
label,
style: TextStyle(
fontSize: 11,
color: active ? kDeskBind : const Color(0xFF5C5C5C),
fontWeight: active ? FontWeight.w600 : FontWeight.w400,
),
),
],
),
),
),
),
);
}
}
class DeskAvatar extends StatelessWidget {
const DeskAvatar({
super.key,
required this.label,
required this.api,
this.fileId,
this.size = 40,
this.color,
this.icon,
});
final String label;
final OaClient api;
final String? fileId;
final double size;
final Color? color;
final IconData? icon;
@override
Widget build(BuildContext context) {
final id = fileId ?? '';
if (id.isNotEmpty) {
return ClipRRect(
borderRadius: BorderRadius.circular(6),
child: AuthImage(fileId: id, api: api, width: size, height: size, fit: BoxFit.cover),
);
}
final c = color ?? kDeskBind;
final ch = label.isNotEmpty ? label.characters.first : '?';
return Container(
width: size,
height: size,
decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(6)),
alignment: Alignment.center,
child: icon != null
? Icon(icon, color: Colors.white, size: size * 0.5)
: Text(ch, style: TextStyle(color: Colors.white, fontSize: size * 0.38, fontWeight: FontWeight.w600)),
);
}
}
class DeskPaneHeader extends StatelessWidget {
const DeskPaneHeader({
super.key,
required this.title,
this.actions = const [],
this.bottom,
this.leading,
});
final String title;
final List<Widget> actions;
final Widget? bottom;
final Widget? leading;
@override
Widget build(BuildContext context) {
return ColoredBox(
color: kDeskPane,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: 48,
child: Row(
children: [
if (leading != null) leading!,
Padding(
padding: EdgeInsets.only(left: leading == null ? 14 : 4),
child: Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: kDeskInk)),
),
const Spacer(),
...actions,
const SizedBox(width: 6),
],
),
),
if (bottom != null) bottom!,
const Divider(height: 1, color: kDeskLine),
],
),
);
}
}
class DeskSearchField extends StatelessWidget {
const DeskSearchField({super.key, required this.hint, this.onChanged, this.controller});
final String hint;
final ValueChanged<String>? onChanged;
final TextEditingController? controller;
@override
Widget build(BuildContext context) {
return Container(
height: 32,
decoration: BoxDecoration(color: const Color(0xFFEFEFEF), borderRadius: BorderRadius.circular(6)),
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Row(
children: [
const Icon(Icons.search, size: 16, color: kDeskMute),
const SizedBox(width: 6),
Expanded(
child: TextField(
controller: controller,
onChanged: onChanged,
style: const TextStyle(fontSize: 13),
decoration: InputDecoration(
hintText: hint,
isDense: true,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: EdgeInsets.zero,
hintStyle: const TextStyle(color: Color(0xFFB2B2B2), fontSize: 13),
),
),
),
],
),
);
}
}
class DeskConvRow extends StatelessWidget {
const DeskConvRow({
super.key,
required this.name,
required this.preview,
required this.time,
required this.unread,
required this.selected,
required this.onTap,
this.avatarId,
this.api,
this.muted = false,
this.pinned = false,
this.onSecondaryTap,
});
final String name;
final String preview;
final String time;
final int unread;
final bool selected;
final VoidCallback onTap;
final String? avatarId;
final OaClient? api;
final bool muted;
final bool pinned;
final void Function(TapDownDetails)? onSecondaryTap;
@override
Widget build(BuildContext context) {
return Material(
color: selected ? kDeskActive : Colors.transparent,
child: InkWell(
onTap: onTap,
onSecondaryTapDown: onSecondaryTap,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
child: Row(
children: [
if (api != null)
DeskAvatar(label: name, fileId: avatarId, api: api!, size: 40)
else
DeskAvatar(label: name, api: OaClient(SessionStore()), size: 40),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (pinned)
const Padding(
padding: EdgeInsets.only(right: 4),
child: Icon(Icons.push_pin, size: 12, color: kDeskMute),
),
Expanded(
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: kDeskInk),
),
),
Text(time, style: const TextStyle(fontSize: 11, color: kDeskMute)),
],
),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: Text(
preview,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: muted ? kDeskMute : const Color(0xFF9A9A9A)),
),
),
if (muted)
const Icon(Icons.notifications_off_outlined, size: 14, color: kDeskMute),
if (unread > 0) ...[
const SizedBox(width: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: const Color(0xFFFA5151),
borderRadius: BorderRadius.circular(9),
),
child: Text(
unread > 99 ? '99+' : '$unread',
style: const TextStyle(color: Colors.white, fontSize: 10),
),
),
],
],
),
],
),
),
],
),
),
),
);
}
}
class DeskEmptyChat extends StatelessWidget {
const DeskEmptyChat({super.key, this.hint = '选择一个会话开始聊天'});
final String hint;
@override
Widget build(BuildContext context) {
return ColoredBox(
color: const Color(0xFFF5F5F5),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Opacity(
opacity: 0.1,
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(color: kDeskBind, borderRadius: BorderRadius.circular(24)),
child: const Icon(Icons.chat_bubble_outline, size: 52, color: Colors.white),
),
),
const SizedBox(height: 16),
Text(hint, style: const TextStyle(color: kDeskMute, fontSize: 13)),
],
),
),
);
}
}
class DeskTabStrip extends StatelessWidget {
const DeskTabStrip({
super.key,
required this.tabs,
required this.active,
required this.onSelect,
required this.onClose,
});
final List<(String key, String title, IconData icon, Color color)> tabs;
final int active;
final ValueChanged<int> onSelect;
final ValueChanged<int> onClose;
@override
Widget build(BuildContext context) {
return ColoredBox(
color: kDeskPane,
child: Column(
children: [
SizedBox(
height: 40,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8),
children: [
for (var i = 0; i < tabs.length; i++)
Padding(
padding: const EdgeInsets.only(right: 2, top: 6, bottom: 6),
child: Material(
color: active == i ? const Color(0xFFF0F4FA) : Colors.transparent,
borderRadius: BorderRadius.circular(6),
child: InkWell(
onTap: () => onSelect(i),
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: EdgeInsets.fromLTRB(10, 5, i > 0 ? 4 : 10, 5),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(tabs[i].$3, size: 14, color: tabs[i].$4),
const SizedBox(width: 5),
Text(
tabs[i].$2,
style: TextStyle(
fontSize: 12,
color: active == i ? kDeskInk : kDeskMute,
fontWeight: active == i ? FontWeight.w600 : FontWeight.w400,
),
),
if (i > 0) ...[
const SizedBox(width: 2),
InkWell(
onTap: () => onClose(i),
borderRadius: BorderRadius.circular(8),
child: const Padding(
padding: EdgeInsets.all(2),
child: Icon(Icons.close, size: 12, color: kDeskMute),
),
),
],
],
),
),
),
),
),
],
),
),
const Divider(height: 1, color: kDeskLine),
],
),
);
}
}
class DeskAppCard extends StatelessWidget {
const DeskAppCard({
super.key,
required this.title,
required this.desc,
required this.icon,
required this.color,
required this.onTap,
});
final String title;
final String desc;
final IconData icon;
final Color color;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(10)),
child: Icon(icon, color: Colors.white, size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, maxLines: 1, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: kDeskInk)),
const SizedBox(height: 4),
Text(desc, maxLines: 2, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, color: kDeskMute, height: 1.35)),
],
),
),
],
),
),
),
);
}
}
class DeskFilterChips extends StatelessWidget {
const DeskFilterChips({super.key, required this.value, required this.onChanged, required this.items});
final String value;
final ValueChanged<String> onChanged;
final List<(String, String)> items;
@override
Widget build(BuildContext context) {
return Row(
children: [
for (final it in items) ...[
_chip(it.$1, it.$2),
if (it != items.last) const SizedBox(width: 6),
],
],
);
}
Widget _chip(String id, String label) {
final on = value == id;
return GestureDetector(
onTap: () => onChanged(id),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: on ? kDeskActive : Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: on ? const Color(0xFFB8D4FF) : kDeskLine),
),
child: Text(label, style: TextStyle(fontSize: 12, color: on ? kDeskBind : kDeskMute, fontWeight: on ? FontWeight.w600 : FontWeight.w400)),
),
);
}
}
void deskToast(BuildContext context, String msg, {bool error = false}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(msg),
backgroundColor: error ? const Color(0xFFFA5151) : null,
behavior: SnackBarBehavior.floating,
width: 320,
),
);
}
+328
View File
@@ -0,0 +1,328 @@
import 'dart:async';
import 'dart:io';
import 'package:audioplayers/audioplayers.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/services.dart';
import 'package:geolocator/geolocator.dart';
import 'package:path_provider/path_provider.dart';
import 'package:record/record.dart';
import '../api/oa_client.dart';
/// 系统截屏、高刷、录音、选图(保留 GIF)。
class DeviceBridge {
DeviceBridge._();
static const _ch = EventChannel('com.fysxkj.oa/device');
static const _m = MethodChannel('com.fysxkj.oa/ota');
static StreamSubscription<dynamic>? _sub;
static final _cbs = <void Function()>{};
static final AudioPlayer _player = AudioPlayer();
static final AudioRecorder _recorder = AudioRecorder();
static String? _recordingPath;
static DateTime? _recordingStarted;
static String? _playingPath;
static OaClient? _api;
static void bindApi(OaClient api) => _api = api;
static bool get _useNativeBridge => Platform.isAndroid;
static Future<void> preferMaxRefresh() async {
if (!_useNativeBridge) return;
try {
await _m.invokeMethod('preferMaxRefresh');
} catch (_) {}
}
static Timer? _clipPoll;
static bool _clipHadImage = false;
static int _lastClipAt = 0;
static void watchScreenshot(void Function() onShot) {
_cbs.add(onShot);
if (Platform.isAndroid || Platform.isWindows) {
_sub ??= _ch.receiveBroadcastStream().listen((event) {
if (event == 'screenshot') {
for (final cb in [..._cbs]) {
cb();
}
}
});
_m.invokeMethod('startScreenshotWatch').catchError((_) {});
} else if (Platform.isLinux) {
_startLinuxScreenshotPoll();
}
}
static void _startLinuxScreenshotPoll() {
_clipPoll ??= Timer.periodic(const Duration(milliseconds: 900), (_) async {
final now = DateTime.now().millisecondsSinceEpoch;
if (now - _lastClipAt < 1500) return;
final has = await _linuxClipboardHasImage();
if (has && !_clipHadImage) {
_clipHadImage = true;
_lastClipAt = now;
for (final cb in [..._cbs]) {
cb();
}
} else if (!has) {
_clipHadImage = false;
}
});
}
static Future<bool> _linuxClipboardHasImage() async {
try {
final x = await Process.run('bash', [
'-c',
'command -v xclip >/dev/null && xclip -selection clipboard -t TARGETS -o 2>/dev/null | grep -qi image && echo 1; '
'command -v wl-paste >/dev/null && wl-paste --list-types 2>/dev/null | grep -qi image && echo 1',
]);
return '${x.stdout}'.contains('1');
} catch (_) {
return false;
}
}
static void unwatchScreenshot(void Function() onShot) {
_cbs.remove(onShot);
if (_cbs.isNotEmpty) return;
if (Platform.isAndroid || Platform.isWindows) {
_m.invokeMethod('stopScreenshotWatch').catchError((_) {});
_sub?.cancel();
_sub = null;
}
_clipPoll?.cancel();
_clipPoll = null;
_clipHadImage = false;
}
static Future<void> startVoice() async {
if (_useNativeBridge) {
await _m.invokeMethod('startVoice');
return;
}
if (!await _recorder.hasPermission()) {
throw Exception('没有麦克风权限');
}
final dir = await getTemporaryDirectory();
_recordingPath = '${dir.path}/voice_${DateTime.now().millisecondsSinceEpoch}.m4a';
_recordingStarted = DateTime.now();
await _recorder.start(const RecordConfig(encoder: AudioEncoder.aacLc), path: _recordingPath!);
}
static Future<Map<String, dynamic>?> stopVoice() async {
if (_useNativeBridge) {
final raw = await _m.invokeMethod('stopVoice');
if (raw is Map) return Map<String, dynamic>.from(raw);
return null;
}
final path = await _recorder.stop();
_recordingPath = null;
if (path == null || path.isEmpty) return null;
final started = _recordingStarted;
_recordingStarted = null;
final seconds = started == null ? 1 : DateTime.now().difference(started).inSeconds.clamp(1, 60);
return {'path': path, 'seconds': seconds};
}
static Future<void> cancelVoice() async {
if (_useNativeBridge) {
try {
await _m.invokeMethod('cancelVoice');
} catch (_) {}
return;
}
try {
await _recorder.stop();
} catch (_) {}
_recordingPath = null;
_recordingStarted = null;
}
static Future<void> playVoice(String path) async {
if (_useNativeBridge) {
await _m.invokeMethod('playVoice', {'path': path});
return;
}
await _player.stop();
_playingPath = path;
await _player.play(DeviceFileSource(path));
}
static Future<void> stopPlay() async {
if (_useNativeBridge) {
try {
await _m.invokeMethod('stopPlay');
} catch (_) {}
return;
}
await _player.stop();
_playingPath = null;
}
static String? get playingPath => _playingPath;
/// 手机端本地语音转文字(不调 OA 服务端)。
static Future<String?> speechToText({required String audioPath}) async {
if (!_useNativeBridge) return null;
final value = await _m.invokeMethod('speechToText', {'path': audioPath});
final text = '$value'.trim();
return value == null || text.isEmpty || text == 'null' ? null : text;
}
static final AudioPlayer _tonePlayer = AudioPlayer();
static Future<void> startCallTone({bool incoming = false}) async {
if (_useNativeBridge) {
try {
await _m.invokeMethod('startCallTone', {'incoming': incoming});
} catch (_) {}
return;
}
try {
await _tonePlayer.setReleaseMode(ReleaseMode.loop);
await _tonePlayer.play(AssetSource('sounds/ring.wav'));
} catch (_) {}
}
static Future<void> stopCallTone() async {
if (_useNativeBridge) {
try {
await _m.invokeMethod('stopCallTone');
} catch (_) {}
return;
}
try {
await _tonePlayer.stop();
} catch (_) {}
}
static Future<void> cancelCallNotification() async {
if (!_useNativeBridge) return;
try {
await _m.invokeMethod('cancelCallNotification');
} catch (_) {}
}
static Future<void> playMessageSent() async {
if (!_useNativeBridge) return;
try {
await _m.invokeMethod('playMessageSent');
} catch (_) {}
}
static Future<Map<String, double>?> getLocation() async {
if (_useNativeBridge) {
final raw = await _m.invokeMethod('getLocation');
if (raw is Map) {
final lat = (raw['lat'] as num?)?.toDouble();
final lng = (raw['lng'] as num?)?.toDouble();
if (lat != null && lng != null) return {'lat': lat, 'lng': lng};
}
return null;
}
try {
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) return null;
var perm = await Geolocator.checkPermission();
if (perm == LocationPermission.denied) {
perm = await Geolocator.requestPermission();
}
if (perm == LocationPermission.denied || perm == LocationPermission.deniedForever) {
return null;
}
final pos = await Geolocator.getCurrentPosition(locationSettings: const LocationSettings(accuracy: LocationAccuracy.high));
return {'lat': pos.latitude, 'lng': pos.longitude};
} catch (_) {
return null;
}
}
static Future<Map<String, String>?> pickFile() async {
if (_useNativeBridge) {
final raw = await _m.invokeMethod('pickFile');
if (raw is Map) return raw.map((k, v) => MapEntry('$k', '$v'));
return null;
}
final picked = await FilePicker.platform.pickFiles(allowMultiple: false);
if (picked == null || picked.files.isEmpty) return null;
final f = picked.files.first;
final path = f.path;
if (path == null || path.isEmpty) return null;
return {'path': path, 'name': f.name, 'mime': f.extension ?? ''};
}
static Future<void> openFile(String path, {String? mime}) async {
if (_useNativeBridge) {
await _m.invokeMethod('openFile', {'path': path, 'mime': mime ?? ''});
return;
}
if (Platform.isLinux) {
await Process.run('xdg-open', [path]);
} else if (Platform.isWindows) {
await Process.start('cmd', ['/c', 'start', '', path], mode: ProcessStartMode.detached);
}
}
static Future<void> saveImage(String path) async {
if (_useNativeBridge) {
await _m.invokeMethod('saveImage', {'path': path});
return;
}
final name = path.split('/').last;
final picked = await FilePicker.platform.saveFile(dialogTitle: '保存图片', fileName: name.isEmpty ? 'image.jpg' : name);
if (picked == null || picked.isEmpty) return;
await File(path).copy(picked);
}
static Future<void> openUrl(String url) async {
if (_useNativeBridge) {
await _m.invokeMethod('openUrl', {'url': url});
return;
}
if (Platform.isLinux) {
await Process.run('xdg-open', [url]);
} else if (Platform.isWindows) {
await Process.start('cmd', ['/c', 'start', '', url], mode: ProcessStartMode.detached);
}
}
static Future<bool> requestCallMedia() async {
if (!_useNativeBridge) return true;
try {
final ok = await _m.invokeMethod('requestCallMedia');
return ok == true;
} catch (_) {
return true;
}
}
static Future<Map<String, String>?> pickMedia() async {
if (_useNativeBridge) {
final raw = await _m.invokeMethod('pickMedia');
if (raw is Map) return raw.map((k, v) => MapEntry('$k', '$v'));
return null;
}
final picked = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'],
allowMultiple: false,
);
if (picked == null || picked.files.isEmpty) return null;
final f = picked.files.first;
final path = f.path;
if (path == null || path.isEmpty) return null;
final ext = (f.extension ?? 'jpg').toLowerCase();
final mime = ext == 'png'
? 'image/png'
: ext == 'gif'
? 'image/gif'
: ext == 'webp'
? 'image/webp'
: 'image/jpeg';
return {'path': path, 'name': f.name, 'mime': mime};
}
}
+98
View File
@@ -0,0 +1,98 @@
import 'dart:async';
import 'package:flutter/services.dart';
import '../api/oa_client.dart';
class PushBridge {
PushBridge._();
static const _m = MethodChannel('com.fysxkj.oa/ota');
static const _ev = EventChannel('com.fysxkj.oa/push');
static StreamSubscription<dynamic>? _sub;
static Future<void> requestFirstLoginPermissions() async {
try {
await _m.invokeMethod('requestFirstLoginPermissions');
} catch (_) {
try {
await _m.invokeMethod('requestPushPermission');
} catch (_) {}
}
}
static Future<void> register(OaClient api) async {
await requestFirstLoginPermissions();
for (var i = 0; i < 10; i++) {
try {
final raw = await _m.invokeMethod('getPushToken');
if (raw is Map) {
final regId = '${raw['regId'] ?? ''}';
if (regId.isNotEmpty) {
await api.post('/push/devices', {
'vendor': '${raw['vendor'] ?? 'vivo'}',
'regId': regId,
'brand': '${raw['brand'] ?? ''}',
'model': '${raw['model'] ?? ''}',
});
return;
}
}
} catch (_) {}
await Future<void>.delayed(const Duration(seconds: 2));
}
}
static Future<void> showOnline(String title, String content, {Map<String, String> extras = const {}}) async {
try {
await _m.invokeMethod('showOnlineNotification', {'title': title, 'content': content, 'extras': extras});
} catch (_) {}
}
static Future<Map<String, String>?> consumeLaunch() async {
try {
final raw = await _m.invokeMethod('consumeLaunchPush');
if (raw is Map) return raw.map((k, v) => MapEntry('$k', '$v'));
} catch (_) {}
return null;
}
static void listen(void Function(Map<String, String>) onClick) {
_sub?.cancel();
_sub = _ev.receiveBroadcastStream().listen((event) {
if (event is Map) {
onClick(event.map((k, v) => MapEntry('$k', '$v')));
}
});
}
static void stop() {
_sub?.cancel();
_sub = null;
}
static Future<Map<String, dynamic>?> oemStatus() async {
try {
final raw = await _m.invokeMethod('oemStatus');
if (raw is Map) return Map<String, dynamic>.from(raw);
} catch (_) {}
return null;
}
static Future<void> openOemKeepAlive() async {
try {
await _m.invokeMethod('openOemKeepAlive');
} catch (_) {}
}
static Future<void> requestBattery() async {
try {
await _m.invokeMethod('requestBattery');
} catch (_) {}
}
static Future<void> markOemPrompted() async {
try {
await _m.invokeMethod('markOemPrompted');
} catch (_) {}
}
}
+32
View File
@@ -0,0 +1,32 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
/// 聊天消息的本地副本。云端只保留 7 天,客户端仍可查看本机历史消息。
class ChatLocalStore {
ChatLocalStore._();
static Future<List<Map<String, dynamic>>> load(String conversationId) async {
if (conversationId.isEmpty) return [];
final p = await SharedPreferences.getInstance();
final raw = p.getString('im.local.$conversationId');
if (raw == null || raw.isEmpty) return [];
try {
final list = jsonDecode(raw);
if (list is! List) return [];
return list.whereType<Map>().map((e) => Map<String, dynamic>.from(e)).toList();
} catch (_) {
return [];
}
}
static Future<void> save(String conversationId, List<Map<String, dynamic>> items) async {
if (conversationId.isEmpty) return;
final p = await SharedPreferences.getInstance();
final durable = items.where((e) => e['_pending'] != true).map((e) {
final copy = Map<String, dynamic>.from(e)..remove('_pending');
return copy;
}).toList();
await p.setString('im.local.$conversationId', jsonEncode(durable));
}
}
+90
View File
@@ -0,0 +1,90 @@
import 'package:shared_preferences/shared_preferences.dart';
/// 会话级本地偏好:免打扰、置顶、背景、本地清空记录。
class ChatPrefs {
ChatPrefs._();
static SharedPreferences? _p;
static Future<void> ensure() async {
_p ??= await SharedPreferences.getInstance();
// 旧版本曾在滑动过程中误写 hidden,导致会话读完后看似“自动删除”。
// 升级时清理一次旧标记,之后只有确认“不显示/删除”才会再次隐藏。
if (_p!.getBool('im.hidden.migration.v3') != true) {
for (final key
in _p!.getKeys().where((k) => k.startsWith('im.hidden.'))) {
await _p!.remove(key);
}
await _p!.setBool('im.hidden.migration.v3', true);
}
}
static bool muted(String id) =>
id.isEmpty ? false : (_p?.getBool('im.mute.$id') ?? false);
static bool pinned(String id) =>
id.isEmpty ? false : (_p?.getBool('im.pin.$id') ?? false);
static bool hidden(String id) =>
id.isEmpty ? false : (_p?.getBool('im.hidden.$id') ?? false);
static int? bgColor(String id) => id.isEmpty ? null : _p?.getInt('im.bg.$id');
static DateTime? clearedAt(String id) {
if (id.isEmpty) return null;
final ms = _p?.getInt('im.cleared.$id');
if (ms == null || ms <= 0) return null;
return DateTime.fromMillisecondsSinceEpoch(ms);
}
static Future<void> setMuted(String id, bool v) async {
await ensure();
await _p!.setBool('im.mute.$id', v);
}
static Future<void> setPinned(String id, bool v) async {
await ensure();
await _p!.setBool('im.pin.$id', v);
}
static Future<void> setHidden(String id, bool v) async {
await ensure();
await _p!.setBool('im.hidden.$id', v);
}
static Future<void> setBg(String id, int color) async {
await ensure();
await _p!.setInt('im.bg.$id', color);
}
static Future<void> clearHistory(String id) async {
await ensure();
await _p!.setInt('im.cleared.$id', DateTime.now().millisecondsSinceEpoch);
}
static int fakeUnread(String id) =>
id.isEmpty ? 0 : (_p?.getInt('im.fakeUnread.$id') ?? 0);
static Future<void> setFakeUnread(String id, int n) async {
await ensure();
if (n <= 0) {
await _p!.remove('im.fakeUnread.$id');
} else {
await _p!.setInt('im.fakeUnread.$id', n);
}
}
/// 语音转文字结果,按消息 id 或 fileId 缓存。
static String? voiceText(String key) {
if (key.isEmpty) return null;
final v = _p?.getString('im.voiceText.$key');
if (v == null || v.isEmpty) return null;
return v;
}
static Future<void> setVoiceText(String key, String text) async {
if (key.isEmpty) return;
await ensure();
final t = text.trim();
if (t.isEmpty) {
await _p!.remove('im.voiceText.$key');
} else {
await _p!.setString('im.voiceText.$key', t);
}
}
}
+38
View File
@@ -0,0 +1,38 @@
import 'package:flutter/services.dart';
/// 与官方 MobileIMSDK 对齐的生命周期:登录前 init,退出 release。
/// Android 走 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;
}
}
+42
View File
@@ -0,0 +1,42 @@
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/widgets.dart';
import '../session/session.dart';
/// 监听网络切换与应用恢复,触发 IM 极速重连。
class ImLifecycle with WidgetsBindingObserver {
ImLifecycle(this.session);
final SessionStore session;
StreamSubscription<List<ConnectivityResult>>? _netSub;
List<ConnectivityResult> _lastNet = const [];
void start() {
WidgetsBinding.instance.addObserver(this);
_netSub = Connectivity().onConnectivityChanged.listen(_onNet);
Connectivity().checkConnectivity().then((r) => _lastNet = r);
}
void stop() {
WidgetsBinding.instance.removeObserver(this);
_netSub?.cancel();
_netSub = null;
}
void _onNet(List<ConnectivityResult> next) {
final wasOffline = _lastNet.every((e) => e == ConnectivityResult.none);
final nowOnline = next.any((e) => e != ConnectivityResult.none);
_lastNet = next;
if (wasOffline && nowOnline && session.signedIn) {
unawaited(session.connectIm(force: true));
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed && session.signedIn) {
unawaited(session.connectIm(force: true));
}
}
}
+33
View File
@@ -0,0 +1,33 @@
import 'package:shared_preferences/shared_preferences.dart';
/// 本地记录各会话已同步到的最大 seq,断线重连后用于 gap fill。
class ImSeqStore {
ImSeqStore._();
static SharedPreferences? _p;
static Future<void> ensure() async {
_p ??= await SharedPreferences.getInstance();
}
static Future<int> lastSeq(String conversationId) async {
if (conversationId.isEmpty) return 0;
await ensure();
return _p?.getInt('im.seq.$conversationId') ?? 0;
}
static Future<void> setLastSeq(String conversationId, int seq) async {
if (conversationId.isEmpty || seq <= 0) return;
await ensure();
final prev = _p?.getInt('im.seq.$conversationId') ?? 0;
if (seq > prev) await _p!.setInt('im.seq.$conversationId', seq);
}
static Future<void> applyItems(String conversationId, Iterable<Map<String, dynamic>> items) async {
var max = 0;
for (final e in items) {
final s = (e['seq'] as num?)?.toInt() ?? 0;
if (s > max) max = s;
}
if (max > 0) await setLastSeq(conversationId, max);
}
}
+81
View File
@@ -0,0 +1,81 @@
/// 姓名首字母。按百家姓,避免大量同事被丢进「#」。
const _surnames = {
'赵': 'Z', '钱': 'Q', '孙': 'S', '李': 'L', '周': 'Z', '吴': 'W', '郑': 'Z', '王': 'W',
'冯': 'F', '陈': 'C', '褚': 'C', '卫': 'W', '蒋': 'J', '沈': 'S', '韩': 'H', '杨': 'Y',
'朱': 'Z', '秦': 'Q', '尤': 'Y', '许': 'X', '何': 'H', '吕': 'L', '施': 'S', '张': 'Z',
'孔': 'K', '曹': 'C', '严': 'Y', '华': 'H', '金': 'J', '魏': 'W', '陶': 'T', '姜': 'J',
'戚': 'Q', '谢': 'X', '邹': 'Z', '喻': 'Y', '柏': 'B', '水': 'S', '窦': 'D', '章': 'Z',
'云': 'Y', '苏': 'S', '潘': 'P', '葛': 'G', '奚': 'X', '范': 'F', '彭': 'P', '郎': 'L',
'鲁': 'L', '韦': 'W', '昌': 'C', '马': 'M', '苗': 'M', '凤': 'F', '花': 'H', '方': 'F',
'俞': 'Y', '任': 'R', '袁': 'Y', '柳': 'L', '酆': 'F', '鲍': 'B', '史': 'S', '唐': 'T',
'费': 'F', '廉': 'L', '岑': 'C', '薛': 'X', '雷': 'L', '贺': 'H', '倪': 'N', '汤': 'T',
'滕': 'T', '殷': 'Y', '罗': 'L', '毕': 'B', '郝': 'H', '邬': 'W', '安': 'A', '常': 'C',
'乐': 'Y', '于': 'Y', '时': 'S', '傅': 'F', '皮': 'P', '卞': 'B', '齐': 'Q', '康': 'K',
'伍': 'W', '余': 'Y', '元': 'Y', '卜': 'B', '顾': 'G', '孟': 'M', '平': 'P', '黄': 'H',
'和': 'H', '穆': 'M', '萧': 'X', '尹': 'Y', '姚': 'Y', '邵': 'S', '湛': 'Z', '汪': 'W',
'祁': 'Q', '毛': 'M', '禹': 'Y', '狄': 'D', '米': 'M', '贝': 'B', '明': 'M', '臧': 'Z',
'计': 'J', '伏': 'F', '成': 'C', '戴': 'D', '谈': 'T', '宋': 'S', '茅': 'M', '庞': 'P',
'熊': 'X', '纪': 'J', '舒': 'S', '屈': 'Q', '项': 'X', '祝': 'Z', '董': 'D', '梁': 'L',
'杜': 'D', '阮': 'R', '蓝': 'L', '闵': 'M', '席': 'X', '季': 'J', '麻': 'M', '强': 'Q',
'贾': 'J', '路': 'L', '娄': 'L', '危': 'W', '江': 'J', '童': 'T', '颜': 'Y', '郭': 'G',
'梅': 'M', '盛': 'S', '林': 'L', '刁': 'D', '钟': 'Z', '徐': 'X', '邱': 'Q', '骆': 'L',
'高': 'G', '夏': 'X', '蔡': 'C', '田': 'T', '樊': 'F', '胡': 'H', '凌': 'L', '霍': 'H',
'虞': 'Y', '万': 'W', '支': 'Z', '柯': 'K', '昝': 'Z', '管': 'G', '卢': 'L', '莫': 'M',
'经': 'J', '房': 'F', '裘': 'Q', '缪': 'M', '干': 'G', '解': 'X', '应': 'Y', '宗': 'Z',
'丁': 'D', '宣': 'X', '贲': 'B', '邓': 'D', '郁': 'Y', '单': 'S', '杭': 'H', '洪': 'H',
'包': 'B', '诸': 'Z', '左': 'Z', '石': 'S', '崔': 'C', '吉': 'J', '钮': 'N', '龚': 'G',
'程': 'C', '嵇': 'J', '邢': 'X', '滑': 'H', '裴': 'P', '陆': 'L', '荣': 'R', '翁': 'W',
'荀': 'X', '羊': 'Y', '於': 'Y', '惠': 'H', '甄': 'Z', '曲': 'Q', '家': 'J', '封': 'F',
'芮': 'R', '羿': 'Y', '储': 'C', '靳': 'J', '汲': 'J', '邴': 'B', '糜': 'M', '松': 'S',
'井': 'J', '段': 'D', '富': 'F', '巫': 'W', '乌': 'W', '焦': 'J', '巴': 'B', '弓': 'G',
'牧': 'M', '隗': 'K', '山': 'S', '谷': 'G', '车': 'C', '侯': 'H', '宓': 'M', '蓬': 'P',
'全': 'Q', '郗': 'X', '班': 'B', '仰': 'Y', '秋': 'Q', '仲': 'Z', '伊': 'Y', '宫': 'G',
'宁': 'N', '仇': 'Q', '栾': 'L', '暴': 'B', '甘': 'G', '钭': 'T', '厉': 'L', '戎': 'R',
'祖': 'Z', '武': 'W', '符': 'F', '刘': 'L', '景': 'J', '詹': 'Z', '束': 'S', '龙': 'L',
'叶': 'Y', '幸': 'X', '司': 'S', '韶': 'S', '郜': 'G', '黎': 'L', '蓟': 'J', '薄': 'B',
'印': 'Y', '宿': 'S', '白': 'B', '怀': 'H', '蒲': 'P', '邰': 'T', '从': 'C', '鄂': 'E',
'索': 'S', '咸': 'X', '籍': 'J', '赖': 'L', '卓': 'Z', '蔺': 'L', '屠': 'T', '蒙': 'M',
'池': 'C', '乔': 'Q', '阴': 'Y', '胥': 'X', '能': 'N', '苍': 'C', '双': 'S', '闻': 'W',
'莘': 'S', '党': 'D', '翟': 'Z', '谭': 'T', '贡': 'G', '劳': 'L', '逄': 'P', '姬': 'J',
'申': 'S', '扶': 'F', '堵': 'D', '冉': 'R', '宰': 'Z', '郦': 'L', '雍': 'Y', '郤': 'X',
'璩': 'Q', '桑': 'S', '桂': 'G', '濮': 'P', '牛': 'N', '寿': 'S', '通': 'T', '边': 'B',
'扈': 'H', '燕': 'Y', '冀': 'J', '郏': 'J', '浦': 'P', '尚': 'S', '农': 'N', '温': 'W',
'别': 'B', '庄': 'Z', '晏': 'Y', '柴': 'C', '瞿': 'Q', '阎': 'Y', '充': 'C', '慕': 'M',
'连': 'L', '茹': 'R', '习': 'X', '宦': 'H', '艾': 'A', '鱼': 'Y', '容': 'R', '向': 'X',
'古': 'G', '易': 'Y', '慎': 'S', '戈': 'G', '廖': 'L', '庾': 'Y', '终': 'Z', '暨': 'J',
'居': 'J', '衡': 'H', '步': 'B', '都': 'D', '耿': 'G', '满': 'M', '弘': 'H', '匡': 'K',
'国': 'G', '文': 'W', '寇': 'K', '广': 'G', '禄': 'L', '阙': 'Q', '东': 'D', '欧': 'O',
'殳': 'S', '沃': 'W', '利': 'L', '蔚': 'W', '越': 'Y', '夔': 'K', '隆': 'L', '师': 'S',
'巩': 'G', '厍': 'S', '聂': 'N', '晁': 'C', '勾': 'G', '敖': 'A', '融': 'R', '冷': 'L',
'訾': 'Z', '辛': 'X', '阚': 'K', '那': 'N', '简': 'J', '饶': 'R', '空': 'K', '曾': 'Z',
'毋': 'W', '沙': 'S', '乜': 'N', '养': 'Y', '鞠': 'J', '须': 'X', '丰': 'F', '巢': 'C',
'关': 'G', '蒯': 'K', '相': 'X', '查': 'Z', '后': 'H', '荆': 'J', '红': 'H', '游': 'Y',
'竺': 'Z', '权': 'Q', '逯': 'L', '盖': 'G', '益': 'Y', '桓': 'H', '公': 'G', '万俟': 'M',
'司马': 'S', '上官': 'S', '欧阳': 'O', '夏侯': 'X', '诸葛': 'Z', '闻人': 'W', '东方': 'D',
'赫连': 'H', '皇甫': 'H', '尉迟': 'Y', '公羊': 'G', '澹台': 'T', '公冶': 'G', '宗政': 'Z',
'濮阳': 'P', '淳于': 'C', '单于': 'C', '太叔': 'T', '申屠': 'S', '公孙': 'G', '仲孙': 'Z',
'轩辕': 'X', '令狐': 'L', '钟离': 'Z', '宇文': 'Y', '长孙': 'Z', '慕容': 'M', '鲜于': 'X',
'闾丘': 'L', '司徒': 'S', '司空': 'S', '丌官': 'Q', '司寇': 'S', '仉': 'Z', '督': 'D',
'子车': 'Z', '颛孙': 'Z', '端木': 'D', '巫马': 'W', '公西': 'G', '漆雕': 'Q', '乐正': 'Y',
'壤驷': 'R', '公良': 'G', '拓跋': 'T', '夹谷': 'J', '宰父': 'Z', '谷梁': 'G', '晋': 'J',
'楚': 'C', '闫': 'Y', '法': 'F', '汝': 'R', '鄢': 'Y', '涂': 'T', '钦': 'Q', '段干': 'D',
'百里': 'B', '东郭': 'D', '南门': 'N', '呼延': 'H', '归': 'G', '海': 'H', '羊舌': 'Y',
'微生': 'W', '岳': 'Y', '帅': 'S', '缑': 'G', '亢': 'K', '况': 'K', '有': 'Y',
'琴': 'Q', '梁丘': 'L', '左丘': 'Z', '东门': 'D', '西门': 'X', '商': 'S', '牟': 'M',
'佘': 'S', '佴': 'N', '伯': 'B', '赏': 'S', '南宫': 'N', '墨': 'M', '哈': 'H', '谯': 'Q',
'笪': 'D', '年': 'N', '爱': 'A', '阳': 'Y', '佟': 'T', '言': 'Y', '福': 'F', '西': 'X',
'南': 'N', '北': 'B',
};
String letterOf(String name) {
final n = name.trim();
if (n.isEmpty) return '#';
if (n.length >= 2) {
final two = _surnames[n.substring(0, 2)];
if (two != null) return two;
}
final c = n[0];
if (RegExp(r'[A-Za-z]').hasMatch(c)) return c.toUpperCase();
if (RegExp(r'[0-9]').hasMatch(c)) return '#';
return _surnames[c] ?? '#';
}
+73
View File
@@ -0,0 +1,73 @@
import 'dart:convert';
import 'dart:typed_data';
/// MobileIMSDK 官方 TCP 帧:4 字节大端长度 + UTF-8 JSON Protocal。
class ClientType {
static const login = 0;
static const keepAlive = 1;
static const commonData = 2;
static const logout = 3;
static const received = 4;
static const echo = 5;
}
class ServerType {
static const login = 50;
static const keepAlive = 51;
static const error = 52;
static const echo = 53;
static const kickout = 54;
}
class Protocal {
Protocal({
required this.type,
this.dataContent = '',
this.from = '0',
this.to = '0',
this.fp,
this.qos = false,
this.typeu = -1,
this.sm = -1,
});
int type;
String dataContent;
String from;
String to;
String? fp;
bool qos;
int typeu;
int sm;
Map<String, dynamic> toJson() => {
'bridge': false,
'type': type,
'dataContent': dataContent,
'from': from,
'to': to,
'fp': fp,
'QoS': qos,
'typeu': typeu,
'sm': sm,
};
factory Protocal.fromJson(Map<String, dynamic> j) => Protocal(
type: (j['type'] as num?)?.toInt() ?? 0,
dataContent: '${j['dataContent'] ?? ''}',
from: '${j['from'] ?? '0'}',
to: '${j['to'] ?? '0'}',
fp: j['fp']?.toString(),
qos: j['QoS'] == true || j['qos'] == true,
typeu: (j['typeu'] as num?)?.toInt() ?? -1,
sm: (j['sm'] as num?)?.toInt() ?? -1,
);
}
Uint8List encodeFrame(Protocal p) {
final body = utf8.encode(jsonEncode(p.toJson()));
final header = ByteData(4)..setUint32(0, body.length);
return Uint8List.fromList([...header.buffer.asUint8List(), ...body]);
}
const maxBody = 6 * 1024;
+32
View File
@@ -0,0 +1,32 @@
import 'package:shared_preferences/shared_preferences.dart';
/// 自定义表情:服务端 fileId 列表。GIF / 表情面板发出的都走这里。
class StickerStore {
StickerStore._();
static const _key = 'im.stickers';
static SharedPreferences? _p;
static List<String> _ids = [];
static List<String> get ids => List.unmodifiable(_ids);
static Future<void> ensure() async {
_p ??= await SharedPreferences.getInstance();
_ids = _p!.getStringList(_key) ?? [];
}
static bool has(String fileId) => fileId.isNotEmpty && _ids.contains(fileId);
static Future<void> add(String fileId) async {
if (fileId.isEmpty) return;
await ensure();
if (_ids.contains(fileId)) return;
_ids = [fileId, ..._ids];
await _p!.setStringList(_key, _ids);
}
static Future<void> remove(String fileId) async {
await ensure();
_ids = _ids.where((e) => e != fileId).toList();
await _p!.setStringList(_key, _ids);
}
}
+300
View File
@@ -0,0 +1,300 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:uuid/uuid.dart';
import 'protocol.dart';
import '../device/device_bridge.dart';
import '../device/push_bridge.dart';
class ImIncoming {
ImIncoming({
required this.from,
required this.text,
this.kind = 'chat',
this.fp,
this.seq = 0,
this.raw = const {},
});
final String from;
final String text;
final String kind;
final String? fp;
final int seq;
final Map<String, dynamic> raw;
}
class _Pending {
_Pending(this.p);
final Protocal p;
int tries = 0;
}
/// 官方 TCP ClientCoreSDK 等价实现:登录、心跳、通用数据、QoS 应答与重发。
class ImTcpClient extends ChangeNotifier {
Socket? _socket;
Uint8List _buf = Uint8List(0);
Timer? _hb;
Timer? _qos;
Timer? _reconnect;
bool connected = false;
bool loggedIn = false;
String lastError = '';
String userId = '';
int firstLoginTime = 0;
final Map<String, _Pending> _pending = {};
final List<ImIncoming> inbox = [];
final _uuid = const Uuid();
String _host = '';
int _port = 0;
String _token = '';
bool _want = false;
int _tries = 0;
DateTime _lastHbAck = DateTime.now();
VoidCallback? onLoggedIn;
Future<void> connect({
required String host,
required int port,
required String userId,
required String token,
}) async {
_host = host;
_port = port;
_token = token;
this.userId = userId;
_want = true;
_tries = 0;
await _open();
}
Future<void> _open() async {
_tearDownSocket();
lastError = '';
try {
_socket = await Socket.connect(_host, _port,
timeout: const Duration(seconds: 8));
connected = true;
_tries = 0;
notifyListeners();
_socket!.listen(_onData, onDone: _onDrop, onError: (_) => _onDrop());
firstLoginTime = DateTime.now().millisecondsSinceEpoch;
_send(Protocal(
type: ClientType.login,
from: userId,
dataContent: jsonEncode({
'loginUserId': userId,
'loginToken': _token,
'clientKind': 'mobile',
'extra': '',
'firstLoginTime': firstLoginTime,
}),
));
_hb = Timer.periodic(const Duration(seconds: 5), (_) {
if (loggedIn) {
if (DateTime.now().difference(_lastHbAck).inSeconds > 25) {
_onDrop();
return;
}
_send(Protocal(type: ClientType.keepAlive, from: userId));
}
});
_qos = Timer.periodic(const Duration(seconds: 4), (_) => _retryQos());
} catch (e) {
lastError = 'IM 连接失败:$e';
connected = false;
loggedIn = false;
notifyListeners();
_scheduleReconnect();
}
}
void _onDrop() {
final was = connected || loggedIn;
_tearDownSocket();
if (was) notifyListeners();
if (_want) _scheduleReconnect();
}
void _scheduleReconnect() {
if (!_want) return;
_reconnect?.cancel();
_tries = (_tries + 1).clamp(1, 12);
_reconnect = Timer(Duration(seconds: _tries < 4 ? 2 : 5), () {
if (_want && !loggedIn) _open();
});
}
void disconnect() {
_want = false;
_reconnect?.cancel();
_reconnect = null;
_tearDownSocket();
notifyListeners();
}
void _tearDownSocket() {
_hb?.cancel();
_qos?.cancel();
_hb = null;
_qos = null;
try {
_socket?.destroy();
} catch (_) {}
_socket = null;
_buf = Uint8List(0);
connected = false;
loggedIn = false;
}
void bump() {
notifyListeners();
}
Future<String?> sendChat(String toUserId, String text) async {
if (!loggedIn) return null;
final fp = _uuid.v4();
final p = Protocal(
type: ClientType.commonData,
from: userId,
to: toUserId,
dataContent:
jsonEncode({'kind': 'chat', 'text': text, 'fromUserId': userId}),
qos: true,
fp: fp,
typeu: 0,
sm: DateTime.now().millisecondsSinceEpoch,
);
_pending[fp] = _Pending(p);
_send(p);
return fp;
}
void _retryQos() {
for (final e in [..._pending.entries]) {
e.value.tries++;
if (e.value.tries > 3) {
_pending.remove(e.key);
continue;
}
_send(e.value.p);
}
}
void _send(Protocal p) {
final s = _socket;
if (s == null) return;
try {
s.add(encodeFrame(p));
} catch (_) {}
}
void _onData(List<int> chunk) {
_buf = Uint8List.fromList([..._buf, ...chunk]);
while (_buf.length >= 4) {
final len = ByteData.sublistView(_buf).getUint32(0);
if (len <= 0 || len > maxBody) {
disconnect();
return;
}
if (_buf.length < 4 + len) break;
final body = utf8.decode(_buf.sublist(4, 4 + len));
_buf = Uint8List.fromList(_buf.sublist(4 + len));
try {
_handle(Protocal.fromJson(jsonDecode(body) as Map<String, dynamic>));
} catch (_) {}
}
}
void _handle(Protocal p) {
switch (p.type) {
case ServerType.login:
try {
final info = jsonDecode(p.dataContent.isEmpty ? '{}' : p.dataContent);
loggedIn = info is Map && info['code'] == 0;
if (loggedIn && info['firstLoginTime'] != null) {
firstLoginTime = (info['firstLoginTime'] as num).toInt();
}
if (!loggedIn) lastError = 'IM 登录被拒绝';
} catch (_) {
loggedIn = false;
}
if (loggedIn) {
_lastHbAck = DateTime.now();
onLoggedIn?.call();
}
notifyListeners();
break;
case ServerType.keepAlive:
case ServerType.echo:
_lastHbAck = DateTime.now();
break;
case ServerType.kickout:
_tearDownSocket();
notifyListeners();
if (_want) _scheduleReconnect();
break;
case ClientType.received:
_pending.remove(p.dataContent);
break;
case ClientType.commonData:
if (p.qos && p.fp != null) {
_send(Protocal(
type: ClientType.received,
from: userId,
to: p.from,
dataContent: p.fp!));
}
String text = p.dataContent;
var kind = 'chat';
Map<String, dynamic> raw = {};
try {
raw = Map<String, dynamic>.from(jsonDecode(p.dataContent) as Map);
text = '${raw['text'] ?? raw['title'] ?? raw['body'] ?? text}';
kind = '${raw['kind'] ?? 'chat'}';
} catch (_) {}
final seq = (raw['seq'] as num?)?.toInt() ??
(p.sm > 0 ? p.sm : 0);
final fp = '${raw['fingerprint'] ?? p.fp ?? ''}';
if (fp.isNotEmpty) {
final dup = inbox.any((e) => e.fp == fp);
if (dup) break;
}
inbox.insert(
0,
ImIncoming(
from: p.from,
text: text,
kind: kind,
fp: fp.isEmpty ? p.fp : fp,
seq: seq,
raw: raw));
final terminalCall = kind == 'call' &&
const {'end', 'reject', 'timeout'}
.contains('${raw['action'] ?? ''}');
if (terminalCall) {
unawaited(DeviceBridge.cancelCallNotification());
} else if (p.from != userId && kind != 'receipt' && kind != 'signal') {
unawaited(PushBridge.showOnline(
'${raw['fromName'] ?? raw['title'] ?? '风影办公'}',
text,
extras: {
if (raw['conversationId'] != null)
'conversationId': '${raw['conversationId']}',
if (raw['kind'] != null) 'kind': '$kind',
if (raw['callId'] != null) 'callId': '${raw['callId']}',
if (raw['callKind'] != null) 'callKind': '${raw['callKind']}',
if (raw['fromName'] != null) 'fromName': '${raw['fromName']}',
if (raw['fromUserId'] != null) 'peerId': '${raw['fromUserId']}',
if (raw['action'] != null) 'action': '${raw['action']}',
},
));
}
if (inbox.length > 200) inbox.removeLast();
notifyListeners();
break;
}
}
}
+407
View File
@@ -0,0 +1,407 @@
import 'package:flutter/material.dart';
import 'theme.dart';
/// 后端枚举 / 码 → 中文。对齐 web `labels.ts` + 投标状态。
const zhMap = <String, String>{
'TECH_INITIAL': '技术初审',
'FETCH': '取标',
'TECH_FINAL': '技术终审',
'DRAFTING': '制标',
'REVIEW1': '商务一审',
'REVIEW2': '商务二审',
'PRINT': '印封',
'LIST': '投标执行',
'PENDING': '待审批',
'WON': '中标',
'LOST': '未中标',
'FAILED': '流标废标',
'TERMINATED': '已终止',
'DRAFT': '草稿',
'APPROVED': '已通过',
'REJECTED': '已驳回',
'CANCELLED': '已取消',
'ACTIVE': '启用',
'DISABLED': '停用',
'OPEN': '进行中',
'OFFSET': '已冲账',
'DONE': '已完成',
'RETURNED': '已交回',
'PAID': '已付',
'UNPAID': '待付',
'TO_ISSUE': '待开',
'ISSUED': '已开',
'RECEIVED': '已回',
'OVERDUE': '逾期',
'INTERNAL_ACCEPTED': '内部已验收',
'ACCEPTED': '已验收',
'ALL': '全部',
'DEPT': '本部门',
'SELF': '本人',
'BID': '投标',
'PROJECT': '正式项目',
'ADMIN': '行政',
'MARKETING': '营销',
'LABOR': '人工',
'TRAVEL': '差旅',
'PURCHASE': '采购',
'TENDER': '招标',
'PERFORMANCE': '履约保证金',
'WARRANTY': '项目质保金',
'EXPENSE': '报销',
'BOND': '保证金打出',
'LOAN': '借款',
'OFFICE': '办公申请',
'SEAL': '用章',
'HR': '人事',
'LEAVE': '请假',
'OVERTIME': '加班',
'TRIP': '出差',
'OUT': '外出',
'RESIGN': '离职',
'PROMOTE': '晋升',
'INTERN': '实习',
'PROBATION': '试用',
'REGULAR': '正式',
'LEFT': '离职',
'SINGLE': '单休',
'DOUBLE': '双休',
'MALE': '男',
'FEMALE': '女',
'HIGH_SCHOOL': '高中及以下',
'COLLEGE': '专科',
'BACHELOR': '本科',
'MASTER': '硕士',
'DOCTOR': '博士',
'HIRE': '入职',
'REGULARIZE': '转正',
'TRANSFER': '调岗',
'CREATE': '发起',
'SUBMIT': '提交',
'APPROVE': '同意',
'REJECT': '驳回',
'DRAFTER': '制标人',
'FETCHER': '取标人',
'COORDINATOR': '特殊协调人',
'BIDDER': '投标人',
'SPECIAL': '特殊筛选',
'FLOW': '普通筛选',
'BID_TECH_INITIAL': '技术初审',
'BID_TECH_FINAL': '技术终审',
'BID_FETCH': '取标',
'BID_ASSIGN_FETCH': '派取标人',
'BID_ASSIGN_DRAFT': '派制标人',
'BID_FINAL_PASSED': '终审通过通知',
'BID_DRAFT_DONE': '制标完成通知',
'BID_TERMINATED': '投标终止通知',
'BID_DRAFT': '制标',
'BID_REVIEW1': '商务一审',
'BID_REVIEW2': '商务二审',
'BID_PRINT': '印封',
'BID_COMPLETE': '补全投标',
'BID_EXECUTION': '投标执行',
'BID_ASSIGN_FETCHER': '派取标人',
'UPDATE_COMPETITOR_WIN': '登记对方中标信息',
'UPDATE_LEGAL_ENTITY': '修改参与主体',
'CONFIRMED': '已确认',
'INTERNAL': '内部验收',
'CUSTOMER': '客户验收',
'CONTRACT_APPROVAL': '合同审批',
'CONTRACT_DRAFT': '完善合同草稿',
'EXPENSE_APPROVAL': '报销确认',
'LOAN_APPROVAL': '借款确认',
'LOAN_OFFSET': '借款冲账',
'PROJECT_TIMESHEET': '工时填报',
'PROJECT_ACCEPT': '项目验收',
'SEAL_APPROVAL': '用章审批',
'SEAL_RETURN': '印章归还',
'CREDENTIAL_RETURN': '证照归还',
'BID_OPEN': '开标反馈',
'WORK_ASSIGN': '工作安排',
'OFFICE_APPLY': '办公申请',
'HR_LEAVE': '人事申请',
'SEAL_REQUEST': '用章申请',
'WORK_REPORT': '工作汇报',
'NOTICE': '通知公告',
'ASSET_BUY': '资产购置',
'ASSET_USE': '资产领用',
'VEHICLE': '用车',
'DIGITAL': '数字资产',
'FIXED': '固定资产',
'IN_USE': '占用中',
'IN_STOCK': '在库',
'OUT_STOCK': '已出库',
'TAKEN_OUT': '外带',
'LENT': '外借',
'BORROWED': '已外借',
'IDLE': '闲置',
'SCRAPPED': '已报废',
'AVAILABLE': '可用',
'MAINTAIN': '维修中',
'OCCUPIED': '占用中',
'PENDING_IN': '待入库',
'ID_CARD': '身份证',
'PASSPORT': '护照',
'OTHER_ID': '其他证件',
'GENERAL': '通用',
'TODO': '待办',
'TASK': '任务截止',
'BORROW': '证照归还',
'LOGIN': '登录',
'LOGIN_FAIL': '登录失败',
'APPROVAL': '审批',
'FORBIDDEN': '越权',
'ACL': '权限',
'SENT': '已提交',
'UPDATE': '修改',
'DELETE': '删除',
'OK': '成功',
'FAIL': '失败',
'BUSINESS': '商务',
'TECH': '技术',
'FINANCE': '财务',
'PUBLISHED': '已发布',
'ANNOUNCE': '公告',
'POLICY': '制度',
'direct': '单聊',
'group': '群聊',
'admin': '管理员',
'owner': '负责人',
'hr': '人事',
'finance': '财务',
'finance_manager': '财务经理',
'cashier': '出纳',
'seal': '用章管理员',
'asset': '资产管理员',
'project_manager': '项目经理',
'tech_director': '技术总监',
'marketing': '营销',
'bidder': '投标',
'staff': '员工',
'user': '用户',
'true': '是',
'false': '否',
'INQUIRY': '询价',
'OPEN_TENDER': '公开招标',
'INVITE': '邀请招标',
'COMPETITIVE': '竞争性谈判',
'SINGLE_SOURCE': '单一来源',
};
/// 详情页字段名。没有映射的 camelCase 一律不展示,避免露出英文列名。
const fieldZh = <String, String>{
'title': '标题',
'name': '名称',
'purpose': '事由',
'reason': '事由',
'remark': '备注',
'status': '状态',
'kind': '类型',
'source': '来源',
'sourceType': '筛选类型',
'bizType': '业务',
'amount': '金额',
'days': '天数',
'claimNo': '报销单号',
'loanNo': '借款单号',
'bidNo': '招标编号',
'externalNo': '招标编号',
'contractNo': '合同号',
'projectNo': '项目编号',
'projectType': '项目类型',
'sealType': '印章类型',
'destination': '目的地',
'assetKind': '资产类型',
'createdAt': '创建时间',
'updatedAt': '更新时间',
'dueAt': '截止',
'startAt': '开始',
'endAt': '结束',
'decidedAt': '审批时间',
'comment': '意见',
'createdBy': '发起人',
'bidCase': '关联投标',
'legalEntity': '参与主体',
'qualificationNeed': '所需资质',
'tenderMethod': '招标方式',
'applyStartAt': '申领开始',
'applyEndAt': '申领截止',
'applyMethod': '申领方式',
'openAt': '开标时间',
'openMethod': '开标方式',
'openPlace': '开标地点',
'bondMethod': '保证金',
'bondStatus': '保证金状态',
'bondType': '保证金类型',
'delivererName': '递交人',
'contactName': '联系人',
'contactPhone': '联系电话',
'nasPath': '标书路径',
'summary': '摘要',
'resultNote': '结果说明',
'printCost': '印封费用',
'printNote': '印封备注',
'projectNature': '项目性质',
'securityLevel': '密级',
'priceCap': '限价',
'quoteAmount': '报价',
'needSurvey': '需要踏勘',
'fetchedAt': '取标时间',
'printedAt': '印封时间',
'openedResultAt': '开标反馈时间',
'actionLogs': '操作记录',
'projects': '关联项目',
'files': '附件',
'reviewers': '审批人',
'assignees': '经办人',
'versions': '标书版本',
'displayName': '姓名',
'departmentName': '部门',
'department': '部门',
'mobile': '手机',
'email': '邮箱',
'party': '客商',
'applicant': '申请人',
'assignee': '经办人',
'keeper': '保管人',
'holder': '占用人',
'spec': '规格',
'unit': '单位',
'qty': '数量',
'quantity': '数量',
'location': '位置',
'address': '地址',
'code': '编号',
'no': '编号',
'type': '类型',
'category': '分类',
'level': '级别',
'priority': '优先级',
'progress': '进度',
'budget': '预算',
'cost': '成本',
'owner': '负责人',
'manager': '负责人',
'description': '说明',
'content': '内容',
'body': '正文',
'note': '备注',
'result': '结果',
'outcome': '结果',
'method': '方式',
'channel': '渠道',
'scope': '范围',
'role': '角色',
'roles': '角色',
'employmentStatus': '用工状态',
'gender': '性别',
'idNo': '证件号',
'idType': '证件类型',
'idNumber': '证件号码',
'idCard': '身份证号',
'jobTitle': '岗位',
'hireDate': '入职日期',
'hiredAt': '入职日期',
'birthday': '出生日期',
'birthDate': '出生日期',
'education': '学历',
'school': '毕业院校',
'major': '专业',
'emergencyContact': '紧急联系人',
'emergencyPhone': '紧急电话',
'bankName': '开户行',
'bankAccount': '银行账号',
'workCity': '工作城市',
'officeLocation': '办公地点',
'employeeNo': '工号',
'staffNo': '工号',
'jobNo': '工号',
'realName': '姓名',
'username': '账号',
'phone': '手机',
'wechat': '微信',
'nation': '民族',
'nativePlace': '籍贯',
'maritalStatus': '婚姻状况',
'addressHome': '家庭住址',
'entryDate': '入职日期',
'regularDate': '转正日期',
'leaveDate': '离职日期',
'contractEnd': '合同到期',
'keeperName': '保管人',
'holderName': '占用人',
'legalEntityName': '参与主体',
'winnerName': '中标单位',
'winnerAmount': '中标金额',
};
String zh(dynamic v) {
if (v == null) return '—';
if (v is bool) return v ? '是' : '否';
final s = '$v'.trim();
if (s.isEmpty) return '—';
return zhMap[s] ?? s;
}
/// 字段名转中文。仍是英文驼峰的返回空串,调用方应跳过。
String fieldLabel(String k) {
final key = k.contains('.') ? k.split('.').last : k;
if (fieldZh.containsKey(k)) return fieldZh[k]!;
if (fieldZh.containsKey(key)) return fieldZh[key]!;
if (zhMap.containsKey(key)) return zhMap[key]!;
if (RegExp(r'[\u4e00-\u9fff]').hasMatch(key)) return key;
return '';
}
Color statusColor(dynamic v) {
switch ('$v') {
case 'APPROVED':
case 'DONE':
case 'OFFSET':
case 'RETURNED':
case 'PAID':
case 'WON':
case 'ACCEPTED':
case 'ISSUED':
case 'RECEIVED':
return kWeGreen;
case 'PENDING':
case 'OPEN':
case 'DRAFT':
case 'FETCH':
case 'DRAFTING':
case 'TECH_INITIAL':
case 'TECH_FINAL':
case 'REVIEW1':
case 'REVIEW2':
case 'PRINT':
case 'LIST':
return const Color(0xFFFA9D3B);
case 'REJECTED':
case 'CANCELLED':
case 'OVERDUE':
case 'TERMINATED':
case 'FAILED':
case 'LOST':
return kDanger;
default:
return kMute;
}
}
class StatusDot extends StatelessWidget {
const StatusDot(this.code, {super.key});
final dynamic code;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: statusColor(code).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(4),
),
child: Text(zh(code), style: TextStyle(fontSize: 11, color: statusColor(code), fontWeight: FontWeight.w600)),
);
}
}
+103
View File
@@ -0,0 +1,103 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:window_manager/window_manager.dart';
import 'api/oa_client.dart';
import 'app_runtime.dart';
import 'device/device_bridge.dart';
import 'desktop/desk_platform.dart';
import 'desktop/desk_theme.dart';
import 'pages/desktop_login_page.dart';
import 'pages/login_page.dart';
import 'session/session.dart';
import 'shell/home_shell.dart';
import 'theme.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await AppRuntime.load();
if (isDesktopPlatform) {
await windowManager.ensureInitialized();
const windowOptions = WindowOptions(
size: Size(1400, 900),
center: true,
title: '风影办公',
);
windowManager.waitUntilReadyToShow(windowOptions, () async {
await windowManager.show();
await windowManager.focus();
});
}
if (!isDesktopPlatform) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values);
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
statusBarColor: Colors.white,
statusBarIconBrightness: Brightness.dark,
));
DeviceBridge.preferMaxRefresh();
}
runApp(const FengyingApp());
}
class FengyingApp extends StatefulWidget {
const FengyingApp({super.key});
@override
State<FengyingApp> createState() => _FengyingAppState();
}
class _FengyingAppState extends State<FengyingApp> {
final session = SessionStore();
late final OaClient api = OaClient(session);
@override
void initState() {
super.initState();
DeviceBridge.bindApi(api);
session.addListener(_onSession);
_boot();
}
Future<void> _boot() async {
await session.load();
if (session.signedIn) {
try {
final me = await api.get('/auth/me');
if (me is Map) {
await session.patchUser(Map<String, dynamic>.from(me));
}
final menus = await api.get('/system/menus');
await session.setMenus(asMaps(menus).map(MenuNode.fromJson).toList());
await session.connectIm();
} catch (_) {
await session.clear();
}
}
}
void _onSession() => setState(() {});
@override
void dispose() {
session.removeListener(_onSession);
session.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '风影办公',
debugShowCheckedModeBanner: false,
theme: isDesktopPlatform ? buildDesktopTheme() : buildTheme(),
home: !session.loaded
? const Scaffold(backgroundColor: Colors.white, body: Center(child: CircularProgressIndicator(color: kBind)))
: session.signedIn
? HomeShell(session: session, api: api)
: isDesktopPlatform
? DesktopLoginPage(session: session, api: api)
: LoginPage(session: session, api: api),
);
}
}
+85
View File
@@ -0,0 +1,85 @@
String? fetchPathOf(Map<String, dynamic> row) {
final forced = '${row['_fetch'] ?? ''}';
if (forced.startsWith('/')) return forced;
final id = '${row['id'] ?? ''}';
final bizId = '${row['bizId'] ?? ''}';
final source = '${row['source'] ?? ''}';
final kind = '${row['kind'] ?? ''}';
final biz = '${row['bizType'] ?? ''}';
final path = '${row['path'] ?? ''}';
if (source == 'EXPENSE' && kind == 'LOAN' && id.isNotEmpty)
return '/loans/$id';
if (source == 'EXPENSE' && id.isNotEmpty) return '/expenses/$id';
if (source == 'HR' && id.isNotEmpty) return '/leave-requests/$id';
if (source == 'OFFICE' && id.isNotEmpty) return '/office/applies/$id';
if (source == 'SEAL' && id.isNotEmpty) return '/seal-requests/$id';
if (bizId.isNotEmpty) {
if (biz == 'EXPENSE_APPROVAL' ||
biz == 'EXPENSE_DRAFT' ||
biz == 'TRAVEL_EXPENSE') return '/expenses/$bizId';
if (biz == 'LOAN_APPROVAL' || biz == 'LOAN_OFFSET') return '/loans/$bizId';
if (biz == 'HR_LEAVE') return '/leave-requests/$bizId';
if (biz == 'OFFICE_APPLY') return '/office/applies/$bizId';
if (biz == 'SEAL_APPROVAL' || biz == 'SEAL_RETURN' || biz == 'SEAL_REQUEST')
return '/seal-requests/$bizId';
if (biz.startsWith('BID')) return '/bid-cases/$bizId';
if (biz.startsWith('CONTRACT')) return '/contracts/$bizId';
if (biz == 'WORK_REPORT') return '/office/reports/$bizId';
if (biz == 'WORK_ASSIGN') return null;
if (biz.startsWith('PROJECT')) return '/projects/$bizId';
}
if (row['expense'] is Map)
return '/expenses/${(row['expense'] as Map)['id']}';
if (row['loan'] is Map) return '/loans/${(row['loan'] as Map)['id']}';
if (row['leaveRequest'] is Map)
return '/leave-requests/${(row['leaveRequest'] as Map)['id']}';
if (row['officeApply'] is Map)
return '/office/applies/${(row['officeApply'] as Map)['id']}';
if (row['sealRequest'] is Map)
return '/seal-requests/${(row['sealRequest'] as Map)['id']}';
if (row['contract'] is Map)
return '/contracts/${(row['contract'] as Map)['id']}';
if (row['bidCase'] is Map)
return '/bid-cases/${(row['bidCase'] as Map)['id']}';
if (row['project'] is Map)
return '/projects/${(row['project'] as Map)['id']}';
if (path.startsWith('/office/expenses/') && path.length > 17)
return '/expenses/${path.split('/').last}';
if (path.startsWith('/office/loans/') && path.length > 14)
return '/loans/${path.split('/').last}';
if (id.length == 36) {
if (row.containsKey('claimNo') || row.containsKey('expenseCategory'))
return '/expenses/$id';
if (row.containsKey('loanNo') || kind == 'LOAN') return '/loans/$id';
if (row.containsKey('bidNo') || row.containsKey('externalNo'))
return '/bid-cases/$id';
if (row.containsKey('contractNo')) return '/contracts/$id';
if (row.containsKey('projectNo')) return '/projects/$id';
if (row.containsKey('sealType') || row.containsKey('takeOut'))
return '/seal-requests/$id';
}
return null;
}
String detailTitleOf(Map<String, dynamic> row) {
for (final k in [
'title',
'name',
'purpose',
'reason',
'claimNo',
'loanNo',
'bidNo',
'contractNo',
'projectName'
]) {
final v = row[k];
if (v != null && '$v'.trim().isNotEmpty) return '$v';
}
return '详情';
}
+45
View File
@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../pages/approvals_page.dart';
import '../pages/record_detail_page.dart';
import '../pages/todos_page.dart';
import '../pages/work_assign_page.dart';
import '../session/session.dart';
import 'biz_route.dart';
/// Opens a system notification at its business destination. System notices are
/// workflow entries, never an IM peer conversation.
void openNoticeTarget(
BuildContext context,
OaClient api, {
required SessionStore session,
required String kind,
required String bizType,
required String bizId,
String title = '系统通知',
}) {
if (bizType == 'WORK_ASSIGN') {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => WorkAssignPage(session: session, api: api),
));
return;
}
final seed = <String, dynamic>{
'bizType': bizType,
'bizId': bizId,
'id': bizId,
'title': title,
};
if (bizId.isNotEmpty && fetchPathOf(seed) != null) {
openRecord(context, api, seed, title: title);
return;
}
if (kind == 'approval' || bizType.contains('APPROVAL')) {
Navigator.of(context)
.push(MaterialPageRoute(builder: (_) => ApprovalsPage(api: api)));
return;
}
Navigator.of(context)
.push(MaterialPageRoute(builder: (_) => TodosPage(api: api)));
}
+116
View File
@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../pages/approvals_page.dart';
import '../pages/calendar_page.dart';
import '../pages/directory_page.dart';
import '../pages/flow_page.dart';
import '../pages/hr_apply_page.dart';
import '../pages/module_list_page.dart';
import '../pages/notices_page.dart';
import '../pages/todos_page.dart';
import '../pages/work_assign_page.dart';
import '../session/session.dart';
void pushPage(BuildContext context, Widget page) {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => page));
}
void openMenuNode(BuildContext context, SessionStore session, OaClient api, MenuNode n) {
if (n.code.startsWith('system') || n.name == '系统设置') return;
if (n.name == '发送短信' || n.code.contains('sms')) return;
if (n.name == '消息' || n.code.contains('office:im') || n.code == 'im') return;
if (n.name == '公司公告' || n.name == '员工通讯' || n.name == '通讯录') return;
switch (n.code) {
case 'office:overview':
return;
case 'office:todos':
pushPage(context, TodosPage(api: api));
return;
case 'office:approvals':
pushPage(context, ApprovalsPage(api: api));
return;
case 'office:flow':
pushPage(context, FlowPage(api: api));
return;
case 'office:calendar':
pushPage(context, CalendarPage(api: api));
return;
case 'office:notices':
pushPage(context, NoticesPage(api: api));
return;
case 'office:assign':
pushPage(context, WorkAssignPage(session: session, api: api));
return;
case 'office:directory':
pushPage(context, DirectoryPage(session: session, api: api));
return;
case 'hr:apply':
case 'office:leave':
pushPage(context, HrApplyPage(api: api));
return;
}
final shell = shellFor(n.code, n.path);
if (shell != null) {
pushPage(context, ModuleListPage(api: api, title: n.name, shell: shell));
return;
}
if (n.children.isNotEmpty) {
showModalBottomSheet<void>(
context: context,
builder: (ctx) => SafeArea(
child: ListView(
shrinkWrap: true,
children: [
for (final c in n.children)
ListTile(
title: Text(c.name),
onTap: () {
Navigator.pop(ctx);
openMenuNode(context, session, api, c);
},
),
],
),
),
);
}
}
IconData iconFor(String code, String name) {
final k = '$code $name';
if (k.contains('todo') || name.contains('待办')) return Icons.task_alt;
if (k.contains('approv') || name.contains('审批')) return Icons.fact_check_outlined;
if (name.contains('申请') || k.contains('flow')) return Icons.assignment_outlined;
if (name.contains('日程') || k.contains('calendar')) return Icons.calendar_month_outlined;
if (name.contains('公告') || k.contains('notice')) return Icons.campaign_outlined;
if (name.contains('安排') || k.contains('assign')) return Icons.event_note_outlined;
if (name.contains('通讯') || k.contains('directory')) return Icons.contacts_outlined;
if (name.contains('人事') || name.contains('员工') || k.contains('hr') || k.contains('employee')) return Icons.badge_outlined;
if (name.contains('投标') || k.contains('bid')) return Icons.gavel_outlined;
if (name.contains('合同') || k.contains('contract')) return Icons.description_outlined;
if (name.contains('用章') || name.contains('证照') || k.contains('seal')) return Icons.verified_outlined;
if (name.contains('资产') || k.contains('asset')) return Icons.devices_other_outlined;
if (name.contains('项目') || k.contains('project')) return Icons.account_tree_outlined;
if (name.contains('财务') || name.contains('报销') || name.contains('付款') || k.contains('finance') || k.contains('expense')) {
return Icons.account_balance_wallet_outlined;
}
if (name.contains('客户') || name.contains('往来') || k.contains('party')) return Icons.handshake_outlined;
if (name.contains('报表') || k.contains('report')) return Icons.bar_chart_outlined;
if (name.contains('请假') || name.contains('加班')) return Icons.beach_access_outlined;
return Icons.apps_outlined;
}
Color colorFor(String code, String name, [int i = 0]) {
const palette = [
Color(0xFF267EF0),
Color(0xFF07C160),
Color(0xFFFA9D3B),
Color(0xFF6267F2),
Color(0xFF10AEFF),
Color(0xFFE75D5D),
Color(0xFF00B578),
Color(0xFF8B5CF6),
];
return palette[(code.hashCode.abs() + name.hashCode.abs() + i) % palette.length];
}
+251
View File
@@ -0,0 +1,251 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
import '../api/oa_client.dart';
import '../app_runtime.dart';
import '../theme.dart';
class AppRelease {
AppRelease({
required this.version,
required this.build,
required this.url,
this.changelog = '',
this.force = false,
this.windowsUrl = '',
this.linuxUrl = '',
this.linuxDebUrl = '',
});
final String version;
final int build;
final String url;
final String changelog;
final bool force;
final String windowsUrl;
final String linuxUrl;
final String linuxDebUrl;
bool get newer => build > AppRuntime.build;
String get desktopUrl {
if (Platform.isWindows) return windowsUrl;
if (Platform.isLinux) return linuxDebUrl.isNotEmpty ? linuxDebUrl : linuxUrl;
return '';
}
factory AppRelease.fromJson(Map<String, dynamic> j) {
return AppRelease(
version: '${j['version'] ?? ''}',
build: (j['build'] as num?)?.toInt() ?? 0,
url: '${j['url'] ?? j['apkUrl'] ?? ''}',
changelog: '${j['changelog'] ?? ''}',
force: j['force'] == true,
windowsUrl: '${j['windowsUrl'] ?? ''}',
linuxUrl: '${j['linuxUrl'] ?? ''}',
linuxDebUrl: '${j['linuxDebUrl'] ?? ''}',
);
}
}
class OtaUpdater {
OtaUpdater(this.api);
final OaClient api;
static const _ch = MethodChannel('com.fysxkj.oa/ota');
Future<AppRelease?> fetch() async {
try {
final data = await api.get('/system/app-release');
if (data is Map) return AppRelease.fromJson(Map<String, dynamic>.from(data));
} catch (_) {}
return null;
}
Future<void> installApk(String path) async {
await _ch.invokeMethod('installApk', {'path': path});
}
Future<File> download(String url, void Function(double p) onProgress, {String filename = 'fengying-oa-update'}) async {
final dir = await getTemporaryDirectory();
final ext = url.contains('.exe')
? '.exe'
: url.contains('.deb')
? '.deb'
: url.contains('.tar.gz')
? '.tar.gz'
: url.contains('.apk')
? '.apk'
: '';
final file = File('${dir.path}/$filename$ext');
final client = HttpClient();
try {
final req = await client.getUrl(Uri.parse(url));
final res = await req.close();
if (res.statusCode >= 400) {
throw ApiException('下载失败 ${res.statusCode}');
}
final total = res.contentLength;
var rec = 0;
final sink = file.openWrite();
await for (final chunk in res) {
rec += chunk.length;
sink.add(chunk);
if (total > 0) onProgress(rec / total);
}
await sink.close();
return file;
} finally {
client.close(force: true);
}
}
Future<void> prompt(BuildContext context, AppRelease rel, {bool manual = false}) async {
if (!rel.newer && !manual) return;
if (!rel.newer && manual) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已是最新版本')));
}
return;
}
if (!context.mounted) return;
final go = await showDialog<bool>(
context: context,
barrierDismissible: !rel.force,
builder: (ctx) => AlertDialog(
title: Text('发现新版本 ${rel.version}'),
content: Text(rel.changelog.isEmpty ? '请升级后继续使用。' : rel.changelog),
actions: [
if (!rel.force) TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('稍后')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('立即升级')),
],
),
);
if (go == true && context.mounted) await start(context, rel);
}
Future<void> start(BuildContext context, AppRelease rel) async {
final desktop = rel.desktopUrl;
if (Platform.isWindows || Platform.isLinux) {
if (desktop.isEmpty) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('暂无桌面端升级包地址')));
}
return;
}
final progress = ValueNotifier<double>(0);
if (!context.mounted) return;
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: const Text('正在下载更新'),
content: OtaProgress(progress: progress),
),
);
try {
final file = await download(desktop, (p) => progress.value = p, filename: 'fengying-oa-setup');
if (context.mounted) Navigator.of(context, rootNavigator: true).pop();
if (Platform.isWindows) {
await Process.start(file.path, ['/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART'], mode: ProcessStartMode.detached);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('安装程序已启动,请按提示完成升级后重启应用')));
}
} else if (file.path.endsWith('.deb')) {
final r = await Process.run('pkexec', ['env', 'DEBIAN_FRONTEND=noninteractive', 'dpkg', '-i', file.path]);
if (context.mounted) {
if (r.exitCode == 0) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('升级完成,请重启应用')));
} else {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('安装失败:${r.stderr}')));
}
}
} else {
await Process.run('xdg-open', [file.parent.path]);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('已下载到 ${file.path},请解压覆盖后重启应用')));
}
}
} catch (e) {
if (context.mounted) {
Navigator.of(context, rootNavigator: true).pop();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('升级失败:$e')));
}
} finally {
progress.dispose();
}
return;
}
if (!Platform.isAndroid) return;
if (rel.url.isEmpty) return;
final progress = ValueNotifier<double>(0);
if (!context.mounted) return;
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: const Text('正在下载更新'),
content: OtaProgress(progress: progress),
),
);
try {
final file = await download(rel.url, (p) => progress.value = p);
if (context.mounted) Navigator.of(context, rootNavigator: true).pop();
await installApk(file.path);
} catch (e) {
if (context.mounted) {
Navigator.of(context, rootNavigator: true).pop();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('升级失败:$e')));
}
} finally {
progress.dispose();
}
}
}
class OtaProgress extends StatefulWidget {
const OtaProgress({super.key, required this.progress});
final ValueListenable<double> progress;
@override
State<OtaProgress> createState() => _OtaProgressState();
}
class _OtaProgressState extends State<OtaProgress> {
@override
void initState() {
super.initState();
widget.progress.addListener(_on);
}
void _on() => setState(() {});
@override
void dispose() {
widget.progress.removeListener(_on);
super.dispose();
}
@override
Widget build(BuildContext context) {
final p = widget.progress.value;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
LinearProgressIndicator(value: p <= 0 ? null : p, color: kBind),
const SizedBox(height: 12),
Text('${(p * 100).clamp(0, 100).toStringAsFixed(0)}%'),
],
);
}
}
String prettyJson(Object v) {
try {
return const JsonEncoder.withIndent(' ').convert(v);
} catch (_) {
return '$v';
}
}
+95
View File
@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../labels.dart';
import '../pages/record_detail_page.dart';
import '../widgets/common.dart';
class ApprovalsPage extends StatefulWidget {
const ApprovalsPage({super.key, required this.api});
final OaClient api;
@override
State<ApprovalsPage> createState() => _ApprovalsPageState();
}
class _ApprovalsPageState extends State<ApprovalsPage> {
List<Map<String, dynamic>> _items = [];
String _bucket = 'PENDING';
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final data = await widget.api.get('/office/approvals');
if (!mounted) return;
setState(() {
_items = asMaps(data);
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
final shown = _bucket.isEmpty ? _items : _items.where((e) => '${e['status']}' == _bucket).toList();
return Scaffold(
appBar: AppBar(title: const Text('待我审批')),
body: Column(
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Row(
children: [
for (final it in [('PENDING', '待审批'), ('APPROVED', '已通过'), ('REJECTED', '已驳回'), ('', '全部')])
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(it.$2),
selected: _bucket == it.$1,
showCheckmark: false,
visualDensity: VisualDensity.compact,
onSelected: (_) => setState(() => _bucket = it.$1),
),
),
],
),
),
if (_loading) const LinearProgressIndicator(minHeight: 2),
Expanded(
child: shown.isEmpty
? const EmptyHint('没有审批任务')
: RefreshIndicator(
onRefresh: _load,
child: ListView.builder(
itemCount: shown.length,
itemBuilder: (_, i) {
final r = shown[i];
return KvTile(
title: '${r['title'] ?? ''}',
subtitle: '${zh(r['bizType'])} ${fmtTime(r['createdAt'])}',
status: r['status'],
onTap: () async {
await Navigator.of(context).push(MaterialPageRoute(
builder: (_) => RecordDetailPage(api: widget.api, seed: r, title: '${r['title'] ?? '审批'}'),
));
_load();
},
);
},
),
),
),
],
),
);
}
}
+169
View File
@@ -0,0 +1,169 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../device/device_bridge.dart';
import '../theme.dart';
import '../widgets/wecom.dart';
class AttendancePage extends StatefulWidget {
const AttendancePage({super.key, required this.api, this.embedded = false});
final OaClient api;
final bool embedded;
@override
State<AttendancePage> createState() => _AttendancePageState();
}
class _AttendancePageState extends State<AttendancePage> {
Map<String, dynamic> _status = {};
Map<String, double>? _location;
bool _loading = true;
bool _sending = false;
String _mode = 'OFFICE';
@override
void initState() { super.initState(); _load(); }
Future<void> _load() async {
try {
_location = await DeviceBridge.getLocation();
final q = <String, String>{
if (_location != null) 'lat': '${_location!['lat']}',
if (_location != null) 'lng': '${_location!['lng']}',
'mode': _mode,
};
final data = Map<String, dynamic>.from(await widget.api.get('/attendance/punch-status', query: q) as Map);
if (mounted) setState(() { _status = data; _loading = false; });
} catch (_) { if (mounted) setState(() => _loading = false); }
}
Future<void> _punch(String kind) async {
if (_mode == 'OFFICE' && _status['tripActive'] == true) return;
if (_mode == 'FIELD' && _status['fieldApproved'] != true) return;
setState(() => _sending = true);
try {
await widget.api.post('/attendance/punch', {'kind': kind, 'mode': _mode, if (_location != null) ..._location!});
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('打卡成功')));
await _load();
} catch (e) { if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e'))); }
finally { if (mounted) setState(() => _sending = false); }
}
@override
Widget build(BuildContext context) {
final inKey = _mode == 'FIELD' ? 'fieldIn' : 'clockIn';
final outKey = _mode == 'FIELD' ? 'fieldOut' : 'clockOut';
final inAt = _time(_status[inKey]);
final outAt = _time(_status[outKey]);
final hasIn = inAt != '未打卡';
final hasOut = outAt != '未打卡';
final trip = _status['tripActive'] == true;
final fieldOk = _status['fieldApproved'] == true;
final canPunch = _mode == 'FIELD' ? fieldOk : !trip && _status['inRange'] != false;
final body = _loading
? const Center(child: CircularProgressIndicator())
: RefreshIndicator(
onRefresh: _load,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 28),
children: [
SegmentedButton<String>(
segments: const [
ButtonSegment(value: 'OFFICE', label: Text('上下班打卡'), icon: Icon(Icons.access_time)),
ButtonSegment(value: 'FIELD', label: Text('外勤打卡'), icon: Icon(Icons.location_on_outlined)),
],
selected: {_mode},
onSelectionChanged: (v) async {
setState(() => _mode = v.first);
await _load();
},
),
const SizedBox(height: 14),
Card(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
child: Padding(
padding: const EdgeInsets.fromLTRB(18, 20, 18, 24),
child: Column(
children: [
Text(_today(), style: const TextStyle(fontSize: 25, fontWeight: FontWeight.w800)),
const SizedBox(height: 8),
_rangeBanner(canPunch, trip, fieldOk),
const SizedBox(height: 18),
_punchCircle(
enabled: canPunch && !hasIn && !_sending,
label: _mode == 'FIELD' ? '外勤打卡' : '上班打卡',
color: const Color(0xFF2575EA),
onTap: () => _punch('IN'),
),
const SizedBox(height: 18),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_timeBox('上班', inAt, _status['late'] == true),
_timeBox('下班', outAt, _status['early'] == true),
],
),
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: canPunch && hasIn && !hasOut && !_sending ? () => _punch('OUT') : null,
icon: const Icon(Icons.logout),
label: Text(hasOut ? '已打下班卡' : '打下班卡'),
),
),
if (_mode == 'OFFICE' && _status['late'] == true) const _Flag(text: '迟到', color: Colors.orange),
if (_mode == 'OFFICE' && _status['early'] == true) const _Flag(text: '早退', color: Colors.deepOrange),
],
),
),
),
const SizedBox(height: 12),
Card(
elevation: 0,
child: ListTile(
leading: Icon(Icons.place, color: _status['inRange'] == true ? Colors.green : Colors.red),
title: Text('${_status['rangeText'] ?? '正在获取考勤范围'}', style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text(_status['distanceMeters'] == null ? '请开启定位后刷新' : '距离考勤点约 ${_status['distanceMeters']} 米'),
trailing: IconButton(onPressed: _load, icon: const Icon(Icons.refresh)),
),
),
],
),
);
if (widget.embedded) {
return ColoredBox(
color: const Color(0xFFF4F6FA),
child: Column(
children: [
const WxHeader(title: '打卡'),
Expanded(child: body),
],
),
);
}
return Scaffold(
backgroundColor: const Color(0xFFF4F6FA),
appBar: AppBar(title: const Text('打卡'), backgroundColor: Colors.transparent),
body: body,
);
}
Widget _rangeBanner(bool canPunch, bool trip, bool fieldOk) {
final text = _mode == 'FIELD' ? (fieldOk ? '外出申请已通过,可进行外勤打卡' : '需先申请并审批通过外出') : (trip ? '出差期间无需打卡' : (canPunch ? '当前可进行上下班打卡' : '未进入考勤范围'));
final color = trip || fieldOk || canPunch ? Colors.green : Colors.orange;
return Container(width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: color.withValues(alpha: .1), borderRadius: BorderRadius.circular(12)), child: Row(children: [Icon(Icons.info_outline, color: color), const SizedBox(width: 8), Expanded(child: Text(text, style: TextStyle(color: color, fontWeight: FontWeight.w600)))]));
}
Widget _punchCircle({required bool enabled, required String label, required Color color, required VoidCallback onTap}) => GestureDetector(onTap: enabled ? onTap : null, child: AnimatedContainer(duration: const Duration(milliseconds: 180), width: 190, height: 190, decoration: BoxDecoration(shape: BoxShape.circle, border: Border.all(color: enabled ? color : Colors.grey.shade400, width: 10), color: Colors.white), child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Text(label, style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: enabled ? kInk : kMute)), const SizedBox(height: 6), Text(_nowTime(), style: TextStyle(fontSize: 24, fontWeight: FontWeight.w800, color: enabled ? color : kMute))])));
Widget _timeBox(String label, String value, bool flag) => Column(children: [Text(label, style: const TextStyle(color: kMute)), const SizedBox(height: 4), Row(children: [Text(value, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)), if (flag) const Padding(padding: EdgeInsets.only(left: 4), child: Icon(Icons.error, color: Colors.orange, size: 16))])]);
String _today() { final d = DateTime.now(); return '${d.year}年${d.month}月${d.day}日'; }
String _nowTime() { final d = DateTime.now(); return '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; }
String _time(dynamic value) { if (value == null || '$value' == 'null' || '$value'.isEmpty) return '未打卡'; final d = DateTime.tryParse('$value')?.toLocal(); return d == null ? '$value' : '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; }
}
class _Flag extends StatelessWidget {
const _Flag({required this.text, required this.color});
final String text; final Color color;
@override Widget build(BuildContext context) => Padding(padding: const EdgeInsets.only(top: 8), child: Text(text, style: TextStyle(color: color, fontWeight: FontWeight.w700)));
}
+62
View File
@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../labels.dart';
import '../widgets/common.dart';
import 'record_detail_page.dart';
class CalendarPage extends StatefulWidget {
const CalendarPage({super.key, required this.api});
final OaClient api;
@override
State<CalendarPage> createState() => _CalendarPageState();
}
class _CalendarPageState extends State<CalendarPage> {
List<Map<String, dynamic>> _items = [];
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final data = await widget.api.get('/office/calendar');
if (!mounted) return;
setState(() {
_items = asMaps(data);
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('我的日程')),
body: _loading
? const Center(child: CircularProgressIndicator())
: _items.isEmpty
? const EmptyHint('近期没有日程')
: RefreshIndicator(
onRefresh: _load,
child: ListView(
children: [
for (final r in _items)
KvTile(
title: '${r['title'] ?? r['name'] ?? '日程'}',
subtitle: '${fmtTime(r['start'] ?? r['beginAt'] ?? r['createdAt'])} ${zh(r['type'] ?? r['kind'])}',
onTap: () => openRecord(context, widget.api, r, title: '${r['title'] ?? r['name'] ?? '日程'}'),
),
],
),
),
);
}
}
+436
View File
@@ -0,0 +1,436 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import '../api/oa_client.dart';
import '../device/device_bridge.dart';
import '../session/session.dart';
import '../theme.dart';
/// LiveKit 音视频房间页面。信令和媒体连接均由 LiveKit 负责,OA 只签发短时房间 Token。
class CallPage extends StatefulWidget {
const CallPage({
super.key,
required this.session,
required this.api,
required this.callId,
required this.kind,
required this.title,
required this.initiatorId,
required this.memberIds,
this.peerName = '同事',
this.incoming = false,
});
final SessionStore session;
final OaClient api;
final String callId;
final String kind;
final String title;
final String initiatorId;
final List<String> memberIds;
final String peerName;
final bool incoming;
@override
State<CallPage> createState() => _CallPageState();
}
class _CallPageState extends State<CallPage> {
Room? _room;
VideoTrack? _remoteVideo;
VideoTrack? _localVideo;
String _status = '正在连接音视频服务…';
bool _ready = false;
bool _muted = false;
bool _camOff = false;
bool _ending = false;
bool _remoteEnded = false;
bool _tone = false;
DateTime? _connectedAt;
Duration _elapsed = Duration.zero;
Timer? _durationTimer;
Timer? _ringTimeoutTimer;
Timer? _statePollTimer;
bool get _video => widget.kind != 'audio';
@override
void initState() {
super.initState();
widget.session.im.addListener(_onIm);
_statePollTimer = Timer.periodic(
const Duration(seconds: 2), (_) => unawaited(_pollCallState()));
_boot();
}
void _onIm() {
if (_ending || _remoteEnded || widget.session.im.inbox.isEmpty) return;
final matches = widget.session.im.inbox.where((item) =>
item.kind == 'call' && '${item.raw['callId'] ?? ''}' == widget.callId);
if (matches.isEmpty) return;
final last = matches.first;
final raw = last.raw;
if (last.kind != 'call' || '${raw['callId'] ?? ''}' != widget.callId)
return;
final action = '${raw['action'] ?? ''}';
if (action != 'end' && action != 'reject' && action != 'timeout') return;
_finishRemote(action == 'timeout'
? '无人接听'
: action == 'reject'
? '对方拒绝接听'
: '通话已结束');
}
Future<void> _pollCallState() async {
if (_ending || _remoteEnded) return;
try {
final raw = await widget.api.get('/im/calls/${widget.callId}');
if (raw is! Map || !mounted) return;
final data = Map<String, dynamic>.from(raw);
final status = '${data['status'] ?? ''}';
if (status == 'ended') {
_finishRemote(data['peerRejected'] == true ? '对方拒绝接听' : '通话已结束');
return;
}
if (data['peerJoined'] == true && _ready) {
_stopTone();
_startDuration();
if (mounted && _status != '通话中') setState(() => _status = '通话中');
}
} catch (_) {
// 短时断网由 LiveKit 重连处理,状态轮询下一轮继续。
}
}
void _finishRemote(String text) {
if (_remoteEnded || _ending) return;
_remoteEnded = true;
_ending = true;
_stopTone();
_stopDuration();
_ringTimeoutTimer?.cancel();
_statePollTimer?.cancel();
unawaited(_room?.disconnect());
if (!mounted) return;
setState(() => _status = text);
Future<void>.delayed(const Duration(milliseconds: 850), () {
if (mounted) Navigator.of(context).pop();
});
}
Future<void> _boot() async {
if (widget.incoming) {
if (mounted) setState(() => _status = '对方邀请你${_video ? '视频' : '语音'}通话');
_startTone();
_startRingTimeout();
return;
}
await _start();
}
Future<void> _start() async {
if (_ready) return;
if (widget.incoming) _stopTone();
if (mounted)
setState(() {
_ready = true;
_status = '正在接通…';
});
_startTone();
_startRingTimeout();
try {
await DeviceBridge.requestCallMedia();
await widget.api.post('/im/calls/${widget.callId}/join');
final auth =
await widget.api.post('/im/calls/${widget.callId}/livekit-token');
final data =
auth is Map ? Map<String, dynamic>.from(auth) : <String, dynamic>{};
final url = '${data['url'] ?? ''}';
final token = '${data['token'] ?? ''}';
if (url.isEmpty || token.isEmpty) throw Exception('音视频服务未配置');
final room = Room();
_room = room;
room.events
..on<ParticipantConnectedEvent>((_) {
_stopTone();
_startDuration();
if (mounted) setState(() => _status = '通话中');
})
..on<ParticipantDisconnectedEvent>((_) {
if (!_ending) _finishRemote('通话已结束');
})
..on<TrackSubscribedEvent>((event) {
_stopTone();
_startDuration();
if (event.track is VideoTrack && mounted)
setState(() => _remoteVideo = event.track as VideoTrack);
if (mounted) setState(() => _status = '通话中');
})
..on<TrackUnsubscribedEvent>((event) {
if (event.track is VideoTrack && mounted)
setState(() => _remoteVideo = null);
})
..on<RoomReconnectingEvent>((_) {
if (mounted) setState(() => _status = '网络恢复中…');
})
..on<RoomReconnectedEvent>((_) {
if (mounted) setState(() => _status = '通话中');
})
..on<RoomDisconnectedEvent>((_) {
if (!_ending) _finishRemote('通话已断开');
});
await room.connect(url, token);
// 接听方完成信令连接后即可停止响铃;拨打方继续响铃直到对方加入房间。
if (widget.incoming) _stopTone();
await room.localParticipant?.setMicrophoneEnabled(true);
if (_video) {
final pub = await room.localParticipant?.setCameraEnabled(true);
if (mounted && pub?.track is VideoTrack)
setState(() => _localVideo = pub!.track as VideoTrack);
}
if (room.remoteParticipants.isNotEmpty) {
_stopTone();
_startDuration();
}
if (mounted) {
setState(() => _status = room.remoteParticipants.isNotEmpty
? '通话中'
: widget.initiatorId == widget.session.userId
? '等待对方接听…'
: '正在建立通话…');
}
} catch (e) {
if (mounted) setState(() => _status = '$e');
}
}
Future<void> _hangup() async {
if (_ending) return;
_ending = true;
_stopTone();
_stopDuration();
_ringTimeoutTimer?.cancel();
_statePollTimer?.cancel();
try {
await widget.api.post(
'/im/calls/${widget.callId}/${(!_ready && widget.incoming) ? 'reject' : 'end'}');
} catch (_) {}
await _room?.disconnect();
if (mounted) Navigator.of(context).pop();
}
Future<void> _toggleMute() async {
final next = !_muted;
await _room?.localParticipant?.setMicrophoneEnabled(!next);
if (mounted) setState(() => _muted = next);
}
Future<void> _toggleCam() async {
final next = !_camOff;
final pub = await _room?.localParticipant?.setCameraEnabled(!next);
if (mounted)
setState(() {
_camOff = next;
if (!next && pub?.track is VideoTrack)
_localVideo = pub!.track as VideoTrack;
});
}
@override
void dispose() {
widget.session.im.removeListener(_onIm);
_stopTone();
_stopDuration();
_ringTimeoutTimer?.cancel();
_statePollTimer?.cancel();
_room?.disconnect();
super.dispose();
}
void _startTone() {
if (_tone) return;
_tone = true;
DeviceBridge.startCallTone(incoming: widget.incoming);
}
void _stopTone() {
if (!_tone) return;
_tone = false;
DeviceBridge.stopCallTone();
}
void _startDuration() {
if (_connectedAt != null) return;
_ringTimeoutTimer?.cancel();
_ringTimeoutTimer = null;
_connectedAt = DateTime.now();
_durationTimer?.cancel();
_durationTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted || _connectedAt == null) return;
setState(() => _elapsed = DateTime.now().difference(_connectedAt!));
});
if (mounted) setState(() => _elapsed = Duration.zero);
}
void _stopDuration() {
_durationTimer?.cancel();
_durationTimer = null;
}
void _startRingTimeout() {
_ringTimeoutTimer?.cancel();
_ringTimeoutTimer = Timer(const Duration(seconds: 45), () {
if (!mounted || _ending || _connectedAt != null) return;
_ending = true;
_remoteEnded = true;
_stopTone();
unawaited(widget.api
.post(
'/im/calls/${widget.callId}/${widget.incoming ? 'reject' : 'end'}')
.catchError((_) {}));
setState(() => _status = '无人接听');
Future<void>.delayed(const Duration(milliseconds: 650), () {
if (mounted) Navigator.of(context).pop();
});
});
}
String get _durationText {
final h = _elapsed.inHours;
final m = _elapsed.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = _elapsed.inSeconds.remainder(60).toString().padLeft(2, '0');
return h > 0 ? '${h.toString().padLeft(2, '0')}:$m:$s' : '$m:$s';
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _hangup();
},
child: Scaffold(
backgroundColor: const Color(0xFF1C1C1E),
body: SafeArea(
child: Stack(children: [
if (_video && _remoteVideo != null)
Positioned.fill(
child: VideoTrackRenderer(_remoteVideo!,
fit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitCover))
else
Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
CircleAvatar(
radius: 48,
backgroundColor: kBind,
child: Text(
widget.peerName.trim().isEmpty
? '通'
: String.fromCharCodes(widget.peerName.runes.take(1)),
style:
const TextStyle(color: Colors.white, fontSize: 32))),
const SizedBox(height: 16),
Text(widget.peerName,
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
Text(_status,
style:
const TextStyle(color: Color(0xFFBBBBBB), fontSize: 14)),
if (_connectedAt != null) ...[
const SizedBox(height: 6),
Text(_durationText,
style: const TextStyle(
color: Color(0xFFBBBBBB), fontSize: 14)),
],
])),
if (_video && _localVideo != null)
Positioned(
right: 16,
top: 16,
width: 110,
height: 160,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: VideoTrackRenderer(_localVideo!,
mirrorMode: VideoViewMirrorMode.mirror))),
if (_video && _remoteVideo != null)
Positioned(
left: 0,
right: 0,
top: 24,
child: Text(_status,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70))),
Positioned(
left: 0,
right: 0,
bottom: 36,
child:
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
if (!_ready) ...[
_round(
Icons.call_end, const Color(0xFFE64340), _hangup, '拒绝'),
const SizedBox(width: 48),
_round(Icons.call, const Color(0xFF07C160), _start, '接听'),
] else ...[
_round(
_muted ? Icons.mic_off : Icons.mic,
const Color(0xFF3A3A3C),
_toggleMute,
_muted ? '开麦' : '静音'),
if (_video) ...[
const SizedBox(width: 28),
_round(_camOff ? Icons.videocam_off : Icons.videocam,
const Color(0xFF3A3A3C), _toggleCam, '摄像头')
],
const SizedBox(width: 28),
_round(
Icons.call_end, const Color(0xFFE64340), _hangup, '挂断'),
],
])),
])),
),
);
}
Widget _round(IconData icon, Color color, VoidCallback onTap, String label) =>
Column(mainAxisSize: MainAxisSize.min, children: [
InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: CircleAvatar(
radius: 28,
backgroundColor: color,
child: Icon(icon, color: Colors.white, size: 26))),
const SizedBox(height: 8),
Text(label,
style: const TextStyle(color: Colors.white70, fontSize: 12)),
]);
}
Future<void> openCallPage(BuildContext context,
{required SessionStore session,
required OaClient api,
required Map<String, dynamic> call,
String peerName = '同事',
bool incoming = false}) async {
final id = '${call['id'] ?? ''}';
if (id.isEmpty) return;
final members =
((call['memberIds'] as List?) ?? []).map((e) => '$e').toList();
await Navigator.of(context).push(MaterialPageRoute(
builder: (_) => CallPage(
session: session,
api: api,
callId: id,
kind: '${call['kind'] ?? 'audio'}',
title: '${call['title'] ?? '通话'}',
initiatorId: '${call['initiatorId'] ?? ''}',
memberIds: members,
peerName: peerName,
incoming: incoming)));
}
+344
View File
@@ -0,0 +1,344 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../im/chat_prefs.dart';
import '../session/session.dart';
import '../theme.dart';
import '../widgets/wecom.dart';
import 'pick_people_page.dart';
class ChatDetailPage extends StatefulWidget {
const ChatDetailPage({
super.key,
required this.session,
required this.api,
required this.conversationId,
required this.peerId,
required this.peerName,
required this.isGroup,
this.peerAvatarFileId,
this.messages = const [],
this.onCall,
});
final SessionStore session;
final OaClient api;
final String conversationId;
final String peerId;
final String peerName;
final bool isGroup;
final String? peerAvatarFileId;
final List<Map<String, dynamic>> messages;
final void Function(String kind)? onCall;
@override
State<ChatDetailPage> createState() => _ChatDetailPageState();
}
class _ChatDetailPageState extends State<ChatDetailPage> {
List<Map<String, dynamic>> _members = [];
bool _mute = false;
bool _pin = false;
@override
void initState() {
super.initState();
_mute = ChatPrefs.muted(widget.conversationId);
_pin = ChatPrefs.pinned(widget.conversationId);
_loadMembers();
}
Future<void> _loadMembers() async {
if (widget.isGroup && widget.conversationId.isNotEmpty) {
try {
final data = await widget.api.get('/im/groups/${widget.conversationId}/members');
if (!mounted) return;
setState(() => _members = asMaps(data));
return;
} catch (_) {}
}
setState(() {
_members = [
{
'id': widget.peerId,
'name': widget.peerName,
'avatarFileId': widget.peerAvatarFileId,
},
{
'id': widget.session.userId,
'name': widget.session.displayName,
'avatarFileId': widget.session.user['avatarFileId'],
},
];
});
}
Future<void> _addMembers() async {
final exclude = {
widget.session.userId,
..._members.map((e) => '${e['id'] ?? e['userId'] ?? ''}'),
};
final picked = await Navigator.of(context).push<List<Map<String, dynamic>>>(
MaterialPageRoute(
builder: (_) => PickPeoplePage(api: widget.api, title: '选择同事', exclude: exclude),
),
);
if (picked == null || picked.isEmpty) return;
try {
if (widget.isGroup && widget.conversationId.isNotEmpty) {
await widget.api.post('/im/groups/${widget.conversationId}/members', {
'memberIds': picked.map((e) => '${e['id']}').toList(),
});
await _loadMembers();
} else {
await widget.api.post('/im/groups', {
'name': '${widget.peerName}的群聊',
'memberIds': [widget.peerId, ...picked.map((e) => '${e['id']}')],
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已创建群聊')));
Navigator.pop(context);
}
}
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e')));
}
}
Future<void> _search() async {
await Navigator.of(context).push(MaterialPageRoute(
builder: (_) => _ChatSearchPage(messages: widget.messages, peerName: widget.peerName),
));
}
Future<void> _pickBg() async {
const colors = [
0xFFF5F5F5,
0xFFE7F0E4,
0xFFE8EEF6,
0xFFF6EFE6,
0xFFF3E8EE,
];
final picked = await showModalBottomSheet<int>(
context: context,
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Wrap(
spacing: 12,
runSpacing: 12,
children: [
for (final c in colors)
GestureDetector(
onTap: () => Navigator.pop(ctx, c),
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Color(c),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFDDDDDD)),
),
),
),
],
),
),
),
);
if (picked == null) return;
await ChatPrefs.setBg(widget.conversationId, picked);
if (mounted) setState(() {});
}
Future<void> _clear() async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('删除聊天记录'),
content: const Text('将从本机清空当前会话的聊天记录,对方不受影响。'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('删除')),
],
),
);
if (ok != true) return;
await ChatPrefs.clearHistory(widget.conversationId);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已删除本机聊天记录')));
Navigator.pop(context, 'cleared');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: kPaper,
appBar: AppBar(title: const Text('聊天详情')),
body: ListView(
children: [
const SizedBox(height: 12),
Container(
color: Colors.white,
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Wrap(
spacing: 16,
runSpacing: 12,
children: [
for (final m in _members)
SizedBox(
width: 56,
child: Column(
children: [
SquareAvatar(
label: '${m['name'] ?? m['displayName'] ?? ''}',
size: 48,
fileId: '${m['avatarFileId'] ?? ''}',
api: widget.api,
),
const SizedBox(height: 4),
Text(
'${m['name'] ?? m['displayName'] ?? ''}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, color: kInk),
),
],
),
),
SizedBox(
width: 56,
child: Column(
children: [
InkWell(
onTap: _addMembers,
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
border: Border.all(color: const Color(0xFFC8C8C8), style: BorderStyle.solid),
),
child: const Icon(Icons.add, color: kMute),
),
),
const SizedBox(height: 4),
const Text(' ', style: TextStyle(fontSize: 12)),
],
),
),
],
),
),
const SizedBox(height: 12),
CellGroup(
children: [
Cell(title: '查找聊天内容', onTap: _search, showLine: false),
],
),
CellGroup(
children: [
Cell(
title: '消息免打扰',
trailing: Switch(
value: _mute,
activeTrackColor: kWeGreen,
onChanged: (v) async {
await ChatPrefs.setMuted(widget.conversationId, v);
setState(() => _mute = v);
},
),
),
Cell(
title: '置顶聊天',
trailing: Switch(
value: _pin,
activeTrackColor: kWeGreen,
onChanged: (v) async {
await ChatPrefs.setPinned(widget.conversationId, v);
setState(() => _pin = v);
},
),
showLine: false,
),
],
),
CellGroup(
children: [
Cell(title: '设置当前聊天背景', onTap: _pickBg),
Cell(title: '语音通话', onTap: () => widget.onCall?.call('audio')),
Cell(
title: '视频通话',
onTap: () => widget.onCall?.call(widget.isGroup ? 'meeting' : 'video'),
showLine: false,
),
],
),
CellGroup(
children: [
Cell(title: '删除聊天记录', onTap: _clear, showLine: false),
],
),
CellGroup(
children: [
Cell(
title: '投诉',
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已记录投诉,我们会尽快处理')));
},
showLine: false,
),
],
),
const SizedBox(height: 24),
],
),
);
}
}
class _ChatSearchPage extends StatefulWidget {
const _ChatSearchPage({required this.messages, required this.peerName});
final List<Map<String, dynamic>> messages;
final String peerName;
@override
State<_ChatSearchPage> createState() => _ChatSearchPageState();
}
class _ChatSearchPageState extends State<_ChatSearchPage> {
String _q = '';
@override
Widget build(BuildContext context) {
final q = _q.trim();
final hits = q.isEmpty
? const <Map<String, dynamic>>[]
: widget.messages.where((e) => '${e['body'] ?? ''}'.contains(q)).toList();
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(title: const Text('查找聊天内容')),
body: Column(
children: [
WxSearchBar(hint: '搜索', onChanged: (v) => setState(() => _q = v)),
Expanded(
child: q.isEmpty
? const Center(child: Text('输入关键词查找聊天内容', style: TextStyle(color: kMute)))
: hits.isEmpty
? const Center(child: Text('没有找到相关内容', style: TextStyle(color: kMute)))
: ListView(
children: [
for (final r in hits)
Cell(
title: '${r['body']}',
subtitle: '${r['fromName'] ?? widget.peerName} ${r['createdAt'] ?? ''}',
showLine: true,
),
],
),
),
],
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -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;
}
+405
View File
@@ -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);
}
}
+83
View File
@@ -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),
);
},
),
),
),
],
),
);
}
}
+193
View File
@@ -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'}),
),
],
),
),
),
],
),
);
}
}
+93
View File
@@ -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)),
],
),
),
);
},
),
),
],
),
);
}
}
+207
View File
@@ -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(),
],
),
),
),
),
);
}
}
+575
View File
@@ -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))),
),
),
);
}
}
+267
View File
@@ -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(),
];
}
}
+146
View File
@@ -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),
],
),
);
}
}
+67
View File
@@ -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'] ?? '公告'}',
),
)),
),
],
),
),
);
}
}
+181
View File
@@ -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))),
),
],
),
);
}
}
+142
View File
@@ -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),
),
],
),
),
),
],
),
),
);
},
),
),
],
),
);
}
}
+140
View File
@@ -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;
}
+152
View File
@@ -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)),
])),
]),
),
),
);
},
),
),
);
}
}
+91
View File
@@ -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);
}
}
+125
View File
@@ -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),
),
],
),
),
);
}
}
+281
View File
@@ -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)),
],
),
),
);
}
}
+150
View File
@@ -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();
}
}
+1
View File
@@ -0,0 +1 @@
export '../desktop/desk_shell.dart';
+405
View File
@@ -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,
),
);
}
}
+77
View File
@@ -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)),
),
),
);
}
+85
View File
@@ -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(),
),
);
}
}
+43
View File
@@ -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),
),
],
),
],
),
);
}
}
+123
View File
@@ -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),
],
),
),
],
),
),
),
);
}
}
+411
View File
@@ -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),
),
),
],
],
),
),
),
),
);
}
}
+157
View File
@@ -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('&nbsp;', ' ')
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.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)),
),
);
}
}
+122
View File
@@ -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,
);
}
}
+540
View File
@@ -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();
});
}