Import JC Web IM

This commit is contained in:
annali168290-ux
2026-10-09 22:49:53 +07:00
commit 3377b6072a
74 changed files with 62902 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules/
.deploy/
.DS_Store
npm-debug.log*
yarn-debug.log*
yarn-error.log*
*.local
.env
.env.*
SESSION_HANDOFF.md
+3
View File
@@ -0,0 +1,3 @@
# JC Web IM 开发记录
- 业务操作的成功、失败、保存结果提示,不要使用几秒后自动消失的 toast / popup。优先放在当前表单、按钮附近或弹窗内部,以常驻文字显示;在用户再次编辑、切换或关闭当前界面时再清除。
+58
View File
@@ -0,0 +1,58 @@
# 绿地信通 Web IM
这是从零重写的 Web IM 前端,UI 和状态管理独立于原 `vue-chat`,底层通过授权的 WildFire Web SDK 连接既有 IM 服务。
## 当前工作范围
- 当前工作是在 `jc-web-im` 目录新写 Web 版 IM 客户端。
- 不要默认回到仓库根目录老 `src/` 客户端里继续改,除非明确需要兼容或参考旧实现。
- 中断恢复时优先阅读 `/var/www/im-web/SESSION_HANDOFF.md`。
## 服务地址
- App Server: `http://52.76.146.193:8888`
- IM route: `http://52.76.146.193:80/route`
- WebSocket: `ws://52.76.146.193:8083/ws`
- HTTPS/WSS 当前未启用为默认连接方式。
远端部署路径:
- App Server: `/opt/app-server`
- IM Server: `/opt/im-server`
## 运行
```bash
cd /var/www/im-web/jc-web-im
npm run serve
```
开发访问地址:
```text
http://localhost:8023
http://10.0.0.120:8023
```
## 构建
```bash
npm run build
```
## 部署
生产部署请看 [WEB_DEPLOYMENT.md](./WEB_DEPLOYMENT.md)。当前 Web 版需要 Nginx 托管静态文件,并通过 PHP-FPM 运行 `dist/api/*.php` 代理接口。
## 连接方式
`src/services/imClient.js` 是唯一的 IM 适配层。上层 UI 只调用这里的方法:
- `loginWithPassword`
- `loginWithCode`
- `connect`
- `getConversationList`
- `getMessages`
- `sendTextMessage`
后续如果替换 SDK 或自研协议,只改这个适配层,不需要改 UI 组件。
+284
View File
@@ -0,0 +1,284 @@
# Web 版部署说明
本文档说明 `jc-web-im` 的生产部署方式。当前 Web 版由两部分组成:
- Vue 构建后的静态文件,部署到 Web 根目录。
- `dist/api/*.php` PHP 代理接口,负责安全调用本机 `im-server` / `app-server` 相关接口。
浏览器不能直接携带 `im-server` admin secret 访问 `18080`,所以联系人列表、备注名修改、消息发送等功能通过 PHP 代理完成。
## 目录
```text
/var/www/jc-im/jc-web-im
├── public/api/ # PHP 代理源码,构建时复制到 dist/api
├── public/config/ # 运行时配置
├── src/ # Vue 前端源码
├── deploy/ # Nginx 示例配置
└── dist/ # npm run build 后的生产目录
```
生产环境建议让 Nginx 的 `root` 指向:
```text
/var/www/jc-im/jc-web-im/dist
```
## 依赖
服务器需要安装:
- Nginx
- PHP-FPM
- PHP 扩展:`curl`、`mbstring`
- Node.js / npm,仅构建时需要
PHP 版本:
- 最低建议:PHP 7.4
- 推荐:PHP 8.1 / 8.2 / 8.3
当前 PHP 代理代码使用 `curl_*`、`random_int`、`Throwable`、`mb_strlen` 等能力,部署前请确认 `curl` 和 `mbstring` 扩展可用。
## 后端服务要求
Web 版依赖以下服务正常运行:
```text
app-server: http://127.0.0.1:18888
im-server HTTP/API: http://127.0.0.1:18000
im-server admin: http://127.0.0.1:18080
```
`im-server admin`、IM 数据库和应用数据库配置通过 PHP-FPM 环境变量提供:
```text
public/api/im_web_bootstrap.php
```
需要配置:
```text
IM_ADMIN_URL=http://127.0.0.1:18080
IM_ADMIN_SECRET=请填写管理端密钥
IM_DB_DSN=mysql:host=127.0.0.1;port=3306;dbname=wfchat;charset=utf8mb4
IM_DB_USER=请填写数据库用户
IM_DB_PASSWORD=请填写数据库密码
IM_APP_DB_DSN=mysql:host=127.0.0.1;port=3306;dbname=appdata;charset=utf8mb4
IM_APP_DB_USER=请填写应用数据库用户
IM_APP_DB_PASSWORD=请填写应用数据库密码
IM_DB_TIMEZONE=Asia/Bangkok
```
请在 PHP-FPM pool、容器 secret 或部署平台的环境变量中配置,禁止把真实密钥提交到仓库。
## 构建
在项目目录执行:
```bash
cd /var/www/jc-im/jc-web-im
npm run build
```
构建完成后,生产文件位于:
```text
/var/www/jc-im/jc-web-im/dist
```
## 运行时配置
运行时配置文件:
```text
dist/config/runtime-config.js
```
示例:
```js
window.JC_WEB_IM_CONFIG = {
appServer: 'http://your-domain:18888',
registerConfigApi: 'http://your-domain:8024/api/register-config',
imServerHost: 'your-domain',
imRoutePort: 18000,
useWss: false,
wsPath: '/ws',
sdkMode: 'custom-ws'
};
```
常用字段说明:
| 字段 | 说明 |
| --- | --- |
| `appServer` | Web 登录、验证码等 App Server 地址 |
| `registerConfigApi` | 注册配置接口地址 |
| `imServerHost` | IM HTTP/API 和 WebSocket 使用的主机名 |
| `imRoutePort` | IM route/API 端口,当前为 `18000` |
| `useWss` | 是否使用 WSS |
| `wsPath` | WebSocket 路径 |
| `sdkMode` | 当前使用 `custom-ws` |
联系人相关 PHP 代理默认使用同源地址:
```text
/api/contacts.php
/api/update_alias.php
/api/add_contact.php
/api/send_message.php
/api/messages.php
```
通常不需要在 `runtime-config.js` 里单独配置这些地址。
## Nginx 示例
根据实际 PHP-FPM socket 调整 `fastcgi_pass`,例如 PHP 8.3:
```nginx
server {
listen 8023;
listen [::]:8023;
server_name your-domain _;
root /var/www/jc-im/jc-web-im/dist;
index index.html;
client_max_body_size 100m;
location = / {
try_files /index.html =404;
expires -1;
add_header Cache-Control "no-store";
}
location = /index.html {
expires -1;
add_header Cache-Control "no-store";
}
location = /config/runtime-config.js {
expires -1;
add_header Cache-Control "no-store";
}
location ~ ^/api/.+\.php$ {
include snippets/fastcgi-php.conf;
fastcgi_param PHP_ADMIN_VALUE "open_basedir=/var/www/jc-im/jc-web-im/dist/:/tmp/";
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
expires -1;
add_header Cache-Control "no-store";
}
location / {
etag on;
try_files $uri $uri/ /index.html;
expires 12h;
}
}
```
启用配置后检查并重载:
```bash
sudo nginx -t
sudo systemctl reload nginx
```
## 防火墙建议
生产环境建议:
- 对外开放 Web 端口,例如 `80` / `443` / `8023`。
- `18888` 如需浏览器直接访问 app-server,需要对外开放。
- `18000` 如 WebSocket/route 需要浏览器直连,需要对外开放。
- `18080` admin 端口不要对公网开放,只允许本机 PHP 代理访问。
## 验证
检查 PHP 语法:
```bash
php -l /var/www/jc-im/jc-web-im/dist/api/contacts.php
php -l /var/www/jc-im/jc-web-im/dist/api/update_alias.php
```
检查 Web 页面:
```bash
curl -I http://127.0.0.1:8023/
```
检查联系人接口:
```bash
curl -s -H 'Content-Type: application/json' \
--data '{"userId":"你的用户ID"}' \
http://127.0.0.1:8023/api/contacts.php
```
成功时返回:
```json
{
"code": 0,
"message": "success",
"result": {
"contacts": []
}
}
```
检查备注名修改接口:
```bash
curl -s -H 'Content-Type: application/json' \
--data '{"userId":"你的用户ID","targetId":"联系人ID","alias":"测试备注"}' \
http://127.0.0.1:8023/api/update_alias.php
```
成功时返回:
```json
{
"code": 0,
"message": "success",
"result": {
"contact": {
"uid": "联系人ID",
"alias": "测试备注"
}
}
}
```
## 常见问题
### 页面能打开,但联系人加载失败
优先检查:
- PHP-FPM 是否运行。
- Nginx 的 PHP location 是否命中 `/api/*.php`。
- `curl` 扩展是否启用。
- `public/api/im_web_bootstrap.php` 中 `IM_ADMIN_URL` 和 `IM_ADMIN_SECRET` 是否匹配当前 `im-server`。
- `im-server` 的 `18080` admin 接口是否在本机可访问。
### 修改备注名无效
检查:
- `dist/api/update_alias.php` 是否存在。
- Nginx 是否允许执行 `dist/api/*.php`。
- `im-server` 是否提供 `/admin/friend/set_alias`。
- 当前登录用户和目标联系人是否确实是好友关系。
### 部署后仍是旧前端
检查:
- Nginx `root` 是否指向新的 `dist` 目录。
- 是否执行过 `npm run build`。
- 浏览器是否缓存了旧资源。`index.html` 和 `runtime-config.js` 应配置为 `no-store`。
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
presets: ['@vue/cli-plugin-babel/preset']
};
+33
View File
@@ -0,0 +1,33 @@
server {
listen 8023;
listen [::]:8023;
server_name winchen.ezpay.asia 10.0.0.120 _;
root /var/www/im-web;
index index.html;
client_max_body_size 100m;
location = / {
try_files /index.html =404;
expires -1;
add_header Cache-Control "no-store";
}
location = /index.html {
expires -1;
add_header Cache-Control "no-store";
}
location ~ ^/api/.+\.php$ {
include snippets/fastcgi-php.conf;
fastcgi_param PHP_ADMIN_VALUE "open_basedir=/var/www/im-web/:/tmp/";
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
expires -1;
}
location / {
etag on;
try_files $uri $uri/ /index.html;
expires 12h;
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? ''));
$keyword = trim((string)($payload['keyword'] ?? ''));
$reason = trim((string)($payload['reason'] ?? '添加联系人'));
if ($userId === '') {
respond(400, '缺少当前用户ID');
}
if ($keyword === '') {
respond(400, '请输入要添加的联系人');
}
function tryImUserInfo(array $payload): ?array
{
try {
$result = requireImSuccess(imAdminPost('/admin/user/get_info', $payload), '用户查询失败');
return $result ?: null;
} catch (Throwable $error) {
return null;
}
}
function normalizeUserInfo(array $user): array
{
$uid = (string)($user['userId'] ?? $user['uid'] ?? '');
return [
'uid' => $uid,
'name' => (string)($user['name'] ?? $uid),
'displayName' => (string)($user['displayName'] ?? $user['name'] ?? $uid),
'mobile' => (string)($user['mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? ''),
'alias' => '',
];
}
try {
$target = tryImUserInfo(['userId' => $keyword]);
if (!$target && preg_match('/^\+?\d{5,32}$/', $keyword)) {
$target = tryImUserInfo(['mobile' => $keyword]);
}
if (!$target) {
$target = tryImUserInfo(['name' => $keyword]);
}
if (!$target) {
respond(404, '没有找到该用户');
}
$contact = normalizeUserInfo($target);
if ($contact['uid'] === '') {
respond(404, '没有找到该用户');
}
if ($contact['uid'] === $userId) {
respond(400, '不能添加自己为联系人');
}
$friendResult = requireImSuccess(
imAdminPost('/admin/friend/list', ['userId' => $userId]),
'好友列表检查失败'
);
$friendIds = array_values(array_filter($friendResult['list'] ?? [], 'is_string'));
if (in_array($contact['uid'], $friendIds, true)) {
respond(409, '该用户已经是联系人', ['contact' => $contact]);
}
requireImSuccess(
imAdminPost('/admin/friend/status', [
'userId' => $userId,
'friendUid' => $contact['uid'],
'status' => 0,
]),
'添加联系人失败'
);
requireImSuccess(
imAdminPost('/admin/friend/status', [
'userId' => $contact['uid'],
'friendUid' => $userId,
'status' => 0,
]),
'添加联系人失败'
);
respond(0, 'success', ['contact' => $contact, 'reason' => $reason]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '添加联系人服务暂不可用');
}
+71
View File
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? $_GET['userId'] ?? ''));
if ($userId === '') {
respond(400, '缺少用户ID');
}
try {
$friendResult = requireImSuccess(
imAdminPost('/admin/friend/list', ['userId' => $userId]),
'联系人加载失败'
);
$friendIds = array_values(array_unique(array_filter(array_map(static function ($uid): string {
return trim((string)$uid);
}, $friendResult['list'] ?? []))));
if (!$friendIds) {
respond(0, 'success', ['contacts' => []]);
}
$usersResult = requireImSuccess(
imAdminPost('/admin/user/batch_get_infos', ['list' => $friendIds]),
'联系人资料加载失败'
);
try {
ensureWebImAppTables();
$remarkMap = fetchWebUserRemarkMap(imAppDb(), $userId, $friendIds);
} catch (Throwable) {
$remarkMap = [];
}
$contactsByUid = [];
foreach (array_values(array_filter($usersResult['userInfos'] ?? [], 'is_array')) as $user) {
$uid = (string)($user['userId'] ?? '');
if ($uid === '' || isset($contactsByUid[$uid])) {
continue;
}
$alias = '';
try {
$aliasResult = requireImSuccess(
imAdminPost('/admin/friend/get_alias', ['operator' => $userId, 'targetId' => $uid]),
'联系人备注加载失败'
);
$alias = trim((string)($aliasResult['alias'] ?? ''));
} catch (Throwable $error) {
$alias = '';
}
if ($alias === '') {
$alias = $remarkMap[$uid] ?? '';
}
$contactsByUid[$uid] = [
'uid' => $uid,
'name' => (string)($user['name'] ?? $uid),
'displayName' => (string)($user['displayName'] ?? $user['name'] ?? $uid),
'mobile' => (string)($user['mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? ''),
'address' => (string)($user['address'] ?? ''),
'alias' => $alias,
];
}
respond(0, 'success', ['contacts' => array_values($contactsByUid)]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '联系人服务暂不可用');
}
+990
View File
@@ -0,0 +1,990 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
function webActionList(mixed $value): array
{
if (is_array($value)) {
$items = $value;
} else {
$items = preg_split('/[\s,,;;]+/u', (string)$value) ?: [];
}
return array_values(array_unique(array_filter(array_map(static function ($item): string {
return trim((string)$item);
}, $items))));
}
function appPlaceholders(array $values): string
{
return implode(',', array_fill(0, count($values), '?'));
}
function resolveWebUserIds(PDO $db, array $values): array
{
$values = webActionList($values);
if (!$values) {
return [];
}
$resolved = [];
foreach ($values as $value) {
$statement = $db->prepare(
'select `_uid` from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ? limit 1'
);
$statement->execute([$value, $value, $value]);
$uid = $statement->fetchColumn();
if (is_string($uid) && $uid !== '') {
$resolved[] = $uid;
}
}
return array_values(array_unique($resolved));
}
function webGroupConversation(array $group, string $role = 'member'): array
{
$name = (string)($group['name'] ?? $group['_name'] ?? $group['group_id'] ?? $group['_gid'] ?? '');
$groupId = (string)($group['group_id'] ?? $group['_gid'] ?? '');
$category = (string)($group['category'] ?? 'group');
$title = $name !== '' ? $name : $groupId;
$updatedAt = (int)($group['updated_at_ms'] ?? $group['_dt'] ?? webImNowMs());
return [
'id' => 'group-' . $groupId,
'type' => $category === 'channel' ? 'channel' : 'group',
'target' => $groupId,
'title' => $title,
'subtitle' => $category === 'channel' ? '公告群' : '群组',
'unread' => 0,
'avatarText' => mb_strtoupper(mb_substr($title, 0, 1, 'UTF-8'), 'UTF-8'),
'updatedAt' => $updatedAt,
'role' => $role,
'raw' => [
'type' => 'group',
'target' => $groupId,
'line' => 0,
'category' => $category,
'role' => $role,
],
];
}
function fetchWebGroup(PDO $db, string $groupKey): ?array
{
$statement = $db->prepare(
'select * from `t_web_groups`'
. ' where `deleted` = 0 and (`group_id` = ? or `name` = ?)'
. ' order by (`group_id` = ?) desc limit 1'
);
$statement->execute([$groupKey, $groupKey, $groupKey]);
$group = $statement->fetch();
return is_array($group) ? $group : null;
}
function fetchWebGroupRole(PDO $db, string $groupId, string $userId): string
{
$statement = $db->prepare(
'select `role` from `t_web_group_members`'
. ' where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
);
$statement->execute([$groupId, $userId]);
$role = $statement->fetchColumn();
return is_string($role) && $role !== '' ? $role : 'member';
}
function fetchWebGroupMember(PDO $db, string $groupId, string $userId): array
{
$statement = $db->prepare(
'select * from `t_web_group_members`'
. ' where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
);
$statement->execute([$groupId, $userId]);
$member = $statement->fetch();
return is_array($member) ? $member : [];
}
function requireWebGroupMember(PDO $db, string $groupId, string $userId): array
{
$member = fetchWebGroupMember($db, $groupId, $userId);
if (!$member) {
respond(403, '你不在该群组中');
}
return $member;
}
function requireWebGroupRole(PDO $db, string $groupId, string $userId, array $roles): array
{
$member = requireWebGroupMember($db, $groupId, $userId);
if (!in_array((string)$member['role'], $roles, true)) {
respond(403, '没有群组管理权限');
}
return $member;
}
function upsertWebGroupMember(PDO $db, string $groupId, string $userId, string $role): void
{
$now = webImNowMs();
$statement = $db->prepare(
'insert into `t_web_group_members`'
. ' (`group_id`, `user_id`, `role`, `joined_at_ms`, `updated_at_ms`, `status`)'
. ' values (?, ?, ?, ?, ?, 0)'
. ' on duplicate key update'
. ' `role` = if(`role` = "owner", `role`, values(`role`)),'
. ' `updated_at_ms` = values(`updated_at_ms`),'
. ' `status` = 0'
);
$statement->execute([$groupId, $userId, $role, $now, $now]);
}
function createNativeGroup(string $operator, string $groupId, string $name, string $category, array $members): array
{
$nativeMembers = [];
foreach ($members as $memberId) {
$nativeMembers[] = [
'member_id' => $memberId,
'type' => $memberId === $operator ? 2 : 0,
];
}
return requireImSuccess(imAdminPost('/admin/group/create', [
'operator' => $operator,
'group' => [
'group_info' => [
'target_id' => $groupId,
'name' => $name,
'owner' => $operator,
'type' => 0,
'mute' => $category === 'channel' ? 1 : 0,
'join_type' => 0,
'searchable' => 1,
'history_message' => 1,
'extra' => json_encode(['webCategory' => $category], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
],
'members' => $nativeMembers,
],
'to_lines' => [0],
]), '原生群组创建失败');
}
function addNativeGroupMember(string $operator, string $groupId, string $userId): void
{
requireImSuccess(imAdminPost('/admin/group/member/add', [
'operator' => $operator,
'group_id' => $groupId,
'members' => [
['member_id' => $userId, 'type' => 0],
],
'to_lines' => [0],
]), '加入原生群组失败');
}
function removeNativeGroupMember(string $operator, string $groupId, array $userIds): void
{
if (!$userIds) {
return;
}
requireImSuccess(imAdminPost('/admin/group/member/del', [
'operator' => $operator,
'group_id' => $groupId,
'members' => array_values($userIds),
'to_lines' => [0],
]), '移除原生成员失败');
}
function dismissNativeGroup(string $operator, string $groupId): void
{
requireImSuccess(imAdminPost('/admin/group/del', [
'operator' => $operator,
'group_id' => $groupId,
'to_lines' => [0],
]), '解散原生群组失败');
}
function transferNativeGroup(string $operator, string $groupId, string $newOwner): void
{
requireImSuccess(imAdminPost('/admin/group/transfer', [
'operator' => $operator,
'group_id' => $groupId,
'new_owner' => $newOwner,
'to_lines' => [0],
]), '转让原生群组失败');
}
function sendWebGroupWelcomeMessage(PDO $appDb, string $groupId, string $senderId, string $welcomeText): void
{
$text = trim($welcomeText);
if ($text === '') {
return;
}
$now = webImNowMs();
$messageUid = webImMessageUid();
$statement = $appDb->prepare(
'insert into `t_web_group_messages`'
. ' (`message_uid`, `group_id`, `sender_uid`, `text`, `created_at_ms`, `edited_at_ms`, `recalled`)'
. ' values (?, ?, ?, ?, ?, 0, 0)'
);
$statement->execute([$messageUid, $groupId, $senderId, $text, $now]);
$update = $appDb->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
$update->execute([$now, $groupId]);
try {
requireImSuccess(imAdminPost('/admin/message/send', [
'sender' => $senderId,
'conv' => [
'type' => 1,
'target' => $groupId,
'line' => 0,
],
'payload' => [
'type' => 1,
'searchableContent' => $text,
'pushContent' => $text,
'content' => $text,
'persistFlag' => 3,
],
'userMessage' => true,
]), '欢迎词发送失败');
} catch (Throwable) {
}
}
function createWebGroupAction(PDO $imDb, PDO $appDb, string $userId, array $payload, string $category): array
{
$name = trim((string)($payload['name'] ?? ''));
$announcement = trim((string)($payload['announcement'] ?? ''));
$welcomeText = trim((string)($payload['welcomeText'] ?? ''));
if ($name === '') {
respond(400, $category === 'channel' ? '请输入公告群名称' : '请输入群组名称');
}
$memberIds = resolveWebUserIds($imDb, webActionList($payload['memberIds'] ?? []));
$memberIds[] = $userId;
$memberIds = array_values(array_unique(array_filter($memberIds)));
if ($category === 'group' && count($memberIds) < 2) {
respond(400, '请选择至少一位联系人');
}
$groupId = 'webg_' . bin2hex(random_bytes(8));
$now = webImNowMs();
$nativeStatus = 'pending';
$nativeGroupId = '';
$nativeError = '';
try {
$nativeResult = createNativeGroup($userId, $groupId, $name, $category, $memberIds);
$nativeGroupId = (string)($nativeResult['group_id'] ?? $nativeResult['groupId'] ?? $groupId);
$nativeStatus = 'ok';
} catch (Throwable $error) {
$nativeStatus = 'fallback';
$nativeError = mb_substr($error->getMessage() ?: '原生群组创建失败', 0, 255, 'UTF-8');
}
$statement = $appDb->prepare(
'insert into `t_web_groups`'
. ' (`group_id`, `category`, `name`, `portrait`, `owner_uid`, `join_mode`, `announcement`,'
. ' `welcome_text`, `native_group_id`, `native_status`, `native_error`, `created_by`, `created_at_ms`, `updated_at_ms`, `deleted`)'
. ' values (?, ?, ?, "", ?, "open", ?, ?, ?, ?, ?, ?, ?, ?, 0)'
);
$statement->execute([
$groupId,
$category,
$name,
$userId,
$announcement,
$welcomeText,
$nativeGroupId,
$nativeStatus,
$nativeError,
$userId,
$now,
$now,
]);
foreach ($memberIds as $memberId) {
upsertWebGroupMember($appDb, $groupId, $memberId, $memberId === $userId ? 'owner' : 'member');
}
sendWebGroupWelcomeMessage($appDb, $groupId, $userId, $welcomeText);
$group = fetchWebGroup($appDb, $groupId);
return [
'group' => $group,
'conversation' => webGroupConversation($group ?: [
'group_id' => $groupId,
'category' => $category,
'name' => $name,
'updated_at_ms' => $now,
], 'owner'),
'nativeStatus' => $nativeStatus,
'nativeError' => $nativeError,
];
}
function fetchWebUserMap(PDO $imDb, array $userIds, string $ownerId = ''): array
{
$userIds = webActionList($userIds);
if (!$userIds) {
return [];
}
$statement = $imDb->prepare(
'select `_uid`, `_name`, `_display_name`, `_mobile`, `_portrait` from `t_user`'
. ' where `_uid` in (' . appPlaceholders($userIds) . ')'
);
$statement->execute($userIds);
$users = [];
foreach ($statement->fetchAll() as $row) {
$uid = (string)$row['_uid'];
$displayName = trim((string)($row['_display_name'] ?? ''))
?: trim((string)($row['_name'] ?? ''))
?: trim((string)($row['_mobile'] ?? ''))
?: $uid;
$users[$uid] = [
'uid' => $uid,
'name' => (string)($row['_name'] ?? ''),
'displayName' => $displayName,
'mobile' => (string)($row['_mobile'] ?? ''),
'portrait' => (string)($row['_portrait'] ?? ''),
'contactAlias' => '',
'friendAlias' => '',
'remarkAlias' => '',
'isContact' => false,
];
}
if ($ownerId !== '') {
$aliasStatement = $imDb->prepare(
'select `_friend_uid`, `_alias` from `t_friend` where `_uid` = ?'
. ' and `_friend_uid` in (' . appPlaceholders($userIds) . ')'
);
$aliasStatement->execute(array_merge([$ownerId], $userIds));
foreach ($aliasStatement->fetchAll() as $row) {
$friendUid = (string)$row['_friend_uid'];
if (!isset($users[$friendUid])) {
continue;
}
$users[$friendUid]['isContact'] = true;
$users[$friendUid]['friendAlias'] = trim((string)($row['_alias'] ?? ''));
}
$remarkMap = fetchWebUserRemarkMap(imAppDb(), $ownerId, $userIds);
foreach ($remarkMap as $targetUid => $alias) {
if (!isset($users[$targetUid])) {
continue;
}
$users[$targetUid]['remarkAlias'] = $alias;
}
foreach ($users as $uid => $user) {
$friendAlias = trim((string)($user['friendAlias'] ?? ''));
$remarkAlias = trim((string)($user['remarkAlias'] ?? ''));
$users[$uid]['contactAlias'] = $friendAlias !== '' ? $friendAlias : $remarkAlias;
}
}
return $users;
}
function currentMuteSetting(PDO $appDb, string $groupId): array
{
$statement = $appDb->prepare('select * from `t_web_group_mute_rules` where `group_id` = ? limit 1');
$statement->execute([$groupId]);
$row = $statement->fetch();
if (!is_array($row)) {
return [
'mode' => 'off',
'scheduleType' => 'once',
'startsAtMs' => 0,
'endsAtMs' => 0,
'dailyStart' => '',
'dailyEnd' => '',
'enabled' => false,
];
}
return [
'mode' => (string)$row['mode'],
'scheduleType' => (string)$row['schedule_type'],
'startsAtMs' => (int)$row['starts_at_ms'],
'endsAtMs' => (int)$row['ends_at_ms'],
'dailyStart' => (string)$row['daily_start'],
'dailyEnd' => (string)$row['daily_end'],
'enabled' => (bool)$row['enabled'],
];
}
function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
{
$group = fetchWebGroup($appDb, $groupId);
if (!$group) {
respond(404, '没有找到该群组');
}
$member = fetchWebGroupMember($appDb, $groupId, $userId);
if (!$member) {
respond(403, '你不在该群组中');
}
$ownerId = (string)$group['owner_uid'];
$users = fetchWebUserMap($imDb, [$ownerId, $userId], $userId);
$owner = $users[$ownerId] ?? ['uid' => $ownerId, 'displayName' => $ownerId, 'contactAlias' => ''];
$me = $users[$userId] ?? ['uid' => $userId, 'displayName' => $userId];
$myAlias = (string)($member['alias'] ?? '');
return [
'group' => [
'groupId' => (string)$group['group_id'],
'name' => (string)$group['name'],
'category' => (string)$group['category'],
'announcement' => (string)($group['announcement'] ?? ''),
'welcomeText' => (string)($group['welcome_text'] ?? ''),
'ownerUid' => $ownerId,
'ownerName' => (string)($owner['contactAlias'] ?? '') !== '' ? (string)$owner['contactAlias'] : (string)$owner['displayName'],
'myRole' => (string)$member['role'],
'myAlias' => $myAlias,
'myDisplayName' => $myAlias !== '' ? $myAlias : (string)$me['displayName'],
'muteSetting' => currentMuteSetting($appDb, (string)$group['group_id']),
],
];
}
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
{
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
respond(403, '你不在该群组中');
}
$statement = $appDb->prepare(
'select `user_id`, `role`, `alias`, `joined_at_ms` from `t_web_group_members`'
. ' where `group_id` = ? and `status` = 0 order by (`role` = "owner") desc, `joined_at_ms` asc'
);
$statement->execute([$groupId]);
$members = $statement->fetchAll();
$users = fetchWebUserMap($imDb, array_column($members, 'user_id'), $userId);
return [
'members' => array_map(static function (array $member) use ($users): array {
$uid = (string)$member['user_id'];
$user = $users[$uid] ?? [
'uid' => $uid,
'displayName' => $uid,
'mobile' => '',
'portrait' => '',
'contactAlias' => '',
'friendAlias' => '',
'remarkAlias' => '',
'isContact' => false,
];
$alias = trim((string)($member['alias'] ?? ''));
$contactAlias = trim((string)($user['contactAlias'] ?? ''));
return [
'uid' => $uid,
'role' => (string)$member['role'],
'alias' => $alias,
'contactAlias' => $contactAlias,
'friendAlias' => trim((string)($user['friendAlias'] ?? '')),
'remarkAlias' => trim((string)($user['remarkAlias'] ?? '')),
'isContact' => (bool)($user['isContact'] ?? false),
'displayName' => $contactAlias !== '' ? $contactAlias : ($alias !== '' ? $alias : (string)$user['displayName']),
'rawDisplayName' => (string)$user['displayName'],
'groupDisplayName' => $alias,
'mobile' => (string)($user['mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? ''),
];
}, $members),
];
}
function updateGroupAliasAction(PDO $appDb, string $userId, string $groupId, string $alias): array
{
if (mb_strlen($alias, 'UTF-8') > 64) {
respond(400, '本群昵称过长');
}
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
respond(403, '你不在该群组中');
}
$now = webImNowMs();
$statement = $appDb->prepare(
'update `t_web_group_members` set `alias` = ?, `updated_at_ms` = ?'
. ' where `group_id` = ? and `user_id` = ? and `status` = 0'
);
$statement->execute([$alias, $now, $groupId, $userId]);
return ['alias' => $alias];
}
function leaveGroupAction(PDO $appDb, string $userId, string $groupId): array
{
$group = fetchWebGroup($appDb, $groupId);
if (!$group) {
respond(404, '没有找到该群组');
}
$member = fetchWebGroupMember($appDb, $groupId, $userId);
if (!$member) {
respond(403, '你不在该群组中');
}
$now = webImNowMs();
$statement = $appDb->prepare(
'update `t_web_group_members` set `status` = 1, `updated_at_ms` = ?'
. ' where `group_id` = ? and `user_id` = ?'
);
$statement->execute([$now, $groupId, $userId]);
$groupUpdate = $appDb->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
$groupUpdate->execute([$now, $groupId]);
try {
requireImSuccess(imAdminPost('/admin/group/member/quit', [
'operator' => $userId,
'group_id' => $groupId,
'to_lines' => [0],
]), '退出原生群组失败');
} catch (Throwable) {
}
return ['groupId' => $groupId];
}
function dismissGroupAction(PDO $appDb, string $userId, string $groupId): array
{
$group = fetchWebGroup($appDb, $groupId);
if (!$group) {
respond(404, '没有找到该群组');
}
requireWebGroupRole($appDb, $groupId, $userId, ['owner']);
$now = webImNowMs();
$update = $appDb->prepare('update `t_web_groups` set `deleted` = 1, `updated_at_ms` = ? where `group_id` = ?');
$update->execute([$now, $groupId]);
$memberUpdate = $appDb->prepare('update `t_web_group_members` set `status` = 1, `updated_at_ms` = ? where `group_id` = ?');
$memberUpdate->execute([$now, $groupId]);
try {
dismissNativeGroup($userId, $groupId);
} catch (Throwable) {
}
return ['groupId' => $groupId];
}
function transferGroupAction(PDO $appDb, string $userId, string $groupId, string $targetUserId): array
{
if ($targetUserId === '' || $targetUserId === $userId) {
respond(400, '请选择新的群主');
}
$group = fetchWebGroup($appDb, $groupId);
if (!$group) {
respond(404, '没有找到该群组');
}
requireWebGroupRole($appDb, $groupId, $userId, ['owner']);
$target = requireWebGroupMember($appDb, $groupId, $targetUserId);
if ((string)$target['role'] === 'owner') {
respond(400, '该成员已经是群主');
}
$now = webImNowMs();
$appDb->beginTransaction();
try {
$groupUpdate = $appDb->prepare('update `t_web_groups` set `owner_uid` = ?, `updated_at_ms` = ? where `group_id` = ?');
$groupUpdate->execute([$targetUserId, $now, $groupId]);
$oldOwnerUpdate = $appDb->prepare(
'update `t_web_group_members` set `role` = "member", `updated_at_ms` = ?'
. ' where `group_id` = ? and `user_id` = ?'
);
$oldOwnerUpdate->execute([$now, $groupId, $userId]);
$newOwnerUpdate = $appDb->prepare(
'update `t_web_group_members` set `role` = "owner", `updated_at_ms` = ?'
. ' where `group_id` = ? and `user_id` = ?'
);
$newOwnerUpdate->execute([$now, $groupId, $targetUserId]);
$appDb->commit();
} catch (Throwable $error) {
$appDb->rollBack();
throw $error;
}
try {
transferNativeGroup($userId, $groupId, $targetUserId);
} catch (Throwable) {
}
return ['groupId' => $groupId, 'newOwner' => $targetUserId];
}
function setGroupAdminsAction(PDO $appDb, string $userId, string $groupId, array $adminIds): array
{
requireWebGroupRole($appDb, $groupId, $userId, ['owner']);
$adminIds = array_values(array_unique(array_filter(array_map('strval', $adminIds), static fn(string $uid): bool => $uid !== $userId)));
$now = webImNowMs();
$reset = $appDb->prepare(
'update `t_web_group_members` set `role` = "member", `updated_at_ms` = ?'
. ' where `group_id` = ? and `role` = "admin" and `status` = 0'
);
$reset->execute([$now, $groupId]);
if ($adminIds) {
$update = $appDb->prepare(
'update `t_web_group_members` set `role` = "admin", `updated_at_ms` = ?'
. ' where `group_id` = ? and `user_id` in (' . appPlaceholders($adminIds) . ')'
. ' and `role` <> "owner" and `status` = 0'
);
$update->execute(array_merge([$now, $groupId], $adminIds));
}
return ['adminIds' => $adminIds];
}
function updateGroupProfileAction(PDO $appDb, string $userId, string $groupId, array $payload): array
{
requireWebGroupRole($appDb, $groupId, $userId, ['owner', 'admin']);
$name = trim((string)($payload['name'] ?? ''));
$announcement = trim((string)($payload['announcement'] ?? ''));
$welcomeText = trim((string)($payload['welcomeText'] ?? ''));
if ($name === '') {
respond(400, '请输入群名称');
}
if (mb_strlen($name, 'UTF-8') > 128) {
respond(400, '群名称过长');
}
$now = webImNowMs();
$statement = $appDb->prepare(
'update `t_web_groups` set `name` = ?, `announcement` = ?, `welcome_text` = ?, `updated_at_ms` = ?'
. ' where `group_id` = ? and `deleted` = 0'
);
$statement->execute([$name, $announcement, $welcomeText, $now, $groupId]);
return [
'groupId' => $groupId,
'name' => $name,
'announcement' => $announcement,
'welcomeText' => $welcomeText,
'updatedAt' => $now,
];
}
function kickGroupMembersAction(PDO $appDb, string $userId, string $groupId, array $targetIds): array
{
$operator = requireWebGroupRole($appDb, $groupId, $userId, ['owner', 'admin']);
$operatorRole = (string)$operator['role'];
$targetIds = array_values(array_unique(array_filter(array_map('strval', $targetIds), static fn(string $uid): bool => $uid !== '')));
$targetIds = array_values(array_filter($targetIds, static fn(string $uid): bool => $uid !== $userId));
if (!$targetIds) {
respond(400, '请选择要移除的群员');
}
$statement = $appDb->prepare(
'select `user_id`, `role` from `t_web_group_members`'
. ' where `group_id` = ? and `user_id` in (' . appPlaceholders($targetIds) . ') and `status` = 0'
);
$statement->execute(array_merge([$groupId], $targetIds));
$targets = $statement->fetchAll();
if (!$targets) {
respond(400, '没有可移除的群员');
}
$allowedIds = [];
foreach ($targets as $target) {
$role = (string)$target['role'];
if ($role === 'owner') {
continue;
}
if ($operatorRole === 'admin' && $role !== 'member') {
continue;
}
$allowedIds[] = (string)$target['user_id'];
}
if (!$allowedIds) {
respond(403, '没有可移除的群员');
}
$now = webImNowMs();
$update = $appDb->prepare(
'update `t_web_group_members` set `status` = 2, `updated_at_ms` = ?'
. ' where `group_id` = ? and `user_id` in (' . appPlaceholders($allowedIds) . ')'
);
$update->execute(array_merge([$now, $groupId], $allowedIds));
try {
removeNativeGroupMember($userId, $groupId, $allowedIds);
} catch (Throwable) {
}
return ['removedIds' => $allowedIds];
}
function updateMuteSettingAction(PDO $appDb, string $userId, string $groupId, array $payload): array
{
requireWebGroupRole($appDb, $groupId, $userId, ['owner', 'admin']);
$mode = trim((string)($payload['mode'] ?? 'off'));
$allowedModes = ['off', 'all_include_admins', 'all_except_admins'];
if (!in_array($mode, $allowedModes, true)) {
respond(400, '禁言模式不正确');
}
$scheduleType = trim((string)($payload['scheduleType'] ?? 'once'));
if (!in_array($scheduleType, ['manual', 'once', 'daily'], true)) {
respond(400, '禁言时段类型不正确');
}
$startsAtMs = max(0, (int)($payload['startsAtMs'] ?? 0));
$endsAtMs = max(0, (int)($payload['endsAtMs'] ?? 0));
$dailyStart = trim((string)($payload['dailyStart'] ?? ''));
$dailyEnd = trim((string)($payload['dailyEnd'] ?? ''));
$enabled = $mode !== 'off';
if ($enabled && $scheduleType === 'once' && ($startsAtMs <= 0 || $endsAtMs <= $startsAtMs)) {
respond(400, '请设置有效的单次禁言时段');
}
if ($enabled && $scheduleType === 'daily' && (!preg_match('/^\d{2}:\d{2}$/', $dailyStart) || !preg_match('/^\d{2}:\d{2}$/', $dailyEnd))) {
respond(400, '请设置有效的每日禁言时段');
}
$now = webImNowMs();
$statement = $appDb->prepare(
'insert into `t_web_group_mute_rules`'
. ' (`group_id`, `mode`, `schedule_type`, `starts_at_ms`, `ends_at_ms`, `daily_start`, `daily_end`, `enabled`, `created_by`, `updated_at_ms`)'
. ' values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
. ' on duplicate key update'
. ' `mode` = values(`mode`),'
. ' `schedule_type` = values(`schedule_type`),'
. ' `starts_at_ms` = values(`starts_at_ms`),'
. ' `ends_at_ms` = values(`ends_at_ms`),'
. ' `daily_start` = values(`daily_start`),'
. ' `daily_end` = values(`daily_end`),'
. ' `enabled` = values(`enabled`),'
. ' `created_by` = values(`created_by`),'
. ' `updated_at_ms` = values(`updated_at_ms`)'
);
$statement->execute([$groupId, $mode, $scheduleType, $startsAtMs, $endsAtMs, $dailyStart, $dailyEnd, $enabled ? 1 : 0, $userId, $now]);
return ['muteSetting' => currentMuteSetting($appDb, $groupId)];
}
function joinWebGroupAction(PDO $appDb, string $userId, string $groupKey): array
{
if ($groupKey === '') {
respond(400, '请输入群组ID或群名称');
}
$group = fetchWebGroup($appDb, $groupKey);
if (!$group) {
try {
$native = requireImSuccess(imAdminPost('/admin/group/get_info', ['groupId' => $groupKey]), '群组不存在');
$now = webImNowMs();
$statement = $appDb->prepare(
'insert into `t_web_groups`'
. ' (`group_id`, `category`, `name`, `portrait`, `owner_uid`, `join_mode`, `announcement`,'
. ' `native_group_id`, `native_status`, `created_by`, `created_at_ms`, `updated_at_ms`, `deleted`)'
. ' values (?, "group", ?, ?, ?, "open", "", ?, "ok", ?, ?, ?, 0)'
. ' on duplicate key update `updated_at_ms` = values(`updated_at_ms`)'
);
$statement->execute([
$groupKey,
(string)($native['name'] ?? $groupKey),
(string)($native['portrait'] ?? ''),
(string)($native['owner'] ?? ''),
$groupKey,
$userId,
$now,
$now,
]);
$group = fetchWebGroup($appDb, $groupKey);
} catch (Throwable) {
respond(404, '没有找到该群组');
}
}
$groupId = (string)$group['group_id'];
upsertWebGroupMember($appDb, $groupId, $userId, 'member');
try {
addNativeGroupMember((string)($group['owner_uid'] ?: $userId), $groupId, $userId);
} catch (Throwable $error) {
// Web membership is authoritative for this web build; native group sync is best-effort.
}
$role = fetchWebGroupRole($appDb, $groupId, $userId);
return [
'group' => fetchWebGroup($appDb, $groupId),
'conversation' => webGroupConversation($group, $role),
];
}
function sendBroadcastAction(PDO $imDb, PDO $appDb, string $userId, array $payload): array
{
$text = trim((string)($payload['text'] ?? ''));
if ($text === '') {
respond(400, '请输入群发内容');
}
$targetIds = resolveWebUserIds($imDb, webActionList($payload['targetIds'] ?? []));
$targetIds = array_values(array_filter(array_unique($targetIds), static fn(string $uid): bool => $uid !== $userId));
if (!$targetIds) {
respond(400, '请选择群发对象');
}
$results = [];
foreach ($targetIds as $targetId) {
try {
$result = requireImSuccess(imAdminPost('/admin/message/send', [
'sender' => $userId,
'conv' => [
'type' => 0,
'target' => $targetId,
'line' => 0,
],
'payload' => [
'type' => 1,
'searchableContent' => $text,
'pushContent' => $text,
'content' => $text,
'persistFlag' => 3,
],
'userMessage' => true,
]), '发送失败');
$results[] = [
'targetId' => $targetId,
'ok' => true,
'messageUid' => isset($result['messageUid']) ? (string)$result['messageUid'] : '',
];
} catch (Throwable $error) {
$results[] = [
'targetId' => $targetId,
'ok' => false,
'error' => $error->getMessage() ?: '发送失败',
];
}
}
$successCount = count(array_filter($results, static fn(array $item): bool => !empty($item['ok'])));
$failedCount = count($results) - $successCount;
$now = webImNowMs();
$statement = $appDb->prepare(
'insert into `t_web_broadcast_logs`'
. ' (`sender_uid`, `target_count`, `success_count`, `failed_count`, `text`, `target_json`, `result_json`, `created_at_ms`)'
. ' values (?, ?, ?, ?, ?, ?, ?, ?)'
);
$statement->execute([
$userId,
count($targetIds),
$successCount,
$failedCount,
$text,
json_encode($targetIds, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
json_encode($results, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
$now,
]);
return [
'targetCount' => count($targetIds),
'successCount' => $successCount,
'failedCount' => $failedCount,
'results' => $results,
];
}
$payload = readJsonPayload();
$action = trim((string)($payload['action'] ?? ''));
$userId = trim((string)($payload['userId'] ?? ''));
if ($userId === '') {
respond(400, '缺少当前用户ID');
}
try {
ensureWebImAppTables();
$imDb = imDb();
$appDb = imAppDb();
if ($action === 'create_group') {
respond(0, 'success', createWebGroupAction($imDb, $appDb, $userId, $payload, 'group'));
}
if ($action === 'create_channel') {
respond(0, 'success', createWebGroupAction($imDb, $appDb, $userId, $payload, 'channel'));
}
if ($action === 'join_group') {
respond(0, 'success', joinWebGroupAction($appDb, $userId, trim((string)($payload['groupKey'] ?? ''))));
}
if ($action === 'group_detail') {
respond(0, 'success', groupDetailAction($imDb, $appDb, $userId, trim((string)($payload['groupId'] ?? ''))));
}
if ($action === 'group_members') {
respond(0, 'success', groupMembersAction($imDb, $appDb, $userId, trim((string)($payload['groupId'] ?? ''))));
}
if ($action === 'update_group_alias') {
respond(0, 'success', updateGroupAliasAction(
$appDb,
$userId,
trim((string)($payload['groupId'] ?? '')),
trim((string)($payload['alias'] ?? ''))
));
}
if ($action === 'leave_group') {
respond(0, 'success', leaveGroupAction($appDb, $userId, trim((string)($payload['groupId'] ?? ''))));
}
if ($action === 'dismiss_group') {
respond(0, 'success', dismissGroupAction($appDb, $userId, trim((string)($payload['groupId'] ?? ''))));
}
if ($action === 'transfer_group') {
respond(0, 'success', transferGroupAction(
$appDb,
$userId,
trim((string)($payload['groupId'] ?? '')),
trim((string)($payload['targetUserId'] ?? ''))
));
}
if ($action === 'set_group_admins') {
respond(0, 'success', setGroupAdminsAction(
$appDb,
$userId,
trim((string)($payload['groupId'] ?? '')),
is_array($payload['adminIds'] ?? null) ? $payload['adminIds'] : []
));
}
if ($action === 'update_group_profile') {
respond(0, 'success', updateGroupProfileAction(
$appDb,
$userId,
trim((string)($payload['groupId'] ?? '')),
$payload
));
}
if ($action === 'kick_group_members') {
respond(0, 'success', kickGroupMembersAction(
$appDb,
$userId,
trim((string)($payload['groupId'] ?? '')),
is_array($payload['targetIds'] ?? null) ? $payload['targetIds'] : []
));
}
if ($action === 'update_mute_setting') {
respond(0, 'success', updateMuteSettingAction(
$appDb,
$userId,
trim((string)($payload['groupId'] ?? '')),
$payload
));
}
if ($action === 'broadcast') {
$result = sendBroadcastAction($imDb, $appDb, $userId, $payload);
if ((int)$result['successCount'] <= 0) {
respond(500, '群发失败', $result);
}
respond(0, 'success', $result);
}
respond(400, '未知操作');
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '会话操作失败');
}
+348
View File
@@ -0,0 +1,348 @@
<?php
declare(strict_types=1);
define('IM_ADMIN_URL', getenv('IM_ADMIN_URL') ?: 'http://127.0.0.1:18080');
define('IM_ADMIN_SECRET', getenv('IM_ADMIN_SECRET') ?: '');
define('IM_DB_DSN', getenv('IM_DB_DSN') ?: 'mysql:host=127.0.0.1;port=3306;dbname=wfchat;charset=utf8mb4');
define('IM_DB_USER', getenv('IM_DB_USER') ?: '');
define('IM_DB_PASSWORD', getenv('IM_DB_PASSWORD') ?: '');
define('IM_APP_DB_DSN', getenv('IM_APP_DB_DSN') ?: 'mysql:host=127.0.0.1;port=3306;dbname=appdata;charset=utf8mb4');
define('IM_APP_DB_USER', getenv('IM_APP_DB_USER') ?: '');
define('IM_APP_DB_PASSWORD', getenv('IM_APP_DB_PASSWORD') ?: '');
define('IM_DB_TIMEZONE', getenv('IM_DB_TIMEZONE') ?: 'Asia/Bangkok');
function respond(int $code, string $message, array $result = []): void
{
echo json_encode([
'code' => $code,
'message' => $message,
'result' => $result,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function readJsonPayload(): array
{
$payload = json_decode(file_get_contents('php://input') ?: '[]', true);
return is_array($payload) ? $payload : [];
}
function imAdminPost(string $path, array $payload): array
{
$nonce = (string)random_int(1, 1000000);
$timestamp = (string)((int)floor(microtime(true) * 1000));
$sign = sha1($nonce . '|' . IM_ADMIN_SECRET . '|' . $timestamp);
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$curl = curl_init(IM_ADMIN_URL . $path);
if ($curl === false) {
throw new RuntimeException('IM admin client init failed');
}
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_0,
CURLOPT_CONNECTTIMEOUT_MS => 1000,
CURLOPT_TIMEOUT_MS => 4000,
CURLOPT_FRESH_CONNECT => true,
CURLOPT_FORBID_REUSE => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json; charset=utf-8',
'Connection: close',
'nonce: ' . $nonce,
'timestamp: ' . $timestamp,
'sign: ' . $sign,
],
]);
$responseBody = curl_exec($curl);
$curlError = curl_error($curl);
curl_close($curl);
if ($responseBody === false || $responseBody === '') {
throw new RuntimeException($curlError ?: 'IM admin request failed');
}
$data = json_decode($responseBody, true);
if (!is_array($data)) {
throw new RuntimeException('Invalid IM admin response');
}
return $data;
}
function requireImSuccess(array $response, string $fallbackMessage): array
{
if (($response['code'] ?? -1) !== 0) {
throw new RuntimeException((string)($response['msg'] ?? $response['message'] ?? $fallbackMessage));
}
return is_array($response['result'] ?? null) ? $response['result'] : [];
}
function imDb(): PDO
{
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
$pdo = new PDO(IM_DB_DSN, IM_DB_USER, IM_DB_PASSWORD, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return $pdo;
}
function imAppDb(): PDO
{
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
$pdo = new PDO(IM_APP_DB_DSN, IM_APP_DB_USER, IM_APP_DB_PASSWORD, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return $pdo;
}
function ensureWebImAppTables(): void
{
static $ensured = false;
if ($ensured) {
return;
}
$ensured = true;
$db = imAppDb();
$db->exec(
"create table if not exists `t_web_groups` (
`id` bigint unsigned not null auto_increment,
`group_id` varchar(64) not null,
`category` varchar(20) not null default 'group',
`name` varchar(128) not null,
`portrait` varchar(1024) not null default '',
`owner_uid` varchar(64) not null,
`join_mode` varchar(20) not null default 'open',
`announcement` text null,
`native_group_id` varchar(64) not null default '',
`native_status` varchar(32) not null default 'pending',
`native_error` varchar(255) not null default '',
`created_by` varchar(64) not null,
`created_at_ms` bigint not null,
`updated_at_ms` bigint not null,
`deleted` tinyint not null default 0,
primary key (`id`),
unique key `uk_t_web_groups_group_id` (`group_id`),
key `idx_t_web_groups_owner` (`owner_uid`),
key `idx_t_web_groups_category` (`category`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
try {
$db->exec("alter table `t_web_groups` add column `welcome_text` text null after `announcement`");
} catch (Throwable) {
}
$db->exec(
"create table if not exists `t_web_group_members` (
`id` bigint unsigned not null auto_increment,
`group_id` varchar(64) not null,
`user_id` varchar(64) not null,
`role` varchar(20) not null default 'member',
`alias` varchar(128) not null default '',
`joined_at_ms` bigint not null,
`updated_at_ms` bigint not null,
`status` tinyint not null default 0,
primary key (`id`),
unique key `uk_t_web_group_members_gid_uid` (`group_id`, `user_id`),
key `idx_t_web_group_members_user` (`user_id`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_group_messages` (
`id` bigint unsigned not null auto_increment,
`message_uid` varchar(64) not null,
`group_id` varchar(64) not null,
`sender_uid` varchar(64) not null,
`text` text not null,
`created_at_ms` bigint not null,
`edited_at_ms` bigint not null default 0,
`recalled` tinyint not null default 0,
primary key (`id`),
unique key `uk_t_web_group_messages_uid` (`message_uid`),
key `idx_t_web_group_messages_group_time` (`group_id`, `created_at_ms`),
key `idx_t_web_group_messages_sender` (`sender_uid`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_message_attachments` (
`id` bigint unsigned not null auto_increment,
`message_uid` varchar(64) not null,
`conversation_type` varchar(16) not null default 'single',
`target_id` varchar(128) not null default '',
`sender_uid` varchar(64) not null default '',
`attachment_type` varchar(16) not null,
`url` varchar(1024) not null,
`name` varchar(255) not null default '',
`mime` varchar(128) not null default '',
`size_bytes` bigint not null default 0,
`created_at_ms` bigint not null,
primary key (`id`),
unique key `uk_t_web_message_attachments_uid` (`message_uid`),
key `idx_t_web_message_attachments_target` (`conversation_type`, `target_id`, `created_at_ms`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_group_reads` (
`id` bigint unsigned not null auto_increment,
`group_id` varchar(64) not null,
`user_id` varchar(64) not null,
`read_at_ms` bigint not null default 0,
`updated_at_ms` bigint not null,
primary key (`id`),
unique key `uk_t_web_group_reads_gid_uid` (`group_id`, `user_id`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_group_mute_rules` (
`id` bigint unsigned not null auto_increment,
`group_id` varchar(64) not null,
`mode` varchar(32) not null default 'off',
`schedule_type` varchar(16) not null default 'once',
`starts_at_ms` bigint not null default 0,
`ends_at_ms` bigint not null default 0,
`daily_start` varchar(8) not null default '',
`daily_end` varchar(8) not null default '',
`enabled` tinyint not null default 0,
`created_by` varchar(64) not null default '',
`updated_at_ms` bigint not null,
primary key (`id`),
unique key `uk_t_web_group_mute_rules_group` (`group_id`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_broadcast_logs` (
`id` bigint unsigned not null auto_increment,
`sender_uid` varchar(64) not null,
`target_count` int not null default 0,
`success_count` int not null default 0,
`failed_count` int not null default 0,
`text` text not null,
`target_json` text null,
`result_json` text null,
`created_at_ms` bigint not null,
primary key (`id`),
key `idx_t_web_broadcast_logs_sender_time` (`sender_uid`, `created_at_ms`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_user_remarks` (
`id` bigint unsigned not null auto_increment,
`owner_uid` varchar(64) not null,
`target_uid` varchar(64) not null,
`alias` varchar(128) not null default '',
`created_at_ms` bigint not null,
`updated_at_ms` bigint not null,
primary key (`id`),
unique key `uk_t_web_user_remarks_owner_target` (`owner_uid`, `target_uid`),
key `idx_t_web_user_remarks_target` (`target_uid`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
}
function webImPlaceholders(array $values): string
{
return implode(',', array_fill(0, count($values), '?'));
}
function fetchWebUserRemarkMap(PDO $db, string $ownerId, array $targetIds): array
{
$targetIds = array_values(array_unique(array_filter(array_map(static function ($targetId): string {
return trim((string)$targetId);
}, $targetIds))));
if ($ownerId === '' || !$targetIds) {
return [];
}
$statement = $db->prepare(
'select `target_uid`, `alias` from `t_web_user_remarks`'
. ' where `owner_uid` = ? and `target_uid` in (' . webImPlaceholders($targetIds) . ')'
);
$statement->execute(array_merge([$ownerId], $targetIds));
$remarks = [];
foreach ($statement->fetchAll() as $row) {
$remarks[(string)$row['target_uid']] = trim((string)($row['alias'] ?? ''));
}
return $remarks;
}
function saveWebUserRemark(PDO $db, string $ownerId, string $targetId, string $alias): void
{
if ($ownerId === '' || $targetId === '') {
return;
}
if ($alias === '') {
$delete = $db->prepare('delete from `t_web_user_remarks` where `owner_uid` = ? and `target_uid` = ?');
$delete->execute([$ownerId, $targetId]);
return;
}
$now = webImNowMs();
$statement = $db->prepare(
'insert into `t_web_user_remarks` (`owner_uid`, `target_uid`, `alias`, `created_at_ms`, `updated_at_ms`)'
. ' values (?, ?, ?, ?, ?)'
. ' on duplicate key update `alias` = values(`alias`), `updated_at_ms` = values(`updated_at_ms`)'
);
$statement->execute([$ownerId, $targetId, $alias, $now, $now]);
}
function webImNowMs(): int
{
return (int)floor(microtime(true) * 1000);
}
function webImMessageUid(): string
{
$timestampPart = (webImNowMs() - 1514736000000) << 21;
return (string)($timestampPart | random_int(1, 0x1FFFFF));
}
function javaStringHashCode(string $value): int
{
$hash = 0;
$length = strlen($value);
for ($i = 0; $i < $length; $i++) {
$hash = (31 * $hash + ord($value[$i])) & 0xFFFFFFFF;
if ($hash >= 0x80000000) {
$hash -= 0x100000000;
}
}
return $hash;
}
function imUserMessagesTable(string $userId): string
{
return 't_user_messages_' . (abs(javaStringHashCode($userId)) % 128);
}
function imMessageTable(string $messageId): string
{
$timestamp = ((int)$messageId >> 21) + 1514736000000;
$date = (new DateTimeImmutable('@' . (int)floor($timestamp / 1000)))
->setTimezone(new DateTimeZone(IM_DB_TIMEZONE));
$year = (int)$date->format('Y');
$month = (int)$date->format('n') - 1;
return 't_messages_' . (($year % 3) * 12 + $month);
}
+58
View File
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
const WEB_MEDIA_ROOT = '/opt/im-server/media';
function failDownload(int $status, string $message): never
{
http_response_code($status);
header('Content-Type: text/plain; charset=utf-8');
echo $message;
exit;
}
function safeDownloadName(string $fileName, string $fallback): string
{
$name = basename(str_replace('\\', '/', $fileName));
$name = preg_replace('/[^\p{L}\p{N}._ -]+/u', '_', $name) ?: '';
$name = trim($name, " .\t\n\r\0\x0B");
if ($name === '') {
$name = $fallback;
}
return mb_substr($name, 0, 180, 'UTF-8');
}
$url = trim((string)($_GET['url'] ?? ''));
$requestedName = trim((string)($_GET['name'] ?? ''));
if ($url === '') {
failDownload(400, '缺少媒体地址');
}
$path = (string)(parse_url($url, PHP_URL_PATH) ?: $url);
$path = rawurldecode($path);
if (!preg_match('#^/fs/[0-9]+/[A-Za-z0-9_./ -]+$#', $path) || str_contains($path, '..')) {
failDownload(400, '媒体地址不合法');
}
$root = realpath(WEB_MEDIA_ROOT);
$fullPath = realpath(rtrim(WEB_MEDIA_ROOT, '/') . $path);
if ($root === false || $fullPath === false || !str_starts_with($fullPath, $root . DIRECTORY_SEPARATOR) || !is_file($fullPath)) {
failDownload(404, '媒体文件不存在');
}
$fallbackName = basename($fullPath);
$downloadName = safeDownloadName($requestedName, $fallbackName);
if (!pathinfo($downloadName, PATHINFO_EXTENSION)) {
$downloadName .= '.' . (pathinfo($fallbackName, PATHINFO_EXTENSION) ?: 'jpg');
}
$mime = mime_content_type($fullPath) ?: 'application/octet-stream';
header('Content-Type: ' . $mime);
header('Content-Length: ' . (string)filesize($fullPath));
header(
'Content-Disposition: attachment; filename="' . addcslashes($downloadName, "\\\"") . '"; filename*=UTF-8\'\''
. rawurlencode($downloadName)
);
header('Cache-Control: no-store');
readfile($fullPath);
+288
View File
@@ -0,0 +1,288 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
class UserActionException extends RuntimeException
{
}
function resolveActionUserId(string $value): string
{
if ($value === '') {
return '';
}
$db = imDb();
$statement = $db->prepare(
'select `_uid` from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ? limit 1'
);
$statement->execute([$value, $value, $value]);
$uid = $statement->fetchColumn();
return is_string($uid) && $uid !== '' ? $uid : $value;
}
function messageTimestampMs(string $messageUid): int
{
return (((int)$messageUid) >> 21) + 1514736000000;
}
function nowTimestampMs(): int
{
return (int)floor(microtime(true) * 1000);
}
function recallLimitMinutes(): float
{
$statement = imAppDb()->prepare(
'select `conf_value` from `system_conf`'
. ' where `conf_name` = ? and `is_valid` = 1 limit 1'
);
$statement->execute(['RetractMessage']);
$value = trim((string)($statement->fetchColumn() ?: '0'));
if ($value === '' || !is_numeric($value)) {
return 0.0;
}
return max(0.0, (float)$value);
}
function assertRecallWithinLimit(string $messageUid): void
{
$limitMinutes = recallLimitMinutes();
if ($limitMinutes <= 0) {
return;
}
$messageAgeMs = nowTimestampMs() - messageTimestampMs($messageUid);
$limitMs = (int)round($limitMinutes * 60 * 1000);
if ($messageAgeMs > $limitMs) {
throw new UserActionException('已超过可撤回时间');
}
}
function fetchActionMessageRow(string $messageUid): array
{
$table = imMessageTable($messageUid);
$statement = imDb()->prepare(
'select `_mid`, `_from`, `_content_type`, `_searchable_key` from `' . $table . '` where `_mid` = ? limit 1'
);
$statement->execute([$messageUid]);
$row = $statement->fetch();
return is_array($row) ? $row : [];
}
function ensureMessageEditsTable(): void
{
imDb()->exec(
'create table if not exists `t_web_message_edits` ('
. '`message_uid` varchar(32) not null,'
. '`editor_uid` varchar(64) not null,'
. '`edited_text` text null,'
. '`edited_at_ms` bigint not null,'
. '`created_at` datetime not null default current_timestamp,'
. '`updated_at` datetime null default current_timestamp on update current_timestamp,'
. 'primary key (`message_uid`),'
. 'key `idx_editor_uid` (`editor_uid`),'
. 'key `idx_edited_at_ms` (`edited_at_ms`)'
. ') engine=InnoDB default charset=utf8mb4'
);
}
function editTextMessage(string $operatorId, string $messageUid, string $text): array
{
$row = fetchActionMessageRow($messageUid);
if (!$row) {
return editWebGroupMessage($operatorId, $messageUid, $text);
}
if ((string)$row['_from'] !== $operatorId) {
throw new UserActionException('只能编辑自己发送的消息');
}
if ((int)$row['_content_type'] !== 1) {
throw new UserActionException('只能编辑文字消息');
}
$editedAt = nowTimestampMs();
$table = imMessageTable($messageUid);
$update = imDb()->prepare('update `' . $table . '` set `_searchable_key` = ? where `_mid` = ?');
$update->execute([$text, $messageUid]);
ensureMessageEditsTable();
$upsert = imDb()->prepare(
'insert into `t_web_message_edits` (`message_uid`, `editor_uid`, `edited_text`, `edited_at_ms`)'
. ' values (?, ?, ?, ?)'
. ' on duplicate key update'
. ' `editor_uid` = values(`editor_uid`),'
. ' `edited_text` = values(`edited_text`),'
. ' `edited_at_ms` = values(`edited_at_ms`),'
. ' `updated_at` = current_timestamp'
);
$upsert->execute([$messageUid, $operatorId, $text, $editedAt]);
return [
'messageUid' => $messageUid,
'text' => $text,
'editedAt' => $editedAt,
];
}
function fetchWebGroupActionMessage(string $messageUid): array
{
ensureWebImAppTables();
$statement = imAppDb()->prepare(
'select m.*, g.`category` from `t_web_group_messages` m'
. ' join `t_web_groups` g on g.`group_id` = m.`group_id` and g.`deleted` = 0'
. ' where m.`message_uid` = ? limit 1'
);
$statement->execute([$messageUid]);
$row = $statement->fetch();
return is_array($row) ? $row : [];
}
function editWebGroupMessage(string $operatorId, string $messageUid, string $text): array
{
$row = fetchWebGroupActionMessage($messageUid);
if (!$row) {
throw new UserActionException('消息不存在');
}
if ((string)$row['sender_uid'] !== $operatorId) {
throw new UserActionException('只能编辑自己发送的消息');
}
if ((int)($row['recalled'] ?? 0) !== 0) {
throw new UserActionException('已撤回的消息请重新编辑发送');
}
$editedAt = nowTimestampMs();
$update = imAppDb()->prepare(
'update `t_web_group_messages` set `text` = ?, `edited_at_ms` = ? where `message_uid` = ?'
);
$update->execute([$text, $editedAt, $messageUid]);
$groupUpdate = imAppDb()->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
$groupUpdate->execute([$editedAt, (string)$row['group_id']]);
return [
'messageUid' => $messageUid,
'text' => $text,
'editedAt' => $editedAt,
];
}
function webGroupMemberRoleForAction(string $groupId, string $userId): string
{
$statement = imAppDb()->prepare(
'select `role` from `t_web_group_members`'
. ' where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
);
$statement->execute([$groupId, $userId]);
$role = $statement->fetchColumn();
return is_string($role) ? $role : '';
}
function recallWebGroupMessage(string $operatorId, string $messageUid): bool
{
$row = fetchWebGroupActionMessage($messageUid);
if (!$row) {
return false;
}
$isSender = (string)$row['sender_uid'] === $operatorId;
if (!$isSender) {
$role = webGroupMemberRoleForAction((string)$row['group_id'], $operatorId);
if (!in_array($role, ['owner', 'admin'], true)) {
throw new UserActionException('只有群主或管理员可以撤回他人消息');
}
}
assertRecallWithinLimit($messageUid);
$now = nowTimestampMs();
$update = imAppDb()->prepare(
'update `t_web_group_messages` set `recalled` = 1 where `message_uid` = ?'
);
$update->execute([$messageUid]);
$groupUpdate = imAppDb()->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
$groupUpdate->execute([$now, (string)$row['group_id']]);
return true;
}
$payload = readJsonPayload();
$action = trim((string)($payload['action'] ?? ''));
$userId = trim((string)($payload['userId'] ?? ''));
$messageUidText = trim((string)($payload['messageUid'] ?? ''));
$messageUid = ctype_digit($messageUidText) ? (int)$messageUidText : 0;
if ($action === '' || $messageUid <= 0) {
respond(400, '缺少操作类型或消息ID');
}
try {
if ($action === 'recall') {
if ($userId === '') {
respond(400, '缺少操作者ID');
}
$userId = resolveActionUserId($userId);
$row = fetchActionMessageRow($messageUidText);
if (!$row && recallWebGroupMessage($userId, $messageUidText)) {
respond(0, 'success', [
'messageUid' => (string)$messageUid,
'action' => $action,
]);
}
if (!$row) {
throw new UserActionException('消息不存在');
}
assertRecallWithinLimit($messageUidText);
requireImSuccess(imAdminPost('/admin/message/recall', [
'operator' => $userId,
'messageUid' => $messageUid,
'userRecall' => true,
'isUserRecall' => true,
]), '撤回失败');
respond(0, 'success', [
'messageUid' => (string)$messageUid,
'action' => $action,
]);
}
if ($action === 'edit') {
if ($userId === '') {
respond(400, '缺少操作者ID');
}
$text = trim((string)($payload['text'] ?? ''));
if ($text === '') {
respond(400, '编辑内容不能为空');
}
if (mb_strlen($text, 'UTF-8') > 5000) {
respond(400, '编辑内容过长');
}
$userId = resolveActionUserId($userId);
respond(0, 'success', [
'message' => editTextMessage($userId, $messageUidText, $text),
'action' => $action,
]);
}
if ($action === 'delete') {
requireImSuccess(imAdminPost('/admin/message/delete', [
'messageUid' => $messageUid,
]), '删除失败');
respond(0, 'success', [
'messageUid' => (string)$messageUid,
'action' => $action,
]);
}
respond(400, '不支持的消息操作');
} catch (Throwable $error) {
respond($error instanceof UserActionException ? 400 : 500, $error->getMessage() ?: '消息操作失败');
}
+387
View File
@@ -0,0 +1,387 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
function detailPlaceholders(array $values): string
{
return implode(',', array_fill(0, count($values), '?'));
}
function detailTimestampMs(?string $datetime, string $messageId = '0'): int
{
$id = (int)$messageId;
if ($id > 0) {
return ($id >> 21) + 1514736000000;
}
if ($datetime !== null && $datetime !== '') {
$date = DateTimeImmutable::createFromFormat(
'Y-m-d H:i:s',
$datetime,
new DateTimeZone(IM_DB_TIMEZONE)
);
if ($date instanceof DateTimeImmutable) {
return $date->getTimestamp() * 1000;
}
}
return 0;
}
function detailResolveUserId(PDO $db, string $value): string
{
if ($value === '') {
return '';
}
$statement = $db->prepare(
'select `_uid` from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ? limit 1'
);
$statement->execute([$value, $value, $value]);
$uid = $statement->fetchColumn();
return is_string($uid) && $uid !== '' ? $uid : $value;
}
function detailConversationType(mixed $value): string
{
$type = strtolower(trim((string)$value));
return in_array($type, ['1', 'group', 'channel'], true) ? 'group' : 'single';
}
function detailDeliveryMap(PDO $db, array $userIds): array
{
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
if (!$userIds) {
return [];
}
$statement = $db->prepare(
'select `_uid`, max(`_dt`) as `_dt` from `t_delivery_report`'
. ' where `_uid` in (' . detailPlaceholders($userIds) . ') group by `_uid`'
);
$statement->execute($userIds);
$items = [];
foreach ($statement->fetchAll() as $row) {
$items[(string)$row['_uid']] = (int)$row['_dt'];
}
return $items;
}
function detailReadAt(PDO $db, string $readerId, array $targets): int
{
$targets = array_values(array_unique(array_filter(array_map('strval', $targets))));
if ($readerId === '' || !$targets) {
return 0;
}
$statement = $db->prepare(
'select max(`_dt`) from `t_read_report`'
. ' where `_uid` = ? and `_type` = 0 and `_line` = 0'
. ' and `_target` in (' . detailPlaceholders($targets) . ')'
);
$statement->execute(array_merge([$readerId], $targets));
$value = $statement->fetchColumn();
return is_numeric($value) ? (int)$value : 0;
}
function detailSingleMessageVisible(PDO $db, string $userId, string $targetId, string $messageUid): bool
{
$table = imUserMessagesTable($userId);
$conversationKeys = [$userId . '|' . $targetId, $targetId . '|' . $userId];
$statement = $db->prepare(
'select 1 from `' . $table . '`'
. ' where `_uid` = ? and `_mid` = ? and `_type` = 0 and `_line` = 0'
. ' and `_target` in (' . detailPlaceholders($conversationKeys) . ') limit 1'
);
$statement->execute(array_merge([$userId, $messageUid], $conversationKeys));
return (bool)$statement->fetchColumn();
}
function detailUserMap(PDO $imDb, PDO $appDb, array $userIds, string $ownerId, string $groupId = ''): array
{
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
if (!$userIds) {
return [];
}
$statement = $imDb->prepare(
'select `_uid`, `_name`, `_display_name`, `_mobile`, `_portrait` from `t_user`'
. ' where `_uid` in (' . detailPlaceholders($userIds) . ')'
);
$statement->execute($userIds);
$users = [];
foreach ($statement->fetchAll() as $row) {
$uid = (string)$row['_uid'];
$rawName = trim((string)($row['_display_name'] ?? ''))
?: trim((string)($row['_name'] ?? ''))
?: trim((string)($row['_mobile'] ?? ''))
?: $uid;
$users[$uid] = [
'uid' => $uid,
'rawName' => $rawName,
'mobile' => (string)($row['_mobile'] ?? ''),
'portrait' => (string)($row['_portrait'] ?? ''),
'contactAlias' => '',
'remarkAlias' => '',
'groupAlias' => '',
'role' => '',
'displayName' => $rawName,
];
}
if ($ownerId !== '') {
$aliasStatement = $imDb->prepare(
'select `_friend_uid`, `_alias` from `t_friend` where `_uid` = ?'
. ' and `_friend_uid` in (' . detailPlaceholders($userIds) . ')'
);
$aliasStatement->execute(array_merge([$ownerId], $userIds));
foreach ($aliasStatement->fetchAll() as $row) {
$uid = (string)$row['_friend_uid'];
if (isset($users[$uid])) {
$users[$uid]['contactAlias'] = trim((string)($row['_alias'] ?? ''));
}
}
try {
$remarkMap = fetchWebUserRemarkMap($appDb, $ownerId, $userIds);
} catch (Throwable) {
$remarkMap = [];
}
foreach ($remarkMap as $uid => $alias) {
if (isset($users[$uid])) {
$users[$uid]['remarkAlias'] = trim((string)$alias);
}
}
}
if ($groupId !== '') {
$memberStatement = $appDb->prepare(
'select `user_id`, `alias`, `role` from `t_web_group_members`'
. ' where `group_id` = ? and `user_id` in (' . detailPlaceholders($userIds) . ')'
);
$memberStatement->execute(array_merge([$groupId], $userIds));
foreach ($memberStatement->fetchAll() as $row) {
$uid = (string)$row['user_id'];
if (isset($users[$uid])) {
$users[$uid]['groupAlias'] = trim((string)($row['alias'] ?? ''));
$users[$uid]['role'] = (string)($row['role'] ?? '');
}
}
}
foreach ($users as $uid => $user) {
$alias = (string)$user['contactAlias'] !== ''
? (string)$user['contactAlias']
: ((string)$user['remarkAlias'] !== '' ? (string)$user['remarkAlias'] : (string)$user['groupAlias']);
$users[$uid]['displayName'] = $alias !== '' ? $alias : (string)$user['rawName'];
}
foreach ($userIds as $uid) {
if (!isset($users[$uid])) {
$users[$uid] = [
'uid' => $uid,
'rawName' => $uid,
'mobile' => '',
'portrait' => '',
'contactAlias' => '',
'remarkAlias' => '',
'groupAlias' => '',
'role' => '',
'displayName' => $uid,
];
}
}
return $users;
}
function detailMember(array $user, int $time = 0): array
{
return [
'uid' => (string)$user['uid'],
'name' => (string)$user['displayName'],
'rawName' => (string)$user['rawName'],
'mobile' => (string)($user['mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? ''),
'role' => (string)($user['role'] ?? ''),
'time' => $time,
];
}
function singleDetail(PDO $imDb, string $userId, string $targetId, string $messageUid): array
{
if (!preg_match('/^\d+$/', $messageUid)) {
respond(400, '缺少可查询的消息ID');
}
$table = imMessageTable($messageUid);
$statement = $imDb->prepare(
'select `_mid`, `_from`, `_target`, `_dt`, `_content_type`, `_searchable_key`, `_to`'
. ' from `' . $table . '` where `_mid` = ? limit 1'
);
$statement->execute([$messageUid]);
$message = $statement->fetch();
if (!is_array($message)) {
respond(404, '没有找到该消息');
}
$senderId = (string)$message['_from'];
if (!detailSingleMessageVisible($imDb, $userId, $targetId, $messageUid)) {
respond(403, '没有权限查看该消息');
}
$receiverId = $senderId === $userId ? $targetId : $userId;
if ($senderId !== $userId && $senderId !== $targetId) {
respond(403, '没有权限查看该消息');
}
$sentAt = detailTimestampMs((string)($message['_dt'] ?? ''), $messageUid);
$targets = [$senderId, $senderId . '|' . $receiverId, $receiverId . '|' . $senderId];
$deliveredRaw = detailDeliveryMap($imDb, [$receiverId])[$receiverId] ?? 0;
$readRaw = detailReadAt($imDb, $receiverId, $targets);
$users = detailUserMap($imDb, imAppDb(), [$senderId, $receiverId], $userId);
return [
'messageUid' => $messageUid,
'conversationType' => 'single',
'sentAt' => $sentAt,
'sender' => detailMember($users[$senderId]),
'receiver' => detailMember($users[$receiverId]),
'deliveredAt' => $deliveredRaw >= $sentAt ? $deliveredRaw : 0,
'readAt' => $readRaw >= $sentAt ? $readRaw : 0,
];
}
function groupDetail(PDO $imDb, PDO $appDb, string $userId, string $groupId, string $messageUid): array
{
ensureWebImAppTables();
if (!preg_match('/^\d+$/', $messageUid)) {
respond(400, '缺少可查询的消息ID');
}
$myMember = $appDb->prepare(
'select `role` from `t_web_group_members` where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
);
$myMember->execute([$groupId, $userId]);
if (!$myMember->fetchColumn()) {
respond(403, '你不在该群组中');
}
$messageStatement = $appDb->prepare(
'select `message_uid`, `group_id`, `sender_uid`, `created_at_ms`, `edited_at_ms`, `recalled`'
. ' from `t_web_group_messages` where `group_id` = ? and `message_uid` = ? limit 1'
);
$messageStatement->execute([$groupId, $messageUid]);
$message = $messageStatement->fetch();
if (!is_array($message)) {
respond(404, '没有找到该群消息');
}
$sentAt = (int)$message['created_at_ms'];
$editedAt = (int)($message['edited_at_ms'] ?? 0);
$activityAt = max($sentAt, $editedAt);
$senderId = (string)$message['sender_uid'];
$memberStatement = $appDb->prepare(
'select `user_id`, `role`, `alias`, `joined_at_ms` from `t_web_group_members`'
. ' where `group_id` = ? and `status` = 0 and `joined_at_ms` <= ?'
. ' order by (`role` = "owner") desc, (`role` = "admin") desc, `joined_at_ms` asc'
);
$memberStatement->execute([$groupId, $sentAt]);
$members = array_values(array_filter($memberStatement->fetchAll(), static function (array $member) use ($senderId): bool {
return (string)$member['user_id'] !== $senderId;
}));
$memberIds = array_values(array_unique(array_map(static fn(array $member): string => (string)$member['user_id'], $members)));
$users = detailUserMap($imDb, $appDb, array_merge([$senderId], $memberIds), $userId, $groupId);
$deliveryMap = detailDeliveryMap($imDb, $memberIds);
$readMap = [];
if ($memberIds) {
$readStatement = $appDb->prepare(
'select `user_id`, `read_at_ms` from `t_web_group_reads`'
. ' where `group_id` = ? and `user_id` in (' . detailPlaceholders($memberIds) . ')'
);
$readStatement->execute(array_merge([$groupId], $memberIds));
foreach ($readStatement->fetchAll() as $row) {
$readMap[(string)$row['user_id']] = (int)$row['read_at_ms'];
}
}
$read = [];
$unread = [];
$undelivered = [];
foreach ($memberIds as $uid) {
$readAt = (int)($readMap[$uid] ?? 0);
$deliveredAt = (int)($deliveryMap[$uid] ?? 0);
$member = detailMember($users[$uid], $readAt >= $activityAt ? $readAt : ($deliveredAt >= $sentAt ? $deliveredAt : 0));
if ($readAt >= $activityAt) {
$read[] = $member;
} elseif ($deliveredAt >= $sentAt) {
$unread[] = $member;
} else {
$undelivered[] = $member;
}
}
return [
'messageUid' => $messageUid,
'conversationType' => 'group',
'groupId' => $groupId,
'sentAt' => $sentAt,
'editedAt' => $editedAt,
'recalled' => (bool)$message['recalled'],
'sender' => detailMember($users[$senderId] ?? ['uid' => $senderId, 'displayName' => $senderId, 'rawName' => $senderId]),
'group' => [
'read' => $read,
'unread' => $unread,
'undelivered' => $undelivered,
'counts' => [
'read' => count($read),
'unread' => count($unread),
'undelivered' => count($undelivered),
'total' => count($memberIds),
],
],
];
}
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? ''));
$target = trim((string)($payload['target'] ?? ''));
$messageUid = trim((string)($payload['messageUid'] ?? $payload['messageId'] ?? ''));
$conversationType = detailConversationType($payload['conversationType'] ?? $payload['type'] ?? 'single');
if ($userId === '') {
respond(400, '缺少用户ID');
}
if ($target === '') {
respond(400, '缺少会话对象');
}
if ($messageUid === '') {
respond(400, '缺少消息ID');
}
try {
$imDb = imDb();
$appDb = imAppDb();
$userId = detailResolveUserId($imDb, $userId);
if ($conversationType === 'group') {
respond(0, 'success', ['detail' => groupDetail($imDb, $appDb, $userId, $target, $messageUid)]);
}
$targetId = detailResolveUserId($imDb, $target);
respond(0, 'success', ['detail' => singleDetail($imDb, $userId, $targetId, $messageUid)]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '消息详情加载失败');
}
+900
View File
@@ -0,0 +1,900 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
function clampLimit(mixed $value, int $default, int $max): int
{
$limit = (int)$value;
if ($limit <= 0) {
return $default;
}
return min($limit, $max);
}
function placeholders(array $values): string
{
return implode(',', array_fill(0, count($values), '?'));
}
function payloadBool(mixed $value): bool
{
return filter_var($value, FILTER_VALIDATE_BOOLEAN);
}
function timestampMs(?string $datetime, string $messageId = '0'): int
{
$id = (int)$messageId;
if ($id > 0) {
return ($id >> 21) + 1514736000000;
}
if ($datetime !== null && $datetime !== '') {
$date = DateTimeImmutable::createFromFormat(
'Y-m-d H:i:s',
$datetime,
new DateTimeZone(IM_DB_TIMEZONE)
);
if ($date instanceof DateTimeImmutable) {
return $date->getTimestamp() * 1000;
}
}
return (int)floor(microtime(true) * 1000);
}
function nowMs(): int
{
return (int)floor(microtime(true) * 1000);
}
function markUserDelivered(PDO $db, string $userId, int $timestamp): void
{
if ($userId === '' || $timestamp <= 0) {
return;
}
$statement = $db->prepare(
'insert into `t_delivery_report` (`_uid`, `_dt`) values (?, ?)'
. ' on duplicate key update `_dt` = if(`_dt` < values(`_dt`), values(`_dt`), `_dt`)'
);
$statement->execute([$userId, $timestamp]);
}
function markConversationRead(PDO $db, string $userId, string $targetId, int $timestamp): void
{
if ($userId === '' || $targetId === '' || $timestamp <= 0) {
return;
}
$statement = $db->prepare(
'select `id`, `_dt` from `t_read_report`'
. ' where `_uid` = ? and `_type` = 0 and `_line` = 0 and `_target` = ?'
. ' order by `_dt` desc limit 1'
);
$statement->execute([$userId, $targetId]);
$row = $statement->fetch();
if ($row) {
if ($timestamp > (int)$row['_dt']) {
$update = $db->prepare('update `t_read_report` set `_dt` = ? where `id` = ?');
$update->execute([$timestamp, (int)$row['id']]);
}
return;
}
$insert = $db->prepare(
'insert into `t_read_report` (`_uid`, `_type`, `_line`, `_target`, `_dt`) values (?, 0, 0, ?, ?)'
);
$insert->execute([$userId, $targetId, $timestamp]);
}
function resolveUserId(PDO $db, string $value): string
{
if ($value === '') {
return '';
}
$statement = $db->prepare(
'select `_uid` from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ? limit 1'
);
$statement->execute([$value, $value, $value]);
$uid = $statement->fetchColumn();
return is_string($uid) && $uid !== '' ? $uid : $value;
}
function peerFromConversationTarget(string $userId, string $target): string
{
$parts = array_values(array_filter(explode('|', $target), static fn($part) => $part !== ''));
if (count($parts) < 2) {
return $target !== $userId ? $target : '';
}
return $parts[0] === $userId ? $parts[1] : $parts[0];
}
function fetchMessageRows(PDO $db, array $messageIds): array
{
$messageIds = array_values(array_unique(array_filter(array_map('strval', $messageIds))));
if (!$messageIds) {
return [];
}
$idsByTable = [];
foreach ($messageIds as $messageId) {
$idsByTable[imMessageTable($messageId)][] = $messageId;
}
$messages = [];
foreach ($idsByTable as $table => $ids) {
$sql = 'select `_mid`, `_from`, `_type`, `_target`, `_line`, `_searchable_key`, `_dt`, `_content_type`, `_to`'
. ' from `' . $table . '` where `_mid` in (' . placeholders($ids) . ')';
$statement = $db->prepare($sql);
$statement->execute($ids);
foreach ($statement->fetchAll() as $row) {
$messages[(string)$row['_mid']] = $row;
}
}
return $messages;
}
function fetchMessageEditMap(PDO $db, array $messageIds): array
{
$messageIds = array_values(array_unique(array_filter(array_map('strval', $messageIds))));
if (!$messageIds) {
return [];
}
try {
$statement = $db->prepare(
'select `message_uid`, `edited_at_ms` from `t_web_message_edits`'
. ' where `message_uid` in (' . placeholders($messageIds) . ')'
);
$statement->execute($messageIds);
} catch (Throwable) {
return [];
}
$edits = [];
foreach ($statement->fetchAll() as $row) {
$edits[(string)$row['message_uid']] = [
'editedAt' => (int)$row['edited_at_ms'],
];
}
return $edits;
}
function normalizeAttachmentRow(array $row): array
{
return [
'type' => (string)$row['attachment_type'],
'url' => (string)$row['url'],
'name' => (string)($row['name'] ?? ''),
'mime' => (string)($row['mime'] ?? ''),
'size' => (int)($row['size_bytes'] ?? 0),
];
}
function fetchAttachmentMap(PDO $appDb, array $messageIds): array
{
$messageIds = array_values(array_unique(array_filter(array_map('strval', $messageIds))));
if (!$messageIds) {
return [];
}
ensureWebImAppTables();
$statement = $appDb->prepare(
'select `message_uid`, `attachment_type`, `url`, `name`, `mime`, `size_bytes`'
. ' from `t_web_message_attachments`'
. ' where `message_uid` in (' . placeholders($messageIds) . ')'
);
$statement->execute($messageIds);
$attachments = [];
foreach ($statement->fetchAll() as $row) {
$attachments[(string)$row['message_uid']] = normalizeAttachmentRow($row);
}
return $attachments;
}
function attachmentFallbackText(?array $attachment): string
{
if (!$attachment) {
return '';
}
if ((string)$attachment['type'] === 'image') {
return '[图片]';
}
return '[文件] ' . ((string)($attachment['name'] ?? '') ?: '文件');
}
function attachmentDisplayText(string $text, ?array $attachment): string
{
if (!$attachment) {
return $text;
}
return $text === attachmentFallbackText($attachment) ? '' : $text;
}
function messagePreviewText(?array $message): string
{
if (!$message) {
return '';
}
$attachment = is_array($message['attachment'] ?? null) ? $message['attachment'] : null;
if ($attachment) {
$text = trim((string)($message['text'] ?? ''));
return $text !== '' ? $text : attachmentFallbackText($attachment);
}
return (string)($message['text'] ?? '');
}
function fetchUserInfoMap(PDO $db, array $userIds, string $ownerId = ''): array
{
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
if (!$userIds) {
return [];
}
$statement = $db->prepare(
'select `_uid`, `_name`, `_display_name`, `_mobile`, `_portrait`, `_address` from `t_user`'
. ' where `_uid` in (' . placeholders($userIds) . ')'
);
$statement->execute($userIds);
$users = [];
foreach ($statement->fetchAll() as $row) {
$row['_is_friend'] = false;
$row['_alias'] = '';
$row['_friend_alias'] = '';
$row['_remark_alias'] = '';
$users[(string)$row['_uid']] = $row;
}
if ($ownerId !== '') {
$aliasStatement = $db->prepare(
'select `_friend_uid`, `_alias` from `t_friend` where `_uid` = ?'
. ' and `_friend_uid` in (' . placeholders($userIds) . ')'
);
$aliasStatement->execute(array_merge([$ownerId], $userIds));
foreach ($aliasStatement->fetchAll() as $row) {
$friendUid = (string)$row['_friend_uid'];
if (!isset($users[$friendUid])) {
continue;
}
$users[$friendUid]['_is_friend'] = true;
$users[$friendUid]['_friend_alias'] = trim((string)($row['_alias'] ?? ''));
}
try {
ensureWebImAppTables();
$remarkMap = fetchWebUserRemarkMap(imAppDb(), $ownerId, $userIds);
} catch (Throwable) {
$remarkMap = [];
}
foreach ($remarkMap as $targetUid => $alias) {
if (!isset($users[$targetUid])) {
continue;
}
$users[$targetUid]['_remark_alias'] = $alias;
}
foreach ($users as $uid => $user) {
$friendAlias = trim((string)($user['_friend_alias'] ?? ''));
$remarkAlias = trim((string)($user['_remark_alias'] ?? ''));
$users[$uid]['_alias'] = $friendAlias !== '' ? $friendAlias : $remarkAlias;
}
}
return $users;
}
function userTitle(array $users, string $userId): string
{
$user = $users[$userId] ?? null;
if (!$user) {
return $userId;
}
return (string)($user['_alias'] ?? '')
?: (string)($user['_display_name'] ?? '')
?: (string)($user['_name'] ?? '')
?: (string)($user['_mobile'] ?? '')
?: $userId;
}
function loadPeerReceiptState(PDO $db, string $currentUserId, string $peerId): array
{
$deliveryStatement = $db->prepare('select `_dt` from `t_delivery_report` where `_uid` = ? limit 1');
$deliveryStatement->execute([$peerId]);
$deliveredAt = $deliveryStatement->fetchColumn();
$readTargets = array_values(array_unique([
$currentUserId,
$currentUserId . '|' . $peerId,
$peerId . '|' . $currentUserId,
]));
$readStatement = $db->prepare(
'select max(`_dt`) from `t_read_report`'
. ' where `_uid` = ? and `_type` = 0 and `_line` = 0'
. ' and `_target` in (' . placeholders($readTargets) . ')'
);
$readStatement->execute(array_merge([$peerId], $readTargets));
$readAt = $readStatement->fetchColumn();
return [
'deliveredAt' => is_numeric($deliveredAt) ? (int)$deliveredAt : 0,
'readAt' => is_numeric($readAt) ? (int)$readAt : 0,
];
}
function loadOwnReadStateMap(PDO $db, string $userId, array $peerIds): array
{
$peerIds = array_values(array_unique(array_filter(array_map('strval', $peerIds))));
if (!$peerIds) {
return [];
}
$statement = $db->prepare(
'select `_target`, max(`_dt`) as `_read_at` from `t_read_report`'
. ' where `_uid` = ? and `_type` = 0 and `_line` = 0'
. ' and `_target` in (' . placeholders($peerIds) . ')'
. ' group by `_target`'
);
$statement->execute(array_merge([$userId], $peerIds));
$readAtByPeer = [];
foreach ($statement->fetchAll() as $row) {
$readAtByPeer[(string)$row['_target']] = (int)$row['_read_at'];
}
return $readAtByPeer;
}
function outgoingDeliveryStatus(int $timestamp, array $receiptState): string
{
$readAt = (int)($receiptState['readAt'] ?? 0);
if ($readAt > 0 && $timestamp <= $readAt) {
return 'read';
}
$deliveredAt = (int)($receiptState['deliveredAt'] ?? 0);
if ($deliveredAt > 0 && $timestamp <= $deliveredAt) {
return 'delivered';
}
return 'sent';
}
function messageActivityTimestamp(int $timestamp, array $editState = []): int
{
return max($timestamp, (int)($editState['editedAt'] ?? 0));
}
function normalizeConversationType(mixed $value): string
{
$type = strtolower(trim((string)$value));
if (in_array($type, ['1', 'group', 'channel'], true)) {
return 'group';
}
return 'single';
}
function isWebGroupTarget(PDO $appDb, string $targetId): bool
{
if ($targetId === '') {
return false;
}
ensureWebImAppTables();
$statement = $appDb->prepare('select 1 from `t_web_groups` where `group_id` = ? and `deleted` = 0 limit 1');
$statement->execute([$targetId]);
return (bool)$statement->fetchColumn();
}
function markWebGroupRead(PDO $appDb, string $userId, string $groupId, int $timestamp): void
{
if ($userId === '' || $groupId === '' || $timestamp <= 0) {
return;
}
$now = nowMs();
$statement = $appDb->prepare(
'insert into `t_web_group_reads` (`group_id`, `user_id`, `read_at_ms`, `updated_at_ms`)'
. ' values (?, ?, ?, ?)'
. ' on duplicate key update'
. ' `read_at_ms` = if(`read_at_ms` < values(`read_at_ms`), values(`read_at_ms`), `read_at_ms`),'
. ' `updated_at_ms` = values(`updated_at_ms`)'
);
$statement->execute([$groupId, $userId, $timestamp, $now]);
}
function webGroupRole(PDO $appDb, string $userId, string $groupId): string
{
$statement = $appDb->prepare(
'select `role` from `t_web_group_members`'
. ' where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
);
$statement->execute([$groupId, $userId]);
$role = $statement->fetchColumn();
return is_string($role) && $role !== '' ? $role : '';
}
function formatWebGroupMessage(
array $row,
string $currentUserId,
array $group,
?array $attachment = null,
array $users = []
): array
{
$groupId = (string)$row['group_id'];
$from = (string)$row['sender_uid'];
$senderUser = $users[$from] ?? [];
$senderAlias = trim((string)($row['sender_alias'] ?? ''));
$senderContactAlias = trim((string)($senderUser['_alias'] ?? ''));
$senderFriendAlias = trim((string)($senderUser['_friend_alias'] ?? ''));
$senderRemarkAlias = trim((string)($senderUser['_remark_alias'] ?? ''));
$senderRawName = trim((string)($senderUser['_display_name'] ?? ''))
?: trim((string)($senderUser['_name'] ?? ''))
?: trim((string)($senderUser['_mobile'] ?? ''))
?: $from;
$senderName = $senderContactAlias
?: $senderAlias
?: $senderRawName;
$senderRole = (string)($row['sender_role'] ?? 'member');
if (!in_array($senderRole, ['owner', 'admin'], true)) {
$senderRole = 'member';
}
$timestamp = (int)$row['created_at_ms'];
$editedAt = (int)($row['edited_at_ms'] ?? 0);
$activityTimestamp = max($timestamp, $editedAt);
$direction = $from === $currentUserId ? 'out' : 'in';
$messageType = $attachment ? (string)$attachment['type'] : 'text';
$contentType = $messageType === 'image' ? 3 : ($messageType === 'file' ? 5 : 1);
return [
'id' => (string)$row['message_uid'],
'messageUid' => (string)$row['message_uid'],
'conversationId' => 'group-' . $groupId,
'from' => $from,
'senderRole' => $senderRole,
'senderName' => $senderName,
'senderPortrait' => (string)($senderUser['_portrait'] ?? ''),
'senderProfile' => [
'uid' => $from,
'displayName' => $senderName,
'rawName' => $senderRawName,
'contactAlias' => $senderContactAlias,
'friendAlias' => $senderFriendAlias,
'remarkAlias' => $senderRemarkAlias,
'groupAlias' => $senderAlias,
'isContact' => (bool)($senderUser['_is_friend'] ?? false),
'mobile' => (string)($senderUser['_mobile'] ?? ''),
'address' => (string)($senderUser['_address'] ?? ''),
'portrait' => (string)($senderUser['_portrait'] ?? ''),
'role' => $senderRole,
],
'to' => $groupId,
'target' => $groupId,
'direction' => $direction,
'type' => $messageType,
'contentType' => $contentType,
'text' => attachmentDisplayText((string)$row['text'], $attachment),
'attachment' => $attachment,
'status' => $direction === 'out' ? 'sent' : 'sent',
'deliveryStatus' => $direction === 'out' ? 'sent' : '',
'recalled' => false,
'recallStatus' => '',
'editedAt' => $editedAt,
'timestamp' => $timestamp,
'activityTimestamp' => $activityTimestamp,
'displayTimestamp' => $timestamp,
'conversationType' => (string)($group['category'] ?? 'group'),
];
}
function loadWebGroupMessages(PDO $appDb, string $userId, string $groupId, int $limit, bool $markRead): array
{
ensureWebImAppTables();
$role = webGroupRole($appDb, $userId, $groupId);
if ($role === '') {
return [];
}
$groupStatement = $appDb->prepare('select * from `t_web_groups` where `group_id` = ? and `deleted` = 0 limit 1');
$groupStatement->execute([$groupId]);
$group = $groupStatement->fetch();
if (!$group) {
return [];
}
$statement = $appDb->prepare(
'select msg.*,'
. ' coalesce(sender_member.`role`, if(g.`owner_uid` = msg.`sender_uid`, "owner", "member")) as `sender_role`,'
. ' coalesce(sender_member.`alias`, "") as `sender_alias`'
. ' from `t_web_group_messages` msg'
. ' join `t_web_groups` g on g.`group_id` = msg.`group_id` and g.`deleted` = 0'
. ' left join `t_web_group_members` sender_member'
. ' on sender_member.`group_id` = msg.`group_id`'
. ' and sender_member.`user_id` = msg.`sender_uid`'
. ' and sender_member.`status` = 0'
. ' where msg.`group_id` = ? and msg.`recalled` = 0'
. ' order by msg.`created_at_ms` desc, msg.`id` desc limit ' . $limit
);
$statement->execute([$groupId]);
$rows = array_reverse($statement->fetchAll());
$attachmentMap = fetchAttachmentMap($appDb, array_column($rows, 'message_uid'));
$users = fetchUserInfoMap(imDb(), array_column($rows, 'sender_uid'), $userId);
$readTimestamp = 0;
$messages = [];
foreach ($rows as $row) {
$messageId = (string)$row['message_uid'];
$message = formatWebGroupMessage($row, $userId, $group, $attachmentMap[$messageId] ?? null, $users);
$readTimestamp = max($readTimestamp, (int)$message['activityTimestamp']);
$messages[] = $message;
}
if ($markRead) {
markWebGroupRead($appDb, $userId, $groupId, $readTimestamp);
}
return $messages;
}
function loadWebGroupConversations(PDO $appDb, string $userId, int $limit): array
{
ensureWebImAppTables();
$statement = $appDb->prepare(
'select g.*, m.`role`'
. ' from `t_web_groups` g'
. ' join `t_web_group_members` m on m.`group_id` = g.`group_id` and m.`status` = 0'
. ' where g.`deleted` = 0 and m.`user_id` = ?'
. ' order by g.`updated_at_ms` desc limit ' . min(max($limit, 50), 100)
);
$statement->execute([$userId]);
$groups = $statement->fetchAll();
if (!$groups) {
return [];
}
$lastMessageStatement = $appDb->prepare(
'select * from `t_web_group_messages`'
. ' where `group_id` = ? and `recalled` = 0'
. ' order by greatest(`created_at_ms`, `edited_at_ms`) desc, `id` desc limit 1'
);
$readStatement = $appDb->prepare(
'select `read_at_ms` from `t_web_group_reads` where `group_id` = ? and `user_id` = ? limit 1'
);
$lastAttachmentStatement = $appDb->prepare(
'select `attachment_type`, `url`, `name`, `mime`, `size_bytes`'
. ' from `t_web_message_attachments` where `message_uid` = ? limit 1'
);
$unreadStatement = $appDb->prepare(
'select count(*) from `t_web_group_messages`'
. ' where `group_id` = ? and `sender_uid` <> ? and `recalled` = 0'
. ' and greatest(`created_at_ms`, `edited_at_ms`) > ?'
);
$memberCountStatement = $appDb->prepare(
'select count(*) from `t_web_group_members` where `group_id` = ? and `status` = 0'
);
$conversations = [];
foreach ($groups as $group) {
$groupId = (string)$group['group_id'];
$lastMessageStatement->execute([$groupId]);
$lastMessage = $lastMessageStatement->fetch();
$readStatement->execute([$groupId, $userId]);
$readAt = (int)($readStatement->fetchColumn() ?: 0);
$unreadStatement->execute([$groupId, $userId, $readAt]);
$unread = (int)$unreadStatement->fetchColumn();
$memberCountStatement->execute([$groupId]);
$memberCount = (int)$memberCountStatement->fetchColumn();
$title = (string)$group['name'];
$category = (string)$group['category'];
$isChannel = $category === 'channel';
$lastAttachment = null;
if ($lastMessage) {
$lastAttachmentStatement->execute([(string)$lastMessage['message_uid']]);
$lastAttachmentRow = $lastAttachmentStatement->fetch();
$lastAttachment = $lastAttachmentRow ? normalizeAttachmentRow($lastAttachmentRow) : null;
}
$subtitle = $lastMessage
? (messagePreviewText([
'text' => attachmentDisplayText((string)$lastMessage['text'], $lastAttachment),
'attachment' => $lastAttachment,
]) ?: (string)$lastMessage['text'])
: ($isChannel ? '公告群' : ($memberCount > 0 ? $memberCount . ' 位成员' : '群组'));
$updatedAt = $lastMessage
? max((int)$lastMessage['created_at_ms'], (int)($lastMessage['edited_at_ms'] ?? 0))
: (int)$group['updated_at_ms'];
$conversations[] = [
'id' => 'group-' . $groupId,
'type' => $isChannel ? 'channel' : 'group',
'target' => $groupId,
'title' => $title,
'subtitle' => $subtitle,
'unread' => $unread,
'avatarText' => mb_strtoupper(mb_substr($title, 0, 1, 'UTF-8'), 'UTF-8'),
'updatedAt' => $updatedAt,
'role' => (string)$group['role'],
'memberCount' => $memberCount,
'raw' => [
'type' => 'group',
'target' => $groupId,
'line' => 0,
'category' => $category,
'role' => (string)$group['role'],
],
];
}
return $conversations;
}
function isRecallMessage(array $row): bool
{
return (int)($row['_content_type'] ?? 0) === 80;
}
function shouldDisplayMessageForUser(array $row, string $currentUserId): bool
{
if (!isRecallMessage($row)) {
return true;
}
return (string)$row['_from'] === $currentUserId;
}
function formatMessage(
array $row,
string $currentUserId,
string $peerId,
array $receiptState = [],
array $editState = [],
?array $attachment = null
): array
{
$messageId = (string)$row['_mid'];
$from = (string)$row['_from'];
$contentType = (int)($row['_content_type'] ?? 0);
$isRecalled = $contentType === 80;
$text = (string)($row['_searchable_key'] ?? '');
$timestamp = timestampMs((string)($row['_dt'] ?? ''), $messageId);
$direction = $from === $currentUserId ? 'out' : 'in';
$messageType = $attachment ? (string)$attachment['type'] : (($contentType === 1 || $isRecalled) ? 'text' : 'unknown');
$messageText = $text !== '' ? $text : ($isRecalled || $attachment ? '' : '[暂不支持的消息类型]');
$messageText = attachmentDisplayText($messageText, $attachment);
$editedAt = (int)($editState['editedAt'] ?? 0);
$activityTimestamp = messageActivityTimestamp($timestamp, $editState);
$deliveryStatus = $direction === 'out' && !$isRecalled
? outgoingDeliveryStatus($activityTimestamp, $receiptState)
: '';
return [
'id' => $messageId,
'messageUid' => $messageId,
'conversationId' => 'single-' . $peerId,
'from' => $from,
'to' => $from === $currentUserId ? $peerId : $currentUserId,
'target' => $peerId,
'direction' => $direction,
'type' => $messageType,
'contentType' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : $contentType,
'text' => $messageText,
'attachment' => $attachment,
'status' => $isRecalled && $direction === 'out' ? 'recalled' : ($direction === 'out' ? $deliveryStatus : 'sent'),
'deliveryStatus' => $deliveryStatus,
'recalled' => $isRecalled,
'recallStatus' => $isRecalled ? '已撤回' : '',
'editedAt' => $editedAt,
'timestamp' => $timestamp,
'activityTimestamp' => $activityTimestamp,
'displayTimestamp' => $timestamp,
];
}
function loadConversationMessages(PDO $db, string $userId, string $targetId, int $limit, bool $markRead): array
{
$table = imUserMessagesTable($userId);
$conversationKeys = [$userId . '|' . $targetId, $targetId . '|' . $userId];
$scanLimit = min(max($limit * 4, $limit), 400);
$statement = $db->prepare(
'select `_mid`, `_dt` from `' . $table . '`'
. ' where `_uid` = ? and `_type` = 0 and `_line` = 0'
. ' and `_target` in (?, ?)'
. ' order by `_mid` desc limit ' . $scanLimit
);
$statement->execute([$userId, $conversationKeys[0], $conversationKeys[1]]);
$userRows = $statement->fetchAll();
$messageRows = fetchMessageRows($db, array_column($userRows, '_mid'));
$editMap = fetchMessageEditMap($db, array_column($userRows, '_mid'));
$attachmentMap = fetchAttachmentMap(imAppDb(), array_column($userRows, '_mid'));
$receiptState = loadPeerReceiptState($db, $userId, $targetId);
$messages = [];
$seenMessageIds = [];
$readTimestamp = 0;
foreach ($userRows as $userRow) {
$messageId = (string)$userRow['_mid'];
if (isset($seenMessageIds[$messageId]) || !isset($messageRows[$messageId])) {
continue;
}
$seenMessageIds[$messageId] = true;
if (!shouldDisplayMessageForUser($messageRows[$messageId], $userId)) {
continue;
}
$message = formatMessage(
$messageRows[$messageId],
$userId,
$targetId,
$receiptState,
$editMap[$messageId] ?? [],
$attachmentMap[$messageId] ?? null
);
$messages[] = $message;
$readTimestamp = max($readTimestamp, (int)$message['activityTimestamp']);
if (count($messages) >= $limit) {
break;
}
}
$messages = array_reverse($messages);
if ($markRead) {
markConversationRead($db, $userId, $targetId, $readTimestamp);
}
return $messages;
}
function loadRecentConversations(PDO $db, string $userId, int $limit): array
{
$table = imUserMessagesTable($userId);
$scanLimit = min(max($limit * 20, 200), 1000);
$statement = $db->prepare(
'select `_mid`, `_target`, `_dt` from `' . $table . '`'
. ' where `_uid` = ? and `_type` = 0 and `_line` = 0'
. ' order by `_mid` desc limit ' . $scanLimit
);
$statement->execute([$userId]);
$scanRows = $statement->fetchAll();
$messageRows = fetchMessageRows($db, array_column($scanRows, '_mid'));
$editMap = fetchMessageEditMap($db, array_column($scanRows, '_mid'));
$attachmentMap = fetchAttachmentMap(imAppDb(), array_column($scanRows, '_mid'));
$latestByPeer = [];
foreach ($scanRows as $row) {
$messageId = (string)$row['_mid'];
$message = $messageRows[$messageId] ?? null;
$peerId = peerFromConversationTarget($userId, (string)$row['_target']);
if ($peerId === '' || !$message) {
continue;
}
if (!shouldDisplayMessageForUser($message, $userId)) {
continue;
}
$activityTimestamp = messageActivityTimestamp(
timestampMs((string)($message['_dt'] ?? ''), $messageId),
$editMap[$messageId] ?? []
);
if (!isset($latestByPeer[$peerId]) || $activityTimestamp > (int)($latestByPeer[$peerId]['_activity_at'] ?? 0)) {
$row['_activity_at'] = $activityTimestamp;
$latestByPeer[$peerId] = $row;
}
}
uasort($latestByPeer, static fn($a, $b) => (int)($b['_activity_at'] ?? 0) <=> (int)($a['_activity_at'] ?? 0));
$latestByPeer = array_slice($latestByPeer, 0, $limit, true);
$users = fetchUserInfoMap($db, array_keys($latestByPeer), $userId);
$readAtByPeer = loadOwnReadStateMap($db, $userId, array_keys($latestByPeer));
$unreadByPeer = [];
$seenUnreadMessageIds = [];
foreach ($scanRows as $row) {
$messageId = (string)$row['_mid'];
if (isset($seenUnreadMessageIds[$messageId])) {
continue;
}
$seenUnreadMessageIds[$messageId] = true;
$peerId = peerFromConversationTarget($userId, (string)$row['_target']);
$message = $messageRows[$messageId] ?? null;
if ($peerId === '' || !$message || !isset($latestByPeer[$peerId])) {
continue;
}
if ((string)$message['_from'] === $userId) {
continue;
}
if (isRecallMessage($message)) {
continue;
}
$timestamp = timestampMs((string)($message['_dt'] ?? ''), $messageId);
$activityTimestamp = messageActivityTimestamp($timestamp, $editMap[$messageId] ?? []);
if ($activityTimestamp > (int)($readAtByPeer[$peerId] ?? 0)) {
$unreadByPeer[$peerId] = (int)($unreadByPeer[$peerId] ?? 0) + 1;
}
}
$conversations = [];
foreach ($latestByPeer as $peerId => $userRow) {
$messageId = (string)$userRow['_mid'];
$message = isset($messageRows[$messageId])
? formatMessage($messageRows[$messageId], $userId, $peerId, [], $editMap[$messageId] ?? [], $attachmentMap[$messageId] ?? null)
: null;
$title = userTitle($users, $peerId);
$conversations[] = [
'id' => 'single-' . $peerId,
'type' => 'single',
'target' => $peerId,
'title' => $title,
'subtitle' => $message ? (messagePreviewText($message) !== '' ? messagePreviewText($message) : (empty($message['recalled']) ? '' : '已撤回')) : '',
'unread' => (int)($unreadByPeer[$peerId] ?? 0),
'portrait' => (string)($users[$peerId]['_portrait'] ?? ''),
'avatarText' => mb_strtoupper(mb_substr($title, 0, 1, 'UTF-8'), 'UTF-8'),
'updatedAt' => $message ? $message['activityTimestamp'] : timestampMs((string)($userRow['_dt'] ?? ''), $messageId),
'raw' => ['type' => 'single', 'target' => $peerId, 'line' => 0],
];
}
try {
$conversations = array_merge($conversations, loadWebGroupConversations(imAppDb(), $userId, $limit));
} catch (Throwable) {
// Keep single-chat sync available if appdata group metadata is temporarily unavailable.
}
usort($conversations, static fn($a, $b) => $b['updatedAt'] <=> $a['updatedAt']);
return array_slice($conversations, 0, $limit);
}
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? ''));
$target = trim((string)($payload['target'] ?? ''));
$limit = clampLimit($payload['limit'] ?? 50, 50, 100);
$markRead = payloadBool($payload['markRead'] ?? false);
$conversationType = normalizeConversationType($payload['conversationType'] ?? $payload['type'] ?? 0);
if ($userId === '') {
respond(400, '缺少用户ID');
}
try {
$db = imDb();
$userId = resolveUserId($db, $userId);
markUserDelivered($db, $userId, nowMs());
if ($target !== '') {
$targetId = resolveUserId($db, $target);
if ($conversationType === 'group' || isWebGroupTarget(imAppDb(), $target)) {
respond(0, 'success', [
'messages' => loadWebGroupMessages(imAppDb(), $userId, $target, $limit, $markRead),
]);
}
respond(0, 'success', [
'messages' => loadConversationMessages($db, $userId, $targetId, $limit, $markRead),
]);
}
respond(0, 'success', [
'conversations' => loadRecentConversations($db, $userId, $limit),
]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '消息同步失败');
}
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
$defaultName = 'Web IM';
$defaultLogo = '/img/logo.png';
try {
$statement = imAppDb()->prepare(
'select `conf_name`, `conf_value`, `updated_at` from `system_conf` where `conf_name` in (?, ?)'
);
$statement->execute(['PlatformName', 'sys_logo']);
$config = [];
$updatedAtValues = [];
foreach ($statement->fetchAll() as $row) {
$config[(string)$row['conf_name']] = trim((string)($row['conf_value'] ?? ''));
$updatedAtValues[] = (string)($row['updated_at'] ?? '');
}
$platformName = $config['PlatformName'] ?? '';
$logo = $config['sys_logo'] ?? '';
$version = implode('|', array_values(array_filter($updatedAtValues ?? [])));
respond(0, 'success', [
'platformName' => $platformName !== '' ? $platformName : $defaultName,
'logo' => $logo !== '' ? $logo : $defaultLogo,
'version' => $version,
]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '平台配置加载失败', [
'platformName' => $defaultName,
'logo' => $defaultLogo,
]);
}
+321
View File
@@ -0,0 +1,321 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
const PROFILE_DISPLAY_NAME_FLAG = 0x01;
const PROFILE_PORTRAIT_FLAG = 0x02;
const PROFILE_PORTRAIT_MEDIA_TYPE = 5;
const IM_LOCAL_MEDIA_ROOT = '/opt/im-server/media';
const IM_LOCAL_MEDIA_PUBLIC_PORT = 18000;
function profileRequestValue(string $key, string $default = ''): string
{
return trim((string)($_POST[$key] ?? $_GET[$key] ?? $default));
}
function normalizeProfileUser(array $user): array
{
$uid = (string)($user['userId'] ?? $user['uid'] ?? $user['_uid'] ?? '');
return [
'uid' => $uid,
'userId' => $uid,
'name' => (string)($user['name'] ?? $user['_name'] ?? ''),
'displayName' => (string)($user['displayName'] ?? $user['_display_name'] ?? $user['name'] ?? $user['_name'] ?? $uid),
'mobile' => (string)($user['mobile'] ?? $user['_mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? $user['_portrait'] ?? ''),
'address' => (string)($user['address'] ?? $user['_address'] ?? ''),
];
}
function tryProfileUserInfo(array $payload): ?array
{
try {
$result = requireImSuccess(imAdminPost('/admin/user/get_info', $payload), '用户资料加载失败');
return $result ?: null;
} catch (Throwable) {
return null;
}
}
function loadProfileUserFromDb(string $identity): ?array
{
$statement = imDb()->prepare(
'select `_uid`, `_name`, `_display_name`, `_mobile`, `_portrait`, `_address`'
. ' from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ?'
. ' order by case when `_uid` = ? then 0 when `_mobile` = ? then 1 when `_name` = ? then 2 else 3 end'
. ' limit 1'
);
$statement->execute([$identity, $identity, $identity, $identity, $identity, $identity]);
$row = $statement->fetch();
return $row ? normalizeProfileUser($row) : null;
}
function mergeProfileUser(array $primary, ?array $fallback): array
{
if (!$fallback) {
return $primary;
}
$fallback = normalizeProfileUser($fallback);
foreach (['name', 'displayName', 'mobile', 'portrait', 'address'] as $key) {
if ((string)($primary[$key] ?? '') === '' && (string)($fallback[$key] ?? '') !== '') {
$primary[$key] = (string)$fallback[$key];
}
}
return $primary;
}
function persistProfileUserToDb(string $userId, string $displayName, string $portrait): void
{
if ($portrait !== '') {
$statement = imDb()->prepare(
'update `t_user` set `_display_name` = ?, `_portrait` = ?, `_dt` = ? where `_uid` = ? limit 1'
);
$statement->execute([$displayName, $portrait, webImNowMs(), $userId]);
return;
}
$statement = imDb()->prepare(
'update `t_user` set `_display_name` = ?, `_dt` = ? where `_uid` = ? limit 1'
);
$statement->execute([$displayName, webImNowMs(), $userId]);
}
function loadProfileUser(string $identity, bool $preferMobile = true): array
{
$identity = trim($identity);
if ($identity === '') {
respond(400, '缺少用户ID');
}
$dbUser = loadProfileUserFromDb($identity);
if ($dbUser) {
$fallback = null;
if ((string)($dbUser['portrait'] ?? '') === '') {
$fallback = tryProfileUserInfo(['userId' => $dbUser['userId']]);
if (!$fallback && (string)($dbUser['mobile'] ?? '') !== '') {
$fallback = tryProfileUserInfo(['mobile' => $dbUser['mobile']]);
}
if (!$fallback && (string)($dbUser['name'] ?? '') !== '') {
$fallback = tryProfileUserInfo(['name' => $dbUser['name']]);
}
}
return mergeProfileUser($dbUser, $fallback);
}
$user = null;
if ($preferMobile && preg_match('/^\+?\d{5,32}$/', $identity)) {
$user = tryProfileUserInfo(['mobile' => $identity]);
}
if (!$user) {
$user = tryProfileUserInfo(['userId' => $identity]);
}
if (!$user) {
$user = tryProfileUserInfo(['name' => $identity]);
}
if (!$user) {
respond(404, '用户资料不存在');
}
return normalizeProfileUser($user);
}
function readProfileJavaSerializedString(string $data, int $offset): ?array
{
if ($offset + 3 > strlen($data) || ord($data[$offset]) !== 0x74) {
return null;
}
$length = (ord($data[$offset + 1]) << 8) | ord($data[$offset + 2]);
$start = $offset + 3;
if ($start + $length > strlen($data)) {
return null;
}
return [substr($data, $start, $length), $start + $length];
}
function profileSessionUserId(string $sessionData): string
{
$marker = "\x74\x00\x06userId";
$offset = strpos($sessionData, $marker);
if ($offset === false) {
return '';
}
$decoded = readProfileJavaSerializedString($sessionData, $offset + strlen($marker));
$userId = is_array($decoded) ? (string)$decoded[0] : '';
if (!preg_match('/^[A-Za-z0-9_:@.+-]{1,128}$/', $userId)) {
return '';
}
return $userId;
}
function requireProfileUpdateAuth(string $targetUserId): void
{
$authToken = trim((string)($_SERVER['HTTP_AUTHTOKEN'] ?? $_SERVER['HTTP_AUTH_TOKEN'] ?? ''));
if ($authToken === '') {
respond(401, '请重新登录后再修改资料');
}
$statement = imAppDb()->prepare('select `session_data` from `shiro_session` where `session_id` = ? limit 1');
$statement->execute([$authToken]);
$row = $statement->fetch();
if (!$row) {
respond(401, '登录状态已失效,请重新登录');
}
$sessionUserId = profileSessionUserId((string)$row['session_data']);
if ($sessionUserId === '' || $sessionUserId !== $targetUserId) {
respond(403, '只能修改自己的资料');
}
}
function profileUploadedFileExtension(string $fileName, string $mime): string
{
$extension = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
$extension = preg_replace('/[^a-z0-9]/', '', $extension) ?: '';
if ($extension === 'jpeg') {
$extension = 'jpg';
}
$allowedExtensions = ['jpg', 'png', 'gif', 'webp', 'bmp', 'ico'];
if (in_array($extension, $allowedExtensions, true)) {
return $extension;
}
return match ($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
'image/bmp', 'image/x-ms-bmp' => 'bmp',
'image/x-icon', 'image/vnd.microsoft.icon' => 'ico',
default => 'jpg',
};
}
function profilePublicMediaBaseUrl(): string
{
$isHttps = (string)($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string)$_SERVER['HTTPS']) !== 'off';
$scheme = $isHttps ? 'https' : 'http';
$host = (string)($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '127.0.0.1');
$host = trim(preg_replace('/:\d+$/', '', $host) ?: '127.0.0.1');
return $scheme . '://' . $host . ':' . IM_LOCAL_MEDIA_PUBLIC_PORT;
}
function uploadProfilePortrait(array $file): string
{
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new RuntimeException('头像上传失败');
}
if (($file['size'] ?? 0) <= 0 || ($file['size'] ?? 0) > 5 * 1024 * 1024) {
throw new RuntimeException('头像不能超过5MB');
}
$tmpName = (string)($file['tmp_name'] ?? '');
$mime = mime_content_type($tmpName) ?: '';
if (!str_starts_with($mime, 'image/')) {
throw new RuntimeException('头像必须是图片文件');
}
if (!is_dir(IM_LOCAL_MEDIA_ROOT) || !is_writable(IM_LOCAL_MEDIA_ROOT)) {
throw new RuntimeException('头像存储目录不可写');
}
$datePath = 'fs/' . PROFILE_PORTRAIT_MEDIA_TYPE . '/' . date('Y/m/d/H/i/s');
$targetDir = rtrim(IM_LOCAL_MEDIA_ROOT, '/') . '/' . $datePath;
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) {
throw new RuntimeException('头像存储目录创建失败');
}
$extension = profileUploadedFileExtension((string)($file['name'] ?? 'avatar.jpg'), $mime);
$targetName = bin2hex(random_bytes(12)) . '.' . $extension;
$targetPath = $targetDir . '/' . $targetName;
if (!move_uploaded_file($tmpName, $targetPath)) {
throw new RuntimeException('头像文件保存失败');
}
@chmod($targetPath, 0644);
return profilePublicMediaBaseUrl() . '/' . $datePath . '/' . $targetName;
}
try {
$contentType = (string)($_SERVER['CONTENT_TYPE'] ?? '');
$isMultipart = stripos($contentType, 'multipart/form-data') !== false;
$payload = $isMultipart ? [] : readJsonPayload();
$action = $isMultipart
? profileRequestValue('action', 'update')
: trim((string)($payload['action'] ?? 'get'));
$userId = $isMultipart
? profileRequestValue('userId')
: trim((string)($payload['userId'] ?? $_GET['userId'] ?? ''));
$currentProfile = loadProfileUser($userId);
if ($action === 'get') {
respond(0, 'success', ['profile' => $currentProfile]);
}
if ($action !== 'update') {
respond(400, '不支持的资料操作');
}
requireProfileUpdateAuth((string)$currentProfile['userId']);
$displayName = $isMultipart
? profileRequestValue('displayName')
: trim((string)($payload['displayName'] ?? ''));
if ($displayName === '') {
respond(400, '昵称不能为空');
}
if (mb_strlen($displayName, 'UTF-8') > 64) {
respond(400, '昵称不能超过64个字');
}
$portrait = '';
if (!empty($_FILES['portraitFile']) && ($_FILES['portraitFile']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
$portrait = uploadProfilePortrait($_FILES['portraitFile']);
}
$flag = PROFILE_DISPLAY_NAME_FLAG;
$userInfo = [
'userId' => $currentProfile['userId'],
'displayName' => $displayName,
];
if ($portrait !== '') {
$flag |= PROFILE_PORTRAIT_FLAG;
$userInfo['portrait'] = $portrait;
}
requireImSuccess(
imAdminPost('/admin/user/update', [
'flag' => $flag,
'userInfo' => $userInfo,
]),
'用户资料保存失败'
);
persistProfileUserToDb($currentProfile['userId'], $displayName, $portrait);
$updatedProfile = loadProfileUser($currentProfile['userId'], false);
if ($portrait === '' && (string)$updatedProfile['portrait'] === '' && (string)($currentProfile['portrait'] ?? '') !== '') {
$updatedProfile['portrait'] = (string)$currentProfile['portrait'];
}
if ($portrait !== '' && (string)$updatedProfile['portrait'] === '') {
$updatedProfile['portrait'] = $portrait;
}
if ((string)$updatedProfile['displayName'] === '') {
$updatedProfile['displayName'] = $displayName;
}
respond(0, 'success', ['profile' => $updatedProfile]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '用户资料服务暂不可用');
}
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
echo json_encode([
'code' => 0,
'message' => 'success',
'data' => [
'methods' => [
'phone' => true,
'email' => false,
'username' => false,
],
'mobile_real_verify' => false,
],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+411
View File
@@ -0,0 +1,411 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
const WEB_MEDIA_ROOT = '/opt/im-server/media';
const WEB_MEDIA_PUBLIC_PORT = 18000;
const WEB_MEDIA_TYPE_IMAGE = 1;
const WEB_MEDIA_TYPE_FILE = 4;
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
function isDailyMuteActive(string $dailyStart, string $dailyEnd, int $nowMs): bool
{
if (!preg_match('/^\d{2}:\d{2}$/', $dailyStart) || !preg_match('/^\d{2}:\d{2}$/', $dailyEnd)) {
return false;
}
$now = (new DateTimeImmutable('@' . (int)floor($nowMs / 1000)))
->setTimezone(new DateTimeZone(IM_DB_TIMEZONE));
$currentMinutes = ((int)$now->format('H')) * 60 + (int)$now->format('i');
[$startHour, $startMinute] = array_map('intval', explode(':', $dailyStart));
[$endHour, $endMinute] = array_map('intval', explode(':', $dailyEnd));
$startMinutes = $startHour * 60 + $startMinute;
$endMinutes = $endHour * 60 + $endMinute;
if ($startMinutes === $endMinutes) {
return true;
}
if ($startMinutes < $endMinutes) {
return $currentMinutes >= $startMinutes && $currentMinutes < $endMinutes;
}
return $currentMinutes >= $startMinutes || $currentMinutes < $endMinutes;
}
function isGroupMutedForSender(PDO $appDb, string $groupId, string $role, int $nowMs): bool
{
if ($role === 'owner') {
return false;
}
$statement = $appDb->prepare('select * from `t_web_group_mute_rules` where `group_id` = ? and `enabled` = 1 limit 1');
$statement->execute([$groupId]);
$rule = $statement->fetch();
if (!is_array($rule)) {
return false;
}
$mode = (string)$rule['mode'];
if ($mode === 'off') {
return false;
}
if ($mode === 'all_except_admins' && $role === 'admin') {
return false;
}
if (!in_array($mode, ['all_include_admins', 'all_except_admins'], true)) {
return false;
}
$scheduleType = (string)$rule['schedule_type'];
if ($scheduleType === 'manual') {
return true;
}
if ($scheduleType === 'daily') {
return isDailyMuteActive((string)$rule['daily_start'], (string)$rule['daily_end'], $nowMs);
}
$startsAtMs = (int)$rule['starts_at_ms'];
$endsAtMs = (int)$rule['ends_at_ms'];
return $startsAtMs > 0 && $endsAtMs > $startsAtMs && $nowMs >= $startsAtMs && $nowMs < $endsAtMs;
}
function messageRequestValue(array $payload, string $key, string $default = ''): string
{
return trim((string)($_POST[$key] ?? $payload[$key] ?? $default));
}
function safeUploadName(string $fileName): string
{
$name = basename(str_replace('\\', '/', $fileName));
$name = preg_replace('/[^\p{L}\p{N}._ -]+/u', '_', $name) ?: 'file';
$name = trim($name, " .\t\n\r\0\x0B");
return $name !== '' ? mb_substr($name, 0, 180, 'UTF-8') : 'file';
}
function uploadExtension(string $fileName, string $mime, string $attachmentType): string
{
$extension = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
$extension = preg_replace('/[^a-z0-9]/', '', $extension) ?: '';
if ($extension === 'jpeg') {
$extension = 'jpg';
}
if ($attachmentType === 'image') {
if (in_array($extension, ['jpg', 'png', 'gif', 'webp', 'bmp', 'svg', 'avif', 'heic', 'heif'], true)) {
return $extension;
}
return match ($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
'image/bmp', 'image/x-ms-bmp' => 'bmp',
'image/svg+xml' => 'svg',
'image/avif' => 'avif',
'image/heic' => 'heic',
'image/heif' => 'heif',
default => 'jpg',
};
}
if ($extension !== '') {
return $extension;
}
return 'bin';
}
function detectedUploadMime(string $tmpName): string
{
$mime = mime_content_type($tmpName) ?: '';
if ($mime !== '' && $mime !== 'application/octet-stream') {
return $mime;
}
$imageInfo = @getimagesize($tmpName);
if (is_array($imageInfo) && !empty($imageInfo['mime'])) {
return (string)$imageInfo['mime'];
}
return $mime !== '' ? $mime : 'application/octet-stream';
}
function normalizedImageUploadMime(string $tmpName, string $mime): string
{
if (str_starts_with($mime, 'image/')) {
return $mime;
}
$imageInfo = @getimagesize($tmpName);
$imageMime = is_array($imageInfo) ? (string)($imageInfo['mime'] ?? '') : '';
if ($imageMime !== '' && str_starts_with($imageMime, 'image/')) {
return $imageMime;
}
throw new RuntimeException('请选择图片文件');
}
function mediaPublicBaseUrl(): string
{
$isHttps = (string)($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string)$_SERVER['HTTPS']) !== 'off';
$scheme = $isHttps ? 'https' : 'http';
$host = trim(preg_replace('/:\d+$/', '', (string)($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '127.0.0.1')) ?: '127.0.0.1');
return $scheme . '://' . $host . ':' . WEB_MEDIA_PUBLIC_PORT;
}
function saveMessageAttachmentFile(array $file, string $attachmentType): array
{
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new RuntimeException('文件上传失败');
}
$size = (int)($file['size'] ?? 0);
$maxSize = $attachmentType === 'image' ? WEB_IMAGE_MAX_BYTES : WEB_FILE_MAX_BYTES;
if ($size <= 0 || $size > $maxSize) {
throw new RuntimeException($attachmentType === 'image' ? '图片不能超过10MB' : '文件不能超过50MB');
}
if (!is_dir(WEB_MEDIA_ROOT) || !is_writable(WEB_MEDIA_ROOT)) {
throw new RuntimeException('媒体存储目录不可写');
}
$tmpName = (string)($file['tmp_name'] ?? '');
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
throw new RuntimeException('文件上传失败');
}
$mime = detectedUploadMime($tmpName);
if ($attachmentType === 'image') {
$mime = normalizedImageUploadMime($tmpName, $mime);
}
$mediaType = $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE;
$datePath = 'fs/' . $mediaType . '/' . date('Y/m/d/H/i/s');
$targetDir = rtrim(WEB_MEDIA_ROOT, '/') . '/' . $datePath;
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) {
throw new RuntimeException('媒体存储目录创建失败');
}
$originalName = safeUploadName((string)($file['name'] ?? ($attachmentType === 'image' ? 'image.jpg' : 'file')));
$extension = uploadExtension($originalName, $mime, $attachmentType);
$targetName = bin2hex(random_bytes(12)) . '.' . $extension;
$targetPath = $targetDir . '/' . $targetName;
if (!move_uploaded_file($tmpName, $targetPath)) {
throw new RuntimeException('文件保存失败');
}
@chmod($targetPath, 0644);
return [
'type' => $attachmentType,
'url' => mediaPublicBaseUrl() . '/' . $datePath . '/' . $targetName,
'name' => $originalName,
'mime' => $mime,
'size' => $size,
'mediaType' => $mediaType,
];
}
function storeMessageAttachment(
PDO $appDb,
string $messageUid,
string $conversationType,
string $target,
string $sender,
array $attachment,
int $timestamp
): void {
if ($messageUid === '' || empty($attachment['url'])) {
return;
}
$statement = $appDb->prepare(
'insert into `t_web_message_attachments`'
. ' (`message_uid`, `conversation_type`, `target_id`, `sender_uid`, `attachment_type`, `url`, `name`, `mime`, `size_bytes`, `created_at_ms`)'
. ' values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
. ' on duplicate key update'
. ' `attachment_type` = values(`attachment_type`),'
. ' `url` = values(`url`),'
. ' `name` = values(`name`),'
. ' `mime` = values(`mime`),'
. ' `size_bytes` = values(`size_bytes`)'
);
$statement->execute([
$messageUid,
$conversationType,
$target,
$sender,
(string)$attachment['type'],
(string)$attachment['url'],
(string)$attachment['name'],
(string)$attachment['mime'],
(int)$attachment['size'],
$timestamp,
]);
}
function attachmentFromRequest(string $messageType): ?array
{
if (!in_array($messageType, ['image', 'file'], true)) {
return null;
}
$attachmentFile = $_FILES['attachment'] ?? $_FILES['file'] ?? null;
if (!is_array($attachmentFile)) {
respond(400, '缺少上传文件');
}
return saveMessageAttachmentFile($attachmentFile, $messageType);
}
$contentTypeHeader = (string)($_SERVER['CONTENT_TYPE'] ?? '');
$isMultipart = stripos($contentTypeHeader, 'multipart/form-data') !== false;
$payload = $isMultipart ? [] : readJsonPayload();
$sender = messageRequestValue($payload, 'sender');
$target = messageRequestValue($payload, 'target');
$text = messageRequestValue($payload, 'text');
$messageType = messageRequestValue($payload, 'messageType', 'text');
$conversationType = $_POST['conversationType'] ?? $_POST['type'] ?? $payload['conversationType'] ?? $payload['type'] ?? 0;
$isGroupConversation = in_array((string)$conversationType, ['1', 'group', 'channel'], true);
$attachment = null;
if ($sender === '' || $target === '' || ($text === '' && !in_array($messageType, ['image', 'file'], true))) {
respond(400, '缺少发送者、接收者或消息内容');
}
try {
if ($isGroupConversation) {
ensureWebImAppTables();
$appDb = imAppDb();
$memberStatement = $appDb->prepare(
'select g.`group_id`, g.`category`, g.`owner_uid`, m.`role`'
. ' from `t_web_groups` g'
. ' join `t_web_group_members` m on m.`group_id` = g.`group_id` and m.`status` = 0'
. ' where g.`deleted` = 0 and g.`group_id` = ? and m.`user_id` = ?'
. ' limit 1'
);
$memberStatement->execute([$target, $sender]);
$group = $memberStatement->fetch();
if (!$group) {
respond(403, '你不在该群组中');
}
$category = (string)($group['category'] ?? 'group');
$role = (string)($group['role'] ?? 'member');
if ($category === 'channel' && !in_array($role, ['owner', 'admin'], true)) {
respond(403, '公告群仅管理员可发言');
}
$now = webImNowMs();
if ($category !== 'channel' && isGroupMutedForSender($appDb, $target, $role, $now)) {
respond(403, '当前群组处于禁言时段');
}
$attachment = attachmentFromRequest($messageType);
$messageUid = webImMessageUid();
$insert = $appDb->prepare(
'insert into `t_web_group_messages`'
. ' (`message_uid`, `group_id`, `sender_uid`, `text`, `created_at_ms`, `edited_at_ms`, `recalled`)'
. ' values (?, ?, ?, ?, ?, 0, 0)'
);
$insert->execute([$messageUid, $target, $sender, $text, $now]);
if ($attachment) {
storeMessageAttachment($appDb, $messageUid, 'group', $target, $sender, $attachment, $now);
}
$updateGroup = $appDb->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
$updateGroup->execute([$now, $target]);
$nativeError = '';
try {
$pushContent = $text !== ''
? $text
: ($attachment
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
: $text);
$payloadBody = [
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
'searchableContent' => $pushContent,
'pushContent' => $pushContent,
'content' => $text,
'persistFlag' => 3,
];
if ($attachment) {
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
$payloadBody['extra'] = json_encode([
'name' => (string)$attachment['name'],
'mime' => (string)$attachment['mime'],
'size' => (int)$attachment['size'],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
requireImSuccess(imAdminPost('/admin/message/send', [
'sender' => $sender,
'conv' => [
'type' => 1,
'target' => $target,
'line' => 0,
],
'payload' => $payloadBody,
'userMessage' => true,
]), '原生群消息发送失败');
} catch (Throwable $error) {
$nativeError = $error->getMessage() ?: '原生群消息发送失败';
}
respond(0, 'success', [
'messageUid' => $messageUid,
'timestamp' => $now,
'senderRole' => $role,
'attachment' => $attachment,
'nativeError' => $nativeError,
]);
}
$attachment = attachmentFromRequest($messageType);
$pushContent = $text !== ''
? $text
: ($attachment
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
: $text);
$payloadBody = [
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
'searchableContent' => $pushContent,
'pushContent' => $pushContent,
'content' => $text,
'persistFlag' => 3,
];
if ($attachment) {
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
$payloadBody['extra'] = json_encode([
'name' => (string)$attachment['name'],
'mime' => (string)$attachment['mime'],
'size' => (int)$attachment['size'],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$result = requireImSuccess(imAdminPost('/admin/message/send', [
'sender' => $sender,
'conv' => [
'type' => 0,
'target' => $target,
'line' => 0,
],
'payload' => $payloadBody,
'userMessage' => true,
]), '发送失败');
$messageUid = isset($result['messageUid']) ? (string)$result['messageUid'] : '';
$timestamp = (int)($result['timestamp'] ?? webImNowMs());
if ($attachment && $messageUid !== '') {
ensureWebImAppTables();
storeMessageAttachment(imAppDb(), $messageUid, 'single', $target, $sender, $attachment, $timestamp);
}
respond(0, 'success', [
'messageUid' => $messageUid !== '' ? $messageUid : null,
'timestamp' => $timestamp,
'attachment' => $attachment,
]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '发送失败');
}
+61
View File
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? ''));
$targetId = trim((string)($payload['targetId'] ?? $payload['friendUid'] ?? ''));
$alias = trim((string)($payload['alias'] ?? ''));
if ($userId === '') {
respond(400, '缺少用户ID');
}
if ($targetId === '') {
respond(400, '缺少联系人ID');
}
if ($targetId === $userId) {
respond(400, '不能给自己设置备注名');
}
if (mb_strlen($alias, 'UTF-8') > 32) {
respond(400, '备注名不能超过32个字');
}
try {
ensureWebImAppTables();
$imDb = imDb();
$exists = $imDb->prepare('select 1 from `t_user` where `_uid` = ? limit 1');
$exists->execute([$targetId]);
if (!$exists->fetchColumn()) {
respond(404, '没有找到该用户');
}
$friend = $imDb->prepare('select 1 from `t_friend` where `_uid` = ? and `_friend_uid` = ? limit 1');
$friend->execute([$userId, $targetId]);
$isContact = (bool)$friend->fetchColumn();
if ($isContact) {
requireImSuccess(
imAdminPost('/admin/friend/set_alias', [
'operator' => $userId,
'targetId' => $targetId,
'alias' => $alias,
]),
'备注名修改失败'
);
}
saveWebUserRemark(imAppDb(), $userId, $targetId, $alias);
respond(0, 'success', [
'contact' => [
'uid' => $targetId,
'alias' => $alias,
'isContact' => $isContact,
],
]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '备注名服务暂不可用');
}
+12
View File
@@ -0,0 +1,12 @@
window.JC_WEB_IM_CONFIG = {
adminServer: 'http://winchen.ezpay.asia:8024',
appServer: 'http://winchen.ezpay.asia:8888',
qrAppServer: 'http://winchen.ezpay.asia:18888',
platformConfigApi: '/api/platform_config.php',
registerConfigApi: 'http://winchen.ezpay.asia:8024/api/register-config',
imServerHost: 'winchen.ezpay.asia',
imRoutePort: 18000,
useWss: false,
wsPath: '/ws',
sdkMode: 'http'
};
+3048
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 958 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Web IM</title><link rel="icon" href="favicon.ico"><script src="config/runtime-config.js"></script><script defer="defer" src="js/chunk-vendors.cd0faf68.js"></script><script defer="defer" src="js/app.26bc1008.js"></script><link href="css/app.5af3ef7e.css" rel="stylesheet"></head><body><noscript>请启用 JavaScript 后使用 Web IM。</noscript><div id="app"></div></body></html>
+3213
View File
File diff suppressed because it is too large Load Diff
+1
View File
File diff suppressed because one or more lines are too long
+24967
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+651
View File
@@ -0,0 +1,651 @@
import axios from 'axios';
import { appConfig } from '@/config';
function randomId() {
if (crypto && crypto.randomUUID) {
return crypto.randomUUID().replaceAll('-', '');
}
return `${Date.now()}${Math.random().toString(16).slice(2)}`;
}
function getAllowedDevice(mobile) {
const allowedDevices = {
'18301902119': '67072d37062c4f31b1165073552933ba',
'18301902118': 'de89fb2f2a61480a8c381f6c16e28bd8',
'18301902117': 'd117f480faa64145b6162107b1b49617'
};
return allowedDevices[mobile] || '';
}
function getUniqueEquipment(mobile, fallback) {
return getAllowedDevice(mobile) || localStorage.getItem('uniqueEquipment') || fallback;
}
function normalizeStatus(message) {
if (message.direction !== 0) {
return 'sent';
}
if (message.status === 0 || message.status === 'sending') {
return 'sending';
}
if (message.status === 2 || message.status === 'failed' || message.status === -1) {
return 'failed';
}
return 'sent';
}
function sendErrorText(errorCode) {
const messages = {
'-1': '连接不可用或发送超时',
1: '密钥不匹配',
2: '无效数据',
4: '服务端错误',
6: 'token错误',
7: '账号被踢下线',
8: '用户被禁用',
9: '不在群组中',
10: '无效消息',
22: '服务未授权',
28: 'WebSocket未配置',
30: '服务器时间不同步',
245: '用户被拉黑',
246: '在黑名单中',
247: '禁止发送消息',
248: '无权限',
249: '服务端超时',
250: '发送过于频繁',
251: '参数无效',
253: '目标不存在',
254: '服务未实现'
};
return messages[errorCode] || `错误码 ${errorCode}`;
}
function resolveConversationTarget(conversation) {
if (!conversation) {
return '';
}
return conversation.target
|| conversation.raw?.target
|| String(conversation.id || '').replace(/^single-/, '').replace(/^0-/, '').replace(/-0$/, '');
}
export class ImClient {
constructor() {
this.listeners = new Map();
this.connected = false;
this.currentUser = null;
this.sdkReady = false;
this.sdk = null;
this.sdkModules = null;
this.sdkEventsBound = false;
this.pendingOutgoingMessages = new Map();
this.loginClientId = '';
}
getClientId() {
if (this.sdkReady && this.sdk) {
return this.sdk.getClientId();
}
let clientId = localStorage.getItem(appConfig.clientIdStorageKey);
if (!clientId) {
clientId = randomId();
localStorage.setItem(appConfig.clientIdStorageKey, clientId);
}
return clientId;
}
getSdkVersion() {
if (!this.sdkReady || !this.sdk || typeof this.sdk.getVersion !== 'function') {
return '';
}
return this.sdk.getVersion() || '';
}
prepareClientIdForLogin(mobile) {
const pinnedClientId = getAllowedDevice(mobile) || localStorage.getItem('uniqueEquipment') || '';
if (pinnedClientId) {
sessionStorage.setItem('clientId', pinnedClientId);
localStorage.setItem('clientId', pinnedClientId);
localStorage.setItem(appConfig.clientIdStorageKey, pinnedClientId);
this.loginClientId = pinnedClientId;
return pinnedClientId;
}
this.loginClientId = this.getClientId();
return this.loginClientId;
}
restoreClientId(clientId) {
if (!clientId) {
return;
}
sessionStorage.setItem('clientId', clientId);
localStorage.setItem('clientId', clientId);
localStorage.setItem(appConfig.clientIdStorageKey, clientId);
this.loginClientId = clientId;
}
async loginWithPassword(mobile, password) {
const deviceId = this.prepareClientIdForLogin(mobile);
await this.ensureSdk();
const url = `${appConfig.appServer}/login_pwd`;
const sdkClientId = this.getClientId();
if (sdkClientId !== deviceId) {
console.warn('[im] clientId mismatch before login', { deviceId, sdkClientId });
}
const response = await axios.post(url, {
mobile,
password,
platform: appConfig.platform,
clientId: deviceId,
uniqueEquipment: deviceId
}, { withCredentials: false });
this.persistAppToken(url, response);
if (response.data?.code !== 0) {
throw new Error(response.data?.message || '登录失败');
}
return response.data.result;
}
async requestAuthCode(mobile) {
const response = await axios.post(`${appConfig.appServer}/send_code`, { mobile }, { withCredentials: false });
if (response.data?.code !== 0) {
throw new Error(response.data?.message || '验证码发送失败');
}
return response.data.result;
}
async loginWithCode(mobile, code) {
const deviceId = this.prepareClientIdForLogin(mobile);
await this.ensureSdk();
const url = `${appConfig.appServer}/login`;
const sdkClientId = this.getClientId();
if (sdkClientId !== deviceId) {
console.warn('[im] clientId mismatch before login', { deviceId, sdkClientId });
}
const response = await axios.post(url, {
mobile,
code,
platform: appConfig.platform,
clientId: deviceId,
uniqueEquipment: deviceId
}, { withCredentials: false });
this.persistAppToken(url, response);
if (response.data?.code !== 0) {
throw new Error(response.data?.message || '登录失败');
}
return response.data.result;
}
async connect(userId, token) {
this.currentUser = { userId, token };
await this.ensureSdk();
console.info('[im] connect', {
userId,
clientId: this.getClientId(),
configuredClientId: this.loginClientId || localStorage.getItem(appConfig.clientIdStorageKey) || ''
});
this.sdk.connect(userId, token);
this.emit('connectionStatusChanged', { connected: false, statusText: '连接中' });
}
disconnect() {
if (this.sdkReady && this.sdk) {
this.sdk.disconnect();
}
this.connected = false;
this.currentUser = null;
this.emit('connectionStatusChanged', { connected: false });
}
sendTextMessage(conversation, text, initialMessage = null) {
const target = resolveConversationTarget(conversation);
const rawConversation = conversation.raw || this.createRawSingleConversation(target);
if (this.sdkReady && this.sdk && rawConversation) {
const { TextMessageContent } = this.sdkModules;
const content = new TextMessageContent(text);
console.info('[im] send text', {
conversationId: conversation.id,
target: rawConversation.target,
type: rawConversation.type,
line: rawConversation.line,
connected: this.connected
});
this.sdk.sendConversationMessage(
rawConversation,
content,
[],
(messageId, timestamp) => {
if (initialMessage) {
this.pendingOutgoingMessages.set(String(messageId), {
...initialMessage,
sdkMessageId: String(messageId),
sdkTimestamp: Number(timestamp || Date.now())
});
}
console.info('[im] send prepared', {
conversationId: conversation.id,
target: rawConversation.target,
messageId,
timestamp
});
if (!initialMessage) {
this.emit('messageSent', this.mapOutgoingMessage(conversation.id, messageId, timestamp, text, 'sending'));
}
},
null,
(messageUid, timestamp) => {
console.info('[im] send success', {
conversationId: conversation.id,
target: rawConversation.target,
messageUid,
timestamp
});
this.emit('messageStatusChanged', {
...(initialMessage || {}),
conversationId: conversation.id,
id: initialMessage?.id || String(messageUid || randomId()),
messageUid,
timestamp: initialMessage?.timestamp || Number(timestamp || Date.now()),
displayTimestamp: initialMessage?.displayTimestamp || initialMessage?.timestamp || Number(timestamp || Date.now()),
status: 'sent'
});
},
errorCode => {
const failedMessage = {
...(initialMessage || {}),
conversationId: conversation.id,
id: initialMessage?.id || randomId(),
text,
direction: 'out',
type: 'text',
status: 'failed',
errorCode,
error: sendErrorText(errorCode)
};
this.updatePendingOutgoingMessage(failedMessage);
console.error('[im] send failed', {
conversationId: conversation.id,
target: rawConversation.target,
connected: this.connected,
errorCode,
error: sendErrorText(errorCode)
});
this.emit('messageStatusChanged', failedMessage);
}
);
return null;
}
const message = {
id: randomId(),
conversationId: conversation.id,
from: this.currentUser?.userId || 'me',
direction: 'out',
type: 'text',
text,
status: 'failed',
timestamp: Date.now(),
error: rawConversation ? 'SDK未就绪' : `会话目标无效:${target || '空'}`
};
this.emit('messageSent', message);
return message;
}
getConversationList() {
if (!this.sdkReady || !this.sdk) {
return [];
}
const { ConversationType } = this.sdkModules;
return this.sdk.getConversationList(
[ConversationType.Single, ConversationType.Group, ConversationType.Channel],
[0]
).map(item => this.mapConversationInfo(item));
}
refreshConversationList() {
const conversations = this.getConversationList();
this.emit('conversationsChanged', conversations);
return conversations;
}
getMessages(conversation, count = 30) {
const rawConversation = conversation.raw || this.createRawSingleConversation(resolveConversationTarget(conversation));
if (!this.sdkReady || !this.sdk || !rawConversation) {
return [];
}
return this.sdk.getMessages(rawConversation, 0, true, count).map(message => this.mapMessage(message));
}
async fetchRemoteMessages(conversation, limit = 50) {
if (!conversation?.id || !this.currentUser?.userId) {
return this.getMessages(conversation, limit);
}
const response = await axios.post(appConfig.messagesApi, {
userId: this.currentUser.userId,
target: resolveConversationTarget(conversation),
limit
}, { withCredentials: false });
if (response.data?.code !== 0) {
throw new Error(response.data?.message || '消息同步失败');
}
return (response.data?.result?.messages || []).map(message => ({
...message,
displayTimestamp: message.displayTimestamp || message.timestamp,
conversationId: conversation.id
}));
}
on(eventName, handler) {
if (!this.listeners.has(eventName)) {
this.listeners.set(eventName, new Set());
}
this.listeners.get(eventName).add(handler);
return () => this.off(eventName, handler);
}
off(eventName, handler) {
this.listeners.get(eventName)?.delete(handler);
}
emit(eventName, payload) {
this.listeners.get(eventName)?.forEach(handler => handler(payload));
}
updatePendingOutgoingMessage(message) {
if (!message?.id) {
return;
}
this.pendingOutgoingMessages.forEach((pendingMessage, sdkMessageId) => {
if (pendingMessage.id === message.id) {
this.pendingOutgoingMessages.set(sdkMessageId, {
...pendingMessage,
...message,
sdkMessageId
});
}
});
}
mapSdkStatusMessage(message) {
const mappedMessage = this.mapMessage(message);
if (message.direction !== 0 || !message.messageId) {
return mappedMessage;
}
const sdkMessageId = String(message.messageId);
const pendingMessage = this.pendingOutgoingMessages.get(sdkMessageId);
if (!pendingMessage) {
return mappedMessage;
}
const nextMessage = {
...pendingMessage,
...mappedMessage,
id: pendingMessage.id,
conversationId: pendingMessage.conversationId || mappedMessage.conversationId,
text: pendingMessage.text || mappedMessage.text,
timestamp: pendingMessage.timestamp || mappedMessage.timestamp,
displayTimestamp: pendingMessage.displayTimestamp || mappedMessage.displayTimestamp,
sdkMessageId,
errorCode: mappedMessage.errorCode || pendingMessage.errorCode,
error: mappedMessage.error || pendingMessage.error
};
this.pendingOutgoingMessages.set(sdkMessageId, nextMessage);
if (nextMessage.status === 'sent') {
window.setTimeout(() => this.pendingOutgoingMessages.delete(sdkMessageId), 60000);
}
return nextMessage;
}
async ensureSdk() {
if (this.sdkReady || appConfig.sdkMode !== 'wildfire') {
return;
}
const wfcModule = await import('legacy-wfc/client/wfc');
const configModule = await import('legacy-config');
const eventModule = await import('legacy-wfc/client/wfcEvent');
const connectionStatusModule = await import('legacy-wfc/client/connectionStatus');
const conversationTypeModule = await import('legacy-wfc/model/conversationType');
const conversationModule = await import('legacy-wfc/model/conversation');
const textMessageModule = await import('legacy-wfc/messages/textMessageContent');
const Config = configModule.default;
Config.APP_SERVER = appConfig.appServer;
Config.USE_WSS = appConfig.useWss;
Config.ROUTE_PORT = appConfig.imRoutePort;
Config.WS_PATH = appConfig.wsPath;
Config.HTTP_IM_PREFIX_PATH = '';
Config.HTTP_UPLOAD_PREFIX_PATH = '';
Config.CLIENT_ID_STRATEGY = 2;
this.sdk = wfcModule.default;
this.sdkModules = {
EventType: eventModule.default,
ConnectionStatus: connectionStatusModule.default,
ConversationType: conversationTypeModule.default,
Conversation: conversationModule.default,
TextMessageContent: textMessageModule.default
};
this.bindSdkEvents();
this.sdk.init();
this.sdkReady = true;
}
bindSdkEvents() {
if (this.sdkEventsBound) {
return;
}
this.sdkEventsBound = true;
const { EventType, ConnectionStatus } = this.sdkModules;
this.sdk.eventEmitter.on(EventType.ConnectionStatusChanged, status => {
const connected = status === ConnectionStatus.ConnectionStatusConnected;
this.connected = connected;
this.emit('connectionStatusChanged', {
connected,
status,
statusText: ConnectionStatus.desc(status) || '连接状态变化'
});
if (connected || status === ConnectionStatus.ConnectionStatusReceiveing) {
window.setTimeout(() => this.refreshConversationList(), 0);
}
});
this.sdk.eventEmitter.on(EventType.ConversationInfoUpdate, () => {
this.refreshConversationList();
});
this.sdk.eventEmitter.on(EventType.FriendListUpdate, () => {
this.emit('contactsChanged', this.getContactList(true));
});
this.sdk.eventEmitter.on(EventType.UserInfosUpdate, () => {
this.emit('contactsChanged', this.getContactList(false));
});
this.sdk.eventEmitter.on(EventType.MessageStatusUpdate, message => {
this.emit('messageStatusChanged', this.mapSdkStatusMessage(message));
});
this.sdk.eventEmitter.on(EventType.ReceiveMessage, message => {
this.emit('messageReceived', this.mapMessage(message));
this.refreshConversationList();
});
}
mapConversationInfo(info) {
const conversation = info.conversation;
const id = `${conversation.type}-${conversation.target}-${conversation.line}`;
const title = this.getConversationTitle(info);
return {
id,
type: conversation.type,
title,
subtitle: info.lastMessage ? this.messageDigest(info.lastMessage) : '',
unread: info.unreadCount?.unread || info.unreadCount || 0,
avatarText: title.slice(0, 1).toUpperCase(),
updatedAt: Number(info.timestamp || info.lastMessage?.timestamp || Date.now()),
raw: conversation,
rawInfo: info
};
}
getContactList(refresh = false) {
if (!this.sdkReady || !this.sdk) {
return [];
}
const friendIds = this.sdk.getMyFriendList(refresh) || [];
if (!friendIds.length) {
return [];
}
const userInfos = this.sdk.getUserInfos(friendIds, '') || [];
return userInfos.map(userInfo => {
return this.mapContact(userInfo.uid, null, userInfo);
}).sort((a, b) => a.title.localeCompare(b.title, 'zh-CN'));
}
async getServerContactList(userId) {
if (!userId) {
return [];
}
const response = await axios.post(appConfig.contactsApi, { userId }, { withCredentials: false });
if (response.data?.code !== 0) {
throw new Error(response.data?.message || '联系人加载失败');
}
return (response.data?.result?.contacts || []).map(item => {
return this.mapContact(item.uid, { alias: item.alias }, {
uid: item.uid,
displayName: item.displayName,
name: item.name,
mobile: item.mobile,
portrait: item.portrait,
friendAlias: item.alias
});
}).sort((a, b) => a.title.localeCompare(b.title, 'zh-CN'));
}
refreshContactList() {
const contacts = this.getContactList(true);
this.emit('contactsChanged', contacts);
return contacts;
}
createRawSingleConversation(userId) {
if (!userId || !this.sdkModules?.Conversation || !this.sdkModules?.ConversationType) {
return null;
}
const { Conversation, ConversationType } = this.sdkModules;
return new Conversation(ConversationType.Single, userId, 0);
}
createSingleConversation(userId) {
if (!this.sdkReady || !this.sdk) {
const title = userId;
return {
id: `single-${userId}`,
type: 'single',
target: userId,
title,
subtitle: '',
unread: 0,
avatarText: title.slice(0, 1).toUpperCase(),
updatedAt: Date.now(),
raw: null
};
}
const { ConversationType } = this.sdkModules;
const raw = this.createRawSingleConversation(userId);
const info = this.sdk.getConversationInfo(raw);
return info ? this.mapConversationInfo(info) : {
id: `${ConversationType.Single}-${userId}-0`,
type: ConversationType.Single,
target: userId,
title: this.sdk.getUserDisplayName(userId),
subtitle: '',
unread: 0,
avatarText: this.sdk.getUserDisplayName(userId).slice(0, 1).toUpperCase(),
updatedAt: Date.now(),
raw
};
}
mapContact(userId, friend, userInfo) {
const title = userInfo?.friendAlias || friend?.alias || userInfo?.displayName || userInfo?.name || userId;
return {
id: userId,
uid: userId,
title,
subtitle: userInfo?.mobile || userInfo?.name || userId,
portrait: userInfo?.portrait || '',
avatarText: title.slice(0, 1).toUpperCase(),
alias: friend?.alias || userInfo?.friendAlias || '',
rawFriend: friend,
rawUser: userInfo
};
}
getConversationTitle(info) {
const conversation = info.conversation;
if (conversation._target?._displayName) {
return conversation._target._displayName;
}
if (conversation.type === this.sdkModules.ConversationType.Single) {
return this.sdk.getUserDisplayName(conversation.target);
}
if (conversation.type === this.sdkModules.ConversationType.Group) {
return this.sdk.getGroupInfo(conversation.target)?.name || conversation.target;
}
return conversation.target;
}
mapMessage(message) {
return {
id: String(message.messageId || message.messageUid || randomId()),
messageUid: message.messageUid,
conversationId: message.conversation ? `${message.conversation.type}-${message.conversation.target}-${message.conversation.line}` : '',
from: message.from,
direction: message.direction === 0 ? 'out' : 'in',
type: 'text',
text: this.messageDigest(message),
status: normalizeStatus(message),
timestamp: Number(message.timestamp || Date.now()),
displayTimestamp: Number(message.timestamp || Date.now()),
raw: message
};
}
mapOutgoingMessage(conversationId, messageId, timestamp, text, status) {
return {
id: String(messageId || randomId()),
conversationId,
from: this.currentUser?.userId || 'me',
direction: 'out',
type: 'text',
text,
status,
timestamp: Number(timestamp || Date.now())
};
}
messageDigest(message) {
if (!message?.messageContent) {
return '';
}
if (message.messageContent.content) {
return message.messageContent.content;
}
if (typeof message.messageContent.digest === 'function') {
return message.messageContent.digest(message);
}
return '[暂不支持的消息]';
}
persistAppToken(url, response) {
const appToken = response.headers?.authtoken || response.headers?.authToken;
if (appToken) {
localStorage.setItem(appConfig.authTokenStoragePrefix + new URL(url).host, appToken);
}
}
}
export const imClient = new ImClient();
+11384
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "jc-web-im",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve --host 0.0.0.0 --port 8023",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"@vueuse/core": "^10.8.0",
"axios": "1.6.7",
"jr-qrcode": "^1.1.4",
"pinia": "^2.1.7",
"process": "^0.11.10",
"vue": "3.5.13",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~5.0.8",
"@vue/cli-service": "~5.0.8",
"@vue/compiler-sfc": "^3.5.13"
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}
+95
View File
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? ''));
$keyword = trim((string)($payload['keyword'] ?? ''));
$reason = trim((string)($payload['reason'] ?? '添加联系人'));
if ($userId === '') {
respond(400, '缺少当前用户ID');
}
if ($keyword === '') {
respond(400, '请输入要添加的联系人');
}
function tryImUserInfo(array $payload): ?array
{
try {
$result = requireImSuccess(imAdminPost('/admin/user/get_info', $payload), '用户查询失败');
return $result ?: null;
} catch (Throwable $error) {
return null;
}
}
function normalizeUserInfo(array $user): array
{
$uid = (string)($user['userId'] ?? $user['uid'] ?? '');
return [
'uid' => $uid,
'name' => (string)($user['name'] ?? $uid),
'displayName' => (string)($user['displayName'] ?? $user['name'] ?? $uid),
'mobile' => (string)($user['mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? ''),
'alias' => '',
];
}
try {
$target = tryImUserInfo(['userId' => $keyword]);
if (!$target && preg_match('/^\+?\d{5,32}$/', $keyword)) {
$target = tryImUserInfo(['mobile' => $keyword]);
}
if (!$target) {
$target = tryImUserInfo(['name' => $keyword]);
}
if (!$target) {
respond(404, '没有找到该用户');
}
$contact = normalizeUserInfo($target);
if ($contact['uid'] === '') {
respond(404, '没有找到该用户');
}
if ($contact['uid'] === $userId) {
respond(400, '不能添加自己为联系人');
}
$friendResult = requireImSuccess(
imAdminPost('/admin/friend/list', ['userId' => $userId]),
'好友列表检查失败'
);
$friendIds = array_values(array_filter($friendResult['list'] ?? [], 'is_string'));
if (in_array($contact['uid'], $friendIds, true)) {
respond(409, '该用户已经是联系人', ['contact' => $contact]);
}
requireImSuccess(
imAdminPost('/admin/friend/status', [
'userId' => $userId,
'friendUid' => $contact['uid'],
'status' => 0,
]),
'添加联系人失败'
);
requireImSuccess(
imAdminPost('/admin/friend/status', [
'userId' => $contact['uid'],
'friendUid' => $userId,
'status' => 0,
]),
'添加联系人失败'
);
respond(0, 'success', ['contact' => $contact, 'reason' => $reason]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '添加联系人服务暂不可用');
}
+71
View File
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? $_GET['userId'] ?? ''));
if ($userId === '') {
respond(400, '缺少用户ID');
}
try {
$friendResult = requireImSuccess(
imAdminPost('/admin/friend/list', ['userId' => $userId]),
'联系人加载失败'
);
$friendIds = array_values(array_unique(array_filter(array_map(static function ($uid): string {
return trim((string)$uid);
}, $friendResult['list'] ?? []))));
if (!$friendIds) {
respond(0, 'success', ['contacts' => []]);
}
$usersResult = requireImSuccess(
imAdminPost('/admin/user/batch_get_infos', ['list' => $friendIds]),
'联系人资料加载失败'
);
try {
ensureWebImAppTables();
$remarkMap = fetchWebUserRemarkMap(imAppDb(), $userId, $friendIds);
} catch (Throwable) {
$remarkMap = [];
}
$contactsByUid = [];
foreach (array_values(array_filter($usersResult['userInfos'] ?? [], 'is_array')) as $user) {
$uid = (string)($user['userId'] ?? '');
if ($uid === '' || isset($contactsByUid[$uid])) {
continue;
}
$alias = '';
try {
$aliasResult = requireImSuccess(
imAdminPost('/admin/friend/get_alias', ['operator' => $userId, 'targetId' => $uid]),
'联系人备注加载失败'
);
$alias = trim((string)($aliasResult['alias'] ?? ''));
} catch (Throwable $error) {
$alias = '';
}
if ($alias === '') {
$alias = $remarkMap[$uid] ?? '';
}
$contactsByUid[$uid] = [
'uid' => $uid,
'name' => (string)($user['name'] ?? $uid),
'displayName' => (string)($user['displayName'] ?? $user['name'] ?? $uid),
'mobile' => (string)($user['mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? ''),
'address' => (string)($user['address'] ?? ''),
'alias' => $alias,
];
}
respond(0, 'success', ['contacts' => array_values($contactsByUid)]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '联系人服务暂不可用');
}
+990
View File
@@ -0,0 +1,990 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
function webActionList(mixed $value): array
{
if (is_array($value)) {
$items = $value;
} else {
$items = preg_split('/[\s,,;;]+/u', (string)$value) ?: [];
}
return array_values(array_unique(array_filter(array_map(static function ($item): string {
return trim((string)$item);
}, $items))));
}
function appPlaceholders(array $values): string
{
return implode(',', array_fill(0, count($values), '?'));
}
function resolveWebUserIds(PDO $db, array $values): array
{
$values = webActionList($values);
if (!$values) {
return [];
}
$resolved = [];
foreach ($values as $value) {
$statement = $db->prepare(
'select `_uid` from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ? limit 1'
);
$statement->execute([$value, $value, $value]);
$uid = $statement->fetchColumn();
if (is_string($uid) && $uid !== '') {
$resolved[] = $uid;
}
}
return array_values(array_unique($resolved));
}
function webGroupConversation(array $group, string $role = 'member'): array
{
$name = (string)($group['name'] ?? $group['_name'] ?? $group['group_id'] ?? $group['_gid'] ?? '');
$groupId = (string)($group['group_id'] ?? $group['_gid'] ?? '');
$category = (string)($group['category'] ?? 'group');
$title = $name !== '' ? $name : $groupId;
$updatedAt = (int)($group['updated_at_ms'] ?? $group['_dt'] ?? webImNowMs());
return [
'id' => 'group-' . $groupId,
'type' => $category === 'channel' ? 'channel' : 'group',
'target' => $groupId,
'title' => $title,
'subtitle' => $category === 'channel' ? '公告群' : '群组',
'unread' => 0,
'avatarText' => mb_strtoupper(mb_substr($title, 0, 1, 'UTF-8'), 'UTF-8'),
'updatedAt' => $updatedAt,
'role' => $role,
'raw' => [
'type' => 'group',
'target' => $groupId,
'line' => 0,
'category' => $category,
'role' => $role,
],
];
}
function fetchWebGroup(PDO $db, string $groupKey): ?array
{
$statement = $db->prepare(
'select * from `t_web_groups`'
. ' where `deleted` = 0 and (`group_id` = ? or `name` = ?)'
. ' order by (`group_id` = ?) desc limit 1'
);
$statement->execute([$groupKey, $groupKey, $groupKey]);
$group = $statement->fetch();
return is_array($group) ? $group : null;
}
function fetchWebGroupRole(PDO $db, string $groupId, string $userId): string
{
$statement = $db->prepare(
'select `role` from `t_web_group_members`'
. ' where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
);
$statement->execute([$groupId, $userId]);
$role = $statement->fetchColumn();
return is_string($role) && $role !== '' ? $role : 'member';
}
function fetchWebGroupMember(PDO $db, string $groupId, string $userId): array
{
$statement = $db->prepare(
'select * from `t_web_group_members`'
. ' where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
);
$statement->execute([$groupId, $userId]);
$member = $statement->fetch();
return is_array($member) ? $member : [];
}
function requireWebGroupMember(PDO $db, string $groupId, string $userId): array
{
$member = fetchWebGroupMember($db, $groupId, $userId);
if (!$member) {
respond(403, '你不在该群组中');
}
return $member;
}
function requireWebGroupRole(PDO $db, string $groupId, string $userId, array $roles): array
{
$member = requireWebGroupMember($db, $groupId, $userId);
if (!in_array((string)$member['role'], $roles, true)) {
respond(403, '没有群组管理权限');
}
return $member;
}
function upsertWebGroupMember(PDO $db, string $groupId, string $userId, string $role): void
{
$now = webImNowMs();
$statement = $db->prepare(
'insert into `t_web_group_members`'
. ' (`group_id`, `user_id`, `role`, `joined_at_ms`, `updated_at_ms`, `status`)'
. ' values (?, ?, ?, ?, ?, 0)'
. ' on duplicate key update'
. ' `role` = if(`role` = "owner", `role`, values(`role`)),'
. ' `updated_at_ms` = values(`updated_at_ms`),'
. ' `status` = 0'
);
$statement->execute([$groupId, $userId, $role, $now, $now]);
}
function createNativeGroup(string $operator, string $groupId, string $name, string $category, array $members): array
{
$nativeMembers = [];
foreach ($members as $memberId) {
$nativeMembers[] = [
'member_id' => $memberId,
'type' => $memberId === $operator ? 2 : 0,
];
}
return requireImSuccess(imAdminPost('/admin/group/create', [
'operator' => $operator,
'group' => [
'group_info' => [
'target_id' => $groupId,
'name' => $name,
'owner' => $operator,
'type' => 0,
'mute' => $category === 'channel' ? 1 : 0,
'join_type' => 0,
'searchable' => 1,
'history_message' => 1,
'extra' => json_encode(['webCategory' => $category], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
],
'members' => $nativeMembers,
],
'to_lines' => [0],
]), '原生群组创建失败');
}
function addNativeGroupMember(string $operator, string $groupId, string $userId): void
{
requireImSuccess(imAdminPost('/admin/group/member/add', [
'operator' => $operator,
'group_id' => $groupId,
'members' => [
['member_id' => $userId, 'type' => 0],
],
'to_lines' => [0],
]), '加入原生群组失败');
}
function removeNativeGroupMember(string $operator, string $groupId, array $userIds): void
{
if (!$userIds) {
return;
}
requireImSuccess(imAdminPost('/admin/group/member/del', [
'operator' => $operator,
'group_id' => $groupId,
'members' => array_values($userIds),
'to_lines' => [0],
]), '移除原生成员失败');
}
function dismissNativeGroup(string $operator, string $groupId): void
{
requireImSuccess(imAdminPost('/admin/group/del', [
'operator' => $operator,
'group_id' => $groupId,
'to_lines' => [0],
]), '解散原生群组失败');
}
function transferNativeGroup(string $operator, string $groupId, string $newOwner): void
{
requireImSuccess(imAdminPost('/admin/group/transfer', [
'operator' => $operator,
'group_id' => $groupId,
'new_owner' => $newOwner,
'to_lines' => [0],
]), '转让原生群组失败');
}
function sendWebGroupWelcomeMessage(PDO $appDb, string $groupId, string $senderId, string $welcomeText): void
{
$text = trim($welcomeText);
if ($text === '') {
return;
}
$now = webImNowMs();
$messageUid = webImMessageUid();
$statement = $appDb->prepare(
'insert into `t_web_group_messages`'
. ' (`message_uid`, `group_id`, `sender_uid`, `text`, `created_at_ms`, `edited_at_ms`, `recalled`)'
. ' values (?, ?, ?, ?, ?, 0, 0)'
);
$statement->execute([$messageUid, $groupId, $senderId, $text, $now]);
$update = $appDb->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
$update->execute([$now, $groupId]);
try {
requireImSuccess(imAdminPost('/admin/message/send', [
'sender' => $senderId,
'conv' => [
'type' => 1,
'target' => $groupId,
'line' => 0,
],
'payload' => [
'type' => 1,
'searchableContent' => $text,
'pushContent' => $text,
'content' => $text,
'persistFlag' => 3,
],
'userMessage' => true,
]), '欢迎词发送失败');
} catch (Throwable) {
}
}
function createWebGroupAction(PDO $imDb, PDO $appDb, string $userId, array $payload, string $category): array
{
$name = trim((string)($payload['name'] ?? ''));
$announcement = trim((string)($payload['announcement'] ?? ''));
$welcomeText = trim((string)($payload['welcomeText'] ?? ''));
if ($name === '') {
respond(400, $category === 'channel' ? '请输入公告群名称' : '请输入群组名称');
}
$memberIds = resolveWebUserIds($imDb, webActionList($payload['memberIds'] ?? []));
$memberIds[] = $userId;
$memberIds = array_values(array_unique(array_filter($memberIds)));
if ($category === 'group' && count($memberIds) < 2) {
respond(400, '请选择至少一位联系人');
}
$groupId = 'webg_' . bin2hex(random_bytes(8));
$now = webImNowMs();
$nativeStatus = 'pending';
$nativeGroupId = '';
$nativeError = '';
try {
$nativeResult = createNativeGroup($userId, $groupId, $name, $category, $memberIds);
$nativeGroupId = (string)($nativeResult['group_id'] ?? $nativeResult['groupId'] ?? $groupId);
$nativeStatus = 'ok';
} catch (Throwable $error) {
$nativeStatus = 'fallback';
$nativeError = mb_substr($error->getMessage() ?: '原生群组创建失败', 0, 255, 'UTF-8');
}
$statement = $appDb->prepare(
'insert into `t_web_groups`'
. ' (`group_id`, `category`, `name`, `portrait`, `owner_uid`, `join_mode`, `announcement`,'
. ' `welcome_text`, `native_group_id`, `native_status`, `native_error`, `created_by`, `created_at_ms`, `updated_at_ms`, `deleted`)'
. ' values (?, ?, ?, "", ?, "open", ?, ?, ?, ?, ?, ?, ?, ?, 0)'
);
$statement->execute([
$groupId,
$category,
$name,
$userId,
$announcement,
$welcomeText,
$nativeGroupId,
$nativeStatus,
$nativeError,
$userId,
$now,
$now,
]);
foreach ($memberIds as $memberId) {
upsertWebGroupMember($appDb, $groupId, $memberId, $memberId === $userId ? 'owner' : 'member');
}
sendWebGroupWelcomeMessage($appDb, $groupId, $userId, $welcomeText);
$group = fetchWebGroup($appDb, $groupId);
return [
'group' => $group,
'conversation' => webGroupConversation($group ?: [
'group_id' => $groupId,
'category' => $category,
'name' => $name,
'updated_at_ms' => $now,
], 'owner'),
'nativeStatus' => $nativeStatus,
'nativeError' => $nativeError,
];
}
function fetchWebUserMap(PDO $imDb, array $userIds, string $ownerId = ''): array
{
$userIds = webActionList($userIds);
if (!$userIds) {
return [];
}
$statement = $imDb->prepare(
'select `_uid`, `_name`, `_display_name`, `_mobile`, `_portrait` from `t_user`'
. ' where `_uid` in (' . appPlaceholders($userIds) . ')'
);
$statement->execute($userIds);
$users = [];
foreach ($statement->fetchAll() as $row) {
$uid = (string)$row['_uid'];
$displayName = trim((string)($row['_display_name'] ?? ''))
?: trim((string)($row['_name'] ?? ''))
?: trim((string)($row['_mobile'] ?? ''))
?: $uid;
$users[$uid] = [
'uid' => $uid,
'name' => (string)($row['_name'] ?? ''),
'displayName' => $displayName,
'mobile' => (string)($row['_mobile'] ?? ''),
'portrait' => (string)($row['_portrait'] ?? ''),
'contactAlias' => '',
'friendAlias' => '',
'remarkAlias' => '',
'isContact' => false,
];
}
if ($ownerId !== '') {
$aliasStatement = $imDb->prepare(
'select `_friend_uid`, `_alias` from `t_friend` where `_uid` = ?'
. ' and `_friend_uid` in (' . appPlaceholders($userIds) . ')'
);
$aliasStatement->execute(array_merge([$ownerId], $userIds));
foreach ($aliasStatement->fetchAll() as $row) {
$friendUid = (string)$row['_friend_uid'];
if (!isset($users[$friendUid])) {
continue;
}
$users[$friendUid]['isContact'] = true;
$users[$friendUid]['friendAlias'] = trim((string)($row['_alias'] ?? ''));
}
$remarkMap = fetchWebUserRemarkMap(imAppDb(), $ownerId, $userIds);
foreach ($remarkMap as $targetUid => $alias) {
if (!isset($users[$targetUid])) {
continue;
}
$users[$targetUid]['remarkAlias'] = $alias;
}
foreach ($users as $uid => $user) {
$friendAlias = trim((string)($user['friendAlias'] ?? ''));
$remarkAlias = trim((string)($user['remarkAlias'] ?? ''));
$users[$uid]['contactAlias'] = $friendAlias !== '' ? $friendAlias : $remarkAlias;
}
}
return $users;
}
function currentMuteSetting(PDO $appDb, string $groupId): array
{
$statement = $appDb->prepare('select * from `t_web_group_mute_rules` where `group_id` = ? limit 1');
$statement->execute([$groupId]);
$row = $statement->fetch();
if (!is_array($row)) {
return [
'mode' => 'off',
'scheduleType' => 'once',
'startsAtMs' => 0,
'endsAtMs' => 0,
'dailyStart' => '',
'dailyEnd' => '',
'enabled' => false,
];
}
return [
'mode' => (string)$row['mode'],
'scheduleType' => (string)$row['schedule_type'],
'startsAtMs' => (int)$row['starts_at_ms'],
'endsAtMs' => (int)$row['ends_at_ms'],
'dailyStart' => (string)$row['daily_start'],
'dailyEnd' => (string)$row['daily_end'],
'enabled' => (bool)$row['enabled'],
];
}
function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
{
$group = fetchWebGroup($appDb, $groupId);
if (!$group) {
respond(404, '没有找到该群组');
}
$member = fetchWebGroupMember($appDb, $groupId, $userId);
if (!$member) {
respond(403, '你不在该群组中');
}
$ownerId = (string)$group['owner_uid'];
$users = fetchWebUserMap($imDb, [$ownerId, $userId], $userId);
$owner = $users[$ownerId] ?? ['uid' => $ownerId, 'displayName' => $ownerId, 'contactAlias' => ''];
$me = $users[$userId] ?? ['uid' => $userId, 'displayName' => $userId];
$myAlias = (string)($member['alias'] ?? '');
return [
'group' => [
'groupId' => (string)$group['group_id'],
'name' => (string)$group['name'],
'category' => (string)$group['category'],
'announcement' => (string)($group['announcement'] ?? ''),
'welcomeText' => (string)($group['welcome_text'] ?? ''),
'ownerUid' => $ownerId,
'ownerName' => (string)($owner['contactAlias'] ?? '') !== '' ? (string)$owner['contactAlias'] : (string)$owner['displayName'],
'myRole' => (string)$member['role'],
'myAlias' => $myAlias,
'myDisplayName' => $myAlias !== '' ? $myAlias : (string)$me['displayName'],
'muteSetting' => currentMuteSetting($appDb, (string)$group['group_id']),
],
];
}
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
{
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
respond(403, '你不在该群组中');
}
$statement = $appDb->prepare(
'select `user_id`, `role`, `alias`, `joined_at_ms` from `t_web_group_members`'
. ' where `group_id` = ? and `status` = 0 order by (`role` = "owner") desc, `joined_at_ms` asc'
);
$statement->execute([$groupId]);
$members = $statement->fetchAll();
$users = fetchWebUserMap($imDb, array_column($members, 'user_id'), $userId);
return [
'members' => array_map(static function (array $member) use ($users): array {
$uid = (string)$member['user_id'];
$user = $users[$uid] ?? [
'uid' => $uid,
'displayName' => $uid,
'mobile' => '',
'portrait' => '',
'contactAlias' => '',
'friendAlias' => '',
'remarkAlias' => '',
'isContact' => false,
];
$alias = trim((string)($member['alias'] ?? ''));
$contactAlias = trim((string)($user['contactAlias'] ?? ''));
return [
'uid' => $uid,
'role' => (string)$member['role'],
'alias' => $alias,
'contactAlias' => $contactAlias,
'friendAlias' => trim((string)($user['friendAlias'] ?? '')),
'remarkAlias' => trim((string)($user['remarkAlias'] ?? '')),
'isContact' => (bool)($user['isContact'] ?? false),
'displayName' => $contactAlias !== '' ? $contactAlias : ($alias !== '' ? $alias : (string)$user['displayName']),
'rawDisplayName' => (string)$user['displayName'],
'groupDisplayName' => $alias,
'mobile' => (string)($user['mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? ''),
];
}, $members),
];
}
function updateGroupAliasAction(PDO $appDb, string $userId, string $groupId, string $alias): array
{
if (mb_strlen($alias, 'UTF-8') > 64) {
respond(400, '本群昵称过长');
}
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
respond(403, '你不在该群组中');
}
$now = webImNowMs();
$statement = $appDb->prepare(
'update `t_web_group_members` set `alias` = ?, `updated_at_ms` = ?'
. ' where `group_id` = ? and `user_id` = ? and `status` = 0'
);
$statement->execute([$alias, $now, $groupId, $userId]);
return ['alias' => $alias];
}
function leaveGroupAction(PDO $appDb, string $userId, string $groupId): array
{
$group = fetchWebGroup($appDb, $groupId);
if (!$group) {
respond(404, '没有找到该群组');
}
$member = fetchWebGroupMember($appDb, $groupId, $userId);
if (!$member) {
respond(403, '你不在该群组中');
}
$now = webImNowMs();
$statement = $appDb->prepare(
'update `t_web_group_members` set `status` = 1, `updated_at_ms` = ?'
. ' where `group_id` = ? and `user_id` = ?'
);
$statement->execute([$now, $groupId, $userId]);
$groupUpdate = $appDb->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
$groupUpdate->execute([$now, $groupId]);
try {
requireImSuccess(imAdminPost('/admin/group/member/quit', [
'operator' => $userId,
'group_id' => $groupId,
'to_lines' => [0],
]), '退出原生群组失败');
} catch (Throwable) {
}
return ['groupId' => $groupId];
}
function dismissGroupAction(PDO $appDb, string $userId, string $groupId): array
{
$group = fetchWebGroup($appDb, $groupId);
if (!$group) {
respond(404, '没有找到该群组');
}
requireWebGroupRole($appDb, $groupId, $userId, ['owner']);
$now = webImNowMs();
$update = $appDb->prepare('update `t_web_groups` set `deleted` = 1, `updated_at_ms` = ? where `group_id` = ?');
$update->execute([$now, $groupId]);
$memberUpdate = $appDb->prepare('update `t_web_group_members` set `status` = 1, `updated_at_ms` = ? where `group_id` = ?');
$memberUpdate->execute([$now, $groupId]);
try {
dismissNativeGroup($userId, $groupId);
} catch (Throwable) {
}
return ['groupId' => $groupId];
}
function transferGroupAction(PDO $appDb, string $userId, string $groupId, string $targetUserId): array
{
if ($targetUserId === '' || $targetUserId === $userId) {
respond(400, '请选择新的群主');
}
$group = fetchWebGroup($appDb, $groupId);
if (!$group) {
respond(404, '没有找到该群组');
}
requireWebGroupRole($appDb, $groupId, $userId, ['owner']);
$target = requireWebGroupMember($appDb, $groupId, $targetUserId);
if ((string)$target['role'] === 'owner') {
respond(400, '该成员已经是群主');
}
$now = webImNowMs();
$appDb->beginTransaction();
try {
$groupUpdate = $appDb->prepare('update `t_web_groups` set `owner_uid` = ?, `updated_at_ms` = ? where `group_id` = ?');
$groupUpdate->execute([$targetUserId, $now, $groupId]);
$oldOwnerUpdate = $appDb->prepare(
'update `t_web_group_members` set `role` = "member", `updated_at_ms` = ?'
. ' where `group_id` = ? and `user_id` = ?'
);
$oldOwnerUpdate->execute([$now, $groupId, $userId]);
$newOwnerUpdate = $appDb->prepare(
'update `t_web_group_members` set `role` = "owner", `updated_at_ms` = ?'
. ' where `group_id` = ? and `user_id` = ?'
);
$newOwnerUpdate->execute([$now, $groupId, $targetUserId]);
$appDb->commit();
} catch (Throwable $error) {
$appDb->rollBack();
throw $error;
}
try {
transferNativeGroup($userId, $groupId, $targetUserId);
} catch (Throwable) {
}
return ['groupId' => $groupId, 'newOwner' => $targetUserId];
}
function setGroupAdminsAction(PDO $appDb, string $userId, string $groupId, array $adminIds): array
{
requireWebGroupRole($appDb, $groupId, $userId, ['owner']);
$adminIds = array_values(array_unique(array_filter(array_map('strval', $adminIds), static fn(string $uid): bool => $uid !== $userId)));
$now = webImNowMs();
$reset = $appDb->prepare(
'update `t_web_group_members` set `role` = "member", `updated_at_ms` = ?'
. ' where `group_id` = ? and `role` = "admin" and `status` = 0'
);
$reset->execute([$now, $groupId]);
if ($adminIds) {
$update = $appDb->prepare(
'update `t_web_group_members` set `role` = "admin", `updated_at_ms` = ?'
. ' where `group_id` = ? and `user_id` in (' . appPlaceholders($adminIds) . ')'
. ' and `role` <> "owner" and `status` = 0'
);
$update->execute(array_merge([$now, $groupId], $adminIds));
}
return ['adminIds' => $adminIds];
}
function updateGroupProfileAction(PDO $appDb, string $userId, string $groupId, array $payload): array
{
requireWebGroupRole($appDb, $groupId, $userId, ['owner', 'admin']);
$name = trim((string)($payload['name'] ?? ''));
$announcement = trim((string)($payload['announcement'] ?? ''));
$welcomeText = trim((string)($payload['welcomeText'] ?? ''));
if ($name === '') {
respond(400, '请输入群名称');
}
if (mb_strlen($name, 'UTF-8') > 128) {
respond(400, '群名称过长');
}
$now = webImNowMs();
$statement = $appDb->prepare(
'update `t_web_groups` set `name` = ?, `announcement` = ?, `welcome_text` = ?, `updated_at_ms` = ?'
. ' where `group_id` = ? and `deleted` = 0'
);
$statement->execute([$name, $announcement, $welcomeText, $now, $groupId]);
return [
'groupId' => $groupId,
'name' => $name,
'announcement' => $announcement,
'welcomeText' => $welcomeText,
'updatedAt' => $now,
];
}
function kickGroupMembersAction(PDO $appDb, string $userId, string $groupId, array $targetIds): array
{
$operator = requireWebGroupRole($appDb, $groupId, $userId, ['owner', 'admin']);
$operatorRole = (string)$operator['role'];
$targetIds = array_values(array_unique(array_filter(array_map('strval', $targetIds), static fn(string $uid): bool => $uid !== '')));
$targetIds = array_values(array_filter($targetIds, static fn(string $uid): bool => $uid !== $userId));
if (!$targetIds) {
respond(400, '请选择要移除的群员');
}
$statement = $appDb->prepare(
'select `user_id`, `role` from `t_web_group_members`'
. ' where `group_id` = ? and `user_id` in (' . appPlaceholders($targetIds) . ') and `status` = 0'
);
$statement->execute(array_merge([$groupId], $targetIds));
$targets = $statement->fetchAll();
if (!$targets) {
respond(400, '没有可移除的群员');
}
$allowedIds = [];
foreach ($targets as $target) {
$role = (string)$target['role'];
if ($role === 'owner') {
continue;
}
if ($operatorRole === 'admin' && $role !== 'member') {
continue;
}
$allowedIds[] = (string)$target['user_id'];
}
if (!$allowedIds) {
respond(403, '没有可移除的群员');
}
$now = webImNowMs();
$update = $appDb->prepare(
'update `t_web_group_members` set `status` = 2, `updated_at_ms` = ?'
. ' where `group_id` = ? and `user_id` in (' . appPlaceholders($allowedIds) . ')'
);
$update->execute(array_merge([$now, $groupId], $allowedIds));
try {
removeNativeGroupMember($userId, $groupId, $allowedIds);
} catch (Throwable) {
}
return ['removedIds' => $allowedIds];
}
function updateMuteSettingAction(PDO $appDb, string $userId, string $groupId, array $payload): array
{
requireWebGroupRole($appDb, $groupId, $userId, ['owner', 'admin']);
$mode = trim((string)($payload['mode'] ?? 'off'));
$allowedModes = ['off', 'all_include_admins', 'all_except_admins'];
if (!in_array($mode, $allowedModes, true)) {
respond(400, '禁言模式不正确');
}
$scheduleType = trim((string)($payload['scheduleType'] ?? 'once'));
if (!in_array($scheduleType, ['manual', 'once', 'daily'], true)) {
respond(400, '禁言时段类型不正确');
}
$startsAtMs = max(0, (int)($payload['startsAtMs'] ?? 0));
$endsAtMs = max(0, (int)($payload['endsAtMs'] ?? 0));
$dailyStart = trim((string)($payload['dailyStart'] ?? ''));
$dailyEnd = trim((string)($payload['dailyEnd'] ?? ''));
$enabled = $mode !== 'off';
if ($enabled && $scheduleType === 'once' && ($startsAtMs <= 0 || $endsAtMs <= $startsAtMs)) {
respond(400, '请设置有效的单次禁言时段');
}
if ($enabled && $scheduleType === 'daily' && (!preg_match('/^\d{2}:\d{2}$/', $dailyStart) || !preg_match('/^\d{2}:\d{2}$/', $dailyEnd))) {
respond(400, '请设置有效的每日禁言时段');
}
$now = webImNowMs();
$statement = $appDb->prepare(
'insert into `t_web_group_mute_rules`'
. ' (`group_id`, `mode`, `schedule_type`, `starts_at_ms`, `ends_at_ms`, `daily_start`, `daily_end`, `enabled`, `created_by`, `updated_at_ms`)'
. ' values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
. ' on duplicate key update'
. ' `mode` = values(`mode`),'
. ' `schedule_type` = values(`schedule_type`),'
. ' `starts_at_ms` = values(`starts_at_ms`),'
. ' `ends_at_ms` = values(`ends_at_ms`),'
. ' `daily_start` = values(`daily_start`),'
. ' `daily_end` = values(`daily_end`),'
. ' `enabled` = values(`enabled`),'
. ' `created_by` = values(`created_by`),'
. ' `updated_at_ms` = values(`updated_at_ms`)'
);
$statement->execute([$groupId, $mode, $scheduleType, $startsAtMs, $endsAtMs, $dailyStart, $dailyEnd, $enabled ? 1 : 0, $userId, $now]);
return ['muteSetting' => currentMuteSetting($appDb, $groupId)];
}
function joinWebGroupAction(PDO $appDb, string $userId, string $groupKey): array
{
if ($groupKey === '') {
respond(400, '请输入群组ID或群名称');
}
$group = fetchWebGroup($appDb, $groupKey);
if (!$group) {
try {
$native = requireImSuccess(imAdminPost('/admin/group/get_info', ['groupId' => $groupKey]), '群组不存在');
$now = webImNowMs();
$statement = $appDb->prepare(
'insert into `t_web_groups`'
. ' (`group_id`, `category`, `name`, `portrait`, `owner_uid`, `join_mode`, `announcement`,'
. ' `native_group_id`, `native_status`, `created_by`, `created_at_ms`, `updated_at_ms`, `deleted`)'
. ' values (?, "group", ?, ?, ?, "open", "", ?, "ok", ?, ?, ?, 0)'
. ' on duplicate key update `updated_at_ms` = values(`updated_at_ms`)'
);
$statement->execute([
$groupKey,
(string)($native['name'] ?? $groupKey),
(string)($native['portrait'] ?? ''),
(string)($native['owner'] ?? ''),
$groupKey,
$userId,
$now,
$now,
]);
$group = fetchWebGroup($appDb, $groupKey);
} catch (Throwable) {
respond(404, '没有找到该群组');
}
}
$groupId = (string)$group['group_id'];
upsertWebGroupMember($appDb, $groupId, $userId, 'member');
try {
addNativeGroupMember((string)($group['owner_uid'] ?: $userId), $groupId, $userId);
} catch (Throwable $error) {
// Web membership is authoritative for this web build; native group sync is best-effort.
}
$role = fetchWebGroupRole($appDb, $groupId, $userId);
return [
'group' => fetchWebGroup($appDb, $groupId),
'conversation' => webGroupConversation($group, $role),
];
}
function sendBroadcastAction(PDO $imDb, PDO $appDb, string $userId, array $payload): array
{
$text = trim((string)($payload['text'] ?? ''));
if ($text === '') {
respond(400, '请输入群发内容');
}
$targetIds = resolveWebUserIds($imDb, webActionList($payload['targetIds'] ?? []));
$targetIds = array_values(array_filter(array_unique($targetIds), static fn(string $uid): bool => $uid !== $userId));
if (!$targetIds) {
respond(400, '请选择群发对象');
}
$results = [];
foreach ($targetIds as $targetId) {
try {
$result = requireImSuccess(imAdminPost('/admin/message/send', [
'sender' => $userId,
'conv' => [
'type' => 0,
'target' => $targetId,
'line' => 0,
],
'payload' => [
'type' => 1,
'searchableContent' => $text,
'pushContent' => $text,
'content' => $text,
'persistFlag' => 3,
],
'userMessage' => true,
]), '发送失败');
$results[] = [
'targetId' => $targetId,
'ok' => true,
'messageUid' => isset($result['messageUid']) ? (string)$result['messageUid'] : '',
];
} catch (Throwable $error) {
$results[] = [
'targetId' => $targetId,
'ok' => false,
'error' => $error->getMessage() ?: '发送失败',
];
}
}
$successCount = count(array_filter($results, static fn(array $item): bool => !empty($item['ok'])));
$failedCount = count($results) - $successCount;
$now = webImNowMs();
$statement = $appDb->prepare(
'insert into `t_web_broadcast_logs`'
. ' (`sender_uid`, `target_count`, `success_count`, `failed_count`, `text`, `target_json`, `result_json`, `created_at_ms`)'
. ' values (?, ?, ?, ?, ?, ?, ?, ?)'
);
$statement->execute([
$userId,
count($targetIds),
$successCount,
$failedCount,
$text,
json_encode($targetIds, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
json_encode($results, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
$now,
]);
return [
'targetCount' => count($targetIds),
'successCount' => $successCount,
'failedCount' => $failedCount,
'results' => $results,
];
}
$payload = readJsonPayload();
$action = trim((string)($payload['action'] ?? ''));
$userId = trim((string)($payload['userId'] ?? ''));
if ($userId === '') {
respond(400, '缺少当前用户ID');
}
try {
ensureWebImAppTables();
$imDb = imDb();
$appDb = imAppDb();
if ($action === 'create_group') {
respond(0, 'success', createWebGroupAction($imDb, $appDb, $userId, $payload, 'group'));
}
if ($action === 'create_channel') {
respond(0, 'success', createWebGroupAction($imDb, $appDb, $userId, $payload, 'channel'));
}
if ($action === 'join_group') {
respond(0, 'success', joinWebGroupAction($appDb, $userId, trim((string)($payload['groupKey'] ?? ''))));
}
if ($action === 'group_detail') {
respond(0, 'success', groupDetailAction($imDb, $appDb, $userId, trim((string)($payload['groupId'] ?? ''))));
}
if ($action === 'group_members') {
respond(0, 'success', groupMembersAction($imDb, $appDb, $userId, trim((string)($payload['groupId'] ?? ''))));
}
if ($action === 'update_group_alias') {
respond(0, 'success', updateGroupAliasAction(
$appDb,
$userId,
trim((string)($payload['groupId'] ?? '')),
trim((string)($payload['alias'] ?? ''))
));
}
if ($action === 'leave_group') {
respond(0, 'success', leaveGroupAction($appDb, $userId, trim((string)($payload['groupId'] ?? ''))));
}
if ($action === 'dismiss_group') {
respond(0, 'success', dismissGroupAction($appDb, $userId, trim((string)($payload['groupId'] ?? ''))));
}
if ($action === 'transfer_group') {
respond(0, 'success', transferGroupAction(
$appDb,
$userId,
trim((string)($payload['groupId'] ?? '')),
trim((string)($payload['targetUserId'] ?? ''))
));
}
if ($action === 'set_group_admins') {
respond(0, 'success', setGroupAdminsAction(
$appDb,
$userId,
trim((string)($payload['groupId'] ?? '')),
is_array($payload['adminIds'] ?? null) ? $payload['adminIds'] : []
));
}
if ($action === 'update_group_profile') {
respond(0, 'success', updateGroupProfileAction(
$appDb,
$userId,
trim((string)($payload['groupId'] ?? '')),
$payload
));
}
if ($action === 'kick_group_members') {
respond(0, 'success', kickGroupMembersAction(
$appDb,
$userId,
trim((string)($payload['groupId'] ?? '')),
is_array($payload['targetIds'] ?? null) ? $payload['targetIds'] : []
));
}
if ($action === 'update_mute_setting') {
respond(0, 'success', updateMuteSettingAction(
$appDb,
$userId,
trim((string)($payload['groupId'] ?? '')),
$payload
));
}
if ($action === 'broadcast') {
$result = sendBroadcastAction($imDb, $appDb, $userId, $payload);
if ((int)$result['successCount'] <= 0) {
respond(500, '群发失败', $result);
}
respond(0, 'success', $result);
}
respond(400, '未知操作');
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '会话操作失败');
}
+348
View File
@@ -0,0 +1,348 @@
<?php
declare(strict_types=1);
define('IM_ADMIN_URL', getenv('IM_ADMIN_URL') ?: 'http://127.0.0.1:18080');
define('IM_ADMIN_SECRET', getenv('IM_ADMIN_SECRET') ?: '');
define('IM_DB_DSN', getenv('IM_DB_DSN') ?: 'mysql:host=127.0.0.1;port=3306;dbname=wfchat;charset=utf8mb4');
define('IM_DB_USER', getenv('IM_DB_USER') ?: '');
define('IM_DB_PASSWORD', getenv('IM_DB_PASSWORD') ?: '');
define('IM_APP_DB_DSN', getenv('IM_APP_DB_DSN') ?: 'mysql:host=127.0.0.1;port=3306;dbname=appdata;charset=utf8mb4');
define('IM_APP_DB_USER', getenv('IM_APP_DB_USER') ?: '');
define('IM_APP_DB_PASSWORD', getenv('IM_APP_DB_PASSWORD') ?: '');
define('IM_DB_TIMEZONE', getenv('IM_DB_TIMEZONE') ?: 'Asia/Bangkok');
function respond(int $code, string $message, array $result = []): void
{
echo json_encode([
'code' => $code,
'message' => $message,
'result' => $result,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function readJsonPayload(): array
{
$payload = json_decode(file_get_contents('php://input') ?: '[]', true);
return is_array($payload) ? $payload : [];
}
function imAdminPost(string $path, array $payload): array
{
$nonce = (string)random_int(1, 1000000);
$timestamp = (string)((int)floor(microtime(true) * 1000));
$sign = sha1($nonce . '|' . IM_ADMIN_SECRET . '|' . $timestamp);
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$curl = curl_init(IM_ADMIN_URL . $path);
if ($curl === false) {
throw new RuntimeException('IM admin client init failed');
}
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_0,
CURLOPT_CONNECTTIMEOUT_MS => 1000,
CURLOPT_TIMEOUT_MS => 4000,
CURLOPT_FRESH_CONNECT => true,
CURLOPT_FORBID_REUSE => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json; charset=utf-8',
'Connection: close',
'nonce: ' . $nonce,
'timestamp: ' . $timestamp,
'sign: ' . $sign,
],
]);
$responseBody = curl_exec($curl);
$curlError = curl_error($curl);
curl_close($curl);
if ($responseBody === false || $responseBody === '') {
throw new RuntimeException($curlError ?: 'IM admin request failed');
}
$data = json_decode($responseBody, true);
if (!is_array($data)) {
throw new RuntimeException('Invalid IM admin response');
}
return $data;
}
function requireImSuccess(array $response, string $fallbackMessage): array
{
if (($response['code'] ?? -1) !== 0) {
throw new RuntimeException((string)($response['msg'] ?? $response['message'] ?? $fallbackMessage));
}
return is_array($response['result'] ?? null) ? $response['result'] : [];
}
function imDb(): PDO
{
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
$pdo = new PDO(IM_DB_DSN, IM_DB_USER, IM_DB_PASSWORD, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return $pdo;
}
function imAppDb(): PDO
{
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
$pdo = new PDO(IM_APP_DB_DSN, IM_APP_DB_USER, IM_APP_DB_PASSWORD, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return $pdo;
}
function ensureWebImAppTables(): void
{
static $ensured = false;
if ($ensured) {
return;
}
$ensured = true;
$db = imAppDb();
$db->exec(
"create table if not exists `t_web_groups` (
`id` bigint unsigned not null auto_increment,
`group_id` varchar(64) not null,
`category` varchar(20) not null default 'group',
`name` varchar(128) not null,
`portrait` varchar(1024) not null default '',
`owner_uid` varchar(64) not null,
`join_mode` varchar(20) not null default 'open',
`announcement` text null,
`native_group_id` varchar(64) not null default '',
`native_status` varchar(32) not null default 'pending',
`native_error` varchar(255) not null default '',
`created_by` varchar(64) not null,
`created_at_ms` bigint not null,
`updated_at_ms` bigint not null,
`deleted` tinyint not null default 0,
primary key (`id`),
unique key `uk_t_web_groups_group_id` (`group_id`),
key `idx_t_web_groups_owner` (`owner_uid`),
key `idx_t_web_groups_category` (`category`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
try {
$db->exec("alter table `t_web_groups` add column `welcome_text` text null after `announcement`");
} catch (Throwable) {
}
$db->exec(
"create table if not exists `t_web_group_members` (
`id` bigint unsigned not null auto_increment,
`group_id` varchar(64) not null,
`user_id` varchar(64) not null,
`role` varchar(20) not null default 'member',
`alias` varchar(128) not null default '',
`joined_at_ms` bigint not null,
`updated_at_ms` bigint not null,
`status` tinyint not null default 0,
primary key (`id`),
unique key `uk_t_web_group_members_gid_uid` (`group_id`, `user_id`),
key `idx_t_web_group_members_user` (`user_id`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_group_messages` (
`id` bigint unsigned not null auto_increment,
`message_uid` varchar(64) not null,
`group_id` varchar(64) not null,
`sender_uid` varchar(64) not null,
`text` text not null,
`created_at_ms` bigint not null,
`edited_at_ms` bigint not null default 0,
`recalled` tinyint not null default 0,
primary key (`id`),
unique key `uk_t_web_group_messages_uid` (`message_uid`),
key `idx_t_web_group_messages_group_time` (`group_id`, `created_at_ms`),
key `idx_t_web_group_messages_sender` (`sender_uid`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_message_attachments` (
`id` bigint unsigned not null auto_increment,
`message_uid` varchar(64) not null,
`conversation_type` varchar(16) not null default 'single',
`target_id` varchar(128) not null default '',
`sender_uid` varchar(64) not null default '',
`attachment_type` varchar(16) not null,
`url` varchar(1024) not null,
`name` varchar(255) not null default '',
`mime` varchar(128) not null default '',
`size_bytes` bigint not null default 0,
`created_at_ms` bigint not null,
primary key (`id`),
unique key `uk_t_web_message_attachments_uid` (`message_uid`),
key `idx_t_web_message_attachments_target` (`conversation_type`, `target_id`, `created_at_ms`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_group_reads` (
`id` bigint unsigned not null auto_increment,
`group_id` varchar(64) not null,
`user_id` varchar(64) not null,
`read_at_ms` bigint not null default 0,
`updated_at_ms` bigint not null,
primary key (`id`),
unique key `uk_t_web_group_reads_gid_uid` (`group_id`, `user_id`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_group_mute_rules` (
`id` bigint unsigned not null auto_increment,
`group_id` varchar(64) not null,
`mode` varchar(32) not null default 'off',
`schedule_type` varchar(16) not null default 'once',
`starts_at_ms` bigint not null default 0,
`ends_at_ms` bigint not null default 0,
`daily_start` varchar(8) not null default '',
`daily_end` varchar(8) not null default '',
`enabled` tinyint not null default 0,
`created_by` varchar(64) not null default '',
`updated_at_ms` bigint not null,
primary key (`id`),
unique key `uk_t_web_group_mute_rules_group` (`group_id`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_broadcast_logs` (
`id` bigint unsigned not null auto_increment,
`sender_uid` varchar(64) not null,
`target_count` int not null default 0,
`success_count` int not null default 0,
`failed_count` int not null default 0,
`text` text not null,
`target_json` text null,
`result_json` text null,
`created_at_ms` bigint not null,
primary key (`id`),
key `idx_t_web_broadcast_logs_sender_time` (`sender_uid`, `created_at_ms`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_user_remarks` (
`id` bigint unsigned not null auto_increment,
`owner_uid` varchar(64) not null,
`target_uid` varchar(64) not null,
`alias` varchar(128) not null default '',
`created_at_ms` bigint not null,
`updated_at_ms` bigint not null,
primary key (`id`),
unique key `uk_t_web_user_remarks_owner_target` (`owner_uid`, `target_uid`),
key `idx_t_web_user_remarks_target` (`target_uid`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
}
function webImPlaceholders(array $values): string
{
return implode(',', array_fill(0, count($values), '?'));
}
function fetchWebUserRemarkMap(PDO $db, string $ownerId, array $targetIds): array
{
$targetIds = array_values(array_unique(array_filter(array_map(static function ($targetId): string {
return trim((string)$targetId);
}, $targetIds))));
if ($ownerId === '' || !$targetIds) {
return [];
}
$statement = $db->prepare(
'select `target_uid`, `alias` from `t_web_user_remarks`'
. ' where `owner_uid` = ? and `target_uid` in (' . webImPlaceholders($targetIds) . ')'
);
$statement->execute(array_merge([$ownerId], $targetIds));
$remarks = [];
foreach ($statement->fetchAll() as $row) {
$remarks[(string)$row['target_uid']] = trim((string)($row['alias'] ?? ''));
}
return $remarks;
}
function saveWebUserRemark(PDO $db, string $ownerId, string $targetId, string $alias): void
{
if ($ownerId === '' || $targetId === '') {
return;
}
if ($alias === '') {
$delete = $db->prepare('delete from `t_web_user_remarks` where `owner_uid` = ? and `target_uid` = ?');
$delete->execute([$ownerId, $targetId]);
return;
}
$now = webImNowMs();
$statement = $db->prepare(
'insert into `t_web_user_remarks` (`owner_uid`, `target_uid`, `alias`, `created_at_ms`, `updated_at_ms`)'
. ' values (?, ?, ?, ?, ?)'
. ' on duplicate key update `alias` = values(`alias`), `updated_at_ms` = values(`updated_at_ms`)'
);
$statement->execute([$ownerId, $targetId, $alias, $now, $now]);
}
function webImNowMs(): int
{
return (int)floor(microtime(true) * 1000);
}
function webImMessageUid(): string
{
$timestampPart = (webImNowMs() - 1514736000000) << 21;
return (string)($timestampPart | random_int(1, 0x1FFFFF));
}
function javaStringHashCode(string $value): int
{
$hash = 0;
$length = strlen($value);
for ($i = 0; $i < $length; $i++) {
$hash = (31 * $hash + ord($value[$i])) & 0xFFFFFFFF;
if ($hash >= 0x80000000) {
$hash -= 0x100000000;
}
}
return $hash;
}
function imUserMessagesTable(string $userId): string
{
return 't_user_messages_' . (abs(javaStringHashCode($userId)) % 128);
}
function imMessageTable(string $messageId): string
{
$timestamp = ((int)$messageId >> 21) + 1514736000000;
$date = (new DateTimeImmutable('@' . (int)floor($timestamp / 1000)))
->setTimezone(new DateTimeZone(IM_DB_TIMEZONE));
$year = (int)$date->format('Y');
$month = (int)$date->format('n') - 1;
return 't_messages_' . (($year % 3) * 12 + $month);
}
+58
View File
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
const WEB_MEDIA_ROOT = '/opt/im-server/media';
function failDownload(int $status, string $message): never
{
http_response_code($status);
header('Content-Type: text/plain; charset=utf-8');
echo $message;
exit;
}
function safeDownloadName(string $fileName, string $fallback): string
{
$name = basename(str_replace('\\', '/', $fileName));
$name = preg_replace('/[^\p{L}\p{N}._ -]+/u', '_', $name) ?: '';
$name = trim($name, " .\t\n\r\0\x0B");
if ($name === '') {
$name = $fallback;
}
return mb_substr($name, 0, 180, 'UTF-8');
}
$url = trim((string)($_GET['url'] ?? ''));
$requestedName = trim((string)($_GET['name'] ?? ''));
if ($url === '') {
failDownload(400, '缺少媒体地址');
}
$path = (string)(parse_url($url, PHP_URL_PATH) ?: $url);
$path = rawurldecode($path);
if (!preg_match('#^/fs/[0-9]+/[A-Za-z0-9_./ -]+$#', $path) || str_contains($path, '..')) {
failDownload(400, '媒体地址不合法');
}
$root = realpath(WEB_MEDIA_ROOT);
$fullPath = realpath(rtrim(WEB_MEDIA_ROOT, '/') . $path);
if ($root === false || $fullPath === false || !str_starts_with($fullPath, $root . DIRECTORY_SEPARATOR) || !is_file($fullPath)) {
failDownload(404, '媒体文件不存在');
}
$fallbackName = basename($fullPath);
$downloadName = safeDownloadName($requestedName, $fallbackName);
if (!pathinfo($downloadName, PATHINFO_EXTENSION)) {
$downloadName .= '.' . (pathinfo($fallbackName, PATHINFO_EXTENSION) ?: 'jpg');
}
$mime = mime_content_type($fullPath) ?: 'application/octet-stream';
header('Content-Type: ' . $mime);
header('Content-Length: ' . (string)filesize($fullPath));
header(
'Content-Disposition: attachment; filename="' . addcslashes($downloadName, "\\\"") . '"; filename*=UTF-8\'\''
. rawurlencode($downloadName)
);
header('Cache-Control: no-store');
readfile($fullPath);
+288
View File
@@ -0,0 +1,288 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
class UserActionException extends RuntimeException
{
}
function resolveActionUserId(string $value): string
{
if ($value === '') {
return '';
}
$db = imDb();
$statement = $db->prepare(
'select `_uid` from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ? limit 1'
);
$statement->execute([$value, $value, $value]);
$uid = $statement->fetchColumn();
return is_string($uid) && $uid !== '' ? $uid : $value;
}
function messageTimestampMs(string $messageUid): int
{
return (((int)$messageUid) >> 21) + 1514736000000;
}
function nowTimestampMs(): int
{
return (int)floor(microtime(true) * 1000);
}
function recallLimitMinutes(): float
{
$statement = imAppDb()->prepare(
'select `conf_value` from `system_conf`'
. ' where `conf_name` = ? and `is_valid` = 1 limit 1'
);
$statement->execute(['RetractMessage']);
$value = trim((string)($statement->fetchColumn() ?: '0'));
if ($value === '' || !is_numeric($value)) {
return 0.0;
}
return max(0.0, (float)$value);
}
function assertRecallWithinLimit(string $messageUid): void
{
$limitMinutes = recallLimitMinutes();
if ($limitMinutes <= 0) {
return;
}
$messageAgeMs = nowTimestampMs() - messageTimestampMs($messageUid);
$limitMs = (int)round($limitMinutes * 60 * 1000);
if ($messageAgeMs > $limitMs) {
throw new UserActionException('已超过可撤回时间');
}
}
function fetchActionMessageRow(string $messageUid): array
{
$table = imMessageTable($messageUid);
$statement = imDb()->prepare(
'select `_mid`, `_from`, `_content_type`, `_searchable_key` from `' . $table . '` where `_mid` = ? limit 1'
);
$statement->execute([$messageUid]);
$row = $statement->fetch();
return is_array($row) ? $row : [];
}
function ensureMessageEditsTable(): void
{
imDb()->exec(
'create table if not exists `t_web_message_edits` ('
. '`message_uid` varchar(32) not null,'
. '`editor_uid` varchar(64) not null,'
. '`edited_text` text null,'
. '`edited_at_ms` bigint not null,'
. '`created_at` datetime not null default current_timestamp,'
. '`updated_at` datetime null default current_timestamp on update current_timestamp,'
. 'primary key (`message_uid`),'
. 'key `idx_editor_uid` (`editor_uid`),'
. 'key `idx_edited_at_ms` (`edited_at_ms`)'
. ') engine=InnoDB default charset=utf8mb4'
);
}
function editTextMessage(string $operatorId, string $messageUid, string $text): array
{
$row = fetchActionMessageRow($messageUid);
if (!$row) {
return editWebGroupMessage($operatorId, $messageUid, $text);
}
if ((string)$row['_from'] !== $operatorId) {
throw new UserActionException('只能编辑自己发送的消息');
}
if ((int)$row['_content_type'] !== 1) {
throw new UserActionException('只能编辑文字消息');
}
$editedAt = nowTimestampMs();
$table = imMessageTable($messageUid);
$update = imDb()->prepare('update `' . $table . '` set `_searchable_key` = ? where `_mid` = ?');
$update->execute([$text, $messageUid]);
ensureMessageEditsTable();
$upsert = imDb()->prepare(
'insert into `t_web_message_edits` (`message_uid`, `editor_uid`, `edited_text`, `edited_at_ms`)'
. ' values (?, ?, ?, ?)'
. ' on duplicate key update'
. ' `editor_uid` = values(`editor_uid`),'
. ' `edited_text` = values(`edited_text`),'
. ' `edited_at_ms` = values(`edited_at_ms`),'
. ' `updated_at` = current_timestamp'
);
$upsert->execute([$messageUid, $operatorId, $text, $editedAt]);
return [
'messageUid' => $messageUid,
'text' => $text,
'editedAt' => $editedAt,
];
}
function fetchWebGroupActionMessage(string $messageUid): array
{
ensureWebImAppTables();
$statement = imAppDb()->prepare(
'select m.*, g.`category` from `t_web_group_messages` m'
. ' join `t_web_groups` g on g.`group_id` = m.`group_id` and g.`deleted` = 0'
. ' where m.`message_uid` = ? limit 1'
);
$statement->execute([$messageUid]);
$row = $statement->fetch();
return is_array($row) ? $row : [];
}
function editWebGroupMessage(string $operatorId, string $messageUid, string $text): array
{
$row = fetchWebGroupActionMessage($messageUid);
if (!$row) {
throw new UserActionException('消息不存在');
}
if ((string)$row['sender_uid'] !== $operatorId) {
throw new UserActionException('只能编辑自己发送的消息');
}
if ((int)($row['recalled'] ?? 0) !== 0) {
throw new UserActionException('已撤回的消息请重新编辑发送');
}
$editedAt = nowTimestampMs();
$update = imAppDb()->prepare(
'update `t_web_group_messages` set `text` = ?, `edited_at_ms` = ? where `message_uid` = ?'
);
$update->execute([$text, $editedAt, $messageUid]);
$groupUpdate = imAppDb()->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
$groupUpdate->execute([$editedAt, (string)$row['group_id']]);
return [
'messageUid' => $messageUid,
'text' => $text,
'editedAt' => $editedAt,
];
}
function webGroupMemberRoleForAction(string $groupId, string $userId): string
{
$statement = imAppDb()->prepare(
'select `role` from `t_web_group_members`'
. ' where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
);
$statement->execute([$groupId, $userId]);
$role = $statement->fetchColumn();
return is_string($role) ? $role : '';
}
function recallWebGroupMessage(string $operatorId, string $messageUid): bool
{
$row = fetchWebGroupActionMessage($messageUid);
if (!$row) {
return false;
}
$isSender = (string)$row['sender_uid'] === $operatorId;
if (!$isSender) {
$role = webGroupMemberRoleForAction((string)$row['group_id'], $operatorId);
if (!in_array($role, ['owner', 'admin'], true)) {
throw new UserActionException('只有群主或管理员可以撤回他人消息');
}
}
assertRecallWithinLimit($messageUid);
$now = nowTimestampMs();
$update = imAppDb()->prepare(
'update `t_web_group_messages` set `recalled` = 1 where `message_uid` = ?'
);
$update->execute([$messageUid]);
$groupUpdate = imAppDb()->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
$groupUpdate->execute([$now, (string)$row['group_id']]);
return true;
}
$payload = readJsonPayload();
$action = trim((string)($payload['action'] ?? ''));
$userId = trim((string)($payload['userId'] ?? ''));
$messageUidText = trim((string)($payload['messageUid'] ?? ''));
$messageUid = ctype_digit($messageUidText) ? (int)$messageUidText : 0;
if ($action === '' || $messageUid <= 0) {
respond(400, '缺少操作类型或消息ID');
}
try {
if ($action === 'recall') {
if ($userId === '') {
respond(400, '缺少操作者ID');
}
$userId = resolveActionUserId($userId);
$row = fetchActionMessageRow($messageUidText);
if (!$row && recallWebGroupMessage($userId, $messageUidText)) {
respond(0, 'success', [
'messageUid' => (string)$messageUid,
'action' => $action,
]);
}
if (!$row) {
throw new UserActionException('消息不存在');
}
assertRecallWithinLimit($messageUidText);
requireImSuccess(imAdminPost('/admin/message/recall', [
'operator' => $userId,
'messageUid' => $messageUid,
'userRecall' => true,
'isUserRecall' => true,
]), '撤回失败');
respond(0, 'success', [
'messageUid' => (string)$messageUid,
'action' => $action,
]);
}
if ($action === 'edit') {
if ($userId === '') {
respond(400, '缺少操作者ID');
}
$text = trim((string)($payload['text'] ?? ''));
if ($text === '') {
respond(400, '编辑内容不能为空');
}
if (mb_strlen($text, 'UTF-8') > 5000) {
respond(400, '编辑内容过长');
}
$userId = resolveActionUserId($userId);
respond(0, 'success', [
'message' => editTextMessage($userId, $messageUidText, $text),
'action' => $action,
]);
}
if ($action === 'delete') {
requireImSuccess(imAdminPost('/admin/message/delete', [
'messageUid' => $messageUid,
]), '删除失败');
respond(0, 'success', [
'messageUid' => (string)$messageUid,
'action' => $action,
]);
}
respond(400, '不支持的消息操作');
} catch (Throwable $error) {
respond($error instanceof UserActionException ? 400 : 500, $error->getMessage() ?: '消息操作失败');
}
+387
View File
@@ -0,0 +1,387 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
function detailPlaceholders(array $values): string
{
return implode(',', array_fill(0, count($values), '?'));
}
function detailTimestampMs(?string $datetime, string $messageId = '0'): int
{
$id = (int)$messageId;
if ($id > 0) {
return ($id >> 21) + 1514736000000;
}
if ($datetime !== null && $datetime !== '') {
$date = DateTimeImmutable::createFromFormat(
'Y-m-d H:i:s',
$datetime,
new DateTimeZone(IM_DB_TIMEZONE)
);
if ($date instanceof DateTimeImmutable) {
return $date->getTimestamp() * 1000;
}
}
return 0;
}
function detailResolveUserId(PDO $db, string $value): string
{
if ($value === '') {
return '';
}
$statement = $db->prepare(
'select `_uid` from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ? limit 1'
);
$statement->execute([$value, $value, $value]);
$uid = $statement->fetchColumn();
return is_string($uid) && $uid !== '' ? $uid : $value;
}
function detailConversationType(mixed $value): string
{
$type = strtolower(trim((string)$value));
return in_array($type, ['1', 'group', 'channel'], true) ? 'group' : 'single';
}
function detailDeliveryMap(PDO $db, array $userIds): array
{
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
if (!$userIds) {
return [];
}
$statement = $db->prepare(
'select `_uid`, max(`_dt`) as `_dt` from `t_delivery_report`'
. ' where `_uid` in (' . detailPlaceholders($userIds) . ') group by `_uid`'
);
$statement->execute($userIds);
$items = [];
foreach ($statement->fetchAll() as $row) {
$items[(string)$row['_uid']] = (int)$row['_dt'];
}
return $items;
}
function detailReadAt(PDO $db, string $readerId, array $targets): int
{
$targets = array_values(array_unique(array_filter(array_map('strval', $targets))));
if ($readerId === '' || !$targets) {
return 0;
}
$statement = $db->prepare(
'select max(`_dt`) from `t_read_report`'
. ' where `_uid` = ? and `_type` = 0 and `_line` = 0'
. ' and `_target` in (' . detailPlaceholders($targets) . ')'
);
$statement->execute(array_merge([$readerId], $targets));
$value = $statement->fetchColumn();
return is_numeric($value) ? (int)$value : 0;
}
function detailSingleMessageVisible(PDO $db, string $userId, string $targetId, string $messageUid): bool
{
$table = imUserMessagesTable($userId);
$conversationKeys = [$userId . '|' . $targetId, $targetId . '|' . $userId];
$statement = $db->prepare(
'select 1 from `' . $table . '`'
. ' where `_uid` = ? and `_mid` = ? and `_type` = 0 and `_line` = 0'
. ' and `_target` in (' . detailPlaceholders($conversationKeys) . ') limit 1'
);
$statement->execute(array_merge([$userId, $messageUid], $conversationKeys));
return (bool)$statement->fetchColumn();
}
function detailUserMap(PDO $imDb, PDO $appDb, array $userIds, string $ownerId, string $groupId = ''): array
{
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
if (!$userIds) {
return [];
}
$statement = $imDb->prepare(
'select `_uid`, `_name`, `_display_name`, `_mobile`, `_portrait` from `t_user`'
. ' where `_uid` in (' . detailPlaceholders($userIds) . ')'
);
$statement->execute($userIds);
$users = [];
foreach ($statement->fetchAll() as $row) {
$uid = (string)$row['_uid'];
$rawName = trim((string)($row['_display_name'] ?? ''))
?: trim((string)($row['_name'] ?? ''))
?: trim((string)($row['_mobile'] ?? ''))
?: $uid;
$users[$uid] = [
'uid' => $uid,
'rawName' => $rawName,
'mobile' => (string)($row['_mobile'] ?? ''),
'portrait' => (string)($row['_portrait'] ?? ''),
'contactAlias' => '',
'remarkAlias' => '',
'groupAlias' => '',
'role' => '',
'displayName' => $rawName,
];
}
if ($ownerId !== '') {
$aliasStatement = $imDb->prepare(
'select `_friend_uid`, `_alias` from `t_friend` where `_uid` = ?'
. ' and `_friend_uid` in (' . detailPlaceholders($userIds) . ')'
);
$aliasStatement->execute(array_merge([$ownerId], $userIds));
foreach ($aliasStatement->fetchAll() as $row) {
$uid = (string)$row['_friend_uid'];
if (isset($users[$uid])) {
$users[$uid]['contactAlias'] = trim((string)($row['_alias'] ?? ''));
}
}
try {
$remarkMap = fetchWebUserRemarkMap($appDb, $ownerId, $userIds);
} catch (Throwable) {
$remarkMap = [];
}
foreach ($remarkMap as $uid => $alias) {
if (isset($users[$uid])) {
$users[$uid]['remarkAlias'] = trim((string)$alias);
}
}
}
if ($groupId !== '') {
$memberStatement = $appDb->prepare(
'select `user_id`, `alias`, `role` from `t_web_group_members`'
. ' where `group_id` = ? and `user_id` in (' . detailPlaceholders($userIds) . ')'
);
$memberStatement->execute(array_merge([$groupId], $userIds));
foreach ($memberStatement->fetchAll() as $row) {
$uid = (string)$row['user_id'];
if (isset($users[$uid])) {
$users[$uid]['groupAlias'] = trim((string)($row['alias'] ?? ''));
$users[$uid]['role'] = (string)($row['role'] ?? '');
}
}
}
foreach ($users as $uid => $user) {
$alias = (string)$user['contactAlias'] !== ''
? (string)$user['contactAlias']
: ((string)$user['remarkAlias'] !== '' ? (string)$user['remarkAlias'] : (string)$user['groupAlias']);
$users[$uid]['displayName'] = $alias !== '' ? $alias : (string)$user['rawName'];
}
foreach ($userIds as $uid) {
if (!isset($users[$uid])) {
$users[$uid] = [
'uid' => $uid,
'rawName' => $uid,
'mobile' => '',
'portrait' => '',
'contactAlias' => '',
'remarkAlias' => '',
'groupAlias' => '',
'role' => '',
'displayName' => $uid,
];
}
}
return $users;
}
function detailMember(array $user, int $time = 0): array
{
return [
'uid' => (string)$user['uid'],
'name' => (string)$user['displayName'],
'rawName' => (string)$user['rawName'],
'mobile' => (string)($user['mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? ''),
'role' => (string)($user['role'] ?? ''),
'time' => $time,
];
}
function singleDetail(PDO $imDb, string $userId, string $targetId, string $messageUid): array
{
if (!preg_match('/^\d+$/', $messageUid)) {
respond(400, '缺少可查询的消息ID');
}
$table = imMessageTable($messageUid);
$statement = $imDb->prepare(
'select `_mid`, `_from`, `_target`, `_dt`, `_content_type`, `_searchable_key`, `_to`'
. ' from `' . $table . '` where `_mid` = ? limit 1'
);
$statement->execute([$messageUid]);
$message = $statement->fetch();
if (!is_array($message)) {
respond(404, '没有找到该消息');
}
$senderId = (string)$message['_from'];
if (!detailSingleMessageVisible($imDb, $userId, $targetId, $messageUid)) {
respond(403, '没有权限查看该消息');
}
$receiverId = $senderId === $userId ? $targetId : $userId;
if ($senderId !== $userId && $senderId !== $targetId) {
respond(403, '没有权限查看该消息');
}
$sentAt = detailTimestampMs((string)($message['_dt'] ?? ''), $messageUid);
$targets = [$senderId, $senderId . '|' . $receiverId, $receiverId . '|' . $senderId];
$deliveredRaw = detailDeliveryMap($imDb, [$receiverId])[$receiverId] ?? 0;
$readRaw = detailReadAt($imDb, $receiverId, $targets);
$users = detailUserMap($imDb, imAppDb(), [$senderId, $receiverId], $userId);
return [
'messageUid' => $messageUid,
'conversationType' => 'single',
'sentAt' => $sentAt,
'sender' => detailMember($users[$senderId]),
'receiver' => detailMember($users[$receiverId]),
'deliveredAt' => $deliveredRaw >= $sentAt ? $deliveredRaw : 0,
'readAt' => $readRaw >= $sentAt ? $readRaw : 0,
];
}
function groupDetail(PDO $imDb, PDO $appDb, string $userId, string $groupId, string $messageUid): array
{
ensureWebImAppTables();
if (!preg_match('/^\d+$/', $messageUid)) {
respond(400, '缺少可查询的消息ID');
}
$myMember = $appDb->prepare(
'select `role` from `t_web_group_members` where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
);
$myMember->execute([$groupId, $userId]);
if (!$myMember->fetchColumn()) {
respond(403, '你不在该群组中');
}
$messageStatement = $appDb->prepare(
'select `message_uid`, `group_id`, `sender_uid`, `created_at_ms`, `edited_at_ms`, `recalled`'
. ' from `t_web_group_messages` where `group_id` = ? and `message_uid` = ? limit 1'
);
$messageStatement->execute([$groupId, $messageUid]);
$message = $messageStatement->fetch();
if (!is_array($message)) {
respond(404, '没有找到该群消息');
}
$sentAt = (int)$message['created_at_ms'];
$editedAt = (int)($message['edited_at_ms'] ?? 0);
$activityAt = max($sentAt, $editedAt);
$senderId = (string)$message['sender_uid'];
$memberStatement = $appDb->prepare(
'select `user_id`, `role`, `alias`, `joined_at_ms` from `t_web_group_members`'
. ' where `group_id` = ? and `status` = 0 and `joined_at_ms` <= ?'
. ' order by (`role` = "owner") desc, (`role` = "admin") desc, `joined_at_ms` asc'
);
$memberStatement->execute([$groupId, $sentAt]);
$members = array_values(array_filter($memberStatement->fetchAll(), static function (array $member) use ($senderId): bool {
return (string)$member['user_id'] !== $senderId;
}));
$memberIds = array_values(array_unique(array_map(static fn(array $member): string => (string)$member['user_id'], $members)));
$users = detailUserMap($imDb, $appDb, array_merge([$senderId], $memberIds), $userId, $groupId);
$deliveryMap = detailDeliveryMap($imDb, $memberIds);
$readMap = [];
if ($memberIds) {
$readStatement = $appDb->prepare(
'select `user_id`, `read_at_ms` from `t_web_group_reads`'
. ' where `group_id` = ? and `user_id` in (' . detailPlaceholders($memberIds) . ')'
);
$readStatement->execute(array_merge([$groupId], $memberIds));
foreach ($readStatement->fetchAll() as $row) {
$readMap[(string)$row['user_id']] = (int)$row['read_at_ms'];
}
}
$read = [];
$unread = [];
$undelivered = [];
foreach ($memberIds as $uid) {
$readAt = (int)($readMap[$uid] ?? 0);
$deliveredAt = (int)($deliveryMap[$uid] ?? 0);
$member = detailMember($users[$uid], $readAt >= $activityAt ? $readAt : ($deliveredAt >= $sentAt ? $deliveredAt : 0));
if ($readAt >= $activityAt) {
$read[] = $member;
} elseif ($deliveredAt >= $sentAt) {
$unread[] = $member;
} else {
$undelivered[] = $member;
}
}
return [
'messageUid' => $messageUid,
'conversationType' => 'group',
'groupId' => $groupId,
'sentAt' => $sentAt,
'editedAt' => $editedAt,
'recalled' => (bool)$message['recalled'],
'sender' => detailMember($users[$senderId] ?? ['uid' => $senderId, 'displayName' => $senderId, 'rawName' => $senderId]),
'group' => [
'read' => $read,
'unread' => $unread,
'undelivered' => $undelivered,
'counts' => [
'read' => count($read),
'unread' => count($unread),
'undelivered' => count($undelivered),
'total' => count($memberIds),
],
],
];
}
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? ''));
$target = trim((string)($payload['target'] ?? ''));
$messageUid = trim((string)($payload['messageUid'] ?? $payload['messageId'] ?? ''));
$conversationType = detailConversationType($payload['conversationType'] ?? $payload['type'] ?? 'single');
if ($userId === '') {
respond(400, '缺少用户ID');
}
if ($target === '') {
respond(400, '缺少会话对象');
}
if ($messageUid === '') {
respond(400, '缺少消息ID');
}
try {
$imDb = imDb();
$appDb = imAppDb();
$userId = detailResolveUserId($imDb, $userId);
if ($conversationType === 'group') {
respond(0, 'success', ['detail' => groupDetail($imDb, $appDb, $userId, $target, $messageUid)]);
}
$targetId = detailResolveUserId($imDb, $target);
respond(0, 'success', ['detail' => singleDetail($imDb, $userId, $targetId, $messageUid)]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '消息详情加载失败');
}
+900
View File
@@ -0,0 +1,900 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
function clampLimit(mixed $value, int $default, int $max): int
{
$limit = (int)$value;
if ($limit <= 0) {
return $default;
}
return min($limit, $max);
}
function placeholders(array $values): string
{
return implode(',', array_fill(0, count($values), '?'));
}
function payloadBool(mixed $value): bool
{
return filter_var($value, FILTER_VALIDATE_BOOLEAN);
}
function timestampMs(?string $datetime, string $messageId = '0'): int
{
$id = (int)$messageId;
if ($id > 0) {
return ($id >> 21) + 1514736000000;
}
if ($datetime !== null && $datetime !== '') {
$date = DateTimeImmutable::createFromFormat(
'Y-m-d H:i:s',
$datetime,
new DateTimeZone(IM_DB_TIMEZONE)
);
if ($date instanceof DateTimeImmutable) {
return $date->getTimestamp() * 1000;
}
}
return (int)floor(microtime(true) * 1000);
}
function nowMs(): int
{
return (int)floor(microtime(true) * 1000);
}
function markUserDelivered(PDO $db, string $userId, int $timestamp): void
{
if ($userId === '' || $timestamp <= 0) {
return;
}
$statement = $db->prepare(
'insert into `t_delivery_report` (`_uid`, `_dt`) values (?, ?)'
. ' on duplicate key update `_dt` = if(`_dt` < values(`_dt`), values(`_dt`), `_dt`)'
);
$statement->execute([$userId, $timestamp]);
}
function markConversationRead(PDO $db, string $userId, string $targetId, int $timestamp): void
{
if ($userId === '' || $targetId === '' || $timestamp <= 0) {
return;
}
$statement = $db->prepare(
'select `id`, `_dt` from `t_read_report`'
. ' where `_uid` = ? and `_type` = 0 and `_line` = 0 and `_target` = ?'
. ' order by `_dt` desc limit 1'
);
$statement->execute([$userId, $targetId]);
$row = $statement->fetch();
if ($row) {
if ($timestamp > (int)$row['_dt']) {
$update = $db->prepare('update `t_read_report` set `_dt` = ? where `id` = ?');
$update->execute([$timestamp, (int)$row['id']]);
}
return;
}
$insert = $db->prepare(
'insert into `t_read_report` (`_uid`, `_type`, `_line`, `_target`, `_dt`) values (?, 0, 0, ?, ?)'
);
$insert->execute([$userId, $targetId, $timestamp]);
}
function resolveUserId(PDO $db, string $value): string
{
if ($value === '') {
return '';
}
$statement = $db->prepare(
'select `_uid` from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ? limit 1'
);
$statement->execute([$value, $value, $value]);
$uid = $statement->fetchColumn();
return is_string($uid) && $uid !== '' ? $uid : $value;
}
function peerFromConversationTarget(string $userId, string $target): string
{
$parts = array_values(array_filter(explode('|', $target), static fn($part) => $part !== ''));
if (count($parts) < 2) {
return $target !== $userId ? $target : '';
}
return $parts[0] === $userId ? $parts[1] : $parts[0];
}
function fetchMessageRows(PDO $db, array $messageIds): array
{
$messageIds = array_values(array_unique(array_filter(array_map('strval', $messageIds))));
if (!$messageIds) {
return [];
}
$idsByTable = [];
foreach ($messageIds as $messageId) {
$idsByTable[imMessageTable($messageId)][] = $messageId;
}
$messages = [];
foreach ($idsByTable as $table => $ids) {
$sql = 'select `_mid`, `_from`, `_type`, `_target`, `_line`, `_searchable_key`, `_dt`, `_content_type`, `_to`'
. ' from `' . $table . '` where `_mid` in (' . placeholders($ids) . ')';
$statement = $db->prepare($sql);
$statement->execute($ids);
foreach ($statement->fetchAll() as $row) {
$messages[(string)$row['_mid']] = $row;
}
}
return $messages;
}
function fetchMessageEditMap(PDO $db, array $messageIds): array
{
$messageIds = array_values(array_unique(array_filter(array_map('strval', $messageIds))));
if (!$messageIds) {
return [];
}
try {
$statement = $db->prepare(
'select `message_uid`, `edited_at_ms` from `t_web_message_edits`'
. ' where `message_uid` in (' . placeholders($messageIds) . ')'
);
$statement->execute($messageIds);
} catch (Throwable) {
return [];
}
$edits = [];
foreach ($statement->fetchAll() as $row) {
$edits[(string)$row['message_uid']] = [
'editedAt' => (int)$row['edited_at_ms'],
];
}
return $edits;
}
function normalizeAttachmentRow(array $row): array
{
return [
'type' => (string)$row['attachment_type'],
'url' => (string)$row['url'],
'name' => (string)($row['name'] ?? ''),
'mime' => (string)($row['mime'] ?? ''),
'size' => (int)($row['size_bytes'] ?? 0),
];
}
function fetchAttachmentMap(PDO $appDb, array $messageIds): array
{
$messageIds = array_values(array_unique(array_filter(array_map('strval', $messageIds))));
if (!$messageIds) {
return [];
}
ensureWebImAppTables();
$statement = $appDb->prepare(
'select `message_uid`, `attachment_type`, `url`, `name`, `mime`, `size_bytes`'
. ' from `t_web_message_attachments`'
. ' where `message_uid` in (' . placeholders($messageIds) . ')'
);
$statement->execute($messageIds);
$attachments = [];
foreach ($statement->fetchAll() as $row) {
$attachments[(string)$row['message_uid']] = normalizeAttachmentRow($row);
}
return $attachments;
}
function attachmentFallbackText(?array $attachment): string
{
if (!$attachment) {
return '';
}
if ((string)$attachment['type'] === 'image') {
return '[图片]';
}
return '[文件] ' . ((string)($attachment['name'] ?? '') ?: '文件');
}
function attachmentDisplayText(string $text, ?array $attachment): string
{
if (!$attachment) {
return $text;
}
return $text === attachmentFallbackText($attachment) ? '' : $text;
}
function messagePreviewText(?array $message): string
{
if (!$message) {
return '';
}
$attachment = is_array($message['attachment'] ?? null) ? $message['attachment'] : null;
if ($attachment) {
$text = trim((string)($message['text'] ?? ''));
return $text !== '' ? $text : attachmentFallbackText($attachment);
}
return (string)($message['text'] ?? '');
}
function fetchUserInfoMap(PDO $db, array $userIds, string $ownerId = ''): array
{
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
if (!$userIds) {
return [];
}
$statement = $db->prepare(
'select `_uid`, `_name`, `_display_name`, `_mobile`, `_portrait`, `_address` from `t_user`'
. ' where `_uid` in (' . placeholders($userIds) . ')'
);
$statement->execute($userIds);
$users = [];
foreach ($statement->fetchAll() as $row) {
$row['_is_friend'] = false;
$row['_alias'] = '';
$row['_friend_alias'] = '';
$row['_remark_alias'] = '';
$users[(string)$row['_uid']] = $row;
}
if ($ownerId !== '') {
$aliasStatement = $db->prepare(
'select `_friend_uid`, `_alias` from `t_friend` where `_uid` = ?'
. ' and `_friend_uid` in (' . placeholders($userIds) . ')'
);
$aliasStatement->execute(array_merge([$ownerId], $userIds));
foreach ($aliasStatement->fetchAll() as $row) {
$friendUid = (string)$row['_friend_uid'];
if (!isset($users[$friendUid])) {
continue;
}
$users[$friendUid]['_is_friend'] = true;
$users[$friendUid]['_friend_alias'] = trim((string)($row['_alias'] ?? ''));
}
try {
ensureWebImAppTables();
$remarkMap = fetchWebUserRemarkMap(imAppDb(), $ownerId, $userIds);
} catch (Throwable) {
$remarkMap = [];
}
foreach ($remarkMap as $targetUid => $alias) {
if (!isset($users[$targetUid])) {
continue;
}
$users[$targetUid]['_remark_alias'] = $alias;
}
foreach ($users as $uid => $user) {
$friendAlias = trim((string)($user['_friend_alias'] ?? ''));
$remarkAlias = trim((string)($user['_remark_alias'] ?? ''));
$users[$uid]['_alias'] = $friendAlias !== '' ? $friendAlias : $remarkAlias;
}
}
return $users;
}
function userTitle(array $users, string $userId): string
{
$user = $users[$userId] ?? null;
if (!$user) {
return $userId;
}
return (string)($user['_alias'] ?? '')
?: (string)($user['_display_name'] ?? '')
?: (string)($user['_name'] ?? '')
?: (string)($user['_mobile'] ?? '')
?: $userId;
}
function loadPeerReceiptState(PDO $db, string $currentUserId, string $peerId): array
{
$deliveryStatement = $db->prepare('select `_dt` from `t_delivery_report` where `_uid` = ? limit 1');
$deliveryStatement->execute([$peerId]);
$deliveredAt = $deliveryStatement->fetchColumn();
$readTargets = array_values(array_unique([
$currentUserId,
$currentUserId . '|' . $peerId,
$peerId . '|' . $currentUserId,
]));
$readStatement = $db->prepare(
'select max(`_dt`) from `t_read_report`'
. ' where `_uid` = ? and `_type` = 0 and `_line` = 0'
. ' and `_target` in (' . placeholders($readTargets) . ')'
);
$readStatement->execute(array_merge([$peerId], $readTargets));
$readAt = $readStatement->fetchColumn();
return [
'deliveredAt' => is_numeric($deliveredAt) ? (int)$deliveredAt : 0,
'readAt' => is_numeric($readAt) ? (int)$readAt : 0,
];
}
function loadOwnReadStateMap(PDO $db, string $userId, array $peerIds): array
{
$peerIds = array_values(array_unique(array_filter(array_map('strval', $peerIds))));
if (!$peerIds) {
return [];
}
$statement = $db->prepare(
'select `_target`, max(`_dt`) as `_read_at` from `t_read_report`'
. ' where `_uid` = ? and `_type` = 0 and `_line` = 0'
. ' and `_target` in (' . placeholders($peerIds) . ')'
. ' group by `_target`'
);
$statement->execute(array_merge([$userId], $peerIds));
$readAtByPeer = [];
foreach ($statement->fetchAll() as $row) {
$readAtByPeer[(string)$row['_target']] = (int)$row['_read_at'];
}
return $readAtByPeer;
}
function outgoingDeliveryStatus(int $timestamp, array $receiptState): string
{
$readAt = (int)($receiptState['readAt'] ?? 0);
if ($readAt > 0 && $timestamp <= $readAt) {
return 'read';
}
$deliveredAt = (int)($receiptState['deliveredAt'] ?? 0);
if ($deliveredAt > 0 && $timestamp <= $deliveredAt) {
return 'delivered';
}
return 'sent';
}
function messageActivityTimestamp(int $timestamp, array $editState = []): int
{
return max($timestamp, (int)($editState['editedAt'] ?? 0));
}
function normalizeConversationType(mixed $value): string
{
$type = strtolower(trim((string)$value));
if (in_array($type, ['1', 'group', 'channel'], true)) {
return 'group';
}
return 'single';
}
function isWebGroupTarget(PDO $appDb, string $targetId): bool
{
if ($targetId === '') {
return false;
}
ensureWebImAppTables();
$statement = $appDb->prepare('select 1 from `t_web_groups` where `group_id` = ? and `deleted` = 0 limit 1');
$statement->execute([$targetId]);
return (bool)$statement->fetchColumn();
}
function markWebGroupRead(PDO $appDb, string $userId, string $groupId, int $timestamp): void
{
if ($userId === '' || $groupId === '' || $timestamp <= 0) {
return;
}
$now = nowMs();
$statement = $appDb->prepare(
'insert into `t_web_group_reads` (`group_id`, `user_id`, `read_at_ms`, `updated_at_ms`)'
. ' values (?, ?, ?, ?)'
. ' on duplicate key update'
. ' `read_at_ms` = if(`read_at_ms` < values(`read_at_ms`), values(`read_at_ms`), `read_at_ms`),'
. ' `updated_at_ms` = values(`updated_at_ms`)'
);
$statement->execute([$groupId, $userId, $timestamp, $now]);
}
function webGroupRole(PDO $appDb, string $userId, string $groupId): string
{
$statement = $appDb->prepare(
'select `role` from `t_web_group_members`'
. ' where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
);
$statement->execute([$groupId, $userId]);
$role = $statement->fetchColumn();
return is_string($role) && $role !== '' ? $role : '';
}
function formatWebGroupMessage(
array $row,
string $currentUserId,
array $group,
?array $attachment = null,
array $users = []
): array
{
$groupId = (string)$row['group_id'];
$from = (string)$row['sender_uid'];
$senderUser = $users[$from] ?? [];
$senderAlias = trim((string)($row['sender_alias'] ?? ''));
$senderContactAlias = trim((string)($senderUser['_alias'] ?? ''));
$senderFriendAlias = trim((string)($senderUser['_friend_alias'] ?? ''));
$senderRemarkAlias = trim((string)($senderUser['_remark_alias'] ?? ''));
$senderRawName = trim((string)($senderUser['_display_name'] ?? ''))
?: trim((string)($senderUser['_name'] ?? ''))
?: trim((string)($senderUser['_mobile'] ?? ''))
?: $from;
$senderName = $senderContactAlias
?: $senderAlias
?: $senderRawName;
$senderRole = (string)($row['sender_role'] ?? 'member');
if (!in_array($senderRole, ['owner', 'admin'], true)) {
$senderRole = 'member';
}
$timestamp = (int)$row['created_at_ms'];
$editedAt = (int)($row['edited_at_ms'] ?? 0);
$activityTimestamp = max($timestamp, $editedAt);
$direction = $from === $currentUserId ? 'out' : 'in';
$messageType = $attachment ? (string)$attachment['type'] : 'text';
$contentType = $messageType === 'image' ? 3 : ($messageType === 'file' ? 5 : 1);
return [
'id' => (string)$row['message_uid'],
'messageUid' => (string)$row['message_uid'],
'conversationId' => 'group-' . $groupId,
'from' => $from,
'senderRole' => $senderRole,
'senderName' => $senderName,
'senderPortrait' => (string)($senderUser['_portrait'] ?? ''),
'senderProfile' => [
'uid' => $from,
'displayName' => $senderName,
'rawName' => $senderRawName,
'contactAlias' => $senderContactAlias,
'friendAlias' => $senderFriendAlias,
'remarkAlias' => $senderRemarkAlias,
'groupAlias' => $senderAlias,
'isContact' => (bool)($senderUser['_is_friend'] ?? false),
'mobile' => (string)($senderUser['_mobile'] ?? ''),
'address' => (string)($senderUser['_address'] ?? ''),
'portrait' => (string)($senderUser['_portrait'] ?? ''),
'role' => $senderRole,
],
'to' => $groupId,
'target' => $groupId,
'direction' => $direction,
'type' => $messageType,
'contentType' => $contentType,
'text' => attachmentDisplayText((string)$row['text'], $attachment),
'attachment' => $attachment,
'status' => $direction === 'out' ? 'sent' : 'sent',
'deliveryStatus' => $direction === 'out' ? 'sent' : '',
'recalled' => false,
'recallStatus' => '',
'editedAt' => $editedAt,
'timestamp' => $timestamp,
'activityTimestamp' => $activityTimestamp,
'displayTimestamp' => $timestamp,
'conversationType' => (string)($group['category'] ?? 'group'),
];
}
function loadWebGroupMessages(PDO $appDb, string $userId, string $groupId, int $limit, bool $markRead): array
{
ensureWebImAppTables();
$role = webGroupRole($appDb, $userId, $groupId);
if ($role === '') {
return [];
}
$groupStatement = $appDb->prepare('select * from `t_web_groups` where `group_id` = ? and `deleted` = 0 limit 1');
$groupStatement->execute([$groupId]);
$group = $groupStatement->fetch();
if (!$group) {
return [];
}
$statement = $appDb->prepare(
'select msg.*,'
. ' coalesce(sender_member.`role`, if(g.`owner_uid` = msg.`sender_uid`, "owner", "member")) as `sender_role`,'
. ' coalesce(sender_member.`alias`, "") as `sender_alias`'
. ' from `t_web_group_messages` msg'
. ' join `t_web_groups` g on g.`group_id` = msg.`group_id` and g.`deleted` = 0'
. ' left join `t_web_group_members` sender_member'
. ' on sender_member.`group_id` = msg.`group_id`'
. ' and sender_member.`user_id` = msg.`sender_uid`'
. ' and sender_member.`status` = 0'
. ' where msg.`group_id` = ? and msg.`recalled` = 0'
. ' order by msg.`created_at_ms` desc, msg.`id` desc limit ' . $limit
);
$statement->execute([$groupId]);
$rows = array_reverse($statement->fetchAll());
$attachmentMap = fetchAttachmentMap($appDb, array_column($rows, 'message_uid'));
$users = fetchUserInfoMap(imDb(), array_column($rows, 'sender_uid'), $userId);
$readTimestamp = 0;
$messages = [];
foreach ($rows as $row) {
$messageId = (string)$row['message_uid'];
$message = formatWebGroupMessage($row, $userId, $group, $attachmentMap[$messageId] ?? null, $users);
$readTimestamp = max($readTimestamp, (int)$message['activityTimestamp']);
$messages[] = $message;
}
if ($markRead) {
markWebGroupRead($appDb, $userId, $groupId, $readTimestamp);
}
return $messages;
}
function loadWebGroupConversations(PDO $appDb, string $userId, int $limit): array
{
ensureWebImAppTables();
$statement = $appDb->prepare(
'select g.*, m.`role`'
. ' from `t_web_groups` g'
. ' join `t_web_group_members` m on m.`group_id` = g.`group_id` and m.`status` = 0'
. ' where g.`deleted` = 0 and m.`user_id` = ?'
. ' order by g.`updated_at_ms` desc limit ' . min(max($limit, 50), 100)
);
$statement->execute([$userId]);
$groups = $statement->fetchAll();
if (!$groups) {
return [];
}
$lastMessageStatement = $appDb->prepare(
'select * from `t_web_group_messages`'
. ' where `group_id` = ? and `recalled` = 0'
. ' order by greatest(`created_at_ms`, `edited_at_ms`) desc, `id` desc limit 1'
);
$readStatement = $appDb->prepare(
'select `read_at_ms` from `t_web_group_reads` where `group_id` = ? and `user_id` = ? limit 1'
);
$lastAttachmentStatement = $appDb->prepare(
'select `attachment_type`, `url`, `name`, `mime`, `size_bytes`'
. ' from `t_web_message_attachments` where `message_uid` = ? limit 1'
);
$unreadStatement = $appDb->prepare(
'select count(*) from `t_web_group_messages`'
. ' where `group_id` = ? and `sender_uid` <> ? and `recalled` = 0'
. ' and greatest(`created_at_ms`, `edited_at_ms`) > ?'
);
$memberCountStatement = $appDb->prepare(
'select count(*) from `t_web_group_members` where `group_id` = ? and `status` = 0'
);
$conversations = [];
foreach ($groups as $group) {
$groupId = (string)$group['group_id'];
$lastMessageStatement->execute([$groupId]);
$lastMessage = $lastMessageStatement->fetch();
$readStatement->execute([$groupId, $userId]);
$readAt = (int)($readStatement->fetchColumn() ?: 0);
$unreadStatement->execute([$groupId, $userId, $readAt]);
$unread = (int)$unreadStatement->fetchColumn();
$memberCountStatement->execute([$groupId]);
$memberCount = (int)$memberCountStatement->fetchColumn();
$title = (string)$group['name'];
$category = (string)$group['category'];
$isChannel = $category === 'channel';
$lastAttachment = null;
if ($lastMessage) {
$lastAttachmentStatement->execute([(string)$lastMessage['message_uid']]);
$lastAttachmentRow = $lastAttachmentStatement->fetch();
$lastAttachment = $lastAttachmentRow ? normalizeAttachmentRow($lastAttachmentRow) : null;
}
$subtitle = $lastMessage
? (messagePreviewText([
'text' => attachmentDisplayText((string)$lastMessage['text'], $lastAttachment),
'attachment' => $lastAttachment,
]) ?: (string)$lastMessage['text'])
: ($isChannel ? '公告群' : ($memberCount > 0 ? $memberCount . ' 位成员' : '群组'));
$updatedAt = $lastMessage
? max((int)$lastMessage['created_at_ms'], (int)($lastMessage['edited_at_ms'] ?? 0))
: (int)$group['updated_at_ms'];
$conversations[] = [
'id' => 'group-' . $groupId,
'type' => $isChannel ? 'channel' : 'group',
'target' => $groupId,
'title' => $title,
'subtitle' => $subtitle,
'unread' => $unread,
'avatarText' => mb_strtoupper(mb_substr($title, 0, 1, 'UTF-8'), 'UTF-8'),
'updatedAt' => $updatedAt,
'role' => (string)$group['role'],
'memberCount' => $memberCount,
'raw' => [
'type' => 'group',
'target' => $groupId,
'line' => 0,
'category' => $category,
'role' => (string)$group['role'],
],
];
}
return $conversations;
}
function isRecallMessage(array $row): bool
{
return (int)($row['_content_type'] ?? 0) === 80;
}
function shouldDisplayMessageForUser(array $row, string $currentUserId): bool
{
if (!isRecallMessage($row)) {
return true;
}
return (string)$row['_from'] === $currentUserId;
}
function formatMessage(
array $row,
string $currentUserId,
string $peerId,
array $receiptState = [],
array $editState = [],
?array $attachment = null
): array
{
$messageId = (string)$row['_mid'];
$from = (string)$row['_from'];
$contentType = (int)($row['_content_type'] ?? 0);
$isRecalled = $contentType === 80;
$text = (string)($row['_searchable_key'] ?? '');
$timestamp = timestampMs((string)($row['_dt'] ?? ''), $messageId);
$direction = $from === $currentUserId ? 'out' : 'in';
$messageType = $attachment ? (string)$attachment['type'] : (($contentType === 1 || $isRecalled) ? 'text' : 'unknown');
$messageText = $text !== '' ? $text : ($isRecalled || $attachment ? '' : '[暂不支持的消息类型]');
$messageText = attachmentDisplayText($messageText, $attachment);
$editedAt = (int)($editState['editedAt'] ?? 0);
$activityTimestamp = messageActivityTimestamp($timestamp, $editState);
$deliveryStatus = $direction === 'out' && !$isRecalled
? outgoingDeliveryStatus($activityTimestamp, $receiptState)
: '';
return [
'id' => $messageId,
'messageUid' => $messageId,
'conversationId' => 'single-' . $peerId,
'from' => $from,
'to' => $from === $currentUserId ? $peerId : $currentUserId,
'target' => $peerId,
'direction' => $direction,
'type' => $messageType,
'contentType' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : $contentType,
'text' => $messageText,
'attachment' => $attachment,
'status' => $isRecalled && $direction === 'out' ? 'recalled' : ($direction === 'out' ? $deliveryStatus : 'sent'),
'deliveryStatus' => $deliveryStatus,
'recalled' => $isRecalled,
'recallStatus' => $isRecalled ? '已撤回' : '',
'editedAt' => $editedAt,
'timestamp' => $timestamp,
'activityTimestamp' => $activityTimestamp,
'displayTimestamp' => $timestamp,
];
}
function loadConversationMessages(PDO $db, string $userId, string $targetId, int $limit, bool $markRead): array
{
$table = imUserMessagesTable($userId);
$conversationKeys = [$userId . '|' . $targetId, $targetId . '|' . $userId];
$scanLimit = min(max($limit * 4, $limit), 400);
$statement = $db->prepare(
'select `_mid`, `_dt` from `' . $table . '`'
. ' where `_uid` = ? and `_type` = 0 and `_line` = 0'
. ' and `_target` in (?, ?)'
. ' order by `_mid` desc limit ' . $scanLimit
);
$statement->execute([$userId, $conversationKeys[0], $conversationKeys[1]]);
$userRows = $statement->fetchAll();
$messageRows = fetchMessageRows($db, array_column($userRows, '_mid'));
$editMap = fetchMessageEditMap($db, array_column($userRows, '_mid'));
$attachmentMap = fetchAttachmentMap(imAppDb(), array_column($userRows, '_mid'));
$receiptState = loadPeerReceiptState($db, $userId, $targetId);
$messages = [];
$seenMessageIds = [];
$readTimestamp = 0;
foreach ($userRows as $userRow) {
$messageId = (string)$userRow['_mid'];
if (isset($seenMessageIds[$messageId]) || !isset($messageRows[$messageId])) {
continue;
}
$seenMessageIds[$messageId] = true;
if (!shouldDisplayMessageForUser($messageRows[$messageId], $userId)) {
continue;
}
$message = formatMessage(
$messageRows[$messageId],
$userId,
$targetId,
$receiptState,
$editMap[$messageId] ?? [],
$attachmentMap[$messageId] ?? null
);
$messages[] = $message;
$readTimestamp = max($readTimestamp, (int)$message['activityTimestamp']);
if (count($messages) >= $limit) {
break;
}
}
$messages = array_reverse($messages);
if ($markRead) {
markConversationRead($db, $userId, $targetId, $readTimestamp);
}
return $messages;
}
function loadRecentConversations(PDO $db, string $userId, int $limit): array
{
$table = imUserMessagesTable($userId);
$scanLimit = min(max($limit * 20, 200), 1000);
$statement = $db->prepare(
'select `_mid`, `_target`, `_dt` from `' . $table . '`'
. ' where `_uid` = ? and `_type` = 0 and `_line` = 0'
. ' order by `_mid` desc limit ' . $scanLimit
);
$statement->execute([$userId]);
$scanRows = $statement->fetchAll();
$messageRows = fetchMessageRows($db, array_column($scanRows, '_mid'));
$editMap = fetchMessageEditMap($db, array_column($scanRows, '_mid'));
$attachmentMap = fetchAttachmentMap(imAppDb(), array_column($scanRows, '_mid'));
$latestByPeer = [];
foreach ($scanRows as $row) {
$messageId = (string)$row['_mid'];
$message = $messageRows[$messageId] ?? null;
$peerId = peerFromConversationTarget($userId, (string)$row['_target']);
if ($peerId === '' || !$message) {
continue;
}
if (!shouldDisplayMessageForUser($message, $userId)) {
continue;
}
$activityTimestamp = messageActivityTimestamp(
timestampMs((string)($message['_dt'] ?? ''), $messageId),
$editMap[$messageId] ?? []
);
if (!isset($latestByPeer[$peerId]) || $activityTimestamp > (int)($latestByPeer[$peerId]['_activity_at'] ?? 0)) {
$row['_activity_at'] = $activityTimestamp;
$latestByPeer[$peerId] = $row;
}
}
uasort($latestByPeer, static fn($a, $b) => (int)($b['_activity_at'] ?? 0) <=> (int)($a['_activity_at'] ?? 0));
$latestByPeer = array_slice($latestByPeer, 0, $limit, true);
$users = fetchUserInfoMap($db, array_keys($latestByPeer), $userId);
$readAtByPeer = loadOwnReadStateMap($db, $userId, array_keys($latestByPeer));
$unreadByPeer = [];
$seenUnreadMessageIds = [];
foreach ($scanRows as $row) {
$messageId = (string)$row['_mid'];
if (isset($seenUnreadMessageIds[$messageId])) {
continue;
}
$seenUnreadMessageIds[$messageId] = true;
$peerId = peerFromConversationTarget($userId, (string)$row['_target']);
$message = $messageRows[$messageId] ?? null;
if ($peerId === '' || !$message || !isset($latestByPeer[$peerId])) {
continue;
}
if ((string)$message['_from'] === $userId) {
continue;
}
if (isRecallMessage($message)) {
continue;
}
$timestamp = timestampMs((string)($message['_dt'] ?? ''), $messageId);
$activityTimestamp = messageActivityTimestamp($timestamp, $editMap[$messageId] ?? []);
if ($activityTimestamp > (int)($readAtByPeer[$peerId] ?? 0)) {
$unreadByPeer[$peerId] = (int)($unreadByPeer[$peerId] ?? 0) + 1;
}
}
$conversations = [];
foreach ($latestByPeer as $peerId => $userRow) {
$messageId = (string)$userRow['_mid'];
$message = isset($messageRows[$messageId])
? formatMessage($messageRows[$messageId], $userId, $peerId, [], $editMap[$messageId] ?? [], $attachmentMap[$messageId] ?? null)
: null;
$title = userTitle($users, $peerId);
$conversations[] = [
'id' => 'single-' . $peerId,
'type' => 'single',
'target' => $peerId,
'title' => $title,
'subtitle' => $message ? (messagePreviewText($message) !== '' ? messagePreviewText($message) : (empty($message['recalled']) ? '' : '已撤回')) : '',
'unread' => (int)($unreadByPeer[$peerId] ?? 0),
'portrait' => (string)($users[$peerId]['_portrait'] ?? ''),
'avatarText' => mb_strtoupper(mb_substr($title, 0, 1, 'UTF-8'), 'UTF-8'),
'updatedAt' => $message ? $message['activityTimestamp'] : timestampMs((string)($userRow['_dt'] ?? ''), $messageId),
'raw' => ['type' => 'single', 'target' => $peerId, 'line' => 0],
];
}
try {
$conversations = array_merge($conversations, loadWebGroupConversations(imAppDb(), $userId, $limit));
} catch (Throwable) {
// Keep single-chat sync available if appdata group metadata is temporarily unavailable.
}
usort($conversations, static fn($a, $b) => $b['updatedAt'] <=> $a['updatedAt']);
return array_slice($conversations, 0, $limit);
}
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? ''));
$target = trim((string)($payload['target'] ?? ''));
$limit = clampLimit($payload['limit'] ?? 50, 50, 100);
$markRead = payloadBool($payload['markRead'] ?? false);
$conversationType = normalizeConversationType($payload['conversationType'] ?? $payload['type'] ?? 0);
if ($userId === '') {
respond(400, '缺少用户ID');
}
try {
$db = imDb();
$userId = resolveUserId($db, $userId);
markUserDelivered($db, $userId, nowMs());
if ($target !== '') {
$targetId = resolveUserId($db, $target);
if ($conversationType === 'group' || isWebGroupTarget(imAppDb(), $target)) {
respond(0, 'success', [
'messages' => loadWebGroupMessages(imAppDb(), $userId, $target, $limit, $markRead),
]);
}
respond(0, 'success', [
'messages' => loadConversationMessages($db, $userId, $targetId, $limit, $markRead),
]);
}
respond(0, 'success', [
'conversations' => loadRecentConversations($db, $userId, $limit),
]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '消息同步失败');
}
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
$defaultName = 'Web IM';
$defaultLogo = '/img/logo.png';
try {
$statement = imAppDb()->prepare(
'select `conf_name`, `conf_value`, `updated_at` from `system_conf` where `conf_name` in (?, ?)'
);
$statement->execute(['PlatformName', 'sys_logo']);
$config = [];
$updatedAtValues = [];
foreach ($statement->fetchAll() as $row) {
$config[(string)$row['conf_name']] = trim((string)($row['conf_value'] ?? ''));
$updatedAtValues[] = (string)($row['updated_at'] ?? '');
}
$platformName = $config['PlatformName'] ?? '';
$logo = $config['sys_logo'] ?? '';
$version = implode('|', array_values(array_filter($updatedAtValues ?? [])));
respond(0, 'success', [
'platformName' => $platformName !== '' ? $platformName : $defaultName,
'logo' => $logo !== '' ? $logo : $defaultLogo,
'version' => $version,
]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '平台配置加载失败', [
'platformName' => $defaultName,
'logo' => $defaultLogo,
]);
}
+321
View File
@@ -0,0 +1,321 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
const PROFILE_DISPLAY_NAME_FLAG = 0x01;
const PROFILE_PORTRAIT_FLAG = 0x02;
const PROFILE_PORTRAIT_MEDIA_TYPE = 5;
const IM_LOCAL_MEDIA_ROOT = '/opt/im-server/media';
const IM_LOCAL_MEDIA_PUBLIC_PORT = 18000;
function profileRequestValue(string $key, string $default = ''): string
{
return trim((string)($_POST[$key] ?? $_GET[$key] ?? $default));
}
function normalizeProfileUser(array $user): array
{
$uid = (string)($user['userId'] ?? $user['uid'] ?? $user['_uid'] ?? '');
return [
'uid' => $uid,
'userId' => $uid,
'name' => (string)($user['name'] ?? $user['_name'] ?? ''),
'displayName' => (string)($user['displayName'] ?? $user['_display_name'] ?? $user['name'] ?? $user['_name'] ?? $uid),
'mobile' => (string)($user['mobile'] ?? $user['_mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? $user['_portrait'] ?? ''),
'address' => (string)($user['address'] ?? $user['_address'] ?? ''),
];
}
function tryProfileUserInfo(array $payload): ?array
{
try {
$result = requireImSuccess(imAdminPost('/admin/user/get_info', $payload), '用户资料加载失败');
return $result ?: null;
} catch (Throwable) {
return null;
}
}
function loadProfileUserFromDb(string $identity): ?array
{
$statement = imDb()->prepare(
'select `_uid`, `_name`, `_display_name`, `_mobile`, `_portrait`, `_address`'
. ' from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ?'
. ' order by case when `_uid` = ? then 0 when `_mobile` = ? then 1 when `_name` = ? then 2 else 3 end'
. ' limit 1'
);
$statement->execute([$identity, $identity, $identity, $identity, $identity, $identity]);
$row = $statement->fetch();
return $row ? normalizeProfileUser($row) : null;
}
function mergeProfileUser(array $primary, ?array $fallback): array
{
if (!$fallback) {
return $primary;
}
$fallback = normalizeProfileUser($fallback);
foreach (['name', 'displayName', 'mobile', 'portrait', 'address'] as $key) {
if ((string)($primary[$key] ?? '') === '' && (string)($fallback[$key] ?? '') !== '') {
$primary[$key] = (string)$fallback[$key];
}
}
return $primary;
}
function persistProfileUserToDb(string $userId, string $displayName, string $portrait): void
{
if ($portrait !== '') {
$statement = imDb()->prepare(
'update `t_user` set `_display_name` = ?, `_portrait` = ?, `_dt` = ? where `_uid` = ? limit 1'
);
$statement->execute([$displayName, $portrait, webImNowMs(), $userId]);
return;
}
$statement = imDb()->prepare(
'update `t_user` set `_display_name` = ?, `_dt` = ? where `_uid` = ? limit 1'
);
$statement->execute([$displayName, webImNowMs(), $userId]);
}
function loadProfileUser(string $identity, bool $preferMobile = true): array
{
$identity = trim($identity);
if ($identity === '') {
respond(400, '缺少用户ID');
}
$dbUser = loadProfileUserFromDb($identity);
if ($dbUser) {
$fallback = null;
if ((string)($dbUser['portrait'] ?? '') === '') {
$fallback = tryProfileUserInfo(['userId' => $dbUser['userId']]);
if (!$fallback && (string)($dbUser['mobile'] ?? '') !== '') {
$fallback = tryProfileUserInfo(['mobile' => $dbUser['mobile']]);
}
if (!$fallback && (string)($dbUser['name'] ?? '') !== '') {
$fallback = tryProfileUserInfo(['name' => $dbUser['name']]);
}
}
return mergeProfileUser($dbUser, $fallback);
}
$user = null;
if ($preferMobile && preg_match('/^\+?\d{5,32}$/', $identity)) {
$user = tryProfileUserInfo(['mobile' => $identity]);
}
if (!$user) {
$user = tryProfileUserInfo(['userId' => $identity]);
}
if (!$user) {
$user = tryProfileUserInfo(['name' => $identity]);
}
if (!$user) {
respond(404, '用户资料不存在');
}
return normalizeProfileUser($user);
}
function readProfileJavaSerializedString(string $data, int $offset): ?array
{
if ($offset + 3 > strlen($data) || ord($data[$offset]) !== 0x74) {
return null;
}
$length = (ord($data[$offset + 1]) << 8) | ord($data[$offset + 2]);
$start = $offset + 3;
if ($start + $length > strlen($data)) {
return null;
}
return [substr($data, $start, $length), $start + $length];
}
function profileSessionUserId(string $sessionData): string
{
$marker = "\x74\x00\x06userId";
$offset = strpos($sessionData, $marker);
if ($offset === false) {
return '';
}
$decoded = readProfileJavaSerializedString($sessionData, $offset + strlen($marker));
$userId = is_array($decoded) ? (string)$decoded[0] : '';
if (!preg_match('/^[A-Za-z0-9_:@.+-]{1,128}$/', $userId)) {
return '';
}
return $userId;
}
function requireProfileUpdateAuth(string $targetUserId): void
{
$authToken = trim((string)($_SERVER['HTTP_AUTHTOKEN'] ?? $_SERVER['HTTP_AUTH_TOKEN'] ?? ''));
if ($authToken === '') {
respond(401, '请重新登录后再修改资料');
}
$statement = imAppDb()->prepare('select `session_data` from `shiro_session` where `session_id` = ? limit 1');
$statement->execute([$authToken]);
$row = $statement->fetch();
if (!$row) {
respond(401, '登录状态已失效,请重新登录');
}
$sessionUserId = profileSessionUserId((string)$row['session_data']);
if ($sessionUserId === '' || $sessionUserId !== $targetUserId) {
respond(403, '只能修改自己的资料');
}
}
function profileUploadedFileExtension(string $fileName, string $mime): string
{
$extension = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
$extension = preg_replace('/[^a-z0-9]/', '', $extension) ?: '';
if ($extension === 'jpeg') {
$extension = 'jpg';
}
$allowedExtensions = ['jpg', 'png', 'gif', 'webp', 'bmp', 'ico'];
if (in_array($extension, $allowedExtensions, true)) {
return $extension;
}
return match ($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
'image/bmp', 'image/x-ms-bmp' => 'bmp',
'image/x-icon', 'image/vnd.microsoft.icon' => 'ico',
default => 'jpg',
};
}
function profilePublicMediaBaseUrl(): string
{
$isHttps = (string)($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string)$_SERVER['HTTPS']) !== 'off';
$scheme = $isHttps ? 'https' : 'http';
$host = (string)($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '127.0.0.1');
$host = trim(preg_replace('/:\d+$/', '', $host) ?: '127.0.0.1');
return $scheme . '://' . $host . ':' . IM_LOCAL_MEDIA_PUBLIC_PORT;
}
function uploadProfilePortrait(array $file): string
{
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new RuntimeException('头像上传失败');
}
if (($file['size'] ?? 0) <= 0 || ($file['size'] ?? 0) > 5 * 1024 * 1024) {
throw new RuntimeException('头像不能超过5MB');
}
$tmpName = (string)($file['tmp_name'] ?? '');
$mime = mime_content_type($tmpName) ?: '';
if (!str_starts_with($mime, 'image/')) {
throw new RuntimeException('头像必须是图片文件');
}
if (!is_dir(IM_LOCAL_MEDIA_ROOT) || !is_writable(IM_LOCAL_MEDIA_ROOT)) {
throw new RuntimeException('头像存储目录不可写');
}
$datePath = 'fs/' . PROFILE_PORTRAIT_MEDIA_TYPE . '/' . date('Y/m/d/H/i/s');
$targetDir = rtrim(IM_LOCAL_MEDIA_ROOT, '/') . '/' . $datePath;
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) {
throw new RuntimeException('头像存储目录创建失败');
}
$extension = profileUploadedFileExtension((string)($file['name'] ?? 'avatar.jpg'), $mime);
$targetName = bin2hex(random_bytes(12)) . '.' . $extension;
$targetPath = $targetDir . '/' . $targetName;
if (!move_uploaded_file($tmpName, $targetPath)) {
throw new RuntimeException('头像文件保存失败');
}
@chmod($targetPath, 0644);
return profilePublicMediaBaseUrl() . '/' . $datePath . '/' . $targetName;
}
try {
$contentType = (string)($_SERVER['CONTENT_TYPE'] ?? '');
$isMultipart = stripos($contentType, 'multipart/form-data') !== false;
$payload = $isMultipart ? [] : readJsonPayload();
$action = $isMultipart
? profileRequestValue('action', 'update')
: trim((string)($payload['action'] ?? 'get'));
$userId = $isMultipart
? profileRequestValue('userId')
: trim((string)($payload['userId'] ?? $_GET['userId'] ?? ''));
$currentProfile = loadProfileUser($userId);
if ($action === 'get') {
respond(0, 'success', ['profile' => $currentProfile]);
}
if ($action !== 'update') {
respond(400, '不支持的资料操作');
}
requireProfileUpdateAuth((string)$currentProfile['userId']);
$displayName = $isMultipart
? profileRequestValue('displayName')
: trim((string)($payload['displayName'] ?? ''));
if ($displayName === '') {
respond(400, '昵称不能为空');
}
if (mb_strlen($displayName, 'UTF-8') > 64) {
respond(400, '昵称不能超过64个字');
}
$portrait = '';
if (!empty($_FILES['portraitFile']) && ($_FILES['portraitFile']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
$portrait = uploadProfilePortrait($_FILES['portraitFile']);
}
$flag = PROFILE_DISPLAY_NAME_FLAG;
$userInfo = [
'userId' => $currentProfile['userId'],
'displayName' => $displayName,
];
if ($portrait !== '') {
$flag |= PROFILE_PORTRAIT_FLAG;
$userInfo['portrait'] = $portrait;
}
requireImSuccess(
imAdminPost('/admin/user/update', [
'flag' => $flag,
'userInfo' => $userInfo,
]),
'用户资料保存失败'
);
persistProfileUserToDb($currentProfile['userId'], $displayName, $portrait);
$updatedProfile = loadProfileUser($currentProfile['userId'], false);
if ($portrait === '' && (string)$updatedProfile['portrait'] === '' && (string)($currentProfile['portrait'] ?? '') !== '') {
$updatedProfile['portrait'] = (string)$currentProfile['portrait'];
}
if ($portrait !== '' && (string)$updatedProfile['portrait'] === '') {
$updatedProfile['portrait'] = $portrait;
}
if ((string)$updatedProfile['displayName'] === '') {
$updatedProfile['displayName'] = $displayName;
}
respond(0, 'success', ['profile' => $updatedProfile]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '用户资料服务暂不可用');
}
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
echo json_encode([
'code' => 0,
'message' => 'success',
'data' => [
'methods' => [
'phone' => true,
'email' => false,
'username' => false,
],
'mobile_real_verify' => false,
],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+411
View File
@@ -0,0 +1,411 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
const WEB_MEDIA_ROOT = '/opt/im-server/media';
const WEB_MEDIA_PUBLIC_PORT = 18000;
const WEB_MEDIA_TYPE_IMAGE = 1;
const WEB_MEDIA_TYPE_FILE = 4;
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
function isDailyMuteActive(string $dailyStart, string $dailyEnd, int $nowMs): bool
{
if (!preg_match('/^\d{2}:\d{2}$/', $dailyStart) || !preg_match('/^\d{2}:\d{2}$/', $dailyEnd)) {
return false;
}
$now = (new DateTimeImmutable('@' . (int)floor($nowMs / 1000)))
->setTimezone(new DateTimeZone(IM_DB_TIMEZONE));
$currentMinutes = ((int)$now->format('H')) * 60 + (int)$now->format('i');
[$startHour, $startMinute] = array_map('intval', explode(':', $dailyStart));
[$endHour, $endMinute] = array_map('intval', explode(':', $dailyEnd));
$startMinutes = $startHour * 60 + $startMinute;
$endMinutes = $endHour * 60 + $endMinute;
if ($startMinutes === $endMinutes) {
return true;
}
if ($startMinutes < $endMinutes) {
return $currentMinutes >= $startMinutes && $currentMinutes < $endMinutes;
}
return $currentMinutes >= $startMinutes || $currentMinutes < $endMinutes;
}
function isGroupMutedForSender(PDO $appDb, string $groupId, string $role, int $nowMs): bool
{
if ($role === 'owner') {
return false;
}
$statement = $appDb->prepare('select * from `t_web_group_mute_rules` where `group_id` = ? and `enabled` = 1 limit 1');
$statement->execute([$groupId]);
$rule = $statement->fetch();
if (!is_array($rule)) {
return false;
}
$mode = (string)$rule['mode'];
if ($mode === 'off') {
return false;
}
if ($mode === 'all_except_admins' && $role === 'admin') {
return false;
}
if (!in_array($mode, ['all_include_admins', 'all_except_admins'], true)) {
return false;
}
$scheduleType = (string)$rule['schedule_type'];
if ($scheduleType === 'manual') {
return true;
}
if ($scheduleType === 'daily') {
return isDailyMuteActive((string)$rule['daily_start'], (string)$rule['daily_end'], $nowMs);
}
$startsAtMs = (int)$rule['starts_at_ms'];
$endsAtMs = (int)$rule['ends_at_ms'];
return $startsAtMs > 0 && $endsAtMs > $startsAtMs && $nowMs >= $startsAtMs && $nowMs < $endsAtMs;
}
function messageRequestValue(array $payload, string $key, string $default = ''): string
{
return trim((string)($_POST[$key] ?? $payload[$key] ?? $default));
}
function safeUploadName(string $fileName): string
{
$name = basename(str_replace('\\', '/', $fileName));
$name = preg_replace('/[^\p{L}\p{N}._ -]+/u', '_', $name) ?: 'file';
$name = trim($name, " .\t\n\r\0\x0B");
return $name !== '' ? mb_substr($name, 0, 180, 'UTF-8') : 'file';
}
function uploadExtension(string $fileName, string $mime, string $attachmentType): string
{
$extension = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
$extension = preg_replace('/[^a-z0-9]/', '', $extension) ?: '';
if ($extension === 'jpeg') {
$extension = 'jpg';
}
if ($attachmentType === 'image') {
if (in_array($extension, ['jpg', 'png', 'gif', 'webp', 'bmp', 'svg', 'avif', 'heic', 'heif'], true)) {
return $extension;
}
return match ($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
'image/bmp', 'image/x-ms-bmp' => 'bmp',
'image/svg+xml' => 'svg',
'image/avif' => 'avif',
'image/heic' => 'heic',
'image/heif' => 'heif',
default => 'jpg',
};
}
if ($extension !== '') {
return $extension;
}
return 'bin';
}
function detectedUploadMime(string $tmpName): string
{
$mime = mime_content_type($tmpName) ?: '';
if ($mime !== '' && $mime !== 'application/octet-stream') {
return $mime;
}
$imageInfo = @getimagesize($tmpName);
if (is_array($imageInfo) && !empty($imageInfo['mime'])) {
return (string)$imageInfo['mime'];
}
return $mime !== '' ? $mime : 'application/octet-stream';
}
function normalizedImageUploadMime(string $tmpName, string $mime): string
{
if (str_starts_with($mime, 'image/')) {
return $mime;
}
$imageInfo = @getimagesize($tmpName);
$imageMime = is_array($imageInfo) ? (string)($imageInfo['mime'] ?? '') : '';
if ($imageMime !== '' && str_starts_with($imageMime, 'image/')) {
return $imageMime;
}
throw new RuntimeException('请选择图片文件');
}
function mediaPublicBaseUrl(): string
{
$isHttps = (string)($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string)$_SERVER['HTTPS']) !== 'off';
$scheme = $isHttps ? 'https' : 'http';
$host = trim(preg_replace('/:\d+$/', '', (string)($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '127.0.0.1')) ?: '127.0.0.1');
return $scheme . '://' . $host . ':' . WEB_MEDIA_PUBLIC_PORT;
}
function saveMessageAttachmentFile(array $file, string $attachmentType): array
{
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new RuntimeException('文件上传失败');
}
$size = (int)($file['size'] ?? 0);
$maxSize = $attachmentType === 'image' ? WEB_IMAGE_MAX_BYTES : WEB_FILE_MAX_BYTES;
if ($size <= 0 || $size > $maxSize) {
throw new RuntimeException($attachmentType === 'image' ? '图片不能超过10MB' : '文件不能超过50MB');
}
if (!is_dir(WEB_MEDIA_ROOT) || !is_writable(WEB_MEDIA_ROOT)) {
throw new RuntimeException('媒体存储目录不可写');
}
$tmpName = (string)($file['tmp_name'] ?? '');
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
throw new RuntimeException('文件上传失败');
}
$mime = detectedUploadMime($tmpName);
if ($attachmentType === 'image') {
$mime = normalizedImageUploadMime($tmpName, $mime);
}
$mediaType = $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE;
$datePath = 'fs/' . $mediaType . '/' . date('Y/m/d/H/i/s');
$targetDir = rtrim(WEB_MEDIA_ROOT, '/') . '/' . $datePath;
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) {
throw new RuntimeException('媒体存储目录创建失败');
}
$originalName = safeUploadName((string)($file['name'] ?? ($attachmentType === 'image' ? 'image.jpg' : 'file')));
$extension = uploadExtension($originalName, $mime, $attachmentType);
$targetName = bin2hex(random_bytes(12)) . '.' . $extension;
$targetPath = $targetDir . '/' . $targetName;
if (!move_uploaded_file($tmpName, $targetPath)) {
throw new RuntimeException('文件保存失败');
}
@chmod($targetPath, 0644);
return [
'type' => $attachmentType,
'url' => mediaPublicBaseUrl() . '/' . $datePath . '/' . $targetName,
'name' => $originalName,
'mime' => $mime,
'size' => $size,
'mediaType' => $mediaType,
];
}
function storeMessageAttachment(
PDO $appDb,
string $messageUid,
string $conversationType,
string $target,
string $sender,
array $attachment,
int $timestamp
): void {
if ($messageUid === '' || empty($attachment['url'])) {
return;
}
$statement = $appDb->prepare(
'insert into `t_web_message_attachments`'
. ' (`message_uid`, `conversation_type`, `target_id`, `sender_uid`, `attachment_type`, `url`, `name`, `mime`, `size_bytes`, `created_at_ms`)'
. ' values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
. ' on duplicate key update'
. ' `attachment_type` = values(`attachment_type`),'
. ' `url` = values(`url`),'
. ' `name` = values(`name`),'
. ' `mime` = values(`mime`),'
. ' `size_bytes` = values(`size_bytes`)'
);
$statement->execute([
$messageUid,
$conversationType,
$target,
$sender,
(string)$attachment['type'],
(string)$attachment['url'],
(string)$attachment['name'],
(string)$attachment['mime'],
(int)$attachment['size'],
$timestamp,
]);
}
function attachmentFromRequest(string $messageType): ?array
{
if (!in_array($messageType, ['image', 'file'], true)) {
return null;
}
$attachmentFile = $_FILES['attachment'] ?? $_FILES['file'] ?? null;
if (!is_array($attachmentFile)) {
respond(400, '缺少上传文件');
}
return saveMessageAttachmentFile($attachmentFile, $messageType);
}
$contentTypeHeader = (string)($_SERVER['CONTENT_TYPE'] ?? '');
$isMultipart = stripos($contentTypeHeader, 'multipart/form-data') !== false;
$payload = $isMultipart ? [] : readJsonPayload();
$sender = messageRequestValue($payload, 'sender');
$target = messageRequestValue($payload, 'target');
$text = messageRequestValue($payload, 'text');
$messageType = messageRequestValue($payload, 'messageType', 'text');
$conversationType = $_POST['conversationType'] ?? $_POST['type'] ?? $payload['conversationType'] ?? $payload['type'] ?? 0;
$isGroupConversation = in_array((string)$conversationType, ['1', 'group', 'channel'], true);
$attachment = null;
if ($sender === '' || $target === '' || ($text === '' && !in_array($messageType, ['image', 'file'], true))) {
respond(400, '缺少发送者、接收者或消息内容');
}
try {
if ($isGroupConversation) {
ensureWebImAppTables();
$appDb = imAppDb();
$memberStatement = $appDb->prepare(
'select g.`group_id`, g.`category`, g.`owner_uid`, m.`role`'
. ' from `t_web_groups` g'
. ' join `t_web_group_members` m on m.`group_id` = g.`group_id` and m.`status` = 0'
. ' where g.`deleted` = 0 and g.`group_id` = ? and m.`user_id` = ?'
. ' limit 1'
);
$memberStatement->execute([$target, $sender]);
$group = $memberStatement->fetch();
if (!$group) {
respond(403, '你不在该群组中');
}
$category = (string)($group['category'] ?? 'group');
$role = (string)($group['role'] ?? 'member');
if ($category === 'channel' && !in_array($role, ['owner', 'admin'], true)) {
respond(403, '公告群仅管理员可发言');
}
$now = webImNowMs();
if ($category !== 'channel' && isGroupMutedForSender($appDb, $target, $role, $now)) {
respond(403, '当前群组处于禁言时段');
}
$attachment = attachmentFromRequest($messageType);
$messageUid = webImMessageUid();
$insert = $appDb->prepare(
'insert into `t_web_group_messages`'
. ' (`message_uid`, `group_id`, `sender_uid`, `text`, `created_at_ms`, `edited_at_ms`, `recalled`)'
. ' values (?, ?, ?, ?, ?, 0, 0)'
);
$insert->execute([$messageUid, $target, $sender, $text, $now]);
if ($attachment) {
storeMessageAttachment($appDb, $messageUid, 'group', $target, $sender, $attachment, $now);
}
$updateGroup = $appDb->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
$updateGroup->execute([$now, $target]);
$nativeError = '';
try {
$pushContent = $text !== ''
? $text
: ($attachment
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
: $text);
$payloadBody = [
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
'searchableContent' => $pushContent,
'pushContent' => $pushContent,
'content' => $text,
'persistFlag' => 3,
];
if ($attachment) {
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
$payloadBody['extra'] = json_encode([
'name' => (string)$attachment['name'],
'mime' => (string)$attachment['mime'],
'size' => (int)$attachment['size'],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
requireImSuccess(imAdminPost('/admin/message/send', [
'sender' => $sender,
'conv' => [
'type' => 1,
'target' => $target,
'line' => 0,
],
'payload' => $payloadBody,
'userMessage' => true,
]), '原生群消息发送失败');
} catch (Throwable $error) {
$nativeError = $error->getMessage() ?: '原生群消息发送失败';
}
respond(0, 'success', [
'messageUid' => $messageUid,
'timestamp' => $now,
'senderRole' => $role,
'attachment' => $attachment,
'nativeError' => $nativeError,
]);
}
$attachment = attachmentFromRequest($messageType);
$pushContent = $text !== ''
? $text
: ($attachment
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
: $text);
$payloadBody = [
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
'searchableContent' => $pushContent,
'pushContent' => $pushContent,
'content' => $text,
'persistFlag' => 3,
];
if ($attachment) {
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
$payloadBody['extra'] = json_encode([
'name' => (string)$attachment['name'],
'mime' => (string)$attachment['mime'],
'size' => (int)$attachment['size'],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$result = requireImSuccess(imAdminPost('/admin/message/send', [
'sender' => $sender,
'conv' => [
'type' => 0,
'target' => $target,
'line' => 0,
],
'payload' => $payloadBody,
'userMessage' => true,
]), '发送失败');
$messageUid = isset($result['messageUid']) ? (string)$result['messageUid'] : '';
$timestamp = (int)($result['timestamp'] ?? webImNowMs());
if ($attachment && $messageUid !== '') {
ensureWebImAppTables();
storeMessageAttachment(imAppDb(), $messageUid, 'single', $target, $sender, $attachment, $timestamp);
}
respond(0, 'success', [
'messageUid' => $messageUid !== '' ? $messageUid : null,
'timestamp' => $timestamp,
'attachment' => $attachment,
]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '发送失败');
}
+61
View File
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? ''));
$targetId = trim((string)($payload['targetId'] ?? $payload['friendUid'] ?? ''));
$alias = trim((string)($payload['alias'] ?? ''));
if ($userId === '') {
respond(400, '缺少用户ID');
}
if ($targetId === '') {
respond(400, '缺少联系人ID');
}
if ($targetId === $userId) {
respond(400, '不能给自己设置备注名');
}
if (mb_strlen($alias, 'UTF-8') > 32) {
respond(400, '备注名不能超过32个字');
}
try {
ensureWebImAppTables();
$imDb = imDb();
$exists = $imDb->prepare('select 1 from `t_user` where `_uid` = ? limit 1');
$exists->execute([$targetId]);
if (!$exists->fetchColumn()) {
respond(404, '没有找到该用户');
}
$friend = $imDb->prepare('select 1 from `t_friend` where `_uid` = ? and `_friend_uid` = ? limit 1');
$friend->execute([$userId, $targetId]);
$isContact = (bool)$friend->fetchColumn();
if ($isContact) {
requireImSuccess(
imAdminPost('/admin/friend/set_alias', [
'operator' => $userId,
'targetId' => $targetId,
'alias' => $alias,
]),
'备注名修改失败'
);
}
saveWebUserRemark(imAppDb(), $userId, $targetId, $alias);
respond(0, 'success', [
'contact' => [
'uid' => $targetId,
'alias' => $alias,
'isContact' => $isContact,
],
]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '备注名服务暂不可用');
}
+12
View File
@@ -0,0 +1,12 @@
window.JC_WEB_IM_CONFIG = {
adminServer: 'http://winchen.ezpay.asia:8024',
appServer: 'http://winchen.ezpay.asia:8888',
qrAppServer: 'http://winchen.ezpay.asia:18888',
platformConfigApi: '/api/platform_config.php',
registerConfigApi: 'http://winchen.ezpay.asia:8024/api/register-config',
imServerHost: 'winchen.ezpay.asia',
imRoutePort: 18000,
useWss: false,
wsPath: '/ws',
sdkMode: 'http'
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 958 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Web IM</title>
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<script src="<%= BASE_URL %>config/runtime-config.js"></script>
</head>
<body>
<noscript>请启用 JavaScript 后使用 Web IM。</noscript>
<div id="app"></div>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
<template>
<LoginView v-if="!session.isAuthenticated" />
<DocumentedShell v-else />
</template>
<script setup>
import { onMounted } from 'vue';
import LoginView from './components/LoginView.vue';
import DocumentedShell from './components/DocumentedShell.vue';
import { useSessionStore } from './stores/session';
const session = useSessionStore();
onMounted(() => {
session.restore();
});
</script>
+34
View File
@@ -0,0 +1,34 @@
<template>
<main class="app-shell">
<NavigationRail />
<ConversationList v-if="navigation.section === 'messages'" />
<FavoriteList v-else-if="navigation.section === 'favorites'" />
<ContactList v-else />
<ChatPanel v-if="navigation.section === 'messages'" />
<FavoritePanel v-else-if="navigation.section === 'favorites'" />
<ContactPanel v-else />
</main>
</template>
<script setup>
import { onMounted } from 'vue';
import NavigationRail from './sidebar/NavigationRail.vue';
import ConversationList from './sidebar/ConversationList.vue';
import ContactList from './sidebar/ContactList.vue';
import ChatPanel from './chat/ChatPanel.vue';
import ContactPanel from './contacts/ContactPanel.vue';
import FavoriteList from './favorites/FavoriteList.vue';
import FavoritePanel from './favorites/FavoritePanel.vue';
import { useConversationStore } from '@/stores/conversations';
import { useNavigationStore } from '@/stores/navigation';
import { useContactStore } from '@/stores/contacts';
const conversations = useConversationStore();
const navigation = useNavigationStore();
const contacts = useContactStore();
onMounted(() => {
conversations.refreshRemoteConversations();
contacts.refreshContacts();
});
</script>
+186
View File
@@ -0,0 +1,186 @@
<template>
<main class="documented-shell">
<aside class="documented-rail">
<div class="documented-avatar">
<img v-if="session.portrait" :src="session.portrait" alt="">
<span v-else>{{ initials }}</span>
</div>
<div class="documented-rail-copy">
<strong>{{ session.displayName || session.userId }}</strong>
<small>{{ session.userId }}</small>
</div>
<button class="documented-rail-button" type="button" @click="openPasswordDialog">修改密码</button>
<button class="documented-rail-button ghost" type="button" @click="logout">退出登录</button>
</aside>
<section class="documented-main">
<header class="documented-header">
<div>
<h1>文档对接模式</h1>
<p>当前界面只保留这两份文档已经明确提供的能力。</p>
</div>
<div class="documented-status">
<span>连接状态</span>
<strong>{{ session.connectionStatusText || '已登录' }}</strong>
</div>
</header>
<section class="documented-card">
<h2>已按文档接入</h2>
<ul class="documented-list">
<li>`/auth/login_pwd` 密码登录</li>
<li>`/auth/register` 注册并返回 `imToken`、`accessToken`、`refreshToken`</li>
<li>`/auth/sendEmailCode` 邮箱验证码</li>
<li>`/auth/refreshToken` 过期自动刷新</li>
<li>`/auth/changePassword` 修改密码</li>
<li>`/auth/logout` 退出登录</li>
<li>扫码登录兼容接口:`/pc_session` 与 `/session_login/{token}`</li>
<li>AES-128-CBC 请求加密、响应解密、`X-Timestamp`、排除路径规则</li>
</ul>
</section>
<section class="documented-card">
<h2>当前按 1 比 1 已停用</h2>
<p class="documented-text">下面这些旧页面功能已经不再走本地 PHP 或旧接口,因为你给的文档里没有对应定义。</p>
<ul class="documented-list muted">
<li>会话列表与消息列表</li>
<li>发送文字、图片、文件消息</li>
<li>联系人列表、添加联系人、备注</li>
<li>群详情、群成员、群管理</li>
<li>个人资料编辑与头像上传</li>
</ul>
</section>
<section class="documented-card">
<h2>当前登录态</h2>
<dl class="documented-meta">
<div>
<dt>用户 ID</dt>
<dd>{{ session.userId || '-' }}</dd>
</div>
<div>
<dt>显示名</dt>
<dd>{{ session.displayName || '-' }}</dd>
</div>
<div>
<dt>IM Token</dt>
<dd>{{ session.token ? '已保存' : '未保存' }}</dd>
</div>
<div>
<dt>App Server</dt>
<dd>{{ appConfig.appServer }}</dd>
</div>
<div>
<dt>扫码服务</dt>
<dd>{{ appConfig.qrAppServer }}</dd>
</div>
</dl>
</section>
</section>
<div v-if="passwordDialogOpen" class="modal-backdrop" @click.self="closePasswordDialog">
<form class="modal-panel documented-password-panel" @submit.prevent="savePassword">
<header>
<strong>修改密码</strong>
<button type="button" class="icon-button" title="关闭" @click="closePasswordDialog">×</button>
</header>
<label>
<span>当前密码</span>
<input v-model="passwordDraft.oldPassword" type="password" autocomplete="current-password">
</label>
<label>
<span>新密码</span>
<input v-model="passwordDraft.newPassword" type="password" autocomplete="new-password">
</label>
<label>
<span>确认新密码</span>
<input v-model="passwordDraft.confirmPassword" type="password" autocomplete="new-password">
</label>
<p v-if="passwordError" class="form-error">{{ passwordError }}</p>
<p v-if="passwordNotice" class="form-info">{{ passwordNotice }}</p>
<div class="modal-actions">
<button type="button" class="ghost-inline-action" :disabled="passwordSaving" @click="closePasswordDialog">取消</button>
<button class="primary-inline-action" :disabled="passwordSaving">
{{ passwordSaving ? '保存中...' : '保存' }}
</button>
</div>
</form>
</div>
</main>
</template>
<script setup>
import { computed, reactive, ref } from 'vue';
import { useSessionStore } from '@/stores/session';
import { appConfig } from '@/config';
const session = useSessionStore();
const passwordDialogOpen = ref(false);
const passwordSaving = ref(false);
const passwordError = ref('');
const passwordNotice = ref('');
const passwordDraft = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: ''
});
const initials = computed(() => String(session.displayName || session.userId || 'U').slice(0, 1).toUpperCase());
function resetPasswordDraft() {
passwordDraft.oldPassword = '';
passwordDraft.newPassword = '';
passwordDraft.confirmPassword = '';
}
function openPasswordDialog() {
passwordError.value = '';
passwordNotice.value = '';
resetPasswordDraft();
passwordDialogOpen.value = true;
}
function closePasswordDialog() {
if (passwordSaving.value) {
return;
}
passwordDialogOpen.value = false;
passwordError.value = '';
passwordNotice.value = '';
resetPasswordDraft();
}
async function savePassword() {
passwordError.value = '';
passwordNotice.value = '';
if (!passwordDraft.oldPassword || !passwordDraft.newPassword || !passwordDraft.confirmPassword) {
passwordError.value = '请填写完整密码';
return;
}
if (passwordDraft.newPassword !== passwordDraft.confirmPassword) {
passwordError.value = '两次输入的新密码不一致';
return;
}
if (passwordDraft.oldPassword === passwordDraft.newPassword) {
passwordError.value = '新密码不能与当前密码相同';
return;
}
passwordSaving.value = true;
try {
await session.changePassword({
oldPassword: passwordDraft.oldPassword,
newPassword: passwordDraft.newPassword
});
passwordNotice.value = '密码已修改,当前登录态会按文档要求失效。';
} catch (error) {
passwordError.value = error.message || '密码修改失败';
} finally {
passwordSaving.value = false;
}
}
async function logout() {
await session.logout();
}
</script>
+502
View File
@@ -0,0 +1,502 @@
<template>
<main class="login-page">
<section class="login-panel">
<div class="brand-lockup">
<img class="brand-mark" :src="platformLogo" :alt="`${platformName} logo`" @error="handleBrandLogoError">
<div>
<h1>{{ platformName }}</h1>
<!-- <p>按照最新 App Server 文档接入的 Web IM 客户端</p> -->
</div>
</div>
<form v-if="view === 'login'" class="login-form" @submit.prevent="submitLogin">
<div class="login-tabs register-tabs" :style="{ gridTemplateColumns: 'repeat(3, minmax(0, 1fr))' }">
<button type="button" :class="{ active: loginMode === 'qr' }" @click="setLoginMode('qr')">扫码登录</button>
<button type="button" :class="{ active: loginMode === 'phone' }" @click="setLoginMode('phone')">手机号</button>
<button type="button" :class="{ active: loginMode === 'email' }" @click="setLoginMode('email')">邮箱</button>
</div>
<template v-if="loginMode === 'qr'">
<div class="qr-login-card" :class="{ scanned: qrStatus === 'scanned', expired: qrStatus === 'expired', error: qrStatus === 'error' }">
<button type="button" class="qr-code-button" :disabled="qrStatus === 'loading' || session.connecting" @click="refreshQrLogin">
<img v-if="qrCodeImage" :src="qrCodeImage" alt="扫码登录二维码">
<span v-else>{{ qrStatus === 'loading' ? '生成中' : '点击刷新' }}</span>
</button>
<div v-if="qrStatus === 'scanned'" class="qr-scan-user">
<img v-if="qrUserPortrait" :src="qrUserPortrait" alt="">
<span v-else>{{ qrUserInitial }}</span>
</div>
</div>
<div class="qr-login-status">
<strong>{{ qrStatusTitle }}</strong>
<span>{{ qrStatusText }}</span>
</div>
<button type="button" class="primary-action" :disabled="qrStatus === 'loading' || session.connecting" @click="refreshQrLogin">
{{ qrRefreshText }}
</button>
</template>
<template v-else>
<label>
<span>{{ loginMode === 'phone' ? '手机号' : '邮箱' }}</span>
<input
v-model.trim="loginAccount"
:autocomplete="loginMode === 'phone' ? 'username' : 'email'"
:inputmode="loginMode === 'phone' ? 'tel' : 'email'"
:placeholder="loginMode === 'phone' ? '请输入手机号' : '请输入邮箱'"
>
</label>
<label>
<span>密码</span>
<input v-model="loginPassword" autocomplete="current-password" type="password" placeholder="请输入密码">
</label>
<button class="primary-action" :disabled="session.connecting || !canSubmitLogin">
{{ session.connecting ? '登录中...' : '登录' }}
</button>
</template>
<p v-if="session.error" class="form-error">{{ session.error }}</p>
<p v-if="info" class="form-info">{{ info }}</p>
</form>
<form v-else class="login-form" @submit.prevent="submitRegister">
<div class="login-tabs register-tabs" :style="{ gridTemplateColumns: 'repeat(2, minmax(0, 1fr))' }">
<button type="button" :class="{ active: registerType === 1 }" @click="switchRegisterType(1)">手机号注册</button>
<button type="button" :class="{ active: registerType === 2 }" @click="switchRegisterType(2)">邮箱注册</button>
</div>
<label>
<span>{{ registerType === 1 ? '手机号' : '邮箱' }}</span>
<input
v-model.trim="registerForm.number"
:autocomplete="registerType === 1 ? 'tel' : 'email'"
:inputmode="registerType === 1 ? 'tel' : 'email'"
:placeholder="registerType === 1 ? '请输入手机号' : '请输入邮箱'"
>
</label>
<label v-if="registerType === 2">
<span>邮箱验证码</span>
<div class="code-row">
<input v-model.trim="registerForm.code" autocomplete="one-time-code" inputmode="numeric" placeholder="请输入邮箱验证码">
<button type="button" class="code-action" :disabled="sendingCode || !canRequestEmailCode" @click="requestEmailCode">
{{ sendingCode ? '发送中' : '发送验证码' }}
</button>
</div>
</label>
<label>
<span>密码</span>
<input v-model="registerForm.password" autocomplete="new-password" type="password" placeholder="请输入密码">
</label>
<label>
<span>确认密码</span>
<input v-model="registerForm.confirmPassword" autocomplete="new-password" type="password" placeholder="请再次输入密码">
</label>
<label>
<span>邀请码</span>
<input v-model.trim="registerForm.invitationCode" autocomplete="off" placeholder="没有可留空">
</label>
<button class="primary-action" :disabled="session.connecting || !canSubmitRegister">
{{ session.connecting ? '注册中...' : '注册并登录' }}
</button>
<p v-if="session.error" class="form-error">{{ session.error }}</p>
<p v-if="info" class="form-info">{{ info }}</p>
</form>
<button v-if="view === 'login'" class="ghost-action" type="button" @click="openRegister">
新用户注册
</button>
<button v-else class="ghost-action" type="button" @click="backToLogin">
返回登录
</button>
</section>
</main>
</template>
<script setup>
import axios from 'axios';
import jrQRCode from 'jr-qrcode';
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { appConfig } from '@/config';
import { useSessionStore } from '@/stores/session';
import { imClient } from '@/services/imClient';
const session = useSessionStore();
const view = ref('login');
const loginMode = ref('phone');
const loginAccount = ref('');
const loginPassword = ref('');
const registerType = ref(1);
const registerForm = reactive({
number: '',
code: '',
password: '',
confirmPassword: '',
invitationCode: ''
});
const sendingCode = ref(false);
const info = ref('');
const QR_CODE_PREFIX_PC_SESSION = 'wildfirechat://pcsession/';
const qrStatus = ref('idle');
const qrCodeImage = ref('');
const qrSessionToken = ref('');
const qrUserName = ref('');
const qrUserPortrait = ref('');
let qrGeneration = 0;
let qrExpireTimer = null;
const defaultPlatformName = appConfig.appName;
const defaultPlatformLogo = '/favicon.ico';
const platformName = ref(defaultPlatformName);
const platformLogo = ref(defaultPlatformLogo);
const canSubmitLogin = computed(() => loginAccount.value.length >= 3 && loginPassword.value.length >= 1);
const canRequestEmailCode = computed(() => registerType.value === 2 && /\S+@\S+\.\S+/.test(registerForm.number));
const canSubmitRegister = computed(() => {
if (registerForm.password.length < 1 || registerForm.password !== registerForm.confirmPassword) {
return false;
}
if (registerType.value === 2) {
return canRequestEmailCode.value && registerForm.code.length >= 1;
}
return registerForm.number.length >= 5;
});
const qrUserInitial = computed(() => String(qrUserName.value || 'U').slice(0, 1).toUpperCase());
const qrStatusTitle = computed(() => {
if (session.connecting || qrStatus.value === 'confirmed') {
return '正在进入';
}
if (qrStatus.value === 'loading') {
return '正在生成二维码';
}
if (qrStatus.value === 'scanned') {
return qrUserName.value ? `${qrUserName.value} 已扫码` : '已扫码,待确认';
}
if (qrStatus.value === 'expired') {
return '二维码已过期';
}
if (qrStatus.value === 'error') {
return '二维码加载失败';
}
return '扫码登录';
});
const qrStatusText = computed(() => {
if (session.connecting || qrStatus.value === 'confirmed') {
return '登录信息已确认,正在建立连接';
}
if (qrStatus.value === 'loading') {
return '正在创建扫码登录会话';
}
if (qrStatus.value === 'scanned') {
return '请在手机 App 上确认登录';
}
if (qrStatus.value === 'expired') {
return '请刷新二维码后重新扫码';
}
if (qrStatus.value === 'error') {
return '请稍后重试';
}
return '使用已登录的手机 App 扫描二维码';
});
const qrRefreshText = computed(() => {
if (qrStatus.value === 'loading') {
return '生成中...';
}
if (qrStatus.value === 'expired' || qrStatus.value === 'error') {
return '重新生成二维码';
}
return '刷新二维码';
});
onMounted(() => {
loadPlatformConfig();
});
onBeforeUnmount(() => {
stopQrLogin();
});
function clearFormMessage() {
session.error = '';
info.value = '';
}
function setLoginMode(mode) {
loginMode.value = mode;
loginAccount.value = '';
loginPassword.value = '';
clearFormMessage();
if (mode === 'qr') {
startQrLogin();
return;
}
stopQrLogin();
}
async function submitLogin() {
clearFormMessage();
const type = loginMode.value === 'email' ? 2 : 1;
await session.loginWithPassword(loginAccount.value, loginPassword.value, type);
}
function switchRegisterType(type) {
registerType.value = type;
registerForm.number = '';
registerForm.code = '';
registerForm.password = '';
registerForm.confirmPassword = '';
registerForm.invitationCode = '';
clearFormMessage();
}
async function requestEmailCode() {
clearFormMessage();
sendingCode.value = true;
try {
await imClient.requestAuthCode(registerForm.number);
info.value = '邮箱验证码已发送,请在当前注册表单中填写后继续。';
} catch (error) {
session.error = error.message || '验证码发送失败';
} finally {
sendingCode.value = false;
}
}
async function submitRegister() {
clearFormMessage();
if (registerForm.password !== registerForm.confirmPassword) {
session.error = '两次输入的密码不一致';
return;
}
const result = await session.register({
type: registerType.value,
number: registerForm.number,
code: registerType.value === 2 ? registerForm.code : '',
password: registerForm.password,
invitationCode: registerForm.invitationCode
});
if (result) {
info.value = '注册成功,正在进入系统。';
}
}
function openRegister() {
clearFormMessage();
stopQrLogin();
view.value = 'register';
}
function backToLogin() {
clearFormMessage();
loginMode.value = 'phone';
stopQrLogin();
view.value = 'login';
}
function resetQrState() {
qrStatus.value = 'idle';
qrCodeImage.value = '';
qrSessionToken.value = '';
qrUserName.value = '';
qrUserPortrait.value = '';
}
function clearQrExpireTimer() {
if (qrExpireTimer) {
window.clearTimeout(qrExpireTimer);
qrExpireTimer = null;
}
}
function stopQrLogin() {
qrGeneration += 1;
clearQrExpireTimer();
resetQrState();
}
function refreshQrLogin() {
startQrLogin(true);
}
async function startQrLogin(force = false) {
if (loginMode.value !== 'qr') {
return;
}
if (!force && qrSessionToken.value && qrCodeImage.value && !['expired', 'error'].includes(qrStatus.value)) {
return;
}
qrGeneration += 1;
const generation = qrGeneration;
clearQrExpireTimer();
clearFormMessage();
qrStatus.value = 'loading';
qrCodeImage.value = '';
qrSessionToken.value = '';
qrUserName.value = '';
qrUserPortrait.value = '';
try {
const pcSession = await imClient.createPcSession();
if (generation !== qrGeneration || loginMode.value !== 'qr') {
return;
}
const token = String(pcSession.token || '');
if (!token) {
throw new Error('服务端没有返回扫码 token');
}
qrSessionToken.value = token;
qrCodeImage.value = jrQRCode.getQrBase64(QR_CODE_PREFIX_PC_SESSION + token);
qrStatus.value = 'waiting';
const expireMs = Math.max(30000, Math.min(Number(pcSession.expired || 300000), 300000));
qrExpireTimer = window.setTimeout(() => {
if (generation === qrGeneration && qrStatus.value !== 'confirmed' && !session.isAuthenticated) {
qrStatus.value = 'expired';
}
}, expireMs);
pollQrLogin(generation, token);
} catch (error) {
if (generation !== qrGeneration) {
return;
}
qrStatus.value = 'error';
session.error = error.message || '二维码加载失败';
}
}
async function pollQrLogin(generation, token) {
try {
while (generation === qrGeneration && loginMode.value === 'qr' && !session.isAuthenticated) {
const payload = await imClient.loginWithPcSession(token);
if (generation !== qrGeneration || loginMode.value !== 'qr') {
return;
}
const code = Number(payload.code);
const data = payload.data || payload.result || {};
const status = Number(data.status);
if (code === 0 && data.imToken) {
qrStatus.value = 'confirmed';
clearQrExpireTimer();
await session.completePcSessionLogin(data);
return;
}
if (code === 20042 || status === 1 || status === 3) {
qrStatus.value = 'scanned';
qrUserName.value = data.userName || data.displayName || qrUserName.value;
qrUserPortrait.value = data.portrait || qrUserPortrait.value;
await delay(1000);
continue;
}
if (status === 0 || code === 13) {
qrStatus.value = 'waiting';
await delay(1000);
continue;
}
if (status === 4) {
qrStatus.value = 'expired';
session.error = payload.message || '扫码登录已取消';
return;
}
if (payload.message) {
throw new Error(payload.message);
}
await delay(1000);
}
} catch (error) {
if (generation === qrGeneration && loginMode.value === 'qr' && !session.isAuthenticated) {
qrStatus.value = 'error';
session.error = error.message || '扫码登录失败';
}
}
}
function delay(ms) {
return new Promise(resolve => window.setTimeout(resolve, ms));
}
async function loadPlatformConfig() {
try {
const response = await axios.get(appConfig.platformConfigApi, { withCredentials: false });
if (response.data?.code !== 0) {
throw new Error(response.data?.message || '平台配置加载失败');
}
const config = response.data?.result || {};
const name = String(config.platformName || '').trim();
const logo = String(config.logoUrl || config.logo || '').trim();
const version = String(config.version || '').trim();
platformName.value = name || defaultPlatformName;
platformLogo.value = resolveBrandLogo(logo, version);
} catch (error) {
platformName.value = defaultPlatformName;
platformLogo.value = defaultPlatformLogo;
}
applyDocumentBrand();
}
function resolveBrandLogo(value, version = '') {
const logo = String(value || '').trim();
if (!logo) {
return defaultPlatformLogo;
}
if (/^(https?:|data:|blob:)/i.test(logo)) {
return appendBrandVersion(logo, version);
}
if (logo.startsWith('//')) {
return appendBrandVersion(`${window.location.protocol}${logo}`, version);
}
const base = logo.startsWith('/') ? appConfig.adminServer : `${appConfig.adminServer}/`;
return appendBrandVersion(new URL(logo, base).toString(), version);
}
function appendBrandVersion(url, version) {
if (!version || /^(data:|blob:)/i.test(url)) {
return url;
}
const parsed = new URL(url, window.location.href);
parsed.searchParams.set('v', version);
return parsed.toString();
}
function applyDocumentBrand() {
if (typeof document === 'undefined') {
return;
}
document.title = platformName.value || defaultPlatformName;
let icon = document.querySelector('link[rel="icon"]');
if (!icon) {
icon = document.createElement('link');
icon.setAttribute('rel', 'icon');
document.head.appendChild(icon);
}
icon.setAttribute('href', platformLogo.value || defaultPlatformLogo);
}
function handleBrandLogoError() {
if (platformLogo.value !== defaultPlatformLogo) {
platformLogo.value = defaultPlatformLogo;
applyDocumentBrand();
}
}
</script>
File diff suppressed because it is too large Load Diff
+223
View File
@@ -0,0 +1,223 @@
<template>
<form
class="message-composer"
:class="{ 'drop-active': dropActive }"
@submit.prevent="submit"
@dragenter.prevent="handleDragEnter"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
@paste="handlePaste"
>
<div v-if="quoteMessage" class="quote-preview">
<span>
<strong>引用</strong>
{{ quoteMessage.quoteText || quoteMessage.text }}
</span>
<button type="button" title="取消引用" @click="$emit('cancel-quote')">×</button>
</div>
<div class="composer-tools">
<button type="button" title="表情" :disabled="disabled" @click="toggleEmojiPicker">☺</button>
<button type="button" title="图片" :disabled="disabled" @click="chooseImage">▧</button>
<button type="button" title="文件" :disabled="disabled" @click="chooseFile">▤</button>
<input ref="imageInput" type="file" accept="image/*" multiple @change="selectAttachment($event, 'image')">
<input ref="fileInput" type="file" multiple @change="selectAttachment($event, 'file')">
<div v-if="emojiOpen" class="emoji-picker" @click.stop>
<button
v-for="emoji in emojiList"
:key="emoji"
type="button"
@click="insertEmoji(emoji)"
>
{{ emoji }}
</button>
</div>
</div>
<textarea
ref="textareaEl"
v-model="text"
:disabled="disabled"
placeholder="输入消息"
rows="3"
@keydown.enter.exact.prevent="submit"
></textarea>
<div class="composer-footer">
<span>Enter 发送,Shift + Enter 换行</span>
<button class="send-button" :disabled="disabled || !text.trim()">发送</button>
</div>
</form>
</template>
<script setup>
import { ref } from 'vue';
const props = defineProps({
disabled: {
type: Boolean,
default: false
},
quoteMessage: {
type: Object,
default: null
}
});
const emit = defineEmits(['send', 'send-attachment', 'cancel-quote']);
const text = ref('');
const emojiOpen = ref(false);
const dropActive = ref(false);
const textareaEl = ref(null);
const imageInput = ref(null);
const fileInput = ref(null);
let dragDepth = 0;
const emojiList = [
'😀', '😄', '😂', '😊', '😍', '😘', '😎', '😢',
'😡', '👍', '🙏', '👏', '💪', '🎉', '❤️', '🔥',
'🌹', '☕', '✅', '⭐', '💰', '🎁', '🤝', '👌'
];
function submit() {
const value = text.value.trim();
if (!value) {
return;
}
emit('send', value);
text.value = '';
emojiOpen.value = false;
}
function toggleEmojiPicker() {
emojiOpen.value = !emojiOpen.value;
}
function insertEmoji(emoji) {
const element = textareaEl.value;
if (!element) {
text.value += emoji;
return;
}
const start = element.selectionStart ?? text.value.length;
const end = element.selectionEnd ?? text.value.length;
text.value = `${text.value.slice(0, start)}${emoji}${text.value.slice(end)}`;
requestAnimationFrame(() => {
element.focus();
const cursor = start + emoji.length;
element.setSelectionRange(cursor, cursor);
});
}
function chooseImage() {
imageInput.value?.click();
}
function chooseFile() {
fileInput.value?.click();
}
function attachmentTypeForFile(file, fallbackType = 'file') {
if (file?.type?.startsWith('image/')) {
return 'image';
}
return fallbackType === 'image' ? 'image' : 'file';
}
function emitFiles(files, fallbackType = 'file') {
if (props.disabled) {
return;
}
const validFiles = Array.from(files || []).filter(file => file instanceof File && file.size > 0);
if (!validFiles.length) {
return;
}
const caption = text.value.trim();
validFiles.forEach((file, index) => {
emit('send-attachment', {
type: attachmentTypeForFile(file, fallbackType),
file,
text: index === 0 ? caption : ''
});
});
text.value = '';
emojiOpen.value = false;
}
function selectAttachment(event, type) {
const input = event.target;
const files = input.files ? Array.from(input.files) : [];
input.value = '';
if (!files.length || props.disabled) {
return;
}
emitFiles(files, type);
}
function hasDraggedFiles(event) {
const types = Array.from(event.dataTransfer?.types || []);
return types.includes('Files');
}
function handleDragEnter(event) {
if (props.disabled || !hasDraggedFiles(event)) {
return;
}
dragDepth += 1;
dropActive.value = true;
event.dataTransfer.dropEffect = 'copy';
}
function handleDragOver(event) {
if (props.disabled || !hasDraggedFiles(event)) {
return;
}
dropActive.value = true;
event.dataTransfer.dropEffect = 'copy';
}
function handleDragLeave() {
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) {
dropActive.value = false;
}
}
function filesFromDataTransfer(dataTransfer) {
const items = Array.from(dataTransfer?.items || []);
const files = items
.filter(item => item.kind === 'file' && !item.webkitGetAsEntry?.()?.isDirectory)
.map(item => item.getAsFile())
.filter(Boolean);
if (files.length) {
return files;
}
return Array.from(dataTransfer?.files || []);
}
function handleDrop(event) {
dragDepth = 0;
dropActive.value = false;
if (props.disabled) {
return;
}
emitFiles(filesFromDataTransfer(event.dataTransfer));
}
function imageFilesFromClipboard(clipboardData) {
return Array.from(clipboardData?.items || [])
.filter(item => item.kind === 'file' && item.type.startsWith('image/'))
.map(item => item.getAsFile())
.filter(Boolean);
}
function handlePaste(event) {
if (props.disabled) {
return;
}
const imageFiles = imageFilesFromClipboard(event.clipboardData);
if (!imageFiles.length) {
return;
}
event.preventDefault();
emitFiles(imageFiles, 'image');
}
</script>
+757
View File
@@ -0,0 +1,757 @@
<template>
<div ref="listEl" class="message-list">
<article
v-for="message in messages"
:key="message.id"
class="message-row"
:class="[message.direction, { selected: isSelected(message), attention: isAttention(message), recalled: message.recalled || message.status === 'recalled' }]"
:data-message-key="messageIdentity(message)"
@click="handleMessageClick(message)"
>
<label v-if="selectionMode" class="message-select">
<input
type="checkbox"
:checked="isSelected(message)"
@click.stop
@change="emit('toggle-select', message)"
>
</label>
<button
v-if="showGroupSender(message)"
type="button"
class="message-sender-avatar"
:title="groupSenderName(message)"
@click.stop="openSenderProfile(message)"
>
<img v-if="message.senderPortrait" :src="message.senderPortrait" alt="">
<span v-else>{{ groupSenderAvatarText(message) }}</span>
</button>
<div class="message-stack">
<button
v-if="showGroupSender(message)"
type="button"
class="message-sender-name"
@click.stop="openSenderProfile(message)"
>
{{ groupSenderName(message) }}
<span v-if="senderRoleLabel(message)" class="message-sender-role">{{ senderRoleLabel(message) }}</span>
</button>
<div
class="message-bubble"
:class="roleBubbleClass(message)"
@contextmenu.prevent="openMenu($event, message)"
>
<button
v-if="isImageMessage(message)"
type="button"
class="message-image"
@click.stop="handleImageClick(message)"
>
<img :src="message.attachment.url" :alt="message.attachment.name || '图片'">
</button>
<a
v-else-if="isFileMessage(message)"
class="message-file"
:href="message.attachment.url"
target="_blank"
rel="noreferrer"
download
>
<span class="message-file-icon">▤</span>
<span class="message-file-main">
<strong>{{ message.attachment.name || '文件' }}</strong>
<small>{{ formatFileSize(message.attachment.size) }}</small>
</span>
</a>
<p v-if="message.text">{{ message.text }}</p>
<time>
<span v-if="isRecallNotice(message)" class="recall-mark">{{ message.recallStatus || '已撤回' }}</span>
<span>{{ formatTime(message.displayTimestamp || message.timestamp) }}</span>
<span v-if="message.editedAt" class="edit-mark">【edit {{ formatTime(message.editedAt) }}】</span>
<span v-if="isFavorite(message)" class="favorite-mark" title="已收藏" aria-label="已收藏">★</span>
<span
v-if="deliveryState(message)"
class="delivery-mark"
:class="`delivery-mark-${deliveryState(message)}`"
:title="deliveryTitle(message)"
:aria-label="deliveryTitle(message)"
>
<span class="delivery-check">✓</span>
<span v-if="deliveryState(message) !== 'sent'" class="delivery-check delivery-check-second">✓</span>
</span>
<span v-if="message.status === 'failed'" class="delivery-error">{{ failureText(message) }}</span>
</time>
</div>
</div>
</article>
<div
v-if="contextMenu.visible"
class="message-context-menu"
:style="{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }"
@click.stop
>
<button
v-for="item in menuItems"
:key="item.action"
type="button"
@click="chooseMenuAction(item.action)"
>
{{ item.label }}
</button>
</div>
<Teleport to="body">
<div
v-if="imageViewer.visible"
class="image-viewer-backdrop"
@click="closeImageViewer"
>
<div
class="image-viewer-dialog"
@click.stop
>
<div class="image-viewer-toolbar">
<span class="image-viewer-title">{{ imageViewer.name || '图片' }}</span>
<button type="button" title="缩小" aria-label="缩小" @click="zoomImage(-0.25)">−</button>
<button type="button" title="放大" aria-label="放大" @click="zoomImage(0.25)">+</button>
<button type="button" title="还原" aria-label="还原" @click="resetImageViewer">1:1</button>
<button type="button" title="旋转" aria-label="旋转" @click="rotateImage">⟳</button>
<button type="button" title="保存" aria-label="保存" @click="saveViewerImage">⇩</button>
<button type="button" title="关闭" aria-label="关闭" @click="closeImageViewer">×</button>
</div>
<div
ref="viewerStage"
class="image-viewer-stage"
:class="{ dragging: imageViewer.dragging, zoomed: imageViewer.scale > 1 }"
@wheel.prevent="handleImageWheel"
@pointerdown.prevent="startImageDrag"
>
<img
class="image-viewer-img"
:src="imageViewer.url"
:alt="imageViewer.name || '图片'"
:style="imageViewerStyle"
draggable="false"
>
</div>
</div>
</div>
</Teleport>
<Teleport to="body">
<div
v-if="senderProfile.visible"
class="sender-profile-backdrop"
@click="closeSenderProfile"
>
<section class="sender-profile-dialog" @click.stop>
<header>
<strong>用户资料</strong>
<button type="button" class="icon-button" title="关闭" @click="closeSenderProfile">×</button>
</header>
<div class="sender-profile-main">
<img v-if="senderProfile.portrait" class="profile-avatar image-avatar" :src="senderProfile.portrait" alt="">
<div v-else class="profile-avatar">{{ senderProfile.avatarText }}</div>
<strong>{{ senderProfile.displayName || senderProfile.uid }}</strong>
<span v-if="senderProfile.roleLabel">{{ senderProfile.roleLabel }}</span>
</div>
<dl class="sender-profile-fields">
<div>
<dt>用户ID</dt>
<dd>{{ senderProfile.uid || '-' }}</dd>
</div>
<div>
<dt>昵称</dt>
<dd>{{ senderProfile.rawName || senderProfile.displayName || '-' }}</dd>
</div>
<div>
<dt>手机号</dt>
<dd>{{ senderProfile.mobile || '未知' }}</dd>
</div>
<div>
<dt>地区</dt>
<dd>{{ senderProfile.address || '未知' }}</dd>
</div>
</dl>
<label class="sender-profile-alias">
<span>备注名称</span>
<input
v-model.trim="senderProfile.aliasDraft"
maxlength="32"
autocomplete="off"
placeholder="可给群员设置备注"
:disabled="senderProfile.saving || senderProfile.adding"
>
</label>
<footer class="sender-profile-actions">
<span v-if="senderProfile.notice" class="inline-save-status">{{ senderProfile.notice }}</span>
<span v-else-if="senderProfile.error" class="inline-save-status error">{{ senderProfile.error }}</span>
<button
v-if="!senderProfile.isContact"
type="button"
class="ghost-inline-action"
:disabled="senderProfile.adding || senderProfile.saving || !senderProfile.uid"
@click="addSenderAsContact"
>
{{ senderProfile.adding ? '添加中...' : '添加为好友' }}
</button>
<button
type="button"
class="primary-inline-action"
:disabled="senderProfile.saving || senderProfile.adding || !senderProfile.uid"
@click="saveSenderAlias"
>
{{ senderProfile.saving ? '保存中...' : '保存备注' }}
</button>
</footer>
</section>
</div>
</Teleport>
</div>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useContactStore } from '@/stores/contacts';
const props = defineProps({
messages: {
type: Array,
default: () => []
},
selectionMode: {
type: Boolean,
default: false
},
selectedMessageIds: {
type: Array,
default: () => []
},
favoriteMessageIds: {
type: Array,
default: () => []
},
attentionMessageId: {
type: String,
default: ''
},
canRecallOthers: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['message-action', 'toggle-select', 'sender-profile-updated']);
const contacts = useContactStore();
const listEl = ref(null);
const viewerStage = ref(null);
const contextMenu = reactive({
visible: false,
x: 0,
y: 0,
message: null
});
const IMAGE_MIN_SCALE = 0.5;
const IMAGE_MAX_SCALE = 6;
const imageViewer = reactive({
visible: false,
url: '',
name: '',
scale: 1,
rotation: 0,
offsetX: 0,
offsetY: 0,
dragging: false,
dragStartX: 0,
dragStartY: 0,
dragOffsetX: 0,
dragOffsetY: 0
});
const senderProfile = reactive({
visible: false,
uid: '',
displayName: '',
rawName: '',
mobile: '',
address: '',
portrait: '',
avatarText: '',
role: '',
roleLabel: '',
contactAlias: '',
groupAlias: '',
isContact: false,
aliasDraft: '',
saving: false,
adding: false,
notice: '',
error: ''
});
const imageViewerStyle = computed(() => ({
transform: `translate3d(${imageViewer.offsetX}px, ${imageViewer.offsetY}px, 0) scale(${imageViewer.scale}) rotate(${imageViewer.rotation}deg)`
}));
const menuItems = computed(() => {
if (!contextMenu.message) {
return [];
}
const isOwnMessage = contextMenu.message.direction === 'out';
if (isRecalledMessage(contextMenu.message)) {
return [
...(isOwnMessage ? [{ action: 'edit', label: '编辑' }] : []),
{ action: 'copy', label: '复制' },
{ action: 'delete', label: '删除' },
{ action: 'detail', label: '详情' }
];
}
const favoriteLabel = isFavorite(contextMenu.message) ? '取消收藏' : '收藏';
const items = [
{ action: 'copy', label: '复制' },
{ action: 'delete', label: '删除' },
{ action: 'forward', label: '转发' },
{ action: 'favorite', label: favoriteLabel },
{ action: 'quote', label: '引用' },
{ action: 'select', label: '多选' },
{ action: 'detail', label: '详情' }
];
if (isOwnMessage) {
items.unshift({ action: 'edit', label: '编辑' });
items.push({ action: 'recall', label: '撤回' });
} else if (props.canRecallOthers) {
items.push({ action: 'recall', label: '撤回' });
}
return items;
});
function scrollToBottom() {
nextTick(() => {
if (listEl.value) {
listEl.value.scrollTop = listEl.value.scrollHeight;
}
});
}
function scrollToMessage(messageId) {
nextTick(() => {
if (!listEl.value || !messageId) {
return;
}
const row = Array.from(listEl.value.querySelectorAll('.message-row'))
.find(element => element.dataset.messageKey === messageId);
if (row) {
row.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
});
}
watch(
() => props.messages.map(message => `${message.id}:${message.status}:${message.deliveryStatus || ''}:${message.messageUid || ''}:${message.recalled ? '1' : ''}`).join('|'),
scrollToBottom,
{ immediate: true }
);
watch(
() => props.attentionMessageId,
scrollToMessage
);
onMounted(() => {
document.addEventListener('click', closeMenu);
document.addEventListener('keydown', closeMenuOnEscape);
document.addEventListener('pointermove', moveImageDrag);
document.addEventListener('pointerup', stopImageDrag);
});
onBeforeUnmount(() => {
document.removeEventListener('click', closeMenu);
document.removeEventListener('keydown', closeMenuOnEscape);
document.removeEventListener('pointermove', moveImageDrag);
document.removeEventListener('pointerup', stopImageDrag);
});
function messageIdentity(message) {
return String(message.messageUid || message.id || '');
}
function canOpenMessageMenu(message) {
return message && message.status !== 'failed' && message.status !== 'sending';
}
function isSelected(message) {
return props.selectedMessageIds.includes(messageIdentity(message));
}
function isFavorite(message) {
return props.favoriteMessageIds.includes(messageIdentity(message));
}
function isAttention(message) {
return props.attentionMessageId === messageIdentity(message);
}
function isRecallNotice(message) {
return message.direction === 'out' && (message.recalled || message.status === 'recalled');
}
function isRecalledMessage(message) {
return message.recalled || message.status === 'recalled';
}
function isImageMessage(message) {
return message?.type === 'image' && message?.attachment?.url;
}
function isFileMessage(message) {
return message?.type === 'file' && message?.attachment?.url;
}
function isGroupMessage(message) {
return ['group', 'channel'].includes(message?.conversationType || '');
}
function showGroupSender(message) {
return isGroupMessage(message) && message?.direction === 'in';
}
function senderRoleLabel(message) {
const role = message?.senderProfile?.role || message?.senderRole || '';
if (role === 'owner') {
return '群主';
}
if (role === 'admin') {
return '管理员';
}
return '';
}
function groupSenderName(message) {
return message?.senderName || message?.senderProfile?.displayName || message?.from || '未知用户';
}
function groupSenderAvatarText(message) {
return String(groupSenderName(message) || '?').slice(0, 1).toUpperCase();
}
function openSenderProfile(message) {
const profile = message?.senderProfile || {};
const uid = profile.uid || message?.from || '';
const cachedContact = contacts.contacts.find(contact => String(contact.uid || contact.id || '') === uid) || null;
const cachedAlias = String(cachedContact?.alias || '').trim();
const contactAlias = String(profile.contactAlias || cachedAlias || profile.remarkAlias || '').trim();
const groupAlias = String(profile.groupAlias || '').trim();
const rawName = profile.rawName
|| cachedContact?.rawUser?.displayName
|| cachedContact?.rawUser?.name
|| cachedContact?.title
|| groupSenderName(message);
const displayName = contactAlias || groupAlias || rawName || uid;
senderProfile.visible = true;
senderProfile.uid = uid;
senderProfile.displayName = displayName;
senderProfile.rawName = rawName || '';
senderProfile.mobile = profile.mobile || cachedContact?.rawUser?.mobile || '';
senderProfile.address = profile.address || cachedContact?.address || cachedContact?.rawUser?.address || '';
senderProfile.portrait = profile.portrait || cachedContact?.portrait || message?.senderPortrait || '';
senderProfile.avatarText = String(displayName || senderProfile.uid || '?').slice(0, 1).toUpperCase();
senderProfile.role = profile.role || message?.senderRole || '';
senderProfile.roleLabel = senderRoleLabel(message);
senderProfile.contactAlias = contactAlias;
senderProfile.groupAlias = groupAlias;
senderProfile.isContact = Boolean(profile.isContact || cachedContact);
senderProfile.aliasDraft = contactAlias;
senderProfile.saving = false;
senderProfile.adding = false;
senderProfile.notice = '';
senderProfile.error = '';
closeMenu();
}
function closeSenderProfile() {
if (senderProfile.saving || senderProfile.adding) {
return;
}
senderProfile.visible = false;
}
async function addSenderAsContact() {
if (!senderProfile.uid || senderProfile.adding || senderProfile.saving) {
return;
}
senderProfile.adding = true;
senderProfile.notice = '';
senderProfile.error = '';
const ok = await contacts.addContact(senderProfile.uid);
if (ok || String(contacts.actionError || '').includes('已经是联系人')) {
senderProfile.isContact = true;
const alias = senderProfile.aliasDraft.trim();
let aliasSaved = false;
if (alias) {
aliasSaved = await contacts.updateContactAlias(senderProfile.uid, alias);
if (aliasSaved) {
senderProfile.contactAlias = alias;
senderProfile.displayName = alias;
senderProfile.avatarText = String(alias || senderProfile.uid || '?').slice(0, 1).toUpperCase();
}
}
await contacts.refreshContacts({ force: true }).catch(() => {});
if (alias && !aliasSaved) {
senderProfile.notice = '';
senderProfile.error = contacts.actionError
? `已添加为好友,但${contacts.actionError}`
: '已添加为好友,但备注名同步失败';
} else {
senderProfile.notice = alias ? '已添加为好友,备注名已保存' : (ok ? '已添加为好友' : '已是好友');
}
emit('sender-profile-updated', { userId: senderProfile.uid });
} else {
senderProfile.error = contacts.actionError || '添加好友失败';
}
senderProfile.adding = false;
}
async function saveSenderAlias() {
if (!senderProfile.uid || senderProfile.saving || senderProfile.adding) {
return;
}
senderProfile.saving = true;
senderProfile.notice = '';
senderProfile.error = '';
const ok = await contacts.updateContactAlias(senderProfile.uid, senderProfile.aliasDraft);
if (ok) {
await contacts.refreshContacts({ force: true }).catch(() => {});
const alias = senderProfile.aliasDraft.trim();
senderProfile.contactAlias = alias;
senderProfile.displayName = alias || senderProfile.groupAlias || senderProfile.rawName || senderProfile.uid;
senderProfile.avatarText = String(senderProfile.displayName || senderProfile.uid || '?').slice(0, 1).toUpperCase();
senderProfile.notice = '备注名已保存';
emit('sender-profile-updated', { userId: senderProfile.uid });
} else {
senderProfile.error = contacts.actionError || '备注名修改失败';
}
senderProfile.saving = false;
}
function roleBubbleClass(message) {
const role = message?.senderRole || '';
if (role === 'owner') {
return 'role-bubble-owner';
}
if (role === 'admin') {
return 'role-bubble-admin';
}
return '';
}
function formatFileSize(size) {
const bytes = Number(size || 0);
if (bytes >= 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(bytes >= 10 * 1024 * 1024 ? 0 : 1)} MB`;
}
if (bytes >= 1024) {
return `${(bytes / 1024).toFixed(bytes >= 10 * 1024 ? 0 : 1)} KB`;
}
return bytes > 0 ? `${bytes} B` : '';
}
function handleMessageClick(message) {
if (props.selectionMode) {
emit('toggle-select', message);
}
}
function handleImageClick(message) {
if (props.selectionMode) {
emit('toggle-select', message);
return;
}
openImageViewer(message);
}
function openImageViewer(message) {
const attachment = message?.attachment || {};
if (!attachment.url) {
return;
}
closeMenu();
imageViewer.visible = true;
imageViewer.url = attachment.url;
imageViewer.name = attachment.name || 'image';
resetImageViewer();
}
function closeImageViewer() {
imageViewer.visible = false;
imageViewer.dragging = false;
}
function resetImageViewer() {
imageViewer.scale = 1;
imageViewer.rotation = 0;
imageViewer.offsetX = 0;
imageViewer.offsetY = 0;
}
function clampImageScale(value) {
return Math.min(IMAGE_MAX_SCALE, Math.max(IMAGE_MIN_SCALE, Number(value.toFixed(2))));
}
function setImageScale(value, originX = null, originY = null) {
const previousScale = imageViewer.scale;
const nextScale = clampImageScale(value);
if (nextScale === previousScale) {
return;
}
if (originX !== null && originY !== null && viewerStage.value) {
const rect = viewerStage.value.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const ratio = nextScale / previousScale - 1;
imageViewer.offsetX -= (originX - centerX - imageViewer.offsetX) * ratio;
imageViewer.offsetY -= (originY - centerY - imageViewer.offsetY) * ratio;
}
imageViewer.scale = nextScale;
if (nextScale <= 1) {
imageViewer.offsetX = 0;
imageViewer.offsetY = 0;
}
}
function zoomImage(delta) {
setImageScale(imageViewer.scale + delta);
}
function rotateImage() {
imageViewer.rotation = (imageViewer.rotation + 90) % 360;
}
function handleImageWheel(event) {
const factor = event.deltaY < 0 ? 1.12 : 1 / 1.12;
setImageScale(imageViewer.scale * factor, event.clientX, event.clientY);
}
function startImageDrag(event) {
if (!imageViewer.visible || imageViewer.scale <= 1) {
return;
}
imageViewer.dragging = true;
imageViewer.dragStartX = event.clientX;
imageViewer.dragStartY = event.clientY;
imageViewer.dragOffsetX = imageViewer.offsetX;
imageViewer.dragOffsetY = imageViewer.offsetY;
}
function moveImageDrag(event) {
if (!imageViewer.dragging) {
return;
}
imageViewer.offsetX = imageViewer.dragOffsetX + event.clientX - imageViewer.dragStartX;
imageViewer.offsetY = imageViewer.dragOffsetY + event.clientY - imageViewer.dragStartY;
}
function stopImageDrag() {
imageViewer.dragging = false;
}
function saveViewerImage() {
if (!imageViewer.url) {
return;
}
const downloadUrl = new URL('/api/media_download.php', window.location.origin);
downloadUrl.searchParams.set('url', imageViewer.url);
downloadUrl.searchParams.set('name', imageViewer.name || 'image');
const link = document.createElement('a');
link.href = downloadUrl.toString();
link.download = imageViewer.name || 'image';
document.body.appendChild(link);
link.click();
link.remove();
}
function openMenu(event, message) {
if (!canOpenMessageMenu(message)) {
closeMenu();
return;
}
contextMenu.visible = true;
contextMenu.message = message;
contextMenu.x = Math.min(event.clientX, window.innerWidth - 148);
contextMenu.y = Math.min(event.clientY, window.innerHeight - 352);
}
function closeMenu() {
contextMenu.visible = false;
contextMenu.message = null;
}
function closeMenuOnEscape(event) {
if (event.key === 'Escape') {
if (senderProfile.visible) {
closeSenderProfile();
}
if (imageViewer.visible) {
closeImageViewer();
}
closeMenu();
}
}
function chooseMenuAction(action) {
if (contextMenu.message) {
emit('message-action', { action, message: contextMenu.message });
}
closeMenu();
}
function formatTime(timestamp) {
return new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone
}).format(timestamp);
}
function deliveryState(message) {
if (message.direction !== 'out' || message.status === 'failed' || message.status === 'recalled' || message.recalled) {
return '';
}
const state = message.deliveryStatus || message.status;
if (state === 'read') {
return 'read';
}
if (state === 'delivered') {
return 'delivered';
}
if (state === 'sent') {
return 'sent';
}
return '';
}
function deliveryTitle(message) {
const state = deliveryState(message);
if (state === 'read') {
return '对方已读';
}
if (state === 'delivered') {
return '已送达对方';
}
if (state === 'sent') {
return '已送出';
}
return '';
}
function failureText(message) {
if (message.status === 'failed') {
if (message.errorCode) {
return message.error ? `失败:${message.error}(${message.errorCode})` : `失败:${message.errorCode}`;
}
return message.error ? `失败:${message.error}` : '失败';
}
return '';
}
</script>
+111
View File
@@ -0,0 +1,111 @@
<template>
<section class="contact-panel">
<template v-if="contact">
<header class="chat-header">
<div>
<h2>{{ remarkName }}</h2>
<p>{{ username }}</p>
</div>
</header>
<div class="contact-profile">
<img v-if="contact.portrait" class="profile-avatar image-avatar" :src="contact.portrait" alt="">
<div v-else class="profile-avatar">{{ contact.avatarText }}</div>
<div class="profile-main">
<div class="remark-row">
<h3>{{ remarkName }}</h3>
<button type="button" class="icon-button remark-edit-button" title="编辑备注名" @click="openAliasEditor">✏️</button>
</div>
</div>
<dl class="profile-fields">
<div>
<dt>用户名</dt>
<dd>{{ username }}</dd>
</div>
<div>
<dt>手机号</dt>
<dd>{{ mobile }}</dd>
</div>
<div>
<dt>地区</dt>
<dd>{{ address }}</dd>
</div>
</dl>
<button class="primary-action profile-action" @click="contacts.openChat(contact)">发消息</button>
</div>
<div v-if="editingAlias" class="modal-backdrop contact-modal-backdrop" @click.self="closeAliasEditor">
<form class="modal-panel" @submit.prevent="submitAlias">
<header>
<strong>编辑备注名</strong>
<button type="button" class="icon-button" title="关闭" @click="closeAliasEditor">×</button>
</header>
<label>
<span>备注名</span>
<input v-model.trim="aliasDraft" maxlength="32" autocomplete="off" autofocus>
</label>
<p v-if="aliasError" class="form-error">{{ aliasError }}</p>
<div class="modal-actions">
<button type="button" class="ghost-inline-action" :disabled="aliasSaving" @click="closeAliasEditor">取消</button>
<button type="submit" class="primary-inline-action" :disabled="aliasSaving">{{ aliasSaving ? '保存中' : '保存' }}</button>
</div>
</form>
</div>
</template>
<div v-else class="empty-chat">
<strong>选择一个联系人</strong>
<span>好友列表同步完成后,可以在左侧查看资料</span>
</div>
</section>
</template>
<script setup>
import { computed, ref, watch } from 'vue';
import { useContactStore } from '@/stores/contacts';
const contacts = useContactStore();
const contact = computed(() => contacts.activeContact);
const remarkName = computed(() => contact.value?.alias || '未设置');
const username = computed(() => contact.value?.rawUser?.displayName || contact.value?.rawUser?.name || contact.value?.uid || '');
const mobile = computed(() => contact.value?.rawUser?.mobile || '未知');
const address = computed(() => contact.value?.rawUser?.address || '未知');
const editingAlias = ref(false);
const aliasDraft = ref('');
const aliasSaving = ref(false);
const aliasError = ref('');
watch(contact, () => {
editingAlias.value = false;
aliasError.value = '';
});
function openAliasEditor() {
aliasDraft.value = contact.value?.alias || '';
aliasError.value = '';
editingAlias.value = true;
}
function closeAliasEditor() {
if (aliasSaving.value) {
return;
}
editingAlias.value = false;
aliasError.value = '';
}
async function submitAlias() {
if (!contact.value) {
return;
}
aliasSaving.value = true;
aliasError.value = '';
const ok = await contacts.updateContactAlias(contact.value.uid, aliasDraft.value);
aliasSaving.value = false;
if (ok) {
editingAlias.value = false;
return;
}
aliasError.value = contacts.actionError || '备注名修改失败';
}
</script>
+79
View File
@@ -0,0 +1,79 @@
<template>
<aside class="conversation-list favorite-list">
<header class="list-header">
<div>
<h2>收藏</h2>
<p>{{ conversations.favoriteMessages.length }} 条消息</p>
</div>
</header>
<div class="sidebar-scroll">
<button
v-for="item in conversations.favoriteMessages"
:key="item.key"
type="button"
class="favorite-item"
:class="{ active: activeKey === item.key }"
@click="conversations.setActiveFavorite(item.key)"
>
<span class="favorite-item-icon">★</span>
<span class="favorite-item-main">
<strong>{{ favoriteTitle(item) }}</strong>
<small>{{ favoritePreview(item) }}</small>
</span>
<time>{{ formatFavoriteTime(item.timestamp) }}</time>
</button>
<div v-if="!conversations.favoriteMessages.length" class="empty-list">
暂无收藏消息
</div>
</div>
</aside>
</template>
<script setup>
import { computed } from 'vue';
import { useConversationStore } from '@/stores/conversations';
const conversations = useConversationStore();
const activeKey = computed(() => conversations.activeFavorite?.key || '');
function favoriteTitle(item) {
if (item.conversationTitle) {
return item.conversationTitle;
}
const conversation = conversations.conversations.find(entry => entry.id === item.conversationId);
return conversation?.title || item.conversationId || '未知会话';
}
function favoritePreview(item) {
if (item.recalled) {
return `[${item.recallStatus || '已撤回'}]`;
}
if (item.preview) {
return item.preview;
}
if (item.text) {
return item.text;
}
if (item.type === 'image') {
return '[图片]';
}
if (item.type === 'file') {
return `[文件] ${item.attachment?.name || '文件'}`;
}
return '(无文字内容)';
}
function formatFavoriteTime(timestamp) {
const value = Number(timestamp || 0);
if (!value) {
return '';
}
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
}).format(value);
}
</script>
+202
View File
@@ -0,0 +1,202 @@
<template>
<section class="favorite-panel">
<template v-if="favorite">
<header class="chat-header">
<div>
<h2>收藏详情</h2>
<p>{{ favoriteTitle(favorite) }}</p>
</div>
</header>
<div class="favorite-detail">
<div class="favorite-detail-message" :class="{ recalled: favorite.recalled }">
<span class="favorite-detail-star">★</span>
<div class="favorite-detail-body">
<span v-if="favorite.recalled" class="recall-mark">{{ favorite.recallStatus || '已撤回' }}</span>
<a
v-if="isImageFavorite(favorite)"
class="favorite-detail-image"
:href="favorite.attachment.url"
target="_blank"
rel="noreferrer"
>
<img :src="favorite.attachment.url" :alt="favorite.attachment.name || '图片'">
</a>
<a
v-else-if="isFileFavorite(favorite)"
class="message-file favorite-detail-file"
:href="favorite.attachment.url"
target="_blank"
rel="noreferrer"
download
>
<span class="message-file-icon">▤</span>
<span class="message-file-main">
<strong>{{ favorite.attachment.name || '文件' }}</strong>
<small>{{ formatFileSize(favorite.attachment.size) }}</small>
</span>
</a>
<p v-if="favoriteText(favorite)">{{ favoriteText(favorite) }}</p>
</div>
<time>{{ formatFavoriteTime(favorite.timestamp) }}</time>
</div>
<div class="favorite-detail-actions">
<span v-if="notice" class="inline-save-status">{{ notice }}</span>
<span v-else-if="error" class="inline-save-status error">{{ error }}</span>
<button type="button" class="ghost-inline-action" @click="copyFavorite">复制</button>
<button type="button" class="ghost-inline-action" @click="openConversation">打开原会话</button>
<button type="button" class="danger-inline-action" @click="removeFavorite">取消收藏</button>
</div>
</div>
</template>
<div v-else class="empty-chat">
<strong>暂无收藏消息</strong>
<span>右键消息气泡选择“收藏”后,会显示在这里</span>
</div>
</section>
</template>
<script setup>
import { computed, ref } from 'vue';
import { useConversationStore } from '@/stores/conversations';
import { useNavigationStore } from '@/stores/navigation';
const conversations = useConversationStore();
const navigation = useNavigationStore();
const favorite = computed(() => conversations.activeFavorite);
const notice = ref('');
const error = ref('');
function clearFeedback() {
notice.value = '';
error.value = '';
}
function favoriteTitle(item) {
if (item.conversationTitle) {
return item.conversationTitle;
}
const conversation = conversations.conversations.find(entry => entry.id === item.conversationId);
return conversation?.title || item.conversationId || '未知会话';
}
function favoriteText(item) {
return item.text || '';
}
function isImageFavorite(item) {
return item?.type === 'image' && item?.attachment?.url;
}
function isFileFavorite(item) {
return item?.type === 'file' && item?.attachment?.url;
}
function favoriteCopyText(item) {
if (!item) {
return '';
}
if (item.text) {
return item.text;
}
if (item.recalled) {
return item.recallStatus || '已撤回';
}
if (isImageFavorite(item)) {
return item.attachment.url;
}
if (isFileFavorite(item)) {
return `${item.attachment.name || '文件'} ${item.attachment.url}`.trim();
}
return item.preview || '';
}
function formatFileSize(size) {
const bytes = Number(size || 0);
if (bytes >= 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(bytes >= 10 * 1024 * 1024 ? 0 : 1)} MB`;
}
if (bytes >= 1024) {
return `${(bytes / 1024).toFixed(bytes >= 10 * 1024 ? 0 : 1)} KB`;
}
return bytes > 0 ? `${bytes} B` : '';
}
function formatFavoriteTime(timestamp) {
const value = Number(timestamp || 0);
if (!value) {
return '';
}
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
}).format(value);
}
async function copyFavorite() {
clearFeedback();
const text = favoriteCopyText(favorite.value || {}).trim();
if (!text) {
error.value = '没有可复制的文字内容';
return;
}
try {
await writeClipboard(text);
notice.value = '已复制';
} catch (copyError) {
error.value = '复制失败';
}
}
async function writeClipboard(text) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const element = document.createElement('textarea');
element.value = text;
element.setAttribute('readonly', 'readonly');
element.style.position = 'fixed';
element.style.left = '-9999px';
document.body.appendChild(element);
element.select();
document.execCommand('copy');
document.body.removeChild(element);
}
function openConversation() {
clearFeedback();
const item = favorite.value;
if (!item?.conversationId) {
error.value = '缺少原会话信息';
return;
}
const conversation = conversations.conversations.find(entry => entry.id === item.conversationId);
if (!conversation) {
error.value = '原会话暂未加载,请刷新会话后再试';
conversations.refreshRemoteConversations();
return;
}
navigation.showMessages();
conversations.setActiveConversation(item.conversationId);
window.setTimeout(() => {
conversations.focusMessage({
id: item.id || item.key,
messageUid: item.messageUid || item.key
});
}, 450);
}
function removeFavorite() {
clearFeedback();
const key = favorite.value?.key || '';
if (!key) {
return;
}
conversations.removeFavorite(key);
notice.value = '已取消收藏';
}
</script>
+103
View File
@@ -0,0 +1,103 @@
<template>
<aside class="conversation-list">
<header class="list-header">
<div>
<h2>联系人</h2>
<p>{{ contacts.contacts.length }} 位好友</p>
</div>
<div class="header-actions">
<button class="icon-button" title="添加联系人" @click="openAddContact">+</button>
<button class="icon-button" title="刷新" @click="contacts.refreshContacts({ force: true })">↻</button>
</div>
</header>
<div class="search-box">
<input v-model.trim="keyword" placeholder="搜索联系人">
</div>
<div class="sidebar-scroll">
<button
v-for="item in filteredContacts"
:key="item.id"
class="conversation-item"
:class="{ active: item.id === contacts.activeId }"
@click="contacts.setActiveContact(item.id)"
>
<img v-if="item.portrait" class="conversation-avatar image-avatar" :src="item.portrait" alt="">
<span v-else class="conversation-avatar">{{ item.avatarText }}</span>
<span class="conversation-copy">
<strong>{{ item.title }}</strong>
<small>{{ item.subtitle }}</small>
</span>
</button>
<div v-if="!filteredContacts.length" class="empty-list">
<strong>{{ contacts.loading ? '正在加载' : '暂无联系人' }}</strong>
<span>{{ contacts.loading ? '请稍候' : '好友同步完成后会显示在这里' }}</span>
</div>
</div>
<div v-if="addingContact" class="modal-backdrop" @click.self="closeAddContact">
<form class="modal-panel" @submit.prevent="submitAddContact">
<header>
<strong>添加联系人</strong>
<button type="button" class="icon-button" title="关闭" @click="closeAddContact">×</button>
</header>
<label>
<span>用户ID / 手机号 / 用户名</span>
<input v-model.trim="addKeyword" autocomplete="off" placeholder="请输入要添加的联系人">
</label>
<p v-if="contacts.actionError" class="form-error">{{ contacts.actionError }}</p>
<p v-if="contacts.actionInfo" class="form-info">{{ contacts.actionInfo }}</p>
<div class="modal-actions">
<button type="button" class="ghost-inline-action" @click="closeAddContact">取消</button>
<button class="primary-inline-action" :disabled="contacts.loading || !addKeyword">
{{ contacts.loading ? '添加中...' : '添加' }}
</button>
</div>
</form>
</div>
</aside>
</template>
<script setup>
import { computed, ref } from 'vue';
import { useContactStore } from '@/stores/contacts';
const contacts = useContactStore();
const keyword = ref('');
const addingContact = ref(false);
const addKeyword = ref('');
const filteredContacts = computed(() => {
const value = keyword.value.toLowerCase();
if (!value) {
return contacts.contacts;
}
return contacts.contacts.filter(item => {
return item.title.toLowerCase().includes(value)
|| item.subtitle.toLowerCase().includes(value)
|| item.uid.toLowerCase().includes(value);
});
});
function openAddContact() {
contacts.actionError = '';
contacts.actionInfo = '';
addKeyword.value = '';
addingContact.value = true;
}
function closeAddContact() {
addingContact.value = false;
}
async function submitAddContact() {
const added = await contacts.addContact(addKeyword.value);
if (added) {
window.setTimeout(() => {
addingContact.value = false;
}, 500);
}
}
</script>
+525
View File
@@ -0,0 +1,525 @@
<template>
<aside class="conversation-list">
<header class="list-header">
<div>
<h2>消息</h2>
<p>{{ conversations.totalUnread }} 条未读</p>
</div>
<div class="header-actions action-menu-anchor">
<button class="icon-button" title="新建会话" @click.stop="toggleActionMenu">+</button>
<div v-if="actionMenuOpen" class="quick-action-menu" @click.stop>
<button type="button" @click="openAction('addContact')">添加联系人</button>
<button type="button" @click="openAction('createGroup')">新建群组</button>
<button type="button" @click="openAction('joinGroup')">加入群组</button>
<button type="button" @click="openAction('broadcast')">群发助手</button>
<button type="button" @click="openAction('createChannel')">创建频道/公告群</button>
</div>
</div>
</header>
<div class="search-box">
<input v-model.trim="searchKeyword" placeholder="搜索会话、联系人">
</div>
<div class="sidebar-scroll">
<button
v-for="item in filteredConversationItems"
:key="item.resultKey"
class="conversation-item"
:class="{ active: isListItemActive(item) }"
@click="selectListItem(item)"
>
<img v-if="item.portrait" class="conversation-avatar image-avatar" :src="item.portrait" alt="">
<span v-else class="conversation-avatar">{{ item.avatarText }}</span>
<span class="conversation-copy">
<strong>{{ item.title }}</strong>
<small>{{ item.subtitle }}</small>
</span>
<span
v-if="Number(item.unread || 0) > 0"
class="unread-badge"
:aria-label="`${item.unread} 条未读消息`"
>
{{ formatUnread(item.unread) }}
</span>
</button>
<div v-if="!filteredConversationItems.length && searchKeyword" class="empty-list">
<strong>没有匹配结果</strong>
<span>换个关键词再试试</span>
</div>
<div v-else-if="!conversations.conversations.length && !conversations.usingDemoData" class="empty-list">
<strong>暂无会话</strong>
<span>收到或发起消息后会出现在这里</span>
</div>
</div>
<div v-if="activeAction" class="modal-backdrop" @click.self="closeActionModal">
<form class="modal-panel conversation-action-panel" @submit.prevent="submitAction">
<header>
<strong>{{ actionTitle }}</strong>
<button type="button" class="icon-button" title="关闭" @click="closeActionModal">×</button>
</header>
<template v-if="activeAction === 'addContact'">
<label>
<span>用户ID / 手机号 / 用户名</span>
<input v-model.trim="addKeyword" autocomplete="off" placeholder="请输入要添加的联系人">
</label>
</template>
<template v-else-if="activeAction === 'createGroup'">
<label>
<span>群组名称</span>
<input v-model.trim="groupName" maxlength="64" autocomplete="off" placeholder="请输入群组名称">
</label>
<label>
<span>群说明</span>
<textarea v-model.trim="groupAnnouncement" maxlength="1000" rows="3" placeholder="可选"></textarea>
</label>
<label>
<span>欢迎词</span>
<textarea v-model.trim="groupWelcomeText" maxlength="1000" rows="3" placeholder="群组创建后自动发送"></textarea>
</label>
<ContactPicker
:contacts="filteredContacts"
:selected-ids="selectedContactIds"
:keyword="contactKeyword"
empty-text="暂无可选联系人"
@update:keyword="contactKeyword = $event"
@toggle="toggleContact"
/>
</template>
<template v-else-if="activeAction === 'joinGroup'">
<label>
<span>群组ID / 群名称</span>
<input v-model.trim="joinGroupKey" autocomplete="off" placeholder="请输入群组ID或群名称">
</label>
</template>
<template v-else-if="activeAction === 'broadcast'">
<label>
<span>群发内容</span>
<textarea v-model.trim="broadcastText" maxlength="2000" rows="4" placeholder="输入要群发的文字"></textarea>
</label>
<ContactPicker
:contacts="filteredContacts"
:selected-ids="selectedContactIds"
:keyword="contactKeyword"
empty-text="暂无可选联系人"
@update:keyword="contactKeyword = $event"
@toggle="toggleContact"
/>
</template>
<template v-else-if="activeAction === 'createChannel'">
<label>
<span>频道/公告群名称</span>
<input v-model.trim="channelName" maxlength="64" autocomplete="off" placeholder="请输入名称">
</label>
<label>
<span>公告说明</span>
<textarea v-model.trim="channelAnnouncement" maxlength="1000" rows="3" placeholder="可选"></textarea>
</label>
<ContactPicker
:contacts="filteredContacts"
:selected-ids="selectedContactIds"
:keyword="contactKeyword"
empty-text="暂无可选联系人"
@update:keyword="contactKeyword = $event"
@toggle="toggleContact"
/>
</template>
<p v-if="modalError" class="form-error">{{ modalError }}</p>
<p v-else-if="modalInfo" class="form-info">{{ modalInfo }}</p>
<div class="modal-actions">
<button type="button" class="ghost-inline-action" @click="closeActionModal">取消</button>
<button class="primary-inline-action" :disabled="actionBusy || submitDisabled">
{{ actionBusy ? '处理中...' : submitLabel }}
</button>
</div>
</form>
</div>
</aside>
</template>
<script setup>
import { computed, defineComponent, h, onBeforeUnmount, onMounted, ref } from 'vue';
import { useConversationStore } from '@/stores/conversations';
import { useContactStore } from '@/stores/contacts';
const conversations = useConversationStore();
const contacts = useContactStore();
const actionMenuOpen = ref(false);
const activeAction = ref('');
const actionBusy = ref(false);
const searchKeyword = ref('');
const addKeyword = ref('');
const groupName = ref('');
const groupAnnouncement = ref('');
const groupWelcomeText = ref('');
const joinGroupKey = ref('');
const broadcastText = ref('');
const channelName = ref('');
const channelAnnouncement = ref('');
const contactKeyword = ref('');
const selectedContactIds = ref([]);
const actionTitles = {
addContact: '添加联系人',
createGroup: '新建群组',
joinGroup: '加入群组',
broadcast: '群发助手',
createChannel: '创建频道/公告群'
};
const submitLabels = {
addContact: '添加',
createGroup: '创建',
joinGroup: '加入',
broadcast: '发送',
createChannel: '创建'
};
const actionTitle = computed(() => actionTitles[activeAction.value] || '');
const submitLabel = computed(() => submitLabels[activeAction.value] || '确定');
const modalError = computed(() => contacts.actionError || conversations.actionError);
const modalInfo = computed(() => contacts.actionInfo || conversations.actionNotice);
const normalizedSearchKeyword = computed(() => searchKeyword.value.trim().toLowerCase());
const contactsByUid = computed(() => {
const map = new Map();
contacts.contacts.forEach(contact => {
const uid = String(contact.uid || contact.id || '');
if (uid) {
map.set(uid, contact);
}
});
return map;
});
const filteredConversationItems = computed(() => {
const keyword = normalizedSearchKeyword.value;
if (!keyword) {
return conversations.conversations.map(item => conversationResult(item));
}
const conversationResults = conversations.conversations
.filter(item => matchesConversation(item, keyword, contactForConversation(item)))
.map(item => conversationResult(item));
const conversationTargets = new Set(conversations.conversations
.filter(item => item.type === 'single')
.map(item => conversationTarget(item)));
const contactResults = contacts.contacts
.filter(item => !conversationTargets.has(String(item.uid || item.id || '')))
.filter(item => matchesContact(item, keyword))
.map(item => contactResult(item));
return [...conversationResults, ...contactResults];
});
const filteredContacts = computed(() => {
const keyword = contactKeyword.value.toLowerCase();
if (!keyword) {
return contacts.contacts;
}
return contacts.contacts.filter(item => {
return String(item.title || '').toLowerCase().includes(keyword)
|| String(item.subtitle || '').toLowerCase().includes(keyword)
|| String(item.uid || '').toLowerCase().includes(keyword);
});
});
const submitDisabled = computed(() => {
if (activeAction.value === 'addContact') {
return !addKeyword.value;
}
if (activeAction.value === 'createGroup') {
return !groupName.value || !selectedContactIds.value.length;
}
if (activeAction.value === 'joinGroup') {
return !joinGroupKey.value;
}
if (activeAction.value === 'broadcast') {
return !broadcastText.value || !selectedContactIds.value.length;
}
if (activeAction.value === 'createChannel') {
return !channelName.value;
}
return true;
});
onMounted(() => {
contacts.refreshContacts().catch(() => {});
});
const ContactPicker = defineComponent({
name: 'ContactPicker',
props: {
contacts: {
type: Array,
default: () => []
},
selectedIds: {
type: Array,
default: () => []
},
keyword: {
type: String,
default: ''
},
emptyText: {
type: String,
default: '暂无联系人'
}
},
emits: ['toggle', 'update:keyword'],
setup(props, { emit }) {
return () => h('div', { class: 'contact-picker' }, [
h('input', {
value: props.keyword,
placeholder: '搜索联系人',
onInput: event => emit('update:keyword', event.target.value)
}),
h('div', { class: 'contact-picker-list' }, props.contacts.length
? props.contacts.map(contact => h('button', {
key: contact.uid,
type: 'button',
class: ['contact-picker-row', { selected: props.selectedIds.includes(contact.uid) }],
onClick: () => emit('toggle', contact.uid)
}, [
h('span', { class: 'contact-check' }, props.selectedIds.includes(contact.uid) ? '✓' : ''),
contact.portrait
? h('img', { class: 'conversation-avatar image-avatar', src: contact.portrait, alt: '' })
: h('span', { class: 'conversation-avatar' }, contact.avatarText),
h('span', { class: 'contact-picker-copy' }, [
h('strong', contact.title),
h('small', contact.subtitle)
])
]))
: [h('div', { class: 'contact-picker-empty' }, props.emptyText)])
]);
}
});
function searchableValues(...values) {
return values
.flatMap(value => {
if (Array.isArray(value)) {
return value;
}
if (value && typeof value === 'object') {
return Object.values(value);
}
return [value];
})
.map(value => String(value || '').toLowerCase())
.filter(Boolean);
}
function matchesKeyword(values, keyword) {
const compactKeyword = keyword.replace(/[^\p{L}\p{N}]+/gu, '');
return searchableValues(values).some(value => {
if (value.includes(keyword)) {
return true;
}
if (!compactKeyword) {
return false;
}
return value.replace(/[^\p{L}\p{N}]+/gu, '').includes(compactKeyword);
});
}
function conversationTarget(item) {
if (item.type !== 'single') {
return String(item.target || item.raw?.target || item.id || '');
}
return String(item.target || item.raw?.target || item.id.replace(/^single-/, '') || '');
}
function contactForConversation(item) {
const target = conversationTarget(item);
return target ? contactsByUid.value.get(target) || null : null;
}
function matchesConversation(item, keyword, contact = null) {
return matchesKeyword([
item.title,
item.subtitle,
item.target,
item.id,
item.type,
item.raw?.target,
item.raw?.groupId,
item.raw?.group_id,
item.raw?.owner_uid,
item.raw?.name,
item.raw?.displayName,
item.raw?.mobile,
item.raw?.friendAlias,
item.raw?.alias,
contact?.title,
contact?.subtitle,
contact?.uid,
contact?.id,
contact?.alias,
contact?.address,
contact?.rawUser,
contact?.rawFriend
], keyword);
}
function matchesContact(item, keyword) {
return matchesKeyword([
item.title,
item.subtitle,
item.uid,
item.id,
item.alias,
item.address,
item.rawUser,
item.rawFriend
], keyword);
}
function conversationResult(item) {
return {
...item,
resultKind: 'conversation',
resultKey: `conversation-${item.id}`
};
}
function contactResult(contact) {
return {
id: contact.id,
resultKind: 'contact',
resultKey: `contact-${contact.uid || contact.id}`,
contact,
title: contact.title,
subtitle: contact.subtitle,
portrait: contact.portrait,
avatarText: contact.avatarText,
unread: 0
};
}
function isListItemActive(item) {
if (item.resultKind === 'conversation') {
return item.id === conversations.activeId;
}
return `single-${item.contact?.uid || item.id}` === conversations.activeId;
}
function selectListItem(item) {
if (item.resultKind === 'contact') {
contacts.openChat(item.contact);
return;
}
conversations.setActiveConversation(item.id);
}
function clearStoreMessages() {
contacts.actionError = '';
contacts.actionInfo = '';
conversations.actionError = '';
conversations.actionNotice = '';
}
function resetActionFields() {
addKeyword.value = '';
groupName.value = '';
groupAnnouncement.value = '';
groupWelcomeText.value = '';
joinGroupKey.value = '';
broadcastText.value = '';
channelName.value = '';
channelAnnouncement.value = '';
contactKeyword.value = '';
selectedContactIds.value = [];
clearStoreMessages();
}
function toggleActionMenu() {
actionMenuOpen.value = !actionMenuOpen.value;
}
function closeActionMenu() {
actionMenuOpen.value = false;
}
function openAction(action) {
resetActionFields();
activeAction.value = action;
actionMenuOpen.value = false;
if (['createGroup', 'broadcast', 'createChannel'].includes(action)) {
contacts.refreshContacts({ force: true });
}
}
function closeActionModal() {
activeAction.value = '';
actionBusy.value = false;
resetActionFields();
}
function toggleContact(contactId) {
const selected = new Set(selectedContactIds.value);
if (selected.has(contactId)) {
selected.delete(contactId);
} else {
selected.add(contactId);
}
selectedContactIds.value = Array.from(selected);
}
async function submitAction() {
if (submitDisabled.value || actionBusy.value) {
return;
}
actionBusy.value = true;
clearStoreMessages();
try {
let ok = false;
if (activeAction.value === 'addContact') {
ok = await contacts.addContact(addKeyword.value);
if (ok && contacts.activeContact) {
contacts.openChat(contacts.activeContact);
}
} else if (activeAction.value === 'createGroup') {
ok = await conversations.createGroup({
name: groupName.value,
announcement: groupAnnouncement.value,
welcomeText: groupWelcomeText.value,
memberIds: selectedContactIds.value
});
} else if (activeAction.value === 'joinGroup') {
ok = await conversations.joinGroup(joinGroupKey.value);
} else if (activeAction.value === 'broadcast') {
ok = await conversations.broadcastMessage(selectedContactIds.value, broadcastText.value);
} else if (activeAction.value === 'createChannel') {
ok = await conversations.createGroup({
name: channelName.value,
memberIds: selectedContactIds.value,
announcement: channelAnnouncement.value,
channel: true
});
}
if (ok) {
window.setTimeout(closeActionModal, 450);
}
} finally {
actionBusy.value = false;
}
}
document.addEventListener('click', closeActionMenu);
onBeforeUnmount(() => {
document.removeEventListener('click', closeActionMenu);
});
function formatUnread(value) {
const count = Number(value || 0);
return count > 99 ? '99+' : String(count);
}
</script>
+724
View File
@@ -0,0 +1,724 @@
<template>
<aside class="navigation-rail">
<button type="button" class="rail-avatar rail-avatar-button" title="编辑个人资料" @click="openProfileEditor">
<img v-if="session.portrait" :src="session.portrait" alt="">
<span v-else>{{ initials }}</span>
</button>
<button class="rail-button" :class="{ active: navigation.section === 'messages' }" title="消息" @click="navigation.showMessages">💬</button>
<button class="rail-button" :class="{ active: navigation.section === 'contacts' }" title="联系人" @click="showContacts">👥</button>
<button class="rail-button" :class="{ active: navigation.section === 'favorites' }" title="收藏" @click="navigation.showFavorites">☆</button>
<div class="rail-spacer"></div>
<button class="rail-button" title="设置" aria-label="设置" @click="openSettings">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 8.2a3.8 3.8 0 1 1 0 7.6 3.8 3.8 0 0 1 0-7.6Z"></path>
<path d="M19.4 13.6a7.8 7.8 0 0 0 .1-1.6l1.7-1.3-1.7-3-2 .8a8 8 0 0 0-1.4-.8L15.8 5h-3.5l-.3 2.1a8 8 0 0 0-1.5.8l-1.9-.8-1.8 3 1.7 1.3a7.8 7.8 0 0 0 0 1.6l-1.7 1.3 1.8 3 1.9-.8c.5.4 1 .7 1.5.8l.3 2.1h3.5l.3-2.1c.5-.2 1-.5 1.4-.8l2 .8 1.7-3-1.8-1.3Z"></path>
</svg>
</button>
<button class="rail-button rail-logout-button" title="退出登录" aria-label="退出登录" @click="logout">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4.5 20.5V3.5h9.2a1.8 1.8 0 0 1 1.8 1.8v13.4a1.8 1.8 0 0 1-1.8 1.8H4.5Z"></path>
<path d="M9 12h9.5"></path>
<path d="m15.5 8.8 3.2 3.2-3.2 3.2"></path>
<path d="M7.8 20.5V5.8l6.5-2.3"></path>
</svg>
</button>
</aside>
<div v-if="settingsOpen" class="modal-backdrop settings-backdrop" @click.self="closeSettings">
<section class="settings-panel">
<header>
<strong>设置</strong>
<button type="button" class="icon-button" title="关闭" @click="closeSettings">×</button>
</header>
<div class="settings-layout">
<nav class="settings-menu" aria-label="设置菜单">
<button
v-for="item in settingsItems"
:key="item.key"
type="button"
:class="{ active: settingsSection === item.key }"
@click="selectSettingsSection(item.key)"
>
<span>{{ item.label }}</span>
<small v-if="item.key !== 'password'">稍后开放</small>
</button>
</nav>
<form v-if="settingsSection === 'password'" class="settings-password-form" @submit.prevent="savePassword">
<h3>更改密码</h3>
<label>
<span>当前密码</span>
<input v-model="passwordDraft.oldPassword" type="password" autocomplete="current-password">
</label>
<label>
<span>新密码</span>
<input v-model="passwordDraft.newPassword" type="password" autocomplete="new-password">
</label>
<label>
<span>确认新密码</span>
<input v-model="passwordDraft.confirmPassword" type="password" autocomplete="new-password">
</label>
<footer>
<span v-if="settingsNotice" class="inline-save-status">{{ settingsNotice }}</span>
<span v-else-if="settingsError" class="inline-save-status error">{{ settingsError }}</span>
<button type="submit" class="primary-inline-action" :disabled="passwordSaving">
{{ passwordSaving ? '保存中...' : '保存密码' }}
</button>
</footer>
</form>
<section v-else class="settings-placeholder">
<h3>{{ activeSettingsItem?.label || '设置' }}</h3>
<p>该功能稍后开放。</p>
</section>
</div>
</section>
</div>
<div v-if="profileOpen" class="modal-backdrop" @click.self="closeProfileEditor">
<form class="modal-panel profile-edit-panel" @submit.prevent="saveProfile">
<header>
<strong>个人资料</strong>
<button type="button" class="icon-button" title="关闭" @click="closeProfileEditor">×</button>
</header>
<div class="profile-avatar-field">
<button
type="button"
class="profile-avatar-preview"
:class="{ clickable: currentAvatarUrl }"
title="预览头像"
@click="openAvatarPreview"
>
<img v-if="currentAvatarUrl" :src="currentAvatarUrl" alt="">
<span v-else>{{ initials }}</span>
</button>
<button type="button" class="primary-inline-action profile-avatar-change-button" @click="choosePortraitFile">更换头像</button>
<input ref="portraitInput" type="file" accept="image/*" @change="selectPortrait">
</div>
<label>
<span>昵称</span>
<input v-model.trim="profileDraft.displayName" maxlength="64" autocomplete="off">
</label>
<label>
<span>用户ID</span>
<input :value="session.userId" readonly>
</label>
<div class="modal-actions">
<button type="button" class="ghost-inline-action" @click="closeProfileEditor">取消</button>
<span v-if="profileNotice" class="inline-save-status">{{ profileNotice }}</span>
<span v-else-if="profileError" class="inline-save-status error">{{ profileError }}</span>
<button class="primary-inline-action" :disabled="profileSaving || !profileDraft.displayName">
{{ profileSaving ? '保存中...' : '保存' }}
</button>
</div>
</form>
</div>
<div v-if="avatarPreviewViewer.open" class="modal-backdrop avatar-preview-backdrop" @click.self="closeAvatarPreview">
<section class="avatar-preview-dialog">
<header>
<strong>头像预览</strong>
<div class="avatar-preview-actions">
<button type="button" title="缩小" @click="zoomAvatarPreview(1 / 1.16)">−</button>
<button type="button" title="放大" @click="zoomAvatarPreview(1.16)">+</button>
<button type="button" title="还原" @click="resetAvatarPreview">1:1</button>
<button type="button" class="icon-button" title="关闭" @click="closeAvatarPreview">×</button>
</div>
</header>
<div
class="avatar-preview-stage"
:class="{ dragging: avatarPreviewViewer.dragging }"
@wheel.prevent="handleAvatarPreviewWheel"
@pointerdown.prevent="startAvatarPreviewDrag"
@pointermove.prevent="moveAvatarPreviewDrag"
@pointerup.prevent="stopAvatarPreviewDrag"
@pointerleave.prevent="stopAvatarPreviewDrag"
>
<img
:src="avatarPreviewViewer.url"
alt="头像预览"
:style="avatarPreviewStyle"
draggable="false"
>
</div>
</section>
</div>
<div v-if="avatarCropper.open" class="modal-backdrop avatar-cropper-backdrop" @click.self="closeAvatarCropper">
<section class="modal-panel avatar-cropper-panel">
<header>
<strong>头像预裁切</strong>
<div class="avatar-cropper-header-actions">
<button type="button" class="primary-inline-action" :disabled="avatarCropper.busy" @click="choosePortraitFile">上传</button>
<button type="button" class="icon-button" title="关闭" @click="closeAvatarCropper">×</button>
</div>
</header>
<div class="avatar-cropper-layout">
<div
class="avatar-cropper-stage"
:class="{ dragging: avatarCropper.dragging }"
@wheel.prevent="handleCropperWheel"
@pointerdown.prevent="startCropperDrag"
@pointermove.prevent="moveCropperDrag"
@pointerup.prevent="stopCropperDrag"
@pointerleave.prevent="stopCropperDrag"
@dragover.prevent
@drop.prevent="dropPortrait"
>
<canvas ref="cropperCanvas"></canvas>
<div class="avatar-cropper-mask" aria-hidden="true"></div>
<div class="avatar-cropper-box" aria-hidden="true"></div>
<div v-if="!avatarCropper.loaded" class="avatar-cropper-placeholder">图片加载中</div>
</div>
<aside class="avatar-cropper-controls">
<div class="avatar-cropper-tools">
<button type="button" title="左旋90度" @click="rotateCropper(-90)">↶90°</button>
<button type="button" title="右旋90度" @click="rotateCropper(90)">↷90°</button>
<button type="button" title="放大" @click="zoomCropper(1.12)">+</button>
<button type="button" title="缩小" @click="zoomCropper(1 / 1.12)">−</button>
<button type="button" title="重置" @click="resetCropperTransform">重置</button>
</div>
<div class="avatar-cropper-preview">
<canvas ref="cropperPreviewCanvas"></canvas>
</div>
<footer>
<span v-if="avatarCropper.error" class="inline-save-status error">{{ avatarCropper.error }}</span>
<button type="button" class="ghost-inline-action" :disabled="avatarCropper.busy" @click="closeAvatarCropper">取消</button>
<button type="button" class="primary-inline-action" :disabled="avatarCropper.busy || !avatarCropper.loaded" @click="confirmAvatarCrop">
{{ avatarCropper.busy ? '处理中...' : '确认裁切' }}
</button>
</footer>
</aside>
</div>
</section>
</div>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { useSessionStore } from '@/stores/session';
import { useNavigationStore } from '@/stores/navigation';
import { useContactStore } from '@/stores/contacts';
const session = useSessionStore();
const navigation = useNavigationStore();
const contacts = useContactStore();
const settingsItems = [
{ key: 'notifications', label: '通知设置' },
{ key: 'password', label: '更改密码' },
{ key: 'wallet', label: '钱包管理' },
{ key: 'language', label: '切换语言' },
{ key: 'feedback', label: '问题反馈' }
];
const initials = computed(() => (session.displayName || session.userId || 'Q').slice(0, 1).toUpperCase());
const settingsOpen = ref(false);
const settingsSection = ref('password');
const passwordSaving = ref(false);
const settingsNotice = ref('');
const settingsError = ref('');
const profileOpen = ref(false);
const profileSaving = ref(false);
const profileNotice = ref('');
const profileError = ref('');
const profileFile = ref(null);
const profilePreview = ref('');
const portraitInput = ref(null);
const cropperCanvas = ref(null);
const cropperPreviewCanvas = ref(null);
const profileDraft = ref({
displayName: ''
});
const passwordDraft = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: ''
});
const currentAvatarUrl = computed(() => profilePreview.value || session.portrait || '');
const activeSettingsItem = computed(() => settingsItems.find(item => item.key === settingsSection.value) || settingsItems[0]);
const avatarPreviewViewer = reactive({
open: false,
url: '',
scale: 1,
offsetX: 0,
offsetY: 0,
dragging: false,
dragStartX: 0,
dragStartY: 0,
dragOffsetX: 0,
dragOffsetY: 0
});
const avatarCropper = reactive({
open: false,
loaded: false,
busy: false,
error: '',
objectUrl: '',
fileName: 'avatar.jpg',
image: null,
baseScale: 1,
scale: 1,
rotation: 0,
offsetX: 0,
offsetY: 0,
dragging: false,
dragStartX: 0,
dragStartY: 0,
dragOffsetX: 0,
dragOffsetY: 0
});
const avatarPreviewStyle = computed(() => ({
transform: `translate3d(${avatarPreviewViewer.offsetX}px, ${avatarPreviewViewer.offsetY}px, 0) scale(${avatarPreviewViewer.scale})`
}));
const CROP_OUTPUT_SIZE = 512;
onMounted(() => {
session.refreshProfile();
window.addEventListener('resize', drawAvatarCropper);
});
onBeforeUnmount(() => {
revokeProfilePreview();
closeAvatarCropper();
window.removeEventListener('resize', drawAvatarCropper);
});
function showContacts() {
navigation.showContacts();
contacts.refreshContacts();
}
function clearProfileFeedback() {
profileNotice.value = '';
profileError.value = '';
}
function clearSettingsFeedback() {
settingsNotice.value = '';
settingsError.value = '';
}
function resetPasswordDraft() {
passwordDraft.oldPassword = '';
passwordDraft.newPassword = '';
passwordDraft.confirmPassword = '';
}
function openSettings() {
settingsOpen.value = true;
settingsSection.value = 'password';
clearSettingsFeedback();
}
function closeSettings() {
if (passwordSaving.value) {
return;
}
settingsOpen.value = false;
clearSettingsFeedback();
resetPasswordDraft();
}
function selectSettingsSection(section) {
settingsSection.value = section;
clearSettingsFeedback();
}
function revokeProfilePreview() {
if (profilePreview.value) {
URL.revokeObjectURL(profilePreview.value);
profilePreview.value = '';
}
}
function revokeCropperObjectUrl() {
if (avatarCropper.objectUrl) {
URL.revokeObjectURL(avatarCropper.objectUrl);
avatarCropper.objectUrl = '';
}
}
async function openProfileEditor() {
profileOpen.value = true;
clearProfileFeedback();
profileDraft.value.displayName = session.displayName || session.userId || '';
const profile = await session.refreshProfile();
if (profileOpen.value && profile) {
profileDraft.value.displayName = session.displayName || session.userId || '';
}
}
function closeProfileEditor() {
profileOpen.value = false;
profileFile.value = null;
clearProfileFeedback();
revokeProfilePreview();
closeAvatarPreview();
closeAvatarCropper();
}
function selectPortrait(event) {
const file = event.target.files?.[0] || null;
event.target.value = '';
clearProfileFeedback();
if (file) {
openAvatarCropper(file);
}
}
function choosePortraitFile() {
portraitInput.value?.click();
}
function openAvatarPreview() {
if (!currentAvatarUrl.value) {
return;
}
avatarPreviewViewer.open = true;
avatarPreviewViewer.url = currentAvatarUrl.value;
resetAvatarPreview();
}
function closeAvatarPreview() {
avatarPreviewViewer.open = false;
avatarPreviewViewer.dragging = false;
}
function resetAvatarPreview() {
avatarPreviewViewer.scale = 1;
avatarPreviewViewer.offsetX = 0;
avatarPreviewViewer.offsetY = 0;
}
function zoomAvatarPreview(factor) {
avatarPreviewViewer.scale = Math.max(0.5, Math.min(6, Number((avatarPreviewViewer.scale * factor).toFixed(2))));
if (avatarPreviewViewer.scale <= 1) {
avatarPreviewViewer.offsetX = 0;
avatarPreviewViewer.offsetY = 0;
}
}
function handleAvatarPreviewWheel(event) {
zoomAvatarPreview(event.deltaY < 0 ? 1.12 : 1 / 1.12);
}
function startAvatarPreviewDrag(event) {
if (avatarPreviewViewer.scale <= 1) {
return;
}
avatarPreviewViewer.dragging = true;
avatarPreviewViewer.dragStartX = event.clientX;
avatarPreviewViewer.dragStartY = event.clientY;
avatarPreviewViewer.dragOffsetX = avatarPreviewViewer.offsetX;
avatarPreviewViewer.dragOffsetY = avatarPreviewViewer.offsetY;
event.currentTarget.setPointerCapture?.(event.pointerId);
}
function moveAvatarPreviewDrag(event) {
if (!avatarPreviewViewer.dragging) {
return;
}
avatarPreviewViewer.offsetX = avatarPreviewViewer.dragOffsetX + event.clientX - avatarPreviewViewer.dragStartX;
avatarPreviewViewer.offsetY = avatarPreviewViewer.dragOffsetY + event.clientY - avatarPreviewViewer.dragStartY;
}
function stopAvatarPreviewDrag() {
avatarPreviewViewer.dragging = false;
}
function dropPortrait(event) {
const file = Array.from(event.dataTransfer?.files || []).find(item => item.type.startsWith('image/'));
if (file) {
clearProfileFeedback();
openAvatarCropper(file);
}
}
function cropperStageSize() {
const canvas = cropperCanvas.value;
const rect = canvas?.getBoundingClientRect();
const width = Math.max(280, Math.round(rect?.width || 360));
const height = Math.max(280, Math.round(rect?.height || 360));
return { width, height };
}
function cropperBoxSize() {
const { width, height } = cropperStageSize();
return Math.round(Math.min(width, height) * 0.74);
}
function rotatedImageSize() {
const image = avatarCropper.image;
if (!image) {
return { width: 1, height: 1 };
}
const quarterTurn = Math.abs(avatarCropper.rotation % 180) === 90;
return {
width: quarterTurn ? image.naturalHeight : image.naturalWidth,
height: quarterTurn ? image.naturalWidth : image.naturalHeight
};
}
function clampCropperOffset() {
const cropSize = cropperBoxSize();
const rotated = rotatedImageSize();
const scaledWidth = rotated.width * avatarCropper.scale;
const scaledHeight = rotated.height * avatarCropper.scale;
const maxX = Math.max(0, (scaledWidth - cropSize) / 2);
const maxY = Math.max(0, (scaledHeight - cropSize) / 2);
avatarCropper.offsetX = Math.max(-maxX, Math.min(maxX, avatarCropper.offsetX));
avatarCropper.offsetY = Math.max(-maxY, Math.min(maxY, avatarCropper.offsetY));
}
function updateCropperBaseScale() {
const cropSize = cropperBoxSize();
const rotated = rotatedImageSize();
avatarCropper.baseScale = Math.max(cropSize / rotated.width, cropSize / rotated.height);
avatarCropper.scale = Math.max(avatarCropper.baseScale, avatarCropper.scale || avatarCropper.baseScale);
}
function drawCanvasImage(canvas, width, height, outputScale = 1, cropOnly = false) {
const image = avatarCropper.image;
if (!canvas || !image) {
return;
}
const dpr = cropOnly ? 1 : (window.devicePixelRatio || 1);
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
const context = canvas.getContext('2d');
context.setTransform(dpr, 0, 0, dpr, 0, 0);
context.clearRect(0, 0, width, height);
context.fillStyle = '#eef4f7';
context.fillRect(0, 0, width, height);
context.save();
context.translate(width / 2 + avatarCropper.offsetX * outputScale, height / 2 + avatarCropper.offsetY * outputScale);
context.rotate((avatarCropper.rotation * Math.PI) / 180);
context.scale(avatarCropper.scale * outputScale, avatarCropper.scale * outputScale);
context.drawImage(image, -image.naturalWidth / 2, -image.naturalHeight / 2);
context.restore();
if (!cropOnly) {
const cropSize = cropperBoxSize();
context.save();
context.fillStyle = 'rgba(10, 18, 30, 0.42)';
context.beginPath();
context.rect(0, 0, width, height);
context.rect((width - cropSize) / 2, (height - cropSize) / 2, cropSize, cropSize);
context.fill('evenodd');
context.restore();
}
}
function drawAvatarCropper() {
if (!avatarCropper.open || !avatarCropper.loaded) {
return;
}
clampCropperOffset();
const { width, height } = cropperStageSize();
drawCanvasImage(cropperCanvas.value, width, height);
const previewCanvas = cropperPreviewCanvas.value;
if (previewCanvas) {
drawCanvasImage(previewCanvas, 108, 108, 108 / cropperBoxSize(), true);
}
}
function resetCropperTransform() {
if (!avatarCropper.image) {
return;
}
avatarCropper.rotation = 0;
avatarCropper.offsetX = 0;
avatarCropper.offsetY = 0;
updateCropperBaseScale();
avatarCropper.scale = avatarCropper.baseScale;
drawAvatarCropper();
}
function openAvatarCropper(file) {
if (!file.type.startsWith('image/')) {
profileError.value = '请选择图片文件';
return;
}
if (file.size > 8 * 1024 * 1024) {
profileError.value = '头像图片不能超过8MB';
return;
}
closeAvatarCropper();
avatarCropper.open = true;
avatarCropper.loaded = false;
avatarCropper.error = '';
avatarCropper.fileName = file.name || 'avatar.jpg';
avatarCropper.objectUrl = URL.createObjectURL(file);
const image = new Image();
image.onload = () => {
avatarCropper.image = image;
avatarCropper.loaded = true;
nextTick(resetCropperTransform);
};
image.onerror = () => {
avatarCropper.error = '图片加载失败';
avatarCropper.loaded = false;
};
image.src = avatarCropper.objectUrl;
}
function closeAvatarCropper() {
avatarCropper.open = false;
avatarCropper.loaded = false;
avatarCropper.busy = false;
avatarCropper.error = '';
avatarCropper.image = null;
avatarCropper.dragging = false;
revokeCropperObjectUrl();
}
function zoomCropper(factor) {
if (!avatarCropper.loaded) {
return;
}
updateCropperBaseScale();
avatarCropper.scale = Math.max(avatarCropper.baseScale, Math.min(avatarCropper.baseScale * 6, avatarCropper.scale * factor));
drawAvatarCropper();
}
function rotateCropper(degrees) {
if (!avatarCropper.loaded) {
return;
}
avatarCropper.rotation = (avatarCropper.rotation + degrees + 360) % 360;
updateCropperBaseScale();
clampCropperOffset();
drawAvatarCropper();
}
function handleCropperWheel(event) {
zoomCropper(event.deltaY < 0 ? 1.08 : 1 / 1.08);
}
function startCropperDrag(event) {
if (!avatarCropper.loaded) {
return;
}
avatarCropper.dragging = true;
avatarCropper.dragStartX = event.clientX;
avatarCropper.dragStartY = event.clientY;
avatarCropper.dragOffsetX = avatarCropper.offsetX;
avatarCropper.dragOffsetY = avatarCropper.offsetY;
event.currentTarget.setPointerCapture?.(event.pointerId);
}
function moveCropperDrag(event) {
if (!avatarCropper.dragging) {
return;
}
avatarCropper.offsetX = avatarCropper.dragOffsetX + event.clientX - avatarCropper.dragStartX;
avatarCropper.offsetY = avatarCropper.dragOffsetY + event.clientY - avatarCropper.dragStartY;
drawAvatarCropper();
}
function stopCropperDrag() {
avatarCropper.dragging = false;
}
function canvasToBlob(canvas, type = 'image/jpeg', quality = 0.9) {
return new Promise((resolve, reject) => {
canvas.toBlob(blob => {
if (blob) {
resolve(blob);
} else {
reject(new Error('头像裁切失败'));
}
}, type, quality);
});
}
async function confirmAvatarCrop() {
if (!avatarCropper.loaded || avatarCropper.busy) {
return;
}
avatarCropper.busy = true;
avatarCropper.error = '';
try {
const outputCanvas = document.createElement('canvas');
drawCanvasImage(outputCanvas, CROP_OUTPUT_SIZE, CROP_OUTPUT_SIZE, CROP_OUTPUT_SIZE / cropperBoxSize(), true);
const blob = await canvasToBlob(outputCanvas);
const fileName = avatarCropper.fileName.replace(/\.[^.]+$/, '') || 'avatar';
const croppedFile = new File([blob], `${fileName}.jpg`, { type: 'image/jpeg' });
profileFile.value = croppedFile;
revokeProfilePreview();
profilePreview.value = URL.createObjectURL(croppedFile);
closeAvatarCropper();
} catch (error) {
avatarCropper.error = error.message || '头像裁切失败';
} finally {
avatarCropper.busy = false;
}
}
async function saveProfile() {
if (profileSaving.value || !profileDraft.value.displayName) {
return;
}
clearProfileFeedback();
profileSaving.value = true;
try {
await session.saveProfile({
displayName: profileDraft.value.displayName,
portraitFile: profileFile.value
});
profileFile.value = null;
revokeProfilePreview();
profileNotice.value = '资料已保存';
contacts.refreshContacts({ force: true });
} catch (error) {
profileError.value = error.message || '资料保存失败';
} finally {
profileSaving.value = false;
}
}
async function savePassword() {
if (passwordSaving.value) {
return;
}
clearSettingsFeedback();
const oldPassword = passwordDraft.oldPassword;
const newPassword = passwordDraft.newPassword;
const confirmPassword = passwordDraft.confirmPassword;
if (!oldPassword || !newPassword || !confirmPassword) {
settingsError.value = '请填写完整密码';
return;
}
if (newPassword !== confirmPassword) {
settingsError.value = '两次输入的新密码不一致';
return;
}
if (oldPassword === newPassword) {
settingsError.value = '新密码不能和当前密码相同';
return;
}
passwordSaving.value = true;
try {
await session.changePassword({ oldPassword, newPassword });
resetPasswordDraft();
settingsNotice.value = '密码已更新';
} catch (error) {
settingsError.value = error.message || '密码修改失败';
} finally {
passwordSaving.value = false;
}
}
function logout() {
closeSettings();
contacts.clearContacts();
session.logout();
}
</script>
+36
View File
@@ -0,0 +1,36 @@
const currentHost = window.location.hostname || '127.0.0.1';
const runtimeConfig = window.JC_WEB_IM_CONFIG || {};
const appServerOrigin = `${window.location.protocol}//${currentHost}:8888`;
const qrAppServerOrigin = `${window.location.protocol}//${currentHost}:18888`;
const adminServerOrigin = `${window.location.protocol}//${currentHost}:8024`;
export const appConfig = {
appName: 'Web IM',
adminServer: runtimeConfig.adminServer || process.env.VUE_APP_ADMIN_SERVER || adminServerOrigin,
appServer: runtimeConfig.appServer || process.env.VUE_APP_APP_SERVER || appServerOrigin,
qrAppServer: runtimeConfig.qrAppServer || process.env.VUE_APP_QR_APP_SERVER || qrAppServerOrigin,
platformConfigApi: runtimeConfig.platformConfigApi || process.env.VUE_APP_PLATFORM_CONFIG_API || (process.env.NODE_ENV === 'development' ? '/local-api/platform_config.php' : '/api/platform_config.php'),
registerConfigApi: runtimeConfig.registerConfigApi || process.env.VUE_APP_REGISTER_CONFIG_API || `${adminServerOrigin}/api/register-config`,
registerPhoneExistsApi: runtimeConfig.registerPhoneExistsApi || process.env.VUE_APP_REGISTER_PHONE_EXISTS_API || `${adminServerOrigin}/api/register-phone-exists`,
contactsApi: runtimeConfig.contactsApi || process.env.VUE_APP_CONTACTS_API || (process.env.NODE_ENV === 'development' ? '/local-api/contacts.php' : '/api/contacts.php'),
updateAliasApi: runtimeConfig.updateAliasApi || process.env.VUE_APP_UPDATE_ALIAS_API || (process.env.NODE_ENV === 'development' ? '/local-api/update_alias.php' : '/api/update_alias.php'),
addContactApi: runtimeConfig.addContactApi || process.env.VUE_APP_ADD_CONTACT_API || (process.env.NODE_ENV === 'development' ? '/local-api/add_contact.php' : '/api/add_contact.php'),
conversationActionsApi: runtimeConfig.conversationActionsApi || process.env.VUE_APP_CONVERSATION_ACTIONS_API || (process.env.NODE_ENV === 'development' ? '/local-api/conversation_actions.php' : '/api/conversation_actions.php'),
sendMessageApi: runtimeConfig.sendMessageApi || process.env.VUE_APP_SEND_MESSAGE_API || (process.env.NODE_ENV === 'development' ? '/local-api/send_message.php' : '/api/send_message.php'),
messageActionApi: runtimeConfig.messageActionApi || process.env.VUE_APP_MESSAGE_ACTION_API || (process.env.NODE_ENV === 'development' ? '/local-api/message_action.php' : '/api/message_action.php'),
messageDetailApi: runtimeConfig.messageDetailApi || process.env.VUE_APP_MESSAGE_DETAIL_API || (process.env.NODE_ENV === 'development' ? '/local-api/message_detail.php' : '/api/message_detail.php'),
messagesApi: runtimeConfig.messagesApi || process.env.VUE_APP_MESSAGES_API || (process.env.NODE_ENV === 'development' ? '/local-api/messages.php' : '/api/messages.php'),
profileApi: runtimeConfig.profileApi || process.env.VUE_APP_PROFILE_API || (process.env.NODE_ENV === 'development' ? '/local-api/profile.php' : '/api/profile.php'),
changePasswordApi: runtimeConfig.changePasswordApi || process.env.VUE_APP_CHANGE_PASSWORD_API || `${appServerOrigin}/auth/changePassword`,
logoutApi: runtimeConfig.logoutApi || process.env.VUE_APP_LOGOUT_API || `${appServerOrigin}/auth/logout`,
imServerHost: runtimeConfig.imServerHost || process.env.VUE_APP_IM_SERVER_HOST || currentHost,
imRoutePort: Number(runtimeConfig.imRoutePort || process.env.VUE_APP_IM_ROUTE_PORT || 18000),
useWss: runtimeConfig.useWss ?? process.env.VUE_APP_USE_WSS === 'true',
wsPath: runtimeConfig.wsPath || process.env.VUE_APP_WS_PATH || '/ws',
wsUrl: runtimeConfig.wsUrl || process.env.VUE_APP_WS_URL || '',
sdkMode: runtimeConfig.sdkMode || process.env.VUE_APP_IM_MODE || 'http',
syncIntervalMs: Number(runtimeConfig.syncIntervalMs || process.env.VUE_APP_SYNC_INTERVAL_MS || 300),
platform: 5,
clientIdStorageKey: 'qmt-web-im-client-id',
authTokenStoragePrefix: 'qmt-web-im-auth-token-'
};
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import './styles/base.css';
createApp(App).use(createPinia()).mount('#app');
+266
View File
@@ -0,0 +1,266 @@
import axios from 'axios';
import { appConfig } from '@/config';
const AES_KEY = 'xczx2026!@#secKe';
const ENCRYPTION_EXCLUDE_PATHS = ['/avatar/', '/f/', '/amr2mp3', '/appVersion/check'];
const AUTH_STORAGE_KEY = 'qmt-web-im-app-auth-v1';
const AUTH_ERROR_CODE = 13;
const REFRESH_ERROR_CODE = 20045;
let importedCryptoKeyPromise = null;
let refreshPromise = null;
function storageScope() {
return appConfig.appServer || '';
}
function readAuthStore() {
try {
return JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY) || '{}');
} catch (error) {
return {};
}
}
function writeAuthStore(store) {
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(store));
}
function normalizeAuthBundle(bundle = {}) {
const accessToken = String(bundle.accessToken || '').trim();
const refreshToken = String(bundle.refreshToken || '').trim();
if (!accessToken || !refreshToken) {
return null;
}
return {
accessToken,
refreshToken,
tokenType: String(bundle.tokenType || 'Bearer').trim() || 'Bearer',
accessTokenExpireIn: Number(bundle.accessTokenExpireIn || 0) || 0,
accessTokenExpireAt: Number(bundle.accessTokenExpireAt || 0) || 0,
refreshTokenExpireIn: Number(bundle.refreshTokenExpireIn || 0) || 0,
refreshTokenExpireAt: Number(bundle.refreshTokenExpireAt || 0) || 0
};
}
export function getStoredAppAuth() {
const store = readAuthStore();
return normalizeAuthBundle(store[storageScope()]);
}
export function persistAppAuth(bundle) {
const normalized = normalizeAuthBundle(bundle);
const store = readAuthStore();
if (!normalized) {
delete store[storageScope()];
writeAuthStore(store);
return null;
}
store[storageScope()] = normalized;
writeAuthStore(store);
return normalized;
}
export function clearStoredAppAuth() {
persistAppAuth(null);
}
function hasFormData(value) {
return typeof FormData !== 'undefined' && value instanceof FormData;
}
function absoluteUrl(url) {
return new URL(url, window.location.origin);
}
function shouldEncrypt(url) {
const pathname = absoluteUrl(url).pathname;
return !ENCRYPTION_EXCLUDE_PATHS.some(path => pathname === path || pathname.startsWith(path));
}
function shouldAttachAuth(config) {
return !config.skipAuth;
}
function textEncoder() {
return new TextEncoder();
}
function textDecoder() {
return new TextDecoder();
}
function bytesToBase64(bytes) {
let binary = '';
bytes.forEach(byte => {
binary += String.fromCharCode(byte);
});
return btoa(binary);
}
function base64ToBytes(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
async function getCryptoKey() {
if (!importedCryptoKeyPromise) {
importedCryptoKeyPromise = window.crypto.subtle.importKey(
'raw',
textEncoder().encode(AES_KEY),
{ name: 'AES-CBC' },
false,
['encrypt', 'decrypt']
);
}
return importedCryptoKeyPromise;
}
async function encryptPayload(plainText) {
const iv = window.crypto.getRandomValues(new Uint8Array(16));
const key = await getCryptoKey();
const encrypted = await window.crypto.subtle.encrypt(
{ name: 'AES-CBC', iv },
key,
textEncoder().encode(plainText)
);
return `${bytesToBase64(iv)}:${bytesToBase64(new Uint8Array(encrypted))}`;
}
async function decryptPayload(cipherText) {
const [ivBase64, encryptedBase64] = String(cipherText || '').split(':', 2);
if (!ivBase64 || !encryptedBase64) {
throw new Error('Encrypted payload format is invalid');
}
const key = await getCryptoKey();
const decrypted = await window.crypto.subtle.decrypt(
{ name: 'AES-CBC', iv: base64ToBytes(ivBase64) },
key,
base64ToBytes(encryptedBase64)
);
return textDecoder().decode(decrypted);
}
function normalizeBusinessResponse(payload) {
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
return payload;
}
return {
code: 0,
message: 'success',
data: payload
};
}
async function decodeResponsePayload(response) {
const encrypted = String(response.headers?.['x-encrypted'] || '').toLowerCase() === 'true';
if (encrypted && typeof response.data === 'string') {
const plainText = await decryptPayload(response.data);
response.data = normalizeBusinessResponse(JSON.parse(plainText));
return response;
}
response.data = normalizeBusinessResponse(response.data);
return response;
}
const client = axios.create({
withCredentials: false
});
client.interceptors.request.use(async config => {
const nextConfig = { ...config };
const url = absoluteUrl(nextConfig.url || '').toString();
if (shouldAttachAuth(nextConfig)) {
const auth = getStoredAppAuth();
if (auth?.accessToken) {
nextConfig.headers = {
...(nextConfig.headers || {}),
Authorization: `${auth.tokenType || 'Bearer'} ${auth.accessToken}`
};
}
}
if (!hasFormData(nextConfig.data) && nextConfig.method?.toLowerCase() !== 'get' && shouldEncrypt(url)) {
const plainBody = JSON.stringify(nextConfig.data ?? {});
nextConfig.data = await encryptPayload(plainBody);
nextConfig.headers = {
...(nextConfig.headers || {}),
'Content-Type': 'text/plain;charset=UTF-8',
'X-Timestamp': `${Math.floor(Date.now() / 1000)}`
};
}
return nextConfig;
});
client.interceptors.response.use(async response => decodeResponsePayload(response));
async function refreshAccessToken() {
if (refreshPromise) {
return refreshPromise;
}
const auth = getStoredAppAuth();
if (!auth?.refreshToken) {
clearStoredAppAuth();
throw new Error('登录状态已失效,请重新登录');
}
refreshPromise = client.post(`${appConfig.appServer}/auth/refreshToken`, {
refreshToken: auth.refreshToken
}, {
skipAuth: true
}).then(response => {
const payload = response.data || {};
if (Number(payload.code) !== 0 || !payload.data) {
if (Number(payload.code) === REFRESH_ERROR_CODE) {
clearStoredAppAuth();
}
throw new Error(payload.message || '刷新登录状态失败');
}
persistAppAuth(payload.data);
return payload.data;
}).finally(() => {
refreshPromise = null;
});
return refreshPromise;
}
async function request(config) {
const response = await client.request(config);
const payload = response.data || {};
if (Number(payload.code) === AUTH_ERROR_CODE && shouldAttachAuth(config) && !config._retriedAfterRefresh) {
await refreshAccessToken();
return request({
...config,
_retriedAfterRefresh: true
});
}
return response;
}
export async function appServerRequest(config) {
return request(config);
}
export async function appServerGet(url, config = {}) {
return request({
...config,
method: 'get',
url
});
}
export async function appServerPost(url, data = {}, config = {}) {
return request({
...config,
method: 'post',
url,
data
});
}
+273
View File
@@ -0,0 +1,273 @@
import { appConfig } from '@/config';
import { HttpImClient } from './httpImClient';
import axios from 'axios';
import {
appServerGet,
appServerPost,
clearStoredAppAuth,
getStoredAppAuth,
persistAppAuth
} from './appServerClient';
function inferAccountType(account) {
return String(account || '').includes('@') ? 2 : 1;
}
function unwrapData(response) {
return response.data?.data ?? response.data?.result ?? null;
}
function unwrapMessage(response, fallback) {
return response.data?.message || response.data?.msg || fallback;
}
export class DocumentedHttpImClient extends HttpImClient {
unsupported(feature) {
throw new Error(`当前对接文档未提供“${feature}”接口,已禁用旧本地代理链路`);
}
async loginWithPassword(account, password, type = inferAccountType(account)) {
const response = await appServerPost(`${appConfig.appServer}/auth/login_pwd`, {
type,
number: account,
password,
platform: appConfig.platform,
clientId: this.getClientId()
}, {
skipAuth: true
});
if (response.data?.code !== 0) {
throw new Error(unwrapMessage(response, '登录失败'));
}
persistAppAuth(unwrapData(response));
return unwrapData(response);
}
async requestAuthCode(email) {
const response = await appServerPost(`${appConfig.appServer}/auth/sendEmailCode`, {
email
}, {
skipAuth: true
});
if (response.data?.code !== 0) {
throw new Error(unwrapMessage(response, '验证码发送失败'));
}
return unwrapData(response);
}
async loginWithCode(account, code) {
void account;
void code;
throw new Error('接口文档未提供验证码登录接口,请使用密码登录');
}
async register({ type, number, code = '', password, invitationCode = '' }) {
const response = await appServerPost(`${appConfig.appServer}/auth/register`, {
type,
number,
code,
password,
clientId: this.getClientId(),
platform: appConfig.platform,
invitationCode
}, {
skipAuth: true
});
if (response.data?.code !== 0) {
throw new Error(unwrapMessage(response, '注册失败'));
}
persistAppAuth(unwrapData(response));
return unwrapData(response);
}
async createPcSession(userId = '') {
const response = await axios.post(`${appConfig.qrAppServer}/pc_session`, {
flag: 1,
device_name: 'Web IM',
userId,
clientId: this.getClientId(),
platform: appConfig.platform
}, { withCredentials: false });
if (response.data?.code !== 0) {
throw new Error(unwrapMessage(response, '二维码登录会话创建失败'));
}
return unwrapData(response) || {};
}
async loginWithPcSession(token) {
const url = `${appConfig.qrAppServer}/session_login/${encodeURIComponent(token)}`;
let response;
try {
response = await axios.post(url, null, { withCredentials: false });
} catch (postError) {
response = await axios.get(url, { withCredentials: false });
}
this.persistAppToken(url, response);
const payload = response.data || {};
const result = payload.result ?? payload.data ?? null;
if (Number(payload.code) === 0 && result) {
persistAppAuth(result);
}
if ([0, 8, 9, 10, 13, 18, 20042].includes(Number(payload.code))) {
return {
...payload,
data: result
};
}
throw new Error(payload.message || '扫码登录失败');
}
async changePassword({ oldPassword, newPassword }) {
const response = await appServerPost(appConfig.changePasswordApi, {
oldPassword,
newPassword
});
if (response.data?.code !== 0) {
throw new Error(unwrapMessage(response, '密码修改失败'));
}
return true;
}
async resetPasswordWithCode({ mobile, resetCode, newPassword }) {
void mobile;
void resetCode;
void newPassword;
throw new Error('接口文档未提供重置密码接口,请联系服务端确认流程');
}
async logout() {
const auth = getStoredAppAuth();
if (!auth?.refreshToken) {
clearStoredAppAuth();
return true;
}
try {
const response = await appServerPost(appConfig.logoutApi, {
refreshToken: auth.refreshToken
});
if (response.data?.code !== 0) {
throw new Error(unwrapMessage(response, '退出登录失败'));
}
} finally {
clearStoredAppAuth();
}
return true;
}
async fetchRemoteConversations() {
return [];
}
async fetchRemoteMessages() {
return [];
}
async getServerContactList() {
return [];
}
async getProfile() {
return null;
}
async sendTextMessage() {
this.unsupported('发送消息');
}
async sendAttachmentMessage() {
this.unsupported('发送附件消息');
}
async addContact() {
this.unsupported('添加联系人');
}
async updateContactAlias() {
this.unsupported('联系人备注');
}
async createGroup() {
this.unsupported('创建群组');
}
async joinGroup() {
this.unsupported('加入群组');
}
async broadcastMessage() {
this.unsupported('群发消息');
}
async getGroupDetail() {
this.unsupported('群详情');
}
async getGroupMembers() {
this.unsupported('群成员');
}
async updateGroupAlias() {
this.unsupported('群备注');
}
async leaveGroup() {
this.unsupported('退出群组');
}
async dismissGroup() {
this.unsupported('解散群组');
}
async transferGroup() {
this.unsupported('转让群组');
}
async setGroupAdmins() {
this.unsupported('设置群管理员');
}
async updateGroupProfile() {
this.unsupported('更新群资料');
}
async kickGroupMembers() {
this.unsupported('移除群成员');
}
async updateGroupMuteSetting() {
this.unsupported('群禁言设置');
}
async updateProfile() {
this.unsupported('更新个人资料');
}
async recallMessage() {
this.unsupported('撤回消息');
}
async editMessage() {
this.unsupported('编辑消息');
}
async getMessageDetail() {
this.unsupported('消息详情');
}
}
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
import { appConfig } from '@/config';
import { HttpImClient } from './httpImClient';
import { LongConnectionImClient } from './longConnectionImClient';
import { DocumentedHttpImClient } from './documentedHttpImClient';
class UnsupportedWildfireImClient {
constructor() {
throw new Error('当前本地社区版 Web 客户端未启用 WildFire Web SDK');
}
}
export const imClient = appConfig.sdkMode === 'wildfire'
? new UnsupportedWildfireImClient()
: appConfig.sdkMode === 'custom-ws' || appConfig.sdkMode === 'long'
? new LongConnectionImClient()
: new DocumentedHttpImClient();
export {
DocumentedHttpImClient,
HttpImClient,
LongConnectionImClient,
UnsupportedWildfireImClient as WildfireImClient
};
+236
View File
@@ -0,0 +1,236 @@
import { appConfig } from '@/config';
import { HttpImClient } from './httpImClient';
const HEARTBEAT_INTERVAL_MS = 25000;
const RECONNECT_MIN_MS = 1000;
const RECONNECT_MAX_MS = 15000;
function buildWsUrl() {
if (appConfig.wsUrl) {
return appConfig.wsUrl;
}
const protocol = appConfig.useWss ? 'wss' : 'ws';
const host = appConfig.imServerHost || window.location.hostname;
const port = appConfig.imRoutePort ? `:${appConfig.imRoutePort}` : '';
const path = appConfig.wsPath?.startsWith('/') ? appConfig.wsPath : `/${appConfig.wsPath || 'ws'}`;
return `${protocol}://${host}${port}${path}`;
}
function normalizeConversationId(payload) {
const target = payload.target || payload.to || payload.receiver || payload.conversationTarget || payload.from || payload.sender;
return payload.conversationId || (target ? `single-${target}` : '');
}
function normalizeIncomingMessage(payload, currentUserId) {
const item = payload.message || payload;
const from = item.from || item.sender || item.fromUser || '';
const target = item.target || item.to || item.receiver || '';
const direction = from && String(from) === String(currentUserId) ? 'out' : 'in';
const conversationId = item.conversationId || normalizeConversationId({
...item,
target: direction === 'out' ? target : from
});
if (!conversationId) {
return null;
}
const status = item.status || item.deliveryStatus || 'sent';
return {
id: item.id || item.messageId || item.messageUid || `${Date.now()}${Math.random().toString(16).slice(2)}`,
messageUid: item.messageUid || item.uid || item.messageId || '',
conversationId,
from,
target,
direction,
type: item.type || 'text',
text: item.text || item.content || item.searchableContent || '',
status,
deliveryStatus: item.deliveryStatus || (direction === 'out' ? status : ''),
timestamp: Number(item.timestamp || item.serverTimestamp || Date.now()),
displayTimestamp: Number(item.displayTimestamp || item.timestamp || item.serverTimestamp || Date.now()),
raw: item
};
}
export class LongConnectionImClient extends HttpImClient {
constructor() {
super();
this.socket = null;
this.wsUrl = buildWsUrl();
this.heartbeatTimer = null;
this.reconnectTimer = null;
this.reconnectAttempts = 0;
this.manualClose = false;
}
getSdkVersion() {
return 'JC-LC/0.1';
}
shouldUseMessagePolling() {
return !this.connected;
}
async ensureSdk() {
if (typeof WebSocket === 'undefined') {
throw new Error('当前浏览器不支持 WebSocket 长连接');
}
return true;
}
connect(userId, token) {
this.currentUser = { userId, token };
this.manualClose = false;
this.openSocket();
}
disconnect() {
this.manualClose = true;
this.clearReconnectTimer();
this.clearHeartbeatTimer();
if (this.socket) {
this.socket.close();
this.socket = null;
}
super.disconnect();
}
openSocket() {
if (this.socket?.readyState === WebSocket.OPEN) {
this.sendAuthFrame();
return;
}
if (this.socket?.readyState === WebSocket.CONNECTING) {
return;
}
this.emit('connectionStatusChanged', { connected: false, statusText: '长连接连接中' });
try {
this.socket = new WebSocket(this.wsUrl);
this.socket.binaryType = 'arraybuffer';
} catch (error) {
this.emit('connectionStatusChanged', { connected: false, statusText: '长连接创建失败,使用HTTP补偿' });
this.scheduleReconnect();
return;
}
this.socket.onopen = () => {
this.connected = true;
this.reconnectAttempts = 0;
this.emit('connectionStatusChanged', { connected: true, statusText: '长连接已连接' });
this.sendAuthFrame();
this.startHeartbeat();
this.refreshConversationList();
};
this.socket.onmessage = event => {
this.handleFrame(event.data);
};
this.socket.onerror = () => {
this.emit('connectionStatusChanged', { connected: false, statusText: '长连接异常,使用HTTP补偿' });
};
this.socket.onclose = () => {
this.connected = false;
this.clearHeartbeatTimer();
this.socket = null;
if (this.manualClose) {
return;
}
this.emit('connectionStatusChanged', { connected: false, statusText: '长连接重连中' });
this.scheduleReconnect();
};
}
sendAuthFrame() {
this.sendFrame({
type: 'auth',
userId: this.currentUser?.userId || '',
token: this.currentUser?.token || '',
clientId: this.getClientId(),
platform: appConfig.platform,
timestamp: Date.now()
});
}
sendFrame(frame) {
if (this.socket?.readyState !== WebSocket.OPEN) {
return false;
}
this.socket.send(JSON.stringify(frame));
return true;
}
handleFrame(rawData) {
if (rawData instanceof ArrayBuffer) {
this.emit('rawFrameReceived', rawData);
return;
}
let payload;
try {
payload = typeof rawData === 'string' ? JSON.parse(rawData) : rawData;
} catch (error) {
return;
}
const type = payload.type || payload.event || payload.cmd || '';
if (type === 'pong' || type === 'auth_ok' || type === 'connected') {
return;
}
if (type === 'contactsChanged') {
this.emit('contactsChanged', payload.contacts || []);
return;
}
if (type === 'conversationsChanged') {
this.emit('conversationsChanged', payload.conversations || []);
return;
}
if (type === 'messages' && Array.isArray(payload.messages)) {
payload.messages
.map(message => normalizeIncomingMessage(message, this.currentUser?.userId))
.filter(Boolean)
.forEach(message => this.emit('messageReceived', message));
return;
}
const message = normalizeIncomingMessage(payload, this.currentUser?.userId);
if (message) {
this.emit(message.direction === 'out' ? 'messageStatusChanged' : 'messageReceived', message);
}
}
startHeartbeat() {
this.clearHeartbeatTimer();
this.heartbeatTimer = window.setInterval(() => {
this.sendFrame({ type: 'ping', timestamp: Date.now() });
}, HEARTBEAT_INTERVAL_MS);
}
clearHeartbeatTimer() {
if (this.heartbeatTimer) {
window.clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
scheduleReconnect() {
this.clearReconnectTimer();
const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_MIN_MS * 2 ** this.reconnectAttempts);
this.reconnectAttempts += 1;
this.reconnectTimer = window.setTimeout(() => {
this.openSocket();
}, delay);
}
clearReconnectTimer() {
if (this.reconnectTimer) {
window.clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
}
+233
View File
@@ -0,0 +1,233 @@
import { defineStore } from 'pinia';
import { imClient } from '@/services/imClient';
import { useConversationStore } from '@/stores/conversations';
import { useNavigationStore } from '@/stores/navigation';
import { useSessionStore } from '@/stores/session';
const CONTACT_CACHE_MS = 15000;
function normalizeContacts(contacts) {
const unique = new Map();
contacts.forEach(contact => {
const uid = String(contact.uid || contact.id || '').trim();
if (!uid) {
return;
}
const normalized = {
...contact,
id: String(contact.id || uid),
uid
};
unique.set(uid, {
...(unique.get(uid) || {}),
...normalized
});
});
return Array.from(unique.values()).sort((a, b) => a.title.localeCompare(b.title, 'zh-CN'));
}
export const useContactStore = defineStore('contacts', {
state: () => ({
contacts: [],
activeId: '',
loading: false,
eventsBound: false,
refreshSerial: 0,
refreshPromise: null,
loadedForUserId: '',
loadedAt: 0,
actionError: '',
actionInfo: ''
}),
getters: {
activeContact(state) {
return state.contacts.find(item => item.id === state.activeId) || state.contacts[0] || null;
}
},
actions: {
bindImEvents() {
if (this.eventsBound) {
return;
}
this.eventsBound = true;
imClient.on('contactsChanged', contacts => {
if (!contacts.length && this.contacts.length) {
return;
}
this.contacts = normalizeContacts(contacts);
if (!this.contacts.find(item => item.id === this.activeId)) {
this.activeId = this.contacts[0]?.id || '';
}
});
},
async refreshContacts(options = {}) {
this.bindImEvents();
const session = useSessionStore();
const requestUserId = session.userId;
const force = Boolean(options.force);
if (!requestUserId) {
this.clearContacts();
return [];
}
if (!force
&& this.contacts.length
&& this.loadedForUserId === requestUserId
&& Date.now() - this.loadedAt < CONTACT_CACHE_MS
) {
return this.contacts;
}
if (this.refreshPromise && !force) {
return this.refreshPromise;
}
const serial = this.refreshSerial + 1;
this.refreshSerial = serial;
this.loading = true;
this.refreshPromise = (async () => {
this.contacts = normalizeContacts(imClient.refreshContactList());
if (!this.contacts.find(item => item.id === this.activeId)) {
this.activeId = this.contacts[0]?.id || '';
}
if (!this.contacts.length || force) {
const serverContacts = await imClient.getServerContactList(requestUserId);
if (serial !== this.refreshSerial || useSessionStore().userId !== requestUserId) {
return this.contacts;
}
this.contacts = normalizeContacts(serverContacts);
this.loadedForUserId = requestUserId;
this.loadedAt = Date.now();
if (!this.contacts.find(item => item.id === this.activeId)) {
this.activeId = this.contacts[0]?.id || '';
}
}
window.setTimeout(() => {
if (serial !== this.refreshSerial || useSessionStore().userId !== requestUserId) {
return;
}
const latest = imClient.getContactList(false);
if (latest.length || !this.contacts.length) {
this.contacts = normalizeContacts(latest);
this.loadedForUserId = requestUserId;
this.loadedAt = Date.now();
if (!this.contacts.find(item => item.id === this.activeId)) {
this.activeId = this.contacts[0]?.id || '';
}
}
}, 1200);
return this.contacts;
})();
try {
return await this.refreshPromise;
} finally {
if (serial === this.refreshSerial) {
this.loading = false;
this.refreshPromise = null;
}
}
},
async addContact(keyword) {
const session = useSessionStore();
const value = String(keyword || '').trim();
this.actionError = '';
this.actionInfo = '';
if (!session.userId) {
this.actionError = '请先登录';
return false;
}
if (!value) {
this.actionError = '请输入用户ID、手机号或用户名';
return false;
}
this.loading = true;
try {
const contact = await imClient.addContact(session.userId, value, '添加联系人');
if (contact) {
this.contacts = normalizeContacts([...this.contacts, contact]);
this.activeId = contact.id;
}
this.actionInfo = '联系人已添加';
await this.refreshContacts({ force: true });
return true;
} catch (error) {
this.actionError = error.message || '添加联系人失败';
return false;
} finally {
this.loading = false;
}
},
async updateContactAlias(contactId, alias) {
const session = useSessionStore();
const targetId = String(contactId || '').trim();
const nextAlias = String(alias || '').trim();
this.actionError = '';
this.actionInfo = '';
if (!session.userId) {
this.actionError = '请先登录';
return false;
}
if (!targetId) {
this.actionError = '缺少联系人ID';
return false;
}
try {
const savedAlias = await imClient.updateContactAlias(session.userId, targetId, nextAlias);
this.contacts = normalizeContacts(this.contacts.map(item => {
if (item.uid !== targetId && item.id !== targetId) {
return item;
}
const rawUser = {
...(item.rawUser || {}),
friendAlias: savedAlias
};
const rawFriend = {
...(item.rawFriend || {}),
alias: savedAlias
};
const title = savedAlias || rawUser.displayName || rawUser.name || item.uid;
return {
...item,
title,
avatarText: title.slice(0, 1).toUpperCase(),
alias: savedAlias,
rawFriend,
rawUser
};
}));
this.loadedAt = Date.now();
this.actionInfo = '备注名已修改';
return true;
} catch (error) {
this.actionError = error.message || '备注名修改失败';
return false;
}
},
clearContacts() {
this.refreshSerial += 1;
this.contacts = [];
this.activeId = '';
this.loading = false;
this.refreshPromise = null;
this.loadedForUserId = '';
this.loadedAt = 0;
this.actionError = '';
this.actionInfo = '';
},
setActiveContact(id) {
this.activeId = id;
},
openChat(contact) {
if (!contact) {
return;
}
const conversations = useConversationStore();
const navigation = useNavigationStore();
const conversation = imClient.createSingleConversation(contact.uid, contact);
conversations.upsertConversation(conversation);
conversations.setActiveConversation(conversation.id);
navigation.showMessages();
}
}
});
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
import { defineStore } from 'pinia';
export const useNavigationStore = defineStore('navigation', {
state: () => ({
section: 'messages'
}),
actions: {
showMessages() {
this.section = 'messages';
},
showContacts() {
this.section = 'contacts';
},
showFavorites() {
this.section = 'favorites';
}
}
});
+225
View File
@@ -0,0 +1,225 @@
import { defineStore } from 'pinia';
import { imClient } from '@/services/imClient';
import { appConfig } from '@/config';
const STORAGE_KEY = 'qmt-web-im-session';
function normalizeStoredPortrait(portrait) {
return portrait || '';
}
function nextProfilePortrait(profilePortrait, currentPortrait) {
return normalizeStoredPortrait(profilePortrait) || normalizeStoredPortrait(currentPortrait);
}
function inferAccountType(account) {
return String(account || '').includes('@') ? 2 : 1;
}
export const useSessionStore = defineStore('session', {
state: () => ({
isAuthenticated: false,
connecting: false,
connected: false,
connectionStatusText: '未连接',
userId: '',
token: '',
displayName: '',
portrait: '',
error: '',
sdkVersion: '',
eventsBound: false
}),
actions: {
restore() {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
return;
}
try {
const session = JSON.parse(raw);
if (session.appServer !== appConfig.appServer) {
localStorage.removeItem(STORAGE_KEY);
return;
}
if (session.userId && session.token) {
if (!session.clientId) {
localStorage.removeItem(STORAGE_KEY);
return;
}
this.userId = session.userId;
this.token = session.token;
this.displayName = session.displayName || session.userId;
this.portrait = normalizeStoredPortrait(session.portrait);
this.isAuthenticated = true;
imClient.restoreClientId(session.clientId);
this.connect();
this.refreshProfile();
}
} catch (error) {
localStorage.removeItem(STORAGE_KEY);
}
},
async loginWithPassword(account, password, type = inferAccountType(account)) {
this.error = '';
this.connecting = true;
try {
const result = await imClient.loginWithPassword(account, password, type);
this.applyLoginResult(result);
await this.connect();
} catch (error) {
this.error = error.message || '登录失败';
} finally {
this.connecting = false;
}
},
async loginWithCode(account, code) {
this.error = '';
this.connecting = true;
try {
const result = await imClient.loginWithCode(account, code);
this.applyLoginResult(result);
await this.connect();
} catch (error) {
this.error = error.message || '登录失败';
} finally {
this.connecting = false;
}
},
async completePcSessionLogin(result) {
this.error = '';
this.connecting = true;
try {
this.applyLoginResult(result);
await this.connect();
} catch (error) {
this.error = error.message || '扫码登录失败';
} finally {
this.connecting = false;
}
},
async register(payload) {
this.error = '';
this.connecting = true;
try {
const result = await imClient.register(payload);
this.applyLoginResult(result);
await this.connect();
return result;
} catch (error) {
this.error = error.message || '注册失败';
return null;
} finally {
this.connecting = false;
}
},
applyLoginResult(result) {
this.userId = result.userId || result.uid || result.id || '';
this.token = result.token || result.imToken || '';
this.displayName = result.displayName || result.userName || result.name || this.userId;
this.portrait = normalizeStoredPortrait(result.portrait);
this.isAuthenticated = Boolean(this.userId && this.token);
this.persistSession();
},
persistSession() {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
userId: this.userId,
token: this.token,
displayName: this.displayName,
portrait: this.portrait,
clientId: imClient.getClientId(),
appServer: appConfig.appServer
}));
},
applyProfile(profile) {
if (!profile) {
return;
}
this.userId = profile.userId || profile.uid || this.userId;
this.displayName = profile.displayName || profile.name || this.displayName || this.userId;
this.portrait = nextProfilePortrait(profile.portrait, this.portrait);
this.persistSession();
},
async refreshProfile() {
if (!this.userId) {
return null;
}
try {
const profile = await imClient.getProfile(this.userId);
this.applyProfile(profile);
return profile;
} catch (error) {
return null;
}
},
async saveProfile({ displayName, portraitFile }) {
if (!this.userId) {
throw new Error('请先登录');
}
const profile = await imClient.updateProfile(this.userId, {
displayName,
portraitFile
});
this.applyProfile(profile);
return profile;
},
async changePassword({ oldPassword, newPassword }) {
if (!this.userId) {
throw new Error('请先登录');
}
await imClient.changePassword({ oldPassword, newPassword });
await this.logout();
return true;
},
async resetPasswordWithCode({ mobile, resetCode, newPassword }) {
await imClient.resetPasswordWithCode({ mobile, resetCode, newPassword });
return true;
},
async connect() {
if (!this.userId || !this.token) {
return;
}
await imClient.ensureSdk();
this.sdkVersion = imClient.getSdkVersion();
this.bindImEvents();
await imClient.connect(this.userId, this.token);
},
bindImEvents() {
if (this.eventsBound) {
return;
}
this.eventsBound = true;
imClient.on('connectionStatusChanged', status => {
this.connected = status.connected;
this.connectionStatusText = status.statusText || (status.connected ? '已连接' : '未连接');
});
},
async logout() {
try {
await imClient.logout?.();
} catch (error) {
this.error = error.message || '退出登录失败';
} finally {
imClient.disconnect();
localStorage.removeItem(STORAGE_KEY);
this.$reset();
}
}
}
});
+3022
View File
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
const path = require('path');
const webpack = require('webpack');
module.exports = {
outputDir: 'dist',
publicPath: './',
transpileDependencies: true,
configureWebpack: {
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
'legacy-wfc': path.resolve(__dirname, '../src/wfc'),
'legacy-config': path.resolve(__dirname, '../src/config.js')
},
fallback: {
path: false,
fs: false,
assert: false,
util: false,
os: false,
crypto: false,
buffer: require.resolve('buffer/')
}
},
plugins: [
new webpack.ProvidePlugin({
process: 'process/browser',
Buffer: ['buffer', 'Buffer']
})
]
},
devServer: {
allowedHosts: 'all',
client: {
overlay: false
},
proxy: {
'/local-api': {
target: 'http://127.0.0.1:8024',
changeOrigin: true,
pathRewrite: {
'^/local-api': '/api'
}
}
}
},
chainWebpack(config) {
config.optimization.concatenateModules(false);
config.optimization.minimize(false);
}
};