Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cabf661ca | ||
|
|
cf66f936e5 | ||
|
|
a5efae932b |
@@ -0,0 +1,3 @@
|
||||
VUE_APP_RUNTIME_ENV=local
|
||||
VUE_APP_APP_SERVER_TARGET=http://18.141.44.165:8888
|
||||
VUE_APP_WEB_API_TARGET=http://18.141.44.165:9000
|
||||
@@ -0,0 +1 @@
|
||||
VUE_APP_RUNTIME_ENV=production
|
||||
+3
-2
@@ -5,6 +5,7 @@ npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
*.local
|
||||
.env
|
||||
.env
|
||||
.env.*
|
||||
SESSION_HANDOFF.md
|
||||
!.env.development
|
||||
!.env.production
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,299 @@
|
||||
# JC Web IM Session Handoff
|
||||
|
||||
Updated: 2026-05-02 03:18:54 UTC
|
||||
|
||||
## Current Goal
|
||||
|
||||
Continue the Web IM version under:
|
||||
|
||||
```text
|
||||
/var/www/jc-im/jc-web-im
|
||||
```
|
||||
|
||||
The current focus is the contact list and contact detail flow:
|
||||
|
||||
- Load contacts quickly.
|
||||
- Avoid duplicate contact rows.
|
||||
- Show contact detail fields correctly.
|
||||
- Allow editing the friend alias / remark name.
|
||||
|
||||
Important user instruction:
|
||||
|
||||
- Do not modify `app-server` code.
|
||||
- Local config may be adjusted to fit the local test environment.
|
||||
- Web-side code and PHP proxy code may be changed as needed.
|
||||
|
||||
## Runtime Services
|
||||
|
||||
Current local runtime:
|
||||
|
||||
```text
|
||||
im-server: /opt/im-server
|
||||
app-server: /opt/app-server
|
||||
web source: /var/www/jc-im/jc-web-im
|
||||
web dist: /var/www/jc-im/jc-web-im/dist
|
||||
```
|
||||
|
||||
PM2 process names:
|
||||
|
||||
```text
|
||||
jc-im-server
|
||||
jc-app-server
|
||||
```
|
||||
|
||||
Useful checks:
|
||||
|
||||
```bash
|
||||
pm2 list
|
||||
curl -s http://127.0.0.1:18000/api/version
|
||||
curl -s http://127.0.0.1:18888/
|
||||
curl -I http://127.0.0.1:8023/
|
||||
```
|
||||
|
||||
Important ports:
|
||||
|
||||
```text
|
||||
18000 im-server HTTP/API
|
||||
18080 im-server admin
|
||||
1883 im-server long connection/MQTT
|
||||
18888 app-server
|
||||
8023 Web IM Nginx
|
||||
```
|
||||
|
||||
## App Server Status
|
||||
|
||||
Remote `app-server` was previously pulled from:
|
||||
|
||||
```text
|
||||
[email protected]:/opt/app-server
|
||||
```
|
||||
|
||||
It is a colleague-modified deployment and is no longer started from a jar. It runs from exploded classes/lib, equivalent to:
|
||||
|
||||
```bash
|
||||
java -Dfile.encoding=UTF-8 -Xms256m -Xmx1024m \
|
||||
-classpath classes:lib/* cn.wildfirechat.app.Application
|
||||
```
|
||||
|
||||
Do not change its code unless the user explicitly reverses that instruction.
|
||||
|
||||
Current local config was preserved/adapted under:
|
||||
|
||||
```text
|
||||
/opt/app-server/config/application.properties
|
||||
```
|
||||
|
||||
Known important setting:
|
||||
|
||||
```properties
|
||||
AppCode=676d5adc3ee049b4aeb69f440305c953
|
||||
```
|
||||
|
||||
Redis was configured locally to accept the password expected by this app-server:
|
||||
|
||||
```text
|
||||
Mjfg2U@YZdLx&wmH
|
||||
```
|
||||
|
||||
Be careful: other Redis clients may need auth if they reconnect.
|
||||
|
||||
## Contact List Architecture
|
||||
|
||||
The Web version currently uses PHP proxy endpoints under:
|
||||
|
||||
```text
|
||||
/var/www/jc-im/jc-web-im/public/api
|
||||
```
|
||||
|
||||
Build copies them to:
|
||||
|
||||
```text
|
||||
/var/www/jc-im/jc-web-im/dist/api
|
||||
```
|
||||
|
||||
Current PHP APIs:
|
||||
|
||||
```text
|
||||
contacts.php
|
||||
update_alias.php
|
||||
add_contact.php
|
||||
send_message.php
|
||||
messages.php
|
||||
im_web_bootstrap.php
|
||||
```
|
||||
|
||||
The PHP proxy exists because the browser must not directly carry the `im-server` admin secret.
|
||||
|
||||
Admin config is in:
|
||||
|
||||
```text
|
||||
public/api/im_web_bootstrap.php
|
||||
```
|
||||
|
||||
Current values:
|
||||
|
||||
```php
|
||||
const IM_ADMIN_URL = 'http://127.0.0.1:18080';
|
||||
const IM_ADMIN_SECRET = '123456';
|
||||
```
|
||||
|
||||
### Contact List API
|
||||
|
||||
Endpoint:
|
||||
|
||||
```text
|
||||
POST /api/contacts.php
|
||||
```
|
||||
|
||||
Payload:
|
||||
|
||||
```json
|
||||
{"userId":"当前登录用户ID"}
|
||||
```
|
||||
|
||||
Current logic:
|
||||
|
||||
1. Calls `im-server` admin `/admin/friend/list`.
|
||||
2. Calls `/admin/user/batch_get_infos`.
|
||||
3. Calls `/admin/friend/get_alias` for each friend.
|
||||
4. Returns unique contacts with `uid`, `displayName`, `mobile`, `portrait`, `address`, `alias`.
|
||||
|
||||
This was verified to be fast locally, around milliseconds rather than the old 8-10 seconds.
|
||||
|
||||
### Remark / Alias Update API
|
||||
|
||||
Endpoint:
|
||||
|
||||
```text
|
||||
POST /api/update_alias.php
|
||||
```
|
||||
|
||||
Payload:
|
||||
|
||||
```json
|
||||
{"userId":"当前登录用户ID","targetId":"联系人ID","alias":"备注名"}
|
||||
```
|
||||
|
||||
Current logic:
|
||||
|
||||
1. Validates `userId`, `targetId`.
|
||||
2. Allows empty alias to clear remark.
|
||||
3. Limits alias to 32 UTF-8 characters.
|
||||
4. Calls `im-server` admin `/admin/friend/set_alias`.
|
||||
|
||||
Verified manually:
|
||||
|
||||
- Setting alias to `测试备注` succeeded.
|
||||
- `contacts.php` returned the new alias.
|
||||
- Test alias was then cleared back to empty.
|
||||
|
||||
## Frontend Changes Completed
|
||||
|
||||
Files touched for contact details and alias editing:
|
||||
|
||||
```text
|
||||
src/config.js
|
||||
src/services/httpImClient.js
|
||||
src/stores/contacts.js
|
||||
src/components/contacts/ContactPanel.vue
|
||||
src/styles/base.css
|
||||
public/api/update_alias.php
|
||||
```
|
||||
|
||||
Important behavior now:
|
||||
|
||||
- `contactsApi` defaults to `/api/contacts.php` in production.
|
||||
- `updateAliasApi` defaults to `/api/update_alias.php` in production.
|
||||
- `HttpImClient.getServerContactList()` maps `address` and `alias`.
|
||||
- `HttpImClient.updateContactAlias()` posts to the PHP proxy.
|
||||
- `contacts.updateContactAlias()` updates local contact state immediately after successful save.
|
||||
- Contact detail display is:
|
||||
- avatar / portrait
|
||||
- remark name from `t_friend._alias`; if empty, show `未设置`
|
||||
- username from `_display_name`
|
||||
- mobile from `_mobile`
|
||||
- address from `_address`; if empty, show `未知`
|
||||
- A `✏️` button appears next to the remark name. Clicking it opens a modal to edit the remark.
|
||||
|
||||
## Build / Deploy Status
|
||||
|
||||
Last successful build:
|
||||
|
||||
```bash
|
||||
cd /var/www/jc-im/jc-web-im
|
||||
npm run build
|
||||
```
|
||||
|
||||
Build output included:
|
||||
|
||||
```text
|
||||
dist/js/app.28165923.js
|
||||
dist/css/app.08b643a5.css
|
||||
dist/api/update_alias.php
|
||||
```
|
||||
|
||||
Build warnings were only webpack asset-size warnings for `chunk-vendors`, not functional errors.
|
||||
|
||||
Syntax checks passed:
|
||||
|
||||
```bash
|
||||
php -l jc-web-im/public/api/update_alias.php
|
||||
php -l jc-web-im/dist/api/update_alias.php
|
||||
```
|
||||
|
||||
## Deployment Documentation
|
||||
|
||||
Deployment doc has been added:
|
||||
|
||||
```text
|
||||
/var/www/jc-im/jc-web-im/WEB_DEPLOYMENT.md
|
||||
```
|
||||
|
||||
README links to it:
|
||||
|
||||
```text
|
||||
/var/www/jc-im/jc-web-im/README.md
|
||||
```
|
||||
|
||||
Key deployment note:
|
||||
|
||||
- Production needs Nginx + PHP-FPM.
|
||||
- PHP minimum recommended version: 7.4.
|
||||
- PHP recommended versions: 8.1 / 8.2 / 8.3.
|
||||
- Required PHP extensions: `curl`, `mbstring`.
|
||||
- Nginx root should point to `/var/www/jc-im/jc-web-im/dist`.
|
||||
- `18080` admin port should not be exposed publicly.
|
||||
|
||||
## Known Current Test User Data
|
||||
|
||||
Recent contact list test used:
|
||||
|
||||
```text
|
||||
current userId: 58f71e6245e145409e69b30216c6ebad
|
||||
```
|
||||
|
||||
Contacts returned:
|
||||
|
||||
```text
|
||||
FireRobot
|
||||
571356e9237c460698329c1653d6fd63 用户5050 183019202118
|
||||
55737875bf7a4d089102cbba03b1a8b8 用户8592 18301902118
|
||||
```
|
||||
|
||||
Both human contacts had empty alias after the manual test was restored.
|
||||
|
||||
## Suggested Next Steps
|
||||
|
||||
Good continuation points:
|
||||
|
||||
1. Open the Web page on port `8023` and manually confirm the alias edit modal in the browser.
|
||||
2. Check whether the contact header should also show the `✏️` edit control or only the profile card should show it.
|
||||
3. Continue message send/sync polish if contact flow is accepted.
|
||||
4. Consider optimizing `contacts.php` alias loading if a user has many friends, because it currently calls `get_alias` once per friend.
|
||||
|
||||
## Do Not Forget
|
||||
|
||||
- Do not modify `/opt/app-server` code.
|
||||
- If changing PHP proxy admin secret or admin URL, update source under `public/api` and rebuild.
|
||||
- If editing runtime config after build, check both `public/config/runtime-config.js` and `dist/config/runtime-config.js` depending on whether the change should survive future builds.
|
||||
- The current workspace is `/var/www/jc-im`, not the old `/var/www/im-web`.
|
||||
+9
-16
@@ -50,27 +50,20 @@ 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 环境变量提供:
|
||||
`im-server admin` 的地址和密钥在:
|
||||
|
||||
```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 或部署平台的环境变量中配置,禁止把真实密钥提交到仓库。
|
||||
默认配置:
|
||||
|
||||
```php
|
||||
const IM_ADMIN_URL = 'http://127.0.0.1:18080';
|
||||
const IM_ADMIN_SECRET = '123456';
|
||||
```
|
||||
|
||||
如果生产环境 admin 端口或 secret 不同,需要同步修改该文件后重新构建。
|
||||
|
||||
## 构建
|
||||
|
||||
|
||||
@@ -4,8 +4,12 @@ server {
|
||||
server_name winchen.ezpay.asia 10.0.0.120 _;
|
||||
|
||||
root /var/www/im-web;
|
||||
index index.html;
|
||||
client_max_body_size 100m;
|
||||
index index.html;
|
||||
client_max_body_size 100m;
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types application/json;
|
||||
|
||||
location = / {
|
||||
try_files /index.html =404;
|
||||
@@ -13,12 +17,19 @@ server {
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
location = /index.html {
|
||||
expires -1;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
location ~ ^/api/.+\.php$ {
|
||||
}
|
||||
|
||||
location = /api/media_proxy.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;
|
||||
fastcgi_read_timeout 300;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
Binary file not shown.
@@ -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() ?: '联系人服务暂不可用');
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,348 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
define('IM_ADMIN_URL', getenv('JC_WEB_IM_ADMIN_URL') ?: 'http://127.0.0.1:18080');
|
||||
define('IM_ADMIN_SECRET', getenv('JC_WEB_IM_ADMIN_SECRET') ?: '123456');
|
||||
const IM_DB_DSN = 'mysql:host=im-mysql-005.cbgumoq8g1ks.ap-southeast-1.rds.amazonaws.com;port=3306;dbname=zhongliao_wfchat;charset=utf8mb4';
|
||||
const IM_DB_USER = 'zhongliaoMysql';
|
||||
const IM_DB_PASSWORD = 'ZhongLiao@admin';
|
||||
const IM_APP_DB_DSN = 'mysql:host=im-mysql-005.cbgumoq8g1ks.ap-southeast-1.rds.amazonaws.com;port=3306;dbname=zhongliao_app;charset=utf8mb4';
|
||||
const IM_APP_DB_USER = 'zhongliaoMysql';
|
||||
const IM_APP_DB_PASSWORD = 'ZhongLiao@admin';
|
||||
const IM_DB_TIMEZONE = 'Asia/Shanghai';
|
||||
|
||||
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,252 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/im_web_bootstrap.php';
|
||||
|
||||
const WEB_MEDIA_PROXY_MAX_BYTES = 15 * 1024 * 1024;
|
||||
const WEB_MEDIA_PROXY_MAX_SECONDS = 25;
|
||||
|
||||
function mediaProxyEnv(string $name, string $default = ''): string
|
||||
{
|
||||
if (function_exists('webImEnv')) {
|
||||
return (string)webImEnv($name, $default);
|
||||
}
|
||||
$value = getenv($name);
|
||||
if ($value === false || trim((string)$value) === '') {
|
||||
return $default;
|
||||
}
|
||||
return trim((string)$value);
|
||||
}
|
||||
|
||||
function failMediaProxy(int $status, string $message): never
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
echo $message;
|
||||
exit;
|
||||
}
|
||||
|
||||
function mediaProxyAllowedHosts(): array
|
||||
{
|
||||
$configured = mediaProxyEnv('JC_WEB_IM_MEDIA_PROXY_HOSTS');
|
||||
$hosts = array_map(
|
||||
static fn(string $host): string => strtolower(rtrim(trim($host), '.')),
|
||||
preg_split('/[,\s]+/', $configured) ?: []
|
||||
);
|
||||
|
||||
$hosts[] = 'zhiyanim.dfpnm.cn';
|
||||
return array_values(array_unique(array_filter($hosts)));
|
||||
}
|
||||
|
||||
function validatedMediaProxyUrl(string $url): array
|
||||
{
|
||||
if ($url === '' || strlen($url) > 2048 || filter_var($url, FILTER_VALIDATE_URL) === false) {
|
||||
failMediaProxy(400, '媒体地址不合法');
|
||||
}
|
||||
|
||||
$parts = parse_url($url);
|
||||
$scheme = strtolower((string)($parts['scheme'] ?? ''));
|
||||
$host = strtolower(rtrim((string)($parts['host'] ?? ''), '.'));
|
||||
$port = isset($parts['port']) ? (int)$parts['port'] : 443;
|
||||
if (
|
||||
$scheme !== 'https'
|
||||
|| $host === ''
|
||||
|| $port !== 443
|
||||
|| isset($parts['user'])
|
||||
|| isset($parts['pass'])
|
||||
|| !in_array($host, mediaProxyAllowedHosts(), true)
|
||||
) {
|
||||
failMediaProxy(403, '媒体来源不允许代理');
|
||||
}
|
||||
|
||||
return [$url, $host];
|
||||
}
|
||||
|
||||
function mediaProxyCacheRoot(): string
|
||||
{
|
||||
$root = rtrim(mediaProxyEnv('JC_WEB_IM_MEDIA_CACHE_ROOT', '/var/cache/jc-web-im-media'), '/');
|
||||
if ($root === '' || !is_dir($root) || !is_writable($root)) {
|
||||
$root = rtrim(sys_get_temp_dir(), '/') . '/jc-web-im-media';
|
||||
if (!is_dir($root) && !@mkdir($root, 0755, true) && !is_dir($root)) {
|
||||
failMediaProxy(503, '媒体缓存目录不可用');
|
||||
}
|
||||
}
|
||||
|
||||
return $root;
|
||||
}
|
||||
|
||||
function mediaProxyPreferredIps(): array
|
||||
{
|
||||
$configured = preg_split('/[,\s]+/', mediaProxyEnv('JC_WEB_IM_MEDIA_PROXY_IPS')) ?: [];
|
||||
$ips = [];
|
||||
foreach ($configured as $ip) {
|
||||
$ip = trim($ip);
|
||||
if (
|
||||
$ip !== ''
|
||||
&& filter_var(
|
||||
$ip,
|
||||
FILTER_VALIDATE_IP,
|
||||
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
|
||||
) !== false
|
||||
) {
|
||||
$ips[$ip] = $ip;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($ips);
|
||||
}
|
||||
|
||||
function mediaProxyMime(string $path): string
|
||||
{
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = $finfo !== false ? (string)(finfo_file($finfo, $path) ?: '') : '';
|
||||
if ($finfo !== false) {
|
||||
finfo_close($finfo);
|
||||
}
|
||||
|
||||
return $mime;
|
||||
}
|
||||
|
||||
function fetchMediaProxyImage(string $url, string $host, string $cacheFile): void
|
||||
{
|
||||
$deadline = microtime(true) + WEB_MEDIA_PROXY_MAX_SECONDS;
|
||||
$lastError = '媒体源无响应';
|
||||
$preferredIps = mediaProxyPreferredIps();
|
||||
$targets = array_merge($preferredIps, array_fill(0, max(2, 6 - count($preferredIps)), null));
|
||||
|
||||
foreach ($targets as $index => $preferredIp) {
|
||||
if (microtime(true) >= $deadline) {
|
||||
break;
|
||||
}
|
||||
$attempt = $index + 1;
|
||||
$temporaryFile = $cacheFile . '.tmp.' . getmypid() . '.' . $attempt;
|
||||
$stream = @fopen($temporaryFile, 'x+b');
|
||||
if ($stream === false) {
|
||||
$lastError = '无法创建媒体缓存文件';
|
||||
break;
|
||||
}
|
||||
|
||||
$bytes = 0;
|
||||
$tooLarge = false;
|
||||
$curl = curl_init($url);
|
||||
if ($curl === false) {
|
||||
fclose($stream);
|
||||
@unlink($temporaryFile);
|
||||
$lastError = '无法初始化媒体请求';
|
||||
break;
|
||||
}
|
||||
|
||||
$remainingMs = max(1000, (int)floor(($deadline - microtime(true)) * 1000));
|
||||
$curlOptions = [
|
||||
CURLOPT_RETURNTRANSFER => false,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_CONNECTTIMEOUT_MS => min(2500, $remainingMs),
|
||||
CURLOPT_TIMEOUT_MS => min(12000, $remainingMs),
|
||||
CURLOPT_NOSIGNAL => true,
|
||||
CURLOPT_DNS_CACHE_TIMEOUT => 0,
|
||||
CURLOPT_FRESH_CONNECT => true,
|
||||
CURLOPT_FORBID_REUSE => true,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_USERAGENT => 'JC-Web-IM-Media-Proxy/1.0',
|
||||
CURLOPT_HTTPHEADER => ['Accept: image/avif,image/webp,image/*,*/*;q=0.8'],
|
||||
CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use ($stream, &$bytes, &$tooLarge): int {
|
||||
$length = strlen($chunk);
|
||||
if ($bytes + $length > WEB_MEDIA_PROXY_MAX_BYTES) {
|
||||
$tooLarge = true;
|
||||
return 0;
|
||||
}
|
||||
$written = fwrite($stream, $chunk);
|
||||
if ($written === false) {
|
||||
return 0;
|
||||
}
|
||||
$bytes += $written;
|
||||
return $written;
|
||||
},
|
||||
];
|
||||
if (is_string($preferredIp) && $preferredIp !== '') {
|
||||
$curlOptions[CURLOPT_RESOLVE] = [$host . ':443:' . $preferredIp];
|
||||
}
|
||||
curl_setopt_array($curl, $curlOptions);
|
||||
|
||||
$result = curl_exec($curl);
|
||||
$status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
$curlError = curl_error($curl);
|
||||
curl_close($curl);
|
||||
fflush($stream);
|
||||
fclose($stream);
|
||||
|
||||
$mime = $bytes > 0 && is_file($temporaryFile) ? mediaProxyMime($temporaryFile) : '';
|
||||
if ($result !== false && $status >= 200 && $status < 300 && !$tooLarge && str_starts_with($mime, 'image/')) {
|
||||
if (!@rename($temporaryFile, $cacheFile)) {
|
||||
@unlink($temporaryFile);
|
||||
$lastError = '媒体缓存保存失败';
|
||||
break;
|
||||
}
|
||||
@chmod($cacheFile, 0644);
|
||||
return;
|
||||
}
|
||||
|
||||
@unlink($temporaryFile);
|
||||
$lastError = $tooLarge
|
||||
? '图片超过代理大小限制'
|
||||
: ($curlError !== '' ? $curlError : ($status > 0 ? '媒体源返回 HTTP ' . $status : '媒体源无响应'));
|
||||
if ($attempt < count($targets) && microtime(true) < $deadline) {
|
||||
usleep(150000);
|
||||
}
|
||||
}
|
||||
|
||||
error_log(sprintf('media_proxy fetch failed host=%s error=%s', $host, $lastError));
|
||||
failMediaProxy(502, '图片源暂时不可用,请稍后重试');
|
||||
}
|
||||
|
||||
function serveMediaProxyImage(string $cacheFile, string $etag): never
|
||||
{
|
||||
if (!is_file($cacheFile)) {
|
||||
failMediaProxy(404, '媒体缓存不存在');
|
||||
}
|
||||
$mime = mediaProxyMime($cacheFile);
|
||||
if (!str_starts_with($mime, 'image/')) {
|
||||
failMediaProxy(415, '媒体内容不是图片');
|
||||
}
|
||||
|
||||
header('ETag: "' . $etag . '"');
|
||||
header('Cache-Control: public, max-age=86400, immutable');
|
||||
header('Content-Type: ' . $mime);
|
||||
header('Content-Length: ' . (string)filesize($cacheFile));
|
||||
header('Content-Disposition: inline');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
if (trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? ''), ' W/') === '"' . $etag . '"') {
|
||||
http_response_code(304);
|
||||
exit;
|
||||
}
|
||||
|
||||
readfile($cacheFile);
|
||||
exit;
|
||||
}
|
||||
|
||||
[$url, $host] = validatedMediaProxyUrl(trim((string)($_GET['url'] ?? '')));
|
||||
$cacheRoot = mediaProxyCacheRoot();
|
||||
$cacheKey = hash('sha256', $url);
|
||||
$cacheFile = $cacheRoot . '/' . $cacheKey . '.image';
|
||||
$lockFile = $cacheRoot . '/' . $cacheKey . '.lock';
|
||||
|
||||
if (!is_file($cacheFile)) {
|
||||
$lock = @fopen($lockFile, 'c');
|
||||
if ($lock === false || !flock($lock, LOCK_EX)) {
|
||||
if (is_resource($lock)) {
|
||||
fclose($lock);
|
||||
}
|
||||
failMediaProxy(503, '媒体缓存暂时忙碌');
|
||||
}
|
||||
if (!is_file($cacheFile)) {
|
||||
fetchMediaProxyImage($url, $host, $cacheFile);
|
||||
}
|
||||
flock($lock, LOCK_UN);
|
||||
fclose($lock);
|
||||
}
|
||||
|
||||
serveMediaProxyImage($cacheFile, $cacheKey);
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
const WEB_MEDIA_STREAM_ROOT = '/opt/im-server/media';
|
||||
|
||||
function failMediaStream(int $status, string $message): never
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
echo $message;
|
||||
exit;
|
||||
}
|
||||
|
||||
$url = trim((string)($_GET['url'] ?? ''));
|
||||
if ($url === '') {
|
||||
failMediaStream(400, '缺少视频地址');
|
||||
}
|
||||
|
||||
$path = rawurldecode((string)(parse_url($url, PHP_URL_PATH) ?: ''));
|
||||
if (!preg_match('#^/fs/[0-9]+/[A-Za-z0-9_./ -]+$#', $path) || str_contains($path, '..')) {
|
||||
failMediaStream(400, '视频地址不合法');
|
||||
}
|
||||
|
||||
$root = realpath(WEB_MEDIA_STREAM_ROOT);
|
||||
$fullPath = realpath(rtrim(WEB_MEDIA_STREAM_ROOT, '/') . $path);
|
||||
if ($root === false || $fullPath === false || !str_starts_with($fullPath, $root . DIRECTORY_SEPARATOR) || !is_file($fullPath)) {
|
||||
failMediaStream(404, '视频文件不存在');
|
||||
}
|
||||
|
||||
$size = (int)filesize($fullPath);
|
||||
$mime = (string)(mime_content_type($fullPath) ?: 'video/mp4');
|
||||
if (!str_starts_with($mime, 'video/')) {
|
||||
$extension = strtolower((string)pathinfo($fullPath, PATHINFO_EXTENSION));
|
||||
$mime = $extension === 'webm' ? 'video/webm' : ($extension === 'mov' ? 'video/quicktime' : 'video/mp4');
|
||||
}
|
||||
|
||||
$start = 0;
|
||||
$end = max(0, $size - 1);
|
||||
$range = trim((string)($_SERVER['HTTP_RANGE'] ?? ''));
|
||||
if ($range !== '' && preg_match('/^bytes=(\d*)-(\d*)$/', $range, $matches)) {
|
||||
if ($matches[1] !== '') $start = (int)$matches[1];
|
||||
if ($matches[2] !== '') $end = min($end, (int)$matches[2]);
|
||||
if ($start < 0 || $start > $end || $start >= $size) {
|
||||
header('Content-Range: bytes */' . $size);
|
||||
failMediaStream(416, '请求范围无效');
|
||||
}
|
||||
http_response_code(206);
|
||||
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
|
||||
}
|
||||
|
||||
$length = $end - $start + 1;
|
||||
header('Content-Type: ' . $mime);
|
||||
header('Content-Length: ' . $length);
|
||||
header('Accept-Ranges: bytes');
|
||||
header('Content-Disposition: inline');
|
||||
header('Cache-Control: public, max-age=86400');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
|
||||
$stream = fopen($fullPath, 'rb');
|
||||
if ($stream === false) failMediaStream(500, '视频文件无法读取');
|
||||
fseek($stream, $start);
|
||||
$remaining = $length;
|
||||
while ($remaining > 0 && !feof($stream)) {
|
||||
$chunk = fread($stream, min(1024 * 1024, $remaining));
|
||||
if ($chunk === false || $chunk === '') break;
|
||||
echo $chunk;
|
||||
$remaining -= strlen($chunk);
|
||||
flush();
|
||||
}
|
||||
fclose($stream);
|
||||
@@ -0,0 +1,351 @@
|
||||
<?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 ensurePinnedMessagesTable(): void
|
||||
{
|
||||
$db = imAppDb();
|
||||
$db->exec(
|
||||
'create table if not exists `t_web_pinned_messages` ('
|
||||
. '`user_id` varchar(64) not null,'
|
||||
. '`conversation_type` varchar(16) not null,'
|
||||
. '`target_id` varchar(128) not null,'
|
||||
. '`message_uid` varchar(32) not null,'
|
||||
. '`message_json` text null,'
|
||||
. '`updated_at_ms` bigint not null,'
|
||||
. 'primary key (`user_id`, `conversation_type`, `target_id`)'
|
||||
. ') engine=InnoDB default charset=utf8mb4'
|
||||
);
|
||||
try {
|
||||
$db->exec('alter table `t_web_pinned_messages` add column `message_json` text null after `message_uid`');
|
||||
} catch (Throwable) {
|
||||
// The column already exists.
|
||||
}
|
||||
}
|
||||
|
||||
function savePinnedMessage(string $userId, string $conversationType, string $targetId, string $messageUid, array $message): void
|
||||
{
|
||||
ensurePinnedMessagesTable();
|
||||
$statement = imAppDb()->prepare(
|
||||
'insert into `t_web_pinned_messages` (`user_id`, `conversation_type`, `target_id`, `message_uid`, `message_json`, `updated_at_ms`)'
|
||||
. ' values (?, ?, ?, ?, ?, ?) on duplicate key update `message_uid` = values(`message_uid`),'
|
||||
. ' `message_json` = values(`message_json`), `updated_at_ms` = values(`updated_at_ms`)'
|
||||
);
|
||||
$statement->execute([
|
||||
$userId,
|
||||
$conversationType,
|
||||
$targetId,
|
||||
$messageUid,
|
||||
json_encode($message, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
nowTimestampMs(),
|
||||
]);
|
||||
}
|
||||
|
||||
function removePinnedMessage(string $userId, string $conversationType, string $targetId): void
|
||||
{
|
||||
ensurePinnedMessagesTable();
|
||||
$statement = imAppDb()->prepare(
|
||||
'delete from `t_web_pinned_messages` where `user_id` = ? and `conversation_type` = ? and `target_id` = ?'
|
||||
);
|
||||
$statement->execute([$userId, $conversationType, $targetId]);
|
||||
}
|
||||
|
||||
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 === 'pin' || $action === 'unpin') {
|
||||
if ($userId === '') respond(400, '缺少操作者ID');
|
||||
$targetId = trim((string)($payload['target'] ?? ''));
|
||||
$conversationType = trim((string)($payload['conversationType'] ?? 'single'));
|
||||
if ($targetId === '') respond(400, '缺少会话ID');
|
||||
$userId = resolveActionUserId($userId);
|
||||
if ($action === 'pin') {
|
||||
$message = is_array($payload['message'] ?? null) ? $payload['message'] : [];
|
||||
savePinnedMessage($userId, $conversationType, $targetId, $messageUidText, $message);
|
||||
} else {
|
||||
removePinnedMessage($userId, $conversationType, $targetId);
|
||||
}
|
||||
respond(0, 'success', ['messageUid' => $messageUidText, 'action' => $action]);
|
||||
}
|
||||
|
||||
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() ?: '消息详情加载失败');
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,498 @@
|
||||
<?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_VIDEO = 3;
|
||||
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', 'video', 'file'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$forwardUrl = trim((string)($_POST['attachmentUrl'] ?? ''));
|
||||
if ($forwardUrl !== '') {
|
||||
$parts = parse_url($forwardUrl);
|
||||
$scheme = strtolower((string)($parts['scheme'] ?? ''));
|
||||
if (!in_array($scheme, ['http', 'https'], true) || empty($parts['host']) || strlen($forwardUrl) > 2048) {
|
||||
respond(400, '媒体地址不合法');
|
||||
}
|
||||
$mime = trim((string)($_POST['attachmentMime'] ?? 'application/octet-stream'));
|
||||
$name = safeUploadName((string)($_POST['attachmentName'] ?? ($messageType === 'image' ? '图片' : ($messageType === 'video' ? '视频.mp4' : '文件'))));
|
||||
return [
|
||||
'type' => $messageType,
|
||||
'url' => $forwardUrl,
|
||||
'name' => $name,
|
||||
'mime' => $mime,
|
||||
'size' => max(0, (int)($_POST['attachmentSize'] ?? 0)),
|
||||
'mediaType' => $messageType === 'image' ? WEB_MEDIA_TYPE_IMAGE : ($messageType === 'video' ? WEB_MEDIA_TYPE_VIDEO : WEB_MEDIA_TYPE_FILE),
|
||||
];
|
||||
}
|
||||
|
||||
$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);
|
||||
$mentionedUserIds = $payload['mentionedUserIds'] ?? [];
|
||||
$mentionedUserIds = is_array($mentionedUserIds)
|
||||
? array_values(array_unique(array_filter(array_map(static fn($value): string => trim((string)$value), $mentionedUserIds))))
|
||||
: [];
|
||||
$attachment = null;
|
||||
|
||||
if ($sender === '' || $target === '' || ($text === '' && !in_array($messageType, ['image', 'video', '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) {
|
||||
// 手机端创建的原生群不一定存在于 Web 自建群表中,需回退检查 IM 群成员表。
|
||||
$imDb = imDb();
|
||||
$nativeGroupStatement = $imDb->prepare(
|
||||
'select 1 from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1'
|
||||
);
|
||||
$nativeGroupStatement->execute([$target]);
|
||||
$nativeMemberStatement = $imDb->prepare(
|
||||
'select `_type` from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1'
|
||||
);
|
||||
$nativeMemberStatement->execute([$target, $sender]);
|
||||
$nativeMemberType = $nativeMemberStatement->fetchColumn();
|
||||
if (!$nativeGroupStatement->fetchColumn() || $nativeMemberType === false) {
|
||||
respond(403, '你不在该群组中');
|
||||
}
|
||||
|
||||
$attachment = attachmentFromRequest($messageType);
|
||||
$pushContent = $text !== ''
|
||||
? $text
|
||||
: ($attachment
|
||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : ((string)$attachment['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name']))
|
||||
: $text);
|
||||
$payloadBody = [
|
||||
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 5)) : 1,
|
||||
'searchableContent' => $pushContent,
|
||||
'pushContent' => $pushContent,
|
||||
'content' => $text,
|
||||
'persistFlag' => 3,
|
||||
];
|
||||
if ($mentionedUserIds) {
|
||||
$payloadBody['mentionedType'] = 1;
|
||||
$payloadBody['mentionedTarget'] = $mentionedUserIds;
|
||||
}
|
||||
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);
|
||||
}
|
||||
$nativeResult = requireImSuccess(imAdminPost('/admin/message/send', [
|
||||
'sender' => $sender,
|
||||
'conv' => ['type' => 1, 'target' => $target, 'line' => 0],
|
||||
'payload' => $payloadBody,
|
||||
'userMessage' => true,
|
||||
]), '原生群消息发送失败');
|
||||
$messageUid = isset($nativeResult['messageUid']) ? (string)$nativeResult['messageUid'] : '';
|
||||
$timestamp = (int)($nativeResult['timestamp'] ?? webImNowMs());
|
||||
if ($attachment && $messageUid !== '') {
|
||||
storeMessageAttachment($appDb, $messageUid, 'group', $target, $sender, $attachment, $timestamp);
|
||||
}
|
||||
$role = (int)$nativeMemberType === 2 ? 'owner' : ((int)$nativeMemberType === 1 ? 'admin' : 'member');
|
||||
respond(0, 'success', [
|
||||
'messageUid' => $messageUid,
|
||||
'timestamp' => $timestamp,
|
||||
'senderRole' => $role,
|
||||
'attachment' => $attachment,
|
||||
'nativeError' => '',
|
||||
]);
|
||||
}
|
||||
$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['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name']))
|
||||
: $text);
|
||||
$payloadBody = [
|
||||
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 5)) : 1,
|
||||
'searchableContent' => $pushContent,
|
||||
'pushContent' => $pushContent,
|
||||
'content' => $text,
|
||||
'persistFlag' => 3,
|
||||
];
|
||||
if ($mentionedUserIds) {
|
||||
$payloadBody['mentionedType'] = 1;
|
||||
$payloadBody['mentionedTarget'] = $mentionedUserIds;
|
||||
}
|
||||
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['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name']))
|
||||
: $text);
|
||||
$payloadBody = [
|
||||
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 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,13 @@
|
||||
window.JC_WEB_IM_CONFIG = {
|
||||
adminServer: 'http://18.141.44.165:8100',
|
||||
appServer: '/app-server',
|
||||
qrAppServer: 'http://18.141.44.165:8888',
|
||||
platformConfigApi: '/api/platform_config.php',
|
||||
registerConfigApi: 'http://18.141.44.165:8100/api/register-config',
|
||||
imServerHost: '18.141.44.165',
|
||||
imRoutePort: 80,
|
||||
useWss: false,
|
||||
wsPath: '/ws',
|
||||
sdkMode: 'legacy-http',
|
||||
syncIntervalMs: 2500
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
window.JC_WEB_IM_CONFIG = {
|
||||
runtimeEnvironment: 'local',
|
||||
adminServer: 'http://18.141.44.165:8100',
|
||||
appServer: '/app-server',
|
||||
qrAppServer: 'http://18.141.44.165:8888',
|
||||
platformConfigApi: '/local-api/platform_config.php',
|
||||
registerConfigApi: '/local-api/register_config.php',
|
||||
registerPhoneExistsApi: 'http://18.141.44.165:8100/api/register-phone-exists',
|
||||
imServerHost: '18.141.44.165',
|
||||
imRoutePort: 80,
|
||||
useWss: false,
|
||||
wsPath: '/ws',
|
||||
sdkMode: 'legacy-http',
|
||||
syncIntervalMs: 2500,
|
||||
sseEnabled: false,
|
||||
sseTicketApi: '/local-api/sse_ticket.php',
|
||||
sseUrl: '/ws-events'
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
(() => {
|
||||
const protocol = window.location.protocol || 'http:';
|
||||
const host = window.location.hostname || '18.141.44.165';
|
||||
|
||||
window.JC_WEB_IM_CONFIG = {
|
||||
runtimeEnvironment: 'production',
|
||||
adminServer: `${protocol}//${host}:8100`,
|
||||
appServer: `${protocol}//${host}:8888`,
|
||||
qrAppServer: `${protocol}//${host}:8888`,
|
||||
platformConfigApi: '/api/platform_config.php',
|
||||
registerConfigApi: `${protocol}//${host}:8100/api/register-config`,
|
||||
registerPhoneExistsApi: `${protocol}//${host}:8100/api/register-phone-exists`,
|
||||
imServerHost: host,
|
||||
imRoutePort: 80,
|
||||
useWss: false,
|
||||
wsPath: '/ws',
|
||||
sdkMode: 'legacy-http',
|
||||
syncIntervalMs: 15000,
|
||||
sseEnabled: true,
|
||||
sseTicketApi: '/api/sse_ticket.php',
|
||||
sseUrl: '/ws-events'
|
||||
};
|
||||
})();
|
||||
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="favicon.ico">
|
||||
<script src="config/runtime-config.local.js"></script>
|
||||
<script defer src="js/chunk-vendors.js"></script><script defer src="js/app.js"></script></head>
|
||||
<body>
|
||||
<noscript>请启用 JavaScript 后使用 Web IM。</noscript>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
BIN
Binary file not shown.
Vendored
+71
-13
@@ -425,12 +425,38 @@ function currentMuteSetting(PDO $appDb, string $groupId): array
|
||||
];
|
||||
}
|
||||
|
||||
function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
||||
{
|
||||
$group = fetchWebGroup($appDb, $groupId);
|
||||
if (!$group) {
|
||||
respond(404, '没有找到该群组');
|
||||
}
|
||||
function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
||||
{
|
||||
$group = fetchWebGroup($appDb, $groupId);
|
||||
if (!$group) {
|
||||
$groupStatement = $imDb->prepare('select * from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1');
|
||||
$groupStatement->execute([$groupId]);
|
||||
$nativeGroup = $groupStatement->fetch();
|
||||
$memberStatement = $imDb->prepare('select `_type`, `_alias` from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1');
|
||||
$memberStatement->execute([$groupId, $userId]);
|
||||
$nativeMember = $memberStatement->fetch();
|
||||
if (!$nativeGroup || !$nativeMember) {
|
||||
respond(403, '你不在该群组中');
|
||||
}
|
||||
$ownerStatement = $imDb->prepare('select `_mid` from `t_group_member` where `_gid` = ? and `_type` = 2 limit 1');
|
||||
$ownerStatement->execute([$groupId]);
|
||||
$ownerId = (string)($ownerStatement->fetchColumn() ?: '');
|
||||
$users = fetchWebUserMap($imDb, array_filter([$ownerId, $userId]), $userId);
|
||||
return ['group' => [
|
||||
'groupId' => $groupId,
|
||||
'publicId' => (string)($nativeGroup['id'] ?? ''),
|
||||
'name' => (string)($nativeGroup['_name'] ?? $groupId),
|
||||
'category' => 'group',
|
||||
'announcement' => (string)($nativeGroup['_bulletin'] ?? ''),
|
||||
'welcomeText' => '',
|
||||
'ownerUid' => $ownerId,
|
||||
'ownerName' => (string)($users[$ownerId]['displayName'] ?? $ownerId),
|
||||
'myRole' => ((int)($nativeMember['_type'] ?? 0) === 2 ? 'owner' : ((int)($nativeMember['_type'] ?? 0) === 1 ? 'admin' : 'member')),
|
||||
'myAlias' => (string)($nativeMember['_alias'] ?? ''),
|
||||
'myDisplayName' => (string)($nativeMember['_alias'] ?? '') ?: (string)($users[$userId]['displayName'] ?? $userId),
|
||||
'muteSetting' => ['enabled' => false, 'mode' => 'off'],
|
||||
]];
|
||||
}
|
||||
$member = fetchWebGroupMember($appDb, $groupId, $userId);
|
||||
if (!$member) {
|
||||
respond(403, '你不在该群组中');
|
||||
@@ -443,8 +469,9 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
|
||||
$myAlias = (string)($member['alias'] ?? '');
|
||||
|
||||
return [
|
||||
'group' => [
|
||||
'groupId' => (string)$group['group_id'],
|
||||
'group' => [
|
||||
'groupId' => (string)$group['group_id'],
|
||||
'publicId' => (string)($group['id'] ?? ''),
|
||||
'name' => (string)$group['name'],
|
||||
'category' => (string)$group['category'],
|
||||
'announcement' => (string)($group['announcement'] ?? ''),
|
||||
@@ -459,11 +486,42 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
|
||||
];
|
||||
}
|
||||
|
||||
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
||||
{
|
||||
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
|
||||
respond(403, '你不在该群组中');
|
||||
}
|
||||
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
||||
{
|
||||
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
|
||||
$membership = $imDb->prepare('select 1 from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1');
|
||||
$membership->execute([$groupId, $userId]);
|
||||
if (!$membership->fetchColumn()) {
|
||||
respond(403, '你不在该群组中');
|
||||
}
|
||||
$nativeStatement = $imDb->prepare(
|
||||
'select `_mid` as `user_id`, `_type`, `_alias`, `_create_dt` from `t_group_member`'
|
||||
. ' where `_gid` = ? order by (`_type` = 2) desc, `_create_dt` asc'
|
||||
);
|
||||
$nativeStatement->execute([$groupId]);
|
||||
$nativeMembers = $nativeStatement->fetchAll();
|
||||
$nativeUsers = fetchWebUserMap($imDb, array_column($nativeMembers, 'user_id'), $userId);
|
||||
return ['members' => array_map(static function (array $member) use ($nativeUsers): array {
|
||||
$uid = (string)$member['user_id'];
|
||||
$user = $nativeUsers[$uid] ?? ['displayName' => $uid, 'mobile' => '', 'portrait' => '', 'isContact' => false];
|
||||
$alias = trim((string)($member['_alias'] ?? ''));
|
||||
$type = (int)($member['_type'] ?? 0);
|
||||
return [
|
||||
'uid' => $uid,
|
||||
'role' => $type === 2 ? 'owner' : ($type === 1 ? 'admin' : 'member'),
|
||||
'alias' => $alias,
|
||||
'contactAlias' => trim((string)($user['contactAlias'] ?? '')),
|
||||
'friendAlias' => trim((string)($user['friendAlias'] ?? '')),
|
||||
'remarkAlias' => trim((string)($user['remarkAlias'] ?? '')),
|
||||
'isContact' => (bool)($user['isContact'] ?? false),
|
||||
'displayName' => trim((string)($user['contactAlias'] ?? '')) ?: ($alias ?: (string)$user['displayName']),
|
||||
'rawDisplayName' => (string)$user['displayName'],
|
||||
'groupDisplayName' => $alias,
|
||||
'mobile' => (string)($user['mobile'] ?? ''),
|
||||
'portrait' => (string)($user['portrait'] ?? ''),
|
||||
];
|
||||
}, $nativeMembers)];
|
||||
}
|
||||
|
||||
$statement = $appDb->prepare(
|
||||
'select `user_id`, `role`, `alias`, `joined_at_ms` from `t_web_group_members`'
|
||||
|
||||
Vendored
+9
-9
@@ -1,15 +1,15 @@
|
||||
<?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');
|
||||
define('IM_ADMIN_URL', getenv('JC_WEB_IM_ADMIN_URL') ?: 'http://127.0.0.1:18080');
|
||||
define('IM_ADMIN_SECRET', getenv('JC_WEB_IM_ADMIN_SECRET') ?: '123456');
|
||||
const IM_DB_DSN = 'mysql:host=im-mysql-005.cbgumoq8g1ks.ap-southeast-1.rds.amazonaws.com;port=3306;dbname=zhongliao_wfchat;charset=utf8mb4';
|
||||
const IM_DB_USER = 'zhongliaoMysql';
|
||||
const IM_DB_PASSWORD = 'ZhongLiao@admin';
|
||||
const IM_APP_DB_DSN = 'mysql:host=im-mysql-005.cbgumoq8g1ks.ap-southeast-1.rds.amazonaws.com;port=3306;dbname=zhongliao_app;charset=utf8mb4';
|
||||
const IM_APP_DB_USER = 'zhongliaoMysql';
|
||||
const IM_APP_DB_PASSWORD = 'ZhongLiao@admin';
|
||||
const IM_DB_TIMEZONE = 'Asia/Shanghai';
|
||||
|
||||
function respond(int $code, string $message, array $result = []): void
|
||||
{
|
||||
|
||||
Vendored
+252
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/im_web_bootstrap.php';
|
||||
|
||||
const WEB_MEDIA_PROXY_MAX_BYTES = 15 * 1024 * 1024;
|
||||
const WEB_MEDIA_PROXY_MAX_SECONDS = 25;
|
||||
|
||||
function mediaProxyEnv(string $name, string $default = ''): string
|
||||
{
|
||||
if (function_exists('webImEnv')) {
|
||||
return (string)webImEnv($name, $default);
|
||||
}
|
||||
$value = getenv($name);
|
||||
if ($value === false || trim((string)$value) === '') {
|
||||
return $default;
|
||||
}
|
||||
return trim((string)$value);
|
||||
}
|
||||
|
||||
function failMediaProxy(int $status, string $message): never
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
echo $message;
|
||||
exit;
|
||||
}
|
||||
|
||||
function mediaProxyAllowedHosts(): array
|
||||
{
|
||||
$configured = mediaProxyEnv('JC_WEB_IM_MEDIA_PROXY_HOSTS');
|
||||
$hosts = array_map(
|
||||
static fn(string $host): string => strtolower(rtrim(trim($host), '.')),
|
||||
preg_split('/[,\s]+/', $configured) ?: []
|
||||
);
|
||||
|
||||
$hosts[] = 'zhiyanim.dfpnm.cn';
|
||||
return array_values(array_unique(array_filter($hosts)));
|
||||
}
|
||||
|
||||
function validatedMediaProxyUrl(string $url): array
|
||||
{
|
||||
if ($url === '' || strlen($url) > 2048 || filter_var($url, FILTER_VALIDATE_URL) === false) {
|
||||
failMediaProxy(400, '媒体地址不合法');
|
||||
}
|
||||
|
||||
$parts = parse_url($url);
|
||||
$scheme = strtolower((string)($parts['scheme'] ?? ''));
|
||||
$host = strtolower(rtrim((string)($parts['host'] ?? ''), '.'));
|
||||
$port = isset($parts['port']) ? (int)$parts['port'] : 443;
|
||||
if (
|
||||
$scheme !== 'https'
|
||||
|| $host === ''
|
||||
|| $port !== 443
|
||||
|| isset($parts['user'])
|
||||
|| isset($parts['pass'])
|
||||
|| !in_array($host, mediaProxyAllowedHosts(), true)
|
||||
) {
|
||||
failMediaProxy(403, '媒体来源不允许代理');
|
||||
}
|
||||
|
||||
return [$url, $host];
|
||||
}
|
||||
|
||||
function mediaProxyCacheRoot(): string
|
||||
{
|
||||
$root = rtrim(mediaProxyEnv('JC_WEB_IM_MEDIA_CACHE_ROOT', '/var/cache/jc-web-im-media'), '/');
|
||||
if ($root === '' || !is_dir($root) || !is_writable($root)) {
|
||||
$root = rtrim(sys_get_temp_dir(), '/') . '/jc-web-im-media';
|
||||
if (!is_dir($root) && !@mkdir($root, 0755, true) && !is_dir($root)) {
|
||||
failMediaProxy(503, '媒体缓存目录不可用');
|
||||
}
|
||||
}
|
||||
|
||||
return $root;
|
||||
}
|
||||
|
||||
function mediaProxyPreferredIps(): array
|
||||
{
|
||||
$configured = preg_split('/[,\s]+/', mediaProxyEnv('JC_WEB_IM_MEDIA_PROXY_IPS')) ?: [];
|
||||
$ips = [];
|
||||
foreach ($configured as $ip) {
|
||||
$ip = trim($ip);
|
||||
if (
|
||||
$ip !== ''
|
||||
&& filter_var(
|
||||
$ip,
|
||||
FILTER_VALIDATE_IP,
|
||||
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
|
||||
) !== false
|
||||
) {
|
||||
$ips[$ip] = $ip;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($ips);
|
||||
}
|
||||
|
||||
function mediaProxyMime(string $path): string
|
||||
{
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = $finfo !== false ? (string)(finfo_file($finfo, $path) ?: '') : '';
|
||||
if ($finfo !== false) {
|
||||
finfo_close($finfo);
|
||||
}
|
||||
|
||||
return $mime;
|
||||
}
|
||||
|
||||
function fetchMediaProxyImage(string $url, string $host, string $cacheFile): void
|
||||
{
|
||||
$deadline = microtime(true) + WEB_MEDIA_PROXY_MAX_SECONDS;
|
||||
$lastError = '媒体源无响应';
|
||||
$preferredIps = mediaProxyPreferredIps();
|
||||
$targets = array_merge($preferredIps, array_fill(0, max(2, 6 - count($preferredIps)), null));
|
||||
|
||||
foreach ($targets as $index => $preferredIp) {
|
||||
if (microtime(true) >= $deadline) {
|
||||
break;
|
||||
}
|
||||
$attempt = $index + 1;
|
||||
$temporaryFile = $cacheFile . '.tmp.' . getmypid() . '.' . $attempt;
|
||||
$stream = @fopen($temporaryFile, 'x+b');
|
||||
if ($stream === false) {
|
||||
$lastError = '无法创建媒体缓存文件';
|
||||
break;
|
||||
}
|
||||
|
||||
$bytes = 0;
|
||||
$tooLarge = false;
|
||||
$curl = curl_init($url);
|
||||
if ($curl === false) {
|
||||
fclose($stream);
|
||||
@unlink($temporaryFile);
|
||||
$lastError = '无法初始化媒体请求';
|
||||
break;
|
||||
}
|
||||
|
||||
$remainingMs = max(1000, (int)floor(($deadline - microtime(true)) * 1000));
|
||||
$curlOptions = [
|
||||
CURLOPT_RETURNTRANSFER => false,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_CONNECTTIMEOUT_MS => min(2500, $remainingMs),
|
||||
CURLOPT_TIMEOUT_MS => min(12000, $remainingMs),
|
||||
CURLOPT_NOSIGNAL => true,
|
||||
CURLOPT_DNS_CACHE_TIMEOUT => 0,
|
||||
CURLOPT_FRESH_CONNECT => true,
|
||||
CURLOPT_FORBID_REUSE => true,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_USERAGENT => 'JC-Web-IM-Media-Proxy/1.0',
|
||||
CURLOPT_HTTPHEADER => ['Accept: image/avif,image/webp,image/*,*/*;q=0.8'],
|
||||
CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use ($stream, &$bytes, &$tooLarge): int {
|
||||
$length = strlen($chunk);
|
||||
if ($bytes + $length > WEB_MEDIA_PROXY_MAX_BYTES) {
|
||||
$tooLarge = true;
|
||||
return 0;
|
||||
}
|
||||
$written = fwrite($stream, $chunk);
|
||||
if ($written === false) {
|
||||
return 0;
|
||||
}
|
||||
$bytes += $written;
|
||||
return $written;
|
||||
},
|
||||
];
|
||||
if (is_string($preferredIp) && $preferredIp !== '') {
|
||||
$curlOptions[CURLOPT_RESOLVE] = [$host . ':443:' . $preferredIp];
|
||||
}
|
||||
curl_setopt_array($curl, $curlOptions);
|
||||
|
||||
$result = curl_exec($curl);
|
||||
$status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
$curlError = curl_error($curl);
|
||||
curl_close($curl);
|
||||
fflush($stream);
|
||||
fclose($stream);
|
||||
|
||||
$mime = $bytes > 0 && is_file($temporaryFile) ? mediaProxyMime($temporaryFile) : '';
|
||||
if ($result !== false && $status >= 200 && $status < 300 && !$tooLarge && str_starts_with($mime, 'image/')) {
|
||||
if (!@rename($temporaryFile, $cacheFile)) {
|
||||
@unlink($temporaryFile);
|
||||
$lastError = '媒体缓存保存失败';
|
||||
break;
|
||||
}
|
||||
@chmod($cacheFile, 0644);
|
||||
return;
|
||||
}
|
||||
|
||||
@unlink($temporaryFile);
|
||||
$lastError = $tooLarge
|
||||
? '图片超过代理大小限制'
|
||||
: ($curlError !== '' ? $curlError : ($status > 0 ? '媒体源返回 HTTP ' . $status : '媒体源无响应'));
|
||||
if ($attempt < count($targets) && microtime(true) < $deadline) {
|
||||
usleep(150000);
|
||||
}
|
||||
}
|
||||
|
||||
error_log(sprintf('media_proxy fetch failed host=%s error=%s', $host, $lastError));
|
||||
failMediaProxy(502, '图片源暂时不可用,请稍后重试');
|
||||
}
|
||||
|
||||
function serveMediaProxyImage(string $cacheFile, string $etag): never
|
||||
{
|
||||
if (!is_file($cacheFile)) {
|
||||
failMediaProxy(404, '媒体缓存不存在');
|
||||
}
|
||||
$mime = mediaProxyMime($cacheFile);
|
||||
if (!str_starts_with($mime, 'image/')) {
|
||||
failMediaProxy(415, '媒体内容不是图片');
|
||||
}
|
||||
|
||||
header('ETag: "' . $etag . '"');
|
||||
header('Cache-Control: public, max-age=86400, immutable');
|
||||
header('Content-Type: ' . $mime);
|
||||
header('Content-Length: ' . (string)filesize($cacheFile));
|
||||
header('Content-Disposition: inline');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
if (trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? ''), ' W/') === '"' . $etag . '"') {
|
||||
http_response_code(304);
|
||||
exit;
|
||||
}
|
||||
|
||||
readfile($cacheFile);
|
||||
exit;
|
||||
}
|
||||
|
||||
[$url, $host] = validatedMediaProxyUrl(trim((string)($_GET['url'] ?? '')));
|
||||
$cacheRoot = mediaProxyCacheRoot();
|
||||
$cacheKey = hash('sha256', $url);
|
||||
$cacheFile = $cacheRoot . '/' . $cacheKey . '.image';
|
||||
$lockFile = $cacheRoot . '/' . $cacheKey . '.lock';
|
||||
|
||||
if (!is_file($cacheFile)) {
|
||||
$lock = @fopen($lockFile, 'c');
|
||||
if ($lock === false || !flock($lock, LOCK_EX)) {
|
||||
if (is_resource($lock)) {
|
||||
fclose($lock);
|
||||
}
|
||||
failMediaProxy(503, '媒体缓存暂时忙碌');
|
||||
}
|
||||
if (!is_file($cacheFile)) {
|
||||
fetchMediaProxyImage($url, $host, $cacheFile);
|
||||
}
|
||||
flock($lock, LOCK_UN);
|
||||
fclose($lock);
|
||||
}
|
||||
|
||||
serveMediaProxyImage($cacheFile, $cacheKey);
|
||||
Vendored
+71
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
const WEB_MEDIA_STREAM_ROOT = '/opt/im-server/media';
|
||||
|
||||
function failMediaStream(int $status, string $message): never
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
echo $message;
|
||||
exit;
|
||||
}
|
||||
|
||||
$url = trim((string)($_GET['url'] ?? ''));
|
||||
if ($url === '') {
|
||||
failMediaStream(400, '缺少视频地址');
|
||||
}
|
||||
|
||||
$path = rawurldecode((string)(parse_url($url, PHP_URL_PATH) ?: ''));
|
||||
if (!preg_match('#^/fs/[0-9]+/[A-Za-z0-9_./ -]+$#', $path) || str_contains($path, '..')) {
|
||||
failMediaStream(400, '视频地址不合法');
|
||||
}
|
||||
|
||||
$root = realpath(WEB_MEDIA_STREAM_ROOT);
|
||||
$fullPath = realpath(rtrim(WEB_MEDIA_STREAM_ROOT, '/') . $path);
|
||||
if ($root === false || $fullPath === false || !str_starts_with($fullPath, $root . DIRECTORY_SEPARATOR) || !is_file($fullPath)) {
|
||||
failMediaStream(404, '视频文件不存在');
|
||||
}
|
||||
|
||||
$size = (int)filesize($fullPath);
|
||||
$mime = (string)(mime_content_type($fullPath) ?: 'video/mp4');
|
||||
if (!str_starts_with($mime, 'video/')) {
|
||||
$extension = strtolower((string)pathinfo($fullPath, PATHINFO_EXTENSION));
|
||||
$mime = $extension === 'webm' ? 'video/webm' : ($extension === 'mov' ? 'video/quicktime' : 'video/mp4');
|
||||
}
|
||||
|
||||
$start = 0;
|
||||
$end = max(0, $size - 1);
|
||||
$range = trim((string)($_SERVER['HTTP_RANGE'] ?? ''));
|
||||
if ($range !== '' && preg_match('/^bytes=(\d*)-(\d*)$/', $range, $matches)) {
|
||||
if ($matches[1] !== '') $start = (int)$matches[1];
|
||||
if ($matches[2] !== '') $end = min($end, (int)$matches[2]);
|
||||
if ($start < 0 || $start > $end || $start >= $size) {
|
||||
header('Content-Range: bytes */' . $size);
|
||||
failMediaStream(416, '请求范围无效');
|
||||
}
|
||||
http_response_code(206);
|
||||
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
|
||||
}
|
||||
|
||||
$length = $end - $start + 1;
|
||||
header('Content-Type: ' . $mime);
|
||||
header('Content-Length: ' . $length);
|
||||
header('Accept-Ranges: bytes');
|
||||
header('Content-Disposition: inline');
|
||||
header('Cache-Control: public, max-age=86400');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
|
||||
$stream = fopen($fullPath, 'rb');
|
||||
if ($stream === false) failMediaStream(500, '视频文件无法读取');
|
||||
fseek($stream, $start);
|
||||
$remaining = $length;
|
||||
while ($remaining > 0 && !feof($stream)) {
|
||||
$chunk = fread($stream, min(1024 * 1024, $remaining));
|
||||
if ($chunk === false || $chunk === '') break;
|
||||
echo $chunk;
|
||||
$remaining -= strlen($chunk);
|
||||
flush();
|
||||
}
|
||||
fclose($stream);
|
||||
Vendored
+66
-3
@@ -78,7 +78,7 @@ function fetchActionMessageRow(string $messageUid): array
|
||||
return is_array($row) ? $row : [];
|
||||
}
|
||||
|
||||
function ensureMessageEditsTable(): void
|
||||
function ensureMessageEditsTable(): void
|
||||
{
|
||||
imDb()->exec(
|
||||
'create table if not exists `t_web_message_edits` ('
|
||||
@@ -93,7 +93,55 @@ function ensureMessageEditsTable(): void
|
||||
. 'key `idx_edited_at_ms` (`edited_at_ms`)'
|
||||
. ') engine=InnoDB default charset=utf8mb4'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePinnedMessagesTable(): void
|
||||
{
|
||||
$db = imAppDb();
|
||||
$db->exec(
|
||||
'create table if not exists `t_web_pinned_messages` ('
|
||||
. '`user_id` varchar(64) not null,'
|
||||
. '`conversation_type` varchar(16) not null,'
|
||||
. '`target_id` varchar(128) not null,'
|
||||
. '`message_uid` varchar(32) not null,'
|
||||
. '`message_json` text null,'
|
||||
. '`updated_at_ms` bigint not null,'
|
||||
. 'primary key (`user_id`, `conversation_type`, `target_id`)'
|
||||
. ') engine=InnoDB default charset=utf8mb4'
|
||||
);
|
||||
try {
|
||||
$db->exec('alter table `t_web_pinned_messages` add column `message_json` text null after `message_uid`');
|
||||
} catch (Throwable) {
|
||||
// The column already exists.
|
||||
}
|
||||
}
|
||||
|
||||
function savePinnedMessage(string $userId, string $conversationType, string $targetId, string $messageUid, array $message): void
|
||||
{
|
||||
ensurePinnedMessagesTable();
|
||||
$statement = imAppDb()->prepare(
|
||||
'insert into `t_web_pinned_messages` (`user_id`, `conversation_type`, `target_id`, `message_uid`, `message_json`, `updated_at_ms`)'
|
||||
. ' values (?, ?, ?, ?, ?, ?) on duplicate key update `message_uid` = values(`message_uid`),'
|
||||
. ' `message_json` = values(`message_json`), `updated_at_ms` = values(`updated_at_ms`)'
|
||||
);
|
||||
$statement->execute([
|
||||
$userId,
|
||||
$conversationType,
|
||||
$targetId,
|
||||
$messageUid,
|
||||
json_encode($message, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
nowTimestampMs(),
|
||||
]);
|
||||
}
|
||||
|
||||
function removePinnedMessage(string $userId, string $conversationType, string $targetId): void
|
||||
{
|
||||
ensurePinnedMessagesTable();
|
||||
$statement = imAppDb()->prepare(
|
||||
'delete from `t_web_pinned_messages` where `user_id` = ? and `conversation_type` = ? and `target_id` = ?'
|
||||
);
|
||||
$statement->execute([$userId, $conversationType, $targetId]);
|
||||
}
|
||||
|
||||
function editTextMessage(string $operatorId, string $messageUid, string $text): array
|
||||
{
|
||||
@@ -222,7 +270,22 @@ if ($action === '' || $messageUid <= 0) {
|
||||
respond(400, '缺少操作类型或消息ID');
|
||||
}
|
||||
|
||||
try {
|
||||
try {
|
||||
if ($action === 'pin' || $action === 'unpin') {
|
||||
if ($userId === '') respond(400, '缺少操作者ID');
|
||||
$targetId = trim((string)($payload['target'] ?? ''));
|
||||
$conversationType = trim((string)($payload['conversationType'] ?? 'single'));
|
||||
if ($targetId === '') respond(400, '缺少会话ID');
|
||||
$userId = resolveActionUserId($userId);
|
||||
if ($action === 'pin') {
|
||||
$message = is_array($payload['message'] ?? null) ? $payload['message'] : [];
|
||||
savePinnedMessage($userId, $conversationType, $targetId, $messageUidText, $message);
|
||||
} else {
|
||||
removePinnedMessage($userId, $conversationType, $targetId);
|
||||
}
|
||||
respond(0, 'success', ['messageUid' => $messageUidText, 'action' => $action]);
|
||||
}
|
||||
|
||||
if ($action === 'recall') {
|
||||
if ($userId === '') {
|
||||
respond(400, '缺少操作者ID');
|
||||
|
||||
Vendored
+1724
-900
File diff suppressed because it is too large
Load Diff
Vendored
+112
-28
@@ -8,8 +8,9 @@ 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_MEDIA_TYPE_IMAGE = 1;
|
||||
const WEB_MEDIA_TYPE_VIDEO = 3;
|
||||
const WEB_MEDIA_TYPE_FILE = 4;
|
||||
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
||||
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
@@ -245,13 +246,32 @@ function storeMessageAttachment(
|
||||
]);
|
||||
}
|
||||
|
||||
function attachmentFromRequest(string $messageType): ?array
|
||||
{
|
||||
if (!in_array($messageType, ['image', 'file'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$attachmentFile = $_FILES['attachment'] ?? $_FILES['file'] ?? null;
|
||||
function attachmentFromRequest(string $messageType): ?array
|
||||
{
|
||||
if (!in_array($messageType, ['image', 'video', 'file'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$forwardUrl = trim((string)($_POST['attachmentUrl'] ?? ''));
|
||||
if ($forwardUrl !== '') {
|
||||
$parts = parse_url($forwardUrl);
|
||||
$scheme = strtolower((string)($parts['scheme'] ?? ''));
|
||||
if (!in_array($scheme, ['http', 'https'], true) || empty($parts['host']) || strlen($forwardUrl) > 2048) {
|
||||
respond(400, '媒体地址不合法');
|
||||
}
|
||||
$mime = trim((string)($_POST['attachmentMime'] ?? 'application/octet-stream'));
|
||||
$name = safeUploadName((string)($_POST['attachmentName'] ?? ($messageType === 'image' ? '图片' : ($messageType === 'video' ? '视频.mp4' : '文件'))));
|
||||
return [
|
||||
'type' => $messageType,
|
||||
'url' => $forwardUrl,
|
||||
'name' => $name,
|
||||
'mime' => $mime,
|
||||
'size' => max(0, (int)($_POST['attachmentSize'] ?? 0)),
|
||||
'mediaType' => $messageType === 'image' ? WEB_MEDIA_TYPE_IMAGE : ($messageType === 'video' ? WEB_MEDIA_TYPE_VIDEO : WEB_MEDIA_TYPE_FILE),
|
||||
];
|
||||
}
|
||||
|
||||
$attachmentFile = $_FILES['attachment'] ?? $_FILES['file'] ?? null;
|
||||
if (!is_array($attachmentFile)) {
|
||||
respond(400, '缺少上传文件');
|
||||
}
|
||||
@@ -266,11 +286,15 @@ $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);
|
||||
$conversationType = $_POST['conversationType'] ?? $_POST['type'] ?? $payload['conversationType'] ?? $payload['type'] ?? 0;
|
||||
$isGroupConversation = in_array((string)$conversationType, ['1', 'group', 'channel'], true);
|
||||
$mentionedUserIds = $payload['mentionedUserIds'] ?? [];
|
||||
$mentionedUserIds = is_array($mentionedUserIds)
|
||||
? array_values(array_unique(array_filter(array_map(static fn($value): string => trim((string)$value), $mentionedUserIds))))
|
||||
: [];
|
||||
$attachment = null;
|
||||
|
||||
if ($sender === '' || $target === '' || ($text === '' && !in_array($messageType, ['image', 'file'], true))) {
|
||||
if ($sender === '' || $target === '' || ($text === '' && !in_array($messageType, ['image', 'video', 'file'], true))) {
|
||||
respond(400, '缺少发送者、接收者或消息内容');
|
||||
}
|
||||
|
||||
@@ -285,11 +309,69 @@ try {
|
||||
. ' 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, '你不在该群组中');
|
||||
}
|
||||
$memberStatement->execute([$target, $sender]);
|
||||
$group = $memberStatement->fetch();
|
||||
if (!$group) {
|
||||
// 手机端创建的原生群不一定存在于 Web 自建群表中,需回退检查 IM 群成员表。
|
||||
$imDb = imDb();
|
||||
$nativeGroupStatement = $imDb->prepare(
|
||||
'select 1 from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1'
|
||||
);
|
||||
$nativeGroupStatement->execute([$target]);
|
||||
$nativeMemberStatement = $imDb->prepare(
|
||||
'select `_type` from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1'
|
||||
);
|
||||
$nativeMemberStatement->execute([$target, $sender]);
|
||||
$nativeMemberType = $nativeMemberStatement->fetchColumn();
|
||||
if (!$nativeGroupStatement->fetchColumn() || $nativeMemberType === false) {
|
||||
respond(403, '你不在该群组中');
|
||||
}
|
||||
|
||||
$attachment = attachmentFromRequest($messageType);
|
||||
$pushContent = $text !== ''
|
||||
? $text
|
||||
: ($attachment
|
||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : ((string)$attachment['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name']))
|
||||
: $text);
|
||||
$payloadBody = [
|
||||
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 5)) : 1,
|
||||
'searchableContent' => $pushContent,
|
||||
'pushContent' => $pushContent,
|
||||
'content' => $text,
|
||||
'persistFlag' => 3,
|
||||
];
|
||||
if ($mentionedUserIds) {
|
||||
$payloadBody['mentionedType'] = 1;
|
||||
$payloadBody['mentionedTarget'] = $mentionedUserIds;
|
||||
}
|
||||
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);
|
||||
}
|
||||
$nativeResult = requireImSuccess(imAdminPost('/admin/message/send', [
|
||||
'sender' => $sender,
|
||||
'conv' => ['type' => 1, 'target' => $target, 'line' => 0],
|
||||
'payload' => $payloadBody,
|
||||
]), '原生群消息发送失败');
|
||||
$messageUid = isset($nativeResult['messageUid']) ? (string)$nativeResult['messageUid'] : '';
|
||||
$timestamp = (int)($nativeResult['timestamp'] ?? webImNowMs());
|
||||
if ($attachment && $messageUid !== '') {
|
||||
storeMessageAttachment($appDb, $messageUid, 'group', $target, $sender, $attachment, $timestamp);
|
||||
}
|
||||
$role = (int)$nativeMemberType === 2 ? 'owner' : ((int)$nativeMemberType === 1 ? 'admin' : 'member');
|
||||
respond(0, 'success', [
|
||||
'messageUid' => $messageUid,
|
||||
'timestamp' => $timestamp,
|
||||
'senderRole' => $role,
|
||||
'attachment' => $attachment,
|
||||
'nativeError' => '',
|
||||
]);
|
||||
}
|
||||
$category = (string)($group['category'] ?? 'group');
|
||||
$role = (string)($group['role'] ?? 'member');
|
||||
if ($category === 'channel' && !in_array($role, ['owner', 'admin'], true)) {
|
||||
@@ -319,15 +401,19 @@ try {
|
||||
$pushContent = $text !== ''
|
||||
? $text
|
||||
: ($attachment
|
||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : ((string)$attachment['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name']))
|
||||
: $text);
|
||||
$payloadBody = [
|
||||
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
|
||||
$payloadBody = [
|
||||
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 5)) : 1,
|
||||
'searchableContent' => $pushContent,
|
||||
'pushContent' => $pushContent,
|
||||
'content' => $text,
|
||||
'persistFlag' => 3,
|
||||
];
|
||||
'persistFlag' => 3,
|
||||
];
|
||||
if ($mentionedUserIds) {
|
||||
$payloadBody['mentionedType'] = 1;
|
||||
$payloadBody['mentionedTarget'] = $mentionedUserIds;
|
||||
}
|
||||
if ($attachment) {
|
||||
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
|
||||
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
|
||||
@@ -344,8 +430,7 @@ try {
|
||||
'target' => $target,
|
||||
'line' => 0,
|
||||
],
|
||||
'payload' => $payloadBody,
|
||||
'userMessage' => true,
|
||||
'payload' => $payloadBody,
|
||||
]), '原生群消息发送失败');
|
||||
} catch (Throwable $error) {
|
||||
$nativeError = $error->getMessage() ?: '原生群消息发送失败';
|
||||
@@ -364,10 +449,10 @@ try {
|
||||
$pushContent = $text !== ''
|
||||
? $text
|
||||
: ($attachment
|
||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : ((string)$attachment['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name']))
|
||||
: $text);
|
||||
$payloadBody = [
|
||||
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
|
||||
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 5)) : 1,
|
||||
'searchableContent' => $pushContent,
|
||||
'pushContent' => $pushContent,
|
||||
'content' => $text,
|
||||
@@ -390,8 +475,7 @@ try {
|
||||
'target' => $target,
|
||||
'line' => 0,
|
||||
],
|
||||
'payload' => $payloadBody,
|
||||
'userMessage' => true,
|
||||
'payload' => $payloadBody,
|
||||
]), '发送失败');
|
||||
|
||||
$messageUid = isset($result['messageUid']) ? (string)$result['messageUid'] : '';
|
||||
|
||||
Vendored
+8
-7
@@ -1,12 +1,13 @@
|
||||
window.JC_WEB_IM_CONFIG = {
|
||||
adminServer: 'http://winchen.ezpay.asia:8024',
|
||||
appServer: 'http://winchen.ezpay.asia:8888',
|
||||
qrAppServer: 'http://winchen.ezpay.asia:18888',
|
||||
adminServer: 'http://18.141.44.165:8100',
|
||||
appServer: '/app-server',
|
||||
qrAppServer: 'http://18.141.44.165:8888',
|
||||
platformConfigApi: '/api/platform_config.php',
|
||||
registerConfigApi: 'http://winchen.ezpay.asia:8024/api/register-config',
|
||||
imServerHost: 'winchen.ezpay.asia',
|
||||
imRoutePort: 18000,
|
||||
registerConfigApi: 'http://18.141.44.165:8100/api/register-config',
|
||||
imServerHost: '18.141.44.165',
|
||||
imRoutePort: 80,
|
||||
useWss: false,
|
||||
wsPath: '/ws',
|
||||
sdkMode: 'http'
|
||||
sdkMode: 'legacy-http',
|
||||
syncIntervalMs: 2500
|
||||
};
|
||||
|
||||
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
window.JC_WEB_IM_CONFIG = {
|
||||
runtimeEnvironment: 'local',
|
||||
adminServer: 'http://18.141.44.165:8100',
|
||||
appServer: '/app-server',
|
||||
qrAppServer: 'http://18.141.44.165:8888',
|
||||
platformConfigApi: '/local-api/platform_config.php',
|
||||
registerConfigApi: '/local-api/register_config.php',
|
||||
registerPhoneExistsApi: 'http://18.141.44.165:8100/api/register-phone-exists',
|
||||
imServerHost: '18.141.44.165',
|
||||
imRoutePort: 80,
|
||||
useWss: false,
|
||||
wsPath: '/ws',
|
||||
sdkMode: 'legacy-http',
|
||||
syncIntervalMs: 2500,
|
||||
sseEnabled: false,
|
||||
sseTicketApi: '/local-api/sse_ticket.php',
|
||||
sseUrl: '/ws-events'
|
||||
};
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
(() => {
|
||||
const protocol = window.location.protocol || 'http:';
|
||||
const host = window.location.hostname || '18.141.44.165';
|
||||
|
||||
window.JC_WEB_IM_CONFIG = {
|
||||
runtimeEnvironment: 'production',
|
||||
adminServer: `${protocol}//${host}:8100`,
|
||||
appServer: `${protocol}//${host}:8888`,
|
||||
qrAppServer: `${protocol}//${host}:8888`,
|
||||
platformConfigApi: '/api/platform_config.php',
|
||||
registerConfigApi: `${protocol}//${host}:8100/api/register-config`,
|
||||
registerPhoneExistsApi: `${protocol}//${host}:8100/api/register-phone-exists`,
|
||||
imServerHost: host,
|
||||
imRoutePort: 80,
|
||||
useWss: false,
|
||||
wsPath: '/ws',
|
||||
sdkMode: 'legacy-http',
|
||||
syncIntervalMs: 15000,
|
||||
sseEnabled: true,
|
||||
sseTicketApi: '/api/sse_ticket.php',
|
||||
sseUrl: '/ws-events'
|
||||
};
|
||||
})();
|
||||
+314
-64
@@ -1268,10 +1268,48 @@ button {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.conversation-item.active,
|
||||
.conversation-item:hover {
|
||||
background: #eaf3f1;
|
||||
}
|
||||
.conversation-item.active,
|
||||
.conversation-item:hover {
|
||||
background: #eaf3f1;
|
||||
}
|
||||
|
||||
.conversation-item.pinned {
|
||||
background: rgba(15, 118, 110, 0.055);
|
||||
}
|
||||
|
||||
.conversation-pin {
|
||||
display: inline-block;
|
||||
margin-right: 5px;
|
||||
color: #0f766e;
|
||||
font-size: 12px;
|
||||
transform: rotate(-25deg);
|
||||
}
|
||||
|
||||
.conversation-context-menu {
|
||||
position: fixed;
|
||||
z-index: 1200;
|
||||
min-width: 124px;
|
||||
padding: 6px;
|
||||
border: 1px solid #d7e0e8;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.conversation-context-menu button {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #1f2937;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.conversation-context-menu button:hover {
|
||||
background: #eef7f6;
|
||||
}
|
||||
|
||||
.favorite-item {
|
||||
width: 100%;
|
||||
@@ -1485,7 +1523,7 @@ button {
|
||||
box-shadow: 0 0 0 2px #f7fafc;
|
||||
}
|
||||
|
||||
.chat-panel {
|
||||
.chat-panel {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
@@ -1493,8 +1531,12 @@ button {
|
||||
display: grid;
|
||||
grid-template-rows: 72px minmax(0, 1fr) auto auto;
|
||||
overflow: hidden;
|
||||
background: #eef2f5;
|
||||
}
|
||||
background: #eef2f5;
|
||||
}
|
||||
|
||||
.chat-panel.has-pinned-message {
|
||||
grid-template-rows: 72px auto minmax(0, 1fr) auto auto;
|
||||
}
|
||||
|
||||
.contact-panel {
|
||||
min-width: 0;
|
||||
@@ -2273,7 +2315,7 @@ button {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.message-row.out .message-bubble {
|
||||
.message-row.out .message-bubble {
|
||||
background: var(--bubble-out);
|
||||
border-color: #bfe4dc;
|
||||
}
|
||||
@@ -2326,39 +2368,233 @@ button {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.message-image img {
|
||||
.message-image img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 320px;
|
||||
-o-object-fit: contain;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.pinned-message-banner {
|
||||
width: calc(100% - 28px);
|
||||
height: 42px;
|
||||
min-height: 42px;
|
||||
margin: 8px 14px;
|
||||
align-self: center;
|
||||
display: grid;
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d5e2df;
|
||||
border-radius: 9px;
|
||||
background: #f7fbfa;
|
||||
color: #52606d;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pinned-message-banner strong {
|
||||
color: #0f766e;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pinned-message-preview {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pinned-message-preview > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pinned-message-preview img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: 0 0 28px;
|
||||
border-radius: 4px;
|
||||
-o-object-fit: cover;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.pinned-message-close {
|
||||
padding: 0 4px;
|
||||
color: #748091;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.message-row.system {
|
||||
justify-content: center;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.system-message-pill {
|
||||
max-width: min(78%, 620px);
|
||||
padding: 5px 12px;
|
||||
border-radius: 5px;
|
||||
background: #c8c8c8;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.message-video {
|
||||
display: block;
|
||||
width: min(360px, 68vw);
|
||||
max-height: 360px;
|
||||
border-radius: 7px;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.mention-picker {
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
bottom: 112px;
|
||||
z-index: 20;
|
||||
width: min(320px, calc(100% - 36px));
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
border: 1px solid #d7e0e8;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 12px 32px rgb(15 23 42 / 18%);
|
||||
}
|
||||
|
||||
.mention-picker button {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mention-picker button:hover {
|
||||
background: #eef7f5;
|
||||
}
|
||||
|
||||
.mention-picker img,
|
||||
.mention-picker button > span {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 7px;
|
||||
-o-object-fit: cover;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.mention-picker button > span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #dceaf3;
|
||||
color: #23445c;
|
||||
}
|
||||
|
||||
.red-packet-card {
|
||||
width: min(320px, 68vw);
|
||||
margin: -10px -12px -8px;
|
||||
overflow: hidden;
|
||||
border-radius: 7px;
|
||||
background: #ff9f32;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.red-packet-card-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
padding: 20px 18px 17px;
|
||||
}
|
||||
|
||||
.red-packet-card-main > span:last-child {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.red-packet-card-main strong {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.red-packet-card-main small {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.red-packet-icon,
|
||||
.red-packet-mini-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 3px;
|
||||
background: #d92f27;
|
||||
color: #ffd968;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.red-packet-icon {
|
||||
width: 34px;
|
||||
height: 44px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.red-packet-card-footer {
|
||||
padding: 9px 15px;
|
||||
background: #fff;
|
||||
color: #8a8f94;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.red-packet-receipt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: #69747d;
|
||||
}
|
||||
|
||||
.red-packet-mini-icon {
|
||||
width: 22px;
|
||||
height: 28px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.image-viewer-backdrop {
|
||||
.image-viewer-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(12, 18, 26, 0.58);
|
||||
}
|
||||
|
||||
.image-viewer-dialog {
|
||||
width: min(880px, calc(100vw - 48px));
|
||||
height: min(640px, calc(100vh - 48px));
|
||||
min-width: 360px;
|
||||
min-height: 280px;
|
||||
max-width: calc(100vw - 48px);
|
||||
max-height: calc(100vh - 48px);
|
||||
padding: 20px;
|
||||
background: rgba(7, 12, 20, 0.82);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.image-viewer-dialog {
|
||||
width: min(920px, calc(100vw - 64px));
|
||||
height: min(660px, calc(100vh - 64px));
|
||||
min-width: 320px;
|
||||
min-height: 260px;
|
||||
max-width: calc(100vw - 64px);
|
||||
max-height: calc(100vh - 64px);
|
||||
display: grid;
|
||||
grid-template-rows: 52px minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
resize: both;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 8px;
|
||||
background: #101820;
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.36);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 14px;
|
||||
background: #0b1119;
|
||||
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.56);
|
||||
}
|
||||
|
||||
.image-viewer-toolbar {
|
||||
@@ -2372,17 +2608,6 @@ button {
|
||||
background: rgba(24, 33, 47, 0.94);
|
||||
}
|
||||
|
||||
.image-viewer-title {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
color: #f8fafc;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.image-viewer-toolbar button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
@@ -2401,42 +2626,67 @@ button {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.image-viewer-stage {
|
||||
.image-viewer-stage {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
background: #0c121a;
|
||||
cursor: default;
|
||||
touch-action: none;
|
||||
}
|
||||
overflow: hidden;
|
||||
background: #0c121a;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.image-viewer-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
z-index: 2;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 50%;
|
||||
background: rgba(18, 27, 39, 0.76);
|
||||
color: #fff;
|
||||
font-size: 42px;
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
transform: translateY(-50%);
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.image-viewer-nav:hover {
|
||||
background: rgba(41, 57, 75, 0.94);
|
||||
}
|
||||
|
||||
.image-viewer-prev {
|
||||
left: 18px;
|
||||
}
|
||||
|
||||
.image-viewer-next {
|
||||
right: 18px;
|
||||
}
|
||||
|
||||
.image-viewer-stage.zoomed {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.image-viewer-stage.dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.image-viewer-img {
|
||||
max-width: calc(100% - 32px);
|
||||
max-height: calc(100% - 32px);
|
||||
-o-object-fit: contain;
|
||||
object-fit: contain;
|
||||
.image-viewer-img {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 90px;
|
||||
width: calc(100% - 180px);
|
||||
height: calc(100% - 40px);
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
-o-object-fit: contain;
|
||||
object-fit: contain;
|
||||
transform-origin: center center;
|
||||
transition: transform 0.08s ease-out;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
transition: transform 0.12s ease-out;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.image-viewer-stage.dragging .image-viewer-img {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.sender-profile-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
Vendored
+1
-1
@@ -1 +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>
|
||||
<!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.production.js"></script><script defer="defer" src="js/chunk-vendors.d3ec3fbc.js"></script><script defer="defer" src="js/app.21e75967.js"></script><link href="css/app.0488f79e.css" rel="stylesheet"></head><body><noscript>请启用 JavaScript 后使用 Web IM。</noscript><div id="app"></div></body></html>
|
||||
Vendored
+10455
File diff suppressed because it is too large
Load Diff
Vendored
+1
File diff suppressed because one or more lines are too long
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
-1
File diff suppressed because one or more lines are too long
@@ -16,6 +16,7 @@
|
||||
/* harmony export */ R1: function() { return /* binding */ unref; },
|
||||
/* harmony export */ X2: function() { return /* binding */ ReactiveEffect; },
|
||||
/* harmony export */ bl: function() { return /* binding */ resetTracking; },
|
||||
/* harmony export */ fE: function() { return /* binding */ isShallow; },
|
||||
/* harmony export */ g8: function() { return /* binding */ isReactive; },
|
||||
/* harmony export */ hV: function() { return /* binding */ traverse; },
|
||||
/* harmony export */ hZ: function() { return /* binding */ trigger; },
|
||||
@@ -23,13 +24,14 @@
|
||||
/* harmony export */ jr: function() { return /* binding */ onScopeDispose; },
|
||||
/* harmony export */ ju: function() { return /* binding */ isProxy; },
|
||||
/* harmony export */ o5: function() { return /* binding */ getCurrentScope; },
|
||||
/* harmony export */ qA: function() { return /* binding */ shallowReadArray; },
|
||||
/* harmony export */ u4: function() { return /* binding */ track; },
|
||||
/* harmony export */ uY: function() { return /* binding */ effectScope; },
|
||||
/* harmony export */ ux: function() { return /* binding */ toRaw; },
|
||||
/* harmony export */ wB: function() { return /* binding */ watch; },
|
||||
/* harmony export */ yC: function() { return /* binding */ EffectScope; }
|
||||
/* harmony export */ });
|
||||
/* unused harmony exports ARRAY_ITERATE_KEY, EffectFlags, ITERATE_KEY, MAP_KEY_ITERATE_KEY, ReactiveFlags, TrackOpTypes, TriggerOpTypes, WatchErrorCodes, customRef, effect, enableTracking, getCurrentWatcher, isReadonly, isShallow, onEffectCleanup, onWatcherCleanup, reactiveReadArray, readonly, shallowReadArray, shallowReadonly, shallowRef, stop, toReactive, toReadonly, toRef, toValue, triggerRef */
|
||||
/* unused harmony exports ARRAY_ITERATE_KEY, EffectFlags, ITERATE_KEY, MAP_KEY_ITERATE_KEY, ReactiveFlags, TrackOpTypes, TriggerOpTypes, WatchErrorCodes, customRef, effect, enableTracking, getCurrentWatcher, isReadonly, onEffectCleanup, onWatcherCleanup, reactiveReadArray, readonly, shallowReadonly, shallowRef, stop, toReadonly, toRef, toValue, triggerRef */
|
||||
/* unused harmony import specifier */ var extend;
|
||||
/* unused harmony import specifier */ var toRawType;
|
||||
/* unused harmony import specifier */ var isFunction;
|
||||
@@ -1825,6 +1827,10 @@ function traverse(value, depth = Infinity, seen) {
|
||||
return value;
|
||||
}
|
||||
|
||||
/* harmony export */ __webpack_require__.d(__webpack_exports__, [
|
||||
/* harmony export */ "lJ", 0, /* binding */ toReactive
|
||||
/* harmony export */ ]);
|
||||
|
||||
|
||||
/***/ }),
|
||||
|
||||
@@ -2363,11 +2369,15 @@ const stringifySymbol = (v, i = "") => {
|
||||
/* harmony export */ Wv: function() { return /* binding */ createBlock; },
|
||||
/* harmony export */ bo: function() { return /* binding */ withDirectives; },
|
||||
/* harmony export */ dY: function() { return /* binding */ nextTick; },
|
||||
/* harmony export */ eW: function() { return /* binding */ createTextVNode; },
|
||||
/* harmony export */ h: function() { return /* binding */ h; },
|
||||
/* harmony export */ pI: function() { return /* binding */ renderList; },
|
||||
/* harmony export */ pM: function() { return /* binding */ defineComponent; },
|
||||
/* harmony export */ qL: function() { return /* binding */ callWithAsyncErrorHandling; },
|
||||
/* harmony export */ uX: function() { return /* binding */ openBlock; },
|
||||
/* harmony export */ wB: function() { return /* binding */ watch; }
|
||||
/* harmony export */ });
|
||||
/* unused harmony exports BaseTransition, BaseTransitionPropsValidators, Comment, DeprecationTypes, ErrorCodes, ErrorTypeStrings, KeepAlive, Static, Suspense, Teleport, Text, assertNumber, callWithErrorHandling, cloneVNode, compatUtils, createHydrationRenderer, createPropsRestProxy, createSlots, createTextVNode, defineAsyncComponent, defineComponent, defineEmits, defineExpose, defineModel, defineOptions, defineProps, defineSlots, devtools, getCurrentInstance, getTransitionRawChildren, guardReactiveProps, h, handleError, hydrateOnIdle, hydrateOnInteraction, hydrateOnMediaQuery, hydrateOnVisible, initCustomFormatter, isMemoSame, isRuntimeOnly, isVNode, mergeDefaults, mergeModels, mergeProps, onActivated, onBeforeMount, onBeforeUpdate, onDeactivated, onErrorCaptured, onRenderTracked, onRenderTriggered, onServerPrefetch, onUnmounted, onUpdated, popScopeId, provide, pushScopeId, queuePostFlushCb, registerRuntimeCompiler, renderList, renderSlot, resolveComponent, resolveDirective, resolveDynamicComponent, resolveFilter, resolveTransitionHooks, setBlockTracking, setDevtoolsHook, setTransitionHooks, ssrContextKey, ssrUtils, toHandlers, transformVNodeArgs, useAttrs, useId, useModel, useSSRContext, useSlots, useTemplateRef, useTransitionState, version, warn, watchEffect, watchPostEffect, watchSyncEffect, withAsyncContext, withCtx, withDefaults, withMemo, withScopeId */
|
||||
/* unused harmony exports BaseTransition, BaseTransitionPropsValidators, Comment, DeprecationTypes, ErrorCodes, ErrorTypeStrings, KeepAlive, Static, Suspense, Text, assertNumber, callWithErrorHandling, cloneVNode, compatUtils, createHydrationRenderer, createPropsRestProxy, createSlots, defineAsyncComponent, defineEmits, defineExpose, defineModel, defineOptions, defineProps, defineSlots, devtools, getCurrentInstance, getTransitionRawChildren, guardReactiveProps, handleError, hydrateOnIdle, hydrateOnInteraction, hydrateOnMediaQuery, hydrateOnVisible, initCustomFormatter, isMemoSame, isRuntimeOnly, isVNode, mergeDefaults, mergeModels, mergeProps, onActivated, onBeforeMount, onBeforeUpdate, onDeactivated, onErrorCaptured, onRenderTracked, onRenderTriggered, onServerPrefetch, onUnmounted, onUpdated, popScopeId, provide, pushScopeId, queuePostFlushCb, registerRuntimeCompiler, renderSlot, resolveComponent, resolveDirective, resolveDynamicComponent, resolveFilter, resolveTransitionHooks, setBlockTracking, setDevtoolsHook, setTransitionHooks, ssrContextKey, ssrUtils, toHandlers, transformVNodeArgs, useAttrs, useId, useModel, useSSRContext, useSlots, useTemplateRef, useTransitionState, version, warn, watchEffect, watchPostEffect, watchSyncEffect, withAsyncContext, withCtx, withDefaults, withMemo, withScopeId */
|
||||
/* unused harmony import specifier */ var pauseTracking;
|
||||
/* unused harmony import specifier */ var resetTracking;
|
||||
/* unused harmony import specifier */ var isRef;
|
||||
@@ -2375,11 +2385,9 @@ const stringifySymbol = (v, i = "") => {
|
||||
/* unused harmony import specifier */ var shallowRef;
|
||||
/* unused harmony import specifier */ var isReactive;
|
||||
/* unused harmony import specifier */ var ref;
|
||||
/* unused harmony import specifier */ var isShallow;
|
||||
/* unused harmony import specifier */ var shallowReadArray;
|
||||
/* unused harmony import specifier */ var toReactive;
|
||||
/* unused harmony import specifier */ var customRef;
|
||||
/* unused harmony import specifier */ var track;
|
||||
/* unused harmony import specifier */ var isShallow;
|
||||
/* unused harmony import specifier */ var isString;
|
||||
/* unused harmony import specifier */ var isFunction;
|
||||
/* unused harmony import specifier */ var extend;
|
||||
@@ -2401,13 +2409,13 @@ const stringifySymbol = (v, i = "") => {
|
||||
/* unused harmony import specifier */ var isRegExp;
|
||||
/* unused harmony import specifier */ var camelize;
|
||||
/* unused harmony import specifier */ var capitalize;
|
||||
/* unused harmony import specifier */ var isObject;
|
||||
/* unused harmony import specifier */ var isSymbol;
|
||||
/* unused harmony import specifier */ var toHandlerKey;
|
||||
/* unused harmony import specifier */ var isGloballyAllowed;
|
||||
/* unused harmony import specifier */ var NOOP;
|
||||
/* unused harmony import specifier */ var isPromise;
|
||||
/* unused harmony import specifier */ var makeMap;
|
||||
/* unused harmony import specifier */ var isObject;
|
||||
/* unused harmony import specifier */ var toRawType;
|
||||
/* unused harmony import specifier */ var hyphenate;
|
||||
/* unused harmony import specifier */ var hasChanged;
|
||||
@@ -3174,7 +3182,7 @@ const isTargetSVG = target => typeof SVGElement !== "undefined" && target instan
|
||||
const isTargetMathML = target => typeof MathMLElement === "function" && target instanceof MathMLElement;
|
||||
const resolveTarget = (props, select) => {
|
||||
const targetSelector = props && props.to;
|
||||
if (isString(targetSelector)) {
|
||||
if ((0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isString */ .Kg)(targetSelector)) {
|
||||
if (!select) {
|
||||
false && 0;
|
||||
return null;
|
||||
@@ -3190,7 +3198,7 @@ const resolveTarget = (props, select) => {
|
||||
return targetSelector;
|
||||
}
|
||||
};
|
||||
const TeleportImpl = (/* unused pure expression or super */ null && ({
|
||||
const TeleportImpl = {
|
||||
name: "Teleport",
|
||||
__isTeleport: true,
|
||||
process(n1, n2, container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds, optimized, internals) {
|
||||
@@ -3333,7 +3341,7 @@ const TeleportImpl = (/* unused pure expression or super */ null && ({
|
||||
},
|
||||
move: moveTeleport,
|
||||
hydrate: hydrateTeleport
|
||||
}));
|
||||
};
|
||||
function moveTeleport(vnode, container, parentAnchor, {
|
||||
o: {
|
||||
insert
|
||||
@@ -3408,7 +3416,7 @@ function hydrateTeleport(node, vnode, parentComponent, parentSuspense, slotScope
|
||||
}
|
||||
return vnode.anchor && nextSibling(vnode.anchor);
|
||||
}
|
||||
const Teleport = (/* unused pure expression or super */ null && (TeleportImpl));
|
||||
const Teleport = TeleportImpl;
|
||||
function updateCssVars(vnode, isDisabled) {
|
||||
const ctx = vnode.ctx;
|
||||
if (ctx && ctx.ut) {
|
||||
@@ -3774,11 +3782,11 @@ function getTransitionRawChildren(children, keepComment = false, parentKey) {
|
||||
/*! #__NO_SIDE_EFFECTS__ */
|
||||
// @__NO_SIDE_EFFECTS__
|
||||
function defineComponent(options, extraOptions) {
|
||||
return isFunction(options) ?
|
||||
return (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isFunction */ .Tn)(options) ?
|
||||
// #8236: extend call and options.name access are considered side-effects
|
||||
// by Rollup, so we have to wrap it in a pure-annotated IIFE.
|
||||
/* @__PURE__ */
|
||||
(() => extend({
|
||||
(() => (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .extend */ .X$)({
|
||||
name: options.name
|
||||
}, extraOptions, {
|
||||
setup: options
|
||||
@@ -4998,17 +5006,17 @@ function resolve(registry, name) {
|
||||
function renderList(source, renderItem, cache, index) {
|
||||
let ret;
|
||||
const cached = cache && cache[index];
|
||||
const sourceIsArray = isArray(source);
|
||||
if (sourceIsArray || isString(source)) {
|
||||
const sourceIsReactiveArray = sourceIsArray && isReactive(source);
|
||||
const sourceIsArray = (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isArray */ .cy)(source);
|
||||
if (sourceIsArray || (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isString */ .Kg)(source)) {
|
||||
const sourceIsReactiveArray = sourceIsArray && (0,_vue_reactivity__WEBPACK_IMPORTED_MODULE_20__/* .isReactive */ .g8)(source);
|
||||
let needsWrap = false;
|
||||
if (sourceIsReactiveArray) {
|
||||
needsWrap = !isShallow(source);
|
||||
source = shallowReadArray(source);
|
||||
needsWrap = !(0,_vue_reactivity__WEBPACK_IMPORTED_MODULE_20__/* .isShallow */ .fE)(source);
|
||||
source = (0,_vue_reactivity__WEBPACK_IMPORTED_MODULE_20__/* .shallowReadArray */ .qA)(source);
|
||||
}
|
||||
ret = new Array(source.length);
|
||||
for (let i = 0, l = source.length; i < l; i++) {
|
||||
ret[i] = renderItem(needsWrap ? toReactive(source[i]) : source[i], i, void 0, cached && cached[i]);
|
||||
ret[i] = renderItem(needsWrap ? (0,_vue_reactivity__WEBPACK_IMPORTED_MODULE_20__/* .toReactive */ .lJ)(source[i]) : source[i], i, void 0, cached && cached[i]);
|
||||
}
|
||||
} else if (typeof source === "number") {
|
||||
if (false) // removed by dead control flow
|
||||
@@ -5017,7 +5025,7 @@ function renderList(source, renderItem, cache, index) {
|
||||
for (let i = 0; i < source; i++) {
|
||||
ret[i] = renderItem(i + 1, i, void 0, cached && cached[i]);
|
||||
}
|
||||
} else if (isObject(source)) {
|
||||
} else if ((0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isObject */ .Gv)(source)) {
|
||||
if (source[Symbol.iterator]) {
|
||||
ret = Array.from(source, (item, i) => renderItem(item, i, void 0, cached && cached[i]));
|
||||
} else {
|
||||
@@ -9275,7 +9283,7 @@ const computed = (getterOrOptions, debugOptions) => {
|
||||
function h(type, propsOrChildren, children) {
|
||||
const l = arguments.length;
|
||||
if (l === 2) {
|
||||
if (isObject(propsOrChildren) && !isArray(propsOrChildren)) {
|
||||
if ((0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isObject */ .Gv)(propsOrChildren) && !(0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isArray */ .cy)(propsOrChildren)) {
|
||||
if (isVNode(propsOrChildren)) {
|
||||
return createVNode(type, null, [propsOrChildren]);
|
||||
}
|
||||
@@ -9449,6 +9457,7 @@ const DeprecationTypes = null;
|
||||
/* harmony export */ __webpack_require__.d(__webpack_exports__, [
|
||||
/* harmony export */ "EW", 0, /* binding */ computed,
|
||||
/* harmony export */ "FK", 0, /* binding */ Fragment,
|
||||
/* harmony export */ "Im", 0, /* binding */ Teleport,
|
||||
/* harmony export */ "bF", 0, /* binding */ createVNode,
|
||||
/* harmony export */ "sV", 0, /* binding */ onMounted,
|
||||
/* harmony export */ "xo", 0, /* binding */ onBeforeUnmount
|
||||
@@ -9989,7 +9998,7 @@ const stringifySymbol = (v, i = "") => {
|
||||
/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
|
||||
|
||||
"use strict";
|
||||
/* unused harmony exports Transition, TransitionGroup, VueElement, createSSRApp, defineCustomElement, defineSSRCustomElement, hydrate, initDirectivesForSSR, render, useCssModule, useCssVars, useHost, useShadowRoot, vModelCheckbox, vModelDynamic, vModelRadio, vModelSelect, vShow, withKeys */
|
||||
/* unused harmony exports Transition, TransitionGroup, VueElement, createSSRApp, defineCustomElement, defineSSRCustomElement, hydrate, initDirectivesForSSR, render, useCssModule, useCssVars, useHost, useShadowRoot, vModelCheckbox, vModelDynamic, vModelRadio, vShow */
|
||||
/* unused harmony import specifier */ var BaseTransitionPropsValidators;
|
||||
/* unused harmony import specifier */ var h;
|
||||
/* unused harmony import specifier */ var BaseTransition;
|
||||
@@ -10009,7 +10018,6 @@ const stringifySymbol = (v, i = "") => {
|
||||
/* unused harmony import specifier */ var resolveTransitionHooks;
|
||||
/* unused harmony import specifier */ var getTransitionRawChildren;
|
||||
/* unused harmony import specifier */ var createVNode;
|
||||
/* unused harmony import specifier */ var nextTick;
|
||||
/* unused harmony import specifier */ var createHydrationRenderer;
|
||||
/* unused harmony import specifier */ var isRuntimeOnly;
|
||||
/* unused harmony import specifier */ var toRaw;
|
||||
@@ -10024,8 +10032,6 @@ const stringifySymbol = (v, i = "") => {
|
||||
/* unused harmony import specifier */ var looseIndexOf;
|
||||
/* unused harmony import specifier */ var isSet;
|
||||
/* unused harmony import specifier */ var looseEqual;
|
||||
/* unused harmony import specifier */ var looseToNumber;
|
||||
/* unused harmony import specifier */ var hyphenate;
|
||||
/* unused harmony import specifier */ var isHTMLTag;
|
||||
/* unused harmony import specifier */ var isSVGTag;
|
||||
/* unused harmony import specifier */ var isMathMLTag;
|
||||
@@ -11571,7 +11577,7 @@ const vModelRadio = (/* unused pure expression or super */ null && ({
|
||||
}
|
||||
}
|
||||
}));
|
||||
const vModelSelect = (/* unused pure expression or super */ null && ({
|
||||
const vModelSelect = {
|
||||
// <select multiple> value need to be deep traversed
|
||||
deep: true,
|
||||
created(el, {
|
||||
@@ -11580,12 +11586,12 @@ const vModelSelect = (/* unused pure expression or super */ null && ({
|
||||
number
|
||||
}
|
||||
}, vnode) {
|
||||
const isSetModel = isSet(value);
|
||||
const isSetModel = (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isSet */ .vM)(value);
|
||||
addEventListener(el, "change", () => {
|
||||
const selectedVal = Array.prototype.filter.call(el.options, o => o.selected).map(o => number ? looseToNumber(getValue(o)) : getValue(o));
|
||||
const selectedVal = Array.prototype.filter.call(el.options, o => o.selected).map(o => number ? (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .looseToNumber */ .bB)(getValue(o)) : getValue(o));
|
||||
el[assignKey](el.multiple ? isSetModel ? new Set(selectedVal) : selectedVal : selectedVal[0]);
|
||||
el._assigning = true;
|
||||
nextTick(() => {
|
||||
(0,_vue_runtime_core__WEBPACK_IMPORTED_MODULE_18__/* .nextTick */ .dY)(() => {
|
||||
el._assigning = false;
|
||||
});
|
||||
});
|
||||
@@ -11608,11 +11614,11 @@ const vModelSelect = (/* unused pure expression or super */ null && ({
|
||||
setSelected(el, value);
|
||||
}
|
||||
}
|
||||
}));
|
||||
};
|
||||
function setSelected(el, value) {
|
||||
const isMultiple = el.multiple;
|
||||
const isArrayValue = isArray(value);
|
||||
if (isMultiple && !isArrayValue && !isSet(value)) {
|
||||
const isArrayValue = (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isArray */ .cy)(value);
|
||||
if (isMultiple && !isArrayValue && !(0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isSet */ .vM)(value)) {
|
||||
false && 0;
|
||||
return;
|
||||
}
|
||||
@@ -11625,12 +11631,12 @@ function setSelected(el, value) {
|
||||
if (optionType === "string" || optionType === "number") {
|
||||
option.selected = value.some(v => String(v) === String(optionValue));
|
||||
} else {
|
||||
option.selected = looseIndexOf(value, optionValue) > -1;
|
||||
option.selected = (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .looseIndexOf */ .u3)(value, optionValue) > -1;
|
||||
}
|
||||
} else {
|
||||
option.selected = value.has(optionValue);
|
||||
}
|
||||
} else if (looseEqual(getValue(option), value)) {
|
||||
} else if ((0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .looseEqual */ .BX)(getValue(option), value)) {
|
||||
if (el.selectedIndex !== i) el.selectedIndex = i;
|
||||
return;
|
||||
}
|
||||
@@ -11755,7 +11761,7 @@ const withModifiers = (fn, modifiers) => {
|
||||
return fn(event, ...args);
|
||||
});
|
||||
};
|
||||
const keyNames = (/* unused pure expression or super */ null && ({
|
||||
const keyNames = {
|
||||
esc: "escape",
|
||||
space: " ",
|
||||
up: "arrow-up",
|
||||
@@ -11763,7 +11769,7 @@ const keyNames = (/* unused pure expression or super */ null && ({
|
||||
right: "arrow-right",
|
||||
down: "arrow-down",
|
||||
delete: "backspace"
|
||||
}));
|
||||
};
|
||||
const withKeys = (fn, modifiers) => {
|
||||
const cache = fn._withKeys || (fn._withKeys = {});
|
||||
const cacheKey = modifiers.join(".");
|
||||
@@ -11771,7 +11777,7 @@ const withKeys = (fn, modifiers) => {
|
||||
if (!("key" in event)) {
|
||||
return;
|
||||
}
|
||||
const eventKey = hyphenate(event.key);
|
||||
const eventKey = (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .hyphenate */ .Tg)(event.key);
|
||||
if (modifiers.some(k => k === eventKey || keyNames[k] === eventKey)) {
|
||||
return fn(event);
|
||||
}
|
||||
@@ -11901,7 +11907,9 @@ const initDirectivesForSSR = () => {
|
||||
/* harmony export */ __webpack_require__.d(__webpack_exports__, [
|
||||
/* harmony export */ "D$", 0, /* binding */ withModifiers,
|
||||
/* harmony export */ "Ef", 0, /* binding */ createApp,
|
||||
/* harmony export */ "Jo", 0, /* binding */ vModelText
|
||||
/* harmony export */ "Jo", 0, /* binding */ vModelText,
|
||||
/* harmony export */ "jR", 0, /* binding */ withKeys,
|
||||
/* harmony export */ "u1", 0, /* binding */ vModelSelect
|
||||
/* harmony export */ ]);
|
||||
|
||||
|
||||
@@ -11912,9 +11920,11 @@ const initDirectivesForSSR = () => {
|
||||
|
||||
"use strict";
|
||||
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
||||
/* harmony export */ Y2: function() { return /* binding */ includeBooleanAttr; }
|
||||
/* harmony export */ BX: function() { return /* binding */ looseEqual; },
|
||||
/* harmony export */ Y2: function() { return /* binding */ includeBooleanAttr; },
|
||||
/* harmony export */ u3: function() { return /* binding */ looseIndexOf; }
|
||||
/* harmony export */ });
|
||||
/* unused harmony exports EMPTY_ARR, EMPTY_OBJ, NO, NOOP, PatchFlagNames, PatchFlags, ShapeFlags, SlotFlags, cssVarNameEscapeSymbolsRE, def, escapeHtml, escapeHtmlComment, genCacheKey, genPropsAccessExp, generateCodeFrame, getEscapedCssVarName, getGlobalThis, hasChanged, isBooleanAttr, isBuiltInDirective, isDate, isGloballyAllowed, isGloballyWhitelisted, isHTMLTag, isIntegerKey, isKnownHtmlAttr, isKnownMathMLAttr, isKnownSvgAttr, isMap, isMathMLTag, isObject, isPromise, isRegExp, isRenderableAttrValue, isReservedProp, isSSRSafeAttrName, isSVGTag, isSet, isVoidTag, looseEqual, looseIndexOf, makeMap, normalizeClass, normalizeProps, normalizeStyle, objectToString, parseStringStyle, propsToAttrMap, remove, slotFlagsText, stringifyStyle, toDisplayString, toHandlerKey, toRawType, toTypeString */
|
||||
/* unused harmony exports EMPTY_ARR, EMPTY_OBJ, NO, NOOP, PatchFlagNames, PatchFlags, ShapeFlags, SlotFlags, cssVarNameEscapeSymbolsRE, def, escapeHtml, escapeHtmlComment, genCacheKey, genPropsAccessExp, generateCodeFrame, getEscapedCssVarName, getGlobalThis, hasChanged, isBooleanAttr, isBuiltInDirective, isDate, isGloballyAllowed, isGloballyWhitelisted, isHTMLTag, isIntegerKey, isKnownHtmlAttr, isKnownMathMLAttr, isKnownSvgAttr, isMap, isMathMLTag, isObject, isPromise, isRegExp, isRenderableAttrValue, isReservedProp, isSSRSafeAttrName, isSVGTag, isVoidTag, makeMap, normalizeClass, normalizeProps, normalizeStyle, objectToString, parseStringStyle, propsToAttrMap, remove, slotFlagsText, stringifyStyle, toDisplayString, toHandlerKey, toRawType, toTypeString */
|
||||
/* harmony import */ var core_js_modules_es_array_push_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(4114);
|
||||
/* harmony import */ var core_js_modules_es_array_push_js__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(core_js_modules_es_array_push_js__WEBPACK_IMPORTED_MODULE_0__);
|
||||
/* harmony import */ var core_js_modules_es_iterator_constructor_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(8111);
|
||||
@@ -12421,7 +12431,8 @@ const stringifySymbol = (v, i = "") => {
|
||||
/* harmony export */ "X$", 0, /* binding */ extend,
|
||||
/* harmony export */ "ZH", 0, /* binding */ capitalize,
|
||||
/* harmony export */ "bB", 0, /* binding */ looseToNumber,
|
||||
/* harmony export */ "cy", 0, /* binding */ isArray
|
||||
/* harmony export */ "cy", 0, /* binding */ isArray,
|
||||
/* harmony export */ "vM", 0, /* binding */ isSet
|
||||
/* harmony export */ ]);
|
||||
|
||||
|
||||
@@ -15965,6 +15976,25 @@ module.exports = function (obj) {
|
||||
};
|
||||
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ 8646:
|
||||
/***/ (function(module, __unused_webpack_exports, __webpack_require__) {
|
||||
|
||||
"use strict";
|
||||
|
||||
var call = __webpack_require__(9565);
|
||||
var anObject = __webpack_require__(8551);
|
||||
var getIteratorDirect = __webpack_require__(1767);
|
||||
var getIteratorMethod = __webpack_require__(851);
|
||||
|
||||
module.exports = function (obj, stringHandling) {
|
||||
if (!stringHandling || typeof obj !== 'string') anObject(obj);
|
||||
var method = getIteratorMethod(obj);
|
||||
return getIteratorDirect(anObject(method !== undefined ? call(method, obj) : obj));
|
||||
};
|
||||
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ 851:
|
||||
@@ -18757,6 +18787,89 @@ $({ target: 'Iterator', proto: true, real: true, forced: findWithoutClosingOnEar
|
||||
});
|
||||
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ 531:
|
||||
/***/ (function(__unused_webpack_module, __unused_webpack_exports, __webpack_require__) {
|
||||
|
||||
"use strict";
|
||||
|
||||
var $ = __webpack_require__(6518);
|
||||
var call = __webpack_require__(9565);
|
||||
var aCallable = __webpack_require__(9306);
|
||||
var anObject = __webpack_require__(8551);
|
||||
var getIteratorDirect = __webpack_require__(1767);
|
||||
var getIteratorFlattenable = __webpack_require__(8646);
|
||||
var createIteratorProxy = __webpack_require__(9462);
|
||||
var iteratorClose = __webpack_require__(9539);
|
||||
var IS_PURE = __webpack_require__(6395);
|
||||
var iteratorHelperThrowsOnInvalidIterator = __webpack_require__(684);
|
||||
var iteratorHelperWithoutClosingOnEarlyError = __webpack_require__(4549);
|
||||
|
||||
// Should not throw an error for an iterator without `return` method. Fixed in Safari 26.2
|
||||
// https://bugs.webkit.org/show_bug.cgi?id=297532
|
||||
function throwsOnIteratorWithoutReturn() {
|
||||
try {
|
||||
// eslint-disable-next-line es/no-map, es/no-iterator, es/no-iterator-prototype-flatmap -- required for testing
|
||||
var it = Iterator.prototype.flatMap.call(new Map([[4, 5]]).entries(), function (v) { return v; });
|
||||
it.next();
|
||||
it['return']();
|
||||
} catch (error) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
var FLAT_MAP_WITHOUT_THROWING_ON_INVALID_ITERATOR = !IS_PURE
|
||||
&& !iteratorHelperThrowsOnInvalidIterator('flatMap', function () { /* empty */ });
|
||||
var flatMapWithoutClosingOnEarlyError = !IS_PURE && !FLAT_MAP_WITHOUT_THROWING_ON_INVALID_ITERATOR
|
||||
&& iteratorHelperWithoutClosingOnEarlyError('flatMap', TypeError);
|
||||
|
||||
var FORCED = IS_PURE || FLAT_MAP_WITHOUT_THROWING_ON_INVALID_ITERATOR || flatMapWithoutClosingOnEarlyError
|
||||
|| throwsOnIteratorWithoutReturn();
|
||||
|
||||
var IteratorProxy = createIteratorProxy(function () {
|
||||
var iterator = this.iterator;
|
||||
var mapper = this.mapper;
|
||||
var result, inner;
|
||||
|
||||
while (true) {
|
||||
if (inner = this.inner) try {
|
||||
result = anObject(call(inner.next, inner.iterator));
|
||||
if (!result.done) return result.value;
|
||||
this.inner = null;
|
||||
} catch (error) { iteratorClose(iterator, 'throw', error); }
|
||||
|
||||
result = anObject(call(this.next, iterator));
|
||||
|
||||
if (this.done = !!result.done) return;
|
||||
|
||||
try {
|
||||
this.inner = getIteratorFlattenable(mapper(result.value, this.counter++), false);
|
||||
} catch (error) { iteratorClose(iterator, 'throw', error); }
|
||||
}
|
||||
});
|
||||
|
||||
// `Iterator.prototype.flatMap` method
|
||||
// https://tc39.es/ecma262/#sec-iterator.prototype.flatmap
|
||||
$({ target: 'Iterator', proto: true, real: true, forced: FORCED }, {
|
||||
flatMap: function flatMap(mapper) {
|
||||
anObject(this);
|
||||
try {
|
||||
aCallable(mapper);
|
||||
} catch (error) {
|
||||
iteratorClose(this, 'throw', error);
|
||||
}
|
||||
|
||||
if (flatMapWithoutClosingOnEarlyError) return call(flatMapWithoutClosingOnEarlyError, this, mapper);
|
||||
|
||||
return new IteratorProxy(getIteratorDirect(this), {
|
||||
mapper: mapper,
|
||||
inner: null
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ 7588:
|
||||
@@ -24964,4 +25077,4 @@ function del(target, key) {
|
||||
/***/ })
|
||||
|
||||
}]);
|
||||
//# sourceMappingURL=chunk-vendors.cd0faf68.js.map
|
||||
//# sourceMappingURL=chunk-vendors.d3ec3fbc.js.map
|
||||
+1
File diff suppressed because one or more lines are too long
Generated
+30
-7
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^10.8.0",
|
||||
"axios": "1.6.7",
|
||||
"buffer": "^6.0.3",
|
||||
"jr-qrcode": "^1.1.4",
|
||||
"pinia": "^2.1.7",
|
||||
"process": "^0.11.10",
|
||||
@@ -3533,7 +3534,6 @@
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -3605,6 +3605,31 @@
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bl/node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/bluebird": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmmirror.com/bluebird/-/bluebird-3.7.2.tgz",
|
||||
@@ -3731,10 +3756,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmmirror.com/buffer/-/buffer-5.7.1.tgz",
|
||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||
"dev": true,
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -3752,7 +3776,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
"ieee754": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
@@ -6363,7 +6387,6 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
||||
+12
-8
@@ -1,16 +1,20 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve --mode development --host 0.0.0.0 --port 8026",
|
||||
"serve:local": "vue-cli-service serve --mode development --host 0.0.0.0 --port 8026",
|
||||
"build": "vue-cli-service build --mode production",
|
||||
"build:local": "vue-cli-service build --mode development --dest dist-local",
|
||||
"build:production": "vue-cli-service build --mode production",
|
||||
"lint": "vue-cli-service lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^10.8.0",
|
||||
"axios": "1.6.7",
|
||||
"jr-qrcode": "^1.1.4",
|
||||
"axios": "1.6.7",
|
||||
"buffer": "^6.0.3",
|
||||
"jr-qrcode": "^1.1.4",
|
||||
"pinia": "^2.1.7",
|
||||
"process": "^0.11.10",
|
||||
"vue": "3.5.13",
|
||||
|
||||
Binary file not shown.
@@ -425,12 +425,38 @@ function currentMuteSetting(PDO $appDb, string $groupId): array
|
||||
];
|
||||
}
|
||||
|
||||
function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
||||
{
|
||||
$group = fetchWebGroup($appDb, $groupId);
|
||||
if (!$group) {
|
||||
respond(404, '没有找到该群组');
|
||||
}
|
||||
function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
||||
{
|
||||
$group = fetchWebGroup($appDb, $groupId);
|
||||
if (!$group) {
|
||||
$groupStatement = $imDb->prepare('select * from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1');
|
||||
$groupStatement->execute([$groupId]);
|
||||
$nativeGroup = $groupStatement->fetch();
|
||||
$memberStatement = $imDb->prepare('select `_type`, `_alias` from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1');
|
||||
$memberStatement->execute([$groupId, $userId]);
|
||||
$nativeMember = $memberStatement->fetch();
|
||||
if (!$nativeGroup || !$nativeMember) {
|
||||
respond(403, '你不在该群组中');
|
||||
}
|
||||
$ownerStatement = $imDb->prepare('select `_mid` from `t_group_member` where `_gid` = ? and `_type` = 2 limit 1');
|
||||
$ownerStatement->execute([$groupId]);
|
||||
$ownerId = (string)($ownerStatement->fetchColumn() ?: '');
|
||||
$users = fetchWebUserMap($imDb, array_filter([$ownerId, $userId]), $userId);
|
||||
return ['group' => [
|
||||
'groupId' => $groupId,
|
||||
'publicId' => (string)($nativeGroup['id'] ?? ''),
|
||||
'name' => (string)($nativeGroup['_name'] ?? $groupId),
|
||||
'category' => 'group',
|
||||
'announcement' => (string)($nativeGroup['_bulletin'] ?? ''),
|
||||
'welcomeText' => '',
|
||||
'ownerUid' => $ownerId,
|
||||
'ownerName' => (string)($users[$ownerId]['displayName'] ?? $ownerId),
|
||||
'myRole' => ((int)($nativeMember['_type'] ?? 0) === 2 ? 'owner' : ((int)($nativeMember['_type'] ?? 0) === 1 ? 'admin' : 'member')),
|
||||
'myAlias' => (string)($nativeMember['_alias'] ?? ''),
|
||||
'myDisplayName' => (string)($nativeMember['_alias'] ?? '') ?: (string)($users[$userId]['displayName'] ?? $userId),
|
||||
'muteSetting' => ['enabled' => false, 'mode' => 'off'],
|
||||
]];
|
||||
}
|
||||
$member = fetchWebGroupMember($appDb, $groupId, $userId);
|
||||
if (!$member) {
|
||||
respond(403, '你不在该群组中');
|
||||
@@ -443,8 +469,9 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
|
||||
$myAlias = (string)($member['alias'] ?? '');
|
||||
|
||||
return [
|
||||
'group' => [
|
||||
'groupId' => (string)$group['group_id'],
|
||||
'group' => [
|
||||
'groupId' => (string)$group['group_id'],
|
||||
'publicId' => (string)($group['id'] ?? ''),
|
||||
'name' => (string)$group['name'],
|
||||
'category' => (string)$group['category'],
|
||||
'announcement' => (string)($group['announcement'] ?? ''),
|
||||
@@ -459,11 +486,42 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
|
||||
];
|
||||
}
|
||||
|
||||
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
||||
{
|
||||
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
|
||||
respond(403, '你不在该群组中');
|
||||
}
|
||||
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
||||
{
|
||||
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
|
||||
$membership = $imDb->prepare('select 1 from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1');
|
||||
$membership->execute([$groupId, $userId]);
|
||||
if (!$membership->fetchColumn()) {
|
||||
respond(403, '你不在该群组中');
|
||||
}
|
||||
$nativeStatement = $imDb->prepare(
|
||||
'select `_mid` as `user_id`, `_type`, `_alias`, `_create_dt` from `t_group_member`'
|
||||
. ' where `_gid` = ? order by (`_type` = 2) desc, `_create_dt` asc'
|
||||
);
|
||||
$nativeStatement->execute([$groupId]);
|
||||
$nativeMembers = $nativeStatement->fetchAll();
|
||||
$nativeUsers = fetchWebUserMap($imDb, array_column($nativeMembers, 'user_id'), $userId);
|
||||
return ['members' => array_map(static function (array $member) use ($nativeUsers): array {
|
||||
$uid = (string)$member['user_id'];
|
||||
$user = $nativeUsers[$uid] ?? ['displayName' => $uid, 'mobile' => '', 'portrait' => '', 'isContact' => false];
|
||||
$alias = trim((string)($member['_alias'] ?? ''));
|
||||
$type = (int)($member['_type'] ?? 0);
|
||||
return [
|
||||
'uid' => $uid,
|
||||
'role' => $type === 2 ? 'owner' : ($type === 1 ? 'admin' : 'member'),
|
||||
'alias' => $alias,
|
||||
'contactAlias' => trim((string)($user['contactAlias'] ?? '')),
|
||||
'friendAlias' => trim((string)($user['friendAlias'] ?? '')),
|
||||
'remarkAlias' => trim((string)($user['remarkAlias'] ?? '')),
|
||||
'isContact' => (bool)($user['isContact'] ?? false),
|
||||
'displayName' => trim((string)($user['contactAlias'] ?? '')) ?: ($alias ?: (string)$user['displayName']),
|
||||
'rawDisplayName' => (string)$user['displayName'],
|
||||
'groupDisplayName' => $alias,
|
||||
'mobile' => (string)($user['mobile'] ?? ''),
|
||||
'portrait' => (string)($user['portrait'] ?? ''),
|
||||
];
|
||||
}, $nativeMembers)];
|
||||
}
|
||||
|
||||
$statement = $appDb->prepare(
|
||||
'select `user_id`, `role`, `alias`, `joined_at_ms` from `t_web_group_members`'
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<?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');
|
||||
define('IM_ADMIN_URL', getenv('JC_WEB_IM_ADMIN_URL') ?: 'http://127.0.0.1:18080');
|
||||
define('IM_ADMIN_SECRET', getenv('JC_WEB_IM_ADMIN_SECRET') ?: '123456');
|
||||
const IM_DB_DSN = 'mysql:host=im-mysql-005.cbgumoq8g1ks.ap-southeast-1.rds.amazonaws.com;port=3306;dbname=zhongliao_wfchat;charset=utf8mb4';
|
||||
const IM_DB_USER = 'zhongliaoMysql';
|
||||
const IM_DB_PASSWORD = 'ZhongLiao@admin';
|
||||
const IM_APP_DB_DSN = 'mysql:host=im-mysql-005.cbgumoq8g1ks.ap-southeast-1.rds.amazonaws.com;port=3306;dbname=zhongliao_app;charset=utf8mb4';
|
||||
const IM_APP_DB_USER = 'zhongliaoMysql';
|
||||
const IM_APP_DB_PASSWORD = 'ZhongLiao@admin';
|
||||
const IM_DB_TIMEZONE = 'Asia/Shanghai';
|
||||
|
||||
function respond(int $code, string $message, array $result = []): void
|
||||
{
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/im_web_bootstrap.php';
|
||||
|
||||
const WEB_MEDIA_PROXY_MAX_BYTES = 15 * 1024 * 1024;
|
||||
const WEB_MEDIA_PROXY_MAX_SECONDS = 25;
|
||||
|
||||
function mediaProxyEnv(string $name, string $default = ''): string
|
||||
{
|
||||
if (function_exists('webImEnv')) {
|
||||
return (string)webImEnv($name, $default);
|
||||
}
|
||||
$value = getenv($name);
|
||||
if ($value === false || trim((string)$value) === '') {
|
||||
return $default;
|
||||
}
|
||||
return trim((string)$value);
|
||||
}
|
||||
|
||||
function failMediaProxy(int $status, string $message): never
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
echo $message;
|
||||
exit;
|
||||
}
|
||||
|
||||
function mediaProxyAllowedHosts(): array
|
||||
{
|
||||
$configured = mediaProxyEnv('JC_WEB_IM_MEDIA_PROXY_HOSTS');
|
||||
$hosts = array_map(
|
||||
static fn(string $host): string => strtolower(rtrim(trim($host), '.')),
|
||||
preg_split('/[,\s]+/', $configured) ?: []
|
||||
);
|
||||
|
||||
$hosts[] = 'zhiyanim.dfpnm.cn';
|
||||
return array_values(array_unique(array_filter($hosts)));
|
||||
}
|
||||
|
||||
function validatedMediaProxyUrl(string $url): array
|
||||
{
|
||||
if ($url === '' || strlen($url) > 2048 || filter_var($url, FILTER_VALIDATE_URL) === false) {
|
||||
failMediaProxy(400, '媒体地址不合法');
|
||||
}
|
||||
|
||||
$parts = parse_url($url);
|
||||
$scheme = strtolower((string)($parts['scheme'] ?? ''));
|
||||
$host = strtolower(rtrim((string)($parts['host'] ?? ''), '.'));
|
||||
$port = isset($parts['port']) ? (int)$parts['port'] : 443;
|
||||
if (
|
||||
$scheme !== 'https'
|
||||
|| $host === ''
|
||||
|| $port !== 443
|
||||
|| isset($parts['user'])
|
||||
|| isset($parts['pass'])
|
||||
|| !in_array($host, mediaProxyAllowedHosts(), true)
|
||||
) {
|
||||
failMediaProxy(403, '媒体来源不允许代理');
|
||||
}
|
||||
|
||||
return [$url, $host];
|
||||
}
|
||||
|
||||
function mediaProxyCacheRoot(): string
|
||||
{
|
||||
$root = rtrim(mediaProxyEnv('JC_WEB_IM_MEDIA_CACHE_ROOT', '/var/cache/jc-web-im-media'), '/');
|
||||
if ($root === '' || !is_dir($root) || !is_writable($root)) {
|
||||
$root = rtrim(sys_get_temp_dir(), '/') . '/jc-web-im-media';
|
||||
if (!is_dir($root) && !@mkdir($root, 0755, true) && !is_dir($root)) {
|
||||
failMediaProxy(503, '媒体缓存目录不可用');
|
||||
}
|
||||
}
|
||||
|
||||
return $root;
|
||||
}
|
||||
|
||||
function mediaProxyPreferredIps(): array
|
||||
{
|
||||
$configured = preg_split('/[,\s]+/', mediaProxyEnv('JC_WEB_IM_MEDIA_PROXY_IPS')) ?: [];
|
||||
$ips = [];
|
||||
foreach ($configured as $ip) {
|
||||
$ip = trim($ip);
|
||||
if (
|
||||
$ip !== ''
|
||||
&& filter_var(
|
||||
$ip,
|
||||
FILTER_VALIDATE_IP,
|
||||
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
|
||||
) !== false
|
||||
) {
|
||||
$ips[$ip] = $ip;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($ips);
|
||||
}
|
||||
|
||||
function mediaProxyMime(string $path): string
|
||||
{
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = $finfo !== false ? (string)(finfo_file($finfo, $path) ?: '') : '';
|
||||
if ($finfo !== false) {
|
||||
finfo_close($finfo);
|
||||
}
|
||||
|
||||
return $mime;
|
||||
}
|
||||
|
||||
function fetchMediaProxyImage(string $url, string $host, string $cacheFile): void
|
||||
{
|
||||
$deadline = microtime(true) + WEB_MEDIA_PROXY_MAX_SECONDS;
|
||||
$lastError = '媒体源无响应';
|
||||
$preferredIps = mediaProxyPreferredIps();
|
||||
$targets = array_merge($preferredIps, array_fill(0, max(2, 6 - count($preferredIps)), null));
|
||||
|
||||
foreach ($targets as $index => $preferredIp) {
|
||||
if (microtime(true) >= $deadline) {
|
||||
break;
|
||||
}
|
||||
$attempt = $index + 1;
|
||||
$temporaryFile = $cacheFile . '.tmp.' . getmypid() . '.' . $attempt;
|
||||
$stream = @fopen($temporaryFile, 'x+b');
|
||||
if ($stream === false) {
|
||||
$lastError = '无法创建媒体缓存文件';
|
||||
break;
|
||||
}
|
||||
|
||||
$bytes = 0;
|
||||
$tooLarge = false;
|
||||
$curl = curl_init($url);
|
||||
if ($curl === false) {
|
||||
fclose($stream);
|
||||
@unlink($temporaryFile);
|
||||
$lastError = '无法初始化媒体请求';
|
||||
break;
|
||||
}
|
||||
|
||||
$remainingMs = max(1000, (int)floor(($deadline - microtime(true)) * 1000));
|
||||
$curlOptions = [
|
||||
CURLOPT_RETURNTRANSFER => false,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_CONNECTTIMEOUT_MS => min(2500, $remainingMs),
|
||||
CURLOPT_TIMEOUT_MS => min(12000, $remainingMs),
|
||||
CURLOPT_NOSIGNAL => true,
|
||||
CURLOPT_DNS_CACHE_TIMEOUT => 0,
|
||||
CURLOPT_FRESH_CONNECT => true,
|
||||
CURLOPT_FORBID_REUSE => true,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_USERAGENT => 'JC-Web-IM-Media-Proxy/1.0',
|
||||
CURLOPT_HTTPHEADER => ['Accept: image/avif,image/webp,image/*,*/*;q=0.8'],
|
||||
CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use ($stream, &$bytes, &$tooLarge): int {
|
||||
$length = strlen($chunk);
|
||||
if ($bytes + $length > WEB_MEDIA_PROXY_MAX_BYTES) {
|
||||
$tooLarge = true;
|
||||
return 0;
|
||||
}
|
||||
$written = fwrite($stream, $chunk);
|
||||
if ($written === false) {
|
||||
return 0;
|
||||
}
|
||||
$bytes += $written;
|
||||
return $written;
|
||||
},
|
||||
];
|
||||
if (is_string($preferredIp) && $preferredIp !== '') {
|
||||
$curlOptions[CURLOPT_RESOLVE] = [$host . ':443:' . $preferredIp];
|
||||
}
|
||||
curl_setopt_array($curl, $curlOptions);
|
||||
|
||||
$result = curl_exec($curl);
|
||||
$status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
$curlError = curl_error($curl);
|
||||
curl_close($curl);
|
||||
fflush($stream);
|
||||
fclose($stream);
|
||||
|
||||
$mime = $bytes > 0 && is_file($temporaryFile) ? mediaProxyMime($temporaryFile) : '';
|
||||
if ($result !== false && $status >= 200 && $status < 300 && !$tooLarge && str_starts_with($mime, 'image/')) {
|
||||
if (!@rename($temporaryFile, $cacheFile)) {
|
||||
@unlink($temporaryFile);
|
||||
$lastError = '媒体缓存保存失败';
|
||||
break;
|
||||
}
|
||||
@chmod($cacheFile, 0644);
|
||||
return;
|
||||
}
|
||||
|
||||
@unlink($temporaryFile);
|
||||
$lastError = $tooLarge
|
||||
? '图片超过代理大小限制'
|
||||
: ($curlError !== '' ? $curlError : ($status > 0 ? '媒体源返回 HTTP ' . $status : '媒体源无响应'));
|
||||
if ($attempt < count($targets) && microtime(true) < $deadline) {
|
||||
usleep(150000);
|
||||
}
|
||||
}
|
||||
|
||||
error_log(sprintf('media_proxy fetch failed host=%s error=%s', $host, $lastError));
|
||||
failMediaProxy(502, '图片源暂时不可用,请稍后重试');
|
||||
}
|
||||
|
||||
function serveMediaProxyImage(string $cacheFile, string $etag): never
|
||||
{
|
||||
if (!is_file($cacheFile)) {
|
||||
failMediaProxy(404, '媒体缓存不存在');
|
||||
}
|
||||
$mime = mediaProxyMime($cacheFile);
|
||||
if (!str_starts_with($mime, 'image/')) {
|
||||
failMediaProxy(415, '媒体内容不是图片');
|
||||
}
|
||||
|
||||
header('ETag: "' . $etag . '"');
|
||||
header('Cache-Control: public, max-age=86400, immutable');
|
||||
header('Content-Type: ' . $mime);
|
||||
header('Content-Length: ' . (string)filesize($cacheFile));
|
||||
header('Content-Disposition: inline');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
if (trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? ''), ' W/') === '"' . $etag . '"') {
|
||||
http_response_code(304);
|
||||
exit;
|
||||
}
|
||||
|
||||
readfile($cacheFile);
|
||||
exit;
|
||||
}
|
||||
|
||||
[$url, $host] = validatedMediaProxyUrl(trim((string)($_GET['url'] ?? '')));
|
||||
$cacheRoot = mediaProxyCacheRoot();
|
||||
$cacheKey = hash('sha256', $url);
|
||||
$cacheFile = $cacheRoot . '/' . $cacheKey . '.image';
|
||||
$lockFile = $cacheRoot . '/' . $cacheKey . '.lock';
|
||||
|
||||
if (!is_file($cacheFile)) {
|
||||
$lock = @fopen($lockFile, 'c');
|
||||
if ($lock === false || !flock($lock, LOCK_EX)) {
|
||||
if (is_resource($lock)) {
|
||||
fclose($lock);
|
||||
}
|
||||
failMediaProxy(503, '媒体缓存暂时忙碌');
|
||||
}
|
||||
if (!is_file($cacheFile)) {
|
||||
fetchMediaProxyImage($url, $host, $cacheFile);
|
||||
}
|
||||
flock($lock, LOCK_UN);
|
||||
fclose($lock);
|
||||
}
|
||||
|
||||
serveMediaProxyImage($cacheFile, $cacheKey);
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
const WEB_MEDIA_STREAM_ROOT = '/opt/im-server/media';
|
||||
|
||||
function failMediaStream(int $status, string $message): never
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
echo $message;
|
||||
exit;
|
||||
}
|
||||
|
||||
$url = trim((string)($_GET['url'] ?? ''));
|
||||
if ($url === '') {
|
||||
failMediaStream(400, '缺少视频地址');
|
||||
}
|
||||
|
||||
$path = rawurldecode((string)(parse_url($url, PHP_URL_PATH) ?: ''));
|
||||
if (!preg_match('#^/fs/[0-9]+/[A-Za-z0-9_./ -]+$#', $path) || str_contains($path, '..')) {
|
||||
failMediaStream(400, '视频地址不合法');
|
||||
}
|
||||
|
||||
$root = realpath(WEB_MEDIA_STREAM_ROOT);
|
||||
$fullPath = realpath(rtrim(WEB_MEDIA_STREAM_ROOT, '/') . $path);
|
||||
if ($root === false || $fullPath === false || !str_starts_with($fullPath, $root . DIRECTORY_SEPARATOR) || !is_file($fullPath)) {
|
||||
failMediaStream(404, '视频文件不存在');
|
||||
}
|
||||
|
||||
$size = (int)filesize($fullPath);
|
||||
$mime = (string)(mime_content_type($fullPath) ?: 'video/mp4');
|
||||
if (!str_starts_with($mime, 'video/')) {
|
||||
$extension = strtolower((string)pathinfo($fullPath, PATHINFO_EXTENSION));
|
||||
$mime = $extension === 'webm' ? 'video/webm' : ($extension === 'mov' ? 'video/quicktime' : 'video/mp4');
|
||||
}
|
||||
|
||||
$start = 0;
|
||||
$end = max(0, $size - 1);
|
||||
$range = trim((string)($_SERVER['HTTP_RANGE'] ?? ''));
|
||||
if ($range !== '' && preg_match('/^bytes=(\d*)-(\d*)$/', $range, $matches)) {
|
||||
if ($matches[1] !== '') $start = (int)$matches[1];
|
||||
if ($matches[2] !== '') $end = min($end, (int)$matches[2]);
|
||||
if ($start < 0 || $start > $end || $start >= $size) {
|
||||
header('Content-Range: bytes */' . $size);
|
||||
failMediaStream(416, '请求范围无效');
|
||||
}
|
||||
http_response_code(206);
|
||||
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
|
||||
}
|
||||
|
||||
$length = $end - $start + 1;
|
||||
header('Content-Type: ' . $mime);
|
||||
header('Content-Length: ' . $length);
|
||||
header('Accept-Ranges: bytes');
|
||||
header('Content-Disposition: inline');
|
||||
header('Cache-Control: public, max-age=86400');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
|
||||
$stream = fopen($fullPath, 'rb');
|
||||
if ($stream === false) failMediaStream(500, '视频文件无法读取');
|
||||
fseek($stream, $start);
|
||||
$remaining = $length;
|
||||
while ($remaining > 0 && !feof($stream)) {
|
||||
$chunk = fread($stream, min(1024 * 1024, $remaining));
|
||||
if ($chunk === false || $chunk === '') break;
|
||||
echo $chunk;
|
||||
$remaining -= strlen($chunk);
|
||||
flush();
|
||||
}
|
||||
fclose($stream);
|
||||
@@ -78,7 +78,7 @@ function fetchActionMessageRow(string $messageUid): array
|
||||
return is_array($row) ? $row : [];
|
||||
}
|
||||
|
||||
function ensureMessageEditsTable(): void
|
||||
function ensureMessageEditsTable(): void
|
||||
{
|
||||
imDb()->exec(
|
||||
'create table if not exists `t_web_message_edits` ('
|
||||
@@ -93,7 +93,55 @@ function ensureMessageEditsTable(): void
|
||||
. 'key `idx_edited_at_ms` (`edited_at_ms`)'
|
||||
. ') engine=InnoDB default charset=utf8mb4'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePinnedMessagesTable(): void
|
||||
{
|
||||
$db = imAppDb();
|
||||
$db->exec(
|
||||
'create table if not exists `t_web_pinned_messages` ('
|
||||
. '`user_id` varchar(64) not null,'
|
||||
. '`conversation_type` varchar(16) not null,'
|
||||
. '`target_id` varchar(128) not null,'
|
||||
. '`message_uid` varchar(32) not null,'
|
||||
. '`message_json` text null,'
|
||||
. '`updated_at_ms` bigint not null,'
|
||||
. 'primary key (`user_id`, `conversation_type`, `target_id`)'
|
||||
. ') engine=InnoDB default charset=utf8mb4'
|
||||
);
|
||||
try {
|
||||
$db->exec('alter table `t_web_pinned_messages` add column `message_json` text null after `message_uid`');
|
||||
} catch (Throwable) {
|
||||
// The column already exists.
|
||||
}
|
||||
}
|
||||
|
||||
function savePinnedMessage(string $userId, string $conversationType, string $targetId, string $messageUid, array $message): void
|
||||
{
|
||||
ensurePinnedMessagesTable();
|
||||
$statement = imAppDb()->prepare(
|
||||
'insert into `t_web_pinned_messages` (`user_id`, `conversation_type`, `target_id`, `message_uid`, `message_json`, `updated_at_ms`)'
|
||||
. ' values (?, ?, ?, ?, ?, ?) on duplicate key update `message_uid` = values(`message_uid`),'
|
||||
. ' `message_json` = values(`message_json`), `updated_at_ms` = values(`updated_at_ms`)'
|
||||
);
|
||||
$statement->execute([
|
||||
$userId,
|
||||
$conversationType,
|
||||
$targetId,
|
||||
$messageUid,
|
||||
json_encode($message, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
nowTimestampMs(),
|
||||
]);
|
||||
}
|
||||
|
||||
function removePinnedMessage(string $userId, string $conversationType, string $targetId): void
|
||||
{
|
||||
ensurePinnedMessagesTable();
|
||||
$statement = imAppDb()->prepare(
|
||||
'delete from `t_web_pinned_messages` where `user_id` = ? and `conversation_type` = ? and `target_id` = ?'
|
||||
);
|
||||
$statement->execute([$userId, $conversationType, $targetId]);
|
||||
}
|
||||
|
||||
function editTextMessage(string $operatorId, string $messageUid, string $text): array
|
||||
{
|
||||
@@ -222,7 +270,22 @@ if ($action === '' || $messageUid <= 0) {
|
||||
respond(400, '缺少操作类型或消息ID');
|
||||
}
|
||||
|
||||
try {
|
||||
try {
|
||||
if ($action === 'pin' || $action === 'unpin') {
|
||||
if ($userId === '') respond(400, '缺少操作者ID');
|
||||
$targetId = trim((string)($payload['target'] ?? ''));
|
||||
$conversationType = trim((string)($payload['conversationType'] ?? 'single'));
|
||||
if ($targetId === '') respond(400, '缺少会话ID');
|
||||
$userId = resolveActionUserId($userId);
|
||||
if ($action === 'pin') {
|
||||
$message = is_array($payload['message'] ?? null) ? $payload['message'] : [];
|
||||
savePinnedMessage($userId, $conversationType, $targetId, $messageUidText, $message);
|
||||
} else {
|
||||
removePinnedMessage($userId, $conversationType, $targetId);
|
||||
}
|
||||
respond(0, 'success', ['messageUid' => $messageUidText, 'action' => $action]);
|
||||
}
|
||||
|
||||
if ($action === 'recall') {
|
||||
if ($userId === '') {
|
||||
respond(400, '缺少操作者ID');
|
||||
|
||||
+1724
-900
File diff suppressed because it is too large
Load Diff
+112
-28
@@ -8,8 +8,9 @@ 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_MEDIA_TYPE_IMAGE = 1;
|
||||
const WEB_MEDIA_TYPE_VIDEO = 3;
|
||||
const WEB_MEDIA_TYPE_FILE = 4;
|
||||
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
||||
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
@@ -245,13 +246,32 @@ function storeMessageAttachment(
|
||||
]);
|
||||
}
|
||||
|
||||
function attachmentFromRequest(string $messageType): ?array
|
||||
{
|
||||
if (!in_array($messageType, ['image', 'file'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$attachmentFile = $_FILES['attachment'] ?? $_FILES['file'] ?? null;
|
||||
function attachmentFromRequest(string $messageType): ?array
|
||||
{
|
||||
if (!in_array($messageType, ['image', 'video', 'file'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$forwardUrl = trim((string)($_POST['attachmentUrl'] ?? ''));
|
||||
if ($forwardUrl !== '') {
|
||||
$parts = parse_url($forwardUrl);
|
||||
$scheme = strtolower((string)($parts['scheme'] ?? ''));
|
||||
if (!in_array($scheme, ['http', 'https'], true) || empty($parts['host']) || strlen($forwardUrl) > 2048) {
|
||||
respond(400, '媒体地址不合法');
|
||||
}
|
||||
$mime = trim((string)($_POST['attachmentMime'] ?? 'application/octet-stream'));
|
||||
$name = safeUploadName((string)($_POST['attachmentName'] ?? ($messageType === 'image' ? '图片' : ($messageType === 'video' ? '视频.mp4' : '文件'))));
|
||||
return [
|
||||
'type' => $messageType,
|
||||
'url' => $forwardUrl,
|
||||
'name' => $name,
|
||||
'mime' => $mime,
|
||||
'size' => max(0, (int)($_POST['attachmentSize'] ?? 0)),
|
||||
'mediaType' => $messageType === 'image' ? WEB_MEDIA_TYPE_IMAGE : ($messageType === 'video' ? WEB_MEDIA_TYPE_VIDEO : WEB_MEDIA_TYPE_FILE),
|
||||
];
|
||||
}
|
||||
|
||||
$attachmentFile = $_FILES['attachment'] ?? $_FILES['file'] ?? null;
|
||||
if (!is_array($attachmentFile)) {
|
||||
respond(400, '缺少上传文件');
|
||||
}
|
||||
@@ -266,11 +286,15 @@ $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);
|
||||
$conversationType = $_POST['conversationType'] ?? $_POST['type'] ?? $payload['conversationType'] ?? $payload['type'] ?? 0;
|
||||
$isGroupConversation = in_array((string)$conversationType, ['1', 'group', 'channel'], true);
|
||||
$mentionedUserIds = $payload['mentionedUserIds'] ?? [];
|
||||
$mentionedUserIds = is_array($mentionedUserIds)
|
||||
? array_values(array_unique(array_filter(array_map(static fn($value): string => trim((string)$value), $mentionedUserIds))))
|
||||
: [];
|
||||
$attachment = null;
|
||||
|
||||
if ($sender === '' || $target === '' || ($text === '' && !in_array($messageType, ['image', 'file'], true))) {
|
||||
if ($sender === '' || $target === '' || ($text === '' && !in_array($messageType, ['image', 'video', 'file'], true))) {
|
||||
respond(400, '缺少发送者、接收者或消息内容');
|
||||
}
|
||||
|
||||
@@ -285,11 +309,69 @@ try {
|
||||
. ' 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, '你不在该群组中');
|
||||
}
|
||||
$memberStatement->execute([$target, $sender]);
|
||||
$group = $memberStatement->fetch();
|
||||
if (!$group) {
|
||||
// 手机端创建的原生群不一定存在于 Web 自建群表中,需回退检查 IM 群成员表。
|
||||
$imDb = imDb();
|
||||
$nativeGroupStatement = $imDb->prepare(
|
||||
'select 1 from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1'
|
||||
);
|
||||
$nativeGroupStatement->execute([$target]);
|
||||
$nativeMemberStatement = $imDb->prepare(
|
||||
'select `_type` from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1'
|
||||
);
|
||||
$nativeMemberStatement->execute([$target, $sender]);
|
||||
$nativeMemberType = $nativeMemberStatement->fetchColumn();
|
||||
if (!$nativeGroupStatement->fetchColumn() || $nativeMemberType === false) {
|
||||
respond(403, '你不在该群组中');
|
||||
}
|
||||
|
||||
$attachment = attachmentFromRequest($messageType);
|
||||
$pushContent = $text !== ''
|
||||
? $text
|
||||
: ($attachment
|
||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : ((string)$attachment['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name']))
|
||||
: $text);
|
||||
$payloadBody = [
|
||||
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 5)) : 1,
|
||||
'searchableContent' => $pushContent,
|
||||
'pushContent' => $pushContent,
|
||||
'content' => $text,
|
||||
'persistFlag' => 3,
|
||||
];
|
||||
if ($mentionedUserIds) {
|
||||
$payloadBody['mentionedType'] = 1;
|
||||
$payloadBody['mentionedTarget'] = $mentionedUserIds;
|
||||
}
|
||||
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);
|
||||
}
|
||||
$nativeResult = requireImSuccess(imAdminPost('/admin/message/send', [
|
||||
'sender' => $sender,
|
||||
'conv' => ['type' => 1, 'target' => $target, 'line' => 0],
|
||||
'payload' => $payloadBody,
|
||||
]), '原生群消息发送失败');
|
||||
$messageUid = isset($nativeResult['messageUid']) ? (string)$nativeResult['messageUid'] : '';
|
||||
$timestamp = (int)($nativeResult['timestamp'] ?? webImNowMs());
|
||||
if ($attachment && $messageUid !== '') {
|
||||
storeMessageAttachment($appDb, $messageUid, 'group', $target, $sender, $attachment, $timestamp);
|
||||
}
|
||||
$role = (int)$nativeMemberType === 2 ? 'owner' : ((int)$nativeMemberType === 1 ? 'admin' : 'member');
|
||||
respond(0, 'success', [
|
||||
'messageUid' => $messageUid,
|
||||
'timestamp' => $timestamp,
|
||||
'senderRole' => $role,
|
||||
'attachment' => $attachment,
|
||||
'nativeError' => '',
|
||||
]);
|
||||
}
|
||||
$category = (string)($group['category'] ?? 'group');
|
||||
$role = (string)($group['role'] ?? 'member');
|
||||
if ($category === 'channel' && !in_array($role, ['owner', 'admin'], true)) {
|
||||
@@ -319,15 +401,19 @@ try {
|
||||
$pushContent = $text !== ''
|
||||
? $text
|
||||
: ($attachment
|
||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : ((string)$attachment['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name']))
|
||||
: $text);
|
||||
$payloadBody = [
|
||||
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
|
||||
$payloadBody = [
|
||||
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 5)) : 1,
|
||||
'searchableContent' => $pushContent,
|
||||
'pushContent' => $pushContent,
|
||||
'content' => $text,
|
||||
'persistFlag' => 3,
|
||||
];
|
||||
'persistFlag' => 3,
|
||||
];
|
||||
if ($mentionedUserIds) {
|
||||
$payloadBody['mentionedType'] = 1;
|
||||
$payloadBody['mentionedTarget'] = $mentionedUserIds;
|
||||
}
|
||||
if ($attachment) {
|
||||
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
|
||||
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
|
||||
@@ -344,8 +430,7 @@ try {
|
||||
'target' => $target,
|
||||
'line' => 0,
|
||||
],
|
||||
'payload' => $payloadBody,
|
||||
'userMessage' => true,
|
||||
'payload' => $payloadBody,
|
||||
]), '原生群消息发送失败');
|
||||
} catch (Throwable $error) {
|
||||
$nativeError = $error->getMessage() ?: '原生群消息发送失败';
|
||||
@@ -364,10 +449,10 @@ try {
|
||||
$pushContent = $text !== ''
|
||||
? $text
|
||||
: ($attachment
|
||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : ((string)$attachment['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name']))
|
||||
: $text);
|
||||
$payloadBody = [
|
||||
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
|
||||
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 5)) : 1,
|
||||
'searchableContent' => $pushContent,
|
||||
'pushContent' => $pushContent,
|
||||
'content' => $text,
|
||||
@@ -390,8 +475,7 @@ try {
|
||||
'target' => $target,
|
||||
'line' => 0,
|
||||
],
|
||||
'payload' => $payloadBody,
|
||||
'userMessage' => true,
|
||||
'payload' => $payloadBody,
|
||||
]), '发送失败');
|
||||
|
||||
$messageUid = isset($result['messageUid']) ? (string)$result['messageUid'] : '';
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
window.JC_WEB_IM_CONFIG = {
|
||||
adminServer: 'http://winchen.ezpay.asia:8024',
|
||||
appServer: 'http://winchen.ezpay.asia:8888',
|
||||
qrAppServer: 'http://winchen.ezpay.asia:18888',
|
||||
adminServer: 'http://18.141.44.165:8100',
|
||||
appServer: '/app-server',
|
||||
qrAppServer: 'http://18.141.44.165:8888',
|
||||
platformConfigApi: '/api/platform_config.php',
|
||||
registerConfigApi: 'http://winchen.ezpay.asia:8024/api/register-config',
|
||||
imServerHost: 'winchen.ezpay.asia',
|
||||
imRoutePort: 18000,
|
||||
registerConfigApi: 'http://18.141.44.165:8100/api/register-config',
|
||||
imServerHost: '18.141.44.165',
|
||||
imRoutePort: 80,
|
||||
useWss: false,
|
||||
wsPath: '/ws',
|
||||
sdkMode: 'http'
|
||||
sdkMode: 'legacy-http',
|
||||
syncIntervalMs: 2500
|
||||
};
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
window.JC_WEB_IM_CONFIG = {
|
||||
runtimeEnvironment: 'local',
|
||||
adminServer: 'http://18.141.44.165:8100',
|
||||
appServer: '/app-server',
|
||||
qrAppServer: 'http://18.141.44.165:8888',
|
||||
platformConfigApi: '/local-api/platform_config.php',
|
||||
registerConfigApi: '/local-api/register_config.php',
|
||||
registerPhoneExistsApi: 'http://18.141.44.165:8100/api/register-phone-exists',
|
||||
imServerHost: '18.141.44.165',
|
||||
imRoutePort: 80,
|
||||
useWss: false,
|
||||
wsPath: '/ws',
|
||||
sdkMode: 'legacy-http',
|
||||
syncIntervalMs: 2500,
|
||||
sseEnabled: false,
|
||||
sseTicketApi: '/local-api/sse_ticket.php',
|
||||
sseUrl: '/ws-events'
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
(() => {
|
||||
const protocol = window.location.protocol || 'http:';
|
||||
const host = window.location.hostname || '18.141.44.165';
|
||||
|
||||
window.JC_WEB_IM_CONFIG = {
|
||||
runtimeEnvironment: 'production',
|
||||
adminServer: `${protocol}//${host}:8100`,
|
||||
appServer: `${protocol}//${host}:8888`,
|
||||
qrAppServer: `${protocol}//${host}:8888`,
|
||||
platformConfigApi: '/api/platform_config.php',
|
||||
registerConfigApi: `${protocol}//${host}:8100/api/register-config`,
|
||||
registerPhoneExistsApi: `${protocol}//${host}:8100/api/register-phone-exists`,
|
||||
imServerHost: host,
|
||||
imRoutePort: 80,
|
||||
useWss: false,
|
||||
wsPath: '/ws',
|
||||
sdkMode: 'legacy-http',
|
||||
syncIntervalMs: 15000,
|
||||
sseEnabled: true,
|
||||
sseTicketApi: '/api/sse_ticket.php',
|
||||
sseUrl: '/ws-events'
|
||||
};
|
||||
})();
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
<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>
|
||||
<script src="<%= BASE_URL %>config/<%= htmlWebpackPlugin.options.runtimeConfigFile %>"></script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>请启用 JavaScript 后使用 Web IM。</noscript>
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<LoginView v-if="!session.isAuthenticated" />
|
||||
<DocumentedShell v-else />
|
||||
<ChatShell v-else />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
import LoginView from './components/LoginView.vue';
|
||||
import DocumentedShell from './components/DocumentedShell.vue';
|
||||
import ChatShell from './components/ChatShell.vue';
|
||||
import { useSessionStore } from './stores/session';
|
||||
|
||||
const session = useSessionStore();
|
||||
|
||||
@@ -21,11 +21,31 @@ 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';
|
||||
import { useContactStore } from '@/stores/contacts';
|
||||
import { useAutoDismiss } from '@/composables/useAutoDismiss';
|
||||
|
||||
const conversations = useConversationStore();
|
||||
const navigation = useNavigationStore();
|
||||
const contacts = useContactStore();
|
||||
const contacts = useContactStore();
|
||||
|
||||
useAutoDismiss(
|
||||
() => conversations.actionError || conversations.actionNotice,
|
||||
value => {
|
||||
if ((conversations.actionError || conversations.actionNotice) === value) {
|
||||
conversations.actionError = '';
|
||||
conversations.actionNotice = '';
|
||||
}
|
||||
}
|
||||
);
|
||||
useAutoDismiss(
|
||||
() => contacts.actionError || contacts.actionInfo,
|
||||
value => {
|
||||
if ((contacts.actionError || contacts.actionInfo) === value) {
|
||||
contacts.actionError = '';
|
||||
contacts.actionInfo = '';
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
conversations.refreshRemoteConversations();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<main class="documented-shell">
|
||||
<aside class="documented-rail">
|
||||
<div class="documented-avatar">
|
||||
<img v-if="session.portrait" :src="session.portrait" alt="">
|
||||
<img v-if="session.portrait" :src="session.portrait" alt="" @error="useImageProxyFallback($event, session.portrait)">
|
||||
<span v-else>{{ initials }}</span>
|
||||
</div>
|
||||
<div class="documented-rail-copy">
|
||||
@@ -113,12 +113,20 @@
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { appConfig } from '@/config';
|
||||
import { useAutoDismiss } from '@/composables/useAutoDismiss';
|
||||
import { useImageProxyFallback } from '@/services/mediaProxy';
|
||||
|
||||
const session = useSessionStore();
|
||||
const passwordDialogOpen = ref(false);
|
||||
const passwordSaving = ref(false);
|
||||
const passwordError = ref('');
|
||||
const passwordNotice = ref('');
|
||||
useAutoDismiss(() => passwordError.value || passwordNotice.value, value => {
|
||||
if ((passwordError.value || passwordNotice.value) === value) {
|
||||
passwordError.value = '';
|
||||
passwordNotice.value = '';
|
||||
}
|
||||
});
|
||||
const passwordDraft = reactive({
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
|
||||
@@ -127,6 +127,7 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
|
||||
import { appConfig } from '@/config';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { imClient } from '@/services/imClient';
|
||||
import { useAutoDismiss } from '@/composables/useAutoDismiss';
|
||||
|
||||
const session = useSessionStore();
|
||||
const view = ref('login');
|
||||
@@ -144,6 +145,13 @@ const registerForm = reactive({
|
||||
const sendingCode = ref(false);
|
||||
const info = ref('');
|
||||
|
||||
useAutoDismiss(() => session.error, value => {
|
||||
if (session.error === value) session.error = '';
|
||||
});
|
||||
useAutoDismiss(info, value => {
|
||||
if (info.value === value) info.value = '';
|
||||
});
|
||||
|
||||
const QR_CODE_PREFIX_PC_SESSION = 'wildfirechat://pcsession/';
|
||||
const qrStatus = ref('idle');
|
||||
const qrCodeImage = ref('');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<section class="chat-panel">
|
||||
<section class="chat-panel" :class="{ 'has-pinned-message': Boolean(conversations.activePinnedMessage) }">
|
||||
<header class="chat-header">
|
||||
<div>
|
||||
<button
|
||||
@@ -8,7 +8,7 @@
|
||||
class="chat-title-button"
|
||||
@click="openGroupOverview"
|
||||
>
|
||||
<img v-if="activeConversation?.portrait" class="chat-title-avatar image-avatar" :src="activeConversation.portrait" alt="">
|
||||
<img v-if="activeConversation?.portrait" class="chat-title-avatar image-avatar" :src="activeConversation.portrait" alt="" @error="useImageProxyFallback($event, activeConversation.portrait)">
|
||||
<span v-else class="chat-title-avatar">{{ activeConversation?.avatarText || '?' }}</span>
|
||||
<span class="chat-title-copy">
|
||||
<h2>{{ activeConversation?.title || '未选择会话' }}</h2>
|
||||
@@ -20,7 +20,7 @@
|
||||
class="chat-title-button"
|
||||
@click="openSingleProfile"
|
||||
>
|
||||
<img v-if="singleProfile.portrait" class="chat-title-avatar image-avatar" :src="singleProfile.portrait" alt="">
|
||||
<img v-if="singleProfile.portrait" class="chat-title-avatar image-avatar" :src="singleProfile.portrait" alt="" @error="useImageProxyFallback($event, singleProfile.portrait)">
|
||||
<span v-else class="chat-title-avatar">{{ singleProfile.avatarText }}</span>
|
||||
<span class="chat-title-copy">
|
||||
<h2>{{ activeConversation?.title || '未选择会话' }}</h2>
|
||||
@@ -40,16 +40,36 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="!activeConversation" class="empty-chat">
|
||||
<div v-if="!activeConversation" class="empty-chat">
|
||||
<strong>选择一个会话</strong>
|
||||
<span>真实会话同步完成后,可以在左侧打开聊天</span>
|
||||
</div>
|
||||
<MessageList
|
||||
v-else
|
||||
</div>
|
||||
<button
|
||||
v-if="activeConversation && conversations.activePinnedMessage"
|
||||
type="button"
|
||||
class="pinned-message-banner"
|
||||
title="点击定位该消息"
|
||||
@click="conversations.focusMessage(conversations.activePinnedMessage)"
|
||||
>
|
||||
<span>📌</span>
|
||||
<strong>置顶消息</strong>
|
||||
<span class="pinned-message-preview">
|
||||
<img
|
||||
v-if="pinnedMessageIsImage"
|
||||
:src="conversations.activePinnedMessage.attachment.url"
|
||||
alt="置顶图片"
|
||||
>
|
||||
<span>{{ pinnedMessageText }}</span>
|
||||
</span>
|
||||
<span class="pinned-message-close" title="取消置顶" @click.stop="conversations.clearPinnedMessage">×</span>
|
||||
</button>
|
||||
<MessageList
|
||||
v-if="activeConversation"
|
||||
:messages="conversations.activeMessages"
|
||||
:selection-mode="conversations.selectionMode"
|
||||
:selected-message-ids="conversations.selectedMessageIds"
|
||||
:favorite-message-ids="conversations.favoriteMessageIds"
|
||||
:favorite-message-ids="conversations.favoriteMessageIds"
|
||||
:pinned-message-id="conversations.activePinnedMessage?.key || ''"
|
||||
:attention-message-id="conversations.attentionMessageId"
|
||||
:can-recall-others="canManageGroup && isGroupConversation"
|
||||
@message-action="conversations.handleMessageAction"
|
||||
@@ -65,7 +85,8 @@
|
||||
</div>
|
||||
<MessageComposer
|
||||
:disabled="!activeConversation || !session.isAuthenticated || !conversations.canSendActiveConversation"
|
||||
:quote-message="conversations.quotedMessage"
|
||||
:quote-message="conversations.quotedMessage"
|
||||
:mention-users="isGroupConversation ? mentionCandidates : []"
|
||||
@cancel-quote="conversations.cancelQuote"
|
||||
@send="conversations.sendText"
|
||||
@send-attachment="conversations.sendAttachment"
|
||||
@@ -162,7 +183,7 @@
|
||||
</header>
|
||||
<div v-if="messageDetailGroup?.read?.length" class="message-detail-member-list">
|
||||
<div v-for="member in messageDetailGroup.read" :key="`read-${member.uid}`" class="message-detail-member-row">
|
||||
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="">
|
||||
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="" @error="useImageProxyFallback($event, member.portrait)">
|
||||
<span v-else class="conversation-avatar">{{ detailMemberAvatar(member) }}</span>
|
||||
<span class="message-detail-member-main">
|
||||
<strong>{{ detailMemberName(member) }}</strong>
|
||||
@@ -182,7 +203,7 @@
|
||||
</header>
|
||||
<div v-if="messageDetailGroup?.unread?.length" class="message-detail-member-list">
|
||||
<div v-for="member in messageDetailGroup.unread" :key="`unread-${member.uid}`" class="message-detail-member-row">
|
||||
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="">
|
||||
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="" @error="useImageProxyFallback($event, member.portrait)">
|
||||
<span v-else class="conversation-avatar">{{ detailMemberAvatar(member) }}</span>
|
||||
<span class="message-detail-member-main">
|
||||
<strong>{{ detailMemberName(member) }}</strong>
|
||||
@@ -202,7 +223,7 @@
|
||||
</header>
|
||||
<div v-if="messageDetailGroup?.undelivered?.length" class="message-detail-member-list">
|
||||
<div v-for="member in messageDetailGroup.undelivered" :key="`undelivered-${member.uid}`" class="message-detail-member-row">
|
||||
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="">
|
||||
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="" @error="useImageProxyFallback($event, member.portrait)">
|
||||
<span v-else class="conversation-avatar">{{ detailMemberAvatar(member) }}</span>
|
||||
<span class="message-detail-member-main">
|
||||
<strong>{{ detailMemberName(member) }}</strong>
|
||||
@@ -226,15 +247,15 @@
|
||||
<button type="button" class="icon-button" title="关闭" @click="closeSingleProfile">×</button>
|
||||
</header>
|
||||
<div class="single-profile-summary">
|
||||
<img v-if="singleProfile.portrait" class="profile-avatar image-avatar" :src="singleProfile.portrait" alt="">
|
||||
<img v-if="singleProfile.portrait" class="profile-avatar image-avatar" :src="singleProfile.portrait" alt="" @error="useImageProxyFallback($event, singleProfile.portrait)">
|
||||
<div v-else class="profile-avatar">{{ singleProfile.avatarText }}</div>
|
||||
<strong>{{ singleProfile.displayName }}</strong>
|
||||
</div>
|
||||
<dl class="group-profile">
|
||||
<div>
|
||||
<dt>用户ID</dt>
|
||||
<dd>{{ singleProfile.uid || '-' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>用户ID</dt>
|
||||
<dd>{{ singleProfile.userId || '-' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>昵称</dt>
|
||||
<dd>{{ singleProfile.rawName || '-' }}</dd>
|
||||
@@ -274,9 +295,9 @@
|
||||
<dt>群名</dt>
|
||||
<dd>{{ groupDetail?.name || activeConversation?.title || '-' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>群ID</dt>
|
||||
<dd>{{ groupDetail?.groupId || activeConversation?.target || '-' }}</dd>
|
||||
<div v-if="groupDisplayId">
|
||||
<dt>群ID</dt>
|
||||
<dd>{{ groupDisplayId }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>群说明</dt>
|
||||
@@ -304,7 +325,7 @@
|
||||
<div v-if="groupBusy" class="group-loading">正在加载</div>
|
||||
<div v-else class="group-member-list">
|
||||
<div v-for="member in groupMembers" :key="member.uid" class="group-member-row">
|
||||
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="">
|
||||
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="" @error="useImageProxyFallback($event, member.portrait)">
|
||||
<span v-else class="conversation-avatar">{{ memberAvatar(member) }}</span>
|
||||
<span>
|
||||
<strong>{{ member.displayName }}</strong>
|
||||
@@ -345,7 +366,7 @@
|
||||
:class="{ selected: transferTargetId === member.uid }"
|
||||
@click="transferTargetId = member.uid"
|
||||
>
|
||||
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="">
|
||||
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="" @error="useImageProxyFallback($event, member.portrait)">
|
||||
<span v-else class="conversation-avatar">{{ memberAvatar(member) }}</span>
|
||||
<span>
|
||||
<strong>{{ member.displayName }}</strong>
|
||||
@@ -382,7 +403,7 @@
|
||||
class="group-member-row selectable with-check"
|
||||
>
|
||||
<input type="checkbox" :checked="selectedAdminIds.includes(member.uid)" @change="toggleAdmin(member.uid)">
|
||||
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="">
|
||||
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="" @error="useImageProxyFallback($event, member.portrait)">
|
||||
<span v-else class="conversation-avatar">{{ memberAvatar(member) }}</span>
|
||||
<span>
|
||||
<strong>{{ member.displayName }}</strong>
|
||||
@@ -422,7 +443,7 @@
|
||||
class="group-member-row selectable with-check"
|
||||
>
|
||||
<input type="checkbox" :checked="selectedKickIds.includes(member.uid)" @change="toggleKick(member.uid)">
|
||||
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="">
|
||||
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="" @error="useImageProxyFallback($event, member.portrait)">
|
||||
<span v-else class="conversation-avatar">{{ memberAvatar(member) }}</span>
|
||||
<span>
|
||||
<strong>{{ member.displayName }}</strong>
|
||||
@@ -494,12 +515,29 @@ import MessageComposer from './MessageComposer.vue';
|
||||
import { imClient } from '@/services/imClient';
|
||||
import { useConversationStore } from '@/stores/conversations';
|
||||
import { useContactStore } from '@/stores/contacts';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { useAutoDismiss } from '@/composables/useAutoDismiss';
|
||||
import { useImageProxyFallback } from '@/services/mediaProxy';
|
||||
|
||||
const conversations = useConversationStore();
|
||||
const contacts = useContactStore();
|
||||
const session = useSessionStore();
|
||||
const activeConversation = computed(() => conversations.activeConversation);
|
||||
const activeConversation = computed(() => conversations.activeConversation);
|
||||
const pinnedMessageIsImage = computed(() => {
|
||||
const message = conversations.activePinnedMessage;
|
||||
return message?.attachment?.url && (message.type === 'image' || message.attachment.type === 'image');
|
||||
});
|
||||
const pinnedMessageText = computed(() => {
|
||||
const message = conversations.activePinnedMessage;
|
||||
if (!message) return '';
|
||||
const text = String(message.text || '').trim();
|
||||
if (text && text !== '[消息]') return text;
|
||||
const attachment = message.attachment || {};
|
||||
if (message.type === 'image' || attachment.type === 'image') return attachment.name || '图片';
|
||||
if (message.type === 'video' || attachment.type === 'video') return attachment.name || '视频';
|
||||
if (attachment.url) return attachment.name || '文件';
|
||||
return '[消息]';
|
||||
});
|
||||
const groupMenuOpen = ref(false);
|
||||
const singleProfileOpen = ref(false);
|
||||
const singleAliasDraft = ref('');
|
||||
@@ -529,7 +567,19 @@ const muteDraft = ref({
|
||||
dailyEnd: '18:00'
|
||||
});
|
||||
const muteSaveNotice = ref('');
|
||||
const muteSaveError = ref('');
|
||||
const muteSaveError = ref('');
|
||||
useAutoDismiss(() => singleProfileError.value || singleProfileNotice.value, value => {
|
||||
if ((singleProfileError.value || singleProfileNotice.value) === value) {
|
||||
singleProfileError.value = '';
|
||||
singleProfileNotice.value = '';
|
||||
}
|
||||
});
|
||||
useAutoDismiss(() => muteSaveError.value || muteSaveNotice.value, value => {
|
||||
if ((muteSaveError.value || muteSaveNotice.value) === value) {
|
||||
muteSaveError.value = '';
|
||||
muteSaveNotice.value = '';
|
||||
}
|
||||
});
|
||||
const singlePeerId = computed(() => {
|
||||
if (!activeConversation.value || isGroupConversation.value) {
|
||||
return '';
|
||||
@@ -543,7 +593,7 @@ const singleContact = computed(() => {
|
||||
}
|
||||
return contacts.contacts.find(contact => String(contact.uid || contact.id || '') === peerId) || null;
|
||||
});
|
||||
const singleProfile = computed(() => {
|
||||
const singleProfile = computed(() => {
|
||||
const contact = singleContact.value;
|
||||
const conversation = activeConversation.value || {};
|
||||
const rawUser = contact?.rawUser || {};
|
||||
@@ -556,8 +606,9 @@ const singleProfile = computed(() => {
|
||||
|| '未知用户';
|
||||
const rawName = rawUser.displayName || rawUser.name || contact?.title || conversation.title || '';
|
||||
|
||||
return {
|
||||
uid: singlePeerId.value,
|
||||
return {
|
||||
uid: singlePeerId.value,
|
||||
userId: rawUser.name || conversation.raw?.userId || singlePeerId.value,
|
||||
displayName,
|
||||
rawName,
|
||||
mobile: rawUser.mobile || conversation.raw?.mobile || '',
|
||||
@@ -566,7 +617,10 @@ const singleProfile = computed(() => {
|
||||
avatarText: String(displayName || '?').slice(0, 1).toUpperCase(),
|
||||
alias: contact?.alias || ''
|
||||
};
|
||||
});
|
||||
});
|
||||
const groupDisplayId = computed(() => {
|
||||
return String(groupDetail.value?.publicId || '');
|
||||
});
|
||||
const isGroupConversation = computed(() => {
|
||||
return ['group', 'channel'].includes(activeConversation.value?.type)
|
||||
|| activeConversation.value?.raw?.type === 'group';
|
||||
@@ -576,8 +630,26 @@ const currentGroupRole = computed(() => {
|
||||
});
|
||||
const isGroupOwner = computed(() => currentGroupRole.value === 'owner');
|
||||
const canManageGroup = computed(() => ['owner', 'admin'].includes(currentGroupRole.value));
|
||||
const adminCandidateMembers = computed(() => groupMembers.value.filter(member => member.role !== 'owner'));
|
||||
const transferableMembers = computed(() => groupMembers.value.filter(member => member.role !== 'owner'));
|
||||
const adminCandidateMembers = computed(() => groupMembers.value.filter(member => member.role !== 'owner'));
|
||||
const transferableMembers = computed(() => groupMembers.value.filter(member => member.role !== 'owner'));
|
||||
const mentionCandidates = computed(() => {
|
||||
const members = new Map();
|
||||
groupMembers.value.forEach(member => {
|
||||
const uid = String(member?.uid || '').trim();
|
||||
if (uid && uid !== String(session.userId || '')) members.set(uid, member);
|
||||
});
|
||||
conversations.activeMessages.forEach(message => {
|
||||
const uid = String(message?.from || '').trim();
|
||||
if (!uid || uid === 'me' || uid === session.userId || members.has(uid)) return;
|
||||
members.set(uid, {
|
||||
uid,
|
||||
displayName: message?.senderName || uid,
|
||||
portrait: message?.senderPortrait || message?.portrait || '',
|
||||
role: message?.senderRole || 'member'
|
||||
});
|
||||
});
|
||||
return Array.from(members.values());
|
||||
});
|
||||
const messageDetail = computed(() => conversations.messageDetail || null);
|
||||
const messageDetailGroup = computed(() => messageDetail.value?.group || null);
|
||||
const isMessageDetailGroup = computed(() => {
|
||||
@@ -734,19 +806,25 @@ function closeGroupPanel() {
|
||||
clearMuteSaveFeedback();
|
||||
}
|
||||
|
||||
async function loadGroupDetail() {
|
||||
async function loadGroupDetail() {
|
||||
if (!session.userId || !activeConversation.value?.target) {
|
||||
return null;
|
||||
}
|
||||
groupBusy.value = true;
|
||||
try {
|
||||
groupDetail.value = await imClient.getGroupDetail(session.userId, activeConversation.value.target);
|
||||
const requestedConversationId = activeConversation.value.id;
|
||||
const requestedTarget = activeConversation.value.target;
|
||||
groupBusy.value = true;
|
||||
try {
|
||||
const detail = await imClient.getGroupDetail(session.userId, requestedTarget);
|
||||
if (activeConversation.value?.id !== requestedConversationId) return null;
|
||||
groupDetail.value = detail;
|
||||
return groupDetail.value;
|
||||
} catch (error) {
|
||||
conversations.setActionError(error.message || '群资料加载失败');
|
||||
if (activeConversation.value?.id === requestedConversationId) {
|
||||
conversations.setActionError(error.message || '群资料加载失败');
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
groupBusy.value = false;
|
||||
if (activeConversation.value?.id === requestedConversationId) groupBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -765,19 +843,26 @@ async function openGroupMembers() {
|
||||
await loadGroupMembers();
|
||||
}
|
||||
|
||||
async function loadGroupMembers() {
|
||||
groupBusy.value = true;
|
||||
try {
|
||||
groupMembers.value = await imClient.getGroupMembers(session.userId, activeConversation.value.target);
|
||||
async function loadGroupMembers({ silent = false } = {}) {
|
||||
const requestedConversationId = activeConversation.value?.id;
|
||||
const requestedTarget = activeConversation.value?.target;
|
||||
if (!session.userId || !requestedConversationId || !requestedTarget) return [];
|
||||
groupBusy.value = true;
|
||||
try {
|
||||
const members = await imClient.getGroupMembers(session.userId, requestedTarget);
|
||||
if (activeConversation.value?.id !== requestedConversationId) return [];
|
||||
groupMembers.value = members;
|
||||
selectedAdminIds.value = groupMembers.value
|
||||
.filter(member => member.role === 'admin')
|
||||
.map(member => member.uid);
|
||||
return groupMembers.value;
|
||||
} catch (error) {
|
||||
conversations.setActionError(error.message || '群员加载失败');
|
||||
} catch (error) {
|
||||
if (!silent && activeConversation.value?.id === requestedConversationId) {
|
||||
conversations.setActionError(error.message || '群员加载失败');
|
||||
}
|
||||
return [];
|
||||
} finally {
|
||||
groupBusy.value = false;
|
||||
if (activeConversation.value?.id === requestedConversationId) groupBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1049,10 +1134,12 @@ onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', closeGroupMenu);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => activeConversation.value?.id,
|
||||
() => {
|
||||
closeGroupMenu();
|
||||
watch(
|
||||
() => activeConversation.value?.id,
|
||||
async () => {
|
||||
conversations.actionError = '';
|
||||
conversations.actionNotice = '';
|
||||
closeGroupMenu();
|
||||
closeSingleProfile();
|
||||
closeGroupPanel();
|
||||
groupDetail.value = null;
|
||||
@@ -1061,7 +1148,10 @@ watch(
|
||||
selectedAdminIds.value = [];
|
||||
selectedKickIds.value = [];
|
||||
transferTargetId.value = '';
|
||||
manageSection.value = '';
|
||||
}
|
||||
);
|
||||
manageSection.value = '';
|
||||
if (isGroupConversation.value) {
|
||||
await loadGroupMembers({ silent: true });
|
||||
}
|
||||
}
|
||||
, { immediate: true });
|
||||
</script>
|
||||
|
||||
@@ -17,8 +17,9 @@
|
||||
<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="toggleEmojiPicker">☺</button>
|
||||
<button type="button" title="艾特群成员" :disabled="disabled || !mentionUsers.length" @click="openMentionPicker">@</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')">
|
||||
@@ -33,14 +34,27 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
<textarea
|
||||
ref="textareaEl"
|
||||
v-model="text"
|
||||
:disabled="disabled"
|
||||
placeholder="输入消息"
|
||||
rows="3"
|
||||
@keydown.enter.exact.prevent="submit"
|
||||
></textarea>
|
||||
rows="3"
|
||||
@input="handleMentionInput"
|
||||
@keydown.enter.exact.prevent="submit"
|
||||
></textarea>
|
||||
<div v-if="mentionOpen && filteredMentionUsers.length" class="mention-picker">
|
||||
<button
|
||||
v-for="member in filteredMentionUsers"
|
||||
:key="member.uid"
|
||||
type="button"
|
||||
@click="selectMention(member)"
|
||||
>
|
||||
<img v-if="member.portrait" :src="member.portrait" alt="">
|
||||
<span v-else>{{ mentionName(member).slice(0, 1) }}</span>
|
||||
<strong>{{ mentionName(member) }}</strong>
|
||||
</button>
|
||||
</div>
|
||||
<div class="composer-footer">
|
||||
<span>Enter 发送,Shift + Enter 换行</span>
|
||||
<button class="send-button" :disabled="disabled || !text.trim()">发送</button>
|
||||
@@ -49,17 +63,21 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
quoteMessage: {
|
||||
quoteMessage: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
mentionUsers: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['send', 'send-attachment', 'cancel-quote']);
|
||||
@@ -68,23 +86,83 @@ const emojiOpen = ref(false);
|
||||
const dropActive = ref(false);
|
||||
const textareaEl = ref(null);
|
||||
const imageInput = ref(null);
|
||||
const fileInput = ref(null);
|
||||
const fileInput = ref(null);
|
||||
const mentionOpen = ref(false);
|
||||
const mentionQuery = ref('');
|
||||
const mentionStart = ref(-1);
|
||||
const mentionedUserIds = ref([]);
|
||||
let dragDepth = 0;
|
||||
const emojiList = [
|
||||
const emojiList = [
|
||||
'😀', '😄', '😂', '😊', '😍', '😘', '😎', '😢',
|
||||
'😡', '👍', '🙏', '👏', '💪', '🎉', '❤️', '🔥',
|
||||
'🌹', '☕', '✅', '⭐', '💰', '🎁', '🤝', '👌'
|
||||
];
|
||||
];
|
||||
const filteredMentionUsers = computed(() => {
|
||||
const query = mentionQuery.value.toLowerCase();
|
||||
return props.mentionUsers
|
||||
.filter(member => member?.uid)
|
||||
.filter(member => !query || mentionName(member).toLowerCase().includes(query))
|
||||
.slice(0, 12);
|
||||
});
|
||||
|
||||
function submit() {
|
||||
const value = text.value.trim();
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
emit('send', value);
|
||||
text.value = '';
|
||||
emit('send', { text: value, mentionedUserIds: [...mentionedUserIds.value] });
|
||||
text.value = '';
|
||||
mentionedUserIds.value = [];
|
||||
mentionOpen.value = false;
|
||||
emojiOpen.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function mentionName(member) {
|
||||
return String(member?.displayName || member?.name || member?.rawName || member?.uid || '群成员');
|
||||
}
|
||||
|
||||
function handleMentionInput(event) {
|
||||
const cursor = event?.target?.selectionStart ?? text.value.length;
|
||||
const beforeCursor = text.value.slice(0, cursor);
|
||||
const match = beforeCursor.match(/@([^\s@]*)$/);
|
||||
mentionOpen.value = Boolean(match && props.mentionUsers.length);
|
||||
mentionQuery.value = match?.[1] || '';
|
||||
mentionStart.value = match ? cursor - match[1].length - 1 : -1;
|
||||
}
|
||||
|
||||
function openMentionPicker() {
|
||||
if (!props.mentionUsers.length) return;
|
||||
const element = textareaEl.value;
|
||||
const start = element?.selectionStart ?? text.value.length;
|
||||
const end = element?.selectionEnd ?? start;
|
||||
text.value = `${text.value.slice(0, start)}@${text.value.slice(end)}`;
|
||||
mentionStart.value = start;
|
||||
mentionQuery.value = '';
|
||||
mentionOpen.value = true;
|
||||
nextTick(() => {
|
||||
element?.focus();
|
||||
element?.setSelectionRange(start + 1, start + 1);
|
||||
});
|
||||
}
|
||||
|
||||
function selectMention(member) {
|
||||
const element = textareaEl.value;
|
||||
const cursor = element?.selectionStart ?? text.value.length;
|
||||
const start = mentionStart.value >= 0 ? mentionStart.value : cursor;
|
||||
const name = mentionName(member);
|
||||
text.value = `${text.value.slice(0, start)}@${name} ${text.value.slice(cursor)}`;
|
||||
if (!mentionedUserIds.value.includes(String(member.uid))) {
|
||||
mentionedUserIds.value.push(String(member.uid));
|
||||
}
|
||||
mentionOpen.value = false;
|
||||
mentionQuery.value = '';
|
||||
mentionStart.value = -1;
|
||||
nextTick(() => {
|
||||
const nextCursor = start + name.length + 2;
|
||||
element?.focus();
|
||||
element?.setSelectionRange(nextCursor, nextCursor);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleEmojiPicker() {
|
||||
emojiOpen.value = !emojiOpen.value;
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
<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' }]"
|
||||
:key="message.id"
|
||||
class="message-row"
|
||||
:class="[message.direction, { system: isSystemMessage(message), 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">
|
||||
<div v-if="isSystemMessage(message)" class="system-message-pill">{{ systemMessageText(message) }}</div>
|
||||
<label v-if="selectionMode && !isSystemMessage(message)" class="message-select">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isSelected(message)"
|
||||
@@ -16,17 +17,17 @@
|
||||
@change="emit('toggle-select', message)"
|
||||
>
|
||||
</label>
|
||||
<button
|
||||
v-if="showGroupSender(message)"
|
||||
<button
|
||||
v-if="!isSystemMessage(message) && showGroupSender(message)"
|
||||
type="button"
|
||||
class="message-sender-avatar"
|
||||
:title="groupSenderName(message)"
|
||||
@click.stop="openSenderProfile(message)"
|
||||
>
|
||||
<img v-if="message.senderPortrait" :src="message.senderPortrait" alt="">
|
||||
<img v-if="message.senderPortrait" :src="message.senderPortrait" alt="" @error="useImageProxyFallback($event, message.senderPortrait)">
|
||||
<span v-else>{{ groupSenderAvatarText(message) }}</span>
|
||||
</button>
|
||||
<div class="message-stack">
|
||||
<div v-if="!isSystemMessage(message)" class="message-stack">
|
||||
<button
|
||||
v-if="showGroupSender(message)"
|
||||
type="button"
|
||||
@@ -40,15 +41,40 @@
|
||||
class="message-bubble"
|
||||
:class="roleBubbleClass(message)"
|
||||
@contextmenu.prevent="openMenu($event, message)"
|
||||
>
|
||||
<button
|
||||
v-if="isImageMessage(message)"
|
||||
type="button"
|
||||
>
|
||||
<div v-if="isRedPacketMessage(message)" class="red-packet-card">
|
||||
<div class="red-packet-card-main">
|
||||
<span class="red-packet-icon">福</span>
|
||||
<span>
|
||||
<strong>{{ redPacketBlessing(message) }}</strong>
|
||||
<small>请在 App 上领取</small>
|
||||
</span>
|
||||
</div>
|
||||
<div class="red-packet-card-footer">中聊红包</div>
|
||||
</div>
|
||||
<div v-else-if="isRedPacketReceipt(message)" class="red-packet-receipt">
|
||||
<span class="red-packet-mini-icon">福</span>
|
||||
<span>{{ redPacketReceiptText(message) }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-else-if="isImageMessage(message)"
|
||||
type="button"
|
||||
class="message-image"
|
||||
@click.stop="handleImageClick(message)"
|
||||
>
|
||||
<img :src="message.attachment.url" :alt="message.attachment.name || '图片'">
|
||||
</button>
|
||||
<img :src="message.attachment.url" :alt="message.attachment.name || '图片'">
|
||||
</button>
|
||||
<video
|
||||
v-else-if="isVideoMessage(message)"
|
||||
class="message-video"
|
||||
:src="videoDisplayUrl(message.attachment.url)"
|
||||
controls
|
||||
playsinline
|
||||
preload="metadata"
|
||||
@error="useVideoOriginalFallback($event, message.attachment.url)"
|
||||
>
|
||||
当前浏览器无法播放该视频
|
||||
</video>
|
||||
<a
|
||||
v-else-if="isFileMessage(message)"
|
||||
class="message-file"
|
||||
@@ -59,11 +85,11 @@
|
||||
>
|
||||
<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>
|
||||
<strong>{{ displayFileName(message) }}</strong>
|
||||
<small>{{ formatFileSize(message.attachment.size) }}</small>
|
||||
</span>
|
||||
</a>
|
||||
<p v-if="message.text && !isFileMessage(message) && !isRedPacketMessage(message) && !isRedPacketReceipt(message)">{{ message.text }}</p>
|
||||
<time>
|
||||
<span v-if="isRecallNotice(message)" class="recall-mark">{{ message.recallStatus || '已撤回' }}</span>
|
||||
<span>{{ formatTime(message.displayTimestamp || message.timestamp) }}</span>
|
||||
@@ -109,29 +135,40 @@
|
||||
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>
|
||||
<div class="image-viewer-toolbar">
|
||||
<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
|
||||
<div
|
||||
ref="viewerStage"
|
||||
class="image-viewer-stage"
|
||||
:class="{ dragging: imageViewer.dragging, zoomed: imageViewer.scale > 1 }"
|
||||
@wheel.prevent="handleImageWheel"
|
||||
@pointerdown.prevent="startImageDrag"
|
||||
>
|
||||
<img
|
||||
>
|
||||
<button
|
||||
v-if="viewerImages.length > 1"
|
||||
type="button"
|
||||
class="image-viewer-nav image-viewer-prev"
|
||||
title="上一张"
|
||||
aria-label="上一张"
|
||||
@pointerdown.stop
|
||||
@click.stop="showPreviousImage"
|
||||
>‹</button>
|
||||
<img
|
||||
class="image-viewer-img"
|
||||
:src="imageViewer.url"
|
||||
:alt="imageViewer.name || '图片'"
|
||||
:style="imageViewerStyle"
|
||||
draggable="false"
|
||||
>
|
||||
draggable="false"
|
||||
>
|
||||
<button
|
||||
v-if="viewerImages.length > 1"
|
||||
type="button"
|
||||
class="image-viewer-nav image-viewer-next"
|
||||
title="下一张"
|
||||
aria-label="下一张"
|
||||
@pointerdown.stop
|
||||
@click.stop="showNextImage"
|
||||
>›</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -148,26 +185,26 @@
|
||||
<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="">
|
||||
<img v-if="senderProfile.portrait" class="profile-avatar image-avatar" :src="senderProfile.portrait" alt="" @error="useImageProxyFallback($event, senderProfile.portrait)">
|
||||
<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>
|
||||
<div v-if="senderProfile.isContact">
|
||||
<dt>用户ID</dt>
|
||||
<dd>{{ senderProfile.userId || '-' }}</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>
|
||||
@@ -209,8 +246,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useContactStore } from '@/stores/contacts';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useContactStore } from '@/stores/contacts';
|
||||
import { useImageProxyFallback, useVideoOriginalFallback, videoDisplayUrl } from '@/services/mediaProxy';
|
||||
import { useAutoDismiss } from '@/composables/useAutoDismiss';
|
||||
|
||||
const props = defineProps({
|
||||
messages: {
|
||||
@@ -225,10 +264,14 @@ const props = defineProps({
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
favoriteMessageIds: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
favoriteMessageIds: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
pinnedMessageId: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
attentionMessageId: {
|
||||
type: String,
|
||||
default: ''
|
||||
@@ -251,10 +294,12 @@ const contextMenu = reactive({
|
||||
});
|
||||
const IMAGE_MIN_SCALE = 0.5;
|
||||
const IMAGE_MAX_SCALE = 6;
|
||||
const imageViewer = reactive({
|
||||
const imageViewer = reactive({
|
||||
visible: false,
|
||||
url: '',
|
||||
name: '',
|
||||
name: '',
|
||||
sourceUrl: '',
|
||||
index: 0,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
offsetX: 0,
|
||||
@@ -265,9 +310,10 @@ const imageViewer = reactive({
|
||||
dragOffsetX: 0,
|
||||
dragOffsetY: 0
|
||||
});
|
||||
const senderProfile = reactive({
|
||||
visible: false,
|
||||
uid: '',
|
||||
const senderProfile = reactive({
|
||||
visible: false,
|
||||
uid: '',
|
||||
userId: '',
|
||||
displayName: '',
|
||||
rawName: '',
|
||||
mobile: '',
|
||||
@@ -284,11 +330,22 @@ const senderProfile = reactive({
|
||||
adding: false,
|
||||
notice: '',
|
||||
error: ''
|
||||
});
|
||||
});
|
||||
useAutoDismiss(
|
||||
() => senderProfile.error || senderProfile.notice,
|
||||
value => {
|
||||
if ((senderProfile.error || senderProfile.notice) === value) {
|
||||
senderProfile.error = '';
|
||||
senderProfile.notice = '';
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const imageViewerStyle = computed(() => ({
|
||||
const imageViewerStyle = computed(() => ({
|
||||
transform: `translate3d(${imageViewer.offsetX}px, ${imageViewer.offsetY}px, 0) scale(${imageViewer.scale}) rotate(${imageViewer.rotation}deg)`
|
||||
}));
|
||||
}));
|
||||
|
||||
const viewerImages = computed(() => props.messages.filter(message => isImageMessage(message)));
|
||||
|
||||
const menuItems = computed(() => {
|
||||
if (!contextMenu.message) {
|
||||
@@ -303,12 +360,14 @@ const menuItems = computed(() => {
|
||||
{ action: 'detail', label: '详情' }
|
||||
];
|
||||
}
|
||||
const favoriteLabel = isFavorite(contextMenu.message) ? '取消收藏' : '收藏';
|
||||
const items = [
|
||||
const favoriteLabel = isFavorite(contextMenu.message) ? '取消收藏' : '收藏';
|
||||
const pinnedLabel = props.pinnedMessageId === messageIdentity(contextMenu.message) ? '取消置顶' : '置顶';
|
||||
const items = [
|
||||
{ action: 'copy', label: '复制' },
|
||||
{ action: 'delete', label: '删除' },
|
||||
{ action: 'forward', label: '转发' },
|
||||
{ action: 'favorite', label: favoriteLabel },
|
||||
{ action: 'favorite', label: favoriteLabel },
|
||||
{ action: 'pin', label: pinnedLabel },
|
||||
{ action: 'quote', label: '引用' },
|
||||
{ action: 'select', label: '多选' },
|
||||
{ action: 'detail', label: '详情' }
|
||||
@@ -400,9 +459,85 @@ function isImageMessage(message) {
|
||||
return message?.type === 'image' && message?.attachment?.url;
|
||||
}
|
||||
|
||||
function isFileMessage(message) {
|
||||
return message?.type === 'file' && message?.attachment?.url;
|
||||
}
|
||||
function isFileMessage(message) {
|
||||
return message?.type === 'file' && message?.attachment?.url;
|
||||
}
|
||||
|
||||
function displayFileName(message) {
|
||||
const text = String(message?.text || '').trim();
|
||||
const attachmentName = String(message?.attachment?.name || '').trim();
|
||||
return text || attachmentName || '文件';
|
||||
}
|
||||
|
||||
function isVideoMessage(message) {
|
||||
return (message?.type === 'video' || Number(message?.contentType || 0) === 6) && message?.attachment?.url;
|
||||
}
|
||||
|
||||
function isRedPacketMessage(message) {
|
||||
return Number(message?.contentType || 0) === 90 || Boolean(redPacketPayload(message));
|
||||
}
|
||||
|
||||
function isRedPacketReceipt(message) {
|
||||
return Number(message?.contentType || 0) === 105;
|
||||
}
|
||||
|
||||
function isSystemMessage(message) {
|
||||
return [92, 93].includes(Number(message?.contentType || 0));
|
||||
}
|
||||
|
||||
function systemMessageText(message) {
|
||||
const contentType = Number(message?.contentType || 0);
|
||||
if (contentType === 92) {
|
||||
return '以上是打招呼的内容';
|
||||
}
|
||||
if (contentType === 93) {
|
||||
return '你们已经是好友了,可以开始聊天了。';
|
||||
}
|
||||
return String(message?.text || '');
|
||||
}
|
||||
|
||||
function redPacketReceiptText(message) {
|
||||
const text = String(message?.text || '').trim();
|
||||
if (text && text !== '[领取了红包]') {
|
||||
return text;
|
||||
}
|
||||
const receiverName = message?.direction === 'out' ? '你' : (message?.senderName || '对方');
|
||||
const packetSenderName = message?.redPacketSenderName || nearestRedPacketSenderName(message);
|
||||
return packetSenderName
|
||||
? `${receiverName}领取了${packetSenderName}的红包`
|
||||
: `${receiverName}领取了红包`;
|
||||
}
|
||||
|
||||
function nearestRedPacketSenderName(receiptMessage) {
|
||||
const receiptIndex = props.messages.findIndex(item => messageIdentity(item) === messageIdentity(receiptMessage));
|
||||
for (let index = receiptIndex - 1; index >= 0; index -= 1) {
|
||||
const candidate = props.messages[index];
|
||||
if (isRedPacketMessage(candidate)) {
|
||||
return candidate?.direction === 'out' ? '你' : (candidate?.senderName || '对方');
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function redPacketPayload(message) {
|
||||
if (message?.redPacket && typeof message.redPacket === 'object') {
|
||||
return message.redPacket;
|
||||
}
|
||||
const text = String(message?.text || '').trim();
|
||||
if (!text.startsWith('{')) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(text);
|
||||
return payload?.redPackets || payload?.redPacket || null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function redPacketBlessing(message) {
|
||||
return String(redPacketPayload(message)?.blessing || '恭喜发财,大吉大利');
|
||||
}
|
||||
|
||||
function isGroupMessage(message) {
|
||||
return ['group', 'channel'].includes(message?.conversationType || '');
|
||||
@@ -444,8 +579,12 @@ function openSenderProfile(message) {
|
||||
|| cachedContact?.title
|
||||
|| groupSenderName(message);
|
||||
const displayName = contactAlias || groupAlias || rawName || uid;
|
||||
senderProfile.visible = true;
|
||||
senderProfile.uid = uid;
|
||||
senderProfile.visible = true;
|
||||
senderProfile.uid = uid;
|
||||
senderProfile.userId = profile.userId
|
||||
|| profile.name
|
||||
|| cachedContact?.rawUser?.name
|
||||
|| '';
|
||||
senderProfile.displayName = displayName;
|
||||
senderProfile.rawName = rawName || '';
|
||||
senderProfile.mobile = profile.mobile || cachedContact?.rawUser?.mobile || '';
|
||||
@@ -566,22 +705,45 @@ function handleImageClick(message) {
|
||||
openImageViewer(message);
|
||||
}
|
||||
|
||||
function 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();
|
||||
}
|
||||
closeMenu();
|
||||
imageViewer.visible = true;
|
||||
const index = viewerImages.value.findIndex(item => messageIdentity(item) === messageIdentity(message));
|
||||
showViewerImage(index >= 0 ? index : 0);
|
||||
}
|
||||
|
||||
function showViewerImage(index) {
|
||||
const images = viewerImages.value;
|
||||
if (!images.length) {
|
||||
closeImageViewer();
|
||||
return;
|
||||
}
|
||||
const normalizedIndex = (index + images.length) % images.length;
|
||||
const attachment = images[normalizedIndex]?.attachment || {};
|
||||
imageViewer.index = normalizedIndex;
|
||||
imageViewer.sourceUrl = attachment.url || '';
|
||||
imageViewer.url = attachment.url || '';
|
||||
imageViewer.name = attachment.name || `图片 ${normalizedIndex + 1}`;
|
||||
resetImageViewer();
|
||||
}
|
||||
|
||||
function showPreviousImage() {
|
||||
showViewerImage(imageViewer.index - 1);
|
||||
}
|
||||
|
||||
function showNextImage() {
|
||||
showViewerImage(imageViewer.index + 1);
|
||||
}
|
||||
|
||||
function closeImageViewer() {
|
||||
function closeImageViewer() {
|
||||
imageViewer.visible = false;
|
||||
imageViewer.dragging = false;
|
||||
}
|
||||
imageViewer.dragging = false;
|
||||
imageViewer.sourceUrl = '';
|
||||
}
|
||||
|
||||
function resetImageViewer() {
|
||||
imageViewer.scale = 1;
|
||||
@@ -653,12 +815,12 @@ function stopImageDrag() {
|
||||
imageViewer.dragging = false;
|
||||
}
|
||||
|
||||
function saveViewerImage() {
|
||||
if (!imageViewer.url) {
|
||||
function saveViewerImage() {
|
||||
if (!imageViewer.sourceUrl) {
|
||||
return;
|
||||
}
|
||||
const downloadUrl = new URL('/api/media_download.php', window.location.origin);
|
||||
downloadUrl.searchParams.set('url', imageViewer.url);
|
||||
downloadUrl.searchParams.set('url', imageViewer.sourceUrl);
|
||||
downloadUrl.searchParams.set('name', imageViewer.name || 'image');
|
||||
|
||||
const link = document.createElement('a');
|
||||
@@ -685,7 +847,17 @@ function closeMenu() {
|
||||
contextMenu.message = null;
|
||||
}
|
||||
|
||||
function closeMenuOnEscape(event) {
|
||||
function closeMenuOnEscape(event) {
|
||||
if (imageViewer.visible && event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
showPreviousImage();
|
||||
return;
|
||||
}
|
||||
if (imageViewer.visible && event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
showNextImage();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
if (senderProfile.visible) {
|
||||
closeSenderProfile();
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</header>
|
||||
|
||||
<div class="contact-profile">
|
||||
<img v-if="contact.portrait" class="profile-avatar image-avatar" :src="contact.portrait" alt="">
|
||||
<img v-if="contact.portrait" class="profile-avatar image-avatar" :src="contact.portrait" alt="" @error="useImageProxyFallback($event, contact.portrait)">
|
||||
<div v-else class="profile-avatar">{{ contact.avatarText }}</div>
|
||||
<div class="profile-main">
|
||||
<div class="remark-row">
|
||||
@@ -62,7 +62,9 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useContactStore } from '@/stores/contacts';
|
||||
import { useContactStore } from '@/stores/contacts';
|
||||
import { useAutoDismiss } from '@/composables/useAutoDismiss';
|
||||
import { useImageProxyFallback } from '@/services/mediaProxy';
|
||||
|
||||
const contacts = useContactStore();
|
||||
const contact = computed(() => contacts.activeContact);
|
||||
@@ -73,7 +75,10 @@ const address = computed(() => contact.value?.rawUser?.address || '未知');
|
||||
const editingAlias = ref(false);
|
||||
const aliasDraft = ref('');
|
||||
const aliasSaving = ref(false);
|
||||
const aliasError = ref('');
|
||||
const aliasError = ref('');
|
||||
useAutoDismiss(aliasError, value => {
|
||||
if (aliasError.value === value) aliasError.value = '';
|
||||
});
|
||||
|
||||
watch(contact, () => {
|
||||
editingAlias.value = false;
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<img :src="favorite.attachment.url" :alt="favorite.attachment.name || '图片'">
|
||||
<img :src="favorite.attachment.url" :alt="favorite.attachment.name || '图片'">
|
||||
</a>
|
||||
<a
|
||||
v-else-if="isFileFavorite(favorite)"
|
||||
@@ -57,14 +57,19 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useConversationStore } from '@/stores/conversations';
|
||||
import { useNavigationStore } from '@/stores/navigation';
|
||||
import { useConversationStore } from '@/stores/conversations';
|
||||
import { useNavigationStore } from '@/stores/navigation';
|
||||
import { useAutoDismiss } from '@/composables/useAutoDismiss';
|
||||
|
||||
const conversations = useConversationStore();
|
||||
const navigation = useNavigationStore();
|
||||
const favorite = computed(() => conversations.activeFavorite);
|
||||
const notice = ref('');
|
||||
const error = ref('');
|
||||
const error = ref('');
|
||||
|
||||
useAutoDismiss(() => error.value || notice.value, value => {
|
||||
if ((error.value || notice.value) === value) clearFeedback();
|
||||
});
|
||||
|
||||
function clearFeedback() {
|
||||
notice.value = '';
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
: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="">
|
||||
<img v-if="item.portrait" class="conversation-avatar image-avatar" :src="item.portrait" alt="" @error="useImageProxyFallback($event, item.portrait)">
|
||||
<span v-else class="conversation-avatar">{{ item.avatarText }}</span>
|
||||
<span class="conversation-copy">
|
||||
<strong>{{ item.title }}</strong>
|
||||
@@ -62,7 +62,8 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useContactStore } from '@/stores/contacts';
|
||||
import { useContactStore } from '@/stores/contacts';
|
||||
import { useImageProxyFallback } from '@/services/mediaProxy';
|
||||
|
||||
const contacts = useContactStore();
|
||||
const keyword = ref('');
|
||||
|
||||
@@ -24,15 +24,16 @@
|
||||
<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="">
|
||||
:key="item.resultKey"
|
||||
class="conversation-item"
|
||||
:class="{ active: isListItemActive(item), pinned: isPinned(item) }"
|
||||
@click="selectListItem(item)"
|
||||
@contextmenu.prevent="openConversationMenu($event, item)"
|
||||
>
|
||||
<img v-if="item.portrait" class="conversation-avatar image-avatar" :src="item.portrait" alt="" @error="useImageProxyFallback($event, item.portrait)">
|
||||
<span v-else class="conversation-avatar">{{ item.avatarText }}</span>
|
||||
<span class="conversation-copy">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<strong><span v-if="isPinned(item)" class="conversation-pin" aria-label="已置顶">⌖</span>{{ item.title }}</strong>
|
||||
<small>{{ item.subtitle }}</small>
|
||||
</span>
|
||||
<span
|
||||
@@ -42,7 +43,19 @@
|
||||
>
|
||||
{{ formatUnread(item.unread) }}
|
||||
</span>
|
||||
</button>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="conversationMenu.item"
|
||||
class="conversation-context-menu"
|
||||
:style="{ left: `${conversationMenu.x}px`, top: `${conversationMenu.y}px` }"
|
||||
@click.stop
|
||||
>
|
||||
<button type="button" @click="togglePinned">
|
||||
{{ isPinned(conversationMenu.item) ? '取消置顶' : '置顶会话' }}
|
||||
</button>
|
||||
<button type="button" @click="deleteConversation">删除会话</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!filteredConversationItems.length && searchKeyword" class="empty-list">
|
||||
<strong>没有匹配结果</strong>
|
||||
@@ -150,7 +163,8 @@
|
||||
<script setup>
|
||||
import { computed, defineComponent, h, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useConversationStore } from '@/stores/conversations';
|
||||
import { useContactStore } from '@/stores/contacts';
|
||||
import { useContactStore } from '@/stores/contacts';
|
||||
import { useImageProxyFallback } from '@/services/mediaProxy';
|
||||
|
||||
const conversations = useConversationStore();
|
||||
const contacts = useContactStore();
|
||||
@@ -167,7 +181,8 @@ const broadcastText = ref('');
|
||||
const channelName = ref('');
|
||||
const channelAnnouncement = ref('');
|
||||
const contactKeyword = ref('');
|
||||
const selectedContactIds = ref([]);
|
||||
const selectedContactIds = ref([]);
|
||||
const conversationMenu = ref({ item: null, x: 0, y: 0 });
|
||||
|
||||
const actionTitles = {
|
||||
addContact: '添加联系人',
|
||||
@@ -249,9 +264,41 @@ const submitDisabled = computed(() => {
|
||||
return true;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
contacts.refreshContacts().catch(() => {});
|
||||
});
|
||||
onMounted(() => {
|
||||
contacts.refreshContacts().catch(() => {});
|
||||
});
|
||||
|
||||
function isPinned(item) {
|
||||
return item?.resultKind === 'conversation' && Number(item.pinnedAt || item.top || 0) > 0;
|
||||
}
|
||||
|
||||
function openConversationMenu(event, item) {
|
||||
if (item.resultKind !== 'conversation') return;
|
||||
conversationMenu.value = {
|
||||
item,
|
||||
x: Math.min(event.clientX, window.innerWidth - 140),
|
||||
y: Math.min(event.clientY, window.innerHeight - 60)
|
||||
};
|
||||
}
|
||||
|
||||
function closeConversationMenu() {
|
||||
conversationMenu.value = { item: null, x: 0, y: 0 };
|
||||
}
|
||||
|
||||
function togglePinned() {
|
||||
const item = conversationMenu.value.item;
|
||||
if (item) conversations.togglePinnedConversation(item.id);
|
||||
closeConversationMenu();
|
||||
}
|
||||
|
||||
function deleteConversation() {
|
||||
const item = conversationMenu.value.item;
|
||||
if (item && window.confirm(`确定删除与“${item.title}”的会话吗?`)) {
|
||||
conversations.removeConversation(item.id);
|
||||
conversations.setActionNotice('会话已删除');
|
||||
}
|
||||
closeConversationMenu();
|
||||
}
|
||||
|
||||
const ContactPicker = defineComponent({
|
||||
name: 'ContactPicker',
|
||||
@@ -513,10 +560,12 @@ async function submitAction() {
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', closeActionMenu);
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', closeActionMenu);
|
||||
});
|
||||
document.addEventListener('click', closeActionMenu);
|
||||
document.addEventListener('click', closeConversationMenu);
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', closeActionMenu);
|
||||
document.removeEventListener('click', closeConversationMenu);
|
||||
});
|
||||
|
||||
function formatUnread(value) {
|
||||
const count = Number(value || 0);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<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="">
|
||||
<img v-if="session.portrait" :src="session.portrait" alt="" @error="useImageProxyFallback($event, session.portrait)">
|
||||
<span v-else>{{ initials }}</span>
|
||||
</button>
|
||||
<button class="rail-button" :class="{ active: navigation.section === 'messages' }" title="消息" @click="navigation.showMessages">💬</button>
|
||||
@@ -200,9 +200,11 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { useNavigationStore } from '@/stores/navigation';
|
||||
import { useContactStore } from '@/stores/contacts';
|
||||
import { useContactStore } from '@/stores/contacts';
|
||||
import { useAutoDismiss } from '@/composables/useAutoDismiss';
|
||||
import { useImageProxyFallback } from '@/services/mediaProxy';
|
||||
|
||||
const session = useSessionStore();
|
||||
const navigation = useNavigationStore();
|
||||
@@ -251,7 +253,7 @@ const avatarPreviewViewer = reactive({
|
||||
dragOffsetX: 0,
|
||||
dragOffsetY: 0
|
||||
});
|
||||
const avatarCropper = reactive({
|
||||
const avatarCropper = reactive({
|
||||
open: false,
|
||||
loaded: false,
|
||||
busy: false,
|
||||
@@ -268,8 +270,23 @@ const avatarCropper = reactive({
|
||||
dragStartX: 0,
|
||||
dragStartY: 0,
|
||||
dragOffsetX: 0,
|
||||
dragOffsetY: 0
|
||||
});
|
||||
dragOffsetY: 0
|
||||
});
|
||||
useAutoDismiss(() => settingsError.value || settingsNotice.value, value => {
|
||||
if ((settingsError.value || settingsNotice.value) === value) {
|
||||
settingsError.value = '';
|
||||
settingsNotice.value = '';
|
||||
}
|
||||
});
|
||||
useAutoDismiss(() => profileError.value || profileNotice.value, value => {
|
||||
if ((profileError.value || profileNotice.value) === value) {
|
||||
profileError.value = '';
|
||||
profileNotice.value = '';
|
||||
}
|
||||
});
|
||||
useAutoDismiss(() => avatarCropper.error, value => {
|
||||
if (avatarCropper.error === value) avatarCropper.error = '';
|
||||
});
|
||||
const avatarPreviewStyle = computed(() => ({
|
||||
transform: `translate3d(${avatarPreviewViewer.offsetX}px, ${avatarPreviewViewer.offsetY}px, 0) scale(${avatarPreviewViewer.scale})`
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { onBeforeUnmount, watch } from 'vue';
|
||||
|
||||
export function useAutoDismiss(source, clear, delay = 3000) {
|
||||
let timer = null;
|
||||
|
||||
watch(source, value => {
|
||||
window.clearTimeout(timer);
|
||||
timer = null;
|
||||
if (!value) return;
|
||||
const currentValue = value;
|
||||
timer = window.setTimeout(() => {
|
||||
clear(currentValue);
|
||||
timer = null;
|
||||
}, delay);
|
||||
}, { flush: 'post' });
|
||||
|
||||
onBeforeUnmount(() => window.clearTimeout(timer));
|
||||
}
|
||||
@@ -30,6 +30,9 @@ export const appConfig = {
|
||||
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),
|
||||
sseEnabled: runtimeConfig.sseEnabled ?? process.env.VUE_APP_SSE_ENABLED === 'true',
|
||||
sseTicketApi: runtimeConfig.sseTicketApi || process.env.VUE_APP_SSE_TICKET_API || '/api/sse_ticket.php',
|
||||
sseUrl: runtimeConfig.sseUrl || process.env.VUE_APP_SSE_URL || '/ws-events',
|
||||
platform: 5,
|
||||
clientIdStorageKey: 'qmt-web-im-client-id',
|
||||
authTokenStoragePrefix: 'qmt-web-im-auth-token-'
|
||||
|
||||
+178
-36
@@ -156,7 +156,13 @@ function mergeSyncedMessages(localMessages, remoteMessages, conversationId, user
|
||||
if (message.status === 'sent') {
|
||||
return !hasRemoteMatch(message) && now - Number(message.timestamp || 0) < 30000;
|
||||
}
|
||||
return message.status === 'sending' || message.status === 'failed';
|
||||
if (message.status === 'failed') {
|
||||
// Failed optimistic messages are only transient feedback. Keeping them in
|
||||
// localStorage forever makes an old server error reappear every time the
|
||||
// conversation is opened, even after later messages send successfully.
|
||||
return now - Number(message.timestamp || 0) < 3000;
|
||||
}
|
||||
return message.status === 'sending';
|
||||
});
|
||||
|
||||
return [...visibleRemoteMessages, ...pendingLocalMessages].sort((a, b) => {
|
||||
@@ -213,16 +219,14 @@ export class HttpImClient {
|
||||
return true;
|
||||
}
|
||||
|
||||
async loginWithPassword(mobile, password) {
|
||||
const url = `${appConfig.appServer}/login_pwd`;
|
||||
const deviceId = getUniqueEquipment(mobile, this.getClientId());
|
||||
const response = await axios.post(url, {
|
||||
mobile,
|
||||
password,
|
||||
platform: appConfig.platform,
|
||||
clientId: deviceId,
|
||||
uniqueEquipment: deviceId
|
||||
}, { withCredentials: false });
|
||||
async loginWithPassword(mobile, password) {
|
||||
const url = `${appConfig.appServer}/login_pwd`;
|
||||
const response = await axios.post(url, {
|
||||
mobile,
|
||||
password,
|
||||
platform: appConfig.platform,
|
||||
clientId: this.getClientId()
|
||||
}, { withCredentials: false });
|
||||
|
||||
this.persistAppToken(url, response);
|
||||
if (response.data?.code !== 0) {
|
||||
@@ -247,16 +251,14 @@ export class HttpImClient {
|
||||
return response.data.result;
|
||||
}
|
||||
|
||||
async loginWithCode(mobile, code) {
|
||||
const url = `${appConfig.appServer}/login`;
|
||||
const deviceId = getUniqueEquipment(mobile, this.getClientId());
|
||||
const response = await axios.post(url, {
|
||||
mobile,
|
||||
code,
|
||||
platform: appConfig.platform,
|
||||
clientId: deviceId,
|
||||
uniqueEquipment: deviceId
|
||||
}, { withCredentials: false });
|
||||
async loginWithCode(mobile, code) {
|
||||
const url = `${appConfig.appServer}/login`;
|
||||
const response = await axios.post(url, {
|
||||
mobile,
|
||||
code,
|
||||
platform: appConfig.platform,
|
||||
clientId: this.getClientId()
|
||||
}, { withCredentials: false });
|
||||
|
||||
this.persistAppToken(url, response);
|
||||
if (response.data?.code !== 0) {
|
||||
@@ -384,26 +386,144 @@ export class HttpImClient {
|
||||
const response = await axios.post(appConfig.messagesApi, {
|
||||
userId: this.currentUser.userId,
|
||||
target: conversation.target || conversation.raw?.target || conversation.id.replace(/^single-/, ''),
|
||||
limit,
|
||||
conversationType: conversation.type || conversation.raw?.type || 0,
|
||||
markRead: Boolean(options.markRead)
|
||||
limit,
|
||||
conversationType: conversation.type || conversation.raw?.type || 0,
|
||||
markRead: Boolean(options.markRead),
|
||||
includeConversations: Boolean(options.includeConversations)
|
||||
}, { withCredentials: false });
|
||||
|
||||
if (response.data?.code !== 0) {
|
||||
throw new Error(response.data?.message || '消息同步失败');
|
||||
}
|
||||
|
||||
const messages = (response.data?.result?.messages || []).map(message => ({
|
||||
const messages = (response.data?.result?.messages || []).map(message => ({
|
||||
...message,
|
||||
displayTimestamp: message.displayTimestamp || message.timestamp,
|
||||
conversationId: conversation.id
|
||||
}));
|
||||
conversationId: conversation.id
|
||||
}));
|
||||
messages.hasPinnedMessageState = Object.prototype.hasOwnProperty.call(response.data?.result || {}, 'pinnedMessageUid');
|
||||
messages.pinnedMessageUid = String(response.data?.result?.pinnedMessageUid || '');
|
||||
messages.pinnedMessage = response.data?.result?.pinnedMessage || null;
|
||||
if (options.includeConversations && Array.isArray(response.data?.result?.conversations)) {
|
||||
const remoteConversations = response.data.result.conversations.map(remoteConversation => ({
|
||||
...remoteConversation,
|
||||
updatedAt: Number(remoteConversation.updatedAt || Date.now()),
|
||||
avatarText: remoteConversation.avatarText || String(remoteConversation.title || remoteConversation.target || '?').slice(0, 1).toUpperCase(),
|
||||
raw: remoteConversation.raw || { type: 'single', target: remoteConversation.target, line: 0 }
|
||||
}));
|
||||
this.emit('conversationsChanged', remoteConversations);
|
||||
}
|
||||
const userId = this.currentUser?.userId || '';
|
||||
const messagesByConversation = readLocalMessages(userId);
|
||||
messagesByConversation[conversation.id] = mergeSyncedMessages(messagesByConversation[conversation.id] || [], messages, conversation.id, userId);
|
||||
const messagesByConversation = readLocalMessages(userId);
|
||||
messagesByConversation[conversation.id] = mergeSyncedMessages(messagesByConversation[conversation.id] || [], messages, conversation.id, userId);
|
||||
messagesByConversation[conversation.id].pinnedMessageUid = messages.pinnedMessageUid;
|
||||
messagesByConversation[conversation.id].pinnedMessage = messages.pinnedMessage;
|
||||
messagesByConversation[conversation.id].hasPinnedMessageState = messages.hasPinnedMessageState;
|
||||
writeLocalMessages(messagesByConversation, userId);
|
||||
return messagesByConversation[conversation.id];
|
||||
}
|
||||
return messagesByConversation[conversation.id];
|
||||
}
|
||||
|
||||
async setPinnedMessage(conversation, messageUid = '', message = null) {
|
||||
const isGroup = ['group', 'channel'].includes(conversation?.type)
|
||||
|| ['group', 'channel', 1, '1'].includes(conversation?.raw?.type);
|
||||
if (isGroup) {
|
||||
if (messageUid && message) {
|
||||
return this.addGroupPinnedMessage(conversation, message);
|
||||
}
|
||||
const serverPinId = String(message?.serverPinId || message?.pinId || '').trim();
|
||||
if (!serverPinId) {
|
||||
throw new Error('缺少置顶记录ID,请刷新会话后重试');
|
||||
}
|
||||
return this.removeGroupPinnedMessage(serverPinId);
|
||||
}
|
||||
const action = messageUid ? 'pin' : 'unpin';
|
||||
const response = await axios.post(appConfig.messageActionApi, {
|
||||
action,
|
||||
userId: this.currentUser?.userId || '',
|
||||
messageUid: messageUid || '1',
|
||||
target: conversation.target || conversation.raw?.target || '',
|
||||
conversationType: ['group', 'channel'].includes(conversation.type) ? 'group' : 'single',
|
||||
message: message ? {
|
||||
id: String(message.messageUid || message.id || messageUid),
|
||||
messageUid: String(message.messageUid || message.id || messageUid),
|
||||
text: String(message.text || ''),
|
||||
type: message.type || 'text',
|
||||
attachment: message.attachment || null,
|
||||
senderName: message.senderName || message.from || '',
|
||||
timestamp: Number(message.displayTimestamp || message.timestamp || Date.now())
|
||||
} : null
|
||||
}, { withCredentials: false });
|
||||
if (response.data?.code !== 0) throw new Error(response.data?.message || '置顶失败');
|
||||
}
|
||||
|
||||
async fetchGroupPinnedMessages(conversation) {
|
||||
const groupId = String(conversation?.target || conversation?.raw?.target || '').trim();
|
||||
if (!groupId) return [];
|
||||
const url = `${appConfig.appServer}/groupTopMessage/gets`;
|
||||
const response = await axios.get(url, {
|
||||
params: { groupId },
|
||||
headers: this.getAppAuthHeaders(url),
|
||||
withCredentials: false
|
||||
});
|
||||
const payload = response.data || {};
|
||||
if (payload.code !== undefined && Number(payload.code) !== 0) {
|
||||
throw new Error(payload.message || '置顶消息加载失败');
|
||||
}
|
||||
const result = payload.result ?? payload.data ?? payload;
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
|
||||
async addGroupPinnedMessage(conversation, message) {
|
||||
const groupId = String(conversation?.target || conversation?.raw?.target || '').trim();
|
||||
const messageId = String(message?.messageId || message?.messageUid || message?.id || '').trim();
|
||||
if (!groupId || !messageId) throw new Error('缺少群ID或消息ID');
|
||||
const searchableContent = String(message?.text || '').trim()
|
||||
|| (message?.type === 'image' ? '[图片]' : message?.type === 'video' ? '[视频]' : '[消息]');
|
||||
const rawConversationType = Number(conversation?.raw?.type);
|
||||
const conversationType = Number.isFinite(rawConversationType) && rawConversationType > 0
|
||||
? rawConversationType
|
||||
: 1;
|
||||
const url = `${appConfig.appServer}/groupTopMessage/add`;
|
||||
const response = await axios.post(url, {
|
||||
groupId,
|
||||
userId: message?.from || this.currentUser?.userId || '',
|
||||
messageId,
|
||||
messageType: conversationType,
|
||||
messageText: JSON.stringify({
|
||||
...message,
|
||||
messageId,
|
||||
from: message?.from || this.currentUser?.userId || '',
|
||||
conversation: {
|
||||
type: conversationType,
|
||||
target: groupId,
|
||||
line: Number(conversation?.raw?.line || 0)
|
||||
},
|
||||
content: { searchableContent }
|
||||
})
|
||||
}, {
|
||||
headers: this.getAppAuthHeaders(url),
|
||||
withCredentials: false
|
||||
});
|
||||
const payload = response.data || {};
|
||||
if (payload.code !== undefined && Number(payload.code) !== 0) {
|
||||
const detail = payload.message || payload.msg || payload.result?.message || '置顶失败';
|
||||
throw new Error(payload.code !== undefined ? `${detail}(错误码 ${payload.code})` : detail);
|
||||
}
|
||||
return payload.result ?? payload.data ?? payload;
|
||||
}
|
||||
|
||||
async removeGroupPinnedMessage(pinId) {
|
||||
const url = `${appConfig.appServer}/groupTopMessage/remove/?id=${encodeURIComponent(pinId)}`;
|
||||
const response = await axios.post(url, {}, {
|
||||
headers: this.getAppAuthHeaders(url),
|
||||
withCredentials: false
|
||||
});
|
||||
const payload = response.data || {};
|
||||
if (payload.code !== undefined && Number(payload.code) !== 0) {
|
||||
throw new Error(payload.message || '取消置顶失败');
|
||||
}
|
||||
return payload.result ?? payload.data ?? payload;
|
||||
}
|
||||
|
||||
hideMessages(conversation, messages) {
|
||||
const items = Array.isArray(messages) ? messages : [messages];
|
||||
@@ -510,7 +630,7 @@ export class HttpImClient {
|
||||
return response.data?.result?.detail || null;
|
||||
}
|
||||
|
||||
async sendTextMessage(conversation, text, initialMessage = null) {
|
||||
async sendTextMessage(conversation, text, initialMessage = null, options = {}) {
|
||||
const senderRole = conversation?.role || conversation?.raw?.role || '';
|
||||
const message = initialMessage || {
|
||||
id: randomId(),
|
||||
@@ -533,8 +653,9 @@ export class HttpImClient {
|
||||
const response = await axios.post(appConfig.sendMessageApi, {
|
||||
sender: this.currentUser?.userId || '',
|
||||
target: conversation.target || conversation.raw?.target || conversation.id.replace(/^single-/, ''),
|
||||
conversationType: conversation.type || conversation.raw?.type || 0,
|
||||
text
|
||||
conversationType: conversation.type || conversation.raw?.type || 0,
|
||||
text,
|
||||
mentionedUserIds: Array.isArray(options.mentionedUserIds) ? options.mentionedUserIds : []
|
||||
}, { withCredentials: false });
|
||||
|
||||
if (response.data?.code !== 0) {
|
||||
@@ -567,7 +688,7 @@ export class HttpImClient {
|
||||
}
|
||||
}
|
||||
|
||||
async sendAttachmentMessage(conversation, { type, file, text = '' }, initialMessage = null) {
|
||||
async sendAttachmentMessage(conversation, { type, file, text = '' }, initialMessage = null) {
|
||||
const senderRole = conversation?.role || conversation?.raw?.role || '';
|
||||
const message = initialMessage || {
|
||||
id: randomId(),
|
||||
@@ -635,7 +756,28 @@ export class HttpImClient {
|
||||
this.emit('messageStatusChanged', failedMessage);
|
||||
return failedMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async forwardAttachmentMessage(conversation, sourceMessage) {
|
||||
const attachment = sourceMessage?.attachment || {};
|
||||
const type = attachment.type || sourceMessage?.type || 'file';
|
||||
const formData = new FormData();
|
||||
formData.append('sender', this.currentUser?.userId || '');
|
||||
formData.append('target', conversation.target || conversation.raw?.target || conversation.id.replace(/^single-/, ''));
|
||||
formData.append('conversationType', conversation.type || conversation.raw?.type || 0);
|
||||
formData.append('messageType', type);
|
||||
formData.append('text', sourceMessage?.text || '');
|
||||
formData.append('attachmentUrl', attachment.url || '');
|
||||
formData.append('attachmentName', attachment.name || (type === 'image' ? '转发图片' : (type === 'video' ? '转发视频.mp4' : '转发文件')));
|
||||
formData.append('attachmentMime', attachment.mime || (type === 'video' ? 'video/mp4' : 'application/octet-stream'));
|
||||
formData.append('attachmentSize', String(attachment.size || 0));
|
||||
|
||||
const response = await axios.post(appConfig.sendMessageApi, formData, { withCredentials: false });
|
||||
if (response.data?.code !== 0) {
|
||||
throw new Error(response.data?.message || '转发失败');
|
||||
}
|
||||
return response.data?.result || {};
|
||||
}
|
||||
|
||||
getContactList() {
|
||||
return [];
|
||||
|
||||
@@ -2,18 +2,20 @@ 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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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();
|
||||
: appConfig.sdkMode === 'legacy-http'
|
||||
? new HttpImClient()
|
||||
: new DocumentedHttpImClient();
|
||||
|
||||
export {
|
||||
DocumentedHttpImClient,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
function configuredProxyHosts() {
|
||||
const configured = window.JC_WEB_IM_CONFIG?.mediaProxyHosts;
|
||||
const values = Array.isArray(configured)
|
||||
? configured
|
||||
: String(configured || '').split(/[\s,]+/);
|
||||
return new Set(
|
||||
['zhiyanim.dfpnm.cn', ...values]
|
||||
.map(value => String(value || '').trim().toLowerCase().replace(/\.$/, ''))
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
export function imageDisplayUrl(value) {
|
||||
const source = String(value || '').trim();
|
||||
if (!source || source.startsWith('blob:') || source.startsWith('data:')) {
|
||||
return source;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(source, window.location.origin);
|
||||
if (url.protocol !== 'https:' || !configuredProxyHosts().has(url.hostname.toLowerCase())) {
|
||||
return source;
|
||||
}
|
||||
const proxyUrl = new URL('/api/media_proxy.php', window.location.origin);
|
||||
proxyUrl.searchParams.set('url', url.toString());
|
||||
return proxyUrl.toString();
|
||||
} catch (error) {
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
export function videoDisplayUrl(value) {
|
||||
const source = String(value || '').trim();
|
||||
if (!source || source.startsWith('blob:') || source.startsWith('data:')) return source;
|
||||
const streamUrl = new URL('/api/media_stream.php', window.location.origin);
|
||||
streamUrl.searchParams.set('url', source);
|
||||
return streamUrl.toString();
|
||||
}
|
||||
|
||||
export function useVideoOriginalFallback(event, value) {
|
||||
const video = event?.currentTarget;
|
||||
const source = String(value || '').trim();
|
||||
if (!video || !source || video.dataset.originalFallback === '1') return;
|
||||
video.dataset.originalFallback = '1';
|
||||
video.src = source;
|
||||
video.load();
|
||||
}
|
||||
|
||||
export function useImageProxyFallback(event, value) {
|
||||
const image = event?.currentTarget;
|
||||
if (!image) return;
|
||||
const fallback = imageDisplayUrl(value);
|
||||
if (!fallback || fallback === value || image.dataset.proxyFallback === '1') return;
|
||||
image.dataset.proxyFallback = '1';
|
||||
image.src = fallback;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import axios from 'axios';
|
||||
import { appConfig } from '@/config';
|
||||
import { imClient } from '@/services/imClient';
|
||||
|
||||
class RealtimeNotificationClient {
|
||||
constructor() {
|
||||
this.source = null;
|
||||
this.userId = '';
|
||||
this.handler = null;
|
||||
this.reconnectTimer = null;
|
||||
this.reconnectAttempt = 0;
|
||||
this.generation = 0;
|
||||
}
|
||||
|
||||
isConnected() {
|
||||
return this.source?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
start(userId, handler) {
|
||||
this.stop();
|
||||
if (!appConfig.sseEnabled || !userId || typeof window.WebSocket !== 'function') return;
|
||||
this.userId = userId;
|
||||
this.handler = handler;
|
||||
this.generation += 1;
|
||||
this.open(this.generation);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.generation += 1;
|
||||
window.clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
if (this.source) this.source.close();
|
||||
this.source = null;
|
||||
this.userId = '';
|
||||
this.handler = null;
|
||||
this.reconnectAttempt = 0;
|
||||
}
|
||||
|
||||
async open(generation) {
|
||||
try {
|
||||
const response = await axios.post(appConfig.sseTicketApi, { userId: this.userId }, {
|
||||
withCredentials: false,
|
||||
headers: imClient.getAppAuthHeaders(appConfig.appServer)
|
||||
});
|
||||
if (generation !== this.generation) return;
|
||||
if (response.data?.code !== 0) throw new Error(response.data?.message || '实时通知凭证申请失败');
|
||||
const ticket = response.data?.result?.ticket || '';
|
||||
if (!ticket) throw new Error('实时通知凭证为空');
|
||||
const separator = appConfig.sseUrl.includes('?') ? '&' : '?';
|
||||
const endpoint = `${appConfig.sseUrl}${separator}ticket=${encodeURIComponent(ticket)}`;
|
||||
const socketUrl = endpoint.startsWith('ws://') || endpoint.startsWith('wss://')
|
||||
? endpoint
|
||||
: `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}${endpoint.startsWith('/') ? '' : '/'}${endpoint}`;
|
||||
const source = new WebSocket(socketUrl);
|
||||
this.source = source;
|
||||
source.onopen = () => {
|
||||
this.reconnectAttempt = 0;
|
||||
};
|
||||
source.onmessage = event => {
|
||||
if (generation !== this.generation) return;
|
||||
let payload = {};
|
||||
try { payload = JSON.parse(event.data || '{}'); } catch (error) { payload = {}; }
|
||||
if (payload.type !== 'message-notification') return;
|
||||
this.handler?.(payload);
|
||||
};
|
||||
source.onerror = () => {
|
||||
if (generation !== this.generation) return;
|
||||
source.close();
|
||||
if (this.source === source) this.source = null;
|
||||
this.scheduleReconnect(generation);
|
||||
};
|
||||
source.onclose = () => {
|
||||
if (generation !== this.generation || this.source !== source) return;
|
||||
this.source = null;
|
||||
this.scheduleReconnect(generation);
|
||||
};
|
||||
} catch (error) {
|
||||
if (generation === this.generation) this.scheduleReconnect(generation);
|
||||
}
|
||||
}
|
||||
|
||||
scheduleReconnect(generation) {
|
||||
window.clearTimeout(this.reconnectTimer);
|
||||
const delay = Math.min(30000, 1000 * (2 ** Math.min(this.reconnectAttempt, 5)));
|
||||
this.reconnectAttempt += 1;
|
||||
this.reconnectTimer = window.setTimeout(() => this.open(generation), delay);
|
||||
}
|
||||
}
|
||||
|
||||
export const realtimeNotifications = new RealtimeNotificationClient();
|
||||
+401
-89
@@ -1,10 +1,13 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { imClient } from '@/services/imClient';
|
||||
import { appConfig } from '@/config';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { appConfig } from '@/config';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { realtimeNotifications } from '@/services/realtimeNotifications';
|
||||
|
||||
const LOCAL_CONVERSATION_STORAGE_KEY = 'qmt-web-im-local-conversations-v2';
|
||||
const LOCAL_FAVORITE_MESSAGES_KEY = 'qmt-web-im-favorite-messages-v1';
|
||||
const LOCAL_FAVORITE_MESSAGES_KEY = 'qmt-web-im-favorite-messages-v1';
|
||||
const LOCAL_PINNED_MESSAGES_KEY = 'qmt-web-im-pinned-messages-v1';
|
||||
const LOCAL_HIDDEN_CONVERSATIONS_KEY = 'qmt-web-im-hidden-conversations-v1';
|
||||
|
||||
function scopedStorageKey(baseKey) {
|
||||
const session = useSessionStore();
|
||||
@@ -20,7 +23,7 @@ function readFavoriteMessages() {
|
||||
}
|
||||
}
|
||||
|
||||
function writeFavoriteMessages(messages) {
|
||||
function writeFavoriteMessages(messages) {
|
||||
localStorage.setItem(scopedStorageKey(LOCAL_FAVORITE_MESSAGES_KEY), JSON.stringify(messages));
|
||||
}
|
||||
|
||||
@@ -32,9 +35,80 @@ function readLocalConversations() {
|
||||
}
|
||||
}
|
||||
|
||||
function writeLocalConversations(conversations) {
|
||||
localStorage.setItem(scopedStorageKey(LOCAL_CONVERSATION_STORAGE_KEY), JSON.stringify(conversations));
|
||||
}
|
||||
function writeLocalConversations(conversations) {
|
||||
localStorage.setItem(scopedStorageKey(LOCAL_CONVERSATION_STORAGE_KEY), JSON.stringify(conversations));
|
||||
}
|
||||
|
||||
function readHiddenConversationIds() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(scopedStorageKey(LOCAL_HIDDEN_CONVERSATIONS_KEY)) || '[]');
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeHiddenConversationIds(ids) {
|
||||
localStorage.setItem(scopedStorageKey(LOCAL_HIDDEN_CONVERSATIONS_KEY), JSON.stringify(ids));
|
||||
}
|
||||
|
||||
function readPinnedMessages() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(scopedStorageKey(LOCAL_PINNED_MESSAGES_KEY)) || '{}');
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writePinnedMessages(messages) {
|
||||
localStorage.setItem(scopedStorageKey(LOCAL_PINNED_MESSAGES_KEY), JSON.stringify(messages));
|
||||
}
|
||||
|
||||
function parseNativePinnedMessage(item, messages = []) {
|
||||
if (!item) return null;
|
||||
const messageId = String(item.messageId || item.messageUid || '').trim();
|
||||
const matched = messages.find(message => [message.messageId, message.messageUid, message.id]
|
||||
.filter(Boolean)
|
||||
.some(value => String(value) === messageId));
|
||||
let encoded = {};
|
||||
try {
|
||||
encoded = typeof item.messageText === 'string' ? JSON.parse(item.messageText) : (item.messageText || {});
|
||||
} catch (error) {
|
||||
encoded = {};
|
||||
}
|
||||
const text = String(
|
||||
encoded?.content?.searchableContent
|
||||
|| encoded?.messageContent?.searchableContent
|
||||
|| encoded?.text
|
||||
|| matched?.text
|
||||
|| '[消息]'
|
||||
);
|
||||
return {
|
||||
...(matched || {}),
|
||||
key: messageId || `group-pin-${item.id}`,
|
||||
id: messageId || `group-pin-${item.id}`,
|
||||
messageUid: messageId || matched?.messageUid || '',
|
||||
serverPinId: String(item.id || ''),
|
||||
text,
|
||||
type: matched?.type || encoded?.type || 'text',
|
||||
attachment: matched?.attachment || encoded?.attachment || null,
|
||||
senderName: matched?.senderName || encoded?.senderName || encoded?.from || '',
|
||||
timestamp: Number(matched?.displayTimestamp || matched?.timestamp || item.updateTime || item.createTime || Date.now())
|
||||
};
|
||||
}
|
||||
|
||||
function sortConversations(conversations) {
|
||||
return [...conversations].sort((left, right) => {
|
||||
const leftPinned = Number(left.pinnedAt || left.top || 0);
|
||||
const rightPinned = Number(right.pinnedAt || right.top || 0);
|
||||
if (Boolean(leftPinned) !== Boolean(rightPinned)) {
|
||||
return rightPinned ? 1 : -1;
|
||||
}
|
||||
if (leftPinned && rightPinned && leftPinned !== rightPinned) {
|
||||
return rightPinned - leftPinned;
|
||||
}
|
||||
return Number(right.updatedAt || 0) - Number(left.updatedAt || 0);
|
||||
});
|
||||
}
|
||||
|
||||
function randomId() {
|
||||
if (window.crypto && window.crypto.randomUUID) {
|
||||
@@ -78,7 +152,7 @@ function messagePreviewText(message) {
|
||||
return text || attachmentLabel(message);
|
||||
}
|
||||
|
||||
function quotePrefix(message) {
|
||||
function quotePrefix(message) {
|
||||
const text = messagePreviewText(message);
|
||||
return text.length > 48 ? `${text.slice(0, 48)}...` : text;
|
||||
}
|
||||
@@ -125,7 +199,7 @@ function shouldUseMessagePolling() {
|
||||
return imClient.shouldUseMessagePolling ? imClient.shouldUseMessagePolling() : true;
|
||||
}
|
||||
|
||||
function canMarkReadNow(options = {}) {
|
||||
function canMarkReadNow(options = {}) {
|
||||
if (typeof document === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
@@ -135,12 +209,42 @@ function canMarkReadNow(options = {}) {
|
||||
if (options.userAction) {
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function mergeVisibleMessages(currentMessages, syncedMessages) {
|
||||
const merged = [...syncedMessages];
|
||||
currentMessages.forEach(currentMessage => {
|
||||
return true;
|
||||
}
|
||||
|
||||
function forwardedText(message) {
|
||||
return String(message?.text || '')
|
||||
.replace(/^转发\s*[::]\s*/u, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeMessageForDisplay(message) {
|
||||
if (!message || message.attachment || String(message.text || '').trim()) {
|
||||
return message;
|
||||
}
|
||||
const contentType = Number(message.contentType || 0);
|
||||
const labels = {
|
||||
3: '[图片加载失败]',
|
||||
6: '[视频加载失败]',
|
||||
90: '[红包]',
|
||||
92: '以上是打招呼的内容',
|
||||
93: '你们已经是好友了,可以开始聊天了。',
|
||||
105: '[领取了红包]'
|
||||
};
|
||||
if (!labels[contentType]) {
|
||||
return message;
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
type: 'text',
|
||||
text: labels[contentType]
|
||||
};
|
||||
}
|
||||
|
||||
function mergeVisibleMessages(currentMessages, syncedMessages) {
|
||||
const merged = [...syncedMessages].map(normalizeMessageForDisplay);
|
||||
currentMessages.forEach(currentMessage => {
|
||||
currentMessage = normalizeMessageForDisplay(currentMessage);
|
||||
const exists = merged.some(message => isSameMessage(message, currentMessage));
|
||||
const shouldKeepLocal = currentMessage.status === 'sending'
|
||||
|| currentMessage.status === 'failed'
|
||||
@@ -245,10 +349,15 @@ export const useConversationStore = defineStore('conversations', {
|
||||
attentionMessageTimer: null,
|
||||
selectionMode: false,
|
||||
selectedMessageIds: [],
|
||||
favoriteMessages: [],
|
||||
favoriteMessages: [],
|
||||
pinnedMessagesByConversation: {},
|
||||
groupPinnedSyncAt: {},
|
||||
hiddenConversationIds: [],
|
||||
activeFavoriteKey: '',
|
||||
actionNotice: '',
|
||||
actionError: ''
|
||||
actionNotice: '',
|
||||
actionError: '',
|
||||
actionNoticeTimer: null,
|
||||
realtimeSyncTimer: null
|
||||
}),
|
||||
getters: {
|
||||
activeConversation(state) {
|
||||
@@ -273,11 +382,18 @@ export const useConversationStore = defineStore('conversations', {
|
||||
favoriteMessageIds(state) {
|
||||
return state.favoriteMessages.map(message => message.key);
|
||||
},
|
||||
activeFavorite(state) {
|
||||
return state.favoriteMessages.find(message => message.key === state.activeFavoriteKey)
|
||||
|| state.favoriteMessages[0]
|
||||
|| null;
|
||||
},
|
||||
activeFavorite(state) {
|
||||
return state.favoriteMessages.find(message => message.key === state.activeFavoriteKey)
|
||||
|| state.favoriteMessages[0]
|
||||
|| null;
|
||||
},
|
||||
activePinnedMessage(state) {
|
||||
const pinned = state.pinnedMessagesByConversation[state.activeId] || null;
|
||||
if (!pinned) return null;
|
||||
const message = (state.visibleMessagesByConversation[state.activeId] || [])
|
||||
.find(item => messageStableKey(item) === pinned.key);
|
||||
return message ? { ...pinned, ...message, key: pinned.key } : pinned;
|
||||
},
|
||||
selectedMessages(state) {
|
||||
const selectedIds = new Set(state.selectedMessageIds);
|
||||
return (state.visibleMessagesByConversation[state.activeId] || [])
|
||||
@@ -295,10 +411,14 @@ export const useConversationStore = defineStore('conversations', {
|
||||
}
|
||||
this.loadScopedLocalState();
|
||||
this.startMessageSync();
|
||||
realtimeNotifications.start(session.userId, payload => this.handleRealtimeNotification(payload));
|
||||
},
|
||||
loadScopedLocalState() {
|
||||
this.conversations = [];
|
||||
this.favoriteMessages = readFavoriteMessages();
|
||||
this.pinnedMessagesByConversation = readPinnedMessages();
|
||||
this.groupPinnedSyncAt = {};
|
||||
this.hiddenConversationIds = readHiddenConversationIds();
|
||||
this.activeFavoriteKey = this.favoriteMessages[0]?.key || '';
|
||||
this.activeId = '';
|
||||
this.messagesByConversation = {};
|
||||
@@ -319,12 +439,17 @@ export const useConversationStore = defineStore('conversations', {
|
||||
clearUserState() {
|
||||
window.clearInterval(this.syncTimer);
|
||||
window.clearInterval(this.conversationSyncTimer);
|
||||
window.clearTimeout(this.syncAfterSendTimer);
|
||||
window.clearTimeout(this.attentionMessageTimer);
|
||||
window.clearTimeout(this.syncAfterSendTimer);
|
||||
window.clearTimeout(this.attentionMessageTimer);
|
||||
window.clearTimeout(this.actionNoticeTimer);
|
||||
window.clearTimeout(this.realtimeSyncTimer);
|
||||
realtimeNotifications.stop();
|
||||
this.syncTimer = null;
|
||||
this.conversationSyncTimer = null;
|
||||
this.syncAfterSendTimer = null;
|
||||
this.attentionMessageTimer = null;
|
||||
this.attentionMessageTimer = null;
|
||||
this.actionNoticeTimer = null;
|
||||
this.realtimeSyncTimer = null;
|
||||
this.syncInFlight = false;
|
||||
this.conversationSyncInFlight = false;
|
||||
this.pendingReadReceiptConversationId = '';
|
||||
@@ -345,7 +470,8 @@ export const useConversationStore = defineStore('conversations', {
|
||||
this.attentionMessageId = '';
|
||||
this.selectionMode = false;
|
||||
this.selectedMessageIds = [];
|
||||
this.favoriteMessages = [];
|
||||
this.favoriteMessages = [];
|
||||
this.hiddenConversationIds = [];
|
||||
this.activeFavoriteKey = '';
|
||||
this.actionNotice = '';
|
||||
this.actionError = '';
|
||||
@@ -358,10 +484,10 @@ export const useConversationStore = defineStore('conversations', {
|
||||
imClient.on('messageSent', message => {
|
||||
this.appendMessage(message.conversationId, message);
|
||||
});
|
||||
imClient.on('messageReceived', message => {
|
||||
this.appendMessage(message.conversationId, message);
|
||||
if (message.conversationId === this.activeId) {
|
||||
this.syncActiveMessages({ allowReadReceipt: true });
|
||||
imClient.on('messageReceived', message => {
|
||||
this.appendMessage(message.conversationId, message);
|
||||
if (message.conversationId === this.activeId) {
|
||||
this.scheduleActiveMessagesSync({ allowReadReceipt: true }, 500);
|
||||
}
|
||||
});
|
||||
imClient.on('messageStatusChanged', message => {
|
||||
@@ -380,9 +506,11 @@ export const useConversationStore = defineStore('conversations', {
|
||||
}
|
||||
this.loadActiveMessages({ allowReadReceipt: true, userAction: true });
|
||||
},
|
||||
sendText(text) {
|
||||
const conversation = this.activeConversation;
|
||||
if (!conversation || !text.trim()) {
|
||||
sendText(payload) {
|
||||
const conversation = this.activeConversation;
|
||||
const text = typeof payload === 'string' ? payload : String(payload?.text || '');
|
||||
const mentionedUserIds = Array.isArray(payload?.mentionedUserIds) ? payload.mentionedUserIds : [];
|
||||
if (!conversation || !text.trim()) {
|
||||
return;
|
||||
}
|
||||
let value = text.trim();
|
||||
@@ -404,7 +532,7 @@ export const useConversationStore = defineStore('conversations', {
|
||||
};
|
||||
this.appendVisibleMessage(conversation.id, message);
|
||||
this.appendMessage(conversation.id, message);
|
||||
imClient.sendTextMessage(conversation, value, message);
|
||||
imClient.sendTextMessage(conversation, value, message, { mentionedUserIds });
|
||||
conversation.subtitle = value;
|
||||
conversation.updatedAt = Date.now();
|
||||
writeLocalConversations(this.conversations);
|
||||
@@ -486,8 +614,12 @@ export const useConversationStore = defineStore('conversations', {
|
||||
this.usingDemoData = false;
|
||||
writeLocalConversations(this.conversations);
|
||||
},
|
||||
removeConversation(conversationId) {
|
||||
this.conversations = this.conversations.filter(conversation => conversation.id !== conversationId);
|
||||
removeConversation(conversationId) {
|
||||
if (!this.hiddenConversationIds.includes(conversationId)) {
|
||||
this.hiddenConversationIds = [...this.hiddenConversationIds, conversationId];
|
||||
writeHiddenConversationIds(this.hiddenConversationIds);
|
||||
}
|
||||
this.conversations = this.conversations.filter(conversation => conversation.id !== conversationId);
|
||||
const {
|
||||
[conversationId]: removedMessages,
|
||||
...messagesByConversation
|
||||
@@ -514,8 +646,10 @@ export const useConversationStore = defineStore('conversations', {
|
||||
this.loadActiveMessages();
|
||||
}
|
||||
},
|
||||
applyRemoteConversations(remoteConversations) {
|
||||
const remote = Array.isArray(remoteConversations) ? remoteConversations : [];
|
||||
applyRemoteConversations(remoteConversations) {
|
||||
const hidden = new Set(this.hiddenConversationIds);
|
||||
const remote = (Array.isArray(remoteConversations) ? remoteConversations : [])
|
||||
.filter(conversation => !hidden.has(conversation.id));
|
||||
if (!remote.length && this.conversations.length) {
|
||||
return;
|
||||
}
|
||||
@@ -533,9 +667,8 @@ export const useConversationStore = defineStore('conversations', {
|
||||
});
|
||||
});
|
||||
|
||||
this.conversations = Array.from(merged.values())
|
||||
.sort((a, b) => Number(b.updatedAt || 0) - Number(a.updatedAt || 0))
|
||||
.slice(0, 100);
|
||||
this.conversations = sortConversations(Array.from(merged.values()))
|
||||
.slice(0, 100);
|
||||
|
||||
if (!this.conversations.length) {
|
||||
this.activeId = '';
|
||||
@@ -549,10 +682,22 @@ export const useConversationStore = defineStore('conversations', {
|
||||
if (!this.conversations.find(item => item.id === this.activeId)) {
|
||||
this.activeId = this.conversations[0].id;
|
||||
}
|
||||
writeLocalConversations(this.conversations);
|
||||
this.loadActiveMessages();
|
||||
},
|
||||
appendMessage(conversationId, message) {
|
||||
writeLocalConversations(this.conversations);
|
||||
this.loadActiveMessages();
|
||||
},
|
||||
togglePinnedConversation(conversationId) {
|
||||
const conversation = this.conversations.find(item => item.id === conversationId);
|
||||
if (!conversation) return;
|
||||
conversation.pinnedAt = Number(conversation.pinnedAt || conversation.top || 0) > 0 ? 0 : Date.now();
|
||||
conversation.top = conversation.pinnedAt;
|
||||
this.conversations = sortConversations(this.conversations);
|
||||
writeLocalConversations(this.conversations);
|
||||
},
|
||||
appendMessage(conversationId, message) {
|
||||
if (message?.direction === 'in' && this.hiddenConversationIds.includes(conversationId)) {
|
||||
this.hiddenConversationIds = this.hiddenConversationIds.filter(id => id !== conversationId);
|
||||
writeHiddenConversationIds(this.hiddenConversationIds);
|
||||
}
|
||||
if (!this.messagesByConversation[conversationId]) {
|
||||
this.messagesByConversation = {
|
||||
...this.messagesByConversation,
|
||||
@@ -731,19 +876,66 @@ export const useConversationStore = defineStore('conversations', {
|
||||
const pollingReadReceipt = canMarkReadNow();
|
||||
const markRead = explicitReadReceipt || pollingReadReceipt;
|
||||
|
||||
if (this.syncInFlight) {
|
||||
if (this.syncInFlight) {
|
||||
if (wantsReadReceipt) {
|
||||
this.pendingReadReceiptConversationId = conversation.id;
|
||||
}
|
||||
return;
|
||||
return;
|
||||
}
|
||||
this.syncInFlight = true;
|
||||
const syncedConversationId = conversation.id;
|
||||
const previousMessages = this.messagesByConversation[syncedConversationId] || [];
|
||||
try {
|
||||
const messages = await imClient.fetchRemoteMessages(conversation, 50, {
|
||||
markRead
|
||||
});
|
||||
const messages = await imClient.fetchRemoteMessages(conversation, 1000, {
|
||||
markRead,
|
||||
includeConversations: Boolean(options.includeConversations)
|
||||
});
|
||||
const isGroupConversation = ['group', 'channel'].includes(conversation.type)
|
||||
|| ['group', 'channel', 1, '1'].includes(conversation.raw?.type);
|
||||
const shouldSyncGroupPin = isGroupConversation
|
||||
&& Date.now() - Number(this.groupPinnedSyncAt[syncedConversationId] || 0) >= 3000;
|
||||
if (shouldSyncGroupPin) {
|
||||
try {
|
||||
const nativePins = await imClient.fetchGroupPinnedMessages(conversation);
|
||||
this.groupPinnedSyncAt = {
|
||||
...this.groupPinnedSyncAt,
|
||||
[syncedConversationId]: Date.now()
|
||||
};
|
||||
const nextPinned = { ...this.pinnedMessagesByConversation };
|
||||
if (nativePins.length) {
|
||||
nextPinned[syncedConversationId] = parseNativePinnedMessage(nativePins[0], messages);
|
||||
} else {
|
||||
delete nextPinned[syncedConversationId];
|
||||
}
|
||||
this.pinnedMessagesByConversation = nextPinned;
|
||||
writePinnedMessages(nextPinned);
|
||||
} catch (error) {
|
||||
console.warn('群置顶消息同步失败', error);
|
||||
}
|
||||
}
|
||||
const remotePinnedKey = String(messages.pinnedMessageUid || '');
|
||||
if (!isGroupConversation && remotePinnedKey) {
|
||||
const pinnedMessage = messages.find(item => messageStableKey(item) === remotePinnedKey)
|
||||
|| messages.pinnedMessage
|
||||
|| (this.pinnedMessagesByConversation[syncedConversationId]?.key === remotePinnedKey
|
||||
? this.pinnedMessagesByConversation[syncedConversationId]
|
||||
: null);
|
||||
this.pinnedMessagesByConversation = {
|
||||
...this.pinnedMessagesByConversation,
|
||||
[syncedConversationId]: pinnedMessage
|
||||
? {
|
||||
key: remotePinnedKey,
|
||||
id: remotePinnedKey,
|
||||
text: messagePreviewText(pinnedMessage),
|
||||
type: pinnedMessage.type || 'text',
|
||||
attachment: pinnedMessage.attachment ? { ...pinnedMessage.attachment } : null,
|
||||
senderName: pinnedMessage.senderName || pinnedMessage.from || '',
|
||||
timestamp: Number(pinnedMessage.displayTimestamp || pinnedMessage.timestamp || Date.now())
|
||||
}
|
||||
: { key: remotePinnedKey, id: remotePinnedKey, text: '[置顶消息]' }
|
||||
};
|
||||
writePinnedMessages(this.pinnedMessagesByConversation);
|
||||
}
|
||||
const editedIncomingMessages = incomingEditedMessages(previousMessages, messages);
|
||||
this.pruneOptimisticMessages(syncedConversationId, messages);
|
||||
this.messagesByConversation = {
|
||||
@@ -774,30 +966,55 @@ export const useConversationStore = defineStore('conversations', {
|
||||
this.pendingReadReceiptConversationId = '';
|
||||
window.setTimeout(() => this.syncActiveMessages({ allowReadReceipt: true }), 0);
|
||||
}
|
||||
}
|
||||
},
|
||||
startMessageSync() {
|
||||
this.bindReadPresenceEvents();
|
||||
if (!this.syncTimer) {
|
||||
this.syncTimer = window.setInterval(() => {
|
||||
this.syncActiveMessages();
|
||||
}, Math.max(250, appConfig.syncIntervalMs));
|
||||
}
|
||||
if (!this.conversationSyncTimer) {
|
||||
this.conversationSyncTimer = window.setInterval(() => {
|
||||
this.refreshRemoteConversations();
|
||||
}, 2000);
|
||||
this.refreshRemoteConversations();
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
scheduleActiveMessagesSync(options = {}, delay = 500) {
|
||||
if (!this.activeId || (typeof document !== 'undefined' && document.visibilityState !== 'visible')) {
|
||||
return;
|
||||
}
|
||||
window.clearTimeout(this.syncAfterSendTimer);
|
||||
this.syncAfterSendTimer = window.setTimeout(() => {
|
||||
this.syncAfterSendTimer = null;
|
||||
this.syncActiveMessages(options);
|
||||
}, Math.max(0, Number(delay) || 0));
|
||||
},
|
||||
startMessageSync() {
|
||||
this.bindReadPresenceEvents();
|
||||
if (!this.syncTimer) {
|
||||
this.syncTimer = window.setInterval(() => {
|
||||
if (document.visibilityState === 'visible' && !realtimeNotifications.isConnected()) {
|
||||
this.syncActiveMessages();
|
||||
}
|
||||
}, Math.max(250, appConfig.syncIntervalMs));
|
||||
}
|
||||
if (!this.conversationSyncTimer) {
|
||||
this.conversationSyncTimer = window.setInterval(() => {
|
||||
if (document.visibilityState === 'visible' && !realtimeNotifications.isConnected()) {
|
||||
this.refreshRemoteConversations();
|
||||
}
|
||||
}, Math.max(5000, appConfig.syncIntervalMs * 2));
|
||||
this.refreshRemoteConversations();
|
||||
}
|
||||
},
|
||||
handleRealtimeNotification() {
|
||||
window.clearTimeout(this.realtimeSyncTimer);
|
||||
this.realtimeSyncTimer = window.setTimeout(() => {
|
||||
this.refreshRemoteConversations();
|
||||
this.scheduleActiveMessagesSync({
|
||||
allowReadReceipt: canMarkReadNow(),
|
||||
includeConversations: true
|
||||
}, 300);
|
||||
}, 300);
|
||||
},
|
||||
bindReadPresenceEvents() {
|
||||
if (this.readPresenceEventsBound || typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
this.readPresenceEventsBound = true;
|
||||
const syncWhenReadable = () => {
|
||||
if (canMarkReadNow()) {
|
||||
this.syncActiveMessages({ allowReadReceipt: true });
|
||||
const syncWhenReadable = () => {
|
||||
if (canMarkReadNow()) {
|
||||
this.refreshRemoteConversations();
|
||||
this.scheduleActiveMessagesSync({ allowReadReceipt: true }, 200);
|
||||
}
|
||||
};
|
||||
window.addEventListener('focus', syncWhenReadable);
|
||||
@@ -822,10 +1039,20 @@ export const useConversationStore = defineStore('conversations', {
|
||||
setActionNotice(message) {
|
||||
this.actionNotice = message;
|
||||
this.actionError = '';
|
||||
window.clearTimeout(this.actionNoticeTimer);
|
||||
this.actionNoticeTimer = window.setTimeout(() => {
|
||||
this.actionNotice = '';
|
||||
this.actionNoticeTimer = null;
|
||||
}, 3000);
|
||||
},
|
||||
setActionError(message) {
|
||||
this.actionError = message;
|
||||
this.actionNotice = '';
|
||||
window.clearTimeout(this.actionNoticeTimer);
|
||||
this.actionNoticeTimer = window.setTimeout(() => {
|
||||
this.actionError = '';
|
||||
this.actionNoticeTimer = null;
|
||||
}, 3000);
|
||||
},
|
||||
focusMessage(message) {
|
||||
const key = messageStableKey(message);
|
||||
@@ -852,8 +1079,10 @@ export const useConversationStore = defineStore('conversations', {
|
||||
this.deleteMessages([message]);
|
||||
} else if (action === 'forward') {
|
||||
this.startForward([message]);
|
||||
} else if (action === 'favorite') {
|
||||
this.toggleFavorite(message);
|
||||
} else if (action === 'favorite') {
|
||||
this.toggleFavorite(message);
|
||||
} else if (action === 'pin') {
|
||||
this.togglePinnedMessage(message);
|
||||
} else if (action === 'detail') {
|
||||
await this.openMessageDetail(message);
|
||||
} else if (action === 'quote') {
|
||||
@@ -928,7 +1157,7 @@ export const useConversationStore = defineStore('conversations', {
|
||||
}
|
||||
this.setActionNotice(messages.length > 1 ? '已删除所选消息' : '已删除');
|
||||
},
|
||||
toggleFavorite(message) {
|
||||
toggleFavorite(message) {
|
||||
const key = messageStableKey(message);
|
||||
if (!key) {
|
||||
return;
|
||||
@@ -962,8 +1191,70 @@ export const useConversationStore = defineStore('conversations', {
|
||||
this.setActionNotice('已收藏');
|
||||
}
|
||||
this.favoriteMessages = this.favoriteMessages.slice(0, 200);
|
||||
writeFavoriteMessages(this.favoriteMessages);
|
||||
},
|
||||
writeFavoriteMessages(this.favoriteMessages);
|
||||
},
|
||||
async togglePinnedMessage(message) {
|
||||
const conversationId = message?.conversationId || this.activeId;
|
||||
const key = messageStableKey(message);
|
||||
if (!conversationId || !key) return;
|
||||
const current = this.pinnedMessagesByConversation[conversationId];
|
||||
const next = { ...this.pinnedMessagesByConversation };
|
||||
if (current && current.key === key) {
|
||||
delete next[conversationId];
|
||||
this.pinnedMessagesByConversation = next;
|
||||
writePinnedMessages(next);
|
||||
this.setActionNotice('已取消置顶');
|
||||
try {
|
||||
await imClient.setPinnedMessage(this.activeConversation, '', current);
|
||||
} catch (error) {
|
||||
this.setActionError(`已在本地取消,服务器同步失败:${error.message || '取消置顶失败'}`);
|
||||
}
|
||||
} else {
|
||||
next[conversationId] = {
|
||||
key,
|
||||
id: key,
|
||||
text: messagePreviewText(message),
|
||||
type: message.type || 'text',
|
||||
attachment: message.attachment ? { ...message.attachment } : null,
|
||||
senderName: message.senderName || message.from || '',
|
||||
timestamp: Number(message.displayTimestamp || message.timestamp || Date.now())
|
||||
};
|
||||
this.pinnedMessagesByConversation = next;
|
||||
writePinnedMessages(next);
|
||||
this.setActionNotice('消息已置顶');
|
||||
try {
|
||||
await imClient.setPinnedMessage(this.activeConversation, key, message);
|
||||
const nativePins = await imClient.fetchGroupPinnedMessages(this.activeConversation).catch(() => []);
|
||||
if (nativePins.length) {
|
||||
const refreshed = parseNativePinnedMessage(nativePins[0], this.activeMessages);
|
||||
this.pinnedMessagesByConversation = {
|
||||
...this.pinnedMessagesByConversation,
|
||||
[conversationId]: refreshed
|
||||
};
|
||||
writePinnedMessages(this.pinnedMessagesByConversation);
|
||||
}
|
||||
} catch (error) {
|
||||
const rollback = { ...this.pinnedMessagesByConversation };
|
||||
delete rollback[conversationId];
|
||||
this.pinnedMessagesByConversation = rollback;
|
||||
writePinnedMessages(rollback);
|
||||
this.setActionError(`置顶失败:${error.message || '服务器同步失败'}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
async clearPinnedMessage() {
|
||||
const current = this.pinnedMessagesByConversation[this.activeId] || null;
|
||||
const next = { ...this.pinnedMessagesByConversation };
|
||||
delete next[this.activeId];
|
||||
this.pinnedMessagesByConversation = next;
|
||||
writePinnedMessages(next);
|
||||
this.setActionNotice('已取消置顶');
|
||||
try {
|
||||
await imClient.setPinnedMessage(this.activeConversation, '', current);
|
||||
} catch (error) {
|
||||
this.setActionError(`已在本地取消,服务器同步失败:${error.message || '取消置顶失败'}`);
|
||||
}
|
||||
},
|
||||
setActiveFavorite(key) {
|
||||
this.activeFavoriteKey = String(key || '');
|
||||
},
|
||||
@@ -1019,8 +1310,8 @@ export const useConversationStore = defineStore('conversations', {
|
||||
deleteSelectedMessages() {
|
||||
this.deleteMessages(this.selectedMessages);
|
||||
},
|
||||
startForward(messages) {
|
||||
this.forwardingMessages = messages.filter(message => message?.text);
|
||||
startForward(messages) {
|
||||
this.forwardingMessages = messages.filter(message => message?.text || message?.attachment?.url);
|
||||
if (!this.forwardingMessages.length) {
|
||||
this.setActionError('没有可转发的消息');
|
||||
}
|
||||
@@ -1083,22 +1374,43 @@ export const useConversationStore = defineStore('conversations', {
|
||||
this.setActionError(error.message || '编辑失败');
|
||||
}
|
||||
},
|
||||
async completeForward(targetConversationId) {
|
||||
async completeForward(targetConversationId) {
|
||||
const target = this.conversations.find(conversation => conversation.id === targetConversationId);
|
||||
if (!target || !this.forwardingMessages.length) {
|
||||
return;
|
||||
}
|
||||
const text = this.forwardingMessages
|
||||
.map(message => `转发:${message.text || ''}`)
|
||||
.join('\n\n');
|
||||
try {
|
||||
await this.sendTextToConversation(target, text);
|
||||
this.forwardingMessages = [];
|
||||
this.clearSelection();
|
||||
this.setActionNotice('已转发');
|
||||
} catch (error) {
|
||||
this.setActionError(error.message || '转发失败');
|
||||
}
|
||||
const messages = [...this.forwardingMessages];
|
||||
let successCount = 0;
|
||||
const errors = [];
|
||||
for (const message of messages) {
|
||||
try {
|
||||
if (message.attachment?.url) {
|
||||
await imClient.forwardAttachmentMessage(target, message);
|
||||
} else if (message.text) {
|
||||
const text = forwardedText(message);
|
||||
if (!text) {
|
||||
throw new Error('消息内容为空');
|
||||
}
|
||||
const sentMessage = await this.sendTextToConversation(target, text);
|
||||
if (sentMessage?.status === 'failed') {
|
||||
throw new Error(sentMessage.error || '转发失败');
|
||||
}
|
||||
}
|
||||
successCount += 1;
|
||||
} catch (error) {
|
||||
errors.push(error.message || '转发失败');
|
||||
}
|
||||
}
|
||||
if (successCount > 0) {
|
||||
this.forwardingMessages = [];
|
||||
this.clearSelection();
|
||||
this.refreshRemoteConversations();
|
||||
}
|
||||
if (errors.length) {
|
||||
this.setActionError(`成功 ${successCount} 条,失败 ${errors.length} 条:${errors[0]}`);
|
||||
} else {
|
||||
this.setActionNotice(`已转发 ${successCount} 条`);
|
||||
}
|
||||
},
|
||||
async recallMessage(message) {
|
||||
const conversation = this.activeConversation;
|
||||
|
||||
+309
-61
@@ -1249,10 +1249,48 @@ button {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.conversation-item.active,
|
||||
.conversation-item:hover {
|
||||
background: #eaf3f1;
|
||||
}
|
||||
.conversation-item.active,
|
||||
.conversation-item:hover {
|
||||
background: #eaf3f1;
|
||||
}
|
||||
|
||||
.conversation-item.pinned {
|
||||
background: rgba(15, 118, 110, 0.055);
|
||||
}
|
||||
|
||||
.conversation-pin {
|
||||
display: inline-block;
|
||||
margin-right: 5px;
|
||||
color: #0f766e;
|
||||
font-size: 12px;
|
||||
transform: rotate(-25deg);
|
||||
}
|
||||
|
||||
.conversation-context-menu {
|
||||
position: fixed;
|
||||
z-index: 1200;
|
||||
min-width: 124px;
|
||||
padding: 6px;
|
||||
border: 1px solid #d7e0e8;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.conversation-context-menu button {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #1f2937;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.conversation-context-menu button:hover {
|
||||
background: #eef7f6;
|
||||
}
|
||||
|
||||
.favorite-item {
|
||||
width: 100%;
|
||||
@@ -1465,7 +1503,7 @@ button {
|
||||
box-shadow: 0 0 0 2px #f7fafc;
|
||||
}
|
||||
|
||||
.chat-panel {
|
||||
.chat-panel {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
@@ -1473,8 +1511,12 @@ button {
|
||||
display: grid;
|
||||
grid-template-rows: 72px minmax(0, 1fr) auto auto;
|
||||
overflow: hidden;
|
||||
background: #eef2f5;
|
||||
}
|
||||
background: #eef2f5;
|
||||
}
|
||||
|
||||
.chat-panel.has-pinned-message {
|
||||
grid-template-rows: 72px auto minmax(0, 1fr) auto auto;
|
||||
}
|
||||
|
||||
.contact-panel {
|
||||
min-width: 0;
|
||||
@@ -2252,7 +2294,7 @@ button {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.message-row.out .message-bubble {
|
||||
.message-row.out .message-bubble {
|
||||
background: var(--bubble-out);
|
||||
border-color: #bfe4dc;
|
||||
}
|
||||
@@ -2305,38 +2347,230 @@ button {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.message-image img {
|
||||
.message-image img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 320px;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.pinned-message-banner {
|
||||
width: calc(100% - 28px);
|
||||
height: 42px;
|
||||
min-height: 42px;
|
||||
margin: 8px 14px;
|
||||
align-self: center;
|
||||
display: grid;
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d5e2df;
|
||||
border-radius: 9px;
|
||||
background: #f7fbfa;
|
||||
color: #52606d;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pinned-message-banner strong {
|
||||
color: #0f766e;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pinned-message-preview {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pinned-message-preview > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pinned-message-preview img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: 0 0 28px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.pinned-message-close {
|
||||
padding: 0 4px;
|
||||
color: #748091;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.message-row.system {
|
||||
justify-content: center;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.system-message-pill {
|
||||
max-width: min(78%, 620px);
|
||||
padding: 5px 12px;
|
||||
border-radius: 5px;
|
||||
background: #c8c8c8;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.message-video {
|
||||
display: block;
|
||||
width: min(360px, 68vw);
|
||||
max-height: 360px;
|
||||
border-radius: 7px;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.mention-picker {
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
bottom: 112px;
|
||||
z-index: 20;
|
||||
width: min(320px, calc(100% - 36px));
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
border: 1px solid #d7e0e8;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 12px 32px rgb(15 23 42 / 18%);
|
||||
}
|
||||
|
||||
.mention-picker button {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mention-picker button:hover {
|
||||
background: #eef7f5;
|
||||
}
|
||||
|
||||
.mention-picker img,
|
||||
.mention-picker button > span {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 7px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.mention-picker button > span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #dceaf3;
|
||||
color: #23445c;
|
||||
}
|
||||
|
||||
.red-packet-card {
|
||||
width: min(320px, 68vw);
|
||||
margin: -10px -12px -8px;
|
||||
overflow: hidden;
|
||||
border-radius: 7px;
|
||||
background: #ff9f32;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.red-packet-card-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
padding: 20px 18px 17px;
|
||||
}
|
||||
|
||||
.red-packet-card-main > span:last-child {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.red-packet-card-main strong {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.red-packet-card-main small {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.red-packet-icon,
|
||||
.red-packet-mini-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 3px;
|
||||
background: #d92f27;
|
||||
color: #ffd968;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.red-packet-icon {
|
||||
width: 34px;
|
||||
height: 44px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.red-packet-card-footer {
|
||||
padding: 9px 15px;
|
||||
background: #fff;
|
||||
color: #8a8f94;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.red-packet-receipt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: #69747d;
|
||||
}
|
||||
|
||||
.red-packet-mini-icon {
|
||||
width: 22px;
|
||||
height: 28px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.image-viewer-backdrop {
|
||||
.image-viewer-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(12, 18, 26, 0.58);
|
||||
}
|
||||
|
||||
.image-viewer-dialog {
|
||||
width: min(880px, calc(100vw - 48px));
|
||||
height: min(640px, calc(100vh - 48px));
|
||||
min-width: 360px;
|
||||
min-height: 280px;
|
||||
max-width: calc(100vw - 48px);
|
||||
max-height: calc(100vh - 48px);
|
||||
padding: 20px;
|
||||
background: rgba(7, 12, 20, 0.82);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.image-viewer-dialog {
|
||||
width: min(920px, calc(100vw - 64px));
|
||||
height: min(660px, calc(100vh - 64px));
|
||||
min-width: 320px;
|
||||
min-height: 260px;
|
||||
max-width: calc(100vw - 64px);
|
||||
max-height: calc(100vh - 64px);
|
||||
display: grid;
|
||||
grid-template-rows: 52px minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
resize: both;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 8px;
|
||||
background: #101820;
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.36);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 14px;
|
||||
background: #0b1119;
|
||||
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.56);
|
||||
}
|
||||
|
||||
.image-viewer-toolbar {
|
||||
@@ -2350,17 +2584,6 @@ button {
|
||||
background: rgba(24, 33, 47, 0.94);
|
||||
}
|
||||
|
||||
.image-viewer-title {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
color: #f8fafc;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.image-viewer-toolbar button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
@@ -2379,39 +2602,64 @@ button {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.image-viewer-stage {
|
||||
.image-viewer-stage {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
background: #0c121a;
|
||||
cursor: default;
|
||||
touch-action: none;
|
||||
}
|
||||
overflow: hidden;
|
||||
background: #0c121a;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.image-viewer-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
z-index: 2;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 50%;
|
||||
background: rgba(18, 27, 39, 0.76);
|
||||
color: #fff;
|
||||
font-size: 42px;
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
transform: translateY(-50%);
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.image-viewer-nav:hover {
|
||||
background: rgba(41, 57, 75, 0.94);
|
||||
}
|
||||
|
||||
.image-viewer-prev {
|
||||
left: 18px;
|
||||
}
|
||||
|
||||
.image-viewer-next {
|
||||
right: 18px;
|
||||
}
|
||||
|
||||
.image-viewer-stage.zoomed {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.image-viewer-stage.dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.image-viewer-img {
|
||||
max-width: calc(100% - 32px);
|
||||
max-height: calc(100% - 32px);
|
||||
object-fit: contain;
|
||||
.image-viewer-img {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 90px;
|
||||
width: calc(100% - 180px);
|
||||
height: calc(100% - 40px);
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
object-fit: contain;
|
||||
transform-origin: center center;
|
||||
transition: transform 0.08s ease-out;
|
||||
transition: transform 0.12s ease-out;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.image-viewer-stage.dragging .image-viewer-img {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.sender-profile-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
+36
-15
@@ -1,5 +1,11 @@
|
||||
const path = require('path');
|
||||
const webpack = require('webpack');
|
||||
const path = require('path');
|
||||
const webpack = require('webpack');
|
||||
|
||||
const runtimeEnvironment = process.env.VUE_APP_RUNTIME_ENV === 'production'
|
||||
? 'production'
|
||||
: 'local';
|
||||
const appServerTarget = process.env.VUE_APP_APP_SERVER_TARGET || 'http://18.141.44.165:8888';
|
||||
const webApiTarget = process.env.VUE_APP_WEB_API_TARGET || 'http://18.141.44.165:9000';
|
||||
|
||||
module.exports = {
|
||||
outputDir: 'dist',
|
||||
@@ -9,8 +15,8 @@ module.exports = {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
'legacy-wfc': path.resolve(__dirname, '../src/wfc'),
|
||||
'legacy-config': path.resolve(__dirname, '../src/config.js')
|
||||
'legacy-wfc': path.resolve(__dirname, '../src/wfc'),
|
||||
'legacy-config': path.resolve(__dirname, '../src/config.js')
|
||||
},
|
||||
fallback: {
|
||||
path: false,
|
||||
@@ -34,18 +40,33 @@ module.exports = {
|
||||
client: {
|
||||
overlay: false
|
||||
},
|
||||
proxy: {
|
||||
'/local-api': {
|
||||
target: 'http://127.0.0.1:8024',
|
||||
changeOrigin: true,
|
||||
pathRewrite: {
|
||||
'^/local-api': '/api'
|
||||
}
|
||||
}
|
||||
proxy: {
|
||||
'/app-server': {
|
||||
target: appServerTarget,
|
||||
changeOrigin: true,
|
||||
pathRewrite: {
|
||||
'^/app-server': ''
|
||||
}
|
||||
},
|
||||
'/local-api': {
|
||||
target: webApiTarget,
|
||||
changeOrigin: true,
|
||||
pathRewrite: {
|
||||
'^/local-api': '/api'
|
||||
}
|
||||
},
|
||||
'/api': {
|
||||
target: webApiTarget,
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
chainWebpack(config) {
|
||||
config.optimization.concatenateModules(false);
|
||||
},
|
||||
chainWebpack(config) {
|
||||
config.plugin('html').tap((args) => {
|
||||
args[0].runtimeConfigFile = `runtime-config.${runtimeEnvironment}.js`;
|
||||
return args;
|
||||
});
|
||||
config.optimization.concatenateModules(false);
|
||||
config.optimization.minimize(false);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user