Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e42bd6a225 | ||
|
|
b492a5ff76 | ||
|
|
e6d5dfbfc1 | ||
|
|
0d3d410bd2 | ||
|
|
21e1ae23c1 | ||
|
|
3694051a95 | ||
|
|
6f18980a64 | ||
|
|
051f60aaf0 | ||
|
|
664839d6d6 | ||
|
|
ee18bf5eb1 | ||
|
|
8b2061ef57 | ||
|
|
b97c23a241 | ||
|
|
4d9ff68220 | ||
|
|
8f54d768d4 | ||
|
|
57af540f09 | ||
|
|
1cbc3c4cf0 |
@@ -7,4 +7,3 @@ yarn-error.log*
|
|||||||
*.local
|
*.local
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
SESSION_HANDOFF.md
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
+6
-13
@@ -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: http://127.0.0.1:18080
|
||||||
```
|
```
|
||||||
|
|
||||||
`im-server admin`、IM 数据库和应用数据库配置通过 PHP-FPM 环境变量提供:
|
`im-server admin` 的地址和密钥在:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
public/api/im_web_bootstrap.php
|
public/api/im_web_bootstrap.php
|
||||||
```
|
```
|
||||||
|
|
||||||
需要配置:
|
默认配置:
|
||||||
|
|
||||||
```text
|
```php
|
||||||
IM_ADMIN_URL=http://127.0.0.1:18080
|
const IM_ADMIN_URL = 'http://127.0.0.1:18080';
|
||||||
IM_ADMIN_SECRET=请填写管理端密钥
|
const IM_ADMIN_SECRET = '123456';
|
||||||
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 或部署平台的环境变量中配置,禁止把真实密钥提交到仓库。
|
如果生产环境 admin 端口或 secret 不同,需要同步修改该文件后重新构建。
|
||||||
|
|
||||||
## 构建
|
## 构建
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ server {
|
|||||||
root /var/www/im-web;
|
root /var/www/im-web;
|
||||||
index index.html;
|
index index.html;
|
||||||
client_max_body_size 100m;
|
client_max_body_size 100m;
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types application/json;
|
||||||
|
|
||||||
location = / {
|
location = / {
|
||||||
try_files /index.html =404;
|
try_files /index.html =404;
|
||||||
@@ -18,6 +22,13 @@ server {
|
|||||||
add_header Cache-Control "no-store";
|
add_header Cache-Control "no-store";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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$ {
|
location ~ ^/api/.+\.php$ {
|
||||||
include snippets/fastcgi-php.conf;
|
include snippets/fastcgi-php.conf;
|
||||||
fastcgi_param PHP_ADMIN_VALUE "open_basedir=/var/www/im-web/:/tmp/";
|
fastcgi_param PHP_ADMIN_VALUE "open_basedir=/var/www/im-web/:/tmp/";
|
||||||
|
|||||||
Executable
+75
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CONFIG_FILE=${JC_WEB_IM_UPDATE_CONFIG:-/etc/jc-web-im-update.conf}
|
||||||
|
STATE_DIR=/var/lib/jc-web-im-update
|
||||||
|
LOCK_FILE=/run/lock/jc-web-im-update.lock
|
||||||
|
|
||||||
|
if [[ ! -r "$CONFIG_FILE" ]]; then
|
||||||
|
echo "Missing configuration: $CONFIG_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$CONFIG_FILE"
|
||||||
|
|
||||||
|
: "${REPO_DIR:?REPO_DIR is required}"
|
||||||
|
: "${WEB_ROOT:?WEB_ROOT is required}"
|
||||||
|
: "${BRANCH:=main}"
|
||||||
|
|
||||||
|
case "$REPO_DIR" in
|
||||||
|
/opt/jc-web-im-release|/opt/jc-web-im-release-*) ;;
|
||||||
|
*) echo "Unsafe REPO_DIR: $REPO_DIR" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
case "$WEB_ROOT" in
|
||||||
|
/var/www/jc-web-im|/var/www/jc-web-im-*) ;;
|
||||||
|
*) echo "Unsafe WEB_ROOT: $WEB_ROOT" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
mkdir -p "$STATE_DIR"
|
||||||
|
exec 9>"$LOCK_FILE"
|
||||||
|
flock -n 9 || {
|
||||||
|
echo "Another update is already running"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
git -C "$REPO_DIR" fetch --quiet origin "$BRANCH"
|
||||||
|
TARGET_COMMIT=$(git -C "$REPO_DIR" rev-parse "origin/$BRANCH")
|
||||||
|
CURRENT_COMMIT=$(cat "$STATE_DIR/last_deployed" 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [[ "$TARGET_COMMIT" == "$CURRENT_COMMIT" ]]; then
|
||||||
|
echo "Already current: $TARGET_COMMIT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
git -C "$REPO_DIR" reset --hard --quiet "$TARGET_COMMIT"
|
||||||
|
DIST_DIR="$REPO_DIR/dist"
|
||||||
|
[[ -f "$DIST_DIR/index.html" ]] || {
|
||||||
|
echo "Missing dist/index.html at $TARGET_COMMIT" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
while IFS= read -r asset; do
|
||||||
|
[[ -f "$DIST_DIR/$asset" ]] || {
|
||||||
|
echo "Missing referenced asset: $asset" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
done < <(grep -oE '(css|js)/[^"'"'"' ]+' "$DIST_DIR/index.html" | sort -u)
|
||||||
|
|
||||||
|
find "$DIST_DIR/api" -type f -name '*.php' -print0 |
|
||||||
|
xargs -0 -r -n1 php -l >/dev/null
|
||||||
|
|
||||||
|
BACKUP_DIR="/var/backups/jc-web-im/$(date +%Y%m%d-%H%M%S)"
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
[[ -f "$WEB_ROOT/index.html" ]] && cp -a "$WEB_ROOT/index.html" "$BACKUP_DIR/"
|
||||||
|
[[ -f "$WEB_ROOT/config/runtime-config.js" ]] && {
|
||||||
|
mkdir -p "$BACKUP_DIR/config"
|
||||||
|
cp -a "$WEB_ROOT/config/runtime-config.js" "$BACKUP_DIR/config/"
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "$WEB_ROOT"
|
||||||
|
rsync -a --exclude='/config/runtime-config.js' "$DIST_DIR/" "$WEB_ROOT/"
|
||||||
|
chown -R www-data:www-data "$WEB_ROOT"
|
||||||
|
printf '%s\n' "$TARGET_COMMIT" >"$STATE_DIR/last_deployed"
|
||||||
|
|
||||||
|
echo "Deployed: $TARGET_COMMIT -> $WEB_ROOT"
|
||||||
Vendored
+6
-10
@@ -34,6 +34,11 @@ try {
|
|||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
$remarkMap = [];
|
$remarkMap = [];
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
$friendAliasMap = fetchImFriendAliasMap(imDb(), $userId, $friendIds);
|
||||||
|
} catch (Throwable) {
|
||||||
|
$friendAliasMap = [];
|
||||||
|
}
|
||||||
|
|
||||||
$contactsByUid = [];
|
$contactsByUid = [];
|
||||||
foreach (array_values(array_filter($usersResult['userInfos'] ?? [], 'is_array')) as $user) {
|
foreach (array_values(array_filter($usersResult['userInfos'] ?? [], 'is_array')) as $user) {
|
||||||
@@ -41,16 +46,7 @@ try {
|
|||||||
if ($uid === '' || isset($contactsByUid[$uid])) {
|
if ($uid === '' || isset($contactsByUid[$uid])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$alias = '';
|
$alias = $friendAliasMap[$uid] ?? '';
|
||||||
try {
|
|
||||||
$aliasResult = requireImSuccess(
|
|
||||||
imAdminPost('/admin/friend/get_alias', ['operator' => $userId, 'targetId' => $uid]),
|
|
||||||
'联系人备注加载失败'
|
|
||||||
);
|
|
||||||
$alias = trim((string)($aliasResult['alias'] ?? ''));
|
|
||||||
} catch (Throwable $error) {
|
|
||||||
$alias = '';
|
|
||||||
}
|
|
||||||
if ($alias === '') {
|
if ($alias === '') {
|
||||||
$alias = $remarkMap[$uid] ?? '';
|
$alias = $remarkMap[$uid] ?? '';
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+194
-11
@@ -70,6 +70,8 @@ function webGroupConversation(array $group, string $role = 'member'): array
|
|||||||
'line' => 0,
|
'line' => 0,
|
||||||
'category' => $category,
|
'category' => $category,
|
||||||
'role' => $role,
|
'role' => $role,
|
||||||
|
'nativeStatus' => (string)($group['native_status'] ?? ''),
|
||||||
|
'nativeError' => (string)($group['native_error'] ?? ''),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -176,6 +178,48 @@ function createNativeGroup(string $operator, string $groupId, string $name, stri
|
|||||||
]), '原生群组创建失败');
|
]), '原生群组创建失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fetchNativeGroupInfo(string $groupKey): array
|
||||||
|
{
|
||||||
|
$payloads = [
|
||||||
|
['group_id' => $groupKey],
|
||||||
|
['groupId' => $groupKey],
|
||||||
|
['target_id' => $groupKey],
|
||||||
|
];
|
||||||
|
$lastError = null;
|
||||||
|
|
||||||
|
foreach ($payloads as $payload) {
|
||||||
|
try {
|
||||||
|
return requireImSuccess(imAdminPost('/admin/group/get_info', $payload), '群组不存在');
|
||||||
|
} catch (Throwable $error) {
|
||||||
|
$lastError = $error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException($lastError ? ($lastError->getMessage() ?: '群组不存在') : '群组不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeGroupInfoValue(array $native, string $key, string $fallback = ''): string
|
||||||
|
{
|
||||||
|
$sources = [$native];
|
||||||
|
if (is_array($native['group_info'] ?? null)) {
|
||||||
|
$sources[] = $native['group_info'];
|
||||||
|
}
|
||||||
|
if (is_array($native['group'] ?? null)) {
|
||||||
|
$sources[] = $native['group'];
|
||||||
|
if (is_array($native['group']['group_info'] ?? null)) {
|
||||||
|
$sources[] = $native['group']['group_info'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($sources as $source) {
|
||||||
|
if (isset($source[$key]) && (string)$source[$key] !== '') {
|
||||||
|
return (string)$source[$key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
function addNativeGroupMember(string $operator, string $groupId, string $userId): void
|
function addNativeGroupMember(string $operator, string $groupId, string $userId): void
|
||||||
{
|
{
|
||||||
requireImSuccess(imAdminPost('/admin/group/member/add', [
|
requireImSuccess(imAdminPost('/admin/group/member/add', [
|
||||||
@@ -425,11 +469,134 @@ function currentMuteSetting(PDO $appDb, string $groupId): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nativeGroupRoleFromType(mixed $type): string
|
||||||
|
{
|
||||||
|
$value = (int)$type;
|
||||||
|
if ($value === 2) {
|
||||||
|
return 'owner';
|
||||||
|
}
|
||||||
|
if ($value === 1) {
|
||||||
|
return 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'member';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchNativeGroupWithMember(PDO $imDb, string $groupId, string $userId): array
|
||||||
|
{
|
||||||
|
$statement = $imDb->prepare(
|
||||||
|
'select g.*, m.`_type` as `member_type`, m.`_alias` as `member_alias`'
|
||||||
|
. ' from `t_group` g'
|
||||||
|
. ' join `t_group_member` m on m.`_gid` = g.`_gid`'
|
||||||
|
. ' where g.`_gid` = ? and g.`_deleted` = 0 and m.`_mid` = ? limit 1'
|
||||||
|
);
|
||||||
|
$statement->execute([$groupId, $userId]);
|
||||||
|
$group = $statement->fetch();
|
||||||
|
|
||||||
|
return is_array($group) ? $group : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureNativeGroupMember(PDO $imDb, string $groupId, string $userId): array
|
||||||
|
{
|
||||||
|
$group = fetchNativeGroupWithMember($imDb, $groupId, $userId);
|
||||||
|
if ($group) {
|
||||||
|
return $group;
|
||||||
|
}
|
||||||
|
|
||||||
|
$exists = $imDb->prepare('select 1 from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1');
|
||||||
|
$exists->execute([$groupId]);
|
||||||
|
if ($exists->fetchColumn()) {
|
||||||
|
respond(403, '你不在该群组中');
|
||||||
|
}
|
||||||
|
|
||||||
|
respond(404, '没有找到该群组');
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeGroupDetailAction(PDO $imDb, string $userId, string $groupId): array
|
||||||
|
{
|
||||||
|
$group = ensureNativeGroupMember($imDb, $groupId, $userId);
|
||||||
|
$ownerId = (string)($group['_owner'] ?? '');
|
||||||
|
$users = fetchWebUserMap($imDb, [$ownerId, $userId], $userId);
|
||||||
|
$owner = $users[$ownerId] ?? ['uid' => $ownerId, 'displayName' => $ownerId, 'contactAlias' => ''];
|
||||||
|
$me = $users[$userId] ?? ['uid' => $userId, 'displayName' => $userId];
|
||||||
|
$myAlias = (string)($group['member_alias'] ?? '');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'group' => [
|
||||||
|
'groupId' => (string)$group['_gid'],
|
||||||
|
'name' => (string)($group['_name'] ?? ''),
|
||||||
|
'category' => 'group',
|
||||||
|
'announcement' => '',
|
||||||
|
'welcomeText' => '',
|
||||||
|
'ownerUid' => $ownerId,
|
||||||
|
'ownerName' => (string)($owner['contactAlias'] ?? '') !== '' ? (string)$owner['contactAlias'] : (string)$owner['displayName'],
|
||||||
|
'myRole' => nativeGroupRoleFromType($group['member_type'] ?? 0),
|
||||||
|
'myAlias' => $myAlias,
|
||||||
|
'myDisplayName' => $myAlias !== '' ? $myAlias : (string)$me['displayName'],
|
||||||
|
'muteSetting' => [
|
||||||
|
'mode' => 'off',
|
||||||
|
'scheduleType' => 'once',
|
||||||
|
'startsAtMs' => 0,
|
||||||
|
'endsAtMs' => 0,
|
||||||
|
'dailyStart' => '',
|
||||||
|
'dailyEnd' => '',
|
||||||
|
'enabled' => false,
|
||||||
|
],
|
||||||
|
'native' => true,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeGroupMembersAction(PDO $imDb, string $userId, string $groupId): array
|
||||||
|
{
|
||||||
|
ensureNativeGroupMember($imDb, $groupId, $userId);
|
||||||
|
|
||||||
|
$statement = $imDb->prepare(
|
||||||
|
'select `_mid`, `_alias`, `_type`, `_create_dt` from `t_group_member`'
|
||||||
|
. ' where `_gid` = ? order by (`_type` = 2) desc, (`_type` = 1) desc, `_create_dt` asc limit 500'
|
||||||
|
);
|
||||||
|
$statement->execute([$groupId]);
|
||||||
|
$members = $statement->fetchAll();
|
||||||
|
$users = fetchWebUserMap($imDb, array_column($members, '_mid'), $userId);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'members' => array_map(static function (array $member) use ($users): array {
|
||||||
|
$uid = (string)$member['_mid'];
|
||||||
|
$user = $users[$uid] ?? [
|
||||||
|
'uid' => $uid,
|
||||||
|
'displayName' => $uid,
|
||||||
|
'mobile' => '',
|
||||||
|
'portrait' => '',
|
||||||
|
'contactAlias' => '',
|
||||||
|
'friendAlias' => '',
|
||||||
|
'remarkAlias' => '',
|
||||||
|
'isContact' => false,
|
||||||
|
];
|
||||||
|
$alias = trim((string)($member['_alias'] ?? ''));
|
||||||
|
$contactAlias = trim((string)($user['contactAlias'] ?? ''));
|
||||||
|
return [
|
||||||
|
'uid' => $uid,
|
||||||
|
'role' => nativeGroupRoleFromType($member['_type'] ?? 0),
|
||||||
|
'alias' => $alias,
|
||||||
|
'contactAlias' => $contactAlias,
|
||||||
|
'friendAlias' => trim((string)($user['friendAlias'] ?? '')),
|
||||||
|
'remarkAlias' => trim((string)($user['remarkAlias'] ?? '')),
|
||||||
|
'isContact' => (bool)($user['isContact'] ?? false),
|
||||||
|
'displayName' => $contactAlias !== '' ? $contactAlias : ($alias !== '' ? $alias : (string)$user['displayName']),
|
||||||
|
'rawDisplayName' => (string)$user['displayName'],
|
||||||
|
'groupDisplayName' => $alias,
|
||||||
|
'mobile' => (string)($user['mobile'] ?? ''),
|
||||||
|
'portrait' => (string)($user['portrait'] ?? ''),
|
||||||
|
];
|
||||||
|
}, $members),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
||||||
{
|
{
|
||||||
$group = fetchWebGroup($appDb, $groupId);
|
$group = fetchWebGroup($appDb, $groupId);
|
||||||
if (!$group) {
|
if (!$group) {
|
||||||
respond(404, '没有找到该群组');
|
return nativeGroupDetailAction($imDb, $userId, $groupId);
|
||||||
}
|
}
|
||||||
$member = fetchWebGroupMember($appDb, $groupId, $userId);
|
$member = fetchWebGroupMember($appDb, $groupId, $userId);
|
||||||
if (!$member) {
|
if (!$member) {
|
||||||
@@ -462,7 +629,7 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
|
|||||||
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
||||||
{
|
{
|
||||||
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
|
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
|
||||||
respond(403, '你不在该群组中');
|
return nativeGroupMembersAction($imDb, $userId, $groupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
$statement = $appDb->prepare(
|
$statement = $appDb->prepare(
|
||||||
@@ -512,7 +679,10 @@ function updateGroupAliasAction(PDO $appDb, string $userId, string $groupId, str
|
|||||||
respond(400, '本群昵称过长');
|
respond(400, '本群昵称过长');
|
||||||
}
|
}
|
||||||
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
|
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
|
||||||
respond(403, '你不在该群组中');
|
ensureNativeGroupMember(imDb(), $groupId, $userId);
|
||||||
|
$update = imDb()->prepare('update `t_group_member` set `_alias` = ? where `_gid` = ? and `_mid` = ?');
|
||||||
|
$update->execute([$alias, $groupId, $userId]);
|
||||||
|
return ['alias' => $alias];
|
||||||
}
|
}
|
||||||
|
|
||||||
$now = webImNowMs();
|
$now = webImNowMs();
|
||||||
@@ -781,8 +951,16 @@ function joinWebGroupAction(PDO $appDb, string $userId, string $groupKey): array
|
|||||||
$group = fetchWebGroup($appDb, $groupKey);
|
$group = fetchWebGroup($appDb, $groupKey);
|
||||||
if (!$group) {
|
if (!$group) {
|
||||||
try {
|
try {
|
||||||
$native = requireImSuccess(imAdminPost('/admin/group/get_info', ['groupId' => $groupKey]), '群组不存在');
|
$native = fetchNativeGroupInfo($groupKey);
|
||||||
$now = webImNowMs();
|
$now = webImNowMs();
|
||||||
|
$nativeGroupId = nativeGroupInfoValue(
|
||||||
|
$native,
|
||||||
|
'target_id',
|
||||||
|
nativeGroupInfoValue($native, 'targetId', nativeGroupInfoValue($native, 'group_id', nativeGroupInfoValue($native, 'groupId', $groupKey)))
|
||||||
|
);
|
||||||
|
$nativeName = nativeGroupInfoValue($native, 'name', $nativeGroupId);
|
||||||
|
$nativePortrait = nativeGroupInfoValue($native, 'portrait', '');
|
||||||
|
$nativeOwner = nativeGroupInfoValue($native, 'owner', nativeGroupInfoValue($native, 'owner_uid', nativeGroupInfoValue($native, 'ownerUid', '')));
|
||||||
$statement = $appDb->prepare(
|
$statement = $appDb->prepare(
|
||||||
'insert into `t_web_groups`'
|
'insert into `t_web_groups`'
|
||||||
. ' (`group_id`, `category`, `name`, `portrait`, `owner_uid`, `join_mode`, `announcement`,'
|
. ' (`group_id`, `category`, `name`, `portrait`, `owner_uid`, `join_mode`, `announcement`,'
|
||||||
@@ -791,33 +969,38 @@ function joinWebGroupAction(PDO $appDb, string $userId, string $groupKey): array
|
|||||||
. ' on duplicate key update `updated_at_ms` = values(`updated_at_ms`)'
|
. ' on duplicate key update `updated_at_ms` = values(`updated_at_ms`)'
|
||||||
);
|
);
|
||||||
$statement->execute([
|
$statement->execute([
|
||||||
$groupKey,
|
$nativeGroupId,
|
||||||
(string)($native['name'] ?? $groupKey),
|
$nativeName,
|
||||||
(string)($native['portrait'] ?? ''),
|
$nativePortrait,
|
||||||
(string)($native['owner'] ?? ''),
|
$nativeOwner,
|
||||||
$groupKey,
|
$nativeGroupId,
|
||||||
$userId,
|
$userId,
|
||||||
$now,
|
$now,
|
||||||
$now,
|
$now,
|
||||||
]);
|
]);
|
||||||
$group = fetchWebGroup($appDb, $groupKey);
|
$group = fetchWebGroup($appDb, $nativeGroupId);
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
respond(404, '没有找到该群组');
|
respond(404, '没有找到该群组');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$groupId = (string)$group['group_id'];
|
$groupId = (string)$group['group_id'];
|
||||||
|
$nativeStatus = (string)($group['native_status'] ?? '');
|
||||||
|
$nativeError = (string)($group['native_error'] ?? '');
|
||||||
upsertWebGroupMember($appDb, $groupId, $userId, 'member');
|
upsertWebGroupMember($appDb, $groupId, $userId, 'member');
|
||||||
try {
|
try {
|
||||||
addNativeGroupMember((string)($group['owner_uid'] ?: $userId), $groupId, $userId);
|
addNativeGroupMember((string)($group['owner_uid'] ?: $userId), $groupId, $userId);
|
||||||
} catch (Throwable $error) {
|
} catch (Throwable $error) {
|
||||||
// Web membership is authoritative for this web build; native group sync is best-effort.
|
$nativeStatus = $nativeStatus === 'ok' ? 'partial' : ($nativeStatus ?: 'fallback');
|
||||||
|
$nativeError = mb_substr($error->getMessage() ?: '加入原生群组失败', 0, 255, 'UTF-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
$role = fetchWebGroupRole($appDb, $groupId, $userId);
|
$role = fetchWebGroupRole($appDb, $groupId, $userId);
|
||||||
return [
|
return [
|
||||||
'group' => fetchWebGroup($appDb, $groupId),
|
'group' => fetchWebGroup($appDb, $groupId),
|
||||||
'conversation' => webGroupConversation($group, $role),
|
'conversation' => webGroupConversation($group, $role),
|
||||||
|
'nativeStatus' => $nativeStatus,
|
||||||
|
'nativeError' => $nativeError,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+104
-9
@@ -1,15 +1,85 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
define('IM_ADMIN_URL', getenv('IM_ADMIN_URL') ?: 'http://127.0.0.1:18080');
|
function webImEnv(string $key, string $fallback = ''): string
|
||||||
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');
|
$runtimeValue = getenv($key);
|
||||||
define('IM_DB_USER', getenv('IM_DB_USER') ?: '');
|
if ($runtimeValue !== false && $runtimeValue !== '') {
|
||||||
define('IM_DB_PASSWORD', getenv('IM_DB_PASSWORD') ?: '');
|
return $runtimeValue;
|
||||||
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') ?: '');
|
static $env = null;
|
||||||
define('IM_DB_TIMEZONE', getenv('IM_DB_TIMEZONE') ?: 'Asia/Bangkok');
|
if ($env === null) {
|
||||||
|
$env = [];
|
||||||
|
$paths = array_filter([
|
||||||
|
getenv('JC_WEB_IM_ENV_FILE') ?: '',
|
||||||
|
dirname(__DIR__, 2) . '/.env',
|
||||||
|
'/var/www/im-admin/.env',
|
||||||
|
'/var/www/im/.env',
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($paths as $path) {
|
||||||
|
if (!is_readable($path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
|
||||||
|
$line = trim($line);
|
||||||
|
if ($line === '' || strpos($line, '#') === 0 || strpos($line, '=') === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
[$name, $value] = array_map('trim', explode('=', $line, 2));
|
||||||
|
if ($name === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$value = trim($value);
|
||||||
|
if (
|
||||||
|
strlen($value) >= 2
|
||||||
|
&& (($value[0] === '"' && substr($value, -1) === '"') || ($value[0] === "'" && substr($value, -1) === "'"))
|
||||||
|
) {
|
||||||
|
$value = substr($value, 1, -1);
|
||||||
|
}
|
||||||
|
$env[$name] = $value;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $env[$key] ?? $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function webImFirstEnv(array $keys, string $fallback = ''): string
|
||||||
|
{
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
$value = webImEnv($key);
|
||||||
|
if ($value !== '') {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function webImMysqlDsn(string $host, string $port, string $database): string
|
||||||
|
{
|
||||||
|
return sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $host, $port, $database);
|
||||||
|
}
|
||||||
|
|
||||||
|
define('IM_ADMIN_URL', webImEnv('JC_WEB_IM_ADMIN_URL', 'http://127.0.0.1:18080'));
|
||||||
|
define('IM_ADMIN_SECRET', webImFirstEnv(['JC_WEB_IM_ADMIN_SECRET', 'SecretKey'], '123456'));
|
||||||
|
define('IM_DB_DSN', webImMysqlDsn(
|
||||||
|
webImEnv('JC_WEB_IM_DB_HOST', webImEnv('DB_HOST', '127.0.0.1')),
|
||||||
|
webImEnv('JC_WEB_IM_DB_PORT', webImEnv('DB_PORT', '3306')),
|
||||||
|
webImEnv('JC_WEB_IM_DB_DATABASE', webImEnv('DB_DATABASE', 'wfchat'))
|
||||||
|
));
|
||||||
|
define('IM_DB_USER', webImEnv('JC_WEB_IM_DB_USER', webImEnv('DB_USERNAME', 'winchen')));
|
||||||
|
define('IM_DB_PASSWORD', webImEnv('JC_WEB_IM_DB_PASSWORD', webImEnv('DB_PASSWORD', 'winchen@0904')));
|
||||||
|
define('IM_APP_DB_DSN', webImMysqlDsn(
|
||||||
|
webImFirstEnv(['JC_WEB_IM_APP_DB_HOST', 'DB_HOST_2', 'DB_HOST2_2'], webImEnv('DB_HOST', '127.0.0.1')),
|
||||||
|
webImFirstEnv(['JC_WEB_IM_APP_DB_PORT', 'DB_PORT_2', 'DB_PORT2_2'], webImEnv('DB_PORT', '3306')),
|
||||||
|
webImFirstEnv(['JC_WEB_IM_APP_DB_DATABASE', 'DB_DATABASE_2', 'DB_DATABASE2_2'], webImEnv('DB_DATABASE', 'appdata'))
|
||||||
|
));
|
||||||
|
define('IM_APP_DB_USER', webImFirstEnv(['JC_WEB_IM_APP_DB_USER', 'DB_USERNAME_2', 'DB_USERNAME2_2'], webImEnv('DB_USERNAME', 'winchen')));
|
||||||
|
define('IM_APP_DB_PASSWORD', webImFirstEnv(['JC_WEB_IM_APP_DB_PASSWORD', 'DB_PASSWORD_2', 'DB_PASSWORD2_2'], webImEnv('DB_PASSWORD', 'winchen@0904')));
|
||||||
|
define('IM_DB_TIMEZONE', webImEnv('JC_WEB_IM_DB_TIMEZONE', 'Asia/Bangkok'));
|
||||||
|
|
||||||
function respond(int $code, string $message, array $result = []): void
|
function respond(int $code, string $message, array $result = []): void
|
||||||
{
|
{
|
||||||
@@ -285,6 +355,31 @@ function fetchWebUserRemarkMap(PDO $db, string $ownerId, array $targetIds): arra
|
|||||||
return $remarks;
|
return $remarks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fetchImFriendAliasMap(PDO $db, string $ownerId, array $friendIds): array
|
||||||
|
{
|
||||||
|
$friendIds = array_values(array_unique(array_filter(array_map(static function ($friendId): string {
|
||||||
|
return trim((string)$friendId);
|
||||||
|
}, $friendIds))));
|
||||||
|
if ($ownerId === '' || !$friendIds) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$aliases = [];
|
||||||
|
foreach (array_chunk($friendIds, 500) as $chunk) {
|
||||||
|
$statement = $db->prepare(
|
||||||
|
'select `_friend_uid`, `_alias` from `t_friend`'
|
||||||
|
. ' where `_uid` = ? and `_friend_uid` in (' . webImPlaceholders($chunk) . ') and `_state` = 0'
|
||||||
|
);
|
||||||
|
$statement->execute(array_merge([$ownerId], $chunk));
|
||||||
|
|
||||||
|
foreach ($statement->fetchAll() as $row) {
|
||||||
|
$aliases[(string)$row['_friend_uid']] = trim((string)($row['_alias'] ?? ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $aliases;
|
||||||
|
}
|
||||||
|
|
||||||
function saveWebUserRemark(PDO $db, string $ownerId, string $targetId, string $alias): void
|
function saveWebUserRemark(PDO $db, string $ownerId, string $targetId, string $alias): void
|
||||||
{
|
{
|
||||||
if ($ownerId === '' || $targetId === '') {
|
if ($ownerId === '' || $targetId === '') {
|
||||||
|
|||||||
Vendored
+236
@@ -0,0 +1,236 @@
|
|||||||
|
<?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 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 = webImEnv('JC_WEB_IM_MEDIA_PROXY_HOSTS');
|
||||||
|
$hosts = array_map(
|
||||||
|
static fn(string $host): string => strtolower(rtrim(trim($host), '.')),
|
||||||
|
preg_split('/[,\s]+/', $configured) ?: []
|
||||||
|
);
|
||||||
|
|
||||||
|
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(webImEnv('JC_WEB_IM_MEDIA_CACHE_ROOT', '/var/cache/jc-web-im-media'), '/');
|
||||||
|
if ($root === '' || !is_dir($root) || !is_writable($root)) {
|
||||||
|
failMediaProxy(503, '媒体缓存目录不可用');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaProxyPreferredIps(): array
|
||||||
|
{
|
||||||
|
$configured = preg_split('/[,\s]+/', webImEnv('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
+723
-15
@@ -132,7 +132,7 @@ function fetchMessageRows(PDO $db, array $messageIds): array
|
|||||||
|
|
||||||
$messages = [];
|
$messages = [];
|
||||||
foreach ($idsByTable as $table => $ids) {
|
foreach ($idsByTable as $table => $ids) {
|
||||||
$sql = 'select `_mid`, `_from`, `_type`, `_target`, `_line`, `_searchable_key`, `_dt`, `_content_type`, `_to`'
|
$sql = 'select `_mid`, `_from`, `_type`, `_target`, `_line`, `_data`, `_searchable_key`, `_dt`, `_content_type`, `_to`'
|
||||||
. ' from `' . $table . '` where `_mid` in (' . placeholders($ids) . ')';
|
. ' from `' . $table . '` where `_mid` in (' . placeholders($ids) . ')';
|
||||||
$statement = $db->prepare($sql);
|
$statement = $db->prepare($sql);
|
||||||
$statement->execute($ids);
|
$statement->execute($ids);
|
||||||
@@ -232,12 +232,21 @@ function messagePreviewText(?array $message): string
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
$attachment = is_array($message['attachment'] ?? null) ? $message['attachment'] : null;
|
$attachment = is_array($message['attachment'] ?? null) ? $message['attachment'] : null;
|
||||||
|
$contentType = (int)($message['contentType'] ?? 0);
|
||||||
|
$messageType = (string)($message['type'] ?? '');
|
||||||
|
$placeholderIsImage = $contentType === 3 || $messageType === 'image';
|
||||||
|
$text = trim((string)($message['text'] ?? ''));
|
||||||
|
if ($contentType !== 1 && $text === '[暂不支持的消息类型]') {
|
||||||
|
$text = '';
|
||||||
|
}
|
||||||
|
if ($placeholderIsImage && ($text === '[图片]' || $text === '图片')) {
|
||||||
|
$text = '';
|
||||||
|
}
|
||||||
if ($attachment) {
|
if ($attachment) {
|
||||||
$text = trim((string)($message['text'] ?? ''));
|
|
||||||
return $text !== '' ? $text : attachmentFallbackText($attachment);
|
return $text !== '' ? $text : attachmentFallbackText($attachment);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (string)($message['text'] ?? '');
|
return $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchUserInfoMap(PDO $db, array $userIds, string $ownerId = ''): array
|
function fetchUserInfoMap(PDO $db, array $userIds, string $ownerId = ''): array
|
||||||
@@ -405,6 +414,42 @@ function isWebGroupTarget(PDO $appDb, string $targetId): bool
|
|||||||
return (bool)$statement->fetchColumn();
|
return (bool)$statement->fetchColumn();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nativeGroupRoleFromType(mixed $type): string
|
||||||
|
{
|
||||||
|
$value = (int)$type;
|
||||||
|
if ($value === 2) {
|
||||||
|
return 'owner';
|
||||||
|
}
|
||||||
|
if ($value === 1) {
|
||||||
|
return 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'member';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNativeGroupTarget(PDO $db, string $targetId): bool
|
||||||
|
{
|
||||||
|
if ($targetId === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$statement = $db->prepare('select 1 from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1');
|
||||||
|
$statement->execute([$targetId]);
|
||||||
|
|
||||||
|
return (bool)$statement->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeGroupRole(PDO $db, string $userId, string $groupId): string
|
||||||
|
{
|
||||||
|
$statement = $db->prepare(
|
||||||
|
'select `_type` from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1'
|
||||||
|
);
|
||||||
|
$statement->execute([$groupId, $userId]);
|
||||||
|
$type = $statement->fetchColumn();
|
||||||
|
|
||||||
|
return $type === false ? '' : nativeGroupRoleFromType($type);
|
||||||
|
}
|
||||||
|
|
||||||
function markWebGroupRead(PDO $appDb, string $userId, string $groupId, int $timestamp): void
|
function markWebGroupRead(PDO $appDb, string $userId, string $groupId, int $timestamp): void
|
||||||
{
|
{
|
||||||
if ($userId === '' || $groupId === '' || $timestamp <= 0) {
|
if ($userId === '' || $groupId === '' || $timestamp <= 0) {
|
||||||
@@ -422,6 +467,34 @@ function markWebGroupRead(PDO $appDb, string $userId, string $groupId, int $time
|
|||||||
$statement->execute([$groupId, $userId, $timestamp, $now]);
|
$statement->execute([$groupId, $userId, $timestamp, $now]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markNativeGroupRead(PDO $db, string $userId, string $groupId, int $timestamp): void
|
||||||
|
{
|
||||||
|
if ($userId === '' || $groupId === '' || $timestamp <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$statement = $db->prepare(
|
||||||
|
'select `id`, `_dt` from `t_read_report`'
|
||||||
|
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0 and `_target` = ?'
|
||||||
|
. ' order by `_dt` desc limit 1'
|
||||||
|
);
|
||||||
|
$statement->execute([$userId, $groupId]);
|
||||||
|
$row = $statement->fetch();
|
||||||
|
|
||||||
|
if ($row) {
|
||||||
|
if ($timestamp > (int)$row['_dt']) {
|
||||||
|
$update = $db->prepare('update `t_read_report` set `_dt` = ? where `id` = ?');
|
||||||
|
$update->execute([$timestamp, (int)$row['id']]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$insert = $db->prepare(
|
||||||
|
'insert into `t_read_report` (`_uid`, `_type`, `_line`, `_target`, `_dt`) values (?, 1, 0, ?, ?)'
|
||||||
|
);
|
||||||
|
$insert->execute([$userId, $groupId, $timestamp]);
|
||||||
|
}
|
||||||
|
|
||||||
function webGroupRole(PDO $appDb, string $userId, string $groupId): string
|
function webGroupRole(PDO $appDb, string $userId, string $groupId): string
|
||||||
{
|
{
|
||||||
$statement = $appDb->prepare(
|
$statement = $appDb->prepare(
|
||||||
@@ -434,6 +507,50 @@ function webGroupRole(PDO $appDb, string $userId, string $groupId): string
|
|||||||
return is_string($role) && $role !== '' ? $role : '';
|
return is_string($role) && $role !== '' ? $role : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadNativeGroupReadStateMap(PDO $db, string $userId, array $groupIds): array
|
||||||
|
{
|
||||||
|
$groupIds = array_values(array_unique(array_filter(array_map('strval', $groupIds))));
|
||||||
|
if ($userId === '' || !$groupIds) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$statement = $db->prepare(
|
||||||
|
'select `_target`, max(`_dt`) as `_read_at` from `t_read_report`'
|
||||||
|
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0'
|
||||||
|
. ' and `_target` in (' . placeholders($groupIds) . ')'
|
||||||
|
. ' group by `_target`'
|
||||||
|
);
|
||||||
|
$statement->execute(array_merge([$userId], $groupIds));
|
||||||
|
|
||||||
|
$readAtByGroup = [];
|
||||||
|
foreach ($statement->fetchAll() as $row) {
|
||||||
|
$readAtByGroup[(string)$row['_target']] = (int)$row['_read_at'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $readAtByGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchNativeGroupMemberMap(PDO $db, string $groupId, array $userIds): array
|
||||||
|
{
|
||||||
|
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
|
||||||
|
if ($groupId === '' || !$userIds) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$statement = $db->prepare(
|
||||||
|
'select `_mid`, `_alias`, `_type`, `_dt`, `_create_dt` from `t_group_member`'
|
||||||
|
. ' where `_gid` = ? and `_mid` in (' . placeholders($userIds) . ')'
|
||||||
|
);
|
||||||
|
$statement->execute(array_merge([$groupId], $userIds));
|
||||||
|
|
||||||
|
$members = [];
|
||||||
|
foreach ($statement->fetchAll() as $row) {
|
||||||
|
$members[(string)$row['_mid']] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $members;
|
||||||
|
}
|
||||||
|
|
||||||
function formatWebGroupMessage(
|
function formatWebGroupMessage(
|
||||||
array $row,
|
array $row,
|
||||||
string $currentUserId,
|
string $currentUserId,
|
||||||
@@ -508,6 +625,318 @@ function formatWebGroupMessage(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanupNativeMediaUrl(string $url): string
|
||||||
|
{
|
||||||
|
// WildFire's protobuf payload places the next field tag (0x40, "@")
|
||||||
|
// and a control byte immediately after remoteMediaUrl. Keep this helper
|
||||||
|
// defensive if it receives an unbounded match from older payloads.
|
||||||
|
$url = preg_replace('/[\x00-\x1F\x7F].*$/s', '', $url) ?? $url;
|
||||||
|
return rtrim($url, " \t\r\n\0\x0B\"'<>.,;)]}@");
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeMediaUrlScore(string $url): int
|
||||||
|
{
|
||||||
|
$path = strtolower((string)(parse_url($url, PHP_URL_PATH) ?: $url));
|
||||||
|
$score = 0;
|
||||||
|
if (preg_match('/\.(?:jpe?g|png|gif|webp|bmp|avif)$/i', $path)) {
|
||||||
|
$score += 30;
|
||||||
|
}
|
||||||
|
if (strpos($path, 'image') !== false || strpos($path, 'media') !== false || strpos($path, 'upload') !== false) {
|
||||||
|
$score += 5;
|
||||||
|
}
|
||||||
|
if (strpos($path, 'thumb') !== false || strpos($path, 'thumbnail') !== false) {
|
||||||
|
$score -= 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractNativeMediaUrlFromData($data): string
|
||||||
|
{
|
||||||
|
if (is_resource($data)) {
|
||||||
|
$data = stream_get_contents($data);
|
||||||
|
}
|
||||||
|
if (!is_string($data) || $data === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (!preg_match_all("~https?://[^\\s\"'<>\\x00-\\x1F\\x7F]+~i", $data, $matches)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$urls = [];
|
||||||
|
foreach ($matches[0] as $url) {
|
||||||
|
$url = cleanupNativeMediaUrl((string)$url);
|
||||||
|
if ($url !== '' && filter_var($url, FILTER_VALIDATE_URL)) {
|
||||||
|
$urls[$url] = $url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$urls) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$urls = array_values($urls);
|
||||||
|
usort($urls, static function (string $left, string $right): int {
|
||||||
|
$scoreDelta = nativeMediaUrlScore($right) <=> nativeMediaUrlScore($left);
|
||||||
|
return $scoreDelta !== 0 ? $scoreDelta : strlen($right) <=> strlen($left);
|
||||||
|
});
|
||||||
|
|
||||||
|
return $urls[0] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeAttachmentName(string $url, string $type): string
|
||||||
|
{
|
||||||
|
$path = (string)(parse_url($url, PHP_URL_PATH) ?: '');
|
||||||
|
$name = trim(rawurldecode((string)basename($path)));
|
||||||
|
if ($name !== '' && $name !== '.' && $name !== '/') {
|
||||||
|
return $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $type === 'image' ? '图片' : '文件';
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeAttachmentMime(string $url, string $type): string
|
||||||
|
{
|
||||||
|
if ($type !== 'image') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = strtolower((string)(parse_url($url, PHP_URL_PATH) ?: ''));
|
||||||
|
if (preg_match('/\.jpe?g$/', $path)) {
|
||||||
|
return 'image/jpeg';
|
||||||
|
}
|
||||||
|
if (preg_match('/\.png$/', $path)) {
|
||||||
|
return 'image/png';
|
||||||
|
}
|
||||||
|
if (preg_match('/\.gif$/', $path)) {
|
||||||
|
return 'image/gif';
|
||||||
|
}
|
||||||
|
if (preg_match('/\.webp$/', $path)) {
|
||||||
|
return 'image/webp';
|
||||||
|
}
|
||||||
|
if (preg_match('/\.bmp$/', $path)) {
|
||||||
|
return 'image/bmp';
|
||||||
|
}
|
||||||
|
if (preg_match('/\.avif$/', $path)) {
|
||||||
|
return 'image/avif';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'image/*';
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeAttachmentFromRow(array $row, ?array $attachment): ?array
|
||||||
|
{
|
||||||
|
if ($attachment !== null) {
|
||||||
|
return $attachment;
|
||||||
|
}
|
||||||
|
|
||||||
|
$contentType = (int)($row['_content_type'] ?? 0);
|
||||||
|
if ($contentType !== 3 && $contentType !== 5) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = extractNativeMediaUrlFromData($row['_data'] ?? '');
|
||||||
|
if ($url === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = $contentType === 3 ? 'image' : 'file';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => $type,
|
||||||
|
'url' => $url,
|
||||||
|
'name' => nativeAttachmentName($url, $type),
|
||||||
|
'mime' => nativeAttachmentMime($url, $type),
|
||||||
|
'size' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNativePlaceholderText(string $text, int $contentType): bool
|
||||||
|
{
|
||||||
|
$text = trim($text);
|
||||||
|
if ($text === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($contentType !== 1 && $text === '[暂不支持的消息类型]') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $contentType === 3 && ($text === '[图片]' || $text === '图片');
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeDisplayText(string $searchableText, int $contentType, bool $hasAttachment, bool $isRecalled = false): string
|
||||||
|
{
|
||||||
|
$text = trim($searchableText);
|
||||||
|
if (isNativePlaceholderText($text, $contentType)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if ($text === '' && !$isRecalled) {
|
||||||
|
return nativeMessageFallbackText($contentType, $hasAttachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeMessageFallbackText(int $contentType, bool $hasAttachment): string
|
||||||
|
{
|
||||||
|
if ($hasAttachment) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if ($contentType === 5) {
|
||||||
|
return '[文件]';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeMessageTableForDate(DateTimeImmutable $date): string
|
||||||
|
{
|
||||||
|
$localDate = $date->setTimezone(new DateTimeZone(IM_DB_TIMEZONE));
|
||||||
|
$year = (int)$localDate->format('Y');
|
||||||
|
$month = (int)$localDate->format('n') - 1;
|
||||||
|
|
||||||
|
return 't_messages_' . (($year % 3) * 12 + $month);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recentNativeMessageTables(int $monthsBack = 3): array
|
||||||
|
{
|
||||||
|
$monthsBack = max(1, min($monthsBack, 12));
|
||||||
|
$cursor = (new DateTimeImmutable('now', new DateTimeZone(IM_DB_TIMEZONE)))
|
||||||
|
->modify('first day of this month');
|
||||||
|
$tables = [];
|
||||||
|
|
||||||
|
for ($index = 0; $index < $monthsBack; $index++) {
|
||||||
|
$tables[] = nativeMessageTableForDate($cursor);
|
||||||
|
$cursor = $cursor->modify('-1 month');
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($tables));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadNativeGroupRowsFromGlobal(PDO $db, array $groupIds, int $limit): array
|
||||||
|
{
|
||||||
|
$groupIds = array_values(array_unique(array_filter(array_map('strval', $groupIds))));
|
||||||
|
if (!$groupIds) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = min(max($limit, 50), 1000);
|
||||||
|
$rows = [];
|
||||||
|
foreach (recentNativeMessageTables(3) as $table) {
|
||||||
|
try {
|
||||||
|
$statement = $db->prepare(
|
||||||
|
'select `_mid`, `_from`, `_type`, `_target`, `_line`, `_data`, `_searchable_key`, `_dt`, `_content_type`, `_to`'
|
||||||
|
. ' from `' . $table . '`'
|
||||||
|
. ' where `_type` = 1 and `_line` = 0'
|
||||||
|
. ' and `_target` in (' . placeholders($groupIds) . ')'
|
||||||
|
. ' order by `_mid` desc limit ' . $limit
|
||||||
|
);
|
||||||
|
$statement->execute($groupIds);
|
||||||
|
} catch (Throwable) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($statement->fetchAll() as $row) {
|
||||||
|
$rows[] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($rows, static fn($left, $right) => strcmp((string)$right['_mid'], (string)$left['_mid']));
|
||||||
|
|
||||||
|
$uniqueRows = [];
|
||||||
|
$seenMessageIds = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$messageId = (string)$row['_mid'];
|
||||||
|
if ($messageId === '' || isset($seenMessageIds[$messageId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seenMessageIds[$messageId] = true;
|
||||||
|
$uniqueRows[] = $row;
|
||||||
|
if (count($uniqueRows) >= $limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $uniqueRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNativeGroupMessage(
|
||||||
|
array $row,
|
||||||
|
string $currentUserId,
|
||||||
|
array $group,
|
||||||
|
?array $attachment = null,
|
||||||
|
array $users = [],
|
||||||
|
array $members = []
|
||||||
|
): array {
|
||||||
|
$groupId = (string)$row['_target'];
|
||||||
|
$from = (string)$row['_from'];
|
||||||
|
$senderUser = $users[$from] ?? [];
|
||||||
|
$senderMember = $members[$from] ?? [];
|
||||||
|
$senderAlias = trim((string)($senderMember['_alias'] ?? ''));
|
||||||
|
$senderContactAlias = trim((string)($senderUser['_alias'] ?? ''));
|
||||||
|
$senderFriendAlias = trim((string)($senderUser['_friend_alias'] ?? ''));
|
||||||
|
$senderRemarkAlias = trim((string)($senderUser['_remark_alias'] ?? ''));
|
||||||
|
$senderRawName = trim((string)($senderUser['_display_name'] ?? ''))
|
||||||
|
?: trim((string)($senderUser['_name'] ?? ''))
|
||||||
|
?: trim((string)($senderUser['_mobile'] ?? ''))
|
||||||
|
?: $from;
|
||||||
|
$senderName = $senderContactAlias
|
||||||
|
?: $senderAlias
|
||||||
|
?: $senderRawName;
|
||||||
|
$senderRole = nativeGroupRoleFromType($senderMember['_type'] ?? 0);
|
||||||
|
$messageId = (string)$row['_mid'];
|
||||||
|
$timestamp = timestampMs((string)($row['_dt'] ?? ''), $messageId);
|
||||||
|
$contentType = (int)($row['_content_type'] ?? 0);
|
||||||
|
$isRecalled = $contentType === 80;
|
||||||
|
$direction = $from === $currentUserId ? 'out' : 'in';
|
||||||
|
$attachment = nativeAttachmentFromRow($row, $attachment);
|
||||||
|
$messageType = $attachment ? (string)$attachment['type'] : (($contentType === 1 || $isRecalled) ? 'text' : 'unknown');
|
||||||
|
$text = nativeDisplayText((string)($row['_searchable_key'] ?? ''), $contentType, $attachment !== null, $isRecalled);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $messageId,
|
||||||
|
'messageUid' => $messageId,
|
||||||
|
'conversationId' => 'group-' . $groupId,
|
||||||
|
'from' => $from,
|
||||||
|
'senderRole' => $senderRole,
|
||||||
|
'senderName' => $senderName,
|
||||||
|
'senderPortrait' => (string)($senderUser['_portrait'] ?? ''),
|
||||||
|
'senderProfile' => [
|
||||||
|
'uid' => $from,
|
||||||
|
'displayName' => $senderName,
|
||||||
|
'rawName' => $senderRawName,
|
||||||
|
'contactAlias' => $senderContactAlias,
|
||||||
|
'friendAlias' => $senderFriendAlias,
|
||||||
|
'remarkAlias' => $senderRemarkAlias,
|
||||||
|
'groupAlias' => $senderAlias,
|
||||||
|
'isContact' => (bool)($senderUser['_is_friend'] ?? false),
|
||||||
|
'mobile' => (string)($senderUser['_mobile'] ?? ''),
|
||||||
|
'address' => (string)($senderUser['_address'] ?? ''),
|
||||||
|
'portrait' => (string)($senderUser['_portrait'] ?? ''),
|
||||||
|
'role' => $senderRole,
|
||||||
|
],
|
||||||
|
'to' => $groupId,
|
||||||
|
'target' => $groupId,
|
||||||
|
'direction' => $direction,
|
||||||
|
'type' => $messageType,
|
||||||
|
'contentType' => $contentType,
|
||||||
|
'text' => attachmentDisplayText($text, $attachment),
|
||||||
|
'attachment' => $attachment,
|
||||||
|
'status' => $isRecalled && $direction === 'out' ? 'recalled' : 'sent',
|
||||||
|
'deliveryStatus' => $direction === 'out' && !$isRecalled ? 'sent' : '',
|
||||||
|
'recalled' => $isRecalled,
|
||||||
|
'recallStatus' => $isRecalled ? '已撤回' : '',
|
||||||
|
'editedAt' => 0,
|
||||||
|
'timestamp' => $timestamp,
|
||||||
|
'activityTimestamp' => $timestamp,
|
||||||
|
'displayTimestamp' => $timestamp,
|
||||||
|
'conversationType' => 'group',
|
||||||
|
'raw' => [
|
||||||
|
'native' => true,
|
||||||
|
'groupName' => (string)($group['_name'] ?? ''),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function loadWebGroupMessages(PDO $appDb, string $userId, string $groupId, int $limit, bool $markRead): array
|
function loadWebGroupMessages(PDO $appDb, string $userId, string $groupId, int $limit, bool $markRead): array
|
||||||
{
|
{
|
||||||
ensureWebImAppTables();
|
ensureWebImAppTables();
|
||||||
@@ -557,6 +986,81 @@ function loadWebGroupMessages(PDO $appDb, string $userId, string $groupId, int $
|
|||||||
return $messages;
|
return $messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadNativeGroupMessages(PDO $db, string $userId, string $groupId, int $limit, bool $markRead): array
|
||||||
|
{
|
||||||
|
$role = nativeGroupRole($db, $userId, $groupId);
|
||||||
|
if ($role === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$groupStatement = $db->prepare('select * from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1');
|
||||||
|
$groupStatement->execute([$groupId]);
|
||||||
|
$group = $groupStatement->fetch();
|
||||||
|
if (!is_array($group)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$table = imUserMessagesTable($userId);
|
||||||
|
$scanLimit = min(max($limit * 4, $limit), 400);
|
||||||
|
$statement = $db->prepare(
|
||||||
|
'select `_mid`, `_dt` from `' . $table . '`'
|
||||||
|
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0 and `_target` = ?'
|
||||||
|
. ' order by `_mid` desc limit ' . $scanLimit
|
||||||
|
);
|
||||||
|
$statement->execute([$userId, $groupId]);
|
||||||
|
$userRows = $statement->fetchAll();
|
||||||
|
$messageRows = fetchMessageRows($db, array_column($userRows, '_mid'));
|
||||||
|
$globalRows = loadNativeGroupRowsFromGlobal($db, [$groupId], $scanLimit);
|
||||||
|
foreach ($globalRows as $row) {
|
||||||
|
$messageId = (string)$row['_mid'];
|
||||||
|
if ($messageId !== '') {
|
||||||
|
$messageRows[$messageId] = $row;
|
||||||
|
$userRows[] = [
|
||||||
|
'_mid' => $messageId,
|
||||||
|
'_dt' => (string)($row['_dt'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usort($userRows, static fn($left, $right) => strcmp((string)$right['_mid'], (string)$left['_mid']));
|
||||||
|
$attachmentMap = fetchAttachmentMap(imAppDb(), array_column($userRows, '_mid'));
|
||||||
|
|
||||||
|
$messages = [];
|
||||||
|
$seenMessageIds = [];
|
||||||
|
foreach ($userRows as $userRow) {
|
||||||
|
$messageId = (string)$userRow['_mid'];
|
||||||
|
if (isset($seenMessageIds[$messageId]) || !isset($messageRows[$messageId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seenMessageIds[$messageId] = true;
|
||||||
|
if (!shouldDisplayMessageForUser($messageRows[$messageId], $userId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$messages[] = $messageRows[$messageId];
|
||||||
|
if (count($messages) >= $limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$messages = array_reverse($messages);
|
||||||
|
$senderIds = array_values(array_unique(array_map(static fn(array $row): string => (string)$row['_from'], $messages)));
|
||||||
|
$users = fetchUserInfoMap($db, $senderIds, $userId);
|
||||||
|
$memberMap = fetchNativeGroupMemberMap($db, $groupId, $senderIds);
|
||||||
|
|
||||||
|
$readTimestamp = 0;
|
||||||
|
$formatted = [];
|
||||||
|
foreach ($messages as $row) {
|
||||||
|
$messageId = (string)$row['_mid'];
|
||||||
|
$message = formatNativeGroupMessage($row, $userId, $group, $attachmentMap[$messageId] ?? null, $users, $memberMap);
|
||||||
|
$readTimestamp = max($readTimestamp, (int)$message['activityTimestamp']);
|
||||||
|
$formatted[] = $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($markRead) {
|
||||||
|
markNativeGroupRead($db, $userId, $groupId, $readTimestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $formatted;
|
||||||
|
}
|
||||||
|
|
||||||
function loadWebGroupConversations(PDO $appDb, string $userId, int $limit): array
|
function loadWebGroupConversations(PDO $appDb, string $userId, int $limit): array
|
||||||
{
|
{
|
||||||
ensureWebImAppTables();
|
ensureWebImAppTables();
|
||||||
@@ -619,7 +1123,7 @@ function loadWebGroupConversations(PDO $appDb, string $userId, int $limit): arra
|
|||||||
? (messagePreviewText([
|
? (messagePreviewText([
|
||||||
'text' => attachmentDisplayText((string)$lastMessage['text'], $lastAttachment),
|
'text' => attachmentDisplayText((string)$lastMessage['text'], $lastAttachment),
|
||||||
'attachment' => $lastAttachment,
|
'attachment' => $lastAttachment,
|
||||||
]) ?: (string)$lastMessage['text'])
|
]))
|
||||||
: ($isChannel ? '公告群' : ($memberCount > 0 ? $memberCount . ' 位成员' : '群组'));
|
: ($isChannel ? '公告群' : ($memberCount > 0 ? $memberCount . ' 位成员' : '群组'));
|
||||||
$updatedAt = $lastMessage
|
$updatedAt = $lastMessage
|
||||||
? max((int)$lastMessage['created_at_ms'], (int)($lastMessage['edited_at_ms'] ?? 0))
|
? max((int)$lastMessage['created_at_ms'], (int)($lastMessage['edited_at_ms'] ?? 0))
|
||||||
@@ -649,6 +1153,152 @@ function loadWebGroupConversations(PDO $appDb, string $userId, int $limit): arra
|
|||||||
return $conversations;
|
return $conversations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadNativeGroupConversations(PDO $db, string $userId, int $limit): array
|
||||||
|
{
|
||||||
|
$statement = $db->prepare(
|
||||||
|
'select g.*, m.`_type` as `member_type`, m.`_alias` as `member_alias`'
|
||||||
|
. ' from `t_group_member` m'
|
||||||
|
. ' join `t_group` g on g.`_gid` = m.`_gid` and g.`_deleted` = 0'
|
||||||
|
. ' where m.`_mid` = ?'
|
||||||
|
. ' order by greatest(g.`_dt`, m.`_dt`) desc limit ' . min(max($limit, 50), 100)
|
||||||
|
);
|
||||||
|
$statement->execute([$userId]);
|
||||||
|
$groups = $statement->fetchAll();
|
||||||
|
if (!$groups) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$groupIds = array_values(array_unique(array_map(static fn(array $group): string => (string)$group['_gid'], $groups)));
|
||||||
|
$table = imUserMessagesTable($userId);
|
||||||
|
$scanLimit = min(max($limit * 20, 200), 1000);
|
||||||
|
$messageStatement = $db->prepare(
|
||||||
|
'select `_mid`, `_target`, `_dt` from `' . $table . '`'
|
||||||
|
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0'
|
||||||
|
. ' and `_target` in (' . placeholders($groupIds) . ')'
|
||||||
|
. ' order by `_mid` desc limit ' . $scanLimit
|
||||||
|
);
|
||||||
|
$messageStatement->execute(array_merge([$userId], $groupIds));
|
||||||
|
$scanRows = $messageStatement->fetchAll();
|
||||||
|
$messageRows = fetchMessageRows($db, array_column($scanRows, '_mid'));
|
||||||
|
$globalRows = loadNativeGroupRowsFromGlobal($db, $groupIds, $scanLimit);
|
||||||
|
foreach ($globalRows as $row) {
|
||||||
|
$messageId = (string)$row['_mid'];
|
||||||
|
if ($messageId !== '') {
|
||||||
|
$messageRows[$messageId] = $row;
|
||||||
|
$scanRows[] = [
|
||||||
|
'_mid' => $messageId,
|
||||||
|
'_target' => (string)($row['_target'] ?? ''),
|
||||||
|
'_dt' => (string)($row['_dt'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usort($scanRows, static fn($left, $right) => strcmp((string)$right['_mid'], (string)$left['_mid']));
|
||||||
|
$attachmentMap = fetchAttachmentMap(imAppDb(), array_column($scanRows, '_mid'));
|
||||||
|
$readAtByGroup = loadNativeGroupReadStateMap($db, $userId, $groupIds);
|
||||||
|
|
||||||
|
$latestByGroup = [];
|
||||||
|
$unreadByGroup = [];
|
||||||
|
$seenMessageIds = [];
|
||||||
|
foreach ($scanRows as $row) {
|
||||||
|
$messageId = (string)$row['_mid'];
|
||||||
|
if (isset($seenMessageIds[$messageId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seenMessageIds[$messageId] = true;
|
||||||
|
$groupId = (string)$row['_target'];
|
||||||
|
$message = $messageRows[$messageId] ?? null;
|
||||||
|
if ($groupId === '' || !$message || !shouldDisplayMessageForUser($message, $userId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$activityTimestamp = timestampMs((string)($message['_dt'] ?? ''), $messageId);
|
||||||
|
if (!isset($latestByGroup[$groupId]) || $activityTimestamp > (int)($latestByGroup[$groupId]['_activity_at'] ?? 0)) {
|
||||||
|
$row['_activity_at'] = $activityTimestamp;
|
||||||
|
$latestByGroup[$groupId] = $row;
|
||||||
|
}
|
||||||
|
if ((string)$message['_from'] !== $userId && !isRecallMessage($message)) {
|
||||||
|
if ($activityTimestamp > (int)($readAtByGroup[$groupId] ?? 0)) {
|
||||||
|
$unreadByGroup[$groupId] = (int)($unreadByGroup[$groupId] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$groupsById = [];
|
||||||
|
foreach ($groups as $group) {
|
||||||
|
$groupsById[(string)$group['_gid']] = $group;
|
||||||
|
}
|
||||||
|
|
||||||
|
$senderIds = [];
|
||||||
|
foreach ($latestByGroup as $row) {
|
||||||
|
$message = $messageRows[(string)$row['_mid']] ?? null;
|
||||||
|
if ($message) {
|
||||||
|
$senderIds[] = (string)$message['_from'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$users = fetchUserInfoMap($db, $senderIds, $userId);
|
||||||
|
|
||||||
|
$conversations = [];
|
||||||
|
foreach ($groupsById as $groupId => $group) {
|
||||||
|
$latestRow = $latestByGroup[$groupId] ?? null;
|
||||||
|
$messageId = $latestRow ? (string)$latestRow['_mid'] : '';
|
||||||
|
$message = $messageId !== '' && isset($messageRows[$messageId])
|
||||||
|
? formatNativeGroupMessage(
|
||||||
|
$messageRows[$messageId],
|
||||||
|
$userId,
|
||||||
|
$group,
|
||||||
|
$attachmentMap[$messageId] ?? null,
|
||||||
|
$users,
|
||||||
|
fetchNativeGroupMemberMap($db, $groupId, [(string)$messageRows[$messageId]['_from']])
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
$title = (string)($group['_name'] ?? '') ?: $groupId;
|
||||||
|
$updatedAt = $message ? (int)$message['activityTimestamp'] : (int)($group['_dt'] ?? 0);
|
||||||
|
$conversations[] = [
|
||||||
|
'id' => 'group-' . $groupId,
|
||||||
|
'type' => 'group',
|
||||||
|
'target' => $groupId,
|
||||||
|
'title' => $title,
|
||||||
|
'subtitle' => $message
|
||||||
|
? (messagePreviewText($message) ?: nativeMessageFallbackText((int)($message['contentType'] ?? 0), false))
|
||||||
|
: (((int)($group['_member_count'] ?? 0) > 0) ? (int)$group['_member_count'] . ' 位成员' : '群组'),
|
||||||
|
'unread' => (int)($unreadByGroup[$groupId] ?? 0),
|
||||||
|
'portrait' => (string)($group['_portrait'] ?? ''),
|
||||||
|
'avatarText' => mb_strtoupper(mb_substr($title, 0, 1, 'UTF-8'), 'UTF-8'),
|
||||||
|
'updatedAt' => $updatedAt,
|
||||||
|
'role' => nativeGroupRoleFromType($group['member_type'] ?? 0),
|
||||||
|
'memberCount' => (int)($group['_member_count'] ?? 0),
|
||||||
|
'raw' => [
|
||||||
|
'type' => 'group',
|
||||||
|
'target' => $groupId,
|
||||||
|
'line' => 0,
|
||||||
|
'native' => true,
|
||||||
|
'role' => nativeGroupRoleFromType($group['member_type'] ?? 0),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $conversations;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeConversationsById(array $conversations, array $extraConversations): array
|
||||||
|
{
|
||||||
|
$merged = [];
|
||||||
|
foreach (array_merge($conversations, $extraConversations) as $conversation) {
|
||||||
|
$id = (string)($conversation['id'] ?? '');
|
||||||
|
if ($id === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isset($merged[$id])) {
|
||||||
|
$merged[$id] = $conversation;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ((int)($conversation['updatedAt'] ?? 0) > (int)($merged[$id]['updatedAt'] ?? 0)) {
|
||||||
|
$merged[$id] = array_merge($merged[$id], $conversation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values($merged);
|
||||||
|
}
|
||||||
|
|
||||||
function isRecallMessage(array $row): bool
|
function isRecallMessage(array $row): bool
|
||||||
{
|
{
|
||||||
return (int)($row['_content_type'] ?? 0) === 80;
|
return (int)($row['_content_type'] ?? 0) === 80;
|
||||||
@@ -679,8 +1329,9 @@ function formatMessage(
|
|||||||
$text = (string)($row['_searchable_key'] ?? '');
|
$text = (string)($row['_searchable_key'] ?? '');
|
||||||
$timestamp = timestampMs((string)($row['_dt'] ?? ''), $messageId);
|
$timestamp = timestampMs((string)($row['_dt'] ?? ''), $messageId);
|
||||||
$direction = $from === $currentUserId ? 'out' : 'in';
|
$direction = $from === $currentUserId ? 'out' : 'in';
|
||||||
|
$attachment = nativeAttachmentFromRow($row, $attachment);
|
||||||
$messageType = $attachment ? (string)$attachment['type'] : (($contentType === 1 || $isRecalled) ? 'text' : 'unknown');
|
$messageType = $attachment ? (string)$attachment['type'] : (($contentType === 1 || $isRecalled) ? 'text' : 'unknown');
|
||||||
$messageText = $text !== '' ? $text : ($isRecalled || $attachment ? '' : '[暂不支持的消息类型]');
|
$messageText = nativeDisplayText($text, $contentType, $attachment !== null, $isRecalled);
|
||||||
$messageText = attachmentDisplayText($messageText, $attachment);
|
$messageText = attachmentDisplayText($messageText, $attachment);
|
||||||
$editedAt = (int)($editState['editedAt'] ?? 0);
|
$editedAt = (int)($editState['editedAt'] ?? 0);
|
||||||
$activityTimestamp = messageActivityTimestamp($timestamp, $editState);
|
$activityTimestamp = messageActivityTimestamp($timestamp, $editState);
|
||||||
@@ -854,21 +1505,43 @@ function loadRecentConversations(PDO $db, string $userId, int $limit): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$conversations = array_merge($conversations, loadWebGroupConversations(imAppDb(), $userId, $limit));
|
$conversations = mergeConversationsById($conversations, loadWebGroupConversations(imAppDb(), $userId, $limit));
|
||||||
} catch (Throwable) {
|
} catch (Throwable $error) {
|
||||||
|
error_log('web group conversation sync failed: ' . $error->getMessage());
|
||||||
// Keep single-chat sync available if appdata group metadata is temporarily unavailable.
|
// Keep single-chat sync available if appdata group metadata is temporarily unavailable.
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
$conversations = mergeConversationsById($conversations, loadNativeGroupConversations($db, $userId, $limit));
|
||||||
|
} catch (Throwable $error) {
|
||||||
|
error_log('native group conversation sync failed: ' . $error->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
usort($conversations, static fn($a, $b) => $b['updatedAt'] <=> $a['updatedAt']);
|
usort($conversations, static fn($a, $b) => $b['updatedAt'] <=> $a['updatedAt']);
|
||||||
|
|
||||||
return array_slice($conversations, 0, $limit);
|
return array_slice($conversations, 0, $limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function messageSyncResult(
|
||||||
|
PDO $db,
|
||||||
|
string $userId,
|
||||||
|
array $messages,
|
||||||
|
bool $includeConversations,
|
||||||
|
int $limit
|
||||||
|
): array {
|
||||||
|
$result = ['messages' => $messages];
|
||||||
|
if ($includeConversations) {
|
||||||
|
$result['conversations'] = loadRecentConversations($db, $userId, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
$payload = readJsonPayload();
|
$payload = readJsonPayload();
|
||||||
$userId = trim((string)($payload['userId'] ?? ''));
|
$userId = trim((string)($payload['userId'] ?? ''));
|
||||||
$target = trim((string)($payload['target'] ?? ''));
|
$target = trim((string)($payload['target'] ?? ''));
|
||||||
$limit = clampLimit($payload['limit'] ?? 50, 50, 100);
|
$limit = clampLimit($payload['limit'] ?? 50, 50, 100);
|
||||||
$markRead = payloadBool($payload['markRead'] ?? false);
|
$markRead = payloadBool($payload['markRead'] ?? false);
|
||||||
|
$includeConversations = payloadBool($payload['includeConversations'] ?? false);
|
||||||
$conversationType = normalizeConversationType($payload['conversationType'] ?? $payload['type'] ?? 0);
|
$conversationType = normalizeConversationType($payload['conversationType'] ?? $payload['type'] ?? 0);
|
||||||
|
|
||||||
if ($userId === '') {
|
if ($userId === '') {
|
||||||
@@ -881,15 +1554,50 @@ try {
|
|||||||
markUserDelivered($db, $userId, nowMs());
|
markUserDelivered($db, $userId, nowMs());
|
||||||
|
|
||||||
if ($target !== '') {
|
if ($target !== '') {
|
||||||
$targetId = resolveUserId($db, $target);
|
if ($conversationType === 'group') {
|
||||||
if ($conversationType === 'group' || isWebGroupTarget(imAppDb(), $target)) {
|
if (isWebGroupTarget(imAppDb(), $target)) {
|
||||||
respond(0, 'success', [
|
respond(0, 'success', messageSyncResult(
|
||||||
'messages' => loadWebGroupMessages(imAppDb(), $userId, $target, $limit, $markRead),
|
$db,
|
||||||
]);
|
$userId,
|
||||||
|
loadWebGroupMessages(imAppDb(), $userId, $target, $limit, $markRead),
|
||||||
|
$includeConversations,
|
||||||
|
$limit
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (isNativeGroupTarget($db, $target)) {
|
||||||
|
respond(0, 'success', messageSyncResult(
|
||||||
|
$db,
|
||||||
|
$userId,
|
||||||
|
loadNativeGroupMessages($db, $userId, $target, $limit, $markRead),
|
||||||
|
$includeConversations,
|
||||||
|
$limit
|
||||||
|
));
|
||||||
|
}
|
||||||
|
respond(0, 'success', messageSyncResult(
|
||||||
|
$db,
|
||||||
|
$userId,
|
||||||
|
[],
|
||||||
|
$includeConversations,
|
||||||
|
$limit
|
||||||
|
));
|
||||||
}
|
}
|
||||||
respond(0, 'success', [
|
if (isWebGroupTarget(imAppDb(), $target)) {
|
||||||
'messages' => loadConversationMessages($db, $userId, $targetId, $limit, $markRead),
|
respond(0, 'success', messageSyncResult(
|
||||||
]);
|
$db,
|
||||||
|
$userId,
|
||||||
|
loadWebGroupMessages(imAppDb(), $userId, $target, $limit, $markRead),
|
||||||
|
$includeConversations,
|
||||||
|
$limit
|
||||||
|
));
|
||||||
|
}
|
||||||
|
$targetId = resolveUserId($db, $target);
|
||||||
|
respond(0, 'success', messageSyncResult(
|
||||||
|
$db,
|
||||||
|
$userId,
|
||||||
|
loadConversationMessages($db, $userId, $targetId, $limit, $markRead),
|
||||||
|
$includeConversations,
|
||||||
|
$limit
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
respond(0, 'success', [
|
respond(0, 'success', [
|
||||||
|
|||||||
Vendored
+1
-1
@@ -7,7 +7,7 @@ header('Cache-Control: no-store');
|
|||||||
require __DIR__ . '/im_web_bootstrap.php';
|
require __DIR__ . '/im_web_bootstrap.php';
|
||||||
|
|
||||||
$defaultName = 'Web IM';
|
$defaultName = 'Web IM';
|
||||||
$defaultLogo = '/img/logo.png';
|
$defaultLogo = '/img/logo.ico';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$statement = imAppDb()->prepare(
|
$statement = imAppDb()->prepare(
|
||||||
|
|||||||
Vendored
+225
-57
@@ -6,12 +6,21 @@ header('Cache-Control: no-store');
|
|||||||
|
|
||||||
require __DIR__ . '/im_web_bootstrap.php';
|
require __DIR__ . '/im_web_bootstrap.php';
|
||||||
|
|
||||||
const WEB_MEDIA_ROOT = '/opt/im-server/media';
|
define(
|
||||||
const WEB_MEDIA_PUBLIC_PORT = 18000;
|
'WEB_MEDIA_ROOT',
|
||||||
|
webImEnv(
|
||||||
|
'JC_WEB_IM_MEDIA_ROOT',
|
||||||
|
is_dir('/var/www/im/public/media')
|
||||||
|
? '/var/www/im/public/media'
|
||||||
|
: (is_dir('/var/www/IM/public/media') ? '/var/www/IM/public/media' : '/opt/im-server/media')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
define('WEB_MEDIA_PUBLIC_PORT', (int)webImEnv('JC_WEB_IM_MEDIA_PORT', '80'));
|
||||||
const WEB_MEDIA_TYPE_IMAGE = 1;
|
const WEB_MEDIA_TYPE_IMAGE = 1;
|
||||||
const WEB_MEDIA_TYPE_FILE = 4;
|
const WEB_MEDIA_TYPE_FILE = 4;
|
||||||
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
||||||
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
||||||
|
const WEB_MINIO_PROJECT_CODE = '001';
|
||||||
|
|
||||||
function isDailyMuteActive(string $dailyStart, string $dailyEnd, int $nowMs): bool
|
function isDailyMuteActive(string $dailyStart, string $dailyEnd, int $nowMs): bool
|
||||||
{
|
{
|
||||||
@@ -72,6 +81,33 @@ function isGroupMutedForSender(PDO $appDb, string $groupId, string $role, int $n
|
|||||||
return $startsAtMs > 0 && $endsAtMs > $startsAtMs && $nowMs >= $startsAtMs && $nowMs < $endsAtMs;
|
return $startsAtMs > 0 && $endsAtMs > $startsAtMs && $nowMs >= $startsAtMs && $nowMs < $endsAtMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nativeGroupRoleFromType(mixed $type): string
|
||||||
|
{
|
||||||
|
$value = (int)$type;
|
||||||
|
if ($value === 2) {
|
||||||
|
return 'owner';
|
||||||
|
}
|
||||||
|
if ($value === 1) {
|
||||||
|
return 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'member';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchNativeGroupForSender(PDO $db, string $sender, string $groupId): array
|
||||||
|
{
|
||||||
|
$statement = $db->prepare(
|
||||||
|
'select g.`_gid`, g.`_name`, g.`_mute`, g.`_deleted`, m.`_type` as `member_type`'
|
||||||
|
. ' from `t_group` g'
|
||||||
|
. ' join `t_group_member` m on m.`_gid` = g.`_gid`'
|
||||||
|
. ' where g.`_gid` = ? and g.`_deleted` = 0 and m.`_mid` = ? limit 1'
|
||||||
|
);
|
||||||
|
$statement->execute([$groupId, $sender]);
|
||||||
|
$group = $statement->fetch();
|
||||||
|
|
||||||
|
return is_array($group) ? $group : [];
|
||||||
|
}
|
||||||
|
|
||||||
function messageRequestValue(array $payload, string $key, string $default = ''): string
|
function messageRequestValue(array $payload, string $key, string $default = ''): string
|
||||||
{
|
{
|
||||||
return trim((string)($_POST[$key] ?? $payload[$key] ?? $default));
|
return trim((string)($_POST[$key] ?? $payload[$key] ?? $default));
|
||||||
@@ -154,11 +190,114 @@ function mediaPublicBaseUrl(): string
|
|||||||
$isHttps = (string)($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string)$_SERVER['HTTPS']) !== 'off';
|
$isHttps = (string)($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string)$_SERVER['HTTPS']) !== 'off';
|
||||||
$scheme = $isHttps ? 'https' : 'http';
|
$scheme = $isHttps ? 'https' : 'http';
|
||||||
$host = trim(preg_replace('/:\d+$/', '', (string)($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '127.0.0.1')) ?: '127.0.0.1');
|
$host = trim(preg_replace('/:\d+$/', '', (string)($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '127.0.0.1')) ?: '127.0.0.1');
|
||||||
|
$port = (int)WEB_MEDIA_PUBLIC_PORT;
|
||||||
|
if ($port <= 0 || ($scheme === 'http' && $port === 80) || ($scheme === 'https' && $port === 443)) {
|
||||||
|
return $scheme . '://' . $host;
|
||||||
|
}
|
||||||
|
|
||||||
return $scheme . '://' . $host . ':' . WEB_MEDIA_PUBLIC_PORT;
|
return $scheme . '://' . $host . ':' . $port;
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveMessageAttachmentFile(array $file, string $attachmentType): array
|
function messageStorageConfig(): array
|
||||||
|
{
|
||||||
|
$config = ['enabled' => false, 'url' => ''];
|
||||||
|
foreach (['t_app_system_conf', 'system_conf'] as $table) {
|
||||||
|
try {
|
||||||
|
$statement = imAppDb()->query(
|
||||||
|
'select `conf_name`, `conf_value` from `' . $table . '`'
|
||||||
|
. " where lower(`conf_name`) in ('is_minioplus', 'minio_url') and `is_valid` = 1"
|
||||||
|
);
|
||||||
|
$rows = $statement->fetchAll();
|
||||||
|
if (!$rows) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$name = strtolower((string)($row['conf_name'] ?? ''));
|
||||||
|
if ($name === 'is_minioplus') {
|
||||||
|
$config['enabled'] = (string)($row['conf_value'] ?? '0') === '1';
|
||||||
|
} elseif ($name === 'minio_url') {
|
||||||
|
$config['url'] = rtrim(trim((string)($row['conf_value'] ?? '')), '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
} catch (Throwable $error) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $config;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveMessageAttachmentToMinio(
|
||||||
|
array $file,
|
||||||
|
string $attachmentType,
|
||||||
|
string $sender,
|
||||||
|
string $baseUrl
|
||||||
|
): array {
|
||||||
|
$size = (int)($file['size'] ?? 0);
|
||||||
|
$tmpName = (string)($file['tmp_name'] ?? '');
|
||||||
|
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
|
||||||
|
throw new RuntimeException('文件上传失败');
|
||||||
|
}
|
||||||
|
$mime = detectedUploadMime($tmpName);
|
||||||
|
if ($attachmentType === 'image') {
|
||||||
|
$mime = normalizedImageUploadMime($tmpName, $mime);
|
||||||
|
}
|
||||||
|
$originalName = safeUploadName((string)($file['name'] ?? ($attachmentType === 'image' ? 'image.jpg' : 'file')));
|
||||||
|
$content = file_get_contents($tmpName);
|
||||||
|
if ($content === false) {
|
||||||
|
throw new RuntimeException('读取上传文件失败');
|
||||||
|
}
|
||||||
|
$requestBody = json_encode([
|
||||||
|
'projectCode' => WEB_MINIO_PROJECT_CODE,
|
||||||
|
'fileScene' => 'temp',
|
||||||
|
'fileName' => $originalName,
|
||||||
|
'contentType' => $mime,
|
||||||
|
'base64Content' => base64_encode($content),
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
if (!is_string($requestBody)) {
|
||||||
|
throw new RuntimeException('生成图片上传请求失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
$handle = curl_init($baseUrl . '/project-storage/upload');
|
||||||
|
curl_setopt_array($handle, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 8,
|
||||||
|
CURLOPT_TIMEOUT => 60,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'Authorization: minio-' . WEB_MINIO_PROJECT_CODE . '-' . $sender,
|
||||||
|
],
|
||||||
|
CURLOPT_POSTFIELDS => $requestBody,
|
||||||
|
]);
|
||||||
|
$responseBody = curl_exec($handle);
|
||||||
|
$status = (int)curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
|
||||||
|
$curlError = curl_error($handle);
|
||||||
|
curl_close($handle);
|
||||||
|
$response = is_string($responseBody) ? json_decode($responseBody, true) : null;
|
||||||
|
$data = is_array($response) && is_array($response['data'] ?? null) ? $response['data'] : [];
|
||||||
|
$url = trim((string)($data['previewUrl'] ?? $data['downloadUrl'] ?? ''));
|
||||||
|
if ($status < 200 || $status >= 300 || (int)($response['code'] ?? -1) !== 0 || $url === '') {
|
||||||
|
$message = trim((string)($response['message'] ?? ''));
|
||||||
|
throw new RuntimeException(
|
||||||
|
$message !== ''
|
||||||
|
? 'MinIO上传失败:' . $message
|
||||||
|
: 'MinIO上传失败' . ($curlError !== '' ? ':' . $curlError : '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => $attachmentType,
|
||||||
|
'url' => $url,
|
||||||
|
'name' => $originalName,
|
||||||
|
'mime' => $mime,
|
||||||
|
'size' => $size,
|
||||||
|
'mediaType' => $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveMessageAttachmentFile(array $file, string $attachmentType, string $sender): array
|
||||||
{
|
{
|
||||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||||
throw new RuntimeException('文件上传失败');
|
throw new RuntimeException('文件上传失败');
|
||||||
@@ -168,10 +307,6 @@ function saveMessageAttachmentFile(array $file, string $attachmentType): array
|
|||||||
if ($size <= 0 || $size > $maxSize) {
|
if ($size <= 0 || $size > $maxSize) {
|
||||||
throw new RuntimeException($attachmentType === 'image' ? '图片不能超过10MB' : '文件不能超过50MB');
|
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'] ?? '');
|
$tmpName = (string)($file['tmp_name'] ?? '');
|
||||||
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
|
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
|
||||||
throw new RuntimeException('文件上传失败');
|
throw new RuntimeException('文件上传失败');
|
||||||
@@ -181,6 +316,17 @@ function saveMessageAttachmentFile(array $file, string $attachmentType): array
|
|||||||
$mime = normalizedImageUploadMime($tmpName, $mime);
|
$mime = normalizedImageUploadMime($tmpName, $mime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$storage = messageStorageConfig();
|
||||||
|
if ($storage['enabled']) {
|
||||||
|
if ($storage['url'] === '') {
|
||||||
|
throw new RuntimeException('MinIO已开启但未配置服务地址');
|
||||||
|
}
|
||||||
|
return saveMessageAttachmentToMinio($file, $attachmentType, $sender, $storage['url']);
|
||||||
|
}
|
||||||
|
if (!is_dir(WEB_MEDIA_ROOT) || !is_writable(WEB_MEDIA_ROOT)) {
|
||||||
|
throw new RuntimeException('媒体存储目录不可写');
|
||||||
|
}
|
||||||
|
|
||||||
$mediaType = $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE;
|
$mediaType = $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE;
|
||||||
$datePath = 'fs/' . $mediaType . '/' . date('Y/m/d/H/i/s');
|
$datePath = 'fs/' . $mediaType . '/' . date('Y/m/d/H/i/s');
|
||||||
$targetDir = rtrim(WEB_MEDIA_ROOT, '/') . '/' . $datePath;
|
$targetDir = rtrim(WEB_MEDIA_ROOT, '/') . '/' . $datePath;
|
||||||
@@ -245,7 +391,7 @@ function storeMessageAttachment(
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachmentFromRequest(string $messageType): ?array
|
function attachmentFromRequest(string $messageType, string $sender): ?array
|
||||||
{
|
{
|
||||||
if (!in_array($messageType, ['image', 'file'], true)) {
|
if (!in_array($messageType, ['image', 'file'], true)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -256,7 +402,61 @@ function attachmentFromRequest(string $messageType): ?array
|
|||||||
respond(400, '缺少上传文件');
|
respond(400, '缺少上传文件');
|
||||||
}
|
}
|
||||||
|
|
||||||
return saveMessageAttachmentFile($attachmentFile, $messageType);
|
return saveMessageAttachmentFile($attachmentFile, $messageType, $sender);
|
||||||
|
}
|
||||||
|
|
||||||
|
function messagePayloadBody(string $text, ?array $attachment): array
|
||||||
|
{
|
||||||
|
$pushContent = $text !== ''
|
||||||
|
? $text
|
||||||
|
: ($attachment
|
||||||
|
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
||||||
|
: $text);
|
||||||
|
$payloadBody = [
|
||||||
|
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
|
||||||
|
'searchableContent' => $pushContent,
|
||||||
|
'pushContent' => $pushContent,
|
||||||
|
'content' => $text,
|
||||||
|
'persistFlag' => 3,
|
||||||
|
];
|
||||||
|
if ($attachment) {
|
||||||
|
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
|
||||||
|
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
|
||||||
|
$payloadBody['extra'] = json_encode([
|
||||||
|
'name' => (string)$attachment['name'],
|
||||||
|
'mime' => (string)$attachment['mime'],
|
||||||
|
'size' => (int)$attachment['size'],
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payloadBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendNativeGroupMessage(string $sender, string $target, string $text, ?array $attachment): array
|
||||||
|
{
|
||||||
|
$result = requireImSuccess(imAdminPost('/admin/message/send', [
|
||||||
|
'sender' => $sender,
|
||||||
|
'conv' => [
|
||||||
|
'type' => 1,
|
||||||
|
'target' => $target,
|
||||||
|
'line' => 0,
|
||||||
|
],
|
||||||
|
'payload' => messagePayloadBody($text, $attachment),
|
||||||
|
'userMessage' => true,
|
||||||
|
]), '原生群消息发送失败');
|
||||||
|
|
||||||
|
$messageUid = isset($result['messageUid']) ? (string)$result['messageUid'] : '';
|
||||||
|
$timestamp = (int)($result['timestamp'] ?? webImNowMs());
|
||||||
|
if ($attachment && $messageUid !== '') {
|
||||||
|
ensureWebImAppTables();
|
||||||
|
storeMessageAttachment(imAppDb(), $messageUid, 'group', $target, $sender, $attachment, $timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'messageUid' => $messageUid !== '' ? $messageUid : null,
|
||||||
|
'timestamp' => $timestamp,
|
||||||
|
'attachment' => $attachment,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$contentTypeHeader = (string)($_SERVER['CONTENT_TYPE'] ?? '');
|
$contentTypeHeader = (string)($_SERVER['CONTENT_TYPE'] ?? '');
|
||||||
@@ -288,7 +488,17 @@ try {
|
|||||||
$memberStatement->execute([$target, $sender]);
|
$memberStatement->execute([$target, $sender]);
|
||||||
$group = $memberStatement->fetch();
|
$group = $memberStatement->fetch();
|
||||||
if (!$group) {
|
if (!$group) {
|
||||||
respond(403, '你不在该群组中');
|
$nativeGroup = fetchNativeGroupForSender(imDb(), $sender, $target);
|
||||||
|
if (!$nativeGroup) {
|
||||||
|
respond(403, '你不在该群组中');
|
||||||
|
}
|
||||||
|
$attachment = attachmentFromRequest($messageType, $sender);
|
||||||
|
$nativeResult = sendNativeGroupMessage($sender, $target, $text, $attachment);
|
||||||
|
respond(0, 'success', [
|
||||||
|
...$nativeResult,
|
||||||
|
'senderRole' => nativeGroupRoleFromType($nativeGroup['member_type'] ?? 0),
|
||||||
|
'native' => true,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
$category = (string)($group['category'] ?? 'group');
|
$category = (string)($group['category'] ?? 'group');
|
||||||
$role = (string)($group['role'] ?? 'member');
|
$role = (string)($group['role'] ?? 'member');
|
||||||
@@ -300,7 +510,7 @@ try {
|
|||||||
if ($category !== 'channel' && isGroupMutedForSender($appDb, $target, $role, $now)) {
|
if ($category !== 'channel' && isGroupMutedForSender($appDb, $target, $role, $now)) {
|
||||||
respond(403, '当前群组处于禁言时段');
|
respond(403, '当前群组处于禁言时段');
|
||||||
}
|
}
|
||||||
$attachment = attachmentFromRequest($messageType);
|
$attachment = attachmentFromRequest($messageType, $sender);
|
||||||
$messageUid = webImMessageUid();
|
$messageUid = webImMessageUid();
|
||||||
$insert = $appDb->prepare(
|
$insert = $appDb->prepare(
|
||||||
'insert into `t_web_group_messages`'
|
'insert into `t_web_group_messages`'
|
||||||
@@ -316,27 +526,6 @@ try {
|
|||||||
|
|
||||||
$nativeError = '';
|
$nativeError = '';
|
||||||
try {
|
try {
|
||||||
$pushContent = $text !== ''
|
|
||||||
? $text
|
|
||||||
: ($attachment
|
|
||||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
|
||||||
: $text);
|
|
||||||
$payloadBody = [
|
|
||||||
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
|
|
||||||
'searchableContent' => $pushContent,
|
|
||||||
'pushContent' => $pushContent,
|
|
||||||
'content' => $text,
|
|
||||||
'persistFlag' => 3,
|
|
||||||
];
|
|
||||||
if ($attachment) {
|
|
||||||
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
|
|
||||||
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
|
|
||||||
$payloadBody['extra'] = json_encode([
|
|
||||||
'name' => (string)$attachment['name'],
|
|
||||||
'mime' => (string)$attachment['mime'],
|
|
||||||
'size' => (int)$attachment['size'],
|
|
||||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
||||||
}
|
|
||||||
requireImSuccess(imAdminPost('/admin/message/send', [
|
requireImSuccess(imAdminPost('/admin/message/send', [
|
||||||
'sender' => $sender,
|
'sender' => $sender,
|
||||||
'conv' => [
|
'conv' => [
|
||||||
@@ -344,7 +533,7 @@ try {
|
|||||||
'target' => $target,
|
'target' => $target,
|
||||||
'line' => 0,
|
'line' => 0,
|
||||||
],
|
],
|
||||||
'payload' => $payloadBody,
|
'payload' => messagePayloadBody($text, $attachment),
|
||||||
'userMessage' => true,
|
'userMessage' => true,
|
||||||
]), '原生群消息发送失败');
|
]), '原生群消息发送失败');
|
||||||
} catch (Throwable $error) {
|
} catch (Throwable $error) {
|
||||||
@@ -360,28 +549,7 @@ try {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$attachment = attachmentFromRequest($messageType);
|
$attachment = attachmentFromRequest($messageType, $sender);
|
||||||
$pushContent = $text !== ''
|
|
||||||
? $text
|
|
||||||
: ($attachment
|
|
||||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
|
||||||
: $text);
|
|
||||||
$payloadBody = [
|
|
||||||
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
|
|
||||||
'searchableContent' => $pushContent,
|
|
||||||
'pushContent' => $pushContent,
|
|
||||||
'content' => $text,
|
|
||||||
'persistFlag' => 3,
|
|
||||||
];
|
|
||||||
if ($attachment) {
|
|
||||||
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
|
|
||||||
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
|
|
||||||
$payloadBody['extra'] = json_encode([
|
|
||||||
'name' => (string)$attachment['name'],
|
|
||||||
'mime' => (string)$attachment['mime'],
|
|
||||||
'size' => (int)$attachment['size'],
|
|
||||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = requireImSuccess(imAdminPost('/admin/message/send', [
|
$result = requireImSuccess(imAdminPost('/admin/message/send', [
|
||||||
'sender' => $sender,
|
'sender' => $sender,
|
||||||
@@ -390,7 +558,7 @@ try {
|
|||||||
'target' => $target,
|
'target' => $target,
|
||||||
'line' => 0,
|
'line' => 0,
|
||||||
],
|
],
|
||||||
'payload' => $payloadBody,
|
'payload' => messagePayloadBody($text, $attachment),
|
||||||
'userMessage' => true,
|
'userMessage' => true,
|
||||||
]), '发送失败');
|
]), '发送失败');
|
||||||
|
|
||||||
|
|||||||
Vendored
+21
-12
@@ -1,12 +1,21 @@
|
|||||||
window.JC_WEB_IM_CONFIG = {
|
(() => {
|
||||||
adminServer: 'http://winchen.ezpay.asia:8024',
|
const protocol = window.location.protocol || 'http:';
|
||||||
appServer: 'http://winchen.ezpay.asia:8888',
|
const host = window.location.hostname || '127.0.0.1';
|
||||||
qrAppServer: 'http://winchen.ezpay.asia:18888',
|
|
||||||
platformConfigApi: '/api/platform_config.php',
|
window.JC_WEB_IM_CONFIG = {
|
||||||
registerConfigApi: 'http://winchen.ezpay.asia:8024/api/register-config',
|
adminServer: `${protocol}//${host}:8100`,
|
||||||
imServerHost: 'winchen.ezpay.asia',
|
appServer: `${protocol}//${host}:8888`,
|
||||||
imRoutePort: 18000,
|
platformConfigApi: '/api/platform_config.php',
|
||||||
useWss: false,
|
registerConfigApi: `${protocol}//${host}:8100/api/register-config`,
|
||||||
wsPath: '/ws',
|
registerPhoneExistsApi: `${protocol}//${host}:8100/api/register-phone-exists`,
|
||||||
sdkMode: 'http'
|
imServerHost: host,
|
||||||
};
|
imRoutePort: 80,
|
||||||
|
useWss: false,
|
||||||
|
wsPath: '/ws',
|
||||||
|
sdkMode: 'http',
|
||||||
|
syncIntervalMs: 15000,
|
||||||
|
sseEnabled: true,
|
||||||
|
sseTicketApi: '/api/sse_ticket.php',
|
||||||
|
sseUrl: '/ws-events'
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|||||||
+126
-181
@@ -492,183 +492,6 @@ button {
|
|||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
.documented-shell {
|
|
||||||
min-height: 100dvh;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 260px minmax(0, 1fr);
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at top left, rgba(23, 125, 114, 0.08), transparent 28%),
|
|
||||||
linear-gradient(180deg, #f4f8f7 0%, #eef3f7 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-rail {
|
|
||||||
min-height: 0;
|
|
||||||
display: grid;
|
|
||||||
align-content: start;
|
|
||||||
gap: 14px;
|
|
||||||
padding: 24px 20px;
|
|
||||||
border-right: 1px solid var(--line);
|
|
||||||
background: rgba(255, 255, 255, 0.82);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-avatar {
|
|
||||||
width: 64px;
|
|
||||||
height: 64px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 16px;
|
|
||||||
background: #dff4ef;
|
|
||||||
color: var(--primary-strong);
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-avatar img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
-o-object-fit: cover;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-rail-copy {
|
|
||||||
display: grid;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-rail-copy strong {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-rail-copy small {
|
|
||||||
color: var(--muted);
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-rail-button {
|
|
||||||
min-height: 40px;
|
|
||||||
padding: 0 14px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--primary);
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 700;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-rail-button.ghost {
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: #fff;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-main {
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 28px;
|
|
||||||
display: grid;
|
|
||||||
align-content: start;
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: end;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-header h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-header p {
|
|
||||||
margin: 6px 0 0;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-status {
|
|
||||||
min-width: 180px;
|
|
||||||
display: grid;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 14px;
|
|
||||||
background: rgba(255, 255, 255, 0.88);
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-status span {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-status strong {
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-card {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 20px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 16px;
|
|
||||||
background: rgba(255, 255, 255, 0.88);
|
|
||||||
box-shadow: 0 16px 36px rgba(24, 33, 47, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-card h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-text {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--muted);
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-list {
|
|
||||||
margin: 0;
|
|
||||||
padding-left: 18px;
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-list.muted {
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-meta {
|
|
||||||
margin: 0;
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-meta div {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 110px minmax(0, 1fr);
|
|
||||||
gap: 12px;
|
|
||||||
padding-top: 10px;
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-meta dt {
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-meta dd {
|
|
||||||
margin: 0;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-password-panel {
|
|
||||||
width: min(100%, 360px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.navigation-rail {
|
.navigation-rail {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1434,6 +1257,14 @@ button {
|
|||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty-list-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.empty-chat {
|
.empty-chat {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
@@ -2094,6 +1925,17 @@ button {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.group-member-row .group-member-role {
|
||||||
|
width: -moz-fit-content;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border: 1px solid #b8d8d1;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #edf8f5;
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.group-management-dialog {
|
.group-management-dialog {
|
||||||
width: min(520px, calc(100% - 32px));
|
width: min(520px, calc(100% - 32px));
|
||||||
}
|
}
|
||||||
@@ -2154,7 +1996,9 @@ button {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
padding: 24px;
|
padding: 24px 24px 72px;
|
||||||
|
scroll-padding-bottom: 72px;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-row {
|
.message-row {
|
||||||
@@ -2970,9 +2814,11 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.composer-tools button {
|
.composer-tools button {
|
||||||
width: 32px;
|
width: 34px;
|
||||||
height: 32px;
|
height: 34px;
|
||||||
border: 0;
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid transparent;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -2983,6 +2829,105 @@ button {
|
|||||||
background: #edf2f5;
|
background: #edf2f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.composer-tools button:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-attachment-preview {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 16px 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-attachment-item {
|
||||||
|
position: relative;
|
||||||
|
width: 88px;
|
||||||
|
min-width: 88px;
|
||||||
|
height: 88px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #edf2f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-attachment-item img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
-o-object-fit: cover;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-attachment-file-icon {
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-attachment-name {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 4px 6px;
|
||||||
|
background: rgba(18, 28, 40, 0.72);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-attachment-item > button {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(18, 28, 40, 0.74);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-tool-button {
|
||||||
|
border-color: rgba(23, 125, 114, 0.28);
|
||||||
|
background: rgba(23, 125, 114, 0.1);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-tool-button.file-tool {
|
||||||
|
border-color: rgba(216, 78, 63, 0.28);
|
||||||
|
background: rgba(216, 78, 63, 0.1);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-tool-button:hover {
|
||||||
|
border-color: currentColor;
|
||||||
|
background: rgba(23, 125, 114, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-tool-button.file-tool:hover {
|
||||||
|
background: rgba(216, 78, 63, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-tool-icon {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.9;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
.emoji-picker {
|
.emoji-picker {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 16px;
|
left: 16px;
|
||||||
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 958 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB |
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="img/5IuOieu9PF8MJtp43kD2kCkdLVjVwDp7.ico"><script src="config/runtime-config.js"></script><script defer="defer" src="js/chunk-vendors.86756863.js"></script><script defer="defer" src="js/app.631b8b2d.js"></script><link href="css/app.2c52dabb.css" rel="stylesheet"></head><body><noscript>请启用 JavaScript 后使用 Web IM。</noscript><div id="app"></div></body></html>
|
||||||
Vendored
-3213
File diff suppressed because it is too large
Load Diff
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+9903
File diff suppressed because it is too large
Load Diff
Vendored
+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 */ R1: function() { return /* binding */ unref; },
|
||||||
/* harmony export */ X2: function() { return /* binding */ ReactiveEffect; },
|
/* harmony export */ X2: function() { return /* binding */ ReactiveEffect; },
|
||||||
/* harmony export */ bl: function() { return /* binding */ resetTracking; },
|
/* harmony export */ bl: function() { return /* binding */ resetTracking; },
|
||||||
|
/* harmony export */ fE: function() { return /* binding */ isShallow; },
|
||||||
/* harmony export */ g8: function() { return /* binding */ isReactive; },
|
/* harmony export */ g8: function() { return /* binding */ isReactive; },
|
||||||
/* harmony export */ hV: function() { return /* binding */ traverse; },
|
/* harmony export */ hV: function() { return /* binding */ traverse; },
|
||||||
/* harmony export */ hZ: function() { return /* binding */ trigger; },
|
/* harmony export */ hZ: function() { return /* binding */ trigger; },
|
||||||
@@ -23,13 +24,14 @@
|
|||||||
/* harmony export */ jr: function() { return /* binding */ onScopeDispose; },
|
/* harmony export */ jr: function() { return /* binding */ onScopeDispose; },
|
||||||
/* harmony export */ ju: function() { return /* binding */ isProxy; },
|
/* harmony export */ ju: function() { return /* binding */ isProxy; },
|
||||||
/* harmony export */ o5: function() { return /* binding */ getCurrentScope; },
|
/* harmony export */ o5: function() { return /* binding */ getCurrentScope; },
|
||||||
|
/* harmony export */ qA: function() { return /* binding */ shallowReadArray; },
|
||||||
/* harmony export */ u4: function() { return /* binding */ track; },
|
/* harmony export */ u4: function() { return /* binding */ track; },
|
||||||
/* harmony export */ uY: function() { return /* binding */ effectScope; },
|
/* harmony export */ uY: function() { return /* binding */ effectScope; },
|
||||||
/* harmony export */ ux: function() { return /* binding */ toRaw; },
|
/* harmony export */ ux: function() { return /* binding */ toRaw; },
|
||||||
/* harmony export */ wB: function() { return /* binding */ watch; },
|
/* harmony export */ wB: function() { return /* binding */ watch; },
|
||||||
/* harmony export */ yC: function() { return /* binding */ EffectScope; }
|
/* harmony export */ yC: function() { return /* binding */ EffectScope; }
|
||||||
/* harmony export */ });
|
/* 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 extend;
|
||||||
/* unused harmony import specifier */ var toRawType;
|
/* unused harmony import specifier */ var toRawType;
|
||||||
/* unused harmony import specifier */ var isFunction;
|
/* unused harmony import specifier */ var isFunction;
|
||||||
@@ -1825,6 +1827,10 @@ function traverse(value, depth = Infinity, seen) {
|
|||||||
return value;
|
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 */ Wv: function() { return /* binding */ createBlock; },
|
||||||
/* harmony export */ bo: function() { return /* binding */ withDirectives; },
|
/* harmony export */ bo: function() { return /* binding */ withDirectives; },
|
||||||
/* harmony export */ dY: function() { return /* binding */ nextTick; },
|
/* 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 */ qL: function() { return /* binding */ callWithAsyncErrorHandling; },
|
||||||
/* harmony export */ uX: function() { return /* binding */ openBlock; },
|
/* harmony export */ uX: function() { return /* binding */ openBlock; },
|
||||||
/* harmony export */ wB: function() { return /* binding */ watch; }
|
/* harmony export */ wB: function() { return /* binding */ watch; }
|
||||||
/* harmony export */ });
|
/* 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 pauseTracking;
|
||||||
/* unused harmony import specifier */ var resetTracking;
|
/* unused harmony import specifier */ var resetTracking;
|
||||||
/* unused harmony import specifier */ var isRef;
|
/* 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 shallowRef;
|
||||||
/* unused harmony import specifier */ var isReactive;
|
/* unused harmony import specifier */ var isReactive;
|
||||||
/* unused harmony import specifier */ var ref;
|
/* 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 customRef;
|
||||||
/* unused harmony import specifier */ var track;
|
/* unused harmony import specifier */ var track;
|
||||||
|
/* unused harmony import specifier */ var isShallow;
|
||||||
/* unused harmony import specifier */ var isString;
|
/* unused harmony import specifier */ var isString;
|
||||||
/* unused harmony import specifier */ var isFunction;
|
/* unused harmony import specifier */ var isFunction;
|
||||||
/* unused harmony import specifier */ var extend;
|
/* 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 isRegExp;
|
||||||
/* unused harmony import specifier */ var camelize;
|
/* unused harmony import specifier */ var camelize;
|
||||||
/* unused harmony import specifier */ var capitalize;
|
/* unused harmony import specifier */ var capitalize;
|
||||||
/* unused harmony import specifier */ var isObject;
|
|
||||||
/* unused harmony import specifier */ var isSymbol;
|
/* unused harmony import specifier */ var isSymbol;
|
||||||
/* unused harmony import specifier */ var toHandlerKey;
|
/* unused harmony import specifier */ var toHandlerKey;
|
||||||
/* unused harmony import specifier */ var isGloballyAllowed;
|
/* unused harmony import specifier */ var isGloballyAllowed;
|
||||||
/* unused harmony import specifier */ var NOOP;
|
/* unused harmony import specifier */ var NOOP;
|
||||||
/* unused harmony import specifier */ var isPromise;
|
/* unused harmony import specifier */ var isPromise;
|
||||||
/* unused harmony import specifier */ var makeMap;
|
/* unused harmony import specifier */ var makeMap;
|
||||||
|
/* unused harmony import specifier */ var isObject;
|
||||||
/* unused harmony import specifier */ var toRawType;
|
/* unused harmony import specifier */ var toRawType;
|
||||||
/* unused harmony import specifier */ var hyphenate;
|
/* unused harmony import specifier */ var hyphenate;
|
||||||
/* unused harmony import specifier */ var hasChanged;
|
/* 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 isTargetMathML = target => typeof MathMLElement === "function" && target instanceof MathMLElement;
|
||||||
const resolveTarget = (props, select) => {
|
const resolveTarget = (props, select) => {
|
||||||
const targetSelector = props && props.to;
|
const targetSelector = props && props.to;
|
||||||
if (isString(targetSelector)) {
|
if ((0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isString */ .Kg)(targetSelector)) {
|
||||||
if (!select) {
|
if (!select) {
|
||||||
false && 0;
|
false && 0;
|
||||||
return null;
|
return null;
|
||||||
@@ -3190,7 +3198,7 @@ const resolveTarget = (props, select) => {
|
|||||||
return targetSelector;
|
return targetSelector;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const TeleportImpl = (/* unused pure expression or super */ null && ({
|
const TeleportImpl = {
|
||||||
name: "Teleport",
|
name: "Teleport",
|
||||||
__isTeleport: true,
|
__isTeleport: true,
|
||||||
process(n1, n2, container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds, optimized, internals) {
|
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,
|
move: moveTeleport,
|
||||||
hydrate: hydrateTeleport
|
hydrate: hydrateTeleport
|
||||||
}));
|
};
|
||||||
function moveTeleport(vnode, container, parentAnchor, {
|
function moveTeleport(vnode, container, parentAnchor, {
|
||||||
o: {
|
o: {
|
||||||
insert
|
insert
|
||||||
@@ -3408,7 +3416,7 @@ function hydrateTeleport(node, vnode, parentComponent, parentSuspense, slotScope
|
|||||||
}
|
}
|
||||||
return vnode.anchor && nextSibling(vnode.anchor);
|
return vnode.anchor && nextSibling(vnode.anchor);
|
||||||
}
|
}
|
||||||
const Teleport = (/* unused pure expression or super */ null && (TeleportImpl));
|
const Teleport = TeleportImpl;
|
||||||
function updateCssVars(vnode, isDisabled) {
|
function updateCssVars(vnode, isDisabled) {
|
||||||
const ctx = vnode.ctx;
|
const ctx = vnode.ctx;
|
||||||
if (ctx && ctx.ut) {
|
if (ctx && ctx.ut) {
|
||||||
@@ -3774,11 +3782,11 @@ function getTransitionRawChildren(children, keepComment = false, parentKey) {
|
|||||||
/*! #__NO_SIDE_EFFECTS__ */
|
/*! #__NO_SIDE_EFFECTS__ */
|
||||||
// @__NO_SIDE_EFFECTS__
|
// @__NO_SIDE_EFFECTS__
|
||||||
function defineComponent(options, extraOptions) {
|
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
|
// #8236: extend call and options.name access are considered side-effects
|
||||||
// by Rollup, so we have to wrap it in a pure-annotated IIFE.
|
// by Rollup, so we have to wrap it in a pure-annotated IIFE.
|
||||||
/* @__PURE__ */
|
/* @__PURE__ */
|
||||||
(() => extend({
|
(() => (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .extend */ .X$)({
|
||||||
name: options.name
|
name: options.name
|
||||||
}, extraOptions, {
|
}, extraOptions, {
|
||||||
setup: options
|
setup: options
|
||||||
@@ -4998,17 +5006,17 @@ function resolve(registry, name) {
|
|||||||
function renderList(source, renderItem, cache, index) {
|
function renderList(source, renderItem, cache, index) {
|
||||||
let ret;
|
let ret;
|
||||||
const cached = cache && cache[index];
|
const cached = cache && cache[index];
|
||||||
const sourceIsArray = isArray(source);
|
const sourceIsArray = (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isArray */ .cy)(source);
|
||||||
if (sourceIsArray || isString(source)) {
|
if (sourceIsArray || (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isString */ .Kg)(source)) {
|
||||||
const sourceIsReactiveArray = sourceIsArray && isReactive(source);
|
const sourceIsReactiveArray = sourceIsArray && (0,_vue_reactivity__WEBPACK_IMPORTED_MODULE_20__/* .isReactive */ .g8)(source);
|
||||||
let needsWrap = false;
|
let needsWrap = false;
|
||||||
if (sourceIsReactiveArray) {
|
if (sourceIsReactiveArray) {
|
||||||
needsWrap = !isShallow(source);
|
needsWrap = !(0,_vue_reactivity__WEBPACK_IMPORTED_MODULE_20__/* .isShallow */ .fE)(source);
|
||||||
source = shallowReadArray(source);
|
source = (0,_vue_reactivity__WEBPACK_IMPORTED_MODULE_20__/* .shallowReadArray */ .qA)(source);
|
||||||
}
|
}
|
||||||
ret = new Array(source.length);
|
ret = new Array(source.length);
|
||||||
for (let i = 0, l = source.length; i < l; i++) {
|
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") {
|
} else if (typeof source === "number") {
|
||||||
if (false) // removed by dead control flow
|
if (false) // removed by dead control flow
|
||||||
@@ -5017,7 +5025,7 @@ function renderList(source, renderItem, cache, index) {
|
|||||||
for (let i = 0; i < source; i++) {
|
for (let i = 0; i < source; i++) {
|
||||||
ret[i] = renderItem(i + 1, i, void 0, cached && cached[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]) {
|
if (source[Symbol.iterator]) {
|
||||||
ret = Array.from(source, (item, i) => renderItem(item, i, void 0, cached && cached[i]));
|
ret = Array.from(source, (item, i) => renderItem(item, i, void 0, cached && cached[i]));
|
||||||
} else {
|
} else {
|
||||||
@@ -9183,7 +9191,7 @@ function finishComponentSetup(instance, isSSR, skipOptions) {
|
|||||||
try {
|
try {
|
||||||
applyOptions(instance);
|
applyOptions(instance);
|
||||||
} finally {
|
} finally {
|
||||||
(0,_vue_reactivity__WEBPACK_IMPORTED_MODULE_20__/* .resetTracking */ .bl)();
|
;(0,_vue_reactivity__WEBPACK_IMPORTED_MODULE_20__/* .resetTracking */ .bl)();
|
||||||
reset();
|
reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9192,7 +9200,7 @@ function finishComponentSetup(instance, isSSR, skipOptions) {
|
|||||||
}
|
}
|
||||||
const attrsProxyHandlers = false ? 0 : {
|
const attrsProxyHandlers = false ? 0 : {
|
||||||
get(target, key) {
|
get(target, key) {
|
||||||
(0,_vue_reactivity__WEBPACK_IMPORTED_MODULE_20__/* .track */ .u4)(target, "get", "");
|
;(0,_vue_reactivity__WEBPACK_IMPORTED_MODULE_20__/* .track */ .u4)(target, "get", "");
|
||||||
return target[key];
|
return target[key];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -9275,7 +9283,7 @@ const computed = (getterOrOptions, debugOptions) => {
|
|||||||
function h(type, propsOrChildren, children) {
|
function h(type, propsOrChildren, children) {
|
||||||
const l = arguments.length;
|
const l = arguments.length;
|
||||||
if (l === 2) {
|
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)) {
|
if (isVNode(propsOrChildren)) {
|
||||||
return createVNode(type, null, [propsOrChildren]);
|
return createVNode(type, null, [propsOrChildren]);
|
||||||
}
|
}
|
||||||
@@ -9449,6 +9457,7 @@ const DeprecationTypes = null;
|
|||||||
/* harmony export */ __webpack_require__.d(__webpack_exports__, [
|
/* harmony export */ __webpack_require__.d(__webpack_exports__, [
|
||||||
/* harmony export */ "EW", 0, /* binding */ computed,
|
/* harmony export */ "EW", 0, /* binding */ computed,
|
||||||
/* harmony export */ "FK", 0, /* binding */ Fragment,
|
/* harmony export */ "FK", 0, /* binding */ Fragment,
|
||||||
|
/* harmony export */ "Im", 0, /* binding */ Teleport,
|
||||||
/* harmony export */ "bF", 0, /* binding */ createVNode,
|
/* harmony export */ "bF", 0, /* binding */ createVNode,
|
||||||
/* harmony export */ "sV", 0, /* binding */ onMounted,
|
/* harmony export */ "sV", 0, /* binding */ onMounted,
|
||||||
/* harmony export */ "xo", 0, /* binding */ onBeforeUnmount
|
/* harmony export */ "xo", 0, /* binding */ onBeforeUnmount
|
||||||
@@ -9989,7 +9998,7 @@ const stringifySymbol = (v, i = "") => {
|
|||||||
/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
|
/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
|
||||||
|
|
||||||
"use strict";
|
"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 BaseTransitionPropsValidators;
|
||||||
/* unused harmony import specifier */ var h;
|
/* unused harmony import specifier */ var h;
|
||||||
/* unused harmony import specifier */ var BaseTransition;
|
/* 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 resolveTransitionHooks;
|
||||||
/* unused harmony import specifier */ var getTransitionRawChildren;
|
/* unused harmony import specifier */ var getTransitionRawChildren;
|
||||||
/* unused harmony import specifier */ var createVNode;
|
/* unused harmony import specifier */ var createVNode;
|
||||||
/* unused harmony import specifier */ var nextTick;
|
|
||||||
/* unused harmony import specifier */ var createHydrationRenderer;
|
/* unused harmony import specifier */ var createHydrationRenderer;
|
||||||
/* unused harmony import specifier */ var isRuntimeOnly;
|
/* unused harmony import specifier */ var isRuntimeOnly;
|
||||||
/* unused harmony import specifier */ var toRaw;
|
/* 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 looseIndexOf;
|
||||||
/* unused harmony import specifier */ var isSet;
|
/* unused harmony import specifier */ var isSet;
|
||||||
/* unused harmony import specifier */ var looseEqual;
|
/* 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 isHTMLTag;
|
||||||
/* unused harmony import specifier */ var isSVGTag;
|
/* unused harmony import specifier */ var isSVGTag;
|
||||||
/* unused harmony import specifier */ var isMathMLTag;
|
/* unused harmony import specifier */ var isMathMLTag;
|
||||||
@@ -10807,7 +10813,7 @@ function createInvoker(initialValue, instance) {
|
|||||||
} else if (e._vts <= invoker.attached) {
|
} else if (e._vts <= invoker.attached) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
(0,_vue_runtime_core__WEBPACK_IMPORTED_MODULE_18__/* .callWithAsyncErrorHandling */ .qL)(patchStopImmediatePropagation(e, invoker.value), instance, 5, [e]);
|
;(0,_vue_runtime_core__WEBPACK_IMPORTED_MODULE_18__/* .callWithAsyncErrorHandling */ .qL)(patchStopImmediatePropagation(e, invoker.value), instance, 5, [e]);
|
||||||
};
|
};
|
||||||
invoker.value = initialValue;
|
invoker.value = initialValue;
|
||||||
invoker.attached = getNow();
|
invoker.attached = getNow();
|
||||||
@@ -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
|
// <select multiple> value need to be deep traversed
|
||||||
deep: true,
|
deep: true,
|
||||||
created(el, {
|
created(el, {
|
||||||
@@ -11580,12 +11586,12 @@ const vModelSelect = (/* unused pure expression or super */ null && ({
|
|||||||
number
|
number
|
||||||
}
|
}
|
||||||
}, vnode) {
|
}, vnode) {
|
||||||
const isSetModel = isSet(value);
|
const isSetModel = (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isSet */ .vM)(value);
|
||||||
addEventListener(el, "change", () => {
|
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[assignKey](el.multiple ? isSetModel ? new Set(selectedVal) : selectedVal : selectedVal[0]);
|
||||||
el._assigning = true;
|
el._assigning = true;
|
||||||
nextTick(() => {
|
(0,_vue_runtime_core__WEBPACK_IMPORTED_MODULE_18__/* .nextTick */ .dY)(() => {
|
||||||
el._assigning = false;
|
el._assigning = false;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -11608,11 +11614,11 @@ const vModelSelect = (/* unused pure expression or super */ null && ({
|
|||||||
setSelected(el, value);
|
setSelected(el, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
};
|
||||||
function setSelected(el, value) {
|
function setSelected(el, value) {
|
||||||
const isMultiple = el.multiple;
|
const isMultiple = el.multiple;
|
||||||
const isArrayValue = isArray(value);
|
const isArrayValue = (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isArray */ .cy)(value);
|
||||||
if (isMultiple && !isArrayValue && !isSet(value)) {
|
if (isMultiple && !isArrayValue && !(0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isSet */ .vM)(value)) {
|
||||||
false && 0;
|
false && 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -11625,12 +11631,12 @@ function setSelected(el, value) {
|
|||||||
if (optionType === "string" || optionType === "number") {
|
if (optionType === "string" || optionType === "number") {
|
||||||
option.selected = value.some(v => String(v) === String(optionValue));
|
option.selected = value.some(v => String(v) === String(optionValue));
|
||||||
} else {
|
} else {
|
||||||
option.selected = looseIndexOf(value, optionValue) > -1;
|
option.selected = (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .looseIndexOf */ .u3)(value, optionValue) > -1;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
option.selected = value.has(optionValue);
|
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;
|
if (el.selectedIndex !== i) el.selectedIndex = i;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -11755,7 +11761,7 @@ const withModifiers = (fn, modifiers) => {
|
|||||||
return fn(event, ...args);
|
return fn(event, ...args);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const keyNames = (/* unused pure expression or super */ null && ({
|
const keyNames = {
|
||||||
esc: "escape",
|
esc: "escape",
|
||||||
space: " ",
|
space: " ",
|
||||||
up: "arrow-up",
|
up: "arrow-up",
|
||||||
@@ -11763,7 +11769,7 @@ const keyNames = (/* unused pure expression or super */ null && ({
|
|||||||
right: "arrow-right",
|
right: "arrow-right",
|
||||||
down: "arrow-down",
|
down: "arrow-down",
|
||||||
delete: "backspace"
|
delete: "backspace"
|
||||||
}));
|
};
|
||||||
const withKeys = (fn, modifiers) => {
|
const withKeys = (fn, modifiers) => {
|
||||||
const cache = fn._withKeys || (fn._withKeys = {});
|
const cache = fn._withKeys || (fn._withKeys = {});
|
||||||
const cacheKey = modifiers.join(".");
|
const cacheKey = modifiers.join(".");
|
||||||
@@ -11771,7 +11777,7 @@ const withKeys = (fn, modifiers) => {
|
|||||||
if (!("key" in event)) {
|
if (!("key" in event)) {
|
||||||
return;
|
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)) {
|
if (modifiers.some(k => k === eventKey || keyNames[k] === eventKey)) {
|
||||||
return fn(event);
|
return fn(event);
|
||||||
}
|
}
|
||||||
@@ -11901,7 +11907,9 @@ const initDirectivesForSSR = () => {
|
|||||||
/* harmony export */ __webpack_require__.d(__webpack_exports__, [
|
/* harmony export */ __webpack_require__.d(__webpack_exports__, [
|
||||||
/* harmony export */ "D$", 0, /* binding */ withModifiers,
|
/* harmony export */ "D$", 0, /* binding */ withModifiers,
|
||||||
/* harmony export */ "Ef", 0, /* binding */ createApp,
|
/* 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 */ ]);
|
/* harmony export */ ]);
|
||||||
|
|
||||||
|
|
||||||
@@ -11912,9 +11920,11 @@ const initDirectivesForSSR = () => {
|
|||||||
|
|
||||||
"use strict";
|
"use strict";
|
||||||
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
/* 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 */ });
|
/* 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__ = __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_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);
|
/* 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 */ "X$", 0, /* binding */ extend,
|
||||||
/* harmony export */ "ZH", 0, /* binding */ capitalize,
|
/* harmony export */ "ZH", 0, /* binding */ capitalize,
|
||||||
/* harmony export */ "bB", 0, /* binding */ looseToNumber,
|
/* 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 */ ]);
|
/* harmony export */ ]);
|
||||||
|
|
||||||
|
|
||||||
@@ -14690,7 +14701,7 @@ module.exports = function (it) {
|
|||||||
|
|
||||||
/***/ }),
|
/***/ }),
|
||||||
|
|
||||||
/***/ 3463:
|
/***/ 5844:
|
||||||
/***/ (function(module) {
|
/***/ (function(module) {
|
||||||
|
|
||||||
"use strict";
|
"use strict";
|
||||||
@@ -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:
|
/***/ 851:
|
||||||
@@ -18170,7 +18200,7 @@ module.exports = function (key) {
|
|||||||
var globalThis = __webpack_require__(4576);
|
var globalThis = __webpack_require__(4576);
|
||||||
var uncurryThis = __webpack_require__(9504);
|
var uncurryThis = __webpack_require__(9504);
|
||||||
var anObjectOrUndefined = __webpack_require__(3972);
|
var anObjectOrUndefined = __webpack_require__(3972);
|
||||||
var aString = __webpack_require__(3463);
|
var aString = __webpack_require__(5844);
|
||||||
var hasOwn = __webpack_require__(9297);
|
var hasOwn = __webpack_require__(9297);
|
||||||
var base64Map = __webpack_require__(2804);
|
var base64Map = __webpack_require__(2804);
|
||||||
var getAlphabetOption = __webpack_require__(944);
|
var getAlphabetOption = __webpack_require__(944);
|
||||||
@@ -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:
|
/***/ 7588:
|
||||||
@@ -19323,7 +19436,7 @@ if (Uint8Array) $({ target: 'Uint8Array', proto: true, forced: INCORRECT_BEHAVIO
|
|||||||
|
|
||||||
var $ = __webpack_require__(6518);
|
var $ = __webpack_require__(6518);
|
||||||
var globalThis = __webpack_require__(4576);
|
var globalThis = __webpack_require__(4576);
|
||||||
var aString = __webpack_require__(3463);
|
var aString = __webpack_require__(5844);
|
||||||
var anUint8Array = __webpack_require__(4154);
|
var anUint8Array = __webpack_require__(4154);
|
||||||
var notDetached = __webpack_require__(5169);
|
var notDetached = __webpack_require__(5169);
|
||||||
var $fromHex = __webpack_require__(2303);
|
var $fromHex = __webpack_require__(2303);
|
||||||
@@ -23157,7 +23270,7 @@ const isThenable = thing => thing && (isObject(thing) || isFunction(thing)) && i
|
|||||||
/* harmony import */ var core_js_modules_es_set_is_superset_of_v2_js__WEBPACK_IMPORTED_MODULE_13__ = __webpack_require__(2475);
|
/* harmony import */ var core_js_modules_es_set_is_superset_of_v2_js__WEBPACK_IMPORTED_MODULE_13__ = __webpack_require__(2475);
|
||||||
/* harmony import */ var core_js_modules_es_set_symmetric_difference_v2_js__WEBPACK_IMPORTED_MODULE_14__ = __webpack_require__(5024);
|
/* harmony import */ var core_js_modules_es_set_symmetric_difference_v2_js__WEBPACK_IMPORTED_MODULE_14__ = __webpack_require__(5024);
|
||||||
/* harmony import */ var core_js_modules_es_set_union_v2_js__WEBPACK_IMPORTED_MODULE_15__ = __webpack_require__(1698);
|
/* harmony import */ var core_js_modules_es_set_union_v2_js__WEBPACK_IMPORTED_MODULE_15__ = __webpack_require__(1698);
|
||||||
/* harmony import */ var vue_demi__WEBPACK_IMPORTED_MODULE_16__ = __webpack_require__(7958);
|
/* harmony import */ var vue_demi__WEBPACK_IMPORTED_MODULE_16__ = __webpack_require__(3463);
|
||||||
/* harmony import */ var vue_demi__WEBPACK_IMPORTED_MODULE_17__ = __webpack_require__(6768);
|
/* harmony import */ var vue_demi__WEBPACK_IMPORTED_MODULE_17__ = __webpack_require__(6768);
|
||||||
/* harmony import */ var vue_demi__WEBPACK_IMPORTED_MODULE_18__ = __webpack_require__(144);
|
/* harmony import */ var vue_demi__WEBPACK_IMPORTED_MODULE_18__ = __webpack_require__(144);
|
||||||
|
|
||||||
@@ -24928,7 +25041,7 @@ const PiniaVuePlugin = function (_Vue) {
|
|||||||
|
|
||||||
/***/ }),
|
/***/ }),
|
||||||
|
|
||||||
/***/ 7958:
|
/***/ 3463:
|
||||||
/***/ (function(__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) {
|
/***/ (function(__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) {
|
||||||
|
|
||||||
"use strict";
|
"use strict";
|
||||||
@@ -24964,4 +25077,4 @@ function del(target, key) {
|
|||||||
/***/ })
|
/***/ })
|
||||||
|
|
||||||
}]);
|
}]);
|
||||||
//# sourceMappingURL=chunk-vendors.cd0faf68.js.map
|
//# sourceMappingURL=chunk-vendors.86756863.js.map
|
||||||
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
Generated
-11384
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,6 @@
|
|||||||
"axios": "1.6.7",
|
"axios": "1.6.7",
|
||||||
"jr-qrcode": "^1.1.4",
|
"jr-qrcode": "^1.1.4",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"process": "^0.11.10",
|
|
||||||
"vue": "3.5.13",
|
"vue": "3.5.13",
|
||||||
"vue-router": "^4.3.0"
|
"vue-router": "^4.3.0"
|
||||||
},
|
},
|
||||||
|
|||||||
+6
-10
@@ -34,6 +34,11 @@ try {
|
|||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
$remarkMap = [];
|
$remarkMap = [];
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
$friendAliasMap = fetchImFriendAliasMap(imDb(), $userId, $friendIds);
|
||||||
|
} catch (Throwable) {
|
||||||
|
$friendAliasMap = [];
|
||||||
|
}
|
||||||
|
|
||||||
$contactsByUid = [];
|
$contactsByUid = [];
|
||||||
foreach (array_values(array_filter($usersResult['userInfos'] ?? [], 'is_array')) as $user) {
|
foreach (array_values(array_filter($usersResult['userInfos'] ?? [], 'is_array')) as $user) {
|
||||||
@@ -41,16 +46,7 @@ try {
|
|||||||
if ($uid === '' || isset($contactsByUid[$uid])) {
|
if ($uid === '' || isset($contactsByUid[$uid])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$alias = '';
|
$alias = $friendAliasMap[$uid] ?? '';
|
||||||
try {
|
|
||||||
$aliasResult = requireImSuccess(
|
|
||||||
imAdminPost('/admin/friend/get_alias', ['operator' => $userId, 'targetId' => $uid]),
|
|
||||||
'联系人备注加载失败'
|
|
||||||
);
|
|
||||||
$alias = trim((string)($aliasResult['alias'] ?? ''));
|
|
||||||
} catch (Throwable $error) {
|
|
||||||
$alias = '';
|
|
||||||
}
|
|
||||||
if ($alias === '') {
|
if ($alias === '') {
|
||||||
$alias = $remarkMap[$uid] ?? '';
|
$alias = $remarkMap[$uid] ?? '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ function webGroupConversation(array $group, string $role = 'member'): array
|
|||||||
'line' => 0,
|
'line' => 0,
|
||||||
'category' => $category,
|
'category' => $category,
|
||||||
'role' => $role,
|
'role' => $role,
|
||||||
|
'nativeStatus' => (string)($group['native_status'] ?? ''),
|
||||||
|
'nativeError' => (string)($group['native_error'] ?? ''),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -176,6 +178,48 @@ function createNativeGroup(string $operator, string $groupId, string $name, stri
|
|||||||
]), '原生群组创建失败');
|
]), '原生群组创建失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fetchNativeGroupInfo(string $groupKey): array
|
||||||
|
{
|
||||||
|
$payloads = [
|
||||||
|
['group_id' => $groupKey],
|
||||||
|
['groupId' => $groupKey],
|
||||||
|
['target_id' => $groupKey],
|
||||||
|
];
|
||||||
|
$lastError = null;
|
||||||
|
|
||||||
|
foreach ($payloads as $payload) {
|
||||||
|
try {
|
||||||
|
return requireImSuccess(imAdminPost('/admin/group/get_info', $payload), '群组不存在');
|
||||||
|
} catch (Throwable $error) {
|
||||||
|
$lastError = $error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException($lastError ? ($lastError->getMessage() ?: '群组不存在') : '群组不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeGroupInfoValue(array $native, string $key, string $fallback = ''): string
|
||||||
|
{
|
||||||
|
$sources = [$native];
|
||||||
|
if (is_array($native['group_info'] ?? null)) {
|
||||||
|
$sources[] = $native['group_info'];
|
||||||
|
}
|
||||||
|
if (is_array($native['group'] ?? null)) {
|
||||||
|
$sources[] = $native['group'];
|
||||||
|
if (is_array($native['group']['group_info'] ?? null)) {
|
||||||
|
$sources[] = $native['group']['group_info'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($sources as $source) {
|
||||||
|
if (isset($source[$key]) && (string)$source[$key] !== '') {
|
||||||
|
return (string)$source[$key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
function addNativeGroupMember(string $operator, string $groupId, string $userId): void
|
function addNativeGroupMember(string $operator, string $groupId, string $userId): void
|
||||||
{
|
{
|
||||||
requireImSuccess(imAdminPost('/admin/group/member/add', [
|
requireImSuccess(imAdminPost('/admin/group/member/add', [
|
||||||
@@ -425,11 +469,134 @@ function currentMuteSetting(PDO $appDb, string $groupId): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nativeGroupRoleFromType(mixed $type): string
|
||||||
|
{
|
||||||
|
$value = (int)$type;
|
||||||
|
if ($value === 2) {
|
||||||
|
return 'owner';
|
||||||
|
}
|
||||||
|
if ($value === 1) {
|
||||||
|
return 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'member';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchNativeGroupWithMember(PDO $imDb, string $groupId, string $userId): array
|
||||||
|
{
|
||||||
|
$statement = $imDb->prepare(
|
||||||
|
'select g.*, m.`_type` as `member_type`, m.`_alias` as `member_alias`'
|
||||||
|
. ' from `t_group` g'
|
||||||
|
. ' join `t_group_member` m on m.`_gid` = g.`_gid`'
|
||||||
|
. ' where g.`_gid` = ? and g.`_deleted` = 0 and m.`_mid` = ? limit 1'
|
||||||
|
);
|
||||||
|
$statement->execute([$groupId, $userId]);
|
||||||
|
$group = $statement->fetch();
|
||||||
|
|
||||||
|
return is_array($group) ? $group : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureNativeGroupMember(PDO $imDb, string $groupId, string $userId): array
|
||||||
|
{
|
||||||
|
$group = fetchNativeGroupWithMember($imDb, $groupId, $userId);
|
||||||
|
if ($group) {
|
||||||
|
return $group;
|
||||||
|
}
|
||||||
|
|
||||||
|
$exists = $imDb->prepare('select 1 from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1');
|
||||||
|
$exists->execute([$groupId]);
|
||||||
|
if ($exists->fetchColumn()) {
|
||||||
|
respond(403, '你不在该群组中');
|
||||||
|
}
|
||||||
|
|
||||||
|
respond(404, '没有找到该群组');
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeGroupDetailAction(PDO $imDb, string $userId, string $groupId): array
|
||||||
|
{
|
||||||
|
$group = ensureNativeGroupMember($imDb, $groupId, $userId);
|
||||||
|
$ownerId = (string)($group['_owner'] ?? '');
|
||||||
|
$users = fetchWebUserMap($imDb, [$ownerId, $userId], $userId);
|
||||||
|
$owner = $users[$ownerId] ?? ['uid' => $ownerId, 'displayName' => $ownerId, 'contactAlias' => ''];
|
||||||
|
$me = $users[$userId] ?? ['uid' => $userId, 'displayName' => $userId];
|
||||||
|
$myAlias = (string)($group['member_alias'] ?? '');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'group' => [
|
||||||
|
'groupId' => (string)$group['_gid'],
|
||||||
|
'name' => (string)($group['_name'] ?? ''),
|
||||||
|
'category' => 'group',
|
||||||
|
'announcement' => '',
|
||||||
|
'welcomeText' => '',
|
||||||
|
'ownerUid' => $ownerId,
|
||||||
|
'ownerName' => (string)($owner['contactAlias'] ?? '') !== '' ? (string)$owner['contactAlias'] : (string)$owner['displayName'],
|
||||||
|
'myRole' => nativeGroupRoleFromType($group['member_type'] ?? 0),
|
||||||
|
'myAlias' => $myAlias,
|
||||||
|
'myDisplayName' => $myAlias !== '' ? $myAlias : (string)$me['displayName'],
|
||||||
|
'muteSetting' => [
|
||||||
|
'mode' => 'off',
|
||||||
|
'scheduleType' => 'once',
|
||||||
|
'startsAtMs' => 0,
|
||||||
|
'endsAtMs' => 0,
|
||||||
|
'dailyStart' => '',
|
||||||
|
'dailyEnd' => '',
|
||||||
|
'enabled' => false,
|
||||||
|
],
|
||||||
|
'native' => true,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeGroupMembersAction(PDO $imDb, string $userId, string $groupId): array
|
||||||
|
{
|
||||||
|
ensureNativeGroupMember($imDb, $groupId, $userId);
|
||||||
|
|
||||||
|
$statement = $imDb->prepare(
|
||||||
|
'select `_mid`, `_alias`, `_type`, `_create_dt` from `t_group_member`'
|
||||||
|
. ' where `_gid` = ? order by (`_type` = 2) desc, (`_type` = 1) desc, `_create_dt` asc limit 500'
|
||||||
|
);
|
||||||
|
$statement->execute([$groupId]);
|
||||||
|
$members = $statement->fetchAll();
|
||||||
|
$users = fetchWebUserMap($imDb, array_column($members, '_mid'), $userId);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'members' => array_map(static function (array $member) use ($users): array {
|
||||||
|
$uid = (string)$member['_mid'];
|
||||||
|
$user = $users[$uid] ?? [
|
||||||
|
'uid' => $uid,
|
||||||
|
'displayName' => $uid,
|
||||||
|
'mobile' => '',
|
||||||
|
'portrait' => '',
|
||||||
|
'contactAlias' => '',
|
||||||
|
'friendAlias' => '',
|
||||||
|
'remarkAlias' => '',
|
||||||
|
'isContact' => false,
|
||||||
|
];
|
||||||
|
$alias = trim((string)($member['_alias'] ?? ''));
|
||||||
|
$contactAlias = trim((string)($user['contactAlias'] ?? ''));
|
||||||
|
return [
|
||||||
|
'uid' => $uid,
|
||||||
|
'role' => nativeGroupRoleFromType($member['_type'] ?? 0),
|
||||||
|
'alias' => $alias,
|
||||||
|
'contactAlias' => $contactAlias,
|
||||||
|
'friendAlias' => trim((string)($user['friendAlias'] ?? '')),
|
||||||
|
'remarkAlias' => trim((string)($user['remarkAlias'] ?? '')),
|
||||||
|
'isContact' => (bool)($user['isContact'] ?? false),
|
||||||
|
'displayName' => $contactAlias !== '' ? $contactAlias : ($alias !== '' ? $alias : (string)$user['displayName']),
|
||||||
|
'rawDisplayName' => (string)$user['displayName'],
|
||||||
|
'groupDisplayName' => $alias,
|
||||||
|
'mobile' => (string)($user['mobile'] ?? ''),
|
||||||
|
'portrait' => (string)($user['portrait'] ?? ''),
|
||||||
|
];
|
||||||
|
}, $members),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
||||||
{
|
{
|
||||||
$group = fetchWebGroup($appDb, $groupId);
|
$group = fetchWebGroup($appDb, $groupId);
|
||||||
if (!$group) {
|
if (!$group) {
|
||||||
respond(404, '没有找到该群组');
|
return nativeGroupDetailAction($imDb, $userId, $groupId);
|
||||||
}
|
}
|
||||||
$member = fetchWebGroupMember($appDb, $groupId, $userId);
|
$member = fetchWebGroupMember($appDb, $groupId, $userId);
|
||||||
if (!$member) {
|
if (!$member) {
|
||||||
@@ -462,7 +629,7 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
|
|||||||
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
||||||
{
|
{
|
||||||
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
|
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
|
||||||
respond(403, '你不在该群组中');
|
return nativeGroupMembersAction($imDb, $userId, $groupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
$statement = $appDb->prepare(
|
$statement = $appDb->prepare(
|
||||||
@@ -512,7 +679,10 @@ function updateGroupAliasAction(PDO $appDb, string $userId, string $groupId, str
|
|||||||
respond(400, '本群昵称过长');
|
respond(400, '本群昵称过长');
|
||||||
}
|
}
|
||||||
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
|
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
|
||||||
respond(403, '你不在该群组中');
|
ensureNativeGroupMember(imDb(), $groupId, $userId);
|
||||||
|
$update = imDb()->prepare('update `t_group_member` set `_alias` = ? where `_gid` = ? and `_mid` = ?');
|
||||||
|
$update->execute([$alias, $groupId, $userId]);
|
||||||
|
return ['alias' => $alias];
|
||||||
}
|
}
|
||||||
|
|
||||||
$now = webImNowMs();
|
$now = webImNowMs();
|
||||||
@@ -781,8 +951,16 @@ function joinWebGroupAction(PDO $appDb, string $userId, string $groupKey): array
|
|||||||
$group = fetchWebGroup($appDb, $groupKey);
|
$group = fetchWebGroup($appDb, $groupKey);
|
||||||
if (!$group) {
|
if (!$group) {
|
||||||
try {
|
try {
|
||||||
$native = requireImSuccess(imAdminPost('/admin/group/get_info', ['groupId' => $groupKey]), '群组不存在');
|
$native = fetchNativeGroupInfo($groupKey);
|
||||||
$now = webImNowMs();
|
$now = webImNowMs();
|
||||||
|
$nativeGroupId = nativeGroupInfoValue(
|
||||||
|
$native,
|
||||||
|
'target_id',
|
||||||
|
nativeGroupInfoValue($native, 'targetId', nativeGroupInfoValue($native, 'group_id', nativeGroupInfoValue($native, 'groupId', $groupKey)))
|
||||||
|
);
|
||||||
|
$nativeName = nativeGroupInfoValue($native, 'name', $nativeGroupId);
|
||||||
|
$nativePortrait = nativeGroupInfoValue($native, 'portrait', '');
|
||||||
|
$nativeOwner = nativeGroupInfoValue($native, 'owner', nativeGroupInfoValue($native, 'owner_uid', nativeGroupInfoValue($native, 'ownerUid', '')));
|
||||||
$statement = $appDb->prepare(
|
$statement = $appDb->prepare(
|
||||||
'insert into `t_web_groups`'
|
'insert into `t_web_groups`'
|
||||||
. ' (`group_id`, `category`, `name`, `portrait`, `owner_uid`, `join_mode`, `announcement`,'
|
. ' (`group_id`, `category`, `name`, `portrait`, `owner_uid`, `join_mode`, `announcement`,'
|
||||||
@@ -791,33 +969,38 @@ function joinWebGroupAction(PDO $appDb, string $userId, string $groupKey): array
|
|||||||
. ' on duplicate key update `updated_at_ms` = values(`updated_at_ms`)'
|
. ' on duplicate key update `updated_at_ms` = values(`updated_at_ms`)'
|
||||||
);
|
);
|
||||||
$statement->execute([
|
$statement->execute([
|
||||||
$groupKey,
|
$nativeGroupId,
|
||||||
(string)($native['name'] ?? $groupKey),
|
$nativeName,
|
||||||
(string)($native['portrait'] ?? ''),
|
$nativePortrait,
|
||||||
(string)($native['owner'] ?? ''),
|
$nativeOwner,
|
||||||
$groupKey,
|
$nativeGroupId,
|
||||||
$userId,
|
$userId,
|
||||||
$now,
|
$now,
|
||||||
$now,
|
$now,
|
||||||
]);
|
]);
|
||||||
$group = fetchWebGroup($appDb, $groupKey);
|
$group = fetchWebGroup($appDb, $nativeGroupId);
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
respond(404, '没有找到该群组');
|
respond(404, '没有找到该群组');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$groupId = (string)$group['group_id'];
|
$groupId = (string)$group['group_id'];
|
||||||
|
$nativeStatus = (string)($group['native_status'] ?? '');
|
||||||
|
$nativeError = (string)($group['native_error'] ?? '');
|
||||||
upsertWebGroupMember($appDb, $groupId, $userId, 'member');
|
upsertWebGroupMember($appDb, $groupId, $userId, 'member');
|
||||||
try {
|
try {
|
||||||
addNativeGroupMember((string)($group['owner_uid'] ?: $userId), $groupId, $userId);
|
addNativeGroupMember((string)($group['owner_uid'] ?: $userId), $groupId, $userId);
|
||||||
} catch (Throwable $error) {
|
} catch (Throwable $error) {
|
||||||
// Web membership is authoritative for this web build; native group sync is best-effort.
|
$nativeStatus = $nativeStatus === 'ok' ? 'partial' : ($nativeStatus ?: 'fallback');
|
||||||
|
$nativeError = mb_substr($error->getMessage() ?: '加入原生群组失败', 0, 255, 'UTF-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
$role = fetchWebGroupRole($appDb, $groupId, $userId);
|
$role = fetchWebGroupRole($appDb, $groupId, $userId);
|
||||||
return [
|
return [
|
||||||
'group' => fetchWebGroup($appDb, $groupId),
|
'group' => fetchWebGroup($appDb, $groupId),
|
||||||
'conversation' => webGroupConversation($group, $role),
|
'conversation' => webGroupConversation($group, $role),
|
||||||
|
'nativeStatus' => $nativeStatus,
|
||||||
|
'nativeError' => $nativeError,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,85 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
define('IM_ADMIN_URL', getenv('IM_ADMIN_URL') ?: 'http://127.0.0.1:18080');
|
function webImEnv(string $key, string $fallback = ''): string
|
||||||
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');
|
$runtimeValue = getenv($key);
|
||||||
define('IM_DB_USER', getenv('IM_DB_USER') ?: '');
|
if ($runtimeValue !== false && $runtimeValue !== '') {
|
||||||
define('IM_DB_PASSWORD', getenv('IM_DB_PASSWORD') ?: '');
|
return $runtimeValue;
|
||||||
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') ?: '');
|
static $env = null;
|
||||||
define('IM_DB_TIMEZONE', getenv('IM_DB_TIMEZONE') ?: 'Asia/Bangkok');
|
if ($env === null) {
|
||||||
|
$env = [];
|
||||||
|
$paths = array_filter([
|
||||||
|
getenv('JC_WEB_IM_ENV_FILE') ?: '',
|
||||||
|
dirname(__DIR__, 2) . '/.env',
|
||||||
|
'/var/www/im-admin/.env',
|
||||||
|
'/var/www/im/.env',
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($paths as $path) {
|
||||||
|
if (!is_readable($path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
|
||||||
|
$line = trim($line);
|
||||||
|
if ($line === '' || strpos($line, '#') === 0 || strpos($line, '=') === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
[$name, $value] = array_map('trim', explode('=', $line, 2));
|
||||||
|
if ($name === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$value = trim($value);
|
||||||
|
if (
|
||||||
|
strlen($value) >= 2
|
||||||
|
&& (($value[0] === '"' && substr($value, -1) === '"') || ($value[0] === "'" && substr($value, -1) === "'"))
|
||||||
|
) {
|
||||||
|
$value = substr($value, 1, -1);
|
||||||
|
}
|
||||||
|
$env[$name] = $value;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $env[$key] ?? $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function webImFirstEnv(array $keys, string $fallback = ''): string
|
||||||
|
{
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
$value = webImEnv($key);
|
||||||
|
if ($value !== '') {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function webImMysqlDsn(string $host, string $port, string $database): string
|
||||||
|
{
|
||||||
|
return sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $host, $port, $database);
|
||||||
|
}
|
||||||
|
|
||||||
|
define('IM_ADMIN_URL', webImEnv('JC_WEB_IM_ADMIN_URL', 'http://127.0.0.1:18080'));
|
||||||
|
define('IM_ADMIN_SECRET', webImFirstEnv(['JC_WEB_IM_ADMIN_SECRET', 'SecretKey'], '123456'));
|
||||||
|
define('IM_DB_DSN', webImMysqlDsn(
|
||||||
|
webImEnv('JC_WEB_IM_DB_HOST', webImEnv('DB_HOST', '127.0.0.1')),
|
||||||
|
webImEnv('JC_WEB_IM_DB_PORT', webImEnv('DB_PORT', '3306')),
|
||||||
|
webImEnv('JC_WEB_IM_DB_DATABASE', webImEnv('DB_DATABASE', 'wfchat'))
|
||||||
|
));
|
||||||
|
define('IM_DB_USER', webImEnv('JC_WEB_IM_DB_USER', webImEnv('DB_USERNAME', 'winchen')));
|
||||||
|
define('IM_DB_PASSWORD', webImEnv('JC_WEB_IM_DB_PASSWORD', webImEnv('DB_PASSWORD', 'winchen@0904')));
|
||||||
|
define('IM_APP_DB_DSN', webImMysqlDsn(
|
||||||
|
webImFirstEnv(['JC_WEB_IM_APP_DB_HOST', 'DB_HOST_2', 'DB_HOST2_2'], webImEnv('DB_HOST', '127.0.0.1')),
|
||||||
|
webImFirstEnv(['JC_WEB_IM_APP_DB_PORT', 'DB_PORT_2', 'DB_PORT2_2'], webImEnv('DB_PORT', '3306')),
|
||||||
|
webImFirstEnv(['JC_WEB_IM_APP_DB_DATABASE', 'DB_DATABASE_2', 'DB_DATABASE2_2'], webImEnv('DB_DATABASE', 'appdata'))
|
||||||
|
));
|
||||||
|
define('IM_APP_DB_USER', webImFirstEnv(['JC_WEB_IM_APP_DB_USER', 'DB_USERNAME_2', 'DB_USERNAME2_2'], webImEnv('DB_USERNAME', 'winchen')));
|
||||||
|
define('IM_APP_DB_PASSWORD', webImFirstEnv(['JC_WEB_IM_APP_DB_PASSWORD', 'DB_PASSWORD_2', 'DB_PASSWORD2_2'], webImEnv('DB_PASSWORD', 'winchen@0904')));
|
||||||
|
define('IM_DB_TIMEZONE', webImEnv('JC_WEB_IM_DB_TIMEZONE', 'Asia/Bangkok'));
|
||||||
|
|
||||||
function respond(int $code, string $message, array $result = []): void
|
function respond(int $code, string $message, array $result = []): void
|
||||||
{
|
{
|
||||||
@@ -285,6 +355,31 @@ function fetchWebUserRemarkMap(PDO $db, string $ownerId, array $targetIds): arra
|
|||||||
return $remarks;
|
return $remarks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fetchImFriendAliasMap(PDO $db, string $ownerId, array $friendIds): array
|
||||||
|
{
|
||||||
|
$friendIds = array_values(array_unique(array_filter(array_map(static function ($friendId): string {
|
||||||
|
return trim((string)$friendId);
|
||||||
|
}, $friendIds))));
|
||||||
|
if ($ownerId === '' || !$friendIds) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$aliases = [];
|
||||||
|
foreach (array_chunk($friendIds, 500) as $chunk) {
|
||||||
|
$statement = $db->prepare(
|
||||||
|
'select `_friend_uid`, `_alias` from `t_friend`'
|
||||||
|
. ' where `_uid` = ? and `_friend_uid` in (' . webImPlaceholders($chunk) . ') and `_state` = 0'
|
||||||
|
);
|
||||||
|
$statement->execute(array_merge([$ownerId], $chunk));
|
||||||
|
|
||||||
|
foreach ($statement->fetchAll() as $row) {
|
||||||
|
$aliases[(string)$row['_friend_uid']] = trim((string)($row['_alias'] ?? ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $aliases;
|
||||||
|
}
|
||||||
|
|
||||||
function saveWebUserRemark(PDO $db, string $ownerId, string $targetId, string $alias): void
|
function saveWebUserRemark(PDO $db, string $ownerId, string $targetId, string $alias): void
|
||||||
{
|
{
|
||||||
if ($ownerId === '' || $targetId === '') {
|
if ($ownerId === '' || $targetId === '') {
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
<?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 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 = webImEnv('JC_WEB_IM_MEDIA_PROXY_HOSTS');
|
||||||
|
$hosts = array_map(
|
||||||
|
static fn(string $host): string => strtolower(rtrim(trim($host), '.')),
|
||||||
|
preg_split('/[,\s]+/', $configured) ?: []
|
||||||
|
);
|
||||||
|
|
||||||
|
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(webImEnv('JC_WEB_IM_MEDIA_CACHE_ROOT', '/var/cache/jc-web-im-media'), '/');
|
||||||
|
if ($root === '' || !is_dir($root) || !is_writable($root)) {
|
||||||
|
failMediaProxy(503, '媒体缓存目录不可用');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaProxyPreferredIps(): array
|
||||||
|
{
|
||||||
|
$configured = preg_split('/[,\s]+/', webImEnv('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);
|
||||||
+723
-15
@@ -132,7 +132,7 @@ function fetchMessageRows(PDO $db, array $messageIds): array
|
|||||||
|
|
||||||
$messages = [];
|
$messages = [];
|
||||||
foreach ($idsByTable as $table => $ids) {
|
foreach ($idsByTable as $table => $ids) {
|
||||||
$sql = 'select `_mid`, `_from`, `_type`, `_target`, `_line`, `_searchable_key`, `_dt`, `_content_type`, `_to`'
|
$sql = 'select `_mid`, `_from`, `_type`, `_target`, `_line`, `_data`, `_searchable_key`, `_dt`, `_content_type`, `_to`'
|
||||||
. ' from `' . $table . '` where `_mid` in (' . placeholders($ids) . ')';
|
. ' from `' . $table . '` where `_mid` in (' . placeholders($ids) . ')';
|
||||||
$statement = $db->prepare($sql);
|
$statement = $db->prepare($sql);
|
||||||
$statement->execute($ids);
|
$statement->execute($ids);
|
||||||
@@ -232,12 +232,21 @@ function messagePreviewText(?array $message): string
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
$attachment = is_array($message['attachment'] ?? null) ? $message['attachment'] : null;
|
$attachment = is_array($message['attachment'] ?? null) ? $message['attachment'] : null;
|
||||||
|
$contentType = (int)($message['contentType'] ?? 0);
|
||||||
|
$messageType = (string)($message['type'] ?? '');
|
||||||
|
$placeholderIsImage = $contentType === 3 || $messageType === 'image';
|
||||||
|
$text = trim((string)($message['text'] ?? ''));
|
||||||
|
if ($contentType !== 1 && $text === '[暂不支持的消息类型]') {
|
||||||
|
$text = '';
|
||||||
|
}
|
||||||
|
if ($placeholderIsImage && ($text === '[图片]' || $text === '图片')) {
|
||||||
|
$text = '';
|
||||||
|
}
|
||||||
if ($attachment) {
|
if ($attachment) {
|
||||||
$text = trim((string)($message['text'] ?? ''));
|
|
||||||
return $text !== '' ? $text : attachmentFallbackText($attachment);
|
return $text !== '' ? $text : attachmentFallbackText($attachment);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (string)($message['text'] ?? '');
|
return $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchUserInfoMap(PDO $db, array $userIds, string $ownerId = ''): array
|
function fetchUserInfoMap(PDO $db, array $userIds, string $ownerId = ''): array
|
||||||
@@ -405,6 +414,42 @@ function isWebGroupTarget(PDO $appDb, string $targetId): bool
|
|||||||
return (bool)$statement->fetchColumn();
|
return (bool)$statement->fetchColumn();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nativeGroupRoleFromType(mixed $type): string
|
||||||
|
{
|
||||||
|
$value = (int)$type;
|
||||||
|
if ($value === 2) {
|
||||||
|
return 'owner';
|
||||||
|
}
|
||||||
|
if ($value === 1) {
|
||||||
|
return 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'member';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNativeGroupTarget(PDO $db, string $targetId): bool
|
||||||
|
{
|
||||||
|
if ($targetId === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$statement = $db->prepare('select 1 from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1');
|
||||||
|
$statement->execute([$targetId]);
|
||||||
|
|
||||||
|
return (bool)$statement->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeGroupRole(PDO $db, string $userId, string $groupId): string
|
||||||
|
{
|
||||||
|
$statement = $db->prepare(
|
||||||
|
'select `_type` from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1'
|
||||||
|
);
|
||||||
|
$statement->execute([$groupId, $userId]);
|
||||||
|
$type = $statement->fetchColumn();
|
||||||
|
|
||||||
|
return $type === false ? '' : nativeGroupRoleFromType($type);
|
||||||
|
}
|
||||||
|
|
||||||
function markWebGroupRead(PDO $appDb, string $userId, string $groupId, int $timestamp): void
|
function markWebGroupRead(PDO $appDb, string $userId, string $groupId, int $timestamp): void
|
||||||
{
|
{
|
||||||
if ($userId === '' || $groupId === '' || $timestamp <= 0) {
|
if ($userId === '' || $groupId === '' || $timestamp <= 0) {
|
||||||
@@ -422,6 +467,34 @@ function markWebGroupRead(PDO $appDb, string $userId, string $groupId, int $time
|
|||||||
$statement->execute([$groupId, $userId, $timestamp, $now]);
|
$statement->execute([$groupId, $userId, $timestamp, $now]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markNativeGroupRead(PDO $db, string $userId, string $groupId, int $timestamp): void
|
||||||
|
{
|
||||||
|
if ($userId === '' || $groupId === '' || $timestamp <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$statement = $db->prepare(
|
||||||
|
'select `id`, `_dt` from `t_read_report`'
|
||||||
|
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0 and `_target` = ?'
|
||||||
|
. ' order by `_dt` desc limit 1'
|
||||||
|
);
|
||||||
|
$statement->execute([$userId, $groupId]);
|
||||||
|
$row = $statement->fetch();
|
||||||
|
|
||||||
|
if ($row) {
|
||||||
|
if ($timestamp > (int)$row['_dt']) {
|
||||||
|
$update = $db->prepare('update `t_read_report` set `_dt` = ? where `id` = ?');
|
||||||
|
$update->execute([$timestamp, (int)$row['id']]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$insert = $db->prepare(
|
||||||
|
'insert into `t_read_report` (`_uid`, `_type`, `_line`, `_target`, `_dt`) values (?, 1, 0, ?, ?)'
|
||||||
|
);
|
||||||
|
$insert->execute([$userId, $groupId, $timestamp]);
|
||||||
|
}
|
||||||
|
|
||||||
function webGroupRole(PDO $appDb, string $userId, string $groupId): string
|
function webGroupRole(PDO $appDb, string $userId, string $groupId): string
|
||||||
{
|
{
|
||||||
$statement = $appDb->prepare(
|
$statement = $appDb->prepare(
|
||||||
@@ -434,6 +507,50 @@ function webGroupRole(PDO $appDb, string $userId, string $groupId): string
|
|||||||
return is_string($role) && $role !== '' ? $role : '';
|
return is_string($role) && $role !== '' ? $role : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadNativeGroupReadStateMap(PDO $db, string $userId, array $groupIds): array
|
||||||
|
{
|
||||||
|
$groupIds = array_values(array_unique(array_filter(array_map('strval', $groupIds))));
|
||||||
|
if ($userId === '' || !$groupIds) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$statement = $db->prepare(
|
||||||
|
'select `_target`, max(`_dt`) as `_read_at` from `t_read_report`'
|
||||||
|
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0'
|
||||||
|
. ' and `_target` in (' . placeholders($groupIds) . ')'
|
||||||
|
. ' group by `_target`'
|
||||||
|
);
|
||||||
|
$statement->execute(array_merge([$userId], $groupIds));
|
||||||
|
|
||||||
|
$readAtByGroup = [];
|
||||||
|
foreach ($statement->fetchAll() as $row) {
|
||||||
|
$readAtByGroup[(string)$row['_target']] = (int)$row['_read_at'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $readAtByGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchNativeGroupMemberMap(PDO $db, string $groupId, array $userIds): array
|
||||||
|
{
|
||||||
|
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
|
||||||
|
if ($groupId === '' || !$userIds) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$statement = $db->prepare(
|
||||||
|
'select `_mid`, `_alias`, `_type`, `_dt`, `_create_dt` from `t_group_member`'
|
||||||
|
. ' where `_gid` = ? and `_mid` in (' . placeholders($userIds) . ')'
|
||||||
|
);
|
||||||
|
$statement->execute(array_merge([$groupId], $userIds));
|
||||||
|
|
||||||
|
$members = [];
|
||||||
|
foreach ($statement->fetchAll() as $row) {
|
||||||
|
$members[(string)$row['_mid']] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $members;
|
||||||
|
}
|
||||||
|
|
||||||
function formatWebGroupMessage(
|
function formatWebGroupMessage(
|
||||||
array $row,
|
array $row,
|
||||||
string $currentUserId,
|
string $currentUserId,
|
||||||
@@ -508,6 +625,318 @@ function formatWebGroupMessage(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanupNativeMediaUrl(string $url): string
|
||||||
|
{
|
||||||
|
// WildFire's protobuf payload places the next field tag (0x40, "@")
|
||||||
|
// and a control byte immediately after remoteMediaUrl. Keep this helper
|
||||||
|
// defensive if it receives an unbounded match from older payloads.
|
||||||
|
$url = preg_replace('/[\x00-\x1F\x7F].*$/s', '', $url) ?? $url;
|
||||||
|
return rtrim($url, " \t\r\n\0\x0B\"'<>.,;)]}@");
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeMediaUrlScore(string $url): int
|
||||||
|
{
|
||||||
|
$path = strtolower((string)(parse_url($url, PHP_URL_PATH) ?: $url));
|
||||||
|
$score = 0;
|
||||||
|
if (preg_match('/\.(?:jpe?g|png|gif|webp|bmp|avif)$/i', $path)) {
|
||||||
|
$score += 30;
|
||||||
|
}
|
||||||
|
if (strpos($path, 'image') !== false || strpos($path, 'media') !== false || strpos($path, 'upload') !== false) {
|
||||||
|
$score += 5;
|
||||||
|
}
|
||||||
|
if (strpos($path, 'thumb') !== false || strpos($path, 'thumbnail') !== false) {
|
||||||
|
$score -= 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractNativeMediaUrlFromData($data): string
|
||||||
|
{
|
||||||
|
if (is_resource($data)) {
|
||||||
|
$data = stream_get_contents($data);
|
||||||
|
}
|
||||||
|
if (!is_string($data) || $data === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (!preg_match_all("~https?://[^\\s\"'<>\\x00-\\x1F\\x7F]+~i", $data, $matches)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$urls = [];
|
||||||
|
foreach ($matches[0] as $url) {
|
||||||
|
$url = cleanupNativeMediaUrl((string)$url);
|
||||||
|
if ($url !== '' && filter_var($url, FILTER_VALIDATE_URL)) {
|
||||||
|
$urls[$url] = $url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$urls) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$urls = array_values($urls);
|
||||||
|
usort($urls, static function (string $left, string $right): int {
|
||||||
|
$scoreDelta = nativeMediaUrlScore($right) <=> nativeMediaUrlScore($left);
|
||||||
|
return $scoreDelta !== 0 ? $scoreDelta : strlen($right) <=> strlen($left);
|
||||||
|
});
|
||||||
|
|
||||||
|
return $urls[0] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeAttachmentName(string $url, string $type): string
|
||||||
|
{
|
||||||
|
$path = (string)(parse_url($url, PHP_URL_PATH) ?: '');
|
||||||
|
$name = trim(rawurldecode((string)basename($path)));
|
||||||
|
if ($name !== '' && $name !== '.' && $name !== '/') {
|
||||||
|
return $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $type === 'image' ? '图片' : '文件';
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeAttachmentMime(string $url, string $type): string
|
||||||
|
{
|
||||||
|
if ($type !== 'image') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = strtolower((string)(parse_url($url, PHP_URL_PATH) ?: ''));
|
||||||
|
if (preg_match('/\.jpe?g$/', $path)) {
|
||||||
|
return 'image/jpeg';
|
||||||
|
}
|
||||||
|
if (preg_match('/\.png$/', $path)) {
|
||||||
|
return 'image/png';
|
||||||
|
}
|
||||||
|
if (preg_match('/\.gif$/', $path)) {
|
||||||
|
return 'image/gif';
|
||||||
|
}
|
||||||
|
if (preg_match('/\.webp$/', $path)) {
|
||||||
|
return 'image/webp';
|
||||||
|
}
|
||||||
|
if (preg_match('/\.bmp$/', $path)) {
|
||||||
|
return 'image/bmp';
|
||||||
|
}
|
||||||
|
if (preg_match('/\.avif$/', $path)) {
|
||||||
|
return 'image/avif';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'image/*';
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeAttachmentFromRow(array $row, ?array $attachment): ?array
|
||||||
|
{
|
||||||
|
if ($attachment !== null) {
|
||||||
|
return $attachment;
|
||||||
|
}
|
||||||
|
|
||||||
|
$contentType = (int)($row['_content_type'] ?? 0);
|
||||||
|
if ($contentType !== 3 && $contentType !== 5) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = extractNativeMediaUrlFromData($row['_data'] ?? '');
|
||||||
|
if ($url === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = $contentType === 3 ? 'image' : 'file';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => $type,
|
||||||
|
'url' => $url,
|
||||||
|
'name' => nativeAttachmentName($url, $type),
|
||||||
|
'mime' => nativeAttachmentMime($url, $type),
|
||||||
|
'size' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNativePlaceholderText(string $text, int $contentType): bool
|
||||||
|
{
|
||||||
|
$text = trim($text);
|
||||||
|
if ($text === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($contentType !== 1 && $text === '[暂不支持的消息类型]') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $contentType === 3 && ($text === '[图片]' || $text === '图片');
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeDisplayText(string $searchableText, int $contentType, bool $hasAttachment, bool $isRecalled = false): string
|
||||||
|
{
|
||||||
|
$text = trim($searchableText);
|
||||||
|
if (isNativePlaceholderText($text, $contentType)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if ($text === '' && !$isRecalled) {
|
||||||
|
return nativeMessageFallbackText($contentType, $hasAttachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeMessageFallbackText(int $contentType, bool $hasAttachment): string
|
||||||
|
{
|
||||||
|
if ($hasAttachment) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if ($contentType === 5) {
|
||||||
|
return '[文件]';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeMessageTableForDate(DateTimeImmutable $date): string
|
||||||
|
{
|
||||||
|
$localDate = $date->setTimezone(new DateTimeZone(IM_DB_TIMEZONE));
|
||||||
|
$year = (int)$localDate->format('Y');
|
||||||
|
$month = (int)$localDate->format('n') - 1;
|
||||||
|
|
||||||
|
return 't_messages_' . (($year % 3) * 12 + $month);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recentNativeMessageTables(int $monthsBack = 3): array
|
||||||
|
{
|
||||||
|
$monthsBack = max(1, min($monthsBack, 12));
|
||||||
|
$cursor = (new DateTimeImmutable('now', new DateTimeZone(IM_DB_TIMEZONE)))
|
||||||
|
->modify('first day of this month');
|
||||||
|
$tables = [];
|
||||||
|
|
||||||
|
for ($index = 0; $index < $monthsBack; $index++) {
|
||||||
|
$tables[] = nativeMessageTableForDate($cursor);
|
||||||
|
$cursor = $cursor->modify('-1 month');
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($tables));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadNativeGroupRowsFromGlobal(PDO $db, array $groupIds, int $limit): array
|
||||||
|
{
|
||||||
|
$groupIds = array_values(array_unique(array_filter(array_map('strval', $groupIds))));
|
||||||
|
if (!$groupIds) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = min(max($limit, 50), 1000);
|
||||||
|
$rows = [];
|
||||||
|
foreach (recentNativeMessageTables(3) as $table) {
|
||||||
|
try {
|
||||||
|
$statement = $db->prepare(
|
||||||
|
'select `_mid`, `_from`, `_type`, `_target`, `_line`, `_data`, `_searchable_key`, `_dt`, `_content_type`, `_to`'
|
||||||
|
. ' from `' . $table . '`'
|
||||||
|
. ' where `_type` = 1 and `_line` = 0'
|
||||||
|
. ' and `_target` in (' . placeholders($groupIds) . ')'
|
||||||
|
. ' order by `_mid` desc limit ' . $limit
|
||||||
|
);
|
||||||
|
$statement->execute($groupIds);
|
||||||
|
} catch (Throwable) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($statement->fetchAll() as $row) {
|
||||||
|
$rows[] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($rows, static fn($left, $right) => strcmp((string)$right['_mid'], (string)$left['_mid']));
|
||||||
|
|
||||||
|
$uniqueRows = [];
|
||||||
|
$seenMessageIds = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$messageId = (string)$row['_mid'];
|
||||||
|
if ($messageId === '' || isset($seenMessageIds[$messageId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seenMessageIds[$messageId] = true;
|
||||||
|
$uniqueRows[] = $row;
|
||||||
|
if (count($uniqueRows) >= $limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $uniqueRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNativeGroupMessage(
|
||||||
|
array $row,
|
||||||
|
string $currentUserId,
|
||||||
|
array $group,
|
||||||
|
?array $attachment = null,
|
||||||
|
array $users = [],
|
||||||
|
array $members = []
|
||||||
|
): array {
|
||||||
|
$groupId = (string)$row['_target'];
|
||||||
|
$from = (string)$row['_from'];
|
||||||
|
$senderUser = $users[$from] ?? [];
|
||||||
|
$senderMember = $members[$from] ?? [];
|
||||||
|
$senderAlias = trim((string)($senderMember['_alias'] ?? ''));
|
||||||
|
$senderContactAlias = trim((string)($senderUser['_alias'] ?? ''));
|
||||||
|
$senderFriendAlias = trim((string)($senderUser['_friend_alias'] ?? ''));
|
||||||
|
$senderRemarkAlias = trim((string)($senderUser['_remark_alias'] ?? ''));
|
||||||
|
$senderRawName = trim((string)($senderUser['_display_name'] ?? ''))
|
||||||
|
?: trim((string)($senderUser['_name'] ?? ''))
|
||||||
|
?: trim((string)($senderUser['_mobile'] ?? ''))
|
||||||
|
?: $from;
|
||||||
|
$senderName = $senderContactAlias
|
||||||
|
?: $senderAlias
|
||||||
|
?: $senderRawName;
|
||||||
|
$senderRole = nativeGroupRoleFromType($senderMember['_type'] ?? 0);
|
||||||
|
$messageId = (string)$row['_mid'];
|
||||||
|
$timestamp = timestampMs((string)($row['_dt'] ?? ''), $messageId);
|
||||||
|
$contentType = (int)($row['_content_type'] ?? 0);
|
||||||
|
$isRecalled = $contentType === 80;
|
||||||
|
$direction = $from === $currentUserId ? 'out' : 'in';
|
||||||
|
$attachment = nativeAttachmentFromRow($row, $attachment);
|
||||||
|
$messageType = $attachment ? (string)$attachment['type'] : (($contentType === 1 || $isRecalled) ? 'text' : 'unknown');
|
||||||
|
$text = nativeDisplayText((string)($row['_searchable_key'] ?? ''), $contentType, $attachment !== null, $isRecalled);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $messageId,
|
||||||
|
'messageUid' => $messageId,
|
||||||
|
'conversationId' => 'group-' . $groupId,
|
||||||
|
'from' => $from,
|
||||||
|
'senderRole' => $senderRole,
|
||||||
|
'senderName' => $senderName,
|
||||||
|
'senderPortrait' => (string)($senderUser['_portrait'] ?? ''),
|
||||||
|
'senderProfile' => [
|
||||||
|
'uid' => $from,
|
||||||
|
'displayName' => $senderName,
|
||||||
|
'rawName' => $senderRawName,
|
||||||
|
'contactAlias' => $senderContactAlias,
|
||||||
|
'friendAlias' => $senderFriendAlias,
|
||||||
|
'remarkAlias' => $senderRemarkAlias,
|
||||||
|
'groupAlias' => $senderAlias,
|
||||||
|
'isContact' => (bool)($senderUser['_is_friend'] ?? false),
|
||||||
|
'mobile' => (string)($senderUser['_mobile'] ?? ''),
|
||||||
|
'address' => (string)($senderUser['_address'] ?? ''),
|
||||||
|
'portrait' => (string)($senderUser['_portrait'] ?? ''),
|
||||||
|
'role' => $senderRole,
|
||||||
|
],
|
||||||
|
'to' => $groupId,
|
||||||
|
'target' => $groupId,
|
||||||
|
'direction' => $direction,
|
||||||
|
'type' => $messageType,
|
||||||
|
'contentType' => $contentType,
|
||||||
|
'text' => attachmentDisplayText($text, $attachment),
|
||||||
|
'attachment' => $attachment,
|
||||||
|
'status' => $isRecalled && $direction === 'out' ? 'recalled' : 'sent',
|
||||||
|
'deliveryStatus' => $direction === 'out' && !$isRecalled ? 'sent' : '',
|
||||||
|
'recalled' => $isRecalled,
|
||||||
|
'recallStatus' => $isRecalled ? '已撤回' : '',
|
||||||
|
'editedAt' => 0,
|
||||||
|
'timestamp' => $timestamp,
|
||||||
|
'activityTimestamp' => $timestamp,
|
||||||
|
'displayTimestamp' => $timestamp,
|
||||||
|
'conversationType' => 'group',
|
||||||
|
'raw' => [
|
||||||
|
'native' => true,
|
||||||
|
'groupName' => (string)($group['_name'] ?? ''),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function loadWebGroupMessages(PDO $appDb, string $userId, string $groupId, int $limit, bool $markRead): array
|
function loadWebGroupMessages(PDO $appDb, string $userId, string $groupId, int $limit, bool $markRead): array
|
||||||
{
|
{
|
||||||
ensureWebImAppTables();
|
ensureWebImAppTables();
|
||||||
@@ -557,6 +986,81 @@ function loadWebGroupMessages(PDO $appDb, string $userId, string $groupId, int $
|
|||||||
return $messages;
|
return $messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadNativeGroupMessages(PDO $db, string $userId, string $groupId, int $limit, bool $markRead): array
|
||||||
|
{
|
||||||
|
$role = nativeGroupRole($db, $userId, $groupId);
|
||||||
|
if ($role === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$groupStatement = $db->prepare('select * from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1');
|
||||||
|
$groupStatement->execute([$groupId]);
|
||||||
|
$group = $groupStatement->fetch();
|
||||||
|
if (!is_array($group)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$table = imUserMessagesTable($userId);
|
||||||
|
$scanLimit = min(max($limit * 4, $limit), 400);
|
||||||
|
$statement = $db->prepare(
|
||||||
|
'select `_mid`, `_dt` from `' . $table . '`'
|
||||||
|
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0 and `_target` = ?'
|
||||||
|
. ' order by `_mid` desc limit ' . $scanLimit
|
||||||
|
);
|
||||||
|
$statement->execute([$userId, $groupId]);
|
||||||
|
$userRows = $statement->fetchAll();
|
||||||
|
$messageRows = fetchMessageRows($db, array_column($userRows, '_mid'));
|
||||||
|
$globalRows = loadNativeGroupRowsFromGlobal($db, [$groupId], $scanLimit);
|
||||||
|
foreach ($globalRows as $row) {
|
||||||
|
$messageId = (string)$row['_mid'];
|
||||||
|
if ($messageId !== '') {
|
||||||
|
$messageRows[$messageId] = $row;
|
||||||
|
$userRows[] = [
|
||||||
|
'_mid' => $messageId,
|
||||||
|
'_dt' => (string)($row['_dt'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usort($userRows, static fn($left, $right) => strcmp((string)$right['_mid'], (string)$left['_mid']));
|
||||||
|
$attachmentMap = fetchAttachmentMap(imAppDb(), array_column($userRows, '_mid'));
|
||||||
|
|
||||||
|
$messages = [];
|
||||||
|
$seenMessageIds = [];
|
||||||
|
foreach ($userRows as $userRow) {
|
||||||
|
$messageId = (string)$userRow['_mid'];
|
||||||
|
if (isset($seenMessageIds[$messageId]) || !isset($messageRows[$messageId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seenMessageIds[$messageId] = true;
|
||||||
|
if (!shouldDisplayMessageForUser($messageRows[$messageId], $userId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$messages[] = $messageRows[$messageId];
|
||||||
|
if (count($messages) >= $limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$messages = array_reverse($messages);
|
||||||
|
$senderIds = array_values(array_unique(array_map(static fn(array $row): string => (string)$row['_from'], $messages)));
|
||||||
|
$users = fetchUserInfoMap($db, $senderIds, $userId);
|
||||||
|
$memberMap = fetchNativeGroupMemberMap($db, $groupId, $senderIds);
|
||||||
|
|
||||||
|
$readTimestamp = 0;
|
||||||
|
$formatted = [];
|
||||||
|
foreach ($messages as $row) {
|
||||||
|
$messageId = (string)$row['_mid'];
|
||||||
|
$message = formatNativeGroupMessage($row, $userId, $group, $attachmentMap[$messageId] ?? null, $users, $memberMap);
|
||||||
|
$readTimestamp = max($readTimestamp, (int)$message['activityTimestamp']);
|
||||||
|
$formatted[] = $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($markRead) {
|
||||||
|
markNativeGroupRead($db, $userId, $groupId, $readTimestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $formatted;
|
||||||
|
}
|
||||||
|
|
||||||
function loadWebGroupConversations(PDO $appDb, string $userId, int $limit): array
|
function loadWebGroupConversations(PDO $appDb, string $userId, int $limit): array
|
||||||
{
|
{
|
||||||
ensureWebImAppTables();
|
ensureWebImAppTables();
|
||||||
@@ -619,7 +1123,7 @@ function loadWebGroupConversations(PDO $appDb, string $userId, int $limit): arra
|
|||||||
? (messagePreviewText([
|
? (messagePreviewText([
|
||||||
'text' => attachmentDisplayText((string)$lastMessage['text'], $lastAttachment),
|
'text' => attachmentDisplayText((string)$lastMessage['text'], $lastAttachment),
|
||||||
'attachment' => $lastAttachment,
|
'attachment' => $lastAttachment,
|
||||||
]) ?: (string)$lastMessage['text'])
|
]))
|
||||||
: ($isChannel ? '公告群' : ($memberCount > 0 ? $memberCount . ' 位成员' : '群组'));
|
: ($isChannel ? '公告群' : ($memberCount > 0 ? $memberCount . ' 位成员' : '群组'));
|
||||||
$updatedAt = $lastMessage
|
$updatedAt = $lastMessage
|
||||||
? max((int)$lastMessage['created_at_ms'], (int)($lastMessage['edited_at_ms'] ?? 0))
|
? max((int)$lastMessage['created_at_ms'], (int)($lastMessage['edited_at_ms'] ?? 0))
|
||||||
@@ -649,6 +1153,152 @@ function loadWebGroupConversations(PDO $appDb, string $userId, int $limit): arra
|
|||||||
return $conversations;
|
return $conversations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadNativeGroupConversations(PDO $db, string $userId, int $limit): array
|
||||||
|
{
|
||||||
|
$statement = $db->prepare(
|
||||||
|
'select g.*, m.`_type` as `member_type`, m.`_alias` as `member_alias`'
|
||||||
|
. ' from `t_group_member` m'
|
||||||
|
. ' join `t_group` g on g.`_gid` = m.`_gid` and g.`_deleted` = 0'
|
||||||
|
. ' where m.`_mid` = ?'
|
||||||
|
. ' order by greatest(g.`_dt`, m.`_dt`) desc limit ' . min(max($limit, 50), 100)
|
||||||
|
);
|
||||||
|
$statement->execute([$userId]);
|
||||||
|
$groups = $statement->fetchAll();
|
||||||
|
if (!$groups) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$groupIds = array_values(array_unique(array_map(static fn(array $group): string => (string)$group['_gid'], $groups)));
|
||||||
|
$table = imUserMessagesTable($userId);
|
||||||
|
$scanLimit = min(max($limit * 20, 200), 1000);
|
||||||
|
$messageStatement = $db->prepare(
|
||||||
|
'select `_mid`, `_target`, `_dt` from `' . $table . '`'
|
||||||
|
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0'
|
||||||
|
. ' and `_target` in (' . placeholders($groupIds) . ')'
|
||||||
|
. ' order by `_mid` desc limit ' . $scanLimit
|
||||||
|
);
|
||||||
|
$messageStatement->execute(array_merge([$userId], $groupIds));
|
||||||
|
$scanRows = $messageStatement->fetchAll();
|
||||||
|
$messageRows = fetchMessageRows($db, array_column($scanRows, '_mid'));
|
||||||
|
$globalRows = loadNativeGroupRowsFromGlobal($db, $groupIds, $scanLimit);
|
||||||
|
foreach ($globalRows as $row) {
|
||||||
|
$messageId = (string)$row['_mid'];
|
||||||
|
if ($messageId !== '') {
|
||||||
|
$messageRows[$messageId] = $row;
|
||||||
|
$scanRows[] = [
|
||||||
|
'_mid' => $messageId,
|
||||||
|
'_target' => (string)($row['_target'] ?? ''),
|
||||||
|
'_dt' => (string)($row['_dt'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usort($scanRows, static fn($left, $right) => strcmp((string)$right['_mid'], (string)$left['_mid']));
|
||||||
|
$attachmentMap = fetchAttachmentMap(imAppDb(), array_column($scanRows, '_mid'));
|
||||||
|
$readAtByGroup = loadNativeGroupReadStateMap($db, $userId, $groupIds);
|
||||||
|
|
||||||
|
$latestByGroup = [];
|
||||||
|
$unreadByGroup = [];
|
||||||
|
$seenMessageIds = [];
|
||||||
|
foreach ($scanRows as $row) {
|
||||||
|
$messageId = (string)$row['_mid'];
|
||||||
|
if (isset($seenMessageIds[$messageId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seenMessageIds[$messageId] = true;
|
||||||
|
$groupId = (string)$row['_target'];
|
||||||
|
$message = $messageRows[$messageId] ?? null;
|
||||||
|
if ($groupId === '' || !$message || !shouldDisplayMessageForUser($message, $userId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$activityTimestamp = timestampMs((string)($message['_dt'] ?? ''), $messageId);
|
||||||
|
if (!isset($latestByGroup[$groupId]) || $activityTimestamp > (int)($latestByGroup[$groupId]['_activity_at'] ?? 0)) {
|
||||||
|
$row['_activity_at'] = $activityTimestamp;
|
||||||
|
$latestByGroup[$groupId] = $row;
|
||||||
|
}
|
||||||
|
if ((string)$message['_from'] !== $userId && !isRecallMessage($message)) {
|
||||||
|
if ($activityTimestamp > (int)($readAtByGroup[$groupId] ?? 0)) {
|
||||||
|
$unreadByGroup[$groupId] = (int)($unreadByGroup[$groupId] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$groupsById = [];
|
||||||
|
foreach ($groups as $group) {
|
||||||
|
$groupsById[(string)$group['_gid']] = $group;
|
||||||
|
}
|
||||||
|
|
||||||
|
$senderIds = [];
|
||||||
|
foreach ($latestByGroup as $row) {
|
||||||
|
$message = $messageRows[(string)$row['_mid']] ?? null;
|
||||||
|
if ($message) {
|
||||||
|
$senderIds[] = (string)$message['_from'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$users = fetchUserInfoMap($db, $senderIds, $userId);
|
||||||
|
|
||||||
|
$conversations = [];
|
||||||
|
foreach ($groupsById as $groupId => $group) {
|
||||||
|
$latestRow = $latestByGroup[$groupId] ?? null;
|
||||||
|
$messageId = $latestRow ? (string)$latestRow['_mid'] : '';
|
||||||
|
$message = $messageId !== '' && isset($messageRows[$messageId])
|
||||||
|
? formatNativeGroupMessage(
|
||||||
|
$messageRows[$messageId],
|
||||||
|
$userId,
|
||||||
|
$group,
|
||||||
|
$attachmentMap[$messageId] ?? null,
|
||||||
|
$users,
|
||||||
|
fetchNativeGroupMemberMap($db, $groupId, [(string)$messageRows[$messageId]['_from']])
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
$title = (string)($group['_name'] ?? '') ?: $groupId;
|
||||||
|
$updatedAt = $message ? (int)$message['activityTimestamp'] : (int)($group['_dt'] ?? 0);
|
||||||
|
$conversations[] = [
|
||||||
|
'id' => 'group-' . $groupId,
|
||||||
|
'type' => 'group',
|
||||||
|
'target' => $groupId,
|
||||||
|
'title' => $title,
|
||||||
|
'subtitle' => $message
|
||||||
|
? (messagePreviewText($message) ?: nativeMessageFallbackText((int)($message['contentType'] ?? 0), false))
|
||||||
|
: (((int)($group['_member_count'] ?? 0) > 0) ? (int)$group['_member_count'] . ' 位成员' : '群组'),
|
||||||
|
'unread' => (int)($unreadByGroup[$groupId] ?? 0),
|
||||||
|
'portrait' => (string)($group['_portrait'] ?? ''),
|
||||||
|
'avatarText' => mb_strtoupper(mb_substr($title, 0, 1, 'UTF-8'), 'UTF-8'),
|
||||||
|
'updatedAt' => $updatedAt,
|
||||||
|
'role' => nativeGroupRoleFromType($group['member_type'] ?? 0),
|
||||||
|
'memberCount' => (int)($group['_member_count'] ?? 0),
|
||||||
|
'raw' => [
|
||||||
|
'type' => 'group',
|
||||||
|
'target' => $groupId,
|
||||||
|
'line' => 0,
|
||||||
|
'native' => true,
|
||||||
|
'role' => nativeGroupRoleFromType($group['member_type'] ?? 0),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $conversations;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeConversationsById(array $conversations, array $extraConversations): array
|
||||||
|
{
|
||||||
|
$merged = [];
|
||||||
|
foreach (array_merge($conversations, $extraConversations) as $conversation) {
|
||||||
|
$id = (string)($conversation['id'] ?? '');
|
||||||
|
if ($id === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isset($merged[$id])) {
|
||||||
|
$merged[$id] = $conversation;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ((int)($conversation['updatedAt'] ?? 0) > (int)($merged[$id]['updatedAt'] ?? 0)) {
|
||||||
|
$merged[$id] = array_merge($merged[$id], $conversation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values($merged);
|
||||||
|
}
|
||||||
|
|
||||||
function isRecallMessage(array $row): bool
|
function isRecallMessage(array $row): bool
|
||||||
{
|
{
|
||||||
return (int)($row['_content_type'] ?? 0) === 80;
|
return (int)($row['_content_type'] ?? 0) === 80;
|
||||||
@@ -679,8 +1329,9 @@ function formatMessage(
|
|||||||
$text = (string)($row['_searchable_key'] ?? '');
|
$text = (string)($row['_searchable_key'] ?? '');
|
||||||
$timestamp = timestampMs((string)($row['_dt'] ?? ''), $messageId);
|
$timestamp = timestampMs((string)($row['_dt'] ?? ''), $messageId);
|
||||||
$direction = $from === $currentUserId ? 'out' : 'in';
|
$direction = $from === $currentUserId ? 'out' : 'in';
|
||||||
|
$attachment = nativeAttachmentFromRow($row, $attachment);
|
||||||
$messageType = $attachment ? (string)$attachment['type'] : (($contentType === 1 || $isRecalled) ? 'text' : 'unknown');
|
$messageType = $attachment ? (string)$attachment['type'] : (($contentType === 1 || $isRecalled) ? 'text' : 'unknown');
|
||||||
$messageText = $text !== '' ? $text : ($isRecalled || $attachment ? '' : '[暂不支持的消息类型]');
|
$messageText = nativeDisplayText($text, $contentType, $attachment !== null, $isRecalled);
|
||||||
$messageText = attachmentDisplayText($messageText, $attachment);
|
$messageText = attachmentDisplayText($messageText, $attachment);
|
||||||
$editedAt = (int)($editState['editedAt'] ?? 0);
|
$editedAt = (int)($editState['editedAt'] ?? 0);
|
||||||
$activityTimestamp = messageActivityTimestamp($timestamp, $editState);
|
$activityTimestamp = messageActivityTimestamp($timestamp, $editState);
|
||||||
@@ -854,21 +1505,43 @@ function loadRecentConversations(PDO $db, string $userId, int $limit): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$conversations = array_merge($conversations, loadWebGroupConversations(imAppDb(), $userId, $limit));
|
$conversations = mergeConversationsById($conversations, loadWebGroupConversations(imAppDb(), $userId, $limit));
|
||||||
} catch (Throwable) {
|
} catch (Throwable $error) {
|
||||||
|
error_log('web group conversation sync failed: ' . $error->getMessage());
|
||||||
// Keep single-chat sync available if appdata group metadata is temporarily unavailable.
|
// Keep single-chat sync available if appdata group metadata is temporarily unavailable.
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
$conversations = mergeConversationsById($conversations, loadNativeGroupConversations($db, $userId, $limit));
|
||||||
|
} catch (Throwable $error) {
|
||||||
|
error_log('native group conversation sync failed: ' . $error->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
usort($conversations, static fn($a, $b) => $b['updatedAt'] <=> $a['updatedAt']);
|
usort($conversations, static fn($a, $b) => $b['updatedAt'] <=> $a['updatedAt']);
|
||||||
|
|
||||||
return array_slice($conversations, 0, $limit);
|
return array_slice($conversations, 0, $limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function messageSyncResult(
|
||||||
|
PDO $db,
|
||||||
|
string $userId,
|
||||||
|
array $messages,
|
||||||
|
bool $includeConversations,
|
||||||
|
int $limit
|
||||||
|
): array {
|
||||||
|
$result = ['messages' => $messages];
|
||||||
|
if ($includeConversations) {
|
||||||
|
$result['conversations'] = loadRecentConversations($db, $userId, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
$payload = readJsonPayload();
|
$payload = readJsonPayload();
|
||||||
$userId = trim((string)($payload['userId'] ?? ''));
|
$userId = trim((string)($payload['userId'] ?? ''));
|
||||||
$target = trim((string)($payload['target'] ?? ''));
|
$target = trim((string)($payload['target'] ?? ''));
|
||||||
$limit = clampLimit($payload['limit'] ?? 50, 50, 100);
|
$limit = clampLimit($payload['limit'] ?? 50, 50, 100);
|
||||||
$markRead = payloadBool($payload['markRead'] ?? false);
|
$markRead = payloadBool($payload['markRead'] ?? false);
|
||||||
|
$includeConversations = payloadBool($payload['includeConversations'] ?? false);
|
||||||
$conversationType = normalizeConversationType($payload['conversationType'] ?? $payload['type'] ?? 0);
|
$conversationType = normalizeConversationType($payload['conversationType'] ?? $payload['type'] ?? 0);
|
||||||
|
|
||||||
if ($userId === '') {
|
if ($userId === '') {
|
||||||
@@ -881,15 +1554,50 @@ try {
|
|||||||
markUserDelivered($db, $userId, nowMs());
|
markUserDelivered($db, $userId, nowMs());
|
||||||
|
|
||||||
if ($target !== '') {
|
if ($target !== '') {
|
||||||
$targetId = resolveUserId($db, $target);
|
if ($conversationType === 'group') {
|
||||||
if ($conversationType === 'group' || isWebGroupTarget(imAppDb(), $target)) {
|
if (isWebGroupTarget(imAppDb(), $target)) {
|
||||||
respond(0, 'success', [
|
respond(0, 'success', messageSyncResult(
|
||||||
'messages' => loadWebGroupMessages(imAppDb(), $userId, $target, $limit, $markRead),
|
$db,
|
||||||
]);
|
$userId,
|
||||||
|
loadWebGroupMessages(imAppDb(), $userId, $target, $limit, $markRead),
|
||||||
|
$includeConversations,
|
||||||
|
$limit
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (isNativeGroupTarget($db, $target)) {
|
||||||
|
respond(0, 'success', messageSyncResult(
|
||||||
|
$db,
|
||||||
|
$userId,
|
||||||
|
loadNativeGroupMessages($db, $userId, $target, $limit, $markRead),
|
||||||
|
$includeConversations,
|
||||||
|
$limit
|
||||||
|
));
|
||||||
|
}
|
||||||
|
respond(0, 'success', messageSyncResult(
|
||||||
|
$db,
|
||||||
|
$userId,
|
||||||
|
[],
|
||||||
|
$includeConversations,
|
||||||
|
$limit
|
||||||
|
));
|
||||||
}
|
}
|
||||||
respond(0, 'success', [
|
if (isWebGroupTarget(imAppDb(), $target)) {
|
||||||
'messages' => loadConversationMessages($db, $userId, $targetId, $limit, $markRead),
|
respond(0, 'success', messageSyncResult(
|
||||||
]);
|
$db,
|
||||||
|
$userId,
|
||||||
|
loadWebGroupMessages(imAppDb(), $userId, $target, $limit, $markRead),
|
||||||
|
$includeConversations,
|
||||||
|
$limit
|
||||||
|
));
|
||||||
|
}
|
||||||
|
$targetId = resolveUserId($db, $target);
|
||||||
|
respond(0, 'success', messageSyncResult(
|
||||||
|
$db,
|
||||||
|
$userId,
|
||||||
|
loadConversationMessages($db, $userId, $targetId, $limit, $markRead),
|
||||||
|
$includeConversations,
|
||||||
|
$limit
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
respond(0, 'success', [
|
respond(0, 'success', [
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ header('Cache-Control: no-store');
|
|||||||
require __DIR__ . '/im_web_bootstrap.php';
|
require __DIR__ . '/im_web_bootstrap.php';
|
||||||
|
|
||||||
$defaultName = 'Web IM';
|
$defaultName = 'Web IM';
|
||||||
$defaultLogo = '/img/logo.png';
|
$defaultLogo = '/img/logo.ico';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$statement = imAppDb()->prepare(
|
$statement = imAppDb()->prepare(
|
||||||
|
|||||||
+225
-57
@@ -6,12 +6,21 @@ header('Cache-Control: no-store');
|
|||||||
|
|
||||||
require __DIR__ . '/im_web_bootstrap.php';
|
require __DIR__ . '/im_web_bootstrap.php';
|
||||||
|
|
||||||
const WEB_MEDIA_ROOT = '/opt/im-server/media';
|
define(
|
||||||
const WEB_MEDIA_PUBLIC_PORT = 18000;
|
'WEB_MEDIA_ROOT',
|
||||||
|
webImEnv(
|
||||||
|
'JC_WEB_IM_MEDIA_ROOT',
|
||||||
|
is_dir('/var/www/im/public/media')
|
||||||
|
? '/var/www/im/public/media'
|
||||||
|
: (is_dir('/var/www/IM/public/media') ? '/var/www/IM/public/media' : '/opt/im-server/media')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
define('WEB_MEDIA_PUBLIC_PORT', (int)webImEnv('JC_WEB_IM_MEDIA_PORT', '80'));
|
||||||
const WEB_MEDIA_TYPE_IMAGE = 1;
|
const WEB_MEDIA_TYPE_IMAGE = 1;
|
||||||
const WEB_MEDIA_TYPE_FILE = 4;
|
const WEB_MEDIA_TYPE_FILE = 4;
|
||||||
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
||||||
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
||||||
|
const WEB_MINIO_PROJECT_CODE = '001';
|
||||||
|
|
||||||
function isDailyMuteActive(string $dailyStart, string $dailyEnd, int $nowMs): bool
|
function isDailyMuteActive(string $dailyStart, string $dailyEnd, int $nowMs): bool
|
||||||
{
|
{
|
||||||
@@ -72,6 +81,33 @@ function isGroupMutedForSender(PDO $appDb, string $groupId, string $role, int $n
|
|||||||
return $startsAtMs > 0 && $endsAtMs > $startsAtMs && $nowMs >= $startsAtMs && $nowMs < $endsAtMs;
|
return $startsAtMs > 0 && $endsAtMs > $startsAtMs && $nowMs >= $startsAtMs && $nowMs < $endsAtMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nativeGroupRoleFromType(mixed $type): string
|
||||||
|
{
|
||||||
|
$value = (int)$type;
|
||||||
|
if ($value === 2) {
|
||||||
|
return 'owner';
|
||||||
|
}
|
||||||
|
if ($value === 1) {
|
||||||
|
return 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'member';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchNativeGroupForSender(PDO $db, string $sender, string $groupId): array
|
||||||
|
{
|
||||||
|
$statement = $db->prepare(
|
||||||
|
'select g.`_gid`, g.`_name`, g.`_mute`, g.`_deleted`, m.`_type` as `member_type`'
|
||||||
|
. ' from `t_group` g'
|
||||||
|
. ' join `t_group_member` m on m.`_gid` = g.`_gid`'
|
||||||
|
. ' where g.`_gid` = ? and g.`_deleted` = 0 and m.`_mid` = ? limit 1'
|
||||||
|
);
|
||||||
|
$statement->execute([$groupId, $sender]);
|
||||||
|
$group = $statement->fetch();
|
||||||
|
|
||||||
|
return is_array($group) ? $group : [];
|
||||||
|
}
|
||||||
|
|
||||||
function messageRequestValue(array $payload, string $key, string $default = ''): string
|
function messageRequestValue(array $payload, string $key, string $default = ''): string
|
||||||
{
|
{
|
||||||
return trim((string)($_POST[$key] ?? $payload[$key] ?? $default));
|
return trim((string)($_POST[$key] ?? $payload[$key] ?? $default));
|
||||||
@@ -154,11 +190,114 @@ function mediaPublicBaseUrl(): string
|
|||||||
$isHttps = (string)($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string)$_SERVER['HTTPS']) !== 'off';
|
$isHttps = (string)($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string)$_SERVER['HTTPS']) !== 'off';
|
||||||
$scheme = $isHttps ? 'https' : 'http';
|
$scheme = $isHttps ? 'https' : 'http';
|
||||||
$host = trim(preg_replace('/:\d+$/', '', (string)($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '127.0.0.1')) ?: '127.0.0.1');
|
$host = trim(preg_replace('/:\d+$/', '', (string)($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '127.0.0.1')) ?: '127.0.0.1');
|
||||||
|
$port = (int)WEB_MEDIA_PUBLIC_PORT;
|
||||||
|
if ($port <= 0 || ($scheme === 'http' && $port === 80) || ($scheme === 'https' && $port === 443)) {
|
||||||
|
return $scheme . '://' . $host;
|
||||||
|
}
|
||||||
|
|
||||||
return $scheme . '://' . $host . ':' . WEB_MEDIA_PUBLIC_PORT;
|
return $scheme . '://' . $host . ':' . $port;
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveMessageAttachmentFile(array $file, string $attachmentType): array
|
function messageStorageConfig(): array
|
||||||
|
{
|
||||||
|
$config = ['enabled' => false, 'url' => ''];
|
||||||
|
foreach (['t_app_system_conf', 'system_conf'] as $table) {
|
||||||
|
try {
|
||||||
|
$statement = imAppDb()->query(
|
||||||
|
'select `conf_name`, `conf_value` from `' . $table . '`'
|
||||||
|
. " where lower(`conf_name`) in ('is_minioplus', 'minio_url') and `is_valid` = 1"
|
||||||
|
);
|
||||||
|
$rows = $statement->fetchAll();
|
||||||
|
if (!$rows) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$name = strtolower((string)($row['conf_name'] ?? ''));
|
||||||
|
if ($name === 'is_minioplus') {
|
||||||
|
$config['enabled'] = (string)($row['conf_value'] ?? '0') === '1';
|
||||||
|
} elseif ($name === 'minio_url') {
|
||||||
|
$config['url'] = rtrim(trim((string)($row['conf_value'] ?? '')), '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
} catch (Throwable $error) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $config;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveMessageAttachmentToMinio(
|
||||||
|
array $file,
|
||||||
|
string $attachmentType,
|
||||||
|
string $sender,
|
||||||
|
string $baseUrl
|
||||||
|
): array {
|
||||||
|
$size = (int)($file['size'] ?? 0);
|
||||||
|
$tmpName = (string)($file['tmp_name'] ?? '');
|
||||||
|
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
|
||||||
|
throw new RuntimeException('文件上传失败');
|
||||||
|
}
|
||||||
|
$mime = detectedUploadMime($tmpName);
|
||||||
|
if ($attachmentType === 'image') {
|
||||||
|
$mime = normalizedImageUploadMime($tmpName, $mime);
|
||||||
|
}
|
||||||
|
$originalName = safeUploadName((string)($file['name'] ?? ($attachmentType === 'image' ? 'image.jpg' : 'file')));
|
||||||
|
$content = file_get_contents($tmpName);
|
||||||
|
if ($content === false) {
|
||||||
|
throw new RuntimeException('读取上传文件失败');
|
||||||
|
}
|
||||||
|
$requestBody = json_encode([
|
||||||
|
'projectCode' => WEB_MINIO_PROJECT_CODE,
|
||||||
|
'fileScene' => 'temp',
|
||||||
|
'fileName' => $originalName,
|
||||||
|
'contentType' => $mime,
|
||||||
|
'base64Content' => base64_encode($content),
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
if (!is_string($requestBody)) {
|
||||||
|
throw new RuntimeException('生成图片上传请求失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
$handle = curl_init($baseUrl . '/project-storage/upload');
|
||||||
|
curl_setopt_array($handle, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 8,
|
||||||
|
CURLOPT_TIMEOUT => 60,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'Authorization: minio-' . WEB_MINIO_PROJECT_CODE . '-' . $sender,
|
||||||
|
],
|
||||||
|
CURLOPT_POSTFIELDS => $requestBody,
|
||||||
|
]);
|
||||||
|
$responseBody = curl_exec($handle);
|
||||||
|
$status = (int)curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
|
||||||
|
$curlError = curl_error($handle);
|
||||||
|
curl_close($handle);
|
||||||
|
$response = is_string($responseBody) ? json_decode($responseBody, true) : null;
|
||||||
|
$data = is_array($response) && is_array($response['data'] ?? null) ? $response['data'] : [];
|
||||||
|
$url = trim((string)($data['previewUrl'] ?? $data['downloadUrl'] ?? ''));
|
||||||
|
if ($status < 200 || $status >= 300 || (int)($response['code'] ?? -1) !== 0 || $url === '') {
|
||||||
|
$message = trim((string)($response['message'] ?? ''));
|
||||||
|
throw new RuntimeException(
|
||||||
|
$message !== ''
|
||||||
|
? 'MinIO上传失败:' . $message
|
||||||
|
: 'MinIO上传失败' . ($curlError !== '' ? ':' . $curlError : '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => $attachmentType,
|
||||||
|
'url' => $url,
|
||||||
|
'name' => $originalName,
|
||||||
|
'mime' => $mime,
|
||||||
|
'size' => $size,
|
||||||
|
'mediaType' => $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveMessageAttachmentFile(array $file, string $attachmentType, string $sender): array
|
||||||
{
|
{
|
||||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||||
throw new RuntimeException('文件上传失败');
|
throw new RuntimeException('文件上传失败');
|
||||||
@@ -168,10 +307,6 @@ function saveMessageAttachmentFile(array $file, string $attachmentType): array
|
|||||||
if ($size <= 0 || $size > $maxSize) {
|
if ($size <= 0 || $size > $maxSize) {
|
||||||
throw new RuntimeException($attachmentType === 'image' ? '图片不能超过10MB' : '文件不能超过50MB');
|
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'] ?? '');
|
$tmpName = (string)($file['tmp_name'] ?? '');
|
||||||
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
|
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
|
||||||
throw new RuntimeException('文件上传失败');
|
throw new RuntimeException('文件上传失败');
|
||||||
@@ -181,6 +316,17 @@ function saveMessageAttachmentFile(array $file, string $attachmentType): array
|
|||||||
$mime = normalizedImageUploadMime($tmpName, $mime);
|
$mime = normalizedImageUploadMime($tmpName, $mime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$storage = messageStorageConfig();
|
||||||
|
if ($storage['enabled']) {
|
||||||
|
if ($storage['url'] === '') {
|
||||||
|
throw new RuntimeException('MinIO已开启但未配置服务地址');
|
||||||
|
}
|
||||||
|
return saveMessageAttachmentToMinio($file, $attachmentType, $sender, $storage['url']);
|
||||||
|
}
|
||||||
|
if (!is_dir(WEB_MEDIA_ROOT) || !is_writable(WEB_MEDIA_ROOT)) {
|
||||||
|
throw new RuntimeException('媒体存储目录不可写');
|
||||||
|
}
|
||||||
|
|
||||||
$mediaType = $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE;
|
$mediaType = $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE;
|
||||||
$datePath = 'fs/' . $mediaType . '/' . date('Y/m/d/H/i/s');
|
$datePath = 'fs/' . $mediaType . '/' . date('Y/m/d/H/i/s');
|
||||||
$targetDir = rtrim(WEB_MEDIA_ROOT, '/') . '/' . $datePath;
|
$targetDir = rtrim(WEB_MEDIA_ROOT, '/') . '/' . $datePath;
|
||||||
@@ -245,7 +391,7 @@ function storeMessageAttachment(
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachmentFromRequest(string $messageType): ?array
|
function attachmentFromRequest(string $messageType, string $sender): ?array
|
||||||
{
|
{
|
||||||
if (!in_array($messageType, ['image', 'file'], true)) {
|
if (!in_array($messageType, ['image', 'file'], true)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -256,7 +402,61 @@ function attachmentFromRequest(string $messageType): ?array
|
|||||||
respond(400, '缺少上传文件');
|
respond(400, '缺少上传文件');
|
||||||
}
|
}
|
||||||
|
|
||||||
return saveMessageAttachmentFile($attachmentFile, $messageType);
|
return saveMessageAttachmentFile($attachmentFile, $messageType, $sender);
|
||||||
|
}
|
||||||
|
|
||||||
|
function messagePayloadBody(string $text, ?array $attachment): array
|
||||||
|
{
|
||||||
|
$pushContent = $text !== ''
|
||||||
|
? $text
|
||||||
|
: ($attachment
|
||||||
|
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
||||||
|
: $text);
|
||||||
|
$payloadBody = [
|
||||||
|
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
|
||||||
|
'searchableContent' => $pushContent,
|
||||||
|
'pushContent' => $pushContent,
|
||||||
|
'content' => $text,
|
||||||
|
'persistFlag' => 3,
|
||||||
|
];
|
||||||
|
if ($attachment) {
|
||||||
|
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
|
||||||
|
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
|
||||||
|
$payloadBody['extra'] = json_encode([
|
||||||
|
'name' => (string)$attachment['name'],
|
||||||
|
'mime' => (string)$attachment['mime'],
|
||||||
|
'size' => (int)$attachment['size'],
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payloadBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendNativeGroupMessage(string $sender, string $target, string $text, ?array $attachment): array
|
||||||
|
{
|
||||||
|
$result = requireImSuccess(imAdminPost('/admin/message/send', [
|
||||||
|
'sender' => $sender,
|
||||||
|
'conv' => [
|
||||||
|
'type' => 1,
|
||||||
|
'target' => $target,
|
||||||
|
'line' => 0,
|
||||||
|
],
|
||||||
|
'payload' => messagePayloadBody($text, $attachment),
|
||||||
|
'userMessage' => true,
|
||||||
|
]), '原生群消息发送失败');
|
||||||
|
|
||||||
|
$messageUid = isset($result['messageUid']) ? (string)$result['messageUid'] : '';
|
||||||
|
$timestamp = (int)($result['timestamp'] ?? webImNowMs());
|
||||||
|
if ($attachment && $messageUid !== '') {
|
||||||
|
ensureWebImAppTables();
|
||||||
|
storeMessageAttachment(imAppDb(), $messageUid, 'group', $target, $sender, $attachment, $timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'messageUid' => $messageUid !== '' ? $messageUid : null,
|
||||||
|
'timestamp' => $timestamp,
|
||||||
|
'attachment' => $attachment,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$contentTypeHeader = (string)($_SERVER['CONTENT_TYPE'] ?? '');
|
$contentTypeHeader = (string)($_SERVER['CONTENT_TYPE'] ?? '');
|
||||||
@@ -288,7 +488,17 @@ try {
|
|||||||
$memberStatement->execute([$target, $sender]);
|
$memberStatement->execute([$target, $sender]);
|
||||||
$group = $memberStatement->fetch();
|
$group = $memberStatement->fetch();
|
||||||
if (!$group) {
|
if (!$group) {
|
||||||
respond(403, '你不在该群组中');
|
$nativeGroup = fetchNativeGroupForSender(imDb(), $sender, $target);
|
||||||
|
if (!$nativeGroup) {
|
||||||
|
respond(403, '你不在该群组中');
|
||||||
|
}
|
||||||
|
$attachment = attachmentFromRequest($messageType, $sender);
|
||||||
|
$nativeResult = sendNativeGroupMessage($sender, $target, $text, $attachment);
|
||||||
|
respond(0, 'success', [
|
||||||
|
...$nativeResult,
|
||||||
|
'senderRole' => nativeGroupRoleFromType($nativeGroup['member_type'] ?? 0),
|
||||||
|
'native' => true,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
$category = (string)($group['category'] ?? 'group');
|
$category = (string)($group['category'] ?? 'group');
|
||||||
$role = (string)($group['role'] ?? 'member');
|
$role = (string)($group['role'] ?? 'member');
|
||||||
@@ -300,7 +510,7 @@ try {
|
|||||||
if ($category !== 'channel' && isGroupMutedForSender($appDb, $target, $role, $now)) {
|
if ($category !== 'channel' && isGroupMutedForSender($appDb, $target, $role, $now)) {
|
||||||
respond(403, '当前群组处于禁言时段');
|
respond(403, '当前群组处于禁言时段');
|
||||||
}
|
}
|
||||||
$attachment = attachmentFromRequest($messageType);
|
$attachment = attachmentFromRequest($messageType, $sender);
|
||||||
$messageUid = webImMessageUid();
|
$messageUid = webImMessageUid();
|
||||||
$insert = $appDb->prepare(
|
$insert = $appDb->prepare(
|
||||||
'insert into `t_web_group_messages`'
|
'insert into `t_web_group_messages`'
|
||||||
@@ -316,27 +526,6 @@ try {
|
|||||||
|
|
||||||
$nativeError = '';
|
$nativeError = '';
|
||||||
try {
|
try {
|
||||||
$pushContent = $text !== ''
|
|
||||||
? $text
|
|
||||||
: ($attachment
|
|
||||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
|
||||||
: $text);
|
|
||||||
$payloadBody = [
|
|
||||||
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
|
|
||||||
'searchableContent' => $pushContent,
|
|
||||||
'pushContent' => $pushContent,
|
|
||||||
'content' => $text,
|
|
||||||
'persistFlag' => 3,
|
|
||||||
];
|
|
||||||
if ($attachment) {
|
|
||||||
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
|
|
||||||
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
|
|
||||||
$payloadBody['extra'] = json_encode([
|
|
||||||
'name' => (string)$attachment['name'],
|
|
||||||
'mime' => (string)$attachment['mime'],
|
|
||||||
'size' => (int)$attachment['size'],
|
|
||||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
||||||
}
|
|
||||||
requireImSuccess(imAdminPost('/admin/message/send', [
|
requireImSuccess(imAdminPost('/admin/message/send', [
|
||||||
'sender' => $sender,
|
'sender' => $sender,
|
||||||
'conv' => [
|
'conv' => [
|
||||||
@@ -344,7 +533,7 @@ try {
|
|||||||
'target' => $target,
|
'target' => $target,
|
||||||
'line' => 0,
|
'line' => 0,
|
||||||
],
|
],
|
||||||
'payload' => $payloadBody,
|
'payload' => messagePayloadBody($text, $attachment),
|
||||||
'userMessage' => true,
|
'userMessage' => true,
|
||||||
]), '原生群消息发送失败');
|
]), '原生群消息发送失败');
|
||||||
} catch (Throwable $error) {
|
} catch (Throwable $error) {
|
||||||
@@ -360,28 +549,7 @@ try {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$attachment = attachmentFromRequest($messageType);
|
$attachment = attachmentFromRequest($messageType, $sender);
|
||||||
$pushContent = $text !== ''
|
|
||||||
? $text
|
|
||||||
: ($attachment
|
|
||||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
|
||||||
: $text);
|
|
||||||
$payloadBody = [
|
|
||||||
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
|
|
||||||
'searchableContent' => $pushContent,
|
|
||||||
'pushContent' => $pushContent,
|
|
||||||
'content' => $text,
|
|
||||||
'persistFlag' => 3,
|
|
||||||
];
|
|
||||||
if ($attachment) {
|
|
||||||
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
|
|
||||||
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
|
|
||||||
$payloadBody['extra'] = json_encode([
|
|
||||||
'name' => (string)$attachment['name'],
|
|
||||||
'mime' => (string)$attachment['mime'],
|
|
||||||
'size' => (int)$attachment['size'],
|
|
||||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = requireImSuccess(imAdminPost('/admin/message/send', [
|
$result = requireImSuccess(imAdminPost('/admin/message/send', [
|
||||||
'sender' => $sender,
|
'sender' => $sender,
|
||||||
@@ -390,7 +558,7 @@ try {
|
|||||||
'target' => $target,
|
'target' => $target,
|
||||||
'line' => 0,
|
'line' => 0,
|
||||||
],
|
],
|
||||||
'payload' => $payloadBody,
|
'payload' => messagePayloadBody($text, $attachment),
|
||||||
'userMessage' => true,
|
'userMessage' => true,
|
||||||
]), '发送失败');
|
]), '发送失败');
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
window.JC_WEB_IM_CONFIG = {
|
(() => {
|
||||||
adminServer: 'http://winchen.ezpay.asia:8024',
|
const protocol = window.location.protocol || 'http:';
|
||||||
appServer: 'http://winchen.ezpay.asia:8888',
|
const host = window.location.hostname || '127.0.0.1';
|
||||||
qrAppServer: 'http://winchen.ezpay.asia:18888',
|
|
||||||
platformConfigApi: '/api/platform_config.php',
|
window.JC_WEB_IM_CONFIG = {
|
||||||
registerConfigApi: 'http://winchen.ezpay.asia:8024/api/register-config',
|
adminServer: `${protocol}//${host}:8100`,
|
||||||
imServerHost: 'winchen.ezpay.asia',
|
appServer: `${protocol}//${host}:8888`,
|
||||||
imRoutePort: 18000,
|
platformConfigApi: '/api/platform_config.php',
|
||||||
useWss: false,
|
registerConfigApi: `${protocol}//${host}:8100/api/register-config`,
|
||||||
wsPath: '/ws',
|
registerPhoneExistsApi: `${protocol}//${host}:8100/api/register-phone-exists`,
|
||||||
sdkMode: 'http'
|
imServerHost: host,
|
||||||
};
|
imRoutePort: 80,
|
||||||
|
useWss: false,
|
||||||
|
wsPath: '/ws',
|
||||||
|
sdkMode: 'http',
|
||||||
|
syncIntervalMs: 15000,
|
||||||
|
sseEnabled: true,
|
||||||
|
sseTicketApi: '/api/sse_ticket.php',
|
||||||
|
sseUrl: '/ws-events'
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 958 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB |
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Web IM</title>
|
<title>Web IM</title>
|
||||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
<link rel="icon" href="<%= BASE_URL %>img/5IuOieu9PF8MJtp43kD2kCkdLVjVwDp7.ico">
|
||||||
<script src="<%= BASE_URL %>config/runtime-config.js"></script>
|
<script src="<%= BASE_URL %>config/runtime-config.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+13
-3
@@ -1,17 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<LoginView v-if="!session.isAuthenticated" />
|
<LoginView v-if="!session.isAuthenticated" />
|
||||||
<DocumentedShell v-else />
|
<ChatShell v-else />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted } from 'vue';
|
import { onMounted, watch } from 'vue';
|
||||||
import LoginView from './components/LoginView.vue';
|
import LoginView from './components/LoginView.vue';
|
||||||
import DocumentedShell from './components/DocumentedShell.vue';
|
import ChatShell from './components/ChatShell.vue';
|
||||||
import { useSessionStore } from './stores/session';
|
import { useSessionStore } from './stores/session';
|
||||||
|
import { useConversationStore } from './stores/conversations';
|
||||||
|
|
||||||
const session = useSessionStore();
|
const session = useSessionStore();
|
||||||
|
const conversations = useConversationStore();
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
session.restore();
|
session.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(() => session.userId, userId => {
|
||||||
|
if (userId && session.isAuthenticated) {
|
||||||
|
conversations.bootstrapDemoData();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
conversations.clearUserState();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,186 +0,0 @@
|
|||||||
<template>
|
|
||||||
<main class="documented-shell">
|
|
||||||
<aside class="documented-rail">
|
|
||||||
<div class="documented-avatar">
|
|
||||||
<img v-if="session.portrait" :src="session.portrait" alt="">
|
|
||||||
<span v-else>{{ initials }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="documented-rail-copy">
|
|
||||||
<strong>{{ session.displayName || session.userId }}</strong>
|
|
||||||
<small>{{ session.userId }}</small>
|
|
||||||
</div>
|
|
||||||
<button class="documented-rail-button" type="button" @click="openPasswordDialog">修改密码</button>
|
|
||||||
<button class="documented-rail-button ghost" type="button" @click="logout">退出登录</button>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section class="documented-main">
|
|
||||||
<header class="documented-header">
|
|
||||||
<div>
|
|
||||||
<h1>文档对接模式</h1>
|
|
||||||
<p>当前界面只保留这两份文档已经明确提供的能力。</p>
|
|
||||||
</div>
|
|
||||||
<div class="documented-status">
|
|
||||||
<span>连接状态</span>
|
|
||||||
<strong>{{ session.connectionStatusText || '已登录' }}</strong>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="documented-card">
|
|
||||||
<h2>已按文档接入</h2>
|
|
||||||
<ul class="documented-list">
|
|
||||||
<li>`/auth/login_pwd` 密码登录</li>
|
|
||||||
<li>`/auth/register` 注册并返回 `imToken`、`accessToken`、`refreshToken`</li>
|
|
||||||
<li>`/auth/sendEmailCode` 邮箱验证码</li>
|
|
||||||
<li>`/auth/refreshToken` 过期自动刷新</li>
|
|
||||||
<li>`/auth/changePassword` 修改密码</li>
|
|
||||||
<li>`/auth/logout` 退出登录</li>
|
|
||||||
<li>扫码登录兼容接口:`/pc_session` 与 `/session_login/{token}`</li>
|
|
||||||
<li>AES-128-CBC 请求加密、响应解密、`X-Timestamp`、排除路径规则</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="documented-card">
|
|
||||||
<h2>当前按 1 比 1 已停用</h2>
|
|
||||||
<p class="documented-text">下面这些旧页面功能已经不再走本地 PHP 或旧接口,因为你给的文档里没有对应定义。</p>
|
|
||||||
<ul class="documented-list muted">
|
|
||||||
<li>会话列表与消息列表</li>
|
|
||||||
<li>发送文字、图片、文件消息</li>
|
|
||||||
<li>联系人列表、添加联系人、备注</li>
|
|
||||||
<li>群详情、群成员、群管理</li>
|
|
||||||
<li>个人资料编辑与头像上传</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="documented-card">
|
|
||||||
<h2>当前登录态</h2>
|
|
||||||
<dl class="documented-meta">
|
|
||||||
<div>
|
|
||||||
<dt>用户 ID</dt>
|
|
||||||
<dd>{{ session.userId || '-' }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>显示名</dt>
|
|
||||||
<dd>{{ session.displayName || '-' }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>IM Token</dt>
|
|
||||||
<dd>{{ session.token ? '已保存' : '未保存' }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>App Server</dt>
|
|
||||||
<dd>{{ appConfig.appServer }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>扫码服务</dt>
|
|
||||||
<dd>{{ appConfig.qrAppServer }}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div v-if="passwordDialogOpen" class="modal-backdrop" @click.self="closePasswordDialog">
|
|
||||||
<form class="modal-panel documented-password-panel" @submit.prevent="savePassword">
|
|
||||||
<header>
|
|
||||||
<strong>修改密码</strong>
|
|
||||||
<button type="button" class="icon-button" title="关闭" @click="closePasswordDialog">×</button>
|
|
||||||
</header>
|
|
||||||
<label>
|
|
||||||
<span>当前密码</span>
|
|
||||||
<input v-model="passwordDraft.oldPassword" type="password" autocomplete="current-password">
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>新密码</span>
|
|
||||||
<input v-model="passwordDraft.newPassword" type="password" autocomplete="new-password">
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>确认新密码</span>
|
|
||||||
<input v-model="passwordDraft.confirmPassword" type="password" autocomplete="new-password">
|
|
||||||
</label>
|
|
||||||
<p v-if="passwordError" class="form-error">{{ passwordError }}</p>
|
|
||||||
<p v-if="passwordNotice" class="form-info">{{ passwordNotice }}</p>
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button type="button" class="ghost-inline-action" :disabled="passwordSaving" @click="closePasswordDialog">取消</button>
|
|
||||||
<button class="primary-inline-action" :disabled="passwordSaving">
|
|
||||||
{{ passwordSaving ? '保存中...' : '保存' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, reactive, ref } from 'vue';
|
|
||||||
import { useSessionStore } from '@/stores/session';
|
|
||||||
import { appConfig } from '@/config';
|
|
||||||
|
|
||||||
const session = useSessionStore();
|
|
||||||
const passwordDialogOpen = ref(false);
|
|
||||||
const passwordSaving = ref(false);
|
|
||||||
const passwordError = ref('');
|
|
||||||
const passwordNotice = ref('');
|
|
||||||
const passwordDraft = reactive({
|
|
||||||
oldPassword: '',
|
|
||||||
newPassword: '',
|
|
||||||
confirmPassword: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
const initials = computed(() => String(session.displayName || session.userId || 'U').slice(0, 1).toUpperCase());
|
|
||||||
|
|
||||||
function resetPasswordDraft() {
|
|
||||||
passwordDraft.oldPassword = '';
|
|
||||||
passwordDraft.newPassword = '';
|
|
||||||
passwordDraft.confirmPassword = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function openPasswordDialog() {
|
|
||||||
passwordError.value = '';
|
|
||||||
passwordNotice.value = '';
|
|
||||||
resetPasswordDraft();
|
|
||||||
passwordDialogOpen.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function closePasswordDialog() {
|
|
||||||
if (passwordSaving.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
passwordDialogOpen.value = false;
|
|
||||||
passwordError.value = '';
|
|
||||||
passwordNotice.value = '';
|
|
||||||
resetPasswordDraft();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function savePassword() {
|
|
||||||
passwordError.value = '';
|
|
||||||
passwordNotice.value = '';
|
|
||||||
if (!passwordDraft.oldPassword || !passwordDraft.newPassword || !passwordDraft.confirmPassword) {
|
|
||||||
passwordError.value = '请填写完整密码';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (passwordDraft.newPassword !== passwordDraft.confirmPassword) {
|
|
||||||
passwordError.value = '两次输入的新密码不一致';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (passwordDraft.oldPassword === passwordDraft.newPassword) {
|
|
||||||
passwordError.value = '新密码不能与当前密码相同';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
passwordSaving.value = true;
|
|
||||||
try {
|
|
||||||
await session.changePassword({
|
|
||||||
oldPassword: passwordDraft.oldPassword,
|
|
||||||
newPassword: passwordDraft.newPassword
|
|
||||||
});
|
|
||||||
passwordNotice.value = '密码已修改,当前登录态会按文档要求失效。';
|
|
||||||
} catch (error) {
|
|
||||||
passwordError.value = error.message || '密码修改失败';
|
|
||||||
} finally {
|
|
||||||
passwordSaving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function logout() {
|
|
||||||
await session.logout();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
+598
-182
File diff suppressed because it is too large
Load Diff
@@ -303,12 +303,15 @@
|
|||||||
</header>
|
</header>
|
||||||
<div v-if="groupBusy" class="group-loading">正在加载</div>
|
<div v-if="groupBusy" class="group-loading">正在加载</div>
|
||||||
<div v-else class="group-member-list">
|
<div v-else class="group-member-list">
|
||||||
<div v-for="member in groupMembers" :key="member.uid" class="group-member-row">
|
<div v-for="member in sortedGroupMembers" :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="">
|
||||||
<span v-else class="conversation-avatar">{{ memberAvatar(member) }}</span>
|
<span v-else class="conversation-avatar">{{ memberAvatar(member) }}</span>
|
||||||
<span>
|
<span>
|
||||||
<strong>{{ member.displayName }}</strong>
|
<strong>{{ member.displayName }}</strong>
|
||||||
<small>{{ member.role === 'owner' ? '群主' : member.rawDisplayName }}</small>
|
<small v-if="detailRoleLabel(member.role)" class="group-member-role">
|
||||||
|
{{ detailRoleLabel(member.role) }}
|
||||||
|
</small>
|
||||||
|
<small v-else>{{ member.rawDisplayName }}</small>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!groupMembers.length" class="contact-picker-empty">暂无群员</div>
|
<div v-if="!groupMembers.length" class="contact-picker-empty">暂无群员</div>
|
||||||
@@ -576,6 +579,17 @@ const currentGroupRole = computed(() => {
|
|||||||
});
|
});
|
||||||
const isGroupOwner = computed(() => currentGroupRole.value === 'owner');
|
const isGroupOwner = computed(() => currentGroupRole.value === 'owner');
|
||||||
const canManageGroup = computed(() => ['owner', 'admin'].includes(currentGroupRole.value));
|
const canManageGroup = computed(() => ['owner', 'admin'].includes(currentGroupRole.value));
|
||||||
|
const sortedGroupMembers = computed(() => {
|
||||||
|
const roleOrder = { owner: 0, admin: 1, member: 2 };
|
||||||
|
return groupMembers.value
|
||||||
|
.map((member, index) => ({ member, index }))
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftOrder = roleOrder[left.member.role] ?? 3;
|
||||||
|
const rightOrder = roleOrder[right.member.role] ?? 3;
|
||||||
|
return leftOrder - rightOrder || left.index - right.index;
|
||||||
|
})
|
||||||
|
.map(item => item.member);
|
||||||
|
});
|
||||||
const adminCandidateMembers = 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 transferableMembers = computed(() => groupMembers.value.filter(member => member.role !== 'owner'));
|
||||||
const messageDetail = computed(() => conversations.messageDetail || null);
|
const messageDetail = computed(() => conversations.messageDetail || null);
|
||||||
|
|||||||
@@ -17,9 +17,36 @@
|
|||||||
<button type="button" title="取消引用" @click="$emit('cancel-quote')">×</button>
|
<button type="button" title="取消引用" @click="$emit('cancel-quote')">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="composer-tools">
|
<div class="composer-tools">
|
||||||
<button type="button" title="表情" :disabled="disabled" @click="toggleEmojiPicker">☺</button>
|
<button type="button" title="表情" aria-label="表情" :disabled="disabled" @click="toggleEmojiPicker">☺</button>
|
||||||
<button type="button" title="图片" :disabled="disabled" @click="chooseImage">▧</button>
|
<button
|
||||||
<button type="button" title="文件" :disabled="disabled" @click="chooseFile">▤</button>
|
type="button"
|
||||||
|
class="composer-tool-button image-tool"
|
||||||
|
title="图片"
|
||||||
|
aria-label="图片"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="chooseImage"
|
||||||
|
>
|
||||||
|
<svg class="composer-tool-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<rect x="3" y="5" width="18" height="14" rx="2.5"></rect>
|
||||||
|
<circle cx="8.2" cy="9.2" r="1.6"></circle>
|
||||||
|
<path d="M6.5 17l4.2-4.7 3.1 3.4 1.8-2 2.9 3.3"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="composer-tool-button file-tool"
|
||||||
|
title="文件"
|
||||||
|
aria-label="文件"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="chooseFile"
|
||||||
|
>
|
||||||
|
<svg class="composer-tool-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M7 3.8h7.2L19 8.6V20a1.8 1.8 0 0 1-1.8 1.8H7A1.8 1.8 0 0 1 5.2 20V5.6A1.8 1.8 0 0 1 7 3.8z"></path>
|
||||||
|
<path d="M14 4v5h5"></path>
|
||||||
|
<path d="M8.5 13h7"></path>
|
||||||
|
<path d="M8.5 16.5h5.2"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<input ref="imageInput" type="file" accept="image/*" multiple @change="selectAttachment($event, 'image')">
|
<input ref="imageInput" type="file" accept="image/*" multiple @change="selectAttachment($event, 'image')">
|
||||||
<input ref="fileInput" type="file" multiple @change="selectAttachment($event, 'file')">
|
<input ref="fileInput" type="file" multiple @change="selectAttachment($event, 'file')">
|
||||||
<div v-if="emojiOpen" class="emoji-picker" @click.stop>
|
<div v-if="emojiOpen" class="emoji-picker" @click.stop>
|
||||||
@@ -33,6 +60,22 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="pendingAttachments.length" class="composer-attachment-preview">
|
||||||
|
<article
|
||||||
|
v-for="(attachment, index) in pendingAttachments"
|
||||||
|
:key="attachment.id"
|
||||||
|
class="composer-attachment-item"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="attachment.type === 'image'"
|
||||||
|
:src="attachment.previewUrl"
|
||||||
|
:alt="attachment.file.name || '待发送图片'"
|
||||||
|
>
|
||||||
|
<span v-else class="composer-attachment-file-icon">▤</span>
|
||||||
|
<span class="composer-attachment-name">{{ attachment.file.name }}</span>
|
||||||
|
<button type="button" title="移除" aria-label="移除待发送文件" @click="removePendingAttachment(index)">×</button>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
ref="textareaEl"
|
ref="textareaEl"
|
||||||
v-model="text"
|
v-model="text"
|
||||||
@@ -43,13 +86,13 @@
|
|||||||
></textarea>
|
></textarea>
|
||||||
<div class="composer-footer">
|
<div class="composer-footer">
|
||||||
<span>Enter 发送,Shift + Enter 换行</span>
|
<span>Enter 发送,Shift + Enter 换行</span>
|
||||||
<button class="send-button" :disabled="disabled || !text.trim()">发送</button>
|
<button class="send-button" :disabled="disabled || (!text.trim() && !pendingAttachments.length)">发送</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { onBeforeUnmount, ref } from 'vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
disabled: {
|
disabled: {
|
||||||
@@ -69,6 +112,7 @@ const dropActive = ref(false);
|
|||||||
const textareaEl = ref(null);
|
const textareaEl = ref(null);
|
||||||
const imageInput = ref(null);
|
const imageInput = ref(null);
|
||||||
const fileInput = ref(null);
|
const fileInput = ref(null);
|
||||||
|
const pendingAttachments = ref([]);
|
||||||
let dragDepth = 0;
|
let dragDepth = 0;
|
||||||
const emojiList = [
|
const emojiList = [
|
||||||
'😀', '😄', '😂', '😊', '😍', '😘', '😎', '😢',
|
'😀', '😄', '😂', '😊', '😍', '😘', '😎', '😢',
|
||||||
@@ -78,10 +122,22 @@ const emojiList = [
|
|||||||
|
|
||||||
function submit() {
|
function submit() {
|
||||||
const value = text.value.trim();
|
const value = text.value.trim();
|
||||||
if (!value) {
|
const attachments = [...pendingAttachments.value];
|
||||||
|
if (!value && !attachments.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
emit('send', value);
|
if (attachments.length) {
|
||||||
|
attachments.forEach((attachment, index) => {
|
||||||
|
emit('send-attachment', {
|
||||||
|
type: attachment.type,
|
||||||
|
file: attachment.file,
|
||||||
|
text: index === 0 ? value : ''
|
||||||
|
});
|
||||||
|
});
|
||||||
|
clearPendingAttachments();
|
||||||
|
} else {
|
||||||
|
emit('send', value);
|
||||||
|
}
|
||||||
text.value = '';
|
text.value = '';
|
||||||
emojiOpen.value = false;
|
emojiOpen.value = false;
|
||||||
}
|
}
|
||||||
@@ -130,18 +186,34 @@ function emitFiles(files, fallbackType = 'file') {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const caption = text.value.trim();
|
validFiles.forEach(file => {
|
||||||
validFiles.forEach((file, index) => {
|
const type = attachmentTypeForFile(file, fallbackType);
|
||||||
emit('send-attachment', {
|
pendingAttachments.value.push({
|
||||||
type: attachmentTypeForFile(file, fallbackType),
|
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
|
type,
|
||||||
file,
|
file,
|
||||||
text: index === 0 ? caption : ''
|
previewUrl: type === 'image' ? URL.createObjectURL(file) : ''
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
text.value = '';
|
|
||||||
emojiOpen.value = false;
|
emojiOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function releasePendingAttachment(attachment) {
|
||||||
|
if (attachment?.previewUrl) {
|
||||||
|
URL.revokeObjectURL(attachment.previewUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePendingAttachment(index) {
|
||||||
|
const [attachment] = pendingAttachments.value.splice(index, 1);
|
||||||
|
releasePendingAttachment(attachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPendingAttachments() {
|
||||||
|
pendingAttachments.value.forEach(releasePendingAttachment);
|
||||||
|
pendingAttachments.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
function selectAttachment(event, type) {
|
function selectAttachment(event, type) {
|
||||||
const input = event.target;
|
const input = event.target;
|
||||||
const files = input.files ? Array.from(input.files) : [];
|
const files = input.files ? Array.from(input.files) : [];
|
||||||
@@ -220,4 +292,6 @@ function handlePaste(event) {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
emitFiles(imageFiles, 'image');
|
emitFiles(imageFiles, 'image');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(clearPendingAttachments);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -47,7 +47,13 @@
|
|||||||
class="message-image"
|
class="message-image"
|
||||||
@click.stop="handleImageClick(message)"
|
@click.stop="handleImageClick(message)"
|
||||||
>
|
>
|
||||||
<img :src="message.attachment.url" :alt="message.attachment.name || '图片'">
|
<img
|
||||||
|
:src="imageDisplayUrl(message.attachment.url)"
|
||||||
|
:alt="message.attachment.name || '图片'"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
@error="retryImageLoad"
|
||||||
|
>
|
||||||
</button>
|
</button>
|
||||||
<a
|
<a
|
||||||
v-else-if="isFileMessage(message)"
|
v-else-if="isFileMessage(message)"
|
||||||
@@ -63,7 +69,7 @@
|
|||||||
<small>{{ formatFileSize(message.attachment.size) }}</small>
|
<small>{{ formatFileSize(message.attachment.size) }}</small>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<p v-if="message.text">{{ message.text }}</p>
|
<p v-if="displayMessageText(message)">{{ displayMessageText(message) }}</p>
|
||||||
<time>
|
<time>
|
||||||
<span v-if="isRecallNotice(message)" class="recall-mark">{{ message.recallStatus || '已撤回' }}</span>
|
<span v-if="isRecallNotice(message)" class="recall-mark">{{ message.recallStatus || '已撤回' }}</span>
|
||||||
<span>{{ formatTime(message.displayTimestamp || message.timestamp) }}</span>
|
<span>{{ formatTime(message.displayTimestamp || message.timestamp) }}</span>
|
||||||
@@ -127,9 +133,11 @@
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
class="image-viewer-img"
|
class="image-viewer-img"
|
||||||
:src="imageViewer.url"
|
:src="imageDisplayUrl(imageViewer.url)"
|
||||||
:alt="imageViewer.name || '图片'"
|
:alt="imageViewer.name || '图片'"
|
||||||
:style="imageViewerStyle"
|
:style="imageViewerStyle"
|
||||||
|
decoding="async"
|
||||||
|
@error="retryImageLoad"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -211,6 +219,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||||
import { useContactStore } from '@/stores/contacts';
|
import { useContactStore } from '@/stores/contacts';
|
||||||
|
import { imageDisplayUrl } from '@/services/mediaProxy';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
messages: {
|
messages: {
|
||||||
@@ -251,6 +260,12 @@ const contextMenu = reactive({
|
|||||||
});
|
});
|
||||||
const IMAGE_MIN_SCALE = 0.5;
|
const IMAGE_MIN_SCALE = 0.5;
|
||||||
const IMAGE_MAX_SCALE = 6;
|
const IMAGE_MAX_SCALE = 6;
|
||||||
|
const messageTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
|
});
|
||||||
const imageViewer = reactive({
|
const imageViewer = reactive({
|
||||||
visible: false,
|
visible: false,
|
||||||
url: '',
|
url: '',
|
||||||
@@ -324,12 +339,22 @@ const menuItems = computed(() => {
|
|||||||
|
|
||||||
function scrollToBottom() {
|
function scrollToBottom() {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (listEl.value) {
|
requestAnimationFrame(() => {
|
||||||
listEl.value.scrollTop = listEl.value.scrollHeight;
|
if (listEl.value) {
|
||||||
}
|
listEl.value.scrollTop = listEl.value.scrollHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isNearBottom() {
|
||||||
|
const element = listEl.value;
|
||||||
|
if (!element) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return element.scrollHeight - element.scrollTop - element.clientHeight < 120;
|
||||||
|
}
|
||||||
|
|
||||||
function scrollToMessage(messageId) {
|
function scrollToMessage(messageId) {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (!listEl.value || !messageId) {
|
if (!listEl.value || !messageId) {
|
||||||
@@ -344,8 +369,12 @@ function scrollToMessage(messageId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.messages.map(message => `${message.id}:${message.status}:${message.deliveryStatus || ''}:${message.messageUid || ''}:${message.recalled ? '1' : ''}`).join('|'),
|
() => props.messages.map(messageIdentity).join('|'),
|
||||||
scrollToBottom,
|
(nextKeys, previousKeys) => {
|
||||||
|
if (!previousKeys || (nextKeys !== previousKeys && isNearBottom())) {
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -372,6 +401,22 @@ function messageIdentity(message) {
|
|||||||
return String(message.messageUid || message.id || '');
|
return String(message.messageUid || message.id || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function displayMessageText(message) {
|
||||||
|
const text = String(message?.text || '').replace(/^引用:.*(?:\r?\n|$)/, '').trim();
|
||||||
|
const contentType = Number(message?.contentType || 0);
|
||||||
|
if (!text) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (contentType !== 1 && text === '[暂不支持的消息类型]') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if ((contentType === 3 || message?.type === 'image') && (text === '[图片]' || text === '图片')) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
function canOpenMessageMenu(message) {
|
function canOpenMessageMenu(message) {
|
||||||
return message && message.status !== 'failed' && message.status !== 'sending';
|
return message && message.status !== 'failed' && message.status !== 'sending';
|
||||||
}
|
}
|
||||||
@@ -552,6 +597,30 @@ function formatFileSize(size) {
|
|||||||
return bytes > 0 ? `${bytes} B` : '';
|
return bytes > 0 ? `${bytes} B` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function retryImageLoad(event) {
|
||||||
|
const image = event.currentTarget;
|
||||||
|
if (!(image instanceof HTMLImageElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const attempts = Number(image.dataset.loadRetries || 0);
|
||||||
|
if (attempts >= 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const originalUrl = image.dataset.originalUrl || image.getAttribute('src') || '';
|
||||||
|
if (!originalUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
image.dataset.originalUrl = originalUrl;
|
||||||
|
image.dataset.loadRetries = String(attempts + 1);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (!image.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const separator = originalUrl.includes('?') ? '&' : '?';
|
||||||
|
image.src = `${originalUrl}${separator}_image_retry=${attempts + 1}`;
|
||||||
|
}, 500 * (attempts + 1));
|
||||||
|
}
|
||||||
|
|
||||||
function handleMessageClick(message) {
|
function handleMessageClick(message) {
|
||||||
if (props.selectionMode) {
|
if (props.selectionMode) {
|
||||||
emit('toggle-select', message);
|
emit('toggle-select', message);
|
||||||
@@ -705,12 +774,7 @@ function chooseMenuAction(action) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(timestamp) {
|
function formatTime(timestamp) {
|
||||||
return new Intl.DateTimeFormat('zh-CN', {
|
return messageTimeFormatter.format(timestamp);
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: false,
|
|
||||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
||||||
}).format(timestamp);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function deliveryState(message) {
|
function deliveryState(message) {
|
||||||
|
|||||||
@@ -15,11 +15,11 @@
|
|||||||
<a
|
<a
|
||||||
v-if="isImageFavorite(favorite)"
|
v-if="isImageFavorite(favorite)"
|
||||||
class="favorite-detail-image"
|
class="favorite-detail-image"
|
||||||
:href="favorite.attachment.url"
|
:href="imageDisplayUrl(favorite.attachment.url)"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
<img :src="favorite.attachment.url" :alt="favorite.attachment.name || '图片'">
|
<img :src="imageDisplayUrl(favorite.attachment.url)" :alt="favorite.attachment.name || '图片'">
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
v-else-if="isFileFavorite(favorite)"
|
v-else-if="isFileFavorite(favorite)"
|
||||||
@@ -59,6 +59,7 @@
|
|||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useConversationStore } from '@/stores/conversations';
|
import { useConversationStore } from '@/stores/conversations';
|
||||||
import { useNavigationStore } from '@/stores/navigation';
|
import { useNavigationStore } from '@/stores/navigation';
|
||||||
|
import { imageDisplayUrl } from '@/services/mediaProxy';
|
||||||
|
|
||||||
const conversations = useConversationStore();
|
const conversations = useConversationStore();
|
||||||
const navigation = useNavigationStore();
|
const navigation = useNavigationStore();
|
||||||
|
|||||||
@@ -52,6 +52,10 @@
|
|||||||
<div v-else-if="!conversations.conversations.length && !conversations.usingDemoData" class="empty-list">
|
<div v-else-if="!conversations.conversations.length && !conversations.usingDemoData" class="empty-list">
|
||||||
<strong>暂无会话</strong>
|
<strong>暂无会话</strong>
|
||||||
<span>收到或发起消息后会出现在这里</span>
|
<span>收到或发起消息后会出现在这里</span>
|
||||||
|
<div class="empty-list-actions">
|
||||||
|
<button type="button" class="primary-inline-action" @click="openAction('createGroup')">新建群组</button>
|
||||||
|
<button type="button" class="ghost-inline-action" @click="openAction('joinGroup')">加入群组</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+7
-6
@@ -1,14 +1,12 @@
|
|||||||
const currentHost = window.location.hostname || '127.0.0.1';
|
const currentHost = window.location.hostname || '127.0.0.1';
|
||||||
const runtimeConfig = window.JC_WEB_IM_CONFIG || {};
|
const runtimeConfig = window.JC_WEB_IM_CONFIG || {};
|
||||||
const appServerOrigin = `${window.location.protocol}//${currentHost}:8888`;
|
const appServerOrigin = `${window.location.protocol}//${currentHost}:18888`;
|
||||||
const qrAppServerOrigin = `${window.location.protocol}//${currentHost}:18888`;
|
|
||||||
const adminServerOrigin = `${window.location.protocol}//${currentHost}:8024`;
|
const adminServerOrigin = `${window.location.protocol}//${currentHost}:8024`;
|
||||||
|
|
||||||
export const appConfig = {
|
export const appConfig = {
|
||||||
appName: 'Web IM',
|
appName: 'Web IM',
|
||||||
adminServer: runtimeConfig.adminServer || process.env.VUE_APP_ADMIN_SERVER || adminServerOrigin,
|
adminServer: runtimeConfig.adminServer || process.env.VUE_APP_ADMIN_SERVER || adminServerOrigin,
|
||||||
appServer: runtimeConfig.appServer || process.env.VUE_APP_APP_SERVER || appServerOrigin,
|
appServer: runtimeConfig.appServer || process.env.VUE_APP_APP_SERVER || appServerOrigin,
|
||||||
qrAppServer: runtimeConfig.qrAppServer || process.env.VUE_APP_QR_APP_SERVER || qrAppServerOrigin,
|
|
||||||
platformConfigApi: runtimeConfig.platformConfigApi || process.env.VUE_APP_PLATFORM_CONFIG_API || (process.env.NODE_ENV === 'development' ? '/local-api/platform_config.php' : '/api/platform_config.php'),
|
platformConfigApi: runtimeConfig.platformConfigApi || process.env.VUE_APP_PLATFORM_CONFIG_API || (process.env.NODE_ENV === 'development' ? '/local-api/platform_config.php' : '/api/platform_config.php'),
|
||||||
registerConfigApi: runtimeConfig.registerConfigApi || process.env.VUE_APP_REGISTER_CONFIG_API || `${adminServerOrigin}/api/register-config`,
|
registerConfigApi: runtimeConfig.registerConfigApi || process.env.VUE_APP_REGISTER_CONFIG_API || `${adminServerOrigin}/api/register-config`,
|
||||||
registerPhoneExistsApi: runtimeConfig.registerPhoneExistsApi || process.env.VUE_APP_REGISTER_PHONE_EXISTS_API || `${adminServerOrigin}/api/register-phone-exists`,
|
registerPhoneExistsApi: runtimeConfig.registerPhoneExistsApi || process.env.VUE_APP_REGISTER_PHONE_EXISTS_API || `${adminServerOrigin}/api/register-phone-exists`,
|
||||||
@@ -21,15 +19,18 @@ export const appConfig = {
|
|||||||
messageDetailApi: runtimeConfig.messageDetailApi || process.env.VUE_APP_MESSAGE_DETAIL_API || (process.env.NODE_ENV === 'development' ? '/local-api/message_detail.php' : '/api/message_detail.php'),
|
messageDetailApi: runtimeConfig.messageDetailApi || process.env.VUE_APP_MESSAGE_DETAIL_API || (process.env.NODE_ENV === 'development' ? '/local-api/message_detail.php' : '/api/message_detail.php'),
|
||||||
messagesApi: runtimeConfig.messagesApi || process.env.VUE_APP_MESSAGES_API || (process.env.NODE_ENV === 'development' ? '/local-api/messages.php' : '/api/messages.php'),
|
messagesApi: runtimeConfig.messagesApi || process.env.VUE_APP_MESSAGES_API || (process.env.NODE_ENV === 'development' ? '/local-api/messages.php' : '/api/messages.php'),
|
||||||
profileApi: runtimeConfig.profileApi || process.env.VUE_APP_PROFILE_API || (process.env.NODE_ENV === 'development' ? '/local-api/profile.php' : '/api/profile.php'),
|
profileApi: runtimeConfig.profileApi || process.env.VUE_APP_PROFILE_API || (process.env.NODE_ENV === 'development' ? '/local-api/profile.php' : '/api/profile.php'),
|
||||||
changePasswordApi: runtimeConfig.changePasswordApi || process.env.VUE_APP_CHANGE_PASSWORD_API || `${appServerOrigin}/auth/changePassword`,
|
changePasswordApi: runtimeConfig.changePasswordApi || process.env.VUE_APP_CHANGE_PASSWORD_API || `${appServerOrigin}/change_pwd`,
|
||||||
logoutApi: runtimeConfig.logoutApi || process.env.VUE_APP_LOGOUT_API || `${appServerOrigin}/auth/logout`,
|
resetPasswordApi: runtimeConfig.resetPasswordApi || process.env.VUE_APP_RESET_PASSWORD_API || `${appServerOrigin}/reset_pwd`,
|
||||||
imServerHost: runtimeConfig.imServerHost || process.env.VUE_APP_IM_SERVER_HOST || currentHost,
|
imServerHost: runtimeConfig.imServerHost || process.env.VUE_APP_IM_SERVER_HOST || currentHost,
|
||||||
imRoutePort: Number(runtimeConfig.imRoutePort || process.env.VUE_APP_IM_ROUTE_PORT || 18000),
|
imRoutePort: Number(runtimeConfig.imRoutePort || process.env.VUE_APP_IM_ROUTE_PORT || 18000),
|
||||||
useWss: runtimeConfig.useWss ?? process.env.VUE_APP_USE_WSS === 'true',
|
useWss: runtimeConfig.useWss ?? process.env.VUE_APP_USE_WSS === 'true',
|
||||||
wsPath: runtimeConfig.wsPath || process.env.VUE_APP_WS_PATH || '/ws',
|
wsPath: runtimeConfig.wsPath || process.env.VUE_APP_WS_PATH || '/ws',
|
||||||
wsUrl: runtimeConfig.wsUrl || process.env.VUE_APP_WS_URL || '',
|
wsUrl: runtimeConfig.wsUrl || process.env.VUE_APP_WS_URL || '',
|
||||||
sdkMode: runtimeConfig.sdkMode || process.env.VUE_APP_IM_MODE || 'http',
|
sdkMode: runtimeConfig.sdkMode || process.env.VUE_APP_IM_MODE || 'http',
|
||||||
syncIntervalMs: Number(runtimeConfig.syncIntervalMs || process.env.VUE_APP_SYNC_INTERVAL_MS || 300),
|
syncIntervalMs: Number(runtimeConfig.syncIntervalMs || process.env.VUE_APP_SYNC_INTERVAL_MS || 15000),
|
||||||
|
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,
|
platform: 5,
|
||||||
clientIdStorageKey: 'qmt-web-im-client-id',
|
clientIdStorageKey: 'qmt-web-im-client-id',
|
||||||
authTokenStoragePrefix: 'qmt-web-im-auth-token-'
|
authTokenStoragePrefix: 'qmt-web-im-auth-token-'
|
||||||
|
|||||||
@@ -1,266 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
import { appConfig } from '@/config';
|
|
||||||
|
|
||||||
const AES_KEY = 'xczx2026!@#secKe';
|
|
||||||
const ENCRYPTION_EXCLUDE_PATHS = ['/avatar/', '/f/', '/amr2mp3', '/appVersion/check'];
|
|
||||||
const AUTH_STORAGE_KEY = 'qmt-web-im-app-auth-v1';
|
|
||||||
const AUTH_ERROR_CODE = 13;
|
|
||||||
const REFRESH_ERROR_CODE = 20045;
|
|
||||||
|
|
||||||
let importedCryptoKeyPromise = null;
|
|
||||||
let refreshPromise = null;
|
|
||||||
|
|
||||||
function storageScope() {
|
|
||||||
return appConfig.appServer || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function readAuthStore() {
|
|
||||||
try {
|
|
||||||
return JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY) || '{}');
|
|
||||||
} catch (error) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeAuthStore(store) {
|
|
||||||
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(store));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeAuthBundle(bundle = {}) {
|
|
||||||
const accessToken = String(bundle.accessToken || '').trim();
|
|
||||||
const refreshToken = String(bundle.refreshToken || '').trim();
|
|
||||||
if (!accessToken || !refreshToken) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
accessToken,
|
|
||||||
refreshToken,
|
|
||||||
tokenType: String(bundle.tokenType || 'Bearer').trim() || 'Bearer',
|
|
||||||
accessTokenExpireIn: Number(bundle.accessTokenExpireIn || 0) || 0,
|
|
||||||
accessTokenExpireAt: Number(bundle.accessTokenExpireAt || 0) || 0,
|
|
||||||
refreshTokenExpireIn: Number(bundle.refreshTokenExpireIn || 0) || 0,
|
|
||||||
refreshTokenExpireAt: Number(bundle.refreshTokenExpireAt || 0) || 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getStoredAppAuth() {
|
|
||||||
const store = readAuthStore();
|
|
||||||
return normalizeAuthBundle(store[storageScope()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function persistAppAuth(bundle) {
|
|
||||||
const normalized = normalizeAuthBundle(bundle);
|
|
||||||
const store = readAuthStore();
|
|
||||||
if (!normalized) {
|
|
||||||
delete store[storageScope()];
|
|
||||||
writeAuthStore(store);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
store[storageScope()] = normalized;
|
|
||||||
writeAuthStore(store);
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearStoredAppAuth() {
|
|
||||||
persistAppAuth(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasFormData(value) {
|
|
||||||
return typeof FormData !== 'undefined' && value instanceof FormData;
|
|
||||||
}
|
|
||||||
|
|
||||||
function absoluteUrl(url) {
|
|
||||||
return new URL(url, window.location.origin);
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldEncrypt(url) {
|
|
||||||
const pathname = absoluteUrl(url).pathname;
|
|
||||||
return !ENCRYPTION_EXCLUDE_PATHS.some(path => pathname === path || pathname.startsWith(path));
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldAttachAuth(config) {
|
|
||||||
return !config.skipAuth;
|
|
||||||
}
|
|
||||||
|
|
||||||
function textEncoder() {
|
|
||||||
return new TextEncoder();
|
|
||||||
}
|
|
||||||
|
|
||||||
function textDecoder() {
|
|
||||||
return new TextDecoder();
|
|
||||||
}
|
|
||||||
|
|
||||||
function bytesToBase64(bytes) {
|
|
||||||
let binary = '';
|
|
||||||
bytes.forEach(byte => {
|
|
||||||
binary += String.fromCharCode(byte);
|
|
||||||
});
|
|
||||||
return btoa(binary);
|
|
||||||
}
|
|
||||||
|
|
||||||
function base64ToBytes(base64) {
|
|
||||||
const binary = atob(base64);
|
|
||||||
const bytes = new Uint8Array(binary.length);
|
|
||||||
for (let index = 0; index < binary.length; index += 1) {
|
|
||||||
bytes[index] = binary.charCodeAt(index);
|
|
||||||
}
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getCryptoKey() {
|
|
||||||
if (!importedCryptoKeyPromise) {
|
|
||||||
importedCryptoKeyPromise = window.crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
textEncoder().encode(AES_KEY),
|
|
||||||
{ name: 'AES-CBC' },
|
|
||||||
false,
|
|
||||||
['encrypt', 'decrypt']
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return importedCryptoKeyPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function encryptPayload(plainText) {
|
|
||||||
const iv = window.crypto.getRandomValues(new Uint8Array(16));
|
|
||||||
const key = await getCryptoKey();
|
|
||||||
const encrypted = await window.crypto.subtle.encrypt(
|
|
||||||
{ name: 'AES-CBC', iv },
|
|
||||||
key,
|
|
||||||
textEncoder().encode(plainText)
|
|
||||||
);
|
|
||||||
return `${bytesToBase64(iv)}:${bytesToBase64(new Uint8Array(encrypted))}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function decryptPayload(cipherText) {
|
|
||||||
const [ivBase64, encryptedBase64] = String(cipherText || '').split(':', 2);
|
|
||||||
if (!ivBase64 || !encryptedBase64) {
|
|
||||||
throw new Error('Encrypted payload format is invalid');
|
|
||||||
}
|
|
||||||
const key = await getCryptoKey();
|
|
||||||
const decrypted = await window.crypto.subtle.decrypt(
|
|
||||||
{ name: 'AES-CBC', iv: base64ToBytes(ivBase64) },
|
|
||||||
key,
|
|
||||||
base64ToBytes(encryptedBase64)
|
|
||||||
);
|
|
||||||
return textDecoder().decode(decrypted);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeBusinessResponse(payload) {
|
|
||||||
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
code: 0,
|
|
||||||
message: 'success',
|
|
||||||
data: payload
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function decodeResponsePayload(response) {
|
|
||||||
const encrypted = String(response.headers?.['x-encrypted'] || '').toLowerCase() === 'true';
|
|
||||||
if (encrypted && typeof response.data === 'string') {
|
|
||||||
const plainText = await decryptPayload(response.data);
|
|
||||||
response.data = normalizeBusinessResponse(JSON.parse(plainText));
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
response.data = normalizeBusinessResponse(response.data);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = axios.create({
|
|
||||||
withCredentials: false
|
|
||||||
});
|
|
||||||
|
|
||||||
client.interceptors.request.use(async config => {
|
|
||||||
const nextConfig = { ...config };
|
|
||||||
const url = absoluteUrl(nextConfig.url || '').toString();
|
|
||||||
|
|
||||||
if (shouldAttachAuth(nextConfig)) {
|
|
||||||
const auth = getStoredAppAuth();
|
|
||||||
if (auth?.accessToken) {
|
|
||||||
nextConfig.headers = {
|
|
||||||
...(nextConfig.headers || {}),
|
|
||||||
Authorization: `${auth.tokenType || 'Bearer'} ${auth.accessToken}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasFormData(nextConfig.data) && nextConfig.method?.toLowerCase() !== 'get' && shouldEncrypt(url)) {
|
|
||||||
const plainBody = JSON.stringify(nextConfig.data ?? {});
|
|
||||||
nextConfig.data = await encryptPayload(plainBody);
|
|
||||||
nextConfig.headers = {
|
|
||||||
...(nextConfig.headers || {}),
|
|
||||||
'Content-Type': 'text/plain;charset=UTF-8',
|
|
||||||
'X-Timestamp': `${Math.floor(Date.now() / 1000)}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextConfig;
|
|
||||||
});
|
|
||||||
|
|
||||||
client.interceptors.response.use(async response => decodeResponsePayload(response));
|
|
||||||
|
|
||||||
async function refreshAccessToken() {
|
|
||||||
if (refreshPromise) {
|
|
||||||
return refreshPromise;
|
|
||||||
}
|
|
||||||
const auth = getStoredAppAuth();
|
|
||||||
if (!auth?.refreshToken) {
|
|
||||||
clearStoredAppAuth();
|
|
||||||
throw new Error('登录状态已失效,请重新登录');
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshPromise = client.post(`${appConfig.appServer}/auth/refreshToken`, {
|
|
||||||
refreshToken: auth.refreshToken
|
|
||||||
}, {
|
|
||||||
skipAuth: true
|
|
||||||
}).then(response => {
|
|
||||||
const payload = response.data || {};
|
|
||||||
if (Number(payload.code) !== 0 || !payload.data) {
|
|
||||||
if (Number(payload.code) === REFRESH_ERROR_CODE) {
|
|
||||||
clearStoredAppAuth();
|
|
||||||
}
|
|
||||||
throw new Error(payload.message || '刷新登录状态失败');
|
|
||||||
}
|
|
||||||
persistAppAuth(payload.data);
|
|
||||||
return payload.data;
|
|
||||||
}).finally(() => {
|
|
||||||
refreshPromise = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
return refreshPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function request(config) {
|
|
||||||
const response = await client.request(config);
|
|
||||||
const payload = response.data || {};
|
|
||||||
if (Number(payload.code) === AUTH_ERROR_CODE && shouldAttachAuth(config) && !config._retriedAfterRefresh) {
|
|
||||||
await refreshAccessToken();
|
|
||||||
return request({
|
|
||||||
...config,
|
|
||||||
_retriedAfterRefresh: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function appServerRequest(config) {
|
|
||||||
return request(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function appServerGet(url, config = {}) {
|
|
||||||
return request({
|
|
||||||
...config,
|
|
||||||
method: 'get',
|
|
||||||
url
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function appServerPost(url, data = {}, config = {}) {
|
|
||||||
return request({
|
|
||||||
...config,
|
|
||||||
method: 'post',
|
|
||||||
url,
|
|
||||||
data
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
import { appConfig } from '@/config';
|
|
||||||
import { HttpImClient } from './httpImClient';
|
|
||||||
import axios from 'axios';
|
|
||||||
import {
|
|
||||||
appServerGet,
|
|
||||||
appServerPost,
|
|
||||||
clearStoredAppAuth,
|
|
||||||
getStoredAppAuth,
|
|
||||||
persistAppAuth
|
|
||||||
} from './appServerClient';
|
|
||||||
|
|
||||||
function inferAccountType(account) {
|
|
||||||
return String(account || '').includes('@') ? 2 : 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function unwrapData(response) {
|
|
||||||
return response.data?.data ?? response.data?.result ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function unwrapMessage(response, fallback) {
|
|
||||||
return response.data?.message || response.data?.msg || fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DocumentedHttpImClient extends HttpImClient {
|
|
||||||
unsupported(feature) {
|
|
||||||
throw new Error(`当前对接文档未提供“${feature}”接口,已禁用旧本地代理链路`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async loginWithPassword(account, password, type = inferAccountType(account)) {
|
|
||||||
const response = await appServerPost(`${appConfig.appServer}/auth/login_pwd`, {
|
|
||||||
type,
|
|
||||||
number: account,
|
|
||||||
password,
|
|
||||||
platform: appConfig.platform,
|
|
||||||
clientId: this.getClientId()
|
|
||||||
}, {
|
|
||||||
skipAuth: true
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.data?.code !== 0) {
|
|
||||||
throw new Error(unwrapMessage(response, '登录失败'));
|
|
||||||
}
|
|
||||||
|
|
||||||
persistAppAuth(unwrapData(response));
|
|
||||||
return unwrapData(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
async requestAuthCode(email) {
|
|
||||||
const response = await appServerPost(`${appConfig.appServer}/auth/sendEmailCode`, {
|
|
||||||
email
|
|
||||||
}, {
|
|
||||||
skipAuth: true
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.data?.code !== 0) {
|
|
||||||
throw new Error(unwrapMessage(response, '验证码发送失败'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return unwrapData(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
async loginWithCode(account, code) {
|
|
||||||
void account;
|
|
||||||
void code;
|
|
||||||
throw new Error('接口文档未提供验证码登录接口,请使用密码登录');
|
|
||||||
}
|
|
||||||
|
|
||||||
async register({ type, number, code = '', password, invitationCode = '' }) {
|
|
||||||
const response = await appServerPost(`${appConfig.appServer}/auth/register`, {
|
|
||||||
type,
|
|
||||||
number,
|
|
||||||
code,
|
|
||||||
password,
|
|
||||||
clientId: this.getClientId(),
|
|
||||||
platform: appConfig.platform,
|
|
||||||
invitationCode
|
|
||||||
}, {
|
|
||||||
skipAuth: true
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.data?.code !== 0) {
|
|
||||||
throw new Error(unwrapMessage(response, '注册失败'));
|
|
||||||
}
|
|
||||||
|
|
||||||
persistAppAuth(unwrapData(response));
|
|
||||||
return unwrapData(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createPcSession(userId = '') {
|
|
||||||
const response = await axios.post(`${appConfig.qrAppServer}/pc_session`, {
|
|
||||||
flag: 1,
|
|
||||||
device_name: 'Web IM',
|
|
||||||
userId,
|
|
||||||
clientId: this.getClientId(),
|
|
||||||
platform: appConfig.platform
|
|
||||||
}, { withCredentials: false });
|
|
||||||
|
|
||||||
if (response.data?.code !== 0) {
|
|
||||||
throw new Error(unwrapMessage(response, '二维码登录会话创建失败'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return unwrapData(response) || {};
|
|
||||||
}
|
|
||||||
|
|
||||||
async loginWithPcSession(token) {
|
|
||||||
const url = `${appConfig.qrAppServer}/session_login/${encodeURIComponent(token)}`;
|
|
||||||
let response;
|
|
||||||
try {
|
|
||||||
response = await axios.post(url, null, { withCredentials: false });
|
|
||||||
} catch (postError) {
|
|
||||||
response = await axios.get(url, { withCredentials: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
this.persistAppToken(url, response);
|
|
||||||
|
|
||||||
const payload = response.data || {};
|
|
||||||
const result = payload.result ?? payload.data ?? null;
|
|
||||||
|
|
||||||
if (Number(payload.code) === 0 && result) {
|
|
||||||
persistAppAuth(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ([0, 8, 9, 10, 13, 18, 20042].includes(Number(payload.code))) {
|
|
||||||
return {
|
|
||||||
...payload,
|
|
||||||
data: result
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(payload.message || '扫码登录失败');
|
|
||||||
}
|
|
||||||
|
|
||||||
async changePassword({ oldPassword, newPassword }) {
|
|
||||||
const response = await appServerPost(appConfig.changePasswordApi, {
|
|
||||||
oldPassword,
|
|
||||||
newPassword
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.data?.code !== 0) {
|
|
||||||
throw new Error(unwrapMessage(response, '密码修改失败'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async resetPasswordWithCode({ mobile, resetCode, newPassword }) {
|
|
||||||
void mobile;
|
|
||||||
void resetCode;
|
|
||||||
void newPassword;
|
|
||||||
throw new Error('接口文档未提供重置密码接口,请联系服务端确认流程');
|
|
||||||
}
|
|
||||||
|
|
||||||
async logout() {
|
|
||||||
const auth = getStoredAppAuth();
|
|
||||||
if (!auth?.refreshToken) {
|
|
||||||
clearStoredAppAuth();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await appServerPost(appConfig.logoutApi, {
|
|
||||||
refreshToken: auth.refreshToken
|
|
||||||
});
|
|
||||||
if (response.data?.code !== 0) {
|
|
||||||
throw new Error(unwrapMessage(response, '退出登录失败'));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
clearStoredAppAuth();
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchRemoteConversations() {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchRemoteMessages() {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async getServerContactList() {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async getProfile() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendTextMessage() {
|
|
||||||
this.unsupported('发送消息');
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendAttachmentMessage() {
|
|
||||||
this.unsupported('发送附件消息');
|
|
||||||
}
|
|
||||||
|
|
||||||
async addContact() {
|
|
||||||
this.unsupported('添加联系人');
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateContactAlias() {
|
|
||||||
this.unsupported('联系人备注');
|
|
||||||
}
|
|
||||||
|
|
||||||
async createGroup() {
|
|
||||||
this.unsupported('创建群组');
|
|
||||||
}
|
|
||||||
|
|
||||||
async joinGroup() {
|
|
||||||
this.unsupported('加入群组');
|
|
||||||
}
|
|
||||||
|
|
||||||
async broadcastMessage() {
|
|
||||||
this.unsupported('群发消息');
|
|
||||||
}
|
|
||||||
|
|
||||||
async getGroupDetail() {
|
|
||||||
this.unsupported('群详情');
|
|
||||||
}
|
|
||||||
|
|
||||||
async getGroupMembers() {
|
|
||||||
this.unsupported('群成员');
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateGroupAlias() {
|
|
||||||
this.unsupported('群备注');
|
|
||||||
}
|
|
||||||
|
|
||||||
async leaveGroup() {
|
|
||||||
this.unsupported('退出群组');
|
|
||||||
}
|
|
||||||
|
|
||||||
async dismissGroup() {
|
|
||||||
this.unsupported('解散群组');
|
|
||||||
}
|
|
||||||
|
|
||||||
async transferGroup() {
|
|
||||||
this.unsupported('转让群组');
|
|
||||||
}
|
|
||||||
|
|
||||||
async setGroupAdmins() {
|
|
||||||
this.unsupported('设置群管理员');
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateGroupProfile() {
|
|
||||||
this.unsupported('更新群资料');
|
|
||||||
}
|
|
||||||
|
|
||||||
async kickGroupMembers() {
|
|
||||||
this.unsupported('移除群成员');
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateGroupMuteSetting() {
|
|
||||||
this.unsupported('群禁言设置');
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateProfile() {
|
|
||||||
this.unsupported('更新个人资料');
|
|
||||||
}
|
|
||||||
|
|
||||||
async recallMessage() {
|
|
||||||
this.unsupported('撤回消息');
|
|
||||||
}
|
|
||||||
|
|
||||||
async editMessage() {
|
|
||||||
this.unsupported('编辑消息');
|
|
||||||
}
|
|
||||||
|
|
||||||
async getMessageDetail() {
|
|
||||||
this.unsupported('消息详情');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,5 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { appConfig } from '@/config';
|
import { appConfig } from '@/config';
|
||||||
import {
|
|
||||||
appServerGet,
|
|
||||||
appServerPost,
|
|
||||||
clearStoredAppAuth,
|
|
||||||
getStoredAppAuth,
|
|
||||||
persistAppAuth
|
|
||||||
} from '@/services/appServerClient';
|
|
||||||
|
|
||||||
const LOCAL_MESSAGE_STORAGE_KEY = 'qmt-web-im-local-messages-v2';
|
const LOCAL_MESSAGE_STORAGE_KEY = 'qmt-web-im-local-messages-v2';
|
||||||
const LOCAL_HIDDEN_MESSAGE_STORAGE_KEY = 'qmt-web-im-hidden-messages-v1';
|
const LOCAL_HIDDEN_MESSAGE_STORAGE_KEY = 'qmt-web-im-hidden-messages-v1';
|
||||||
@@ -386,7 +379,8 @@ export class HttpImClient {
|
|||||||
target: conversation.target || conversation.raw?.target || conversation.id.replace(/^single-/, ''),
|
target: conversation.target || conversation.raw?.target || conversation.id.replace(/^single-/, ''),
|
||||||
limit,
|
limit,
|
||||||
conversationType: conversation.type || conversation.raw?.type || 0,
|
conversationType: conversation.type || conversation.raw?.type || 0,
|
||||||
markRead: Boolean(options.markRead)
|
markRead: Boolean(options.markRead),
|
||||||
|
includeConversations: Boolean(options.includeConversations)
|
||||||
}, { withCredentials: false });
|
}, { withCredentials: false });
|
||||||
|
|
||||||
if (response.data?.code !== 0) {
|
if (response.data?.code !== 0) {
|
||||||
@@ -398,6 +392,15 @@ export class HttpImClient {
|
|||||||
displayTimestamp: message.displayTimestamp || message.timestamp,
|
displayTimestamp: message.displayTimestamp || message.timestamp,
|
||||||
conversationId: conversation.id
|
conversationId: conversation.id
|
||||||
}));
|
}));
|
||||||
|
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 userId = this.currentUser?.userId || '';
|
||||||
const messagesByConversation = readLocalMessages(userId);
|
const messagesByConversation = readLocalMessages(userId);
|
||||||
messagesByConversation[conversation.id] = mergeSyncedMessages(messagesByConversation[conversation.id] || [], messages, conversation.id, userId);
|
messagesByConversation[conversation.id] = mergeSyncedMessages(messagesByConversation[conversation.id] || [], messages, conversation.id, userId);
|
||||||
@@ -726,7 +729,16 @@ export class HttpImClient {
|
|||||||
throw new Error(response.data?.message || (channel ? '创建公告群失败' : '创建群组失败'));
|
throw new Error(response.data?.message || (channel ? '创建公告群失败' : '创建群组失败'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.normalizeConversation(response.data?.result?.conversation || {});
|
const result = response.data?.result || {};
|
||||||
|
const conversation = this.normalizeConversation(result.conversation || {});
|
||||||
|
conversation.nativeStatus = result.nativeStatus || '';
|
||||||
|
conversation.nativeError = result.nativeError || '';
|
||||||
|
conversation.raw = {
|
||||||
|
...(conversation.raw || {}),
|
||||||
|
nativeStatus: conversation.nativeStatus,
|
||||||
|
nativeError: conversation.nativeError
|
||||||
|
};
|
||||||
|
return conversation;
|
||||||
}
|
}
|
||||||
|
|
||||||
async joinGroup(userId, groupKey) {
|
async joinGroup(userId, groupKey) {
|
||||||
@@ -740,7 +752,16 @@ export class HttpImClient {
|
|||||||
throw new Error(response.data?.message || '加入群组失败');
|
throw new Error(response.data?.message || '加入群组失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.normalizeConversation(response.data?.result?.conversation || {});
|
const result = response.data?.result || {};
|
||||||
|
const conversation = this.normalizeConversation(result.conversation || {});
|
||||||
|
conversation.nativeStatus = result.nativeStatus || '';
|
||||||
|
conversation.nativeError = result.nativeError || '';
|
||||||
|
conversation.raw = {
|
||||||
|
...(conversation.raw || {}),
|
||||||
|
nativeStatus: conversation.nativeStatus,
|
||||||
|
nativeError: conversation.nativeError
|
||||||
|
};
|
||||||
|
return conversation;
|
||||||
}
|
}
|
||||||
|
|
||||||
async broadcastMessage(userId, targetIds, text) {
|
async broadcastMessage(userId, targetIds, text) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { appConfig } from '@/config';
|
import { appConfig } from '@/config';
|
||||||
import { HttpImClient } from './httpImClient';
|
import { HttpImClient } from './httpImClient';
|
||||||
import { LongConnectionImClient } from './longConnectionImClient';
|
import { LongConnectionImClient } from './longConnectionImClient';
|
||||||
import { DocumentedHttpImClient } from './documentedHttpImClient';
|
|
||||||
|
|
||||||
class UnsupportedWildfireImClient {
|
class UnsupportedWildfireImClient {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -13,11 +12,6 @@ export const imClient = appConfig.sdkMode === 'wildfire'
|
|||||||
? new UnsupportedWildfireImClient()
|
? new UnsupportedWildfireImClient()
|
||||||
: appConfig.sdkMode === 'custom-ws' || appConfig.sdkMode === 'long'
|
: appConfig.sdkMode === 'custom-ws' || appConfig.sdkMode === 'long'
|
||||||
? new LongConnectionImClient()
|
? new LongConnectionImClient()
|
||||||
: new DocumentedHttpImClient();
|
: new HttpImClient();
|
||||||
|
|
||||||
export {
|
export { HttpImClient, LongConnectionImClient, UnsupportedWildfireImClient as WildfireImClient };
|
||||||
DocumentedHttpImClient,
|
|
||||||
HttpImClient,
|
|
||||||
LongConnectionImClient,
|
|
||||||
UnsupportedWildfireImClient as WildfireImClient
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
function configuredProxyHosts() {
|
||||||
|
const configured = window.JC_WEB_IM_CONFIG?.mediaProxyHosts;
|
||||||
|
const values = Array.isArray(configured)
|
||||||
|
? configured
|
||||||
|
: String(configured || '').split(/[\s,]+/);
|
||||||
|
return new Set(
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
+296
-50
@@ -2,9 +2,30 @@ import { defineStore } from 'pinia';
|
|||||||
import { imClient } from '@/services/imClient';
|
import { imClient } from '@/services/imClient';
|
||||||
import { appConfig } from '@/config';
|
import { appConfig } from '@/config';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
import { realtimeNotifications } from '@/services/realtimeNotifications';
|
||||||
|
|
||||||
const LOCAL_CONVERSATION_STORAGE_KEY = 'qmt-web-im-local-conversations-v2';
|
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 recentRealtimeNotifications = new Map();
|
||||||
|
|
||||||
|
function isDuplicateRealtimeNotification(payload) {
|
||||||
|
const messageId = String(payload?.messageId || '');
|
||||||
|
if (!messageId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const key = `${payload?.kind || 'message'}:${messageId}`;
|
||||||
|
const now = Date.now();
|
||||||
|
const previousAt = Number(recentRealtimeNotifications.get(key) || 0);
|
||||||
|
recentRealtimeNotifications.set(key, now);
|
||||||
|
if (recentRealtimeNotifications.size > 200) {
|
||||||
|
recentRealtimeNotifications.forEach((seenAt, seenKey) => {
|
||||||
|
if (now - seenAt > 30000) {
|
||||||
|
recentRealtimeNotifications.delete(seenKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return now - previousAt < 5000;
|
||||||
|
}
|
||||||
|
|
||||||
function scopedStorageKey(baseKey) {
|
function scopedStorageKey(baseKey) {
|
||||||
const session = useSessionStore();
|
const session = useSessionStore();
|
||||||
@@ -55,6 +76,17 @@ function messageActivityTimestamp(message) {
|
|||||||
return Number(message?.activityTimestamp || message?.editedAt || message?.displayTimestamp || message?.timestamp || 0);
|
return Number(message?.activityTimestamp || message?.editedAt || message?.displayTimestamp || message?.timestamp || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasSameRenderState(left, right) {
|
||||||
|
if (left === right) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(left) === JSON.stringify(right);
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isRecalledMessage(message) {
|
function isRecalledMessage(message) {
|
||||||
return Boolean(message?.recalled) || message?.status === 'recalled';
|
return Boolean(message?.recalled) || message?.status === 'recalled';
|
||||||
}
|
}
|
||||||
@@ -70,14 +102,84 @@ function attachmentLabel(message) {
|
|||||||
return `[文件] ${attachment.name || '文件'}`;
|
return `[文件] ${attachment.name || '文件'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanPlaceholderText(text, message = {}) {
|
||||||
|
const value = String(text || '').replace(/\s+/g, ' ').trim();
|
||||||
|
const contentType = Number(message?.contentType || 0);
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (contentType !== 1 && value === '[暂不支持的消息类型]') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if ((contentType === 3 || message?.type === 'image') && (value === '[图片]' || value === '图片')) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
function messagePreviewText(message) {
|
function messagePreviewText(message) {
|
||||||
if (isRecalledMessage(message)) {
|
if (isRecalledMessage(message)) {
|
||||||
return `[${message?.recallStatus || '已撤回'}]`;
|
return `[${message?.recallStatus || '已撤回'}]`;
|
||||||
}
|
}
|
||||||
const text = String(message?.text || '').replace(/\s+/g, ' ').trim();
|
const text = cleanPlaceholderText(message?.text, message);
|
||||||
return text || attachmentLabel(message);
|
return text || attachmentLabel(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeConversation(conversation) {
|
||||||
|
if (!conversation) {
|
||||||
|
return conversation;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...conversation,
|
||||||
|
subtitle: cleanPlaceholderText(conversation.subtitle, conversation)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHttpUrl(value) {
|
||||||
|
try {
|
||||||
|
const url = new URL(String(value || '').trim());
|
||||||
|
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileNameFromUrl(url, fallback = '图片') {
|
||||||
|
try {
|
||||||
|
const path = new URL(url).pathname;
|
||||||
|
const name = decodeURIComponent(path.split('/').filter(Boolean).pop() || '');
|
||||||
|
return name || fallback;
|
||||||
|
} catch (error) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMessageForDisplay(message) {
|
||||||
|
if (!message || message.attachment) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = Number(message.contentType || 0);
|
||||||
|
const text = String(message.text || '').trim();
|
||||||
|
if ((contentType === 3 || message.type === 'image') && isHttpUrl(text)) {
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
type: 'image',
|
||||||
|
text: '',
|
||||||
|
attachment: {
|
||||||
|
type: 'image',
|
||||||
|
url: text,
|
||||||
|
name: fileNameFromUrl(text, '图片'),
|
||||||
|
mime: 'image/*',
|
||||||
|
size: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
function quotePrefix(message) {
|
function quotePrefix(message) {
|
||||||
const text = messagePreviewText(message);
|
const text = messagePreviewText(message);
|
||||||
return text.length > 48 ? `${text.slice(0, 48)}...` : text;
|
return text.length > 48 ? `${text.slice(0, 48)}...` : text;
|
||||||
@@ -138,9 +240,23 @@ function canMarkReadNow(options = {}) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPageVisible() {
|
||||||
|
return typeof document === 'undefined' || document.visibilityState === 'visible';
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeMessageSyncIntervalMs() {
|
||||||
|
const configured = Number(appConfig.syncIntervalMs || 0);
|
||||||
|
return Math.max(15000, Number.isFinite(configured) && configured > 0 ? configured : 15000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function conversationListSyncIntervalMs() {
|
||||||
|
return Math.max(30000, activeMessageSyncIntervalMs() * 2);
|
||||||
|
}
|
||||||
|
|
||||||
function mergeVisibleMessages(currentMessages, syncedMessages) {
|
function mergeVisibleMessages(currentMessages, syncedMessages) {
|
||||||
const merged = [...syncedMessages];
|
const merged = [...syncedMessages].map(normalizeMessageForDisplay);
|
||||||
currentMessages.forEach(currentMessage => {
|
currentMessages.forEach(currentMessage => {
|
||||||
|
currentMessage = normalizeMessageForDisplay(currentMessage);
|
||||||
const exists = merged.some(message => isSameMessage(message, currentMessage));
|
const exists = merged.some(message => isSameMessage(message, currentMessage));
|
||||||
const shouldKeepLocal = currentMessage.status === 'sending'
|
const shouldKeepLocal = currentMessage.status === 'sending'
|
||||||
|| currentMessage.status === 'failed'
|
|| currentMessage.status === 'failed'
|
||||||
@@ -229,8 +345,12 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
readPresenceEventsBound: false,
|
readPresenceEventsBound: false,
|
||||||
syncInFlight: false,
|
syncInFlight: false,
|
||||||
pendingReadReceiptConversationId: '',
|
pendingReadReceiptConversationId: '',
|
||||||
|
pendingConversationSync: false,
|
||||||
conversationSyncInFlight: false,
|
conversationSyncInFlight: false,
|
||||||
syncAfterSendTimer: null,
|
messageSyncTimer: null,
|
||||||
|
messageSyncQueuedAt: 0,
|
||||||
|
pendingMessageSyncOptions: {},
|
||||||
|
lastReadSyncByConversation: {},
|
||||||
optimisticMessagesByConversation: {},
|
optimisticMessagesByConversation: {},
|
||||||
quotedMessage: null,
|
quotedMessage: null,
|
||||||
forwardingMessages: [],
|
forwardingMessages: [],
|
||||||
@@ -248,7 +368,8 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
favoriteMessages: [],
|
favoriteMessages: [],
|
||||||
activeFavoriteKey: '',
|
activeFavoriteKey: '',
|
||||||
actionNotice: '',
|
actionNotice: '',
|
||||||
actionError: ''
|
actionError: '',
|
||||||
|
realtimeSyncTimer: null
|
||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
activeConversation(state) {
|
activeConversation(state) {
|
||||||
@@ -295,12 +416,13 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
}
|
}
|
||||||
this.loadScopedLocalState();
|
this.loadScopedLocalState();
|
||||||
this.startMessageSync();
|
this.startMessageSync();
|
||||||
|
realtimeNotifications.start(session.userId, payload => this.handleRealtimeNotification(payload));
|
||||||
},
|
},
|
||||||
loadScopedLocalState() {
|
loadScopedLocalState() {
|
||||||
this.conversations = [];
|
this.conversations = readLocalConversations().map(sanitizeConversation);
|
||||||
this.favoriteMessages = readFavoriteMessages();
|
this.favoriteMessages = readFavoriteMessages();
|
||||||
this.activeFavoriteKey = this.favoriteMessages[0]?.key || '';
|
this.activeFavoriteKey = this.favoriteMessages[0]?.key || '';
|
||||||
this.activeId = '';
|
this.activeId = this.conversations[0]?.id || '';
|
||||||
this.messagesByConversation = {};
|
this.messagesByConversation = {};
|
||||||
this.visibleMessagesByConversation = {};
|
this.visibleMessagesByConversation = {};
|
||||||
this.optimisticMessagesByConversation = {};
|
this.optimisticMessagesByConversation = {};
|
||||||
@@ -315,19 +437,29 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
this.messageDetail = null;
|
this.messageDetail = null;
|
||||||
this.selectionMode = false;
|
this.selectionMode = false;
|
||||||
this.selectedMessageIds = [];
|
this.selectedMessageIds = [];
|
||||||
|
if (this.activeId) {
|
||||||
|
this.loadActiveMessages({ allowReadReceipt: true });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
clearUserState() {
|
clearUserState() {
|
||||||
window.clearInterval(this.syncTimer);
|
window.clearInterval(this.syncTimer);
|
||||||
window.clearInterval(this.conversationSyncTimer);
|
window.clearInterval(this.conversationSyncTimer);
|
||||||
window.clearTimeout(this.syncAfterSendTimer);
|
window.clearTimeout(this.messageSyncTimer);
|
||||||
window.clearTimeout(this.attentionMessageTimer);
|
window.clearTimeout(this.attentionMessageTimer);
|
||||||
|
window.clearTimeout(this.realtimeSyncTimer);
|
||||||
|
realtimeNotifications.stop();
|
||||||
this.syncTimer = null;
|
this.syncTimer = null;
|
||||||
this.conversationSyncTimer = null;
|
this.conversationSyncTimer = null;
|
||||||
this.syncAfterSendTimer = null;
|
this.messageSyncTimer = null;
|
||||||
|
this.messageSyncQueuedAt = 0;
|
||||||
|
this.pendingMessageSyncOptions = {};
|
||||||
this.attentionMessageTimer = null;
|
this.attentionMessageTimer = null;
|
||||||
|
this.realtimeSyncTimer = null;
|
||||||
this.syncInFlight = false;
|
this.syncInFlight = false;
|
||||||
this.conversationSyncInFlight = false;
|
this.conversationSyncInFlight = false;
|
||||||
this.pendingReadReceiptConversationId = '';
|
this.pendingReadReceiptConversationId = '';
|
||||||
|
this.pendingConversationSync = false;
|
||||||
|
this.lastReadSyncByConversation = {};
|
||||||
this.activeId = '';
|
this.activeId = '';
|
||||||
this.conversations = [];
|
this.conversations = [];
|
||||||
this.messagesByConversation = {};
|
this.messagesByConversation = {};
|
||||||
@@ -361,7 +493,7 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
imClient.on('messageReceived', message => {
|
imClient.on('messageReceived', message => {
|
||||||
this.appendMessage(message.conversationId, message);
|
this.appendMessage(message.conversationId, message);
|
||||||
if (message.conversationId === this.activeId) {
|
if (message.conversationId === this.activeId) {
|
||||||
this.syncActiveMessages({ allowReadReceipt: true });
|
this.scheduleActiveMessagesSync({ allowReadReceipt: true }, 700);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
imClient.on('messageStatusChanged', message => {
|
imClient.on('messageStatusChanged', message => {
|
||||||
@@ -387,7 +519,6 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
}
|
}
|
||||||
let value = text.trim();
|
let value = text.trim();
|
||||||
if (this.quotedMessage) {
|
if (this.quotedMessage) {
|
||||||
value = `引用:${quotePrefix(this.quotedMessage)}\n${value}`;
|
|
||||||
this.quotedMessage = null;
|
this.quotedMessage = null;
|
||||||
}
|
}
|
||||||
const message = {
|
const message = {
|
||||||
@@ -408,8 +539,7 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
conversation.subtitle = value;
|
conversation.subtitle = value;
|
||||||
conversation.updatedAt = Date.now();
|
conversation.updatedAt = Date.now();
|
||||||
writeLocalConversations(this.conversations);
|
writeLocalConversations(this.conversations);
|
||||||
window.clearTimeout(this.syncAfterSendTimer);
|
this.scheduleActiveMessagesSync({}, 700);
|
||||||
this.syncAfterSendTimer = window.setTimeout(() => this.syncActiveMessages(), 250);
|
|
||||||
},
|
},
|
||||||
sendAttachment({ type, file, text = '' }) {
|
sendAttachment({ type, file, text = '' }) {
|
||||||
const conversation = this.activeConversation;
|
const conversation = this.activeConversation;
|
||||||
@@ -418,7 +548,6 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
}
|
}
|
||||||
let value = text.trim();
|
let value = text.trim();
|
||||||
if (this.quotedMessage) {
|
if (this.quotedMessage) {
|
||||||
value = `引用:${quotePrefix(this.quotedMessage)}\n${value}`.trim();
|
|
||||||
this.quotedMessage = null;
|
this.quotedMessage = null;
|
||||||
}
|
}
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -448,8 +577,7 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
conversation.subtitle = messagePreviewText(message);
|
conversation.subtitle = messagePreviewText(message);
|
||||||
conversation.updatedAt = now;
|
conversation.updatedAt = now;
|
||||||
writeLocalConversations(this.conversations);
|
writeLocalConversations(this.conversations);
|
||||||
window.clearTimeout(this.syncAfterSendTimer);
|
this.scheduleActiveMessagesSync({}, 700);
|
||||||
this.syncAfterSendTimer = window.setTimeout(() => this.syncActiveMessages(), 350);
|
|
||||||
},
|
},
|
||||||
async sendTextToConversation(conversation, text) {
|
async sendTextToConversation(conversation, text) {
|
||||||
if (!conversation || !text.trim()) {
|
if (!conversation || !text.trim()) {
|
||||||
@@ -474,6 +602,7 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
return sentMessage;
|
return sentMessage;
|
||||||
},
|
},
|
||||||
upsertConversation(conversation) {
|
upsertConversation(conversation) {
|
||||||
|
conversation = sanitizeConversation(conversation);
|
||||||
const index = this.conversations.findIndex(item => item.id === conversation.id);
|
const index = this.conversations.findIndex(item => item.id === conversation.id);
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
this.conversations.splice(index, 1, {
|
this.conversations.splice(index, 1, {
|
||||||
@@ -520,6 +649,7 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previousActiveId = this.activeId;
|
||||||
const merged = new Map();
|
const merged = new Map();
|
||||||
this.conversations.forEach(conversation => {
|
this.conversations.forEach(conversation => {
|
||||||
merged.set(conversation.id, conversation);
|
merged.set(conversation.id, conversation);
|
||||||
@@ -528,14 +658,19 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
const unread = conversation.id === this.activeId && canMarkReadNow() ? 0 : Number(conversation.unread || 0);
|
const unread = conversation.id === this.activeId && canMarkReadNow() ? 0 : Number(conversation.unread || 0);
|
||||||
merged.set(conversation.id, {
|
merged.set(conversation.id, {
|
||||||
...(merged.get(conversation.id) || {}),
|
...(merged.get(conversation.id) || {}),
|
||||||
...conversation,
|
...sanitizeConversation(conversation),
|
||||||
unread
|
unread
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
this.conversations = Array.from(merged.values())
|
const nextConversations = Array.from(merged.values())
|
||||||
|
.map(sanitizeConversation)
|
||||||
.sort((a, b) => Number(b.updatedAt || 0) - Number(a.updatedAt || 0))
|
.sort((a, b) => Number(b.updatedAt || 0) - Number(a.updatedAt || 0))
|
||||||
.slice(0, 100);
|
.slice(0, 100);
|
||||||
|
const conversationsChanged = !hasSameRenderState(this.conversations, nextConversations);
|
||||||
|
if (conversationsChanged) {
|
||||||
|
this.conversations = nextConversations;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.conversations.length) {
|
if (!this.conversations.length) {
|
||||||
this.activeId = '';
|
this.activeId = '';
|
||||||
@@ -549,10 +684,15 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
if (!this.conversations.find(item => item.id === this.activeId)) {
|
if (!this.conversations.find(item => item.id === this.activeId)) {
|
||||||
this.activeId = this.conversations[0].id;
|
this.activeId = this.conversations[0].id;
|
||||||
}
|
}
|
||||||
writeLocalConversations(this.conversations);
|
if (conversationsChanged) {
|
||||||
this.loadActiveMessages();
|
writeLocalConversations(this.conversations);
|
||||||
|
}
|
||||||
|
if (previousActiveId !== this.activeId || !this.messagesByConversation[this.activeId]) {
|
||||||
|
this.loadActiveMessages();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
appendMessage(conversationId, message) {
|
appendMessage(conversationId, message) {
|
||||||
|
message = normalizeMessageForDisplay(message);
|
||||||
if (!this.messagesByConversation[conversationId]) {
|
if (!this.messagesByConversation[conversationId]) {
|
||||||
this.messagesByConversation = {
|
this.messagesByConversation = {
|
||||||
...this.messagesByConversation,
|
...this.messagesByConversation,
|
||||||
@@ -612,7 +752,7 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
conversation.subtitle = message.text || conversation.subtitle || '';
|
conversation.subtitle = messagePreviewText(message) || cleanPlaceholderText(conversation.subtitle, conversation);
|
||||||
conversation.updatedAt = messageActivityTimestamp(message) || Date.now();
|
conversation.updatedAt = messageActivityTimestamp(message) || Date.now();
|
||||||
if (message.direction === 'in') {
|
if (message.direction === 'in') {
|
||||||
conversation.unread = conversationId === this.activeId ? 0 : Number(conversation.unread || 0) + 1;
|
conversation.unread = conversationId === this.activeId ? 0 : Number(conversation.unread || 0) + 1;
|
||||||
@@ -621,6 +761,7 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
.sort((a, b) => Number(b.updatedAt || 0) - Number(a.updatedAt || 0));
|
.sort((a, b) => Number(b.updatedAt || 0) - Number(a.updatedAt || 0));
|
||||||
},
|
},
|
||||||
appendVisibleMessage(conversationId, message) {
|
appendVisibleMessage(conversationId, message) {
|
||||||
|
message = normalizeMessageForDisplay(message);
|
||||||
const messages = this.visibleMessagesByConversation[conversationId] || [];
|
const messages = this.visibleMessagesByConversation[conversationId] || [];
|
||||||
this.visibleMessagesByConversation = {
|
this.visibleMessagesByConversation = {
|
||||||
...this.visibleMessagesByConversation,
|
...this.visibleMessagesByConversation,
|
||||||
@@ -629,6 +770,7 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
replaceMessage(conversationId, message) {
|
replaceMessage(conversationId, message) {
|
||||||
|
message = normalizeMessageForDisplay(message);
|
||||||
if (!this.messagesByConversation[conversationId]) {
|
if (!this.messagesByConversation[conversationId]) {
|
||||||
this.messagesByConversation = {
|
this.messagesByConversation = {
|
||||||
...this.messagesByConversation,
|
...this.messagesByConversation,
|
||||||
@@ -655,13 +797,17 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
this.syncVisibleMessages(conversationId);
|
this.syncVisibleMessages(conversationId);
|
||||||
},
|
},
|
||||||
syncVisibleMessages(conversationId) {
|
syncVisibleMessages(conversationId) {
|
||||||
this.visibleMessagesByConversation = {
|
const currentMessages = this.visibleMessagesByConversation[conversationId] || [];
|
||||||
...this.visibleMessagesByConversation,
|
const nextMessages = mergeVisibleMessages(
|
||||||
[conversationId]: mergeVisibleMessages(
|
this.optimisticMessagesByConversation[conversationId] || [],
|
||||||
this.optimisticMessagesByConversation[conversationId] || [],
|
this.messagesByConversation[conversationId] || []
|
||||||
this.messagesByConversation[conversationId] || []
|
);
|
||||||
)
|
if (!hasSameRenderState(currentMessages, nextMessages)) {
|
||||||
};
|
this.visibleMessagesByConversation = {
|
||||||
|
...this.visibleMessagesByConversation,
|
||||||
|
[conversationId]: nextMessages
|
||||||
|
};
|
||||||
|
}
|
||||||
},
|
},
|
||||||
keepOptimisticMessage(conversationId, message) {
|
keepOptimisticMessage(conversationId, message) {
|
||||||
if (!this.optimisticMessagesByConversation[conversationId]) {
|
if (!this.optimisticMessagesByConversation[conversationId]) {
|
||||||
@@ -713,10 +859,13 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
this.loading = true;
|
this.loading = true;
|
||||||
const messages = imClient.getMessages(conversation, 30);
|
const messages = imClient.getMessages(conversation, 30);
|
||||||
this.pruneOptimisticMessages(conversation.id, messages);
|
this.pruneOptimisticMessages(conversation.id, messages);
|
||||||
this.messagesByConversation = {
|
const nextMessages = mergeVisibleMessages(this.messagesByConversation[conversation.id] || [], messages);
|
||||||
...this.messagesByConversation,
|
if (!hasSameRenderState(this.messagesByConversation[conversation.id] || [], nextMessages)) {
|
||||||
[conversation.id]: mergeVisibleMessages(this.messagesByConversation[conversation.id] || [], messages)
|
this.messagesByConversation = {
|
||||||
};
|
...this.messagesByConversation,
|
||||||
|
[conversation.id]: nextMessages
|
||||||
|
};
|
||||||
|
}
|
||||||
this.syncVisibleMessages(conversation.id);
|
this.syncVisibleMessages(conversation.id);
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
this.syncActiveMessages(syncOptions);
|
this.syncActiveMessages(syncOptions);
|
||||||
@@ -726,15 +875,23 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (options.skipWhenHidden !== false && !isPageVisible()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const wantsReadReceipt = Boolean(options.allowReadReceipt);
|
const wantsReadReceipt = Boolean(options.allowReadReceipt);
|
||||||
const explicitReadReceipt = wantsReadReceipt && canMarkReadNow({ userAction: Boolean(options.userAction) });
|
const explicitReadReceipt = wantsReadReceipt && canMarkReadNow({ userAction: Boolean(options.userAction) });
|
||||||
const pollingReadReceipt = canMarkReadNow();
|
const now = Date.now();
|
||||||
|
const previousReadSyncAt = Number(this.lastReadSyncByConversation[conversation.id] || 0);
|
||||||
|
const pollingReadReceipt = canMarkReadNow() && now - previousReadSyncAt >= 10000;
|
||||||
const markRead = explicitReadReceipt || pollingReadReceipt;
|
const markRead = explicitReadReceipt || pollingReadReceipt;
|
||||||
|
|
||||||
if (this.syncInFlight) {
|
if (this.syncInFlight) {
|
||||||
if (wantsReadReceipt) {
|
if (wantsReadReceipt) {
|
||||||
this.pendingReadReceiptConversationId = conversation.id;
|
this.pendingReadReceiptConversationId = conversation.id;
|
||||||
}
|
}
|
||||||
|
if (options.includeConversations) {
|
||||||
|
this.pendingConversationSync = true;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.syncInFlight = true;
|
this.syncInFlight = true;
|
||||||
@@ -742,14 +899,27 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
const previousMessages = this.messagesByConversation[syncedConversationId] || [];
|
const previousMessages = this.messagesByConversation[syncedConversationId] || [];
|
||||||
try {
|
try {
|
||||||
const messages = await imClient.fetchRemoteMessages(conversation, 50, {
|
const messages = await imClient.fetchRemoteMessages(conversation, 50, {
|
||||||
markRead
|
markRead,
|
||||||
|
includeConversations: Boolean(options.includeConversations)
|
||||||
});
|
});
|
||||||
|
if (markRead) {
|
||||||
|
this.lastReadSyncByConversation = {
|
||||||
|
...this.lastReadSyncByConversation,
|
||||||
|
[syncedConversationId]: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
const editedIncomingMessages = incomingEditedMessages(previousMessages, messages);
|
const editedIncomingMessages = incomingEditedMessages(previousMessages, messages);
|
||||||
this.pruneOptimisticMessages(syncedConversationId, messages);
|
this.pruneOptimisticMessages(syncedConversationId, messages);
|
||||||
this.messagesByConversation = {
|
const nextMessages = mergeVisibleMessages(
|
||||||
...this.messagesByConversation,
|
this.messagesByConversation[syncedConversationId] || [],
|
||||||
[syncedConversationId]: mergeVisibleMessages(this.messagesByConversation[syncedConversationId] || [], messages)
|
messages
|
||||||
};
|
);
|
||||||
|
if (!hasSameRenderState(this.messagesByConversation[syncedConversationId] || [], nextMessages)) {
|
||||||
|
this.messagesByConversation = {
|
||||||
|
...this.messagesByConversation,
|
||||||
|
[syncedConversationId]: nextMessages
|
||||||
|
};
|
||||||
|
}
|
||||||
this.syncVisibleMessages(syncedConversationId);
|
this.syncVisibleMessages(syncedConversationId);
|
||||||
if (editedIncomingMessages.length && syncedConversationId === this.activeId) {
|
if (editedIncomingMessages.length && syncedConversationId === this.activeId) {
|
||||||
this.focusMessage(editedIncomingMessages.sort((a, b) => messageActivityTimestamp(b) - messageActivityTimestamp(a))[0]);
|
this.focusMessage(editedIncomingMessages.sort((a, b) => messageActivityTimestamp(b) - messageActivityTimestamp(a))[0]);
|
||||||
@@ -762,34 +932,95 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
return latestMessage;
|
return latestMessage;
|
||||||
}, null);
|
}, null);
|
||||||
if (latest) {
|
if (latest) {
|
||||||
conversation.subtitle = messagePreviewText(latest);
|
const nextSubtitle = messagePreviewText(latest);
|
||||||
conversation.updatedAt = messageActivityTimestamp(latest);
|
const nextUpdatedAt = messageActivityTimestamp(latest);
|
||||||
writeLocalConversations(this.conversations);
|
if (conversation.subtitle !== nextSubtitle || Number(conversation.updatedAt || 0) !== nextUpdatedAt) {
|
||||||
|
conversation.subtitle = nextSubtitle;
|
||||||
|
conversation.updatedAt = nextUpdatedAt;
|
||||||
|
writeLocalConversations(this.conversations);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Keep the local view usable if a polling request misses.
|
// Keep the local view usable if a polling request misses.
|
||||||
} finally {
|
} finally {
|
||||||
this.syncInFlight = false;
|
this.syncInFlight = false;
|
||||||
if (this.pendingReadReceiptConversationId === this.activeId && canMarkReadNow()) {
|
if (
|
||||||
|
(this.pendingReadReceiptConversationId === this.activeId || this.pendingConversationSync)
|
||||||
|
&& canMarkReadNow()
|
||||||
|
) {
|
||||||
|
const includeConversations = this.pendingConversationSync;
|
||||||
this.pendingReadReceiptConversationId = '';
|
this.pendingReadReceiptConversationId = '';
|
||||||
window.setTimeout(() => this.syncActiveMessages({ allowReadReceipt: true }), 0);
|
this.pendingConversationSync = false;
|
||||||
|
this.scheduleActiveMessagesSync({
|
||||||
|
allowReadReceipt: true,
|
||||||
|
includeConversations
|
||||||
|
}, 700);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
scheduleActiveMessagesSync(options = {}, delay = 700) {
|
||||||
|
if (!this.activeId || !isPageVisible()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
if (!this.messageSyncQueuedAt) {
|
||||||
|
this.messageSyncQueuedAt = now;
|
||||||
|
}
|
||||||
|
this.pendingMessageSyncOptions = {
|
||||||
|
...this.pendingMessageSyncOptions,
|
||||||
|
allowReadReceipt: Boolean(
|
||||||
|
this.pendingMessageSyncOptions.allowReadReceipt || options.allowReadReceipt
|
||||||
|
),
|
||||||
|
userAction: Boolean(this.pendingMessageSyncOptions.userAction || options.userAction),
|
||||||
|
includeConversations: Boolean(
|
||||||
|
this.pendingMessageSyncOptions.includeConversations || options.includeConversations
|
||||||
|
)
|
||||||
|
};
|
||||||
|
const maxWait = 1200;
|
||||||
|
const runAt = Math.min(now + Math.max(0, Number(delay) || 0), this.messageSyncQueuedAt + maxWait);
|
||||||
|
window.clearTimeout(this.messageSyncTimer);
|
||||||
|
this.messageSyncTimer = window.setTimeout(() => {
|
||||||
|
const pendingOptions = this.pendingMessageSyncOptions;
|
||||||
|
this.messageSyncTimer = null;
|
||||||
|
this.messageSyncQueuedAt = 0;
|
||||||
|
this.pendingMessageSyncOptions = {};
|
||||||
|
this.syncActiveMessages(pendingOptions);
|
||||||
|
}, Math.max(0, runAt - now));
|
||||||
|
},
|
||||||
startMessageSync() {
|
startMessageSync() {
|
||||||
this.bindReadPresenceEvents();
|
this.bindReadPresenceEvents();
|
||||||
if (!this.syncTimer) {
|
if (!this.syncTimer) {
|
||||||
this.syncTimer = window.setInterval(() => {
|
this.syncTimer = window.setInterval(() => {
|
||||||
this.syncActiveMessages();
|
if (isPageVisible() && !realtimeNotifications.isConnected()) {
|
||||||
}, Math.max(250, appConfig.syncIntervalMs));
|
this.syncActiveMessages();
|
||||||
|
}
|
||||||
|
}, activeMessageSyncIntervalMs());
|
||||||
}
|
}
|
||||||
if (!this.conversationSyncTimer) {
|
if (!this.conversationSyncTimer) {
|
||||||
this.conversationSyncTimer = window.setInterval(() => {
|
this.conversationSyncTimer = window.setInterval(() => {
|
||||||
this.refreshRemoteConversations();
|
if (isPageVisible() && !realtimeNotifications.isConnected()) {
|
||||||
}, 2000);
|
this.refreshRemoteConversations();
|
||||||
|
}
|
||||||
|
}, conversationListSyncIntervalMs());
|
||||||
this.refreshRemoteConversations();
|
this.refreshRemoteConversations();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
handleRealtimeNotification(payload) {
|
||||||
|
if (isDuplicateRealtimeNotification(payload)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.clearTimeout(this.realtimeSyncTimer);
|
||||||
|
this.realtimeSyncTimer = window.setTimeout(() => {
|
||||||
|
if (this.activeId) {
|
||||||
|
this.scheduleActiveMessagesSync({
|
||||||
|
allowReadReceipt: canMarkReadNow(),
|
||||||
|
includeConversations: true
|
||||||
|
}, 700);
|
||||||
|
} else {
|
||||||
|
this.refreshRemoteConversations();
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
},
|
||||||
bindReadPresenceEvents() {
|
bindReadPresenceEvents() {
|
||||||
if (this.readPresenceEventsBound || typeof window === 'undefined' || typeof document === 'undefined') {
|
if (this.readPresenceEventsBound || typeof window === 'undefined' || typeof document === 'undefined') {
|
||||||
return;
|
return;
|
||||||
@@ -797,7 +1028,8 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
this.readPresenceEventsBound = true;
|
this.readPresenceEventsBound = true;
|
||||||
const syncWhenReadable = () => {
|
const syncWhenReadable = () => {
|
||||||
if (canMarkReadNow()) {
|
if (canMarkReadNow()) {
|
||||||
this.syncActiveMessages({ allowReadReceipt: true });
|
this.refreshRemoteConversations();
|
||||||
|
this.scheduleActiveMessagesSync({ allowReadReceipt: true }, 300);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('focus', syncWhenReadable);
|
window.addEventListener('focus', syncWhenReadable);
|
||||||
@@ -822,10 +1054,18 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
setActionNotice(message) {
|
setActionNotice(message) {
|
||||||
this.actionNotice = message;
|
this.actionNotice = message;
|
||||||
this.actionError = '';
|
this.actionError = '';
|
||||||
|
window.clearTimeout(this.actionNoticeTimer);
|
||||||
|
this.actionNoticeTimer = window.setTimeout(() => {
|
||||||
|
this.actionNotice = '';
|
||||||
|
}, 1800);
|
||||||
},
|
},
|
||||||
setActionError(message) {
|
setActionError(message) {
|
||||||
this.actionError = message;
|
this.actionError = message;
|
||||||
this.actionNotice = '';
|
this.actionNotice = '';
|
||||||
|
window.clearTimeout(this.actionErrorTimer);
|
||||||
|
this.actionErrorTimer = window.setTimeout(() => {
|
||||||
|
this.actionError = '';
|
||||||
|
}, 2400);
|
||||||
},
|
},
|
||||||
focusMessage(message) {
|
focusMessage(message) {
|
||||||
const key = messageStableKey(message);
|
const key = messageStableKey(message);
|
||||||
@@ -1073,7 +1313,7 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
const editedMessage = await imClient.editMessage(conversation, message, text);
|
const editedMessage = await imClient.editMessage(conversation, message, text);
|
||||||
this.replaceLocalMessage(conversation.id, message, editedMessage);
|
this.replaceLocalMessage(conversation.id, message, editedMessage);
|
||||||
if (messageActivityTimestamp(editedMessage) >= Number(conversation.updatedAt || 0)) {
|
if (messageActivityTimestamp(editedMessage) >= Number(conversation.updatedAt || 0)) {
|
||||||
conversation.subtitle = editedMessage.text;
|
conversation.subtitle = messagePreviewText(editedMessage);
|
||||||
conversation.updatedAt = messageActivityTimestamp(editedMessage);
|
conversation.updatedAt = messageActivityTimestamp(editedMessage);
|
||||||
writeLocalConversations(this.conversations);
|
writeLocalConversations(this.conversations);
|
||||||
}
|
}
|
||||||
@@ -1134,7 +1374,12 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
const conversation = await imClient.createGroup(session.userId, options);
|
const conversation = await imClient.createGroup(session.userId, options);
|
||||||
this.upsertConversation(conversation);
|
this.upsertConversation(conversation);
|
||||||
this.setActiveConversation(conversation.id);
|
this.setActiveConversation(conversation.id);
|
||||||
this.setActionNotice(options.channel ? '公告群已创建' : '群组已创建');
|
const nativeStatus = conversation.nativeStatus || conversation.raw?.nativeStatus || '';
|
||||||
|
if (nativeStatus && nativeStatus !== 'ok') {
|
||||||
|
this.setActionNotice(options.channel ? '公告群已创建,仅网页可见' : '群组已创建,仅网页可见');
|
||||||
|
} else {
|
||||||
|
this.setActionNotice(options.channel ? '公告群已创建' : '群组已创建');
|
||||||
|
}
|
||||||
this.refreshRemoteConversations();
|
this.refreshRemoteConversations();
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1152,7 +1397,8 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
const conversation = await imClient.joinGroup(session.userId, groupKey);
|
const conversation = await imClient.joinGroup(session.userId, groupKey);
|
||||||
this.upsertConversation(conversation);
|
this.upsertConversation(conversation);
|
||||||
this.setActiveConversation(conversation.id);
|
this.setActiveConversation(conversation.id);
|
||||||
this.setActionNotice('已加入群组');
|
const nativeStatus = conversation.nativeStatus || conversation.raw?.nativeStatus || '';
|
||||||
|
this.setActionNotice(nativeStatus && nativeStatus !== 'ok' ? '已加入群组,仅网页可见' : '已加入群组');
|
||||||
this.refreshRemoteConversations();
|
this.refreshRemoteConversations();
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+19
-42
@@ -12,10 +12,6 @@ function nextProfilePortrait(profilePortrait, currentPortrait) {
|
|||||||
return normalizeStoredPortrait(profilePortrait) || normalizeStoredPortrait(currentPortrait);
|
return normalizeStoredPortrait(profilePortrait) || normalizeStoredPortrait(currentPortrait);
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferAccountType(account) {
|
|
||||||
return String(account || '').includes('@') ? 2 : 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useSessionStore = defineStore('session', {
|
export const useSessionStore = defineStore('session', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
@@ -36,14 +32,12 @@ export const useSessionStore = defineStore('session', {
|
|||||||
if (!raw) {
|
if (!raw) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = JSON.parse(raw);
|
const session = JSON.parse(raw);
|
||||||
if (session.appServer !== appConfig.appServer) {
|
if (session.appServer !== appConfig.appServer) {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.userId && session.token) {
|
if (session.userId && session.token) {
|
||||||
if (!session.clientId) {
|
if (!session.clientId) {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
@@ -55,6 +49,9 @@ export const useSessionStore = defineStore('session', {
|
|||||||
this.portrait = normalizeStoredPortrait(session.portrait);
|
this.portrait = normalizeStoredPortrait(session.portrait);
|
||||||
this.isAuthenticated = true;
|
this.isAuthenticated = true;
|
||||||
imClient.restoreClientId(session.clientId);
|
imClient.restoreClientId(session.clientId);
|
||||||
|
if (session.localOnly) {
|
||||||
|
this.connectionStatusText = '本地调试登录';
|
||||||
|
}
|
||||||
this.connect();
|
this.connect();
|
||||||
this.refreshProfile();
|
this.refreshProfile();
|
||||||
}
|
}
|
||||||
@@ -62,12 +59,11 @@ export const useSessionStore = defineStore('session', {
|
|||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async loginWithPassword(mobile, password) {
|
||||||
async loginWithPassword(account, password, type = inferAccountType(account)) {
|
|
||||||
this.error = '';
|
this.error = '';
|
||||||
this.connecting = true;
|
this.connecting = true;
|
||||||
try {
|
try {
|
||||||
const result = await imClient.loginWithPassword(account, password, type);
|
const result = await imClient.loginWithPassword(mobile, password);
|
||||||
this.applyLoginResult(result);
|
this.applyLoginResult(result);
|
||||||
await this.connect();
|
await this.connect();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -76,12 +72,11 @@ export const useSessionStore = defineStore('session', {
|
|||||||
this.connecting = false;
|
this.connecting = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async loginWithCode(mobile, code) {
|
||||||
async loginWithCode(account, code) {
|
|
||||||
this.error = '';
|
this.error = '';
|
||||||
this.connecting = true;
|
this.connecting = true;
|
||||||
try {
|
try {
|
||||||
const result = await imClient.loginWithCode(account, code);
|
const result = await imClient.loginWithCode(mobile, code);
|
||||||
this.applyLoginResult(result);
|
this.applyLoginResult(result);
|
||||||
await this.connect();
|
await this.connect();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -90,7 +85,6 @@ export const useSessionStore = defineStore('session', {
|
|||||||
this.connecting = false;
|
this.connecting = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async completePcSessionLogin(result) {
|
async completePcSessionLogin(result) {
|
||||||
this.error = '';
|
this.error = '';
|
||||||
this.connecting = true;
|
this.connecting = true;
|
||||||
@@ -103,15 +97,11 @@ export const useSessionStore = defineStore('session', {
|
|||||||
this.connecting = false;
|
this.connecting = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async registerWithPhone(mobile, code) {
|
||||||
async register(payload) {
|
|
||||||
this.error = '';
|
this.error = '';
|
||||||
this.connecting = true;
|
this.connecting = true;
|
||||||
try {
|
try {
|
||||||
const result = await imClient.register(payload);
|
return await imClient.registerWithPhone(mobile, code);
|
||||||
this.applyLoginResult(result);
|
|
||||||
await this.connect();
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.error = error.message || '注册失败';
|
this.error = error.message || '注册失败';
|
||||||
return null;
|
return null;
|
||||||
@@ -119,27 +109,28 @@ export const useSessionStore = defineStore('session', {
|
|||||||
this.connecting = false;
|
this.connecting = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
applyLoginResult(result) {
|
applyLoginResult(result) {
|
||||||
this.userId = result.userId || result.uid || result.id || '';
|
this.userId = result.userId || result.uid || result.id || '';
|
||||||
this.token = result.token || result.imToken || '';
|
this.token = result.token || result.imToken || '';
|
||||||
this.displayName = result.displayName || result.userName || result.name || this.userId;
|
this.displayName = result.displayName || result.userName || result.name || this.userId;
|
||||||
this.portrait = normalizeStoredPortrait(result.portrait);
|
this.portrait = normalizeStoredPortrait(result.portrait);
|
||||||
this.isAuthenticated = Boolean(this.userId && this.token);
|
this.isAuthenticated = Boolean(this.userId && this.token);
|
||||||
this.persistSession();
|
if (result.localOnly) {
|
||||||
|
this.connectionStatusText = '本地调试登录';
|
||||||
|
}
|
||||||
|
this.persistSession(Boolean(result.localOnly));
|
||||||
},
|
},
|
||||||
|
persistSession(localOnly = false) {
|
||||||
persistSession() {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||||
userId: this.userId,
|
userId: this.userId,
|
||||||
token: this.token,
|
token: this.token,
|
||||||
displayName: this.displayName,
|
displayName: this.displayName,
|
||||||
portrait: this.portrait,
|
portrait: this.portrait,
|
||||||
clientId: imClient.getClientId(),
|
clientId: imClient.getClientId(),
|
||||||
|
localOnly,
|
||||||
appServer: appConfig.appServer
|
appServer: appConfig.appServer
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
applyProfile(profile) {
|
applyProfile(profile) {
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
return;
|
return;
|
||||||
@@ -149,7 +140,6 @@ export const useSessionStore = defineStore('session', {
|
|||||||
this.portrait = nextProfilePortrait(profile.portrait, this.portrait);
|
this.portrait = nextProfilePortrait(profile.portrait, this.portrait);
|
||||||
this.persistSession();
|
this.persistSession();
|
||||||
},
|
},
|
||||||
|
|
||||||
async refreshProfile() {
|
async refreshProfile() {
|
||||||
if (!this.userId) {
|
if (!this.userId) {
|
||||||
return null;
|
return null;
|
||||||
@@ -162,7 +152,6 @@ export const useSessionStore = defineStore('session', {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async saveProfile({ displayName, portraitFile }) {
|
async saveProfile({ displayName, portraitFile }) {
|
||||||
if (!this.userId) {
|
if (!this.userId) {
|
||||||
throw new Error('请先登录');
|
throw new Error('请先登录');
|
||||||
@@ -174,21 +163,17 @@ export const useSessionStore = defineStore('session', {
|
|||||||
this.applyProfile(profile);
|
this.applyProfile(profile);
|
||||||
return profile;
|
return profile;
|
||||||
},
|
},
|
||||||
|
|
||||||
async changePassword({ oldPassword, newPassword }) {
|
async changePassword({ oldPassword, newPassword }) {
|
||||||
if (!this.userId) {
|
if (!this.userId) {
|
||||||
throw new Error('请先登录');
|
throw new Error('请先登录');
|
||||||
}
|
}
|
||||||
await imClient.changePassword({ oldPassword, newPassword });
|
await imClient.changePassword({ oldPassword, newPassword });
|
||||||
await this.logout();
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
async resetPasswordWithCode({ mobile, resetCode, newPassword }) {
|
async resetPasswordWithCode({ mobile, resetCode, newPassword }) {
|
||||||
await imClient.resetPasswordWithCode({ mobile, resetCode, newPassword });
|
await imClient.resetPasswordWithCode({ mobile, resetCode, newPassword });
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
async connect() {
|
async connect() {
|
||||||
if (!this.userId || !this.token) {
|
if (!this.userId || !this.token) {
|
||||||
return;
|
return;
|
||||||
@@ -198,7 +183,6 @@ export const useSessionStore = defineStore('session', {
|
|||||||
this.bindImEvents();
|
this.bindImEvents();
|
||||||
await imClient.connect(this.userId, this.token);
|
await imClient.connect(this.userId, this.token);
|
||||||
},
|
},
|
||||||
|
|
||||||
bindImEvents() {
|
bindImEvents() {
|
||||||
if (this.eventsBound) {
|
if (this.eventsBound) {
|
||||||
return;
|
return;
|
||||||
@@ -209,17 +193,10 @@ export const useSessionStore = defineStore('session', {
|
|||||||
this.connectionStatusText = status.statusText || (status.connected ? '已连接' : '未连接');
|
this.connectionStatusText = status.statusText || (status.connected ? '已连接' : '未连接');
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
logout() {
|
||||||
async logout() {
|
imClient.disconnect();
|
||||||
try {
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
await imClient.logout?.();
|
this.$reset();
|
||||||
} catch (error) {
|
|
||||||
this.error = error.message || '退出登录失败';
|
|
||||||
} finally {
|
|
||||||
imClient.disconnect();
|
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
|
||||||
this.$reset();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+124
-180
@@ -479,182 +479,6 @@ button {
|
|||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
.documented-shell {
|
|
||||||
min-height: 100dvh;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 260px minmax(0, 1fr);
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at top left, rgba(23, 125, 114, 0.08), transparent 28%),
|
|
||||||
linear-gradient(180deg, #f4f8f7 0%, #eef3f7 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-rail {
|
|
||||||
min-height: 0;
|
|
||||||
display: grid;
|
|
||||||
align-content: start;
|
|
||||||
gap: 14px;
|
|
||||||
padding: 24px 20px;
|
|
||||||
border-right: 1px solid var(--line);
|
|
||||||
background: rgba(255, 255, 255, 0.82);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-avatar {
|
|
||||||
width: 64px;
|
|
||||||
height: 64px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 16px;
|
|
||||||
background: #dff4ef;
|
|
||||||
color: var(--primary-strong);
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-avatar img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-rail-copy {
|
|
||||||
display: grid;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-rail-copy strong {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-rail-copy small {
|
|
||||||
color: var(--muted);
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-rail-button {
|
|
||||||
min-height: 40px;
|
|
||||||
padding: 0 14px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--primary);
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 700;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-rail-button.ghost {
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: #fff;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-main {
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 28px;
|
|
||||||
display: grid;
|
|
||||||
align-content: start;
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: end;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-header h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-header p {
|
|
||||||
margin: 6px 0 0;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-status {
|
|
||||||
min-width: 180px;
|
|
||||||
display: grid;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 14px;
|
|
||||||
background: rgba(255, 255, 255, 0.88);
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-status span {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-status strong {
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-card {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 20px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 16px;
|
|
||||||
background: rgba(255, 255, 255, 0.88);
|
|
||||||
box-shadow: 0 16px 36px rgba(24, 33, 47, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-card h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-text {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--muted);
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-list {
|
|
||||||
margin: 0;
|
|
||||||
padding-left: 18px;
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-list.muted {
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-meta {
|
|
||||||
margin: 0;
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-meta div {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 110px minmax(0, 1fr);
|
|
||||||
gap: 12px;
|
|
||||||
padding-top: 10px;
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-meta dt {
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-meta dd {
|
|
||||||
margin: 0;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.documented-password-panel {
|
|
||||||
width: min(100%, 360px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.navigation-rail {
|
.navigation-rail {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1414,6 +1238,14 @@ button {
|
|||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty-list-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.empty-chat {
|
.empty-chat {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
@@ -2074,6 +1906,16 @@ button {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.group-member-row .group-member-role {
|
||||||
|
width: fit-content;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border: 1px solid #b8d8d1;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #edf8f5;
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.group-management-dialog {
|
.group-management-dialog {
|
||||||
width: min(520px, calc(100% - 32px));
|
width: min(520px, calc(100% - 32px));
|
||||||
}
|
}
|
||||||
@@ -2134,7 +1976,9 @@ button {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
padding: 24px;
|
padding: 24px 24px 72px;
|
||||||
|
scroll-padding-bottom: 72px;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-row {
|
.message-row {
|
||||||
@@ -2945,9 +2789,11 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.composer-tools button {
|
.composer-tools button {
|
||||||
width: 32px;
|
width: 34px;
|
||||||
height: 32px;
|
height: 34px;
|
||||||
border: 0;
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid transparent;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -2958,6 +2804,104 @@ button {
|
|||||||
background: #edf2f5;
|
background: #edf2f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.composer-tools button:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-attachment-preview {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 16px 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-attachment-item {
|
||||||
|
position: relative;
|
||||||
|
width: 88px;
|
||||||
|
min-width: 88px;
|
||||||
|
height: 88px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #edf2f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-attachment-item img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-attachment-file-icon {
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-attachment-name {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 4px 6px;
|
||||||
|
background: rgba(18, 28, 40, 0.72);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-attachment-item > button {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(18, 28, 40, 0.74);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-tool-button {
|
||||||
|
border-color: rgba(23, 125, 114, 0.28);
|
||||||
|
background: rgba(23, 125, 114, 0.1);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-tool-button.file-tool {
|
||||||
|
border-color: rgba(216, 78, 63, 0.28);
|
||||||
|
background: rgba(216, 78, 63, 0.1);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-tool-button:hover {
|
||||||
|
border-color: currentColor;
|
||||||
|
background: rgba(23, 125, 114, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-tool-button.file-tool:hover {
|
||||||
|
background: rgba(216, 78, 63, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-tool-icon {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.9;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
.emoji-picker {
|
.emoji-picker {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 16px;
|
left: 16px;
|
||||||
|
|||||||
Reference in New Issue
Block a user