feat(mobile,account-service): 统一发送服务 + 员工目录接口 + 好友申请五态/验证消息(B-52 施工 2/3)

- message_send_service:所有消息走统一门禁(登录/连接/空接收方/好友校验),
  失败打印 SDK 原始 code 并映射中文提示,不再只吞异常
- chat_screen 接入统一发送服务,失败提示真实原因
- account-service 新增 GET /api/directory(登录用户可读启用员工,用于添加同事)
- add_friend_screen 增加可编辑验证消息(视觉规范 4.2)
- new_friends_screen 五态 + 窄屏(<360)按钮组换行不挤压姓名(视觉规范 5.3)

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
编码工程师
2026-08-16 02:16:00 +08:00
co-authored by multica-agent
parent d6ef6eb9a4
commit 12b0a1c943
5 changed files with 232 additions and 41 deletions
+27
View File
@@ -203,6 +203,11 @@ function readBody(req) {
});
}
/** 从已解析的 URL 里取查询参数(原样返回,未解码处理由调用方负责) */
function urlParam(req, key) {
return req.urlObj ? (req.urlObj.searchParams.get(key) || '') : '';
}
function checkAdmin(req, res) {
const t = req.headers['admin-token'] || '';
const a = Buffer.from(String(t));
@@ -221,6 +226,27 @@ function publicView(emp, staffNo) {
const routes = {
'GET /api/health': async (req, res) => ok(res, { status: 'up', employees: Object.keys(employees).length }),
// 员工目录:登录用户可读的启用员工列表(供客户端「添加同事」搜索)。
// 请求头 Authorization: Bearer <OpenIM imToken>(与 /api/rtc_token 一致,用 parse_token 校验身份)。
// 只返回启用员工、排除自己,不返回密码/手机号/管理字段。
'GET /api/directory': async (req, res) => {
const m = /^Bearer\s+(.+)$/.exec(String(req.headers.authorization || ''));
if (!m) return fail(res, '未登录或登录已过期', 401);
const userID = await parseImToken(m[1]);
if (!userID) return fail(res, '登录已过期,请重新登录', 401);
const keyword = (urlParam(req, 'keyword') || '').trim();
const limit = Math.min(Math.max(parseInt(urlParam(req, 'limit') || '20', 10) || 20, 1), 100);
let list = Object.keys(employees)
.filter((k) => employees[k].status === 'active' && k !== userID)
.map((k) => ({ userID: k, nickname: employees[k].name, department: '', title: '', faceURL: '' }));
if (keyword) {
const kw = keyword.toLowerCase();
list = list.filter((u) => u.userID.toLowerCase().includes(kw) || u.nickname.toLowerCase().includes(kw));
}
const items = list.slice(0, limit);
ok(res, { items, nextCursor: null });
},
'POST /api/login': async (req, res, body) => {
const { staffNo, password, platformID } = body;
if (!staffNo || !password) return fail(res, '工号和密码不能为空');
@@ -334,6 +360,7 @@ const routes = {
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url, 'http://localhost');
req.urlObj = url;
if (req.method === 'OPTIONS') return send(res, 204, {});
// 管理页(免登录打开,页面内再输入管理口令)
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/admin')) {
+20 -2
View File
@@ -3,7 +3,7 @@ import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
import '../theme.dart';
/// 添加同事:输入对方工号(userID)发送好友申请
/// 添加同事:输入对方工号(userID)+ 可编辑验证消息,发送好友申请
class AddFriendScreen extends StatefulWidget {
const AddFriendScreen({super.key});
@@ -13,11 +13,13 @@ class AddFriendScreen extends StatefulWidget {
class _AddFriendScreenState extends State<AddFriendScreen> {
final TextEditingController _idCtrl = TextEditingController();
final TextEditingController _msgCtrl = TextEditingController(text: '你好,我想加你为同事');
bool _sending = false;
@override
void dispose() {
_idCtrl.dispose();
_msgCtrl.dispose();
super.dispose();
}
@@ -29,7 +31,7 @@ class _AddFriendScreenState extends State<AddFriendScreen> {
}
setState(() => _sending = true);
try {
await OpenIM.iMManager.friendshipManager.addFriend(userID: userID, reason: '你好,我想加你为同事');
await OpenIM.iMManager.friendshipManager.addFriend(userID: userID, reason: _msgCtrl.text.trim());
if (!mounted) return;
_toast('申请已发送,等对方通过');
Navigator.of(context).pop();
@@ -66,6 +68,22 @@ class _AddFriendScreenState extends State<AddFriendScreen> {
),
),
),
const SizedBox(height: AppGap.x4),
TextField(
controller: _msgCtrl,
maxLines: 3,
minLines: 2,
decoration: InputDecoration(
hintText: '验证消息(可不填)',
hintStyle: const TextStyle(color: AppColors.textSecondary),
filled: true,
fillColor: AppColors.searchBg,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
),
),
const SizedBox(height: AppGap.x6),
SizedBox(
height: 48,
+9 -6
View File
@@ -12,6 +12,7 @@ import 'package:path_provider/path_provider.dart';
import '../services/call_service.dart';
import '../services/im_service.dart';
import '../services/message_send_service.dart';
import '../theme.dart';
import '../utils/format.dart';
import '../widgets/chat_input_bar.dart';
@@ -135,6 +136,8 @@ class _ChatScreenState extends State<ChatScreen> {
// ---------------- 发送 ----------------
/// 统一发送入口:文字/表情/语音/图片/文件都经过这里。
/// 失败时用 [MessageSendService] 的中文提示,不再只标红感叹号。
Future<void> _sendMessage(Message message) async {
setState(() {
message.status = MessageStatus.sending;
@@ -142,17 +145,17 @@ class _ChatScreenState extends State<ChatScreen> {
});
if (_scroll.hasClients) _scroll.jumpTo(0);
try {
final sent = await OpenIM.iMManager.messageManager.sendMessage(
final sent = await MessageSendService.instance.send(
message: message,
userID: _isGroup ? null : _peerID,
groupID: _isGroup ? _groupID : null,
offlinePushInfo: OfflinePushInfo(),
);
if (!mounted) return;
setState(() => message.status = sent.status ?? MessageStatus.succeeded);
} catch (_) {
} catch (e) {
if (!mounted) return;
setState(() => message.status = MessageStatus.failed);
_toast(e is Exception ? e.toString() : '消息没发出去,请重试');
}
}
@@ -160,17 +163,17 @@ class _ChatScreenState extends State<ChatScreen> {
Future<void> _resend(Message message) async {
setState(() => message.status = MessageStatus.sending);
try {
final sent = await OpenIM.iMManager.messageManager.sendMessage(
final sent = await MessageSendService.instance.send(
message: message,
userID: _isGroup ? null : _peerID,
groupID: _isGroup ? _groupID : null,
offlinePushInfo: OfflinePushInfo(),
);
if (!mounted) return;
setState(() => message.status = sent.status ?? MessageStatus.succeeded);
} catch (_) {
} catch (e) {
if (!mounted) return;
setState(() => message.status = MessageStatus.failed);
_toast(e is Exception ? e.toString() : '消息没发出去,请重试');
}
}
+56 -16
View File
@@ -98,20 +98,20 @@ class _NewFriendsScreenState extends State<NewFriendsScreen> {
Widget _tile(FriendApplicationInfo a) {
final name = a.fromNickname?.isNotEmpty == true ? a.fromNickname! : (a.fromUserID ?? '');
Widget trailing;
if (a.handleResult == 1) {
trailing = const Text('已添加', style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary));
} else if (a.handleResult == -1) {
trailing = const Text('已拒绝', style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary));
} else {
trailing = Row(
final isPending = a.handleResult == 0;
final showButtons = isPending;
// 窄屏(<360)时按钮组换到第二行右对齐,避免挤压姓名区
final narrow = MediaQuery.sizeOf(context).width < 360;
// 「接受/拒绝」按钮组:总宽固定 132,任何情况下不压缩、不换行
final buttonGroup = Row(
mainAxisSize: MainAxisSize.min,
children: [
FilledButton(
onPressed: () => _handle(a, true),
style: FilledButton.styleFrom(
backgroundColor: AppColors.primary,
minimumSize: const Size(0, 32),
minimumSize: const Size(60, 32),
padding: const EdgeInsets.symmetric(horizontal: AppGap.x3),
),
child: const Text('接受', style: TextStyle(fontSize: AppFont.sub)),
@@ -120,21 +120,61 @@ class _NewFriendsScreenState extends State<NewFriendsScreen> {
OutlinedButton(
onPressed: () => _handle(a, false),
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 32),
minimumSize: const Size(60, 32),
padding: const EdgeInsets.symmetric(horizontal: AppGap.x3),
),
child: const Text('拒绝', style: TextStyle(fontSize: AppFont.sub)),
),
],
);
Widget statusText;
if (a.handleResult == 1) {
statusText = const Text('已添加', style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary));
} else if (a.handleResult == -1) {
statusText = const Text('已拒绝', style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary));
} else {
statusText = const Text('等待对方通过', style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary));
}
return ListTile(
leading: NameAvatar(name: name),
title: Text(name, style: const TextStyle(fontSize: AppFont.body)),
subtitle: a.reqMsg?.isNotEmpty == true
? Text(a.reqMsg!, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary))
: null,
trailing: trailing,
return Padding(
padding: const EdgeInsets.symmetric(horizontal: AppGap.x4, vertical: AppGap.x3),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
NameAvatar(name: name),
const SizedBox(width: AppGap.x3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary),
),
if (a.reqMsg?.isNotEmpty == true) ...[
const SizedBox(height: AppGap.x1),
Text(
a.reqMsg!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary),
),
],
if (narrow && showButtons) ...[
const SizedBox(height: AppGap.x2),
Align(alignment: Alignment.centerRight, child: buttonGroup),
],
],
),
),
if (!narrow)
SizedBox(width: 132, child: Align(alignment: Alignment.centerRight, child: showButtons ? buttonGroup : statusText)),
],
),
);
}
}
@@ -0,0 +1,103 @@
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
import 'im_service.dart';
/// 统一消息发送服务:所有消息(文字/表情/语音/图片/文件/通话信令)都走这里。
///
/// 职责:
/// 1. 发送前置校验:已登录、连接正常、接收方非空;
/// 2. 单聊时校验好友状态(非好友明确提示,不静默失败);
/// 3. 失败时把 SDK 原始 code/message 记进日志(不含 token 与正文),
/// 并返回可直接展示的中文错误,杜绝「只吞异常看不到原因」。
class MessageSendService {
MessageSendService._();
static final MessageSendService instance = MessageSendService._();
/// 发送消息。成功返回 SDK 回执消息;失败抛出带中文提示的异常。
/// 单聊且非好友时抛 [FriendRequiredException]。
Future<Message> send({
required Message message,
String? userID,
String? groupID,
String? offlinePushInfo = '',
}) async {
final im = IMService.instance;
if (!im.loggedIn || im.currentUserID == null) {
throw const SendException('登录状态失效,请重新登录');
}
if (im.connectStatus != ConnectStatus.success) {
throw const SendException('正在重连,请稍后再试');
}
final isGroup = groupID != null && groupID.isNotEmpty;
final peer = userID ?? '';
if (!isGroup && peer.isEmpty) {
throw const SendException('请先添加对方为同事,再发起会话');
}
// 单聊:非好友提示先添加(OpenIM 服务端是否拦截交给服务端,但提示要明确)
if (!isGroup) {
final isFriend = await _isFriend(peer);
if (!isFriend) {
throw FriendRequiredException('请先添加对方为同事,再发送消息');
}
}
try {
final sent = await OpenIM.iMManager.messageManager.sendMessage(
message: message,
userID: isGroup ? null : peer,
groupID: isGroup ? groupID : null,
offlinePushInfo: OfflinePushInfo(),
);
return sent;
} catch (e) {
// 记录原始错误码(不含 token / 消息正文)
// ignore: avoid_print
print('[MessageSendService] 发送失败 peer=$peer groupID=$groupID err=$e');
throw SendException(_mapError(e));
}
}
/// 判断 peer 是否已是好友。查询失败按非好友处理(宁可让用户先加好友)。
Future<bool> _isFriend(String userID) async {
try {
final friends = await OpenIM.iMManager.friendshipManager.getFriendList();
return friends.any((f) => (f.userID ?? f.friendUserID) == userID);
} catch (_) {
return false;
}
}
/// 把 SDK 原始错误映射成中文提示
String _mapError(dynamic e) {
final s = e.toString();
// SDK 错误码常量,见 flutter_openim_sdk/lib/src/enum/sdk_error_code.dart
if (s.contains('1303')) return '对方还不是你的同事,请先添加对方';
if (s.contains('1304')) return '已经是同事了,可以正常聊天';
if (s.contains('1302')) return '对方已把你加入黑名单,无法发送';
if (s.contains('1101')) return '对方账号不存在';
if (s.contains('1501')) return '登录已过期,请重新登录';
if (s.contains('1502') || s.contains('1503')) return '登录凭证无效,请重新登录';
if (s.contains('10000') || s.contains('10001') || s.contains('网络') || s.contains('timeout')) {
return '网络异常,请检查网络后重试';
}
return '消息没发出去,请重试';
}
}
/// 发送失败的中文提示
class SendException implements Exception {
final String message;
const SendException(this.message);
@override
String toString() => message;
}
/// 单聊非好友时的明确提示
class FriendRequiredException extends SendException {
const FriendRequiredException(super.message);
}