Import JC Web IM
This commit is contained in:
+10
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
.deploy/
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
*.local
|
||||
.env
|
||||
.env.*
|
||||
SESSION_HANDOFF.md
|
||||
@@ -0,0 +1,3 @@
|
||||
# JC Web IM 开发记录
|
||||
|
||||
- 业务操作的成功、失败、保存结果提示,不要使用几秒后自动消失的 toast / popup。优先放在当前表单、按钮附近或弹窗内部,以常驻文字显示;在用户再次编辑、切换或关闭当前界面时再清除。
|
||||
@@ -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 组件。
|
||||
@@ -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`。
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
presets: ['@vue/cli-plugin-babel/preset']
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+95
@@ -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() ?: '添加联系人服务暂不可用');
|
||||
}
|
||||
Vendored
+71
@@ -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() ?: '联系人服务暂不可用');
|
||||
}
|
||||
Vendored
+990
@@ -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() ?: '会话操作失败');
|
||||
}
|
||||
Vendored
+348
@@ -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);
|
||||
}
|
||||
Vendored
+58
@@ -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);
|
||||
Vendored
+288
@@ -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() ?: '消息操作失败');
|
||||
}
|
||||
Vendored
+387
@@ -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() ?: '消息详情加载失败');
|
||||
}
|
||||
Vendored
+900
@@ -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() ?: '消息同步失败');
|
||||
}
|
||||
Vendored
+39
@@ -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,
|
||||
]);
|
||||
}
|
||||
Vendored
+321
@@ -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() ?: '用户资料服务暂不可用');
|
||||
}
|
||||
Vendored
+18
@@ -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);
|
||||
Vendored
+411
@@ -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() ?: '发送失败');
|
||||
}
|
||||
Vendored
+61
@@ -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() ?: '备注名服务暂不可用');
|
||||
}
|
||||
Vendored
+12
@@ -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'
|
||||
};
|
||||
Vendored
+3048
File diff suppressed because it is too large
Load Diff
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 958 KiB |
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
Vendored
+1
@@ -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>
|
||||
Vendored
+3213
File diff suppressed because it is too large
Load Diff
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+24967
File diff suppressed because it is too large
Load Diff
+1
File diff suppressed because one or more lines are too long
@@ -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();
|
||||
Generated
+11384
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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() ?: '添加联系人服务暂不可用');
|
||||
}
|
||||
@@ -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() ?: '联系人服务暂不可用');
|
||||
}
|
||||
@@ -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() ?: '会话操作失败');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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() ?: '消息操作失败');
|
||||
}
|
||||
@@ -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() ?: '消息详情加载失败');
|
||||
}
|
||||
@@ -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() ?: '消息同步失败');
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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() ?: '用户资料服务暂不可用');
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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() ?: '发送失败');
|
||||
}
|
||||
@@ -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() ?: '备注名服务暂不可用');
|
||||
}
|
||||
@@ -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 |
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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-'
|
||||
};
|
||||
@@ -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');
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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';
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user