mirror of
https://github.com/kuaifan/dootask.git
synced 2026-08-11 15:28:32 +00:00
Compare commits
No commits in common. "pro" and "v1.8.89" have entirely different histories.
@ -1,79 +1,64 @@
|
|||||||
---
|
---
|
||||||
name: dootask-fix-permission
|
name: dootask-fix-permission
|
||||||
description: 修复 DooTask 整个项目的目录和文件权限:根目录 chmod 755,bootstrap/cache、docker、public、storage chown 回调用用户且目录 chmod 775,public 文件补充所有用户读权限。用于 Nginx 静态文件 403/Permission denied、install/build EACCES 或可写目录检测失败;优先使用 sudo ./cmd permission,赋权不删数据。
|
description: 修复 DooTask 可写目录(bootstrap/cache、docker、public、storage)的属主/权限:chown 回当前用户 + 目录 chmod 775,对齐 install 的赋权逻辑,赋权不删数据。
|
||||||
---
|
---
|
||||||
|
|
||||||
# DooTask 目录权限修复
|
# DooTask 目录权限修复
|
||||||
|
|
||||||
项目根目录如果缺少 `x` 穿越权限,Nginx 即使看到 `public` 中的文件也无法访问。容器内进程还常以 **root** 写入挂载目录(`storage`、`public/uploads`、`bootstrap/cache` 等),导致宿主机当前用户没有写权限。常见现象:
|
容器内进程常以 **root** 写入挂载目录(`storage`、`public/uploads`、`bootstrap/cache` 等),导致宿主机当前用户对这些文件**没有写权限**,进而触发:
|
||||||
|
|
||||||
- Nginx 日志出现 `stat() failed (13: Permission denied)`,静态文件请求被回退到 Laravel 首页
|
- `./cmd install` 报「目录【xxx】权限不足」/ 目录权限检测失败
|
||||||
- `./cmd install` 报「目录【xxx】权限不足」/目录权限检测失败
|
- `./cmd build`(vite)报 `EACCES: permission denied, copyfile`(复制 `public/uploads/...` 时)
|
||||||
- `./cmd build`(vite)报 `EACCES: permission denied, copyfile`
|
|
||||||
- Laravel 运行时写 `storage`/`bootstrap/cache` 失败
|
- Laravel 运行时写 `storage`/`bootstrap/cache` 失败
|
||||||
|
|
||||||
对齐 `./cmd permission`/`./cmd install` 的赋权逻辑:项目根目录设为 `755`;四个可写目录做 `chmod 775`(仅目录)+ `chown` 回调用 sudo 的用户;`public` 普通文件用 `a+r` 补充 Nginx 所需读权限。
|
本技能**对齐 `./cmd install` 的目录赋权逻辑**:对四个可写目录做 `chmod 775`(目录)+ `chown` 回当前用户。
|
||||||
|
|
||||||
## 适用目录
|
## 适用目录
|
||||||
|
|
||||||
```text
|
与 install 一致的四个:
|
||||||
. # 项目根目录,只修本层为 755
|
|
||||||
|
```
|
||||||
bootstrap/cache
|
bootstrap/cache
|
||||||
docker
|
docker
|
||||||
public # 目录 775,普通文件 a+r;含真实上传数据
|
public # 含 public/uploads(真实上传数据)
|
||||||
storage
|
storage
|
||||||
```
|
```
|
||||||
|
|
||||||
## 核心原则:赋权,不删数据
|
## 核心原则:赋权,不删数据
|
||||||
|
|
||||||
`public/uploads` 含真实上传文件。永远优先 `chown` 改属主,不要删数据。即便用户说“清理一下”,也只允许清理临时目录 `public/uploads/tmp`,切勿删除 uploads 下其他内容。
|
`public/uploads` 含真实上传文件(头像、附件等)。**永远优先 `chown` 改属主,不要删数据。** 即便用户说"清理一下",也只允许清临时目录 `public/uploads/tmp`,**切勿**删 uploads 下其他内容。
|
||||||
|
|
||||||
## 前置检查
|
## 前置检查
|
||||||
|
|
||||||
1. 在项目根目录执行,确认存在 `cmd` 和上述四个目录。
|
1. **工作目录**:在项目根(存在 `cmd` 且这四个目录在)
|
||||||
2. 用 `ls -ld .` 检查项目根目录是否缺少组/其他用户的 `x` 穿越权限。
|
2. **sudo**:改属主需 root(当前文件多为 root 属主)。本机一般可免密 sudo;不行则经 docker 以 root 改权限
|
||||||
3. 确认可使用 sudo;改 root 属主的文件或目录需要 root 权限。
|
3. 确认要修的范围:默认四个目录全修;若用户只想解 build 报错,也可只针对 `public`(含 `public/uploads`)
|
||||||
4. 用 `find public -type f ! -perm -004 -print` 检查 Nginx 用户可能无法读取的静态文件。
|
|
||||||
5. 默认修复项目根目录、四个可写目录和 `public` 文件;若用户只想解决 build 的 uploads 报错,可只处理 `public/uploads`。
|
|
||||||
|
|
||||||
检查通过后,汇报将执行的命令,向用户确认一次再执行。
|
检查通过后汇报将执行的命令,**向用户确认一次**再执行。
|
||||||
|
|
||||||
## 执行
|
## 执行
|
||||||
|
|
||||||
确认后优先执行独立权限修复命令(不依赖 Docker 正在运行):
|
确认后执行(属主修回当前用户,目录权限 775):
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
sudo ./cmd permission
|
# 1) 属主修回当前用户(递归)
|
||||||
```
|
|
||||||
|
|
||||||
旧版 `cmd` 没有 `permission` 命令时,手动执行:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
# 1) 保证 Nginx 可穿越项目根目录(不递归)
|
|
||||||
sudo chmod 755 .
|
|
||||||
|
|
||||||
# 2) 可写目录属主修回当前用户(递归)
|
|
||||||
sudo chown -R "$(id -u):$(id -g)" bootstrap/cache docker public storage
|
sudo chown -R "$(id -u):$(id -g)" bootstrap/cache docker public storage
|
||||||
|
|
||||||
# 3) 可写目录权限 775(仅目录)
|
# 2) 目录权限 775(仅目录,对齐 install 的 `find -type d -exec chmod 775`)
|
||||||
find bootstrap/cache docker public storage -type d -exec chmod 775 {} \;
|
find bootstrap/cache docker public storage -type d -exec chmod 775 {} \;
|
||||||
|
|
||||||
# 4) public 普通文件只补充读权限,保留现有写入/执行位
|
|
||||||
find public -type f -exec chmod a+r {} \;
|
|
||||||
```
|
```
|
||||||
|
|
||||||
只想解决 build 的 uploads 报错时,可只执行:
|
> 只想解 build 的 uploads 报错时,可只对 `public`:
|
||||||
|
> ```shell
|
||||||
|
> sudo chown -R "$(id -u):$(id -g)" public/uploads
|
||||||
|
> ```
|
||||||
|
|
||||||
```shell
|
执行后报告:改了哪些目录、属主/权限现状(可 `ls -ld` 抽查),并提示用户可重试之前失败的 install/build/update。
|
||||||
sudo chown -R "$(id -u):$(id -g)" public/uploads
|
|
||||||
```
|
|
||||||
|
|
||||||
执行后用 `ls -ld . bootstrap/cache docker public storage` 抽查目录,并用 `find public -type f ! -perm -004 -print` 确认不再有缺少 others 读权限的静态文件。然后重试之前失败的静态文件访问、install/build/update。
|
|
||||||
|
|
||||||
## 失败处理
|
## 失败处理
|
||||||
|
|
||||||
- `chmod`/`chown` 报权限不足:立即停止,提示使用有 root 权限的账户,或经 docker 以 root 执行;不要静默跳过。
|
- `chown` 报权限不足 → 当前用户无 sudo 权限,提示用户用有 root 权限的账户,或经 docker 以 root 执行;不要静默跳过
|
||||||
- 任何步骤失败都立即停止并报告,不自动重试。
|
- 任何步骤失败立即停止报告,不自动重试
|
||||||
|
|
||||||
## 禁止项
|
## 禁止项
|
||||||
|
|
||||||
@ -81,15 +66,11 @@ sudo chown -R "$(id -u):$(id -g)" public/uploads
|
|||||||
|---------|---------|
|
|---------|---------|
|
||||||
| build 报 uploads EACCES 就 `rm` 删文件 | `chown` 修属主,保留数据 |
|
| build 报 uploads EACCES 就 `rm` 删文件 | `chown` 修属主,保留数据 |
|
||||||
| 删整个 `public/uploads` 清场 | 最多清 `public/uploads/tmp`,别碰真实上传数据 |
|
| 删整个 `public/uploads` 清场 | 最多清 `public/uploads/tmp`,别碰真实上传数据 |
|
||||||
| 对文件无差别 `chmod 777` | 可写目录 `chmod 775` + `chown` 回当前用户 |
|
| 对文件无差别 `chmod 777` | 目录 `chmod 775` + `chown` 回当前用户即可 |
|
||||||
| 把 `public` 所有文件强制改为 `644` | 用 `chmod a+r` 只补读权限,保留现有权限位 |
|
|
||||||
| 递归 `chmod 755` 整个项目 | 只对项目根目录执行 `chmod 755 .` |
|
|
||||||
| 不加 sudo 直接 chown root 文件 | 改属主需 root |
|
| 不加 sudo 直接 chown root 文件 | 改属主需 root |
|
||||||
|
|
||||||
## Red Flags
|
## Red Flags —— 出现这些念头立即停下
|
||||||
|
|
||||||
- “uploads 复制失败,删掉再 build” → 不,`chown` 赋权,不丢数据。
|
- "uploads 复制失败,删掉再 build" → 不,`chown` 赋权,不丢数据
|
||||||
- “777 一把梭最省事” → 不,按 install 的 775(目录)+ chown。
|
- "777 一把梭最省事" → 不,按 install 的 775(目录)+ chown
|
||||||
- “根目录不可穿越,递归 chmod 全仓库” → 不,只修项目根目录为 755。
|
- "权限不够就跳过这个目录" → 不,报告交用户处理 sudo
|
||||||
- “静态文件 403,给整个项目所有文件加读权限” → 不,只对 `public` 普通文件执行 `a+r`。
|
|
||||||
- “权限不够就跳过这个目录” → 不,报告交用户处理 sudo。
|
|
||||||
|
|||||||
150
.github/scripts/deployment-regression.sh
vendored
150
.github/scripts/deployment-regression.sh
vendored
@ -1,150 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
env_value() {
|
|
||||||
local key="$1"
|
|
||||||
sed -n "s/^${key}=//p" .env | tail -n 1
|
|
||||||
}
|
|
||||||
|
|
||||||
db_exec() {
|
|
||||||
local database username password
|
|
||||||
database="$(env_value DB_DATABASE)"
|
|
||||||
username="$(env_value DB_USERNAME)"
|
|
||||||
password="$(env_value DB_PASSWORD)"
|
|
||||||
|
|
||||||
docker compose exec -T mariadb \
|
|
||||||
mariadb --batch --skip-column-names \
|
|
||||||
-u"${username}" -p"${password}" "${database}"
|
|
||||||
}
|
|
||||||
|
|
||||||
table_prefix() {
|
|
||||||
local prefix
|
|
||||||
prefix="$(env_value DB_PREFIX)"
|
|
||||||
if [[ ! "$prefix" =~ ^[A-Za-z0-9_]*$ ]]; then
|
|
||||||
echo "Invalid database table prefix: ${prefix}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
printf '%s' "$prefix"
|
|
||||||
}
|
|
||||||
|
|
||||||
wait_for_services() {
|
|
||||||
local deadline service container_id state health all_ready
|
|
||||||
local -a services
|
|
||||||
mapfile -t services < <(docker compose config --services)
|
|
||||||
deadline=$((SECONDS + 300))
|
|
||||||
|
|
||||||
while (( SECONDS < deadline )); do
|
|
||||||
all_ready=true
|
|
||||||
for service in "${services[@]}"; do
|
|
||||||
container_id="$(docker compose ps -q "$service")"
|
|
||||||
if [[ -z "$container_id" ]]; then
|
|
||||||
all_ready=false
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
|
|
||||||
state="$(docker inspect --format '{{.State.Status}}' "$container_id")"
|
|
||||||
health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container_id")"
|
|
||||||
if [[ "$state" != "running" || ( "$health" != "healthy" && "$health" != "none" ) ]]; then
|
|
||||||
all_ready=false
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ "$all_ready" == "true" ]]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Services did not become healthy within 300 seconds." >&2
|
|
||||||
docker compose ps >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
check_installation() {
|
|
||||||
local port prefix user_count migration_status expected_version setting_response
|
|
||||||
wait_for_services
|
|
||||||
|
|
||||||
port="$(env_value APP_PORT)"
|
|
||||||
curl --fail --silent --show-error \
|
|
||||||
--retry 30 --retry-delay 2 --retry-connrefused \
|
|
||||||
"http://127.0.0.1:${port}/health" >/dev/null
|
|
||||||
|
|
||||||
expected_version="$(jq -r '.version' package.json)"
|
|
||||||
echo "Checking database-backed API: GET /api/system/setting (expected version: ${expected_version})"
|
|
||||||
setting_response="$(curl --fail --silent --show-error \
|
|
||||||
--retry 5 --retry-delay 2 --retry-connrefused \
|
|
||||||
"http://127.0.0.1:${port}/api/system/setting")"
|
|
||||||
jq -e --arg expected_version "$expected_version" '
|
|
||||||
.ret == 1
|
|
||||||
and .data.reg == "open"
|
|
||||||
and .data.server_version == $expected_version
|
|
||||||
' <<<"$setting_response" >/dev/null
|
|
||||||
echo "Database-backed API check passed: ret=1, reg=open, server_version=${expected_version}"
|
|
||||||
|
|
||||||
prefix="$(table_prefix)"
|
|
||||||
user_count="$(printf 'SELECT COUNT(*) FROM `%susers`;\n' "$prefix" | db_exec | tr -d '[:space:]')"
|
|
||||||
if [[ ! "$user_count" =~ ^[1-9][0-9]*$ ]]; then
|
|
||||||
echo "Expected seeded users, got: ${user_count:-empty}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
migration_status="$(sudo ./cmd artisan migrate:status --no-ansi)"
|
|
||||||
printf '%s\n' "$migration_status"
|
|
||||||
if grep -qE '[|[:space:]]Pending[|[:space:]]' <<<"$migration_status"; then
|
|
||||||
echo "Pending database migrations remain." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
seed_upgrade_sentinel() {
|
|
||||||
local prefix
|
|
||||||
prefix="$(table_prefix)"
|
|
||||||
printf "DELETE FROM \`%ssettings\` WHERE name = 'ci_upgrade_sentinel';\n" "$prefix" | db_exec
|
|
||||||
printf "INSERT INTO \`%ssettings\` (name, \`desc\`, setting, created_at, updated_at) VALUES ('ci_upgrade_sentinel', 'deployment regression', 'dootask-upgrade-ci', NOW(), NOW());\n" "$prefix" | db_exec
|
|
||||||
|
|
||||||
mkdir -p public/uploads
|
|
||||||
printf '%s\n' 'dootask-upgrade-ci' > public/uploads/ci-upgrade-sentinel.txt
|
|
||||||
}
|
|
||||||
|
|
||||||
verify_upgrade_sentinel() {
|
|
||||||
local prefix row_count
|
|
||||||
prefix="$(table_prefix)"
|
|
||||||
row_count="$(printf "SELECT COUNT(*) FROM \`%ssettings\` WHERE name = 'ci_upgrade_sentinel' AND setting = 'dootask-upgrade-ci';\n" "$prefix" | db_exec | tr -d '[:space:]')"
|
|
||||||
if [[ "$row_count" != "1" ]]; then
|
|
||||||
echo "Database upgrade sentinel was not preserved." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
grep -qx 'dootask-upgrade-ci' public/uploads/ci-upgrade-sentinel.txt
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_revision() {
|
|
||||||
local expected_sha="$1"
|
|
||||||
local actual_sha
|
|
||||||
actual_sha="$(git rev-parse HEAD)"
|
|
||||||
if [[ "$actual_sha" != "$expected_sha" ]]; then
|
|
||||||
echo "Expected revision ${expected_sha}, got ${actual_sha}." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
case "${1:-}" in
|
|
||||||
check-installation)
|
|
||||||
check_installation
|
|
||||||
;;
|
|
||||||
seed-upgrade-sentinel)
|
|
||||||
seed_upgrade_sentinel
|
|
||||||
;;
|
|
||||||
verify-upgrade-sentinel)
|
|
||||||
verify_upgrade_sentinel
|
|
||||||
;;
|
|
||||||
assert-revision)
|
|
||||||
assert_revision "${2:?Expected revision is required}"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Usage: $0 {check-installation|seed-upgrade-sentinel|verify-upgrade-sentinel|assert-revision <sha>}" >&2
|
|
||||||
exit 2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
136
.github/workflows/deployment-regression.yml
vendored
136
.github/workflows/deployment-regression.yml
vendored
@ -1,136 +0,0 @@
|
|||||||
name: Deployment Regression
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [pro]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: deployment-regression-pro
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
fresh-install:
|
|
||||||
name: Fresh install
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 45
|
|
||||||
steps:
|
|
||||||
- name: Checkout pro
|
|
||||||
uses: actions/checkout@v5
|
|
||||||
|
|
||||||
- name: Install
|
|
||||||
run: sudo ./cmd install
|
|
||||||
|
|
||||||
- name: Verify installation
|
|
||||||
run: |
|
|
||||||
.github/scripts/deployment-regression.sh assert-revision "$GITHUB_SHA"
|
|
||||||
.github/scripts/deployment-regression.sh check-installation
|
|
||||||
|
|
||||||
- name: Print diagnostics
|
|
||||||
if: failure()
|
|
||||||
run: |
|
|
||||||
docker compose ps -a || true
|
|
||||||
docker compose logs --no-color --tail=300 || true
|
|
||||||
sudo find storage/logs -maxdepth 1 -type f -name '*.log' -exec tail -n 300 {} \; || true
|
|
||||||
|
|
||||||
- name: Clean up
|
|
||||||
if: always()
|
|
||||||
run: docker compose down --volumes --remove-orphans || true
|
|
||||||
|
|
||||||
upgrade-v1-8-64:
|
|
||||||
name: Upgrade v1.8.64 with ./cmd update
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 45
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Prepare historical version
|
|
||||||
run: |
|
|
||||||
cp .github/scripts/deployment-regression.sh "$RUNNER_TEMP/deployment-regression.sh"
|
|
||||||
chmod +x "$RUNNER_TEMP/deployment-regression.sh"
|
|
||||||
git checkout -B pro v1.8.64
|
|
||||||
|
|
||||||
- name: Install v1.8.64
|
|
||||||
run: sudo ./cmd install
|
|
||||||
|
|
||||||
- name: Seed upgrade sentinel
|
|
||||||
run: |
|
|
||||||
"$RUNNER_TEMP/deployment-regression.sh" check-installation
|
|
||||||
"$RUNNER_TEMP/deployment-regression.sh" seed-upgrade-sentinel
|
|
||||||
|
|
||||||
- name: Upgrade with ./cmd update
|
|
||||||
run: sudo ./cmd update
|
|
||||||
|
|
||||||
- name: Verify upgrade
|
|
||||||
run: |
|
|
||||||
"$RUNNER_TEMP/deployment-regression.sh" assert-revision "$GITHUB_SHA"
|
|
||||||
"$RUNNER_TEMP/deployment-regression.sh" check-installation
|
|
||||||
"$RUNNER_TEMP/deployment-regression.sh" verify-upgrade-sentinel
|
|
||||||
|
|
||||||
- name: Print diagnostics
|
|
||||||
if: failure()
|
|
||||||
run: |
|
|
||||||
docker compose ps -a || true
|
|
||||||
docker compose logs --no-color --tail=300 || true
|
|
||||||
sudo find storage/logs -maxdepth 1 -type f -name '*.log' -exec tail -n 300 {} \; || true
|
|
||||||
|
|
||||||
- name: Clean up
|
|
||||||
if: always()
|
|
||||||
run: docker compose down --volumes --remove-orphans || true
|
|
||||||
|
|
||||||
upgrade-v1-6-89:
|
|
||||||
name: Upgrade v1.6.89 with one-click installer
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 45
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Prepare historical version
|
|
||||||
run: |
|
|
||||||
cp .github/scripts/deployment-regression.sh "$RUNNER_TEMP/deployment-regression.sh"
|
|
||||||
chmod +x "$RUNNER_TEMP/deployment-regression.sh"
|
|
||||||
git checkout -B pro v1.6.89
|
|
||||||
|
|
||||||
- name: Install v1.6.89
|
|
||||||
run: sudo ./cmd install
|
|
||||||
|
|
||||||
- name: Reload v1.6.89 services
|
|
||||||
run: |
|
|
||||||
sudo ./cmd restart php
|
|
||||||
sudo ./cmd up
|
|
||||||
|
|
||||||
- name: Seed upgrade sentinel
|
|
||||||
run: |
|
|
||||||
"$RUNNER_TEMP/deployment-regression.sh" check-installation
|
|
||||||
"$RUNNER_TEMP/deployment-regression.sh" seed-upgrade-sentinel
|
|
||||||
|
|
||||||
- name: Upgrade with one-click installer
|
|
||||||
run: |
|
|
||||||
printf 'Y\n' | script -qefc \
|
|
||||||
'curl -fsSL https://raw.githubusercontent.com/kuaifan/dootask/pro/bin/install | bash' \
|
|
||||||
/dev/null
|
|
||||||
|
|
||||||
- name: Verify upgrade
|
|
||||||
run: |
|
|
||||||
"$RUNNER_TEMP/deployment-regression.sh" assert-revision "$GITHUB_SHA"
|
|
||||||
"$RUNNER_TEMP/deployment-regression.sh" check-installation
|
|
||||||
"$RUNNER_TEMP/deployment-regression.sh" verify-upgrade-sentinel
|
|
||||||
|
|
||||||
- name: Print diagnostics
|
|
||||||
if: failure()
|
|
||||||
run: |
|
|
||||||
docker compose ps -a || true
|
|
||||||
docker compose logs --no-color --tail=300 || true
|
|
||||||
sudo find storage/logs -maxdepth 1 -type f -name '*.log' -exec tail -n 300 {} \; || true
|
|
||||||
|
|
||||||
- name: Clean up
|
|
||||||
if: always()
|
|
||||||
run: docker compose down --volumes --remove-orphans || true
|
|
||||||
@ -1,131 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
|
||||||
|
|
||||||
use App\Models\WebSocketDialogMsg;
|
|
||||||
use App\Models\WebSocketDialogMsgAttachmentBackfill;
|
|
||||||
use App\Module\Base;
|
|
||||||
use App\Services\MessageAttachmentBackfillService;
|
|
||||||
use App\Services\MessageAttachmentService;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
|
|
||||||
class BackfillMessageAttachments extends Command
|
|
||||||
{
|
|
||||||
protected $signature = 'collaboration-files:backfill-attachments
|
|
||||||
{--after=0 : 从此消息ID之后开始}
|
|
||||||
{--before=0 : 处理到此消息ID,默认取启动时的最大ID}
|
|
||||||
{--batch=500 : 每批处理消息数量}
|
|
||||||
{--limit=0 : 本次最多处理消息数量,0表示不限}
|
|
||||||
{--sleep=0 : 每批结束后的休眠毫秒数}
|
|
||||||
{--status : 查看自动回填状态,不处理消息}
|
|
||||||
{--dry-run : 只解析和统计,不写入附件表}';
|
|
||||||
|
|
||||||
protected $description = '分批回填协作文件所需的消息附件索引';
|
|
||||||
|
|
||||||
public function handle(): int
|
|
||||||
{
|
|
||||||
if ($this->option('status')) {
|
|
||||||
return $this->showStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
$after = max(0, intval($this->option('after')));
|
|
||||||
$before = max(0, intval($this->option('before')));
|
|
||||||
$batch = min(2000, max(1, intval($this->option('batch'))));
|
|
||||||
$limit = max(0, intval($this->option('limit')));
|
|
||||||
$sleep = min(60000, max(0, intval($this->option('sleep'))));
|
|
||||||
$dryRun = boolval($this->option('dry-run'));
|
|
||||||
|
|
||||||
if ($before === 0) {
|
|
||||||
$before = intval(WebSocketDialogMsg::withTrashed()->max('id'));
|
|
||||||
}
|
|
||||||
if ($before <= $after) {
|
|
||||||
$this->info("没有需要处理的消息(after={$after}, before={$before})");
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->info(sprintf(
|
|
||||||
'开始回填消息附件:范围 (%d, %d],批量 %d%s',
|
|
||||||
$after,
|
|
||||||
$before,
|
|
||||||
$batch,
|
|
||||||
$dryRun ? ',仅预览' : ''
|
|
||||||
));
|
|
||||||
|
|
||||||
$lastId = $after;
|
|
||||||
$processed = 0;
|
|
||||||
$attachmentCount = 0;
|
|
||||||
$emptyCount = 0;
|
|
||||||
$failedIds = [];
|
|
||||||
|
|
||||||
while ($lastId < $before && ($limit === 0 || $processed < $limit)) {
|
|
||||||
$take = $limit > 0 ? min($batch, $limit - $processed) : $batch;
|
|
||||||
$messages = MessageAttachmentBackfillService::candidateQuery($lastId, $before)
|
|
||||||
->take($take)
|
|
||||||
->get();
|
|
||||||
|
|
||||||
if ($messages->isEmpty()) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($messages as $message) {
|
|
||||||
$lastId = intval($message->id);
|
|
||||||
$processed++;
|
|
||||||
try {
|
|
||||||
if ($dryRun) {
|
|
||||||
$data = Base::json2array($message->getRawOriginal('msg'));
|
|
||||||
$count = count(MessageAttachmentService::extract((string)$message->type, $data));
|
|
||||||
} else {
|
|
||||||
$count = MessageAttachmentService::sync($message);
|
|
||||||
}
|
|
||||||
$attachmentCount += $count;
|
|
||||||
if ($count === 0) {
|
|
||||||
$emptyCount++;
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$failedIds[] = $lastId;
|
|
||||||
$this->warn("消息 {$lastId} 处理失败:{$e->getMessage()}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->line("已处理 {$processed} 条消息,附件 {$attachmentCount} 条,当前消息ID {$lastId}");
|
|
||||||
if ($sleep > 0 && $lastId < $before) {
|
|
||||||
usleep($sleep * 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->newLine();
|
|
||||||
$this->info("回填结束:消息 {$processed} 条,附件 {$attachmentCount} 条,无有效附件 {$emptyCount} 条");
|
|
||||||
$this->info("续跑参数:--after={$lastId} --before={$before}");
|
|
||||||
if (!empty($failedIds)) {
|
|
||||||
$this->error('失败消息ID:' . implode(',', $failedIds));
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function showStatus(): int
|
|
||||||
{
|
|
||||||
$state = WebSocketDialogMsgAttachmentBackfill::query()->orderBy('id')->first();
|
|
||||||
if (!$state) {
|
|
||||||
$this->warn('自动回填任务尚未登记,请先执行数据库迁移');
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->table(['项目', '值'], [
|
|
||||||
['状态', $state->status],
|
|
||||||
['消息快照上界', $state->before_msg_id],
|
|
||||||
['当前消息ID', $state->last_msg_id],
|
|
||||||
['成功处理消息', $state->processed_count],
|
|
||||||
['已索引附件', $state->attachment_count],
|
|
||||||
['无有效附件消息', $state->empty_count],
|
|
||||||
['失败尝试', $state->failure_count],
|
|
||||||
['当前连续重试', $state->retry_count],
|
|
||||||
['下次重试时间', $state->next_retry_at?->toDateTimeString() ?: '-'],
|
|
||||||
['最近错误', $state->last_error ?: '-'],
|
|
||||||
['首次开始时间', $state->started_at?->toDateTimeString() ?: '-'],
|
|
||||||
['完成时间', $state->completed_at?->toDateTimeString() ?: '-'],
|
|
||||||
]);
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -34,7 +34,6 @@ use App\Models\WebSocketDialogMsgTodo;
|
|||||||
use App\Models\WebSocketDialogMsgTranslate;
|
use App\Models\WebSocketDialogMsgTranslate;
|
||||||
use App\Models\WebSocketDialogSession;
|
use App\Models\WebSocketDialogSession;
|
||||||
use App\Models\UserRecentItem;
|
use App\Models\UserRecentItem;
|
||||||
use App\Services\CollaborationFileService;
|
|
||||||
use App\Module\Table\OnlineData;
|
use App\Module\Table\OnlineData;
|
||||||
use App\Module\Manticore\ManticoreMsg;
|
use App\Module\Manticore\ManticoreMsg;
|
||||||
use Hhxsv5\LaravelS\Swoole\Task\Task;
|
use Hhxsv5\LaravelS\Swoole\Task\Task;
|
||||||
@ -1975,7 +1974,6 @@ class DialogController extends AbstractController
|
|||||||
if (empty($dialogMsg)) {
|
if (empty($dialogMsg)) {
|
||||||
return Base::retError("文件不存在");
|
return Base::retError("文件不存在");
|
||||||
}
|
}
|
||||||
CollaborationFileService::authorizeMessage($dialogMsg, $user);
|
|
||||||
//
|
//
|
||||||
if ($only_update_at == 'yes') {
|
if ($only_update_at == 'yes') {
|
||||||
return Base::retSuccess('success', [
|
return Base::retSuccess('success', [
|
||||||
@ -2037,7 +2035,7 @@ class DialogController extends AbstractController
|
|||||||
*/
|
*/
|
||||||
public function msg__download()
|
public function msg__download()
|
||||||
{
|
{
|
||||||
$user = User::auth();
|
User::auth();
|
||||||
//
|
//
|
||||||
$msg_id = intval(Request::input('msg_id'));
|
$msg_id = intval(Request::input('msg_id'));
|
||||||
$down = Request::input('down', 'yes');
|
$down = Request::input('down', 'yes');
|
||||||
@ -2045,11 +2043,6 @@ class DialogController extends AbstractController
|
|||||||
$msg = WebSocketDialogMsg::whereId($msg_id)->first();
|
$msg = WebSocketDialogMsg::whereId($msg_id)->first();
|
||||||
abort_if(empty($msg), 403, "This file not exist.");
|
abort_if(empty($msg), 403, "This file not exist.");
|
||||||
abort_if($msg->type != 'file', 403, "This file not support download.");
|
abort_if($msg->type != 'file', 403, "This file not support download.");
|
||||||
try {
|
|
||||||
CollaborationFileService::authorizeMessage($msg, $user);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
abort(403, $e->getMessage() ?: "This file not support download.");
|
|
||||||
}
|
|
||||||
$array = Base::json2array($msg->getRawOriginal('msg'));
|
$array = Base::json2array($msg->getRawOriginal('msg'));
|
||||||
//
|
//
|
||||||
if ($down === 'preview') {
|
if ($down === 'preview') {
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Models\WebSocketDialogMsg;
|
use App\Models\WebSocketDialogMsg;
|
||||||
use App\Models\WebSocketDialogMsgAttachment;
|
|
||||||
use App\Models\WebSocketDialog;
|
use App\Models\WebSocketDialog;
|
||||||
use App\Exceptions\ApiException;
|
use App\Exceptions\ApiException;
|
||||||
use App\Models\AbstractModel;
|
use App\Models\AbstractModel;
|
||||||
@ -13,7 +12,6 @@ use App\Models\FileLink;
|
|||||||
use App\Models\FileUser;
|
use App\Models\FileUser;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserRecentItem;
|
use App\Models\UserRecentItem;
|
||||||
use App\Services\CollaborationFileService;
|
|
||||||
use App\Module\Base;
|
use App\Module\Base;
|
||||||
use App\Module\Down;
|
use App\Module\Down;
|
||||||
use App\Module\Lock;
|
use App\Module\Lock;
|
||||||
@ -34,82 +32,6 @@ use ZipArchive;
|
|||||||
*/
|
*/
|
||||||
class FileController extends AbstractController
|
class FileController extends AbstractController
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* @api {get} api/file/collaboration/lists 获取协作文件列表
|
|
||||||
*
|
|
||||||
* @apiDescription 汇总用户有权访问的会话、项目群聊和任务中的文件消息及聊天正文图片
|
|
||||||
* @apiVersion 1.0.0
|
|
||||||
* @apiGroup file
|
|
||||||
* @apiName collaboration__lists
|
|
||||||
*
|
|
||||||
* @apiParam {String} [scope] 范围:all、conversation、project
|
|
||||||
* @apiParam {String} [conversation_type] 会话类型:all、private、group
|
|
||||||
* @apiParam {Number} [project_id] 项目ID(scope=project 时传0表示全部未归档项目)
|
|
||||||
* @apiParam {String} [project_source] 项目来源:all、project_chat、task
|
|
||||||
* @apiParam {String} [file_type] 文件类型
|
|
||||||
* @apiParam {Number} [sender_id] 发送人ID
|
|
||||||
* @apiParam {String} [key] 搜索关键词
|
|
||||||
* @apiParam {String} [cursor] 上一页返回的不透明游标
|
|
||||||
* @apiParam {Number} [take] 获取条数,默认50,最大100
|
|
||||||
*
|
|
||||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
|
||||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
|
||||||
* @apiSuccess {Object} data 返回数据
|
|
||||||
* @apiSuccess {Number} data.list[].attachment_id 附件索引ID
|
|
||||||
* @apiSuccess {String} data.list[].attachment_source 附件来源:file_message、inline_image
|
|
||||||
* @apiSuccess {String} data.list[].image_url 图片缩略图地址,非图片时为空
|
|
||||||
*/
|
|
||||||
public function collaboration__lists()
|
|
||||||
{
|
|
||||||
$user = User::auth();
|
|
||||||
$params = Request::only([
|
|
||||||
'scope',
|
|
||||||
'conversation_type',
|
|
||||||
'project_id',
|
|
||||||
'project_source',
|
|
||||||
'file_type',
|
|
||||||
'sender_id',
|
|
||||||
'key',
|
|
||||||
'cursor',
|
|
||||||
'take',
|
|
||||||
]);
|
|
||||||
|
|
||||||
return Base::retSuccess('success', CollaborationFileService::lists($user, $params));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @api {get} api/file/collaboration/download 下载协作文件附件
|
|
||||||
*
|
|
||||||
* @apiDescription 下载文件消息附件或聊天正文图片
|
|
||||||
* @apiVersion 1.0.0
|
|
||||||
* @apiGroup file
|
|
||||||
* @apiName collaboration__download
|
|
||||||
*
|
|
||||||
* @apiParam {Number} attachment_id 附件索引ID
|
|
||||||
*/
|
|
||||||
public function collaboration__download()
|
|
||||||
{
|
|
||||||
$attachment = WebSocketDialogMsgAttachment::whereId(intval(Request::input('attachment_id')))->first();
|
|
||||||
abort_if(empty($attachment), 404, 'This file not exist.');
|
|
||||||
|
|
||||||
try {
|
|
||||||
CollaborationFileService::authorizeAttachment($attachment, User::auth());
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
abort(403, $e->getMessage() ?: 'This file not support download.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$path = (string)$attachment->path;
|
|
||||||
abort_if($path === '', 404, 'This file not exist.');
|
|
||||||
if (preg_match('/^https?:\/\//i', $path)) {
|
|
||||||
return Redirect::away($path);
|
|
||||||
}
|
|
||||||
|
|
||||||
$filePath = CollaborationFileService::resolveLocalAttachmentPath($path);
|
|
||||||
abort_if($filePath === null, 404, 'This file not exist.');
|
|
||||||
$name = trim((string)$attachment->name) ?: basename($filePath);
|
|
||||||
return Base::DownloadFileResponse($filePath, $name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @api {get} api/file/lists 获取文件列表
|
* @api {get} api/file/lists 获取文件列表
|
||||||
*
|
*
|
||||||
|
|||||||
@ -154,7 +154,7 @@ class SystemController extends AbstractController
|
|||||||
$setting['unclaimed_task_reminder'] = $setting['unclaimed_task_reminder'] ?: 'close';
|
$setting['unclaimed_task_reminder'] = $setting['unclaimed_task_reminder'] ?: 'close';
|
||||||
$setting['unclaimed_task_reminder_time'] = $setting['unclaimed_task_reminder_time'] ?: '';
|
$setting['unclaimed_task_reminder_time'] = $setting['unclaimed_task_reminder_time'] ?: '';
|
||||||
$setting['task_ai_auto_analyze'] = $setting['task_ai_auto_analyze'] ?: 'open';
|
$setting['task_ai_auto_analyze'] = $setting['task_ai_auto_analyze'] ?: 'open';
|
||||||
$setting['department_owner_project_view'] = $setting['department_owner_project_view'] ?: 'open';
|
$setting['department_owner_project_view'] = $setting['department_owner_project_view'] ?: 'close';
|
||||||
$setting['server_timezone'] = config('app.timezone');
|
$setting['server_timezone'] = config('app.timezone');
|
||||||
$setting['server_version'] = Base::getVersion();
|
$setting['server_version'] = Base::getVersion();
|
||||||
// 指定人员名单仅管理员可见
|
// 指定人员名单仅管理员可见
|
||||||
|
|||||||
@ -418,7 +418,7 @@ class UsersController extends AbstractController
|
|||||||
public function info__managed_departments()
|
public function info__managed_departments()
|
||||||
{
|
{
|
||||||
$user = User::auth();
|
$user = User::auth();
|
||||||
if (Base::settingFind('system', 'department_owner_project_view', 'open') !== 'open') {
|
if (Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') {
|
||||||
return Base::retSuccess('success', []);
|
return Base::retSuccess('success', []);
|
||||||
}
|
}
|
||||||
return Base::retSuccess('success', UserDepartment::getManagedDepartments($user->userid));
|
return Base::retSuccess('success', UserDepartment::getManagedDepartments($user->userid));
|
||||||
|
|||||||
@ -25,7 +25,6 @@ use App\Tasks\ManticoreSyncTask;
|
|||||||
use App\Tasks\UnclaimedTaskRemindTask;
|
use App\Tasks\UnclaimedTaskRemindTask;
|
||||||
use App\Tasks\TodoRemindTask;
|
use App\Tasks\TodoRemindTask;
|
||||||
use App\Tasks\AiTaskLoopTask;
|
use App\Tasks\AiTaskLoopTask;
|
||||||
use App\Tasks\MessageAttachmentBackfillTask;
|
|
||||||
use Hhxsv5\LaravelS\Swoole\Task\Task;
|
use Hhxsv5\LaravelS\Swoole\Task\Task;
|
||||||
use App\Module\PatchedAvatar as Avatar;
|
use App\Module\PatchedAvatar as Avatar;
|
||||||
|
|
||||||
@ -282,8 +281,6 @@ class IndexController extends InvokeController
|
|||||||
Task::deliver(new ManticoreSyncTask());
|
Task::deliver(new ManticoreSyncTask());
|
||||||
// AI 任务建议
|
// AI 任务建议
|
||||||
Task::deliver(new AiTaskLoopTask());
|
Task::deliver(new AiTaskLoopTask());
|
||||||
// 聊天附件历史索引回填
|
|
||||||
Task::deliver(new MessageAttachmentBackfillTask());
|
|
||||||
|
|
||||||
return "success";
|
return "success";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -561,7 +561,7 @@ class UserDepartment extends AbstractModel
|
|||||||
'own_project_ids' => [],
|
'own_project_ids' => [],
|
||||||
'own_project_id_map' => [],
|
'own_project_id_map' => [],
|
||||||
];
|
];
|
||||||
if ($ids === null || $ids === '' || Base::settingFind('system', 'department_owner_project_view', 'open') !== 'open') {
|
if ($ids === null || $ids === '' || Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') {
|
||||||
return $empty;
|
return $empty;
|
||||||
}
|
}
|
||||||
$memberUserids = self::getManagedMemberUserids($user->userid, $ids);
|
$memberUserids = self::getManagedMemberUserids($user->userid, $ids);
|
||||||
@ -653,7 +653,7 @@ class UserDepartment extends AbstractModel
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
// 部门负责人只读视角
|
// 部门负责人只读视角
|
||||||
if (Base::settingFind('system', 'department_owner_project_view', 'open') !== 'open') {
|
if (Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') {
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
$memberUserids = self::getManagedMemberUserids($viewer->userid, 'all');
|
$memberUserids = self::getManagedMemberUserids($viewer->userid, 'all');
|
||||||
|
|||||||
@ -11,7 +11,6 @@ use App\Tasks\PushTask;
|
|||||||
use App\Models\ProjectTaskRelation;
|
use App\Models\ProjectTaskRelation;
|
||||||
use App\Exceptions\ApiException;
|
use App\Exceptions\ApiException;
|
||||||
use App\Tasks\WebSocketDialogMsgTask;
|
use App\Tasks\WebSocketDialogMsgTask;
|
||||||
use App\Services\MessageAttachmentService;
|
|
||||||
use Hhxsv5\LaravelS\Swoole\Task\Task;
|
use Hhxsv5\LaravelS\Swoole\Task\Task;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
@ -1478,7 +1477,6 @@ class WebSocketDialogMsg extends AbstractModel
|
|||||||
];
|
];
|
||||||
$dialogMsg->updateInstance($updateData);
|
$dialogMsg->updateInstance($updateData);
|
||||||
$dialogMsg->generateKeyAndSave($search_key);
|
$dialogMsg->generateKeyAndSave($search_key);
|
||||||
MessageAttachmentService::syncSafely($dialogMsg, true);
|
|
||||||
ProjectTaskRelation::recordMentionsFromMessage($dialogMsg);
|
ProjectTaskRelation::recordMentionsFromMessage($dialogMsg);
|
||||||
//
|
//
|
||||||
WebSocketDialogUser::whereDialogId($dialog->id)->whereUserid($sender)->whereHide(1)->change([
|
WebSocketDialogUser::whereDialogId($dialog->id)->whereUserid($sender)->whereHide(1)->change([
|
||||||
@ -1546,7 +1544,6 @@ class WebSocketDialogMsg extends AbstractModel
|
|||||||
'updated_at' => Carbon::now()->toDateTimeString('millisecond'),
|
'updated_at' => Carbon::now()->toDateTimeString('millisecond'),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
MessageAttachmentService::syncSafely($dialogMsg);
|
|
||||||
ProjectTaskRelation::recordMentionsFromMessage($dialogMsg);
|
ProjectTaskRelation::recordMentionsFromMessage($dialogMsg);
|
||||||
//
|
//
|
||||||
$task = new WebSocketDialogMsgTask($dialogMsg->id);
|
$task = new WebSocketDialogMsgTask($dialogMsg->id);
|
||||||
|
|||||||
@ -1,49 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @property int $id
|
|
||||||
* @property int $msg_id 消息ID
|
|
||||||
* @property int $dialog_id 会话ID
|
|
||||||
* @property string $source_type 来源类型
|
|
||||||
* @property string $kind 附件类型
|
|
||||||
* @property int $position 消息内附件顺序
|
|
||||||
* @property string $name 附件名称
|
|
||||||
* @property string $ext 文件扩展名
|
|
||||||
* @property string|null $path 原文件地址
|
|
||||||
* @property string|null $thumb 缩略图地址
|
|
||||||
* @property int $size 文件大小(B)
|
|
||||||
* @property int $width 图片宽度
|
|
||||||
* @property int $height 图片高度
|
|
||||||
* @property \Illuminate\Support\Carbon|null $created_at
|
|
||||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgAttachment newModelQuery()
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgAttachment newQuery()
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgAttachment query()
|
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgAttachment whereMsgId($value)
|
|
||||||
* @mixin \Eloquent
|
|
||||||
*/
|
|
||||||
class WebSocketDialogMsgAttachment extends AbstractModel
|
|
||||||
{
|
|
||||||
public const SOURCE_FILE_MESSAGE = 'file_message';
|
|
||||||
public const SOURCE_INLINE_IMAGE = 'inline_image';
|
|
||||||
|
|
||||||
public const KIND_FILE = 'file';
|
|
||||||
public const KIND_IMAGE = 'image';
|
|
||||||
|
|
||||||
protected $fillable = [
|
|
||||||
'msg_id',
|
|
||||||
'dialog_id',
|
|
||||||
'source_type',
|
|
||||||
'kind',
|
|
||||||
'position',
|
|
||||||
'name',
|
|
||||||
'ext',
|
|
||||||
'path',
|
|
||||||
'thumb',
|
|
||||||
'size',
|
|
||||||
'width',
|
|
||||||
'height',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @property int $id
|
|
||||||
* @property string $status
|
|
||||||
* @property int $before_msg_id
|
|
||||||
* @property int $last_msg_id
|
|
||||||
* @property int $processed_count
|
|
||||||
* @property int $attachment_count
|
|
||||||
* @property int $empty_count
|
|
||||||
* @property int $failure_count
|
|
||||||
* @property int $retry_count
|
|
||||||
* @property string|null $last_error
|
|
||||||
* @property \Illuminate\Support\Carbon|null $started_at
|
|
||||||
* @property \Illuminate\Support\Carbon|null $completed_at
|
|
||||||
* @property \Illuminate\Support\Carbon|null $next_retry_at
|
|
||||||
* @property \Illuminate\Support\Carbon|null $created_at
|
|
||||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
|
||||||
* @mixin \Eloquent
|
|
||||||
*/
|
|
||||||
class WebSocketDialogMsgAttachmentBackfill extends AbstractModel
|
|
||||||
{
|
|
||||||
public const STATUS_PENDING = 'pending';
|
|
||||||
public const STATUS_RUNNING = 'running';
|
|
||||||
public const STATUS_FAILED = 'failed';
|
|
||||||
public const STATUS_COMPLETED = 'completed';
|
|
||||||
|
|
||||||
protected $table = 'web_socket_dialog_msg_attachment_backfills';
|
|
||||||
|
|
||||||
protected $fillable = [
|
|
||||||
'status',
|
|
||||||
'before_msg_id',
|
|
||||||
'last_msg_id',
|
|
||||||
'processed_count',
|
|
||||||
'attachment_count',
|
|
||||||
'empty_count',
|
|
||||||
'failure_count',
|
|
||||||
'retry_count',
|
|
||||||
'last_error',
|
|
||||||
'started_at',
|
|
||||||
'completed_at',
|
|
||||||
'next_retry_at',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'started_at' => 'datetime',
|
|
||||||
'completed_at' => 'datetime',
|
|
||||||
'next_retry_at' => 'datetime',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@ -30,7 +30,7 @@ class DashboardTeam
|
|||||||
*/
|
*/
|
||||||
public static function context(User $user, $selectedDepartmentIds = null): array
|
public static function context(User $user, $selectedDepartmentIds = null): array
|
||||||
{
|
{
|
||||||
if (Base::settingFind('system', 'department_owner_project_view', 'open') !== 'open') {
|
if (Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') {
|
||||||
throw new ApiException('未开启部门负责人视角功能');
|
throw new ApiException('未开启部门负责人视角功能');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,715 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services;
|
|
||||||
|
|
||||||
use App\Exceptions\ApiException;
|
|
||||||
use App\Models\Project;
|
|
||||||
use App\Models\ProjectUser;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\UserDepartment;
|
|
||||||
use App\Models\WebSocketDialog;
|
|
||||||
use App\Models\WebSocketDialogMsg;
|
|
||||||
use App\Models\WebSocketDialogMsgAttachment;
|
|
||||||
use App\Models\WebSocketDialogMsgAttachmentBackfill;
|
|
||||||
use App\Models\WebSocketDialogUser;
|
|
||||||
use App\Module\Base;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
class CollaborationFileService
|
|
||||||
{
|
|
||||||
private const CURSOR_SOURCE_MESSAGES = 'messages';
|
|
||||||
private const CURSOR_SOURCE_ATTACHMENTS = 'attachments';
|
|
||||||
|
|
||||||
private const SCOPES = ['all', 'conversation', 'project'];
|
|
||||||
private const CONVERSATION_TYPES = ['all', 'private', 'group'];
|
|
||||||
private const PROJECT_SOURCES = ['all', 'project_chat', 'task'];
|
|
||||||
private const FILE_TYPES = ['all', 'document', 'sheet', 'slide', 'image', 'video', 'archive', 'other'];
|
|
||||||
|
|
||||||
private const FILE_TYPE_EXTENSIONS = [
|
|
||||||
'document' => ['doc', 'docx', 'dot', 'dotx', 'odt', 'ott', 'pdf', 'rtf', 'txt', 'md'],
|
|
||||||
'sheet' => ['csv', 'ods', 'ots', 'tsv', 'xls', 'xlsm', 'xlsx', 'xlt', 'xltx'],
|
|
||||||
'slide' => ['odp', 'otp', 'pot', 'potx', 'pps', 'ppsx', 'ppt', 'pptx'],
|
|
||||||
'image' => ['bmp', 'gif', 'jpeg', 'jpg', 'png', 'svg', 'tif', 'tiff', 'webp'],
|
|
||||||
'video' => ['3gp', 'avi', 'flv', 'mkv', 'mov', 'mp4', 'mpeg', 'mpg', 'rm', 'wmv'],
|
|
||||||
'archive' => ['7z', 'gz', 'rar', 'tar', 'tgz', 'zip'],
|
|
||||||
];
|
|
||||||
|
|
||||||
private const PREVIEW_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'webp', 'png', 'gif', 'bmp'];
|
|
||||||
|
|
||||||
public static function normalizeParams(array $params): array
|
|
||||||
{
|
|
||||||
$scope = trim((string)($params['scope'] ?? 'all'));
|
|
||||||
$conversationType = trim((string)($params['conversation_type'] ?? 'all'));
|
|
||||||
$projectSource = trim((string)($params['project_source'] ?? 'all'));
|
|
||||||
$fileType = trim((string)($params['file_type'] ?? 'all'));
|
|
||||||
|
|
||||||
if (!in_array($scope, self::SCOPES, true)
|
|
||||||
|| !in_array($conversationType, self::CONVERSATION_TYPES, true)
|
|
||||||
|| !in_array($projectSource, self::PROJECT_SOURCES, true)
|
|
||||||
|| !in_array($fileType, self::FILE_TYPES, true)) {
|
|
||||||
throw new ApiException('参数错误');
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalized = [
|
|
||||||
'scope' => $scope,
|
|
||||||
'conversation_type' => $conversationType,
|
|
||||||
'project_id' => max(0, intval($params['project_id'] ?? 0)),
|
|
||||||
'project_source' => $projectSource,
|
|
||||||
'file_type' => $fileType,
|
|
||||||
'sender_id' => max(0, intval($params['sender_id'] ?? 0)),
|
|
||||||
'key' => mb_substr(trim((string)($params['key'] ?? '')), 0, 100),
|
|
||||||
'cursor' => mb_substr(trim((string)($params['cursor'] ?? '')), 0, 100),
|
|
||||||
'take' => min(100, max(1, intval($params['take'] ?? 50))),
|
|
||||||
];
|
|
||||||
|
|
||||||
return $normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function lists(User $user, array $params): array
|
|
||||||
{
|
|
||||||
$params = self::normalizeParams($params);
|
|
||||||
$departmentView = UserDepartment::ownerViewContext($user, true);
|
|
||||||
|
|
||||||
if ($params['scope'] === 'project' && $params['project_id'] > 0) {
|
|
||||||
if (!Project::whereKey($params['project_id'])->exists()) {
|
|
||||||
throw new ApiException('项目不存在或已被删除');
|
|
||||||
}
|
|
||||||
if (!self::canAccessProject($params['project_id'], intval($user->userid), $departmentView)) {
|
|
||||||
throw new ApiException('无权限访问此文件');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$cursorSource = self::cursorSource($params['cursor']);
|
|
||||||
if ($cursorSource === self::CURSOR_SOURCE_MESSAGES) {
|
|
||||||
return self::listsFromMessages($user, $params, $departmentView);
|
|
||||||
}
|
|
||||||
if ($cursorSource === self::CURSOR_SOURCE_ATTACHMENTS || self::attachmentIndexReady()) {
|
|
||||||
return self::listsFromAttachments($user, $params, $departmentView);
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::listsFromMessages($user, $params, $departmentView);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function listsFromMessages(User $user, array $params, array $departmentView): array
|
|
||||||
{
|
|
||||||
$query = WebSocketDialogMsg::query()
|
|
||||||
->select([
|
|
||||||
'web_socket_dialog_msgs.*',
|
|
||||||
'dialogs.type as source_dialog_type',
|
|
||||||
'dialogs.group_type as source_group_type',
|
|
||||||
'dialogs.name as source_dialog_name',
|
|
||||||
'project_chat.id as project_chat_id',
|
|
||||||
'project_chat.name as project_chat_name',
|
|
||||||
'project_task.id as source_task_id',
|
|
||||||
'project_task.name as source_task_name',
|
|
||||||
'project_task.complete_at as source_task_complete_at',
|
|
||||||
'project_task.archived_at as source_task_archived_at',
|
|
||||||
'task_project.id as task_project_id',
|
|
||||||
'task_project.name as task_project_name',
|
|
||||||
])
|
|
||||||
->join('web_socket_dialogs as dialogs', 'dialogs.id', '=', 'web_socket_dialog_msgs.dialog_id')
|
|
||||||
->leftJoin('projects as project_chat', function ($join) {
|
|
||||||
$join->on('project_chat.dialog_id', '=', 'dialogs.id')
|
|
||||||
->whereNull('project_chat.deleted_at');
|
|
||||||
})
|
|
||||||
->leftJoin('project_tasks as project_task', function ($join) {
|
|
||||||
$join->on('project_task.dialog_id', '=', 'dialogs.id')
|
|
||||||
->whereNull('project_task.deleted_at');
|
|
||||||
})
|
|
||||||
->leftJoin('projects as task_project', function ($join) {
|
|
||||||
$join->on('task_project.id', '=', 'project_task.project_id')
|
|
||||||
->whereNull('task_project.deleted_at');
|
|
||||||
})
|
|
||||||
->whereNull('dialogs.deleted_at')
|
|
||||||
->where('web_socket_dialog_msgs.type', 'file');
|
|
||||||
|
|
||||||
self::applyAccess($query, $user, $departmentView);
|
|
||||||
self::applyScope($query, $params);
|
|
||||||
self::applyFilters($query, $params, false);
|
|
||||||
|
|
||||||
$cursor = self::messageCursor($params['cursor']);
|
|
||||||
if ($cursor > 0) {
|
|
||||||
$query->where('web_socket_dialog_msgs.id', '<', $cursor);
|
|
||||||
}
|
|
||||||
|
|
||||||
$rows = $query
|
|
||||||
->orderByDesc('web_socket_dialog_msgs.id')
|
|
||||||
->take($params['take'] + 1)
|
|
||||||
->get();
|
|
||||||
|
|
||||||
$hasMore = $rows->count() > $params['take'];
|
|
||||||
if ($hasMore) {
|
|
||||||
$rows->pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
$userIds = $rows->pluck('userid')->map(fn($id) => intval($id))->filter()->unique()->values();
|
|
||||||
$users = User::select(User::$basicField)->whereIn('userid', $userIds)->get()->keyBy('userid');
|
|
||||||
$privateNames = self::privateDialogNames($rows, intval($user->userid));
|
|
||||||
|
|
||||||
$list = $rows->map(function (WebSocketDialogMsg $row) use ($users, $privateNames, $user) {
|
|
||||||
return self::formatRow($row, $users->get($row->userid), $privateNames, $user);
|
|
||||||
})->values()->toArray();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'list' => $list,
|
|
||||||
'next_cursor' => $hasMore && $rows->isNotEmpty() ? 'm:' . intval($rows->last()->id) : 0,
|
|
||||||
'has_more' => $hasMore,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function listsFromAttachments(User $user, array $params, array $departmentView): array
|
|
||||||
{
|
|
||||||
$query = WebSocketDialogMsgAttachment::query()
|
|
||||||
->select([
|
|
||||||
'web_socket_dialog_msg_attachments.*',
|
|
||||||
'messages.userid as sender_id',
|
|
||||||
'messages.key as message_key',
|
|
||||||
'messages.created_at as message_created_at',
|
|
||||||
'dialogs.type as source_dialog_type',
|
|
||||||
'dialogs.group_type as source_group_type',
|
|
||||||
'dialogs.name as source_dialog_name',
|
|
||||||
'project_chat.id as project_chat_id',
|
|
||||||
'project_chat.name as project_chat_name',
|
|
||||||
'project_task.id as source_task_id',
|
|
||||||
'project_task.name as source_task_name',
|
|
||||||
'project_task.complete_at as source_task_complete_at',
|
|
||||||
'project_task.archived_at as source_task_archived_at',
|
|
||||||
'task_project.id as task_project_id',
|
|
||||||
'task_project.name as task_project_name',
|
|
||||||
])
|
|
||||||
->join('web_socket_dialog_msgs as messages', 'messages.id', '=', 'web_socket_dialog_msg_attachments.msg_id')
|
|
||||||
->join('web_socket_dialogs as dialogs', 'dialogs.id', '=', 'messages.dialog_id')
|
|
||||||
->leftJoin('projects as project_chat', function ($join) {
|
|
||||||
$join->on('project_chat.dialog_id', '=', 'dialogs.id')
|
|
||||||
->whereNull('project_chat.deleted_at');
|
|
||||||
})
|
|
||||||
->leftJoin('project_tasks as project_task', function ($join) {
|
|
||||||
$join->on('project_task.dialog_id', '=', 'dialogs.id')
|
|
||||||
->whereNull('project_task.deleted_at');
|
|
||||||
})
|
|
||||||
->leftJoin('projects as task_project', function ($join) {
|
|
||||||
$join->on('task_project.id', '=', 'project_task.project_id')
|
|
||||||
->whereNull('task_project.deleted_at');
|
|
||||||
})
|
|
||||||
->whereNull('messages.deleted_at')
|
|
||||||
->whereNull('dialogs.deleted_at');
|
|
||||||
|
|
||||||
self::applyAccess($query, $user, $departmentView);
|
|
||||||
self::applyScope($query, $params);
|
|
||||||
self::applyFilters($query, $params, true);
|
|
||||||
self::applyAttachmentCursor($query, $params['cursor']);
|
|
||||||
|
|
||||||
$rows = $query
|
|
||||||
->orderBy('web_socket_dialog_msg_attachments.cursor_msg_id')
|
|
||||||
->orderBy('web_socket_dialog_msg_attachments.position')
|
|
||||||
->orderBy('web_socket_dialog_msg_attachments.id')
|
|
||||||
->take($params['take'] + 1)
|
|
||||||
->get();
|
|
||||||
|
|
||||||
$hasMore = $rows->count() > $params['take'];
|
|
||||||
if ($hasMore) {
|
|
||||||
$rows->pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
$userIds = $rows->pluck('sender_id')->map(fn($id) => intval($id))->filter()->unique()->values();
|
|
||||||
$users = User::select(User::$basicField)->whereIn('userid', $userIds)->get()->keyBy('userid');
|
|
||||||
$privateNames = self::privateDialogNames($rows, intval($user->userid));
|
|
||||||
|
|
||||||
$list = $rows->map(function (WebSocketDialogMsgAttachment $row) use ($users, $privateNames, $user) {
|
|
||||||
return self::formatAttachmentRow($row, $users->get($row->sender_id), $privateNames, $user);
|
|
||||||
})->values()->toArray();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'list' => $list,
|
|
||||||
'next_cursor' => $hasMore && $rows->isNotEmpty() ? self::attachmentCursor($rows->last()) : 0,
|
|
||||||
'has_more' => $hasMore,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function authorizeMessage(WebSocketDialogMsg $message, User $user): void
|
|
||||||
{
|
|
||||||
$dialog = WebSocketDialog::whereId($message->dialog_id)->first();
|
|
||||||
if (empty($dialog)) {
|
|
||||||
throw new ApiException('对话不存在或已被删除');
|
|
||||||
}
|
|
||||||
|
|
||||||
$departmentView = UserDepartment::ownerViewContext($user, true);
|
|
||||||
if ($dialog->group_type === 'project') {
|
|
||||||
$projectId = intval(Project::whereDialogId($dialog->id)->value('id'));
|
|
||||||
if ($projectId <= 0 || !self::canAccessProject($projectId, intval($user->userid), $departmentView)) {
|
|
||||||
throw new ApiException('无权限访问此文件');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($dialog->group_type === 'task') {
|
|
||||||
$taskQuery = DB::table('project_tasks')
|
|
||||||
->whereNull('project_tasks.deleted_at')
|
|
||||||
->where('project_tasks.dialog_id', $dialog->id);
|
|
||||||
self::applyTaskPermission($taskQuery, 'project_tasks', intval($user->userid), $departmentView);
|
|
||||||
if (!$taskQuery->exists()) {
|
|
||||||
throw new ApiException('无权限访问此文件');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!WebSocketDialogUser::whereDialogId($dialog->id)->whereUserid($user->userid)->exists()) {
|
|
||||||
throw new ApiException('无权限访问此文件');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function authorizeAttachment(WebSocketDialogMsgAttachment $attachment, User $user): WebSocketDialogMsg
|
|
||||||
{
|
|
||||||
$message = WebSocketDialogMsg::whereId($attachment->msg_id)->first();
|
|
||||||
if (!$message || intval($message->dialog_id) !== intval($attachment->dialog_id)) {
|
|
||||||
throw new ApiException('文件不存在或已被删除');
|
|
||||||
}
|
|
||||||
self::authorizeMessage($message, $user);
|
|
||||||
return $message;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function resolveLocalAttachmentPath(string $path): ?string
|
|
||||||
{
|
|
||||||
$urlPath = parse_url(trim($path), PHP_URL_PATH);
|
|
||||||
if (!is_string($urlPath) || $urlPath === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$relativePath = rawurldecode($urlPath);
|
|
||||||
if (str_contains($relativePath, "\0")) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
$relativePath = ltrim(str_replace('\\', '/', $relativePath), '/');
|
|
||||||
if (!str_starts_with($relativePath, 'uploads/')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$uploadsRoot = realpath(public_path('uploads'));
|
|
||||||
$filePath = realpath(public_path($relativePath));
|
|
||||||
if ($uploadsRoot === false || $filePath === false || !is_file($filePath)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$uploadsPrefix = rtrim($uploadsRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
|
|
||||||
return str_starts_with($filePath, $uploadsPrefix) ? $filePath : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function applyAccess(Builder $query, User $user, array $departmentView): void
|
|
||||||
{
|
|
||||||
$userid = intval($user->userid);
|
|
||||||
$query->where(function (Builder $access) use ($userid, $departmentView) {
|
|
||||||
$access->where(function (Builder $conversation) use ($userid) {
|
|
||||||
self::applyConversationType($conversation);
|
|
||||||
$conversation->whereExists(function (QueryBuilder $member) use ($userid) {
|
|
||||||
$member->selectRaw('1')
|
|
||||||
->from('web_socket_dialog_users as access_dialog_user')
|
|
||||||
->whereColumn('access_dialog_user.dialog_id', 'dialogs.id')
|
|
||||||
->where('access_dialog_user.userid', $userid);
|
|
||||||
});
|
|
||||||
})->orWhere(function (Builder $project) use ($userid, $departmentView) {
|
|
||||||
$project->where('dialogs.group_type', 'project')
|
|
||||||
->whereNotNull('project_chat.id');
|
|
||||||
self::applyProjectPermission($project, 'project_chat.id', $userid, $departmentView);
|
|
||||||
})->orWhere(function (Builder $task) use ($userid, $departmentView) {
|
|
||||||
$task->where('dialogs.group_type', 'task')
|
|
||||||
->whereNotNull('project_task.id');
|
|
||||||
self::applyTaskPermission($task, 'project_task', $userid, $departmentView);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function applyScope(Builder $query, array $params): void
|
|
||||||
{
|
|
||||||
if ($params['scope'] === 'conversation') {
|
|
||||||
self::applyConversationType($query);
|
|
||||||
if ($params['conversation_type'] === 'private') {
|
|
||||||
$query->where('dialogs.type', 'user');
|
|
||||||
} elseif ($params['conversation_type'] === 'group') {
|
|
||||||
$query->where('dialogs.type', 'group')
|
|
||||||
->whereNotIn('dialogs.group_type', ['project', 'task']);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($params['scope'] === 'project') {
|
|
||||||
$projectId = $params['project_id'];
|
|
||||||
$query->where(function (Builder $scope) use ($projectId, $params) {
|
|
||||||
if ($params['project_source'] !== 'task') {
|
|
||||||
$scope->where(function (Builder $projectChat) use ($projectId) {
|
|
||||||
$projectChat->where('dialogs.group_type', 'project')
|
|
||||||
->whereNotNull('project_chat.id')
|
|
||||||
->whereNull('project_chat.archived_at');
|
|
||||||
if ($projectId > 0) {
|
|
||||||
$projectChat->where('project_chat.id', $projectId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if ($params['project_source'] !== 'project_chat') {
|
|
||||||
$method = $params['project_source'] === 'task' ? 'where' : 'orWhere';
|
|
||||||
$scope->{$method}(function (Builder $task) use ($projectId) {
|
|
||||||
$task->where('dialogs.group_type', 'task')
|
|
||||||
->whereNotNull('task_project.id')
|
|
||||||
->whereNull('task_project.archived_at');
|
|
||||||
if ($projectId > 0) {
|
|
||||||
$task->where('task_project.id', $projectId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function applyFilters(Builder $query, array $params, bool $indexed): void
|
|
||||||
{
|
|
||||||
$messageTable = $indexed ? 'messages' : 'web_socket_dialog_msgs';
|
|
||||||
if ($params['sender_id'] > 0) {
|
|
||||||
$query->where("{$messageTable}.userid", $params['sender_id']);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($params['file_type'] !== 'all') {
|
|
||||||
$extension = $indexed
|
|
||||||
? 'web_socket_dialog_msg_attachments.ext'
|
|
||||||
: DB::raw("LOWER(JSON_UNQUOTE(JSON_EXTRACT(" . DB::getTablePrefix() . "web_socket_dialog_msgs.msg, '$.ext')))");
|
|
||||||
if ($params['file_type'] === 'other') {
|
|
||||||
$known = array_values(array_unique(array_merge(...array_values(self::FILE_TYPE_EXTENSIONS))));
|
|
||||||
$query->whereNotIn($extension, $known);
|
|
||||||
} else {
|
|
||||||
$query->whereIn($extension, self::FILE_TYPE_EXTENSIONS[$params['file_type']]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($params['key'] !== '') {
|
|
||||||
$key = '%' . addcslashes($params['key'], '%_\\') . '%';
|
|
||||||
$query->where(function (Builder $search) use ($key, $indexed, $messageTable) {
|
|
||||||
$search->where($indexed ? 'web_socket_dialog_msg_attachments.name' : "{$messageTable}.key", 'like', $key);
|
|
||||||
if ($indexed) {
|
|
||||||
$search->orWhere("{$messageTable}.key", 'like', $key);
|
|
||||||
}
|
|
||||||
$search
|
|
||||||
->orWhere('dialogs.name', 'like', $key)
|
|
||||||
->orWhere('project_chat.name', 'like', $key)
|
|
||||||
->orWhere('project_task.name', 'like', $key)
|
|
||||||
->orWhere('task_project.name', 'like', $key)
|
|
||||||
->orWhereExists(function (QueryBuilder $sender) use ($key, $messageTable) {
|
|
||||||
$sender->selectRaw('1')
|
|
||||||
->from('users as search_sender')
|
|
||||||
->whereColumn('search_sender.userid', "{$messageTable}.userid")
|
|
||||||
->where('search_sender.nickname', 'like', $key);
|
|
||||||
})->orWhereExists(function (QueryBuilder $privateUser) use ($key) {
|
|
||||||
$privateUser->selectRaw('1')
|
|
||||||
->from('web_socket_dialog_users as search_dialog_user')
|
|
||||||
->join('users as search_private_user', 'search_private_user.userid', '=', 'search_dialog_user.userid')
|
|
||||||
->whereColumn('search_dialog_user.dialog_id', 'dialogs.id')
|
|
||||||
->where('search_private_user.nickname', 'like', $key);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function attachmentIndexReady(): bool
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
return WebSocketDialogMsgAttachmentBackfill::whereStatus(
|
|
||||||
WebSocketDialogMsgAttachmentBackfill::STATUS_COMPLETED
|
|
||||||
)->exists();
|
|
||||||
} catch (\Throwable) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function cursorSource(string $cursor): ?string
|
|
||||||
{
|
|
||||||
if ($cursor === '' || $cursor === '0') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (preg_match('/^m:\d+$/', $cursor) || ctype_digit($cursor)) {
|
|
||||||
return self::CURSOR_SOURCE_MESSAGES;
|
|
||||||
}
|
|
||||||
if (preg_match('/^a:\d+:\d+:\d+$/', $cursor)
|
|
||||||
|| preg_match('/^\d+:\d+:\d+$/', $cursor)) {
|
|
||||||
return self::CURSOR_SOURCE_ATTACHMENTS;
|
|
||||||
}
|
|
||||||
throw new ApiException('参数错误');
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function messageCursor(string $cursor): int
|
|
||||||
{
|
|
||||||
if ($cursor === '' || $cursor === '0') {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (preg_match('/^m:(\d+)$/', $cursor, $matches)) {
|
|
||||||
return intval($matches[1]);
|
|
||||||
}
|
|
||||||
if (ctype_digit($cursor)) {
|
|
||||||
return intval($cursor);
|
|
||||||
}
|
|
||||||
throw new ApiException('参数错误');
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function applyAttachmentCursor(Builder $query, string $cursor): void
|
|
||||||
{
|
|
||||||
if ($cursor === '' || $cursor === '0') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preg_match('/^a:(\d+):(\d+):(\d+)$/', $cursor, $matches)
|
|
||||||
|| preg_match('/^(\d+):(\d+):(\d+)$/', $cursor, $matches)) {
|
|
||||||
$msgId = intval($matches[1]);
|
|
||||||
$position = intval($matches[2]);
|
|
||||||
$attachmentId = intval($matches[3]);
|
|
||||||
} else {
|
|
||||||
throw new ApiException('参数错误');
|
|
||||||
}
|
|
||||||
|
|
||||||
$query->where(function (Builder $page) use ($msgId, $position, $attachmentId) {
|
|
||||||
$page->where('web_socket_dialog_msg_attachments.cursor_msg_id', '>', -$msgId)
|
|
||||||
->orWhere(function (Builder $sameMessage) use ($msgId, $position, $attachmentId) {
|
|
||||||
$sameMessage->where('web_socket_dialog_msg_attachments.cursor_msg_id', -$msgId)
|
|
||||||
->where(function (Builder $afterAttachment) use ($position, $attachmentId) {
|
|
||||||
$afterAttachment->where('web_socket_dialog_msg_attachments.position', '>', $position)
|
|
||||||
->orWhere(function (Builder $samePosition) use ($position, $attachmentId) {
|
|
||||||
$samePosition->where('web_socket_dialog_msg_attachments.position', $position)
|
|
||||||
->where('web_socket_dialog_msg_attachments.id', '>', $attachmentId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function attachmentCursor(WebSocketDialogMsgAttachment $attachment): string
|
|
||||||
{
|
|
||||||
return implode(':', [
|
|
||||||
'a',
|
|
||||||
intval($attachment->msg_id),
|
|
||||||
intval($attachment->position),
|
|
||||||
intval($attachment->id),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function applyConversationType(Builder $query): void
|
|
||||||
{
|
|
||||||
$query->where(function (Builder $type) {
|
|
||||||
$type->where('dialogs.type', 'user')
|
|
||||||
->orWhere(function (Builder $group) {
|
|
||||||
$group->where('dialogs.type', 'group')
|
|
||||||
->whereNotIn('dialogs.group_type', ['project', 'task']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function applyProjectPermission(Builder|QueryBuilder $query, string $projectColumn, int $userid, array $departmentView): void
|
|
||||||
{
|
|
||||||
$query->where(function ($permission) use ($projectColumn, $userid, $departmentView) {
|
|
||||||
$permission->whereExists(function (QueryBuilder $member) use ($projectColumn, $userid) {
|
|
||||||
$member->selectRaw('1')
|
|
||||||
->from('project_users as access_project_user')
|
|
||||||
->whereColumn('access_project_user.project_id', $projectColumn)
|
|
||||||
->where('access_project_user.userid', $userid);
|
|
||||||
});
|
|
||||||
if (!empty($departmentView['project_ids'])) {
|
|
||||||
$permission->orWhereIn($projectColumn, $departmentView['project_ids']);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function applyTaskPermission(Builder|QueryBuilder $query, string $taskAlias, int $userid, array $departmentView): void
|
|
||||||
{
|
|
||||||
$query->where(function ($permission) use ($taskAlias, $userid, $departmentView) {
|
|
||||||
$permission->where(function ($projectVisible) use ($taskAlias, $userid, $departmentView) {
|
|
||||||
$projectVisible->where("{$taskAlias}.visibility", 1);
|
|
||||||
self::applyProjectPermission($projectVisible, "{$taskAlias}.project_id", $userid, $departmentView);
|
|
||||||
})->orWhereExists(function (QueryBuilder $projectOwner) use ($taskAlias, $userid) {
|
|
||||||
$projectOwner->selectRaw('1')
|
|
||||||
->from('project_users as access_task_project_owner')
|
|
||||||
->whereColumn('access_task_project_owner.project_id', "{$taskAlias}.project_id")
|
|
||||||
->where('access_task_project_owner.userid', $userid)
|
|
||||||
->whereIn('access_task_project_owner.owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY]);
|
|
||||||
})->orWhereExists(function (QueryBuilder $taskUser) use ($taskAlias, $userid) {
|
|
||||||
$taskUser->selectRaw('1')
|
|
||||||
->from('project_task_users as access_task_user')
|
|
||||||
->whereColumn('access_task_user.task_id', "{$taskAlias}.id")
|
|
||||||
->where('access_task_user.userid', $userid);
|
|
||||||
})->orWhereExists(function (QueryBuilder $visibleUser) use ($taskAlias, $userid) {
|
|
||||||
$visibleUser->selectRaw('1')
|
|
||||||
->from('project_task_visibility_users as access_task_visible_user')
|
|
||||||
->whereColumn('access_task_visible_user.task_id', "{$taskAlias}.id")
|
|
||||||
->where('access_task_visible_user.userid', $userid);
|
|
||||||
})->orWhereExists(function (QueryBuilder $parentVisibleUser) use ($taskAlias, $userid) {
|
|
||||||
$parentVisibleUser->selectRaw('1')
|
|
||||||
->from('project_task_visibility_users as access_parent_task_visible_user')
|
|
||||||
->whereColumn('access_parent_task_visible_user.task_id', "{$taskAlias}.parent_id")
|
|
||||||
->where('access_parent_task_visible_user.userid', $userid);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function canAccessProject(int $projectId, int $userid, array $departmentView): bool
|
|
||||||
{
|
|
||||||
if (isset($departmentView['project_id_map'][$projectId])) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return DB::table('project_users')->where('project_id', $projectId)->where('userid', $userid)->exists();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function privateDialogNames($rows, int $userid): array
|
|
||||||
{
|
|
||||||
$dialogIds = $rows->filter(fn($row) => $row->source_dialog_type === 'user')
|
|
||||||
->pluck('dialog_id')->map(fn($id) => intval($id))->unique()->values();
|
|
||||||
if ($dialogIds->isEmpty()) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$names = [];
|
|
||||||
$members = DB::table('web_socket_dialog_users as dialog_user')
|
|
||||||
->join('users', 'users.userid', '=', 'dialog_user.userid')
|
|
||||||
->whereIn('dialog_user.dialog_id', $dialogIds)
|
|
||||||
->where('dialog_user.userid', '!=', $userid)
|
|
||||||
->select(['dialog_user.dialog_id', 'users.nickname'])
|
|
||||||
->get();
|
|
||||||
foreach ($members as $member) {
|
|
||||||
$names[intval($member->dialog_id)] = $member->nickname;
|
|
||||||
}
|
|
||||||
return $names;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function formatRow(WebSocketDialogMsg $row, ?User $sender, array $privateNames, User $currentUser): array
|
|
||||||
{
|
|
||||||
$file = Base::json2array($row->getRawOriginal('msg'));
|
|
||||||
$ext = strtolower((string)($file['ext'] ?? pathinfo((string)($file['name'] ?? ''), PATHINFO_EXTENSION)));
|
|
||||||
$imageUrl = '';
|
|
||||||
if (in_array($ext, self::PREVIEW_IMAGE_EXTENSIONS, true)) {
|
|
||||||
$imageUrl = Base::fillUrl(($file['thumb'] ?? '') ?: ($file['path'] ?? ''));
|
|
||||||
}
|
|
||||||
$sourceType = 'group';
|
|
||||||
$sourceName = $row->source_dialog_name;
|
|
||||||
$projectId = 0;
|
|
||||||
$projectName = '';
|
|
||||||
$taskId = 0;
|
|
||||||
$taskName = '';
|
|
||||||
|
|
||||||
if ($row->source_dialog_type === 'user') {
|
|
||||||
$sourceType = 'private';
|
|
||||||
$sourceName = $privateNames[intval($row->dialog_id)] ?? $currentUser->nickname;
|
|
||||||
} elseif ($row->source_group_type === 'project') {
|
|
||||||
$sourceType = 'project_chat';
|
|
||||||
$sourceName = $row->project_chat_name ?: $row->source_dialog_name;
|
|
||||||
$projectId = intval($row->project_chat_id);
|
|
||||||
$projectName = (string)$row->project_chat_name;
|
|
||||||
} elseif ($row->source_group_type === 'task') {
|
|
||||||
$sourceType = 'task';
|
|
||||||
$sourceName = $row->source_task_name ?: $row->source_dialog_name;
|
|
||||||
$projectId = intval($row->task_project_id);
|
|
||||||
$projectName = (string)$row->task_project_name;
|
|
||||||
$taskId = intval($row->source_task_id);
|
|
||||||
$taskName = (string)$row->source_task_name;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'attachment_id' => 0,
|
|
||||||
'attachment_source' => WebSocketDialogMsgAttachment::SOURCE_FILE_MESSAGE,
|
|
||||||
'attachment_position' => 0,
|
|
||||||
'generated_name' => false,
|
|
||||||
'msg_id' => intval($row->id),
|
|
||||||
'dialog_id' => intval($row->dialog_id),
|
|
||||||
'name' => (string)($file['name'] ?? ''),
|
|
||||||
'ext' => $ext,
|
|
||||||
'size' => intval($file['size'] ?? 0),
|
|
||||||
'thumb' => Base::fillUrl($file['thumb'] ?? Base::extIcon($ext)),
|
|
||||||
'image_url' => $imageUrl,
|
|
||||||
'width' => intval($file['width'] ?? -1),
|
|
||||||
'height' => intval($file['height'] ?? -1),
|
|
||||||
'file_type' => self::fileType($ext),
|
|
||||||
'source_type' => $sourceType,
|
|
||||||
'source_name' => (string)$sourceName,
|
|
||||||
'project_id' => $projectId,
|
|
||||||
'project_name' => $projectName,
|
|
||||||
'task_id' => $taskId,
|
|
||||||
'task_name' => $taskName,
|
|
||||||
'task_status' => $taskId > 0 ? ($row->source_task_archived_at ? 'archived' : ($row->source_task_complete_at ? 'completed' : 'active')) : '',
|
|
||||||
'sender' => $sender ? $sender->toArray() : ['userid' => intval($row->userid)],
|
|
||||||
'created_at' => $row->created_at?->toDateTimeString(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function formatAttachmentRow(
|
|
||||||
WebSocketDialogMsgAttachment $row,
|
|
||||||
?User $sender,
|
|
||||||
array $privateNames,
|
|
||||||
User $currentUser
|
|
||||||
): array {
|
|
||||||
$ext = strtolower((string)$row->ext);
|
|
||||||
$name = trim((string)$row->name);
|
|
||||||
$generatedName = $name === '';
|
|
||||||
if ($name === '') {
|
|
||||||
$path = (string)(parse_url((string)$row->path, PHP_URL_PATH) ?: $row->path);
|
|
||||||
$name = basename($path) ?: "image-{$row->msg_id}-" . (intval($row->position) + 1) . ($ext ? ".{$ext}" : '');
|
|
||||||
}
|
|
||||||
|
|
||||||
$imageUrl = '';
|
|
||||||
if ($row->kind === WebSocketDialogMsgAttachment::KIND_IMAGE) {
|
|
||||||
$imageUrl = Base::fillUrl($row->thumb ?: $row->path);
|
|
||||||
}
|
|
||||||
|
|
||||||
$sourceType = 'group';
|
|
||||||
$sourceName = $row->source_dialog_name;
|
|
||||||
$projectId = 0;
|
|
||||||
$projectName = '';
|
|
||||||
$taskId = 0;
|
|
||||||
$taskName = '';
|
|
||||||
|
|
||||||
if ($row->source_dialog_type === 'user') {
|
|
||||||
$sourceType = 'private';
|
|
||||||
$sourceName = $privateNames[intval($row->dialog_id)] ?? $currentUser->nickname;
|
|
||||||
} elseif ($row->source_group_type === 'project') {
|
|
||||||
$sourceType = 'project_chat';
|
|
||||||
$sourceName = $row->project_chat_name ?: $row->source_dialog_name;
|
|
||||||
$projectId = intval($row->project_chat_id);
|
|
||||||
$projectName = (string)$row->project_chat_name;
|
|
||||||
} elseif ($row->source_group_type === 'task') {
|
|
||||||
$sourceType = 'task';
|
|
||||||
$sourceName = $row->source_task_name ?: $row->source_dialog_name;
|
|
||||||
$projectId = intval($row->task_project_id);
|
|
||||||
$projectName = (string)$row->task_project_name;
|
|
||||||
$taskId = intval($row->source_task_id);
|
|
||||||
$taskName = (string)$row->source_task_name;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'attachment_id' => intval($row->id),
|
|
||||||
'attachment_source' => (string)$row->source_type,
|
|
||||||
'attachment_position' => intval($row->position),
|
|
||||||
'generated_name' => $generatedName,
|
|
||||||
'msg_id' => intval($row->msg_id),
|
|
||||||
'dialog_id' => intval($row->dialog_id),
|
|
||||||
'name' => $name,
|
|
||||||
'ext' => $ext,
|
|
||||||
'size' => intval($row->size),
|
|
||||||
'thumb' => Base::fillUrl($row->thumb ?: Base::extIcon($ext)),
|
|
||||||
'image_url' => $imageUrl,
|
|
||||||
'width' => intval($row->width),
|
|
||||||
'height' => intval($row->height),
|
|
||||||
'file_type' => self::fileType($ext),
|
|
||||||
'source_type' => $sourceType,
|
|
||||||
'source_name' => (string)$sourceName,
|
|
||||||
'project_id' => $projectId,
|
|
||||||
'project_name' => $projectName,
|
|
||||||
'task_id' => $taskId,
|
|
||||||
'task_name' => $taskName,
|
|
||||||
'task_status' => $taskId > 0 ? ($row->source_task_archived_at ? 'archived' : ($row->source_task_complete_at ? 'completed' : 'active')) : '',
|
|
||||||
'sender' => $sender ? $sender->toArray() : ['userid' => intval($row->sender_id)],
|
|
||||||
'created_at' => (string)$row->message_created_at,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function fileType(string $ext): string
|
|
||||||
{
|
|
||||||
foreach (self::FILE_TYPE_EXTENSIONS as $type => $extensions) {
|
|
||||||
if (in_array($ext, $extensions, true)) {
|
|
||||||
return $type;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 'other';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,193 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services;
|
|
||||||
|
|
||||||
use App\Models\WebSocketDialogMsg;
|
|
||||||
use App\Models\WebSocketDialogMsgAttachmentBackfill;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
|
|
||||||
class MessageAttachmentBackfillService
|
|
||||||
{
|
|
||||||
public const DEFAULT_BATCH_SIZE = 500;
|
|
||||||
public const DEFAULT_TIME_LIMIT = 40;
|
|
||||||
|
|
||||||
private const LOCK_KEY = 'collaboration-files:attachment-backfill';
|
|
||||||
private const LOCK_SECONDS = 120;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 自动回填与手工回填共用同一套候选消息筛选规则。
|
|
||||||
*/
|
|
||||||
public static function candidateQuery(int $after, int $before): Builder
|
|
||||||
{
|
|
||||||
return WebSocketDialogMsg::query()
|
|
||||||
->where('id', '>', $after)
|
|
||||||
->where('id', '<=', $before)
|
|
||||||
->where(function ($query) {
|
|
||||||
$query->where('type', 'file')
|
|
||||||
->orWhere(function ($text) {
|
|
||||||
$text->where('type', 'text')
|
|
||||||
->where(function ($images) {
|
|
||||||
$images->where('msg', 'like', '%browse%')
|
|
||||||
->orWhere('msg', 'like', '%[:IMAGE:browse:%');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})
|
|
||||||
->orderBy('id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 在时间预算内推进自动回填;已有任务运行时直接跳过。
|
|
||||||
*/
|
|
||||||
public static function processAutomatic(
|
|
||||||
int $batchSize = self::DEFAULT_BATCH_SIZE,
|
|
||||||
int $timeLimit = self::DEFAULT_TIME_LIMIT
|
|
||||||
): array {
|
|
||||||
$batchSize = min(2000, max(1, $batchSize));
|
|
||||||
$timeLimit = min(50, max(1, $timeLimit));
|
|
||||||
$lockKey = config('app.env') . ':' . self::LOCK_KEY;
|
|
||||||
$lock = Cache::store('redis')->lock($lockKey, self::LOCK_SECONDS);
|
|
||||||
if (!$lock->get()) {
|
|
||||||
return ['status' => 'locked'];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return self::processState($batchSize, $timeLimit);
|
|
||||||
} finally {
|
|
||||||
$lock->release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function processState(int $batchSize, int $timeLimit): array
|
|
||||||
{
|
|
||||||
$state = WebSocketDialogMsgAttachmentBackfill::query()->orderBy('id')->first();
|
|
||||||
if (!$state) {
|
|
||||||
return ['status' => 'missing'];
|
|
||||||
}
|
|
||||||
if ($state->status === WebSocketDialogMsgAttachmentBackfill::STATUS_COMPLETED) {
|
|
||||||
return ['status' => WebSocketDialogMsgAttachmentBackfill::STATUS_COMPLETED];
|
|
||||||
}
|
|
||||||
if ($state->next_retry_at && $state->next_retry_at->isFuture()) {
|
|
||||||
return ['status' => 'waiting'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$before = intval($state->before_msg_id);
|
|
||||||
if (!$state->started_at) {
|
|
||||||
// 迁移到新服务启动之间仍可能由旧进程写入消息,首次运行时补齐这段升级窗口。
|
|
||||||
$before = max($before, intval(WebSocketDialogMsg::withTrashed()->max('id')));
|
|
||||||
}
|
|
||||||
|
|
||||||
$state->update([
|
|
||||||
'status' => WebSocketDialogMsgAttachmentBackfill::STATUS_RUNNING,
|
|
||||||
'before_msg_id' => $before,
|
|
||||||
'started_at' => $state->started_at ?: now(),
|
|
||||||
'next_retry_at' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$startedAt = microtime(true);
|
|
||||||
$lastId = intval($state->last_msg_id);
|
|
||||||
$processed = intval($state->processed_count);
|
|
||||||
$attachmentCount = intval($state->attachment_count);
|
|
||||||
$emptyCount = intval($state->empty_count);
|
|
||||||
|
|
||||||
while ($lastId < $before && microtime(true) - $startedAt < $timeLimit) {
|
|
||||||
$messages = self::candidateQuery($lastId, $before)
|
|
||||||
->take($batchSize)
|
|
||||||
->get();
|
|
||||||
|
|
||||||
if ($messages->isEmpty()) {
|
|
||||||
return self::complete($state, $before, $processed, $attachmentCount, $emptyCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($messages as $message) {
|
|
||||||
try {
|
|
||||||
$count = MessageAttachmentService::sync($message);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
self::fail($state, $lastId, $processed, $attachmentCount, $emptyCount, $e);
|
|
||||||
return [
|
|
||||||
'status' => WebSocketDialogMsgAttachmentBackfill::STATUS_FAILED,
|
|
||||||
'last_msg_id' => $lastId,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$lastId = intval($message->id);
|
|
||||||
$processed++;
|
|
||||||
$attachmentCount += $count;
|
|
||||||
if ($count === 0) {
|
|
||||||
$emptyCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$state->update([
|
|
||||||
'last_msg_id' => $lastId,
|
|
||||||
'processed_count' => $processed,
|
|
||||||
'attachment_count' => $attachmentCount,
|
|
||||||
'empty_count' => $emptyCount,
|
|
||||||
'retry_count' => 0,
|
|
||||||
'last_error' => null,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($lastId >= $before) {
|
|
||||||
return self::complete($state, $before, $processed, $attachmentCount, $emptyCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
$state->update(['status' => WebSocketDialogMsgAttachmentBackfill::STATUS_PENDING]);
|
|
||||||
return [
|
|
||||||
'status' => WebSocketDialogMsgAttachmentBackfill::STATUS_PENDING,
|
|
||||||
'last_msg_id' => $lastId,
|
|
||||||
'processed_count' => $processed,
|
|
||||||
'attachment_count' => $attachmentCount,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function complete(
|
|
||||||
WebSocketDialogMsgAttachmentBackfill $state,
|
|
||||||
int $before,
|
|
||||||
int $processed,
|
|
||||||
int $attachmentCount,
|
|
||||||
int $emptyCount
|
|
||||||
): array {
|
|
||||||
$state->update([
|
|
||||||
'status' => WebSocketDialogMsgAttachmentBackfill::STATUS_COMPLETED,
|
|
||||||
'last_msg_id' => $before,
|
|
||||||
'processed_count' => $processed,
|
|
||||||
'attachment_count' => $attachmentCount,
|
|
||||||
'empty_count' => $emptyCount,
|
|
||||||
'retry_count' => 0,
|
|
||||||
'last_error' => null,
|
|
||||||
'next_retry_at' => null,
|
|
||||||
'completed_at' => now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'status' => WebSocketDialogMsgAttachmentBackfill::STATUS_COMPLETED,
|
|
||||||
'last_msg_id' => $before,
|
|
||||||
'processed_count' => $processed,
|
|
||||||
'attachment_count' => $attachmentCount,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function fail(
|
|
||||||
WebSocketDialogMsgAttachmentBackfill $state,
|
|
||||||
int $lastId,
|
|
||||||
int $processed,
|
|
||||||
int $attachmentCount,
|
|
||||||
int $emptyCount,
|
|
||||||
\Throwable $error
|
|
||||||
): void {
|
|
||||||
$retryCount = intval($state->retry_count) + 1;
|
|
||||||
$retryMinutes = min(30, 2 ** min(4, $retryCount - 1));
|
|
||||||
$state->update([
|
|
||||||
'status' => WebSocketDialogMsgAttachmentBackfill::STATUS_FAILED,
|
|
||||||
'last_msg_id' => $lastId,
|
|
||||||
'processed_count' => $processed,
|
|
||||||
'attachment_count' => $attachmentCount,
|
|
||||||
'empty_count' => $emptyCount,
|
|
||||||
'failure_count' => intval($state->failure_count) + 1,
|
|
||||||
'retry_count' => $retryCount,
|
|
||||||
'last_error' => mb_substr($error->getMessage(), 0, 2000),
|
|
||||||
'next_retry_at' => now()->addMinutes($retryMinutes),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,236 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services;
|
|
||||||
|
|
||||||
use App\Models\File;
|
|
||||||
use App\Models\WebSocketDialogMsg;
|
|
||||||
use App\Models\WebSocketDialogMsgAttachment;
|
|
||||||
use App\Module\Base;
|
|
||||||
use DOMDocument;
|
|
||||||
use DOMElement;
|
|
||||||
use DOMXPath;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
class MessageAttachmentService
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* 从消息内容提取可检索附件。返回值不包含消息关联字段和时间字段。
|
|
||||||
*/
|
|
||||||
public static function extract(string $messageType, array $messageData): array
|
|
||||||
{
|
|
||||||
if ($messageType === 'file') {
|
|
||||||
$attachment = self::extractFileMessage($messageData);
|
|
||||||
return $attachment ? [$attachment] : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($messageType !== 'text') {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::extractInlineImages((string)($messageData['text'] ?? ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将一条消息的附件索引同步为当前内容,重复执行不会产生重复记录。
|
|
||||||
*/
|
|
||||||
public static function sync(WebSocketDialogMsg $message, bool $removeMissing = true): int
|
|
||||||
{
|
|
||||||
$messageData = Base::json2array($message->getRawOriginal('msg'));
|
|
||||||
$attachments = self::extract((string)$message->type, $messageData);
|
|
||||||
if (empty($attachments) && !$removeMissing) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
DB::transaction(function () use ($message, $attachments) {
|
|
||||||
if (empty($attachments)) {
|
|
||||||
WebSocketDialogMsgAttachment::whereMsgId($message->id)->delete();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$now = now();
|
|
||||||
$sourceType = $attachments[0]['source_type'];
|
|
||||||
$positions = [];
|
|
||||||
$rows = [];
|
|
||||||
foreach ($attachments as $attachment) {
|
|
||||||
$positions[] = $attachment['position'];
|
|
||||||
$rows[] = array_merge($attachment, [
|
|
||||||
'msg_id' => intval($message->id),
|
|
||||||
'dialog_id' => intval($message->dialog_id),
|
|
||||||
'created_at' => $message->created_at ?: $now,
|
|
||||||
'updated_at' => $now,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
DB::table('web_socket_dialog_msg_attachments')->upsert(
|
|
||||||
$rows,
|
|
||||||
['msg_id', 'source_type', 'position'],
|
|
||||||
['dialog_id', 'kind', 'name', 'ext', 'path', 'thumb', 'size', 'width', 'height', 'updated_at']
|
|
||||||
);
|
|
||||||
|
|
||||||
WebSocketDialogMsgAttachment::whereMsgId($message->id)
|
|
||||||
->where(function ($query) use ($sourceType, $positions) {
|
|
||||||
$query->where('source_type', '!=', $sourceType)
|
|
||||||
->orWhereNotIn('position', $positions);
|
|
||||||
})
|
|
||||||
->delete();
|
|
||||||
});
|
|
||||||
|
|
||||||
return count($attachments);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 双写失败不影响聊天主链路,历史回填命令可再次同步失败消息。
|
|
||||||
*/
|
|
||||||
public static function syncSafely(WebSocketDialogMsg $message, bool $removeMissing = false): bool
|
|
||||||
{
|
|
||||||
if (!in_array($message->type, ['file', 'text'], true)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
self::sync($message, $removeMissing);
|
|
||||||
return true;
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
Log::warning('Message attachment sync failed', [
|
|
||||||
'msg_id' => intval($message->id),
|
|
||||||
'dialog_id' => intval($message->dialog_id),
|
|
||||||
'message' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function extractFileMessage(array $messageData): ?array
|
|
||||||
{
|
|
||||||
$path = self::normalizeStoredPath((string)($messageData['path'] ?? ''));
|
|
||||||
if ($path === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$ext = strtolower((string)($messageData['ext'] ?? self::pathExtension($path)));
|
|
||||||
$thumb = self::normalizeStoredPath((string)($messageData['thumb'] ?? ''));
|
|
||||||
|
|
||||||
return [
|
|
||||||
'source_type' => WebSocketDialogMsgAttachment::SOURCE_FILE_MESSAGE,
|
|
||||||
'kind' => in_array($ext, File::imageExt, true)
|
|
||||||
? WebSocketDialogMsgAttachment::KIND_IMAGE
|
|
||||||
: WebSocketDialogMsgAttachment::KIND_FILE,
|
|
||||||
'position' => 0,
|
|
||||||
'name' => (string)($messageData['name'] ?? ''),
|
|
||||||
'ext' => $ext,
|
|
||||||
'path' => $path,
|
|
||||||
'thumb' => $thumb,
|
|
||||||
'size' => max(0, intval($messageData['size'] ?? 0)),
|
|
||||||
'width' => max(0, intval($messageData['width'] ?? 0)),
|
|
||||||
'height' => max(0, intval($messageData['height'] ?? 0)),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function extractInlineImages(string $html): array
|
|
||||||
{
|
|
||||||
if ($html === '') {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$attachments = [];
|
|
||||||
if (str_contains($html, '<img')) {
|
|
||||||
$document = new DOMDocument('1.0', 'UTF-8');
|
|
||||||
$previous = libxml_use_internal_errors(true);
|
|
||||||
$loaded = $document->loadHTML(
|
|
||||||
'<?xml encoding="UTF-8"><div>' . $html . '</div>',
|
|
||||||
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NOERROR | LIBXML_NOWARNING
|
|
||||||
);
|
|
||||||
libxml_clear_errors();
|
|
||||||
libxml_use_internal_errors($previous);
|
|
||||||
|
|
||||||
if ($loaded) {
|
|
||||||
$nodes = (new DOMXPath($document))->query(
|
|
||||||
"//img[contains(concat(' ', normalize-space(@class), ' '), ' browse ')]"
|
|
||||||
);
|
|
||||||
foreach ($nodes ?: [] as $node) {
|
|
||||||
if (!$node instanceof DOMElement) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
self::appendInlineImage($attachments, [
|
|
||||||
'src' => $node->getAttribute('src'),
|
|
||||||
'width' => $node->getAttribute('width'),
|
|
||||||
'height' => $node->getAttribute('height'),
|
|
||||||
'name' => $node->getAttribute('alt'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($attachments)) {
|
|
||||||
preg_match_all('/\[:IMAGE:browse:([^:]*):([^:]*):(.*?):(.*?):\]/i', $html, $matches, PREG_SET_ORDER);
|
|
||||||
foreach ($matches as $match) {
|
|
||||||
self::appendInlineImage($attachments, [
|
|
||||||
'src' => $match[3] ?? '',
|
|
||||||
'width' => $match[1] ?? 0,
|
|
||||||
'height' => $match[2] ?? 0,
|
|
||||||
'name' => $match[4] ?? '',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($attachments as $position => &$attachment) {
|
|
||||||
$attachment['position'] = $position;
|
|
||||||
}
|
|
||||||
unset($attachment);
|
|
||||||
|
|
||||||
return $attachments;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function appendInlineImage(array &$attachments, array $image): void
|
|
||||||
{
|
|
||||||
$thumb = self::normalizeStoredPath(html_entity_decode(trim((string)$image['src']), ENT_QUOTES | ENT_HTML5));
|
|
||||||
if ($thumb === '' || str_contains($thumb, 'images/other/imgerr.jpg')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$path = Base::thumbRestore($thumb);
|
|
||||||
$attachments[] = [
|
|
||||||
'source_type' => WebSocketDialogMsgAttachment::SOURCE_INLINE_IMAGE,
|
|
||||||
'kind' => WebSocketDialogMsgAttachment::KIND_IMAGE,
|
|
||||||
'position' => 0,
|
|
||||||
'name' => trim(strip_tags((string)$image['name'])),
|
|
||||||
'ext' => self::pathExtension($path),
|
|
||||||
'path' => $path,
|
|
||||||
'thumb' => $thumb,
|
|
||||||
'size' => self::localFileSize($path),
|
|
||||||
'width' => max(0, intval($image['width'])),
|
|
||||||
'height' => max(0, intval($image['height'])),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function normalizeStoredPath(string $path): string
|
|
||||||
{
|
|
||||||
$path = trim(str_replace('{{RemoteURL}}', '', $path));
|
|
||||||
if ($path === '' || str_starts_with($path, 'data:')) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$path = Base::unFillUrl($path);
|
|
||||||
if (str_starts_with($path, '/') && !str_starts_with($path, '//')) {
|
|
||||||
$path = ltrim($path, '/');
|
|
||||||
}
|
|
||||||
return $path;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function pathExtension(string $path): string
|
|
||||||
{
|
|
||||||
$urlPath = parse_url($path, PHP_URL_PATH);
|
|
||||||
return strtolower(pathinfo(urldecode((string)($urlPath ?: $path)), PATHINFO_EXTENSION));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function localFileSize(string $path): int
|
|
||||||
{
|
|
||||||
if ($path === '' || preg_match('/^[a-z][a-z0-9+.-]*:\/\//i', $path)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$file = public_path(ltrim($path, '/'));
|
|
||||||
return is_file($file) ? max(0, intval(filesize($file))) : 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Tasks;
|
|
||||||
|
|
||||||
use App\Services\MessageAttachmentBackfillService;
|
|
||||||
|
|
||||||
class MessageAttachmentBackfillTask extends AbstractTask
|
|
||||||
{
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
parent::__construct();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function start()
|
|
||||||
{
|
|
||||||
MessageAttachmentBackfillService::processAutomatic();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function end()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
71
cmd
71
cmd
@ -63,8 +63,6 @@ msg() {
|
|||||||
"端口 (*) 已被占用,请指定其他端口") out="Port (*) is already in use, please specify another port" ;;
|
"端口 (*) 已被占用,请指定其他端口") out="Port (*) is already in use, please specify another port" ;;
|
||||||
"目录权限检测失败!请检查目录权限设置") out="Directory permission check failed! Please check directory permissions" ;;
|
"目录权限检测失败!请检查目录权限设置") out="Directory permission check failed! Please check directory permissions" ;;
|
||||||
"目录【(*)】权限不足!") out="Directory [(*)] is not writable!" ;;
|
"目录【(*)】权限不足!") out="Directory [(*)] is not writable!" ;;
|
||||||
"项目权限修复失败") out="Failed to repair project permissions" ;;
|
|
||||||
"项目权限修复完成") out="Project permissions repaired" ;;
|
|
||||||
"安装依赖失败") out="Failed to install dependencies" ;;
|
"安装依赖失败") out="Failed to install dependencies" ;;
|
||||||
"安装依赖失败,请重试!") out="Failed to install dependencies, please retry!" ;;
|
"安装依赖失败,请重试!") out="Failed to install dependencies, please retry!" ;;
|
||||||
"生成密钥失败") out="Failed to generate app key" ;;
|
"生成密钥失败") out="Failed to generate app key" ;;
|
||||||
@ -229,49 +227,6 @@ check_sudo() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# 修复项目权限
|
|
||||||
permission_fix() {
|
|
||||||
local owner_uid="${SUDO_UID:-$(id -u)}"
|
|
||||||
local owner_gid="${SUDO_GID:-$(id -g)}"
|
|
||||||
volumes=(
|
|
||||||
"bootstrap/cache"
|
|
||||||
"docker"
|
|
||||||
"public"
|
|
||||||
"storage"
|
|
||||||
)
|
|
||||||
|
|
||||||
chmod 755 "${WORK_DIR}"
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
error "$(msg '项目权限修复失败')"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
for vol in "${volumes[@]}"; do
|
|
||||||
tmp_path="${WORK_DIR}/${vol}"
|
|
||||||
mkdir -p "${tmp_path}"
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
error "$(msg '项目权限修复失败')"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
chown -R "${owner_uid}:${owner_gid}" "${tmp_path}"
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
error "$(msg '项目权限修复失败')"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
find "${tmp_path}" -type d -exec chmod 775 {} \;
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
error "$(msg '项目权限修复失败')"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
find "${WORK_DIR}/public" -type f -exec chmod a+r {} \;
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
error "$(msg '项目权限修复失败')"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# 检查docker、docker-compose
|
# 检查docker、docker-compose
|
||||||
check_docker() {
|
check_docker() {
|
||||||
docker --version &> /dev/null
|
docker --version &> /dev/null
|
||||||
@ -630,7 +585,6 @@ DooTask 管理脚本
|
|||||||
install 安装 DooTask (支持 --port <端口> --relock)
|
install 安装 DooTask (支持 --port <端口> --relock)
|
||||||
update 更新 DooTask (支持 --branch <分支> --force --local)
|
update 更新 DooTask (支持 --branch <分支> --force --local)
|
||||||
uninstall 卸载 DooTask
|
uninstall 卸载 DooTask
|
||||||
permission 修复整个项目的权限
|
|
||||||
|
|
||||||
⚙️ 配置管理:
|
⚙️ 配置管理:
|
||||||
port <端口> 修改服务端口
|
port <端口> 修改服务端口
|
||||||
@ -667,7 +621,6 @@ DooTask 管理脚本
|
|||||||
示例:
|
示例:
|
||||||
./cmd install --port 8080 安装并指定端口 8080
|
./cmd install --port 8080 安装并指定端口 8080
|
||||||
./cmd update --branch dev 切换到 dev 分支并更新
|
./cmd update --branch dev 切换到 dev 分支并更新
|
||||||
./cmd permission 修复整个项目的权限
|
|
||||||
./cmd mysql backup 备份数据库
|
./cmd mysql backup 备份数据库
|
||||||
./cmd artisan migrate 执行数据库迁移
|
./cmd artisan migrate 执行数据库迁移
|
||||||
EOF
|
EOF
|
||||||
@ -681,7 +634,6 @@ Usage: ./cmd <command> [options]
|
|||||||
install Install DooTask (supports --port <port> --relock)
|
install Install DooTask (supports --port <port> --relock)
|
||||||
update Update DooTask (supports --branch <branch> --force --local)
|
update Update DooTask (supports --branch <branch> --force --local)
|
||||||
uninstall Uninstall DooTask
|
uninstall Uninstall DooTask
|
||||||
permission Repair permissions for the whole project
|
|
||||||
|
|
||||||
⚙️ Configuration:
|
⚙️ Configuration:
|
||||||
port <port> Change service port
|
port <port> Change service port
|
||||||
@ -718,7 +670,6 @@ Usage: ./cmd <command> [options]
|
|||||||
Examples:
|
Examples:
|
||||||
./cmd install --port 8080 Install on port 8080
|
./cmd install --port 8080 Install on port 8080
|
||||||
./cmd update --branch dev Switch to dev branch and update
|
./cmd update --branch dev Switch to dev branch and update
|
||||||
./cmd permission Repair permissions for the whole project
|
|
||||||
./cmd mysql backup Back up database
|
./cmd mysql backup Back up database
|
||||||
./cmd artisan migrate Run database migration
|
./cmd artisan migrate Run database migration
|
||||||
EOF
|
EOF
|
||||||
@ -763,12 +714,20 @@ handle_install() {
|
|||||||
rm -rf node_modules package-lock.json vendor composer.lock
|
rm -rf node_modules package-lock.json vendor composer.lock
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 目录和静态文件权限设置
|
# 目录权限设置
|
||||||
permission_fix
|
volumes=(
|
||||||
|
"bootstrap/cache"
|
||||||
|
"docker"
|
||||||
|
"public"
|
||||||
|
"storage"
|
||||||
|
)
|
||||||
cmda=""
|
cmda=""
|
||||||
cmdb=""
|
cmdb=""
|
||||||
for vol in "${volumes[@]}"; do
|
for vol in "${volumes[@]}"; do
|
||||||
tmp_path="${WORK_DIR}/${vol}"
|
tmp_path="${WORK_DIR}/${vol}"
|
||||||
|
mkdir -p "${tmp_path}"
|
||||||
|
find "${tmp_path}" -type d -exec chmod 775 {} \;
|
||||||
|
|
||||||
rm -f "${tmp_path}/dootask.lock"
|
rm -f "${tmp_path}/dootask.lock"
|
||||||
cmda="${cmda} -v ${tmp_path}:/usr/share/${vol}"
|
cmda="${cmda} -v ${tmp_path}:/usr/share/${vol}"
|
||||||
cmdb="${cmdb} touch /usr/share/${vol}/dootask.lock &&"
|
cmdb="${cmdb} touch /usr/share/${vol}/dootask.lock &&"
|
||||||
@ -1003,8 +962,8 @@ if [[ "$1" == "help" ]] || [[ "$1" == "--help" ]] || [[ "$1" == "-h" ]] || [[ $#
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 非electron和permission命令需要检查Docker环境
|
# 非electron命令需要检查Docker环境
|
||||||
if [[ "$1" != "electron" ]] && [[ "$1" != "permission" ]]; then
|
if [[ "$1" != "electron" ]]; then
|
||||||
check_docker
|
check_docker
|
||||||
env_init
|
env_init
|
||||||
fi
|
fi
|
||||||
@ -1023,12 +982,6 @@ case "$1" in
|
|||||||
shift 1
|
shift 1
|
||||||
handle_uninstall
|
handle_uninstall
|
||||||
;;
|
;;
|
||||||
"permission")
|
|
||||||
shift 1
|
|
||||||
check_sudo
|
|
||||||
permission_fix
|
|
||||||
success "$(msg '项目权限修复完成')"
|
|
||||||
;;
|
|
||||||
"port")
|
"port")
|
||||||
shift 1
|
shift 1
|
||||||
check_port "$1" "$(env_get APP_PORT)"
|
check_port "$1" "$(env_get APP_PORT)"
|
||||||
|
|||||||
@ -1,25 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::table('web_socket_dialog_msgs', function (Blueprint $table) {
|
|
||||||
$table->index(
|
|
||||||
['type', 'deleted_at', 'id', 'dialog_id'],
|
|
||||||
'idx_dialog_file_lists'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::table('web_socket_dialog_msgs', function (Blueprint $table) {
|
|
||||||
$table->dropIndex('idx_dialog_file_lists');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('web_socket_dialog_msg_attachments', function (Blueprint $table) {
|
|
||||||
$table->bigIncrements('id');
|
|
||||||
$table->unsignedBigInteger('msg_id')->comment('消息ID');
|
|
||||||
$table->unsignedBigInteger('dialog_id')->comment('会话ID');
|
|
||||||
$table->string('source_type', 20)->comment('来源类型:file_message、inline_image');
|
|
||||||
$table->string('kind', 20)->comment('附件类型:file、image');
|
|
||||||
$table->unsignedSmallInteger('position')->default(0)->comment('消息内附件顺序');
|
|
||||||
$table->string('name')->default('')->comment('附件名称');
|
|
||||||
$table->string('ext', 20)->default('')->comment('文件扩展名');
|
|
||||||
$table->text('path')->nullable()->comment('原文件地址');
|
|
||||||
$table->text('thumb')->nullable()->comment('缩略图地址');
|
|
||||||
$table->unsignedBigInteger('size')->default(0)->comment('文件大小(B)');
|
|
||||||
$table->unsignedInteger('width')->default(0)->comment('图片宽度');
|
|
||||||
$table->unsignedInteger('height')->default(0)->comment('图片高度');
|
|
||||||
$table->timestamps();
|
|
||||||
|
|
||||||
$table->unique(['msg_id', 'source_type', 'position'], 'uniq_ws_msg_attachment_position');
|
|
||||||
$table->index(['dialog_id', 'id'], 'idx_ws_msg_attachment_dialog');
|
|
||||||
$table->index(['kind', 'id'], 'idx_ws_msg_attachment_kind');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('web_socket_dialog_msg_attachments');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('web_socket_dialog_msg_attachment_backfills', function (Blueprint $table) {
|
|
||||||
$table->bigIncrements('id');
|
|
||||||
$table->string('status', 20)->default('pending')->comment('状态:pending、running、failed、completed');
|
|
||||||
$table->unsignedBigInteger('before_msg_id')->default(0)->comment('启动时消息快照上界');
|
|
||||||
$table->unsignedBigInteger('last_msg_id')->default(0)->comment('已处理到的消息ID');
|
|
||||||
$table->unsignedBigInteger('processed_count')->default(0)->comment('成功处理消息数');
|
|
||||||
$table->unsignedBigInteger('attachment_count')->default(0)->comment('已索引附件数');
|
|
||||||
$table->unsignedBigInteger('empty_count')->default(0)->comment('无有效附件消息数');
|
|
||||||
$table->unsignedBigInteger('failure_count')->default(0)->comment('失败尝试次数');
|
|
||||||
$table->unsignedInteger('retry_count')->default(0)->comment('当前消息连续重试次数');
|
|
||||||
$table->text('last_error')->nullable()->comment('最近一次错误');
|
|
||||||
$table->timestamp('started_at')->nullable()->comment('首次开始时间');
|
|
||||||
$table->timestamp('completed_at')->nullable()->comment('完成时间');
|
|
||||||
$table->timestamp('next_retry_at')->nullable()->comment('下次重试时间');
|
|
||||||
$table->timestamps();
|
|
||||||
|
|
||||||
$table->index(['status', 'next_retry_at'], 'idx_ws_msg_attachment_backfill_pending');
|
|
||||||
});
|
|
||||||
|
|
||||||
$beforeMsgId = intval(DB::table('web_socket_dialog_msgs')->max('id'));
|
|
||||||
$now = now();
|
|
||||||
DB::table('web_socket_dialog_msg_attachment_backfills')->insert([
|
|
||||||
'status' => $beforeMsgId > 0 ? 'pending' : 'completed',
|
|
||||||
'before_msg_id' => $beforeMsgId,
|
|
||||||
'last_msg_id' => 0,
|
|
||||||
'completed_at' => $beforeMsgId > 0 ? null : $now,
|
|
||||||
'created_at' => $now,
|
|
||||||
'updated_at' => $now,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('web_socket_dialog_msg_attachment_backfills');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::table('web_socket_dialog_msg_attachments', function (Blueprint $table) {
|
|
||||||
// MariaDB 10.7 ignores DESC index direction, so negate msg_id to preserve mixed ordering.
|
|
||||||
$table->bigInteger('cursor_msg_id')->storedAs('-`msg_id`');
|
|
||||||
$table->index(['cursor_msg_id', 'position', 'id'], 'idx_ws_msg_attachment_cursor');
|
|
||||||
$table->index(['ext', 'msg_id'], 'idx_ws_msg_attachment_ext_msg');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
$hasCursorColumn = Schema::hasColumn('web_socket_dialog_msg_attachments', 'cursor_msg_id');
|
|
||||||
Schema::table('web_socket_dialog_msg_attachments', function (Blueprint $table) use ($hasCursorColumn) {
|
|
||||||
$table->dropIndex('idx_ws_msg_attachment_cursor');
|
|
||||||
$table->dropIndex('idx_ws_msg_attachment_ext_msg');
|
|
||||||
if ($hasCursorColumn) {
|
|
||||||
$table->dropColumn('cursor_msg_id');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -3,11 +3,11 @@
|
|||||||
> **本文件由脚本自动生成,请勿手改。**
|
> **本文件由脚本自动生成,请勿手改。**
|
||||||
>
|
>
|
||||||
> - 生成命令: `node scripts/gen-events-map.mjs`
|
> - 生成命令: `node scripts/gen-events-map.mjs`
|
||||||
> - 扫描范围: `resources/assets/js` 下所有 `.js` / `.vue` 文件(共 272 个)
|
> - 扫描范围: `resources/assets/js` 下所有 `.js` / `.vue` 文件(共 267 个)
|
||||||
> - 事件总线: `resources/assets/js/store/events.js`(mitt 实例)
|
> - 事件总线: `resources/assets/js/store/events.js`(mitt 实例)
|
||||||
> - 仅匹配裸 `emitter.emit/on/off(` 调用;`xxx.emitter.emit(`(如 Quill 内部 emitter)不属于本总线,已排除
|
> - 仅匹配裸 `emitter.emit/on/off(` 调用;`xxx.emitter.emit(`(如 Quill 内部 emitter)不属于本总线,已排除
|
||||||
|
|
||||||
共 **30** 个静态可解析事件,**124** 处 `emitter.emit/on/off` 调用。
|
共 **29** 个静态可解析事件,**121** 处 `emitter.emit/on/off` 调用。
|
||||||
|
|
||||||
## 事件清单
|
## 事件清单
|
||||||
|
|
||||||
@ -15,14 +15,14 @@
|
|||||||
|
|
||||||
- **emit(10)**
|
- **emit(10)**
|
||||||
- `resources/assets/js/App.vue:420`
|
- `resources/assets/js/App.vue:420`
|
||||||
- `resources/assets/js/pages/manage.vue:1206`
|
- `resources/assets/js/pages/manage.vue:1192`
|
||||||
- `resources/assets/js/pages/manage.vue:1213`
|
- `resources/assets/js/pages/manage.vue:1199`
|
||||||
- `resources/assets/js/pages/manage/application.vue:1195`
|
- `resources/assets/js/pages/manage/application.vue:1181`
|
||||||
- `resources/assets/js/pages/manage/application.vue:1201`
|
- `resources/assets/js/pages/manage/application.vue:1187`
|
||||||
- `resources/assets/js/pages/manage/components/ChatInput/index.vue:1882`
|
- `resources/assets/js/pages/manage/components/ChatInput/index.vue:1882`
|
||||||
- `resources/assets/js/pages/manage/components/DialogView/index.vue:621`
|
- `resources/assets/js/pages/manage/components/DialogView/index.vue:621`
|
||||||
- `resources/assets/js/pages/manage/components/DialogWrapper.vue:2040`
|
- `resources/assets/js/pages/manage/components/DialogWrapper.vue:2012`
|
||||||
- `resources/assets/js/pages/manage/components/DialogWrapper.vue:2048`
|
- `resources/assets/js/pages/manage/components/DialogWrapper.vue:2020`
|
||||||
- `resources/assets/js/pages/manage/messenger.vue:1219`
|
- `resources/assets/js/pages/manage/messenger.vue:1219`
|
||||||
- **on(1)**
|
- **on(1)**
|
||||||
- `resources/assets/js/pages/manage/components/MeetingManager/index.vue:187`
|
- `resources/assets/js/pages/manage/components/MeetingManager/index.vue:187`
|
||||||
@ -32,45 +32,36 @@
|
|||||||
### `addTask`
|
### `addTask`
|
||||||
|
|
||||||
- **emit(3)**
|
- **emit(3)**
|
||||||
- `resources/assets/js/pages/manage/calendar.vue:247`
|
- `resources/assets/js/pages/manage/calendar.vue:255`
|
||||||
- `resources/assets/js/pages/manage/components/DialogWrapper.vue:3536`
|
- `resources/assets/js/pages/manage/components/DialogWrapper.vue:3508`
|
||||||
- `resources/assets/js/pages/manage/components/ProjectPanel.vue:1357`
|
- `resources/assets/js/pages/manage/components/ProjectPanel.vue:1357`
|
||||||
- **on(1)**
|
- **on(1)**
|
||||||
- `resources/assets/js/pages/manage.vue:595`
|
- `resources/assets/js/pages/manage.vue:592`
|
||||||
- **off(1)**
|
- **off(1)**
|
||||||
- `resources/assets/js/pages/manage.vue:613`
|
- `resources/assets/js/pages/manage.vue:610`
|
||||||
|
|
||||||
### `aiAssistantClosed`
|
### `aiAssistantClosed`
|
||||||
|
|
||||||
- **emit(1)**
|
- **emit(1)**
|
||||||
- `resources/assets/js/components/AIAssistant/index.vue:447`
|
- `resources/assets/js/components/AIAssistant/index.vue:442`
|
||||||
- **on(1)**
|
- **on(1)**
|
||||||
- `resources/assets/js/components/AIAssistant/float-button.vue:159`
|
- `resources/assets/js/components/AIAssistant/float-button.vue:154`
|
||||||
- **off(1)**
|
- **off(1)**
|
||||||
- `resources/assets/js/components/AIAssistant/float-button.vue:168`
|
- `resources/assets/js/components/AIAssistant/float-button.vue:162`
|
||||||
|
|
||||||
### `aiAssistantFloatButtonVisibilityChanged`
|
|
||||||
|
|
||||||
- **emit(1)**
|
|
||||||
- `resources/assets/js/pages/manage/setting/assistant.vue:59`
|
|
||||||
- **on(1)**
|
|
||||||
- `resources/assets/js/components/AIAssistant/float-button.vue:161`
|
|
||||||
- **off(1)**
|
|
||||||
- `resources/assets/js/components/AIAssistant/float-button.vue:170`
|
|
||||||
|
|
||||||
### `aiOperationRequest`
|
### `aiOperationRequest`
|
||||||
|
|
||||||
- **emit(1)**
|
- **emit(1)**
|
||||||
- `resources/assets/js/store/actions.js:4890`
|
- `resources/assets/js/store/actions.js:4752`
|
||||||
- **on(1)**
|
- **on(1)**
|
||||||
- `resources/assets/js/components/AIAssistant/float-button.vue:160`
|
- `resources/assets/js/components/AIAssistant/float-button.vue:155`
|
||||||
- **off(1)**
|
- **off(1)**
|
||||||
- `resources/assets/js/components/AIAssistant/float-button.vue:169`
|
- `resources/assets/js/components/AIAssistant/float-button.vue:163`
|
||||||
|
|
||||||
### `clickAgainDialog`
|
### `clickAgainDialog`
|
||||||
|
|
||||||
- **emit(1)**
|
- **emit(1)**
|
||||||
- `resources/assets/js/components/Mobile/Tabbar.vue:189`
|
- `resources/assets/js/components/Mobile/Tabbar.vue:182`
|
||||||
- **on(1)**
|
- **on(1)**
|
||||||
- `resources/assets/js/pages/manage/messenger.vue:344`
|
- `resources/assets/js/pages/manage/messenger.vue:344`
|
||||||
- **off(1)**
|
- **off(1)**
|
||||||
@ -79,30 +70,30 @@
|
|||||||
### `createGroup`
|
### `createGroup`
|
||||||
|
|
||||||
- **emit(3)**
|
- **emit(3)**
|
||||||
- `resources/assets/js/pages/manage/components/DialogWrapper.vue:2894`
|
- `resources/assets/js/pages/manage/components/DialogWrapper.vue:2866`
|
||||||
- `resources/assets/js/pages/manage/components/UserDetail.vue:288`
|
- `resources/assets/js/pages/manage/components/UserDetail.vue:288`
|
||||||
- `resources/assets/js/pages/manage/messenger.vue:1224`
|
- `resources/assets/js/pages/manage/messenger.vue:1224`
|
||||||
- **on(1)**
|
- **on(1)**
|
||||||
- `resources/assets/js/pages/manage.vue:596`
|
- `resources/assets/js/pages/manage.vue:593`
|
||||||
- **off(1)**
|
- **off(1)**
|
||||||
- `resources/assets/js/pages/manage.vue:614`
|
- `resources/assets/js/pages/manage.vue:611`
|
||||||
|
|
||||||
### `dialogMsgPush`
|
### `dialogMsgPush`
|
||||||
|
|
||||||
- **emit(1)**
|
- **emit(1)**
|
||||||
- `resources/assets/js/store/actions.js:4961`
|
- `resources/assets/js/store/actions.js:4823`
|
||||||
- **on(2)**
|
- **on(2)**
|
||||||
- `resources/assets/js/components/Mobile/Tabbar.vue:50`
|
- `resources/assets/js/components/Mobile/Tabbar.vue:49`
|
||||||
- `resources/assets/js/pages/manage.vue:597`
|
- `resources/assets/js/pages/manage.vue:594`
|
||||||
- **off(2)**
|
- **off(2)**
|
||||||
- `resources/assets/js/components/Mobile/Tabbar.vue:54`
|
- `resources/assets/js/components/Mobile/Tabbar.vue:53`
|
||||||
- `resources/assets/js/pages/manage.vue:615`
|
- `resources/assets/js/pages/manage.vue:612`
|
||||||
|
|
||||||
### `handleMoveTop`
|
### `handleMoveTop`
|
||||||
|
|
||||||
- **emit(2)**
|
- **emit(2)**
|
||||||
- `resources/assets/js/store/actions.js:2810`
|
- `resources/assets/js/store/actions.js:2698`
|
||||||
- `resources/assets/js/store/actions.js:3803`
|
- `resources/assets/js/store/actions.js:3691`
|
||||||
- **on(2)**
|
- **on(2)**
|
||||||
- `resources/assets/js/pages/manage/components/DialogModal.vue:41`
|
- `resources/assets/js/pages/manage/components/DialogModal.vue:41`
|
||||||
- `resources/assets/js/pages/manage/components/TaskModal.vue:49`
|
- `resources/assets/js/pages/manage/components/TaskModal.vue:49`
|
||||||
@ -115,56 +106,56 @@
|
|||||||
- **emit(1)**
|
- **emit(1)**
|
||||||
- `resources/assets/js/components/AIAssistant/action-executor.js:234`
|
- `resources/assets/js/components/AIAssistant/action-executor.js:234`
|
||||||
- **on(1)**
|
- **on(1)**
|
||||||
- `resources/assets/js/components/MicroApps/index.vue:203`
|
- `resources/assets/js/components/MicroApps/index.vue:145`
|
||||||
- **off(1)**
|
- **off(1)**
|
||||||
- `resources/assets/js/components/MicroApps/index.vue:209`
|
- `resources/assets/js/components/MicroApps/index.vue:151`
|
||||||
|
|
||||||
### `observeMicroApp:open`
|
### `observeMicroApp:open`
|
||||||
|
|
||||||
- **emit(1)**
|
- **emit(1)**
|
||||||
- `resources/assets/js/store/actions.js:5468`
|
- `resources/assets/js/store/actions.js:5321`
|
||||||
- **on(1)**
|
- **on(1)**
|
||||||
- `resources/assets/js/components/MicroApps/index.vue:202`
|
- `resources/assets/js/components/MicroApps/index.vue:144`
|
||||||
- **off(1)**
|
- **off(1)**
|
||||||
- `resources/assets/js/components/MicroApps/index.vue:208`
|
- `resources/assets/js/components/MicroApps/index.vue:150`
|
||||||
|
|
||||||
### `observeMicroApp:updatedOrUninstalled`
|
### `observeMicroApp:updatedOrUninstalled`
|
||||||
|
|
||||||
- **emit(1)**
|
- **emit(1)**
|
||||||
- `resources/assets/js/store/mutations.js:452`
|
- `resources/assets/js/store/mutations.js:429`
|
||||||
- **on(1)**
|
- **on(1)**
|
||||||
- `resources/assets/js/components/MicroApps/index.vue:204`
|
- `resources/assets/js/components/MicroApps/index.vue:146`
|
||||||
- **off(1)**
|
- **off(1)**
|
||||||
- `resources/assets/js/components/MicroApps/index.vue:210`
|
- `resources/assets/js/components/MicroApps/index.vue:152`
|
||||||
|
|
||||||
### `openAIAssistant`
|
### `openAIAssistant`
|
||||||
|
|
||||||
- **emit(7)**
|
- **emit(7)**
|
||||||
- `resources/assets/js/components/AIAssistant/float-button.vue:508`
|
- `resources/assets/js/components/AIAssistant/float-button.vue:476`
|
||||||
- `resources/assets/js/components/SearchBox.vue:582`
|
- `resources/assets/js/components/SearchBox.vue:582`
|
||||||
- `resources/assets/js/pages/manage.vue:1237`
|
- `resources/assets/js/pages/manage.vue:1223`
|
||||||
- `resources/assets/js/pages/manage/components/ChatInput/index.vue:1925`
|
- `resources/assets/js/pages/manage/components/ChatInput/index.vue:1925`
|
||||||
- `resources/assets/js/pages/manage/components/ReportDetail.vue:176`
|
- `resources/assets/js/pages/manage/components/ReportDetail.vue:176`
|
||||||
- `resources/assets/js/pages/manage/components/ReportEdit.vue:267`
|
- `resources/assets/js/pages/manage/components/ReportEdit.vue:267`
|
||||||
- `resources/assets/js/pages/manage/components/TaskAdd.vue:710`
|
- `resources/assets/js/pages/manage/components/TaskAdd.vue:710`
|
||||||
- **on(1)**
|
- **on(1)**
|
||||||
- `resources/assets/js/components/AIAssistant/index.vue:387`
|
- `resources/assets/js/components/AIAssistant/index.vue:382`
|
||||||
- **off(1)**
|
- **off(1)**
|
||||||
- `resources/assets/js/components/AIAssistant/index.vue:394`
|
- `resources/assets/js/components/AIAssistant/index.vue:389`
|
||||||
|
|
||||||
### `openAIAssistantGlobal`
|
### `openAIAssistantGlobal`
|
||||||
|
|
||||||
- **emit(1)**
|
- **emit(1)**
|
||||||
- `resources/assets/js/pages/manage.vue:1225`
|
- `resources/assets/js/pages/manage.vue:1211`
|
||||||
- **on(1)**
|
- **on(1)**
|
||||||
- `resources/assets/js/components/AIAssistant/float-button.vue:158`
|
- `resources/assets/js/components/AIAssistant/float-button.vue:153`
|
||||||
- **off(1)**
|
- **off(1)**
|
||||||
- `resources/assets/js/components/AIAssistant/float-button.vue:167`
|
- `resources/assets/js/components/AIAssistant/float-button.vue:161`
|
||||||
|
|
||||||
### `openDownloadClient`
|
### `openDownloadClient`
|
||||||
|
|
||||||
- **emit(1)**
|
- **emit(1)**
|
||||||
- `resources/assets/js/pages/manage.vue:1106`
|
- `resources/assets/js/pages/manage.vue:1092`
|
||||||
- **on(1)**
|
- **on(1)**
|
||||||
- `resources/assets/js/components/RightBottom.vue:73`
|
- `resources/assets/js/components/RightBottom.vue:73`
|
||||||
- **off(1)**
|
- **off(1)**
|
||||||
@ -173,25 +164,25 @@
|
|||||||
### `openFavorite`
|
### `openFavorite`
|
||||||
|
|
||||||
- **emit(1)**
|
- **emit(1)**
|
||||||
- `resources/assets/js/pages/manage/application.vue:1068`
|
- `resources/assets/js/pages/manage/application.vue:1054`
|
||||||
- **on(1)**
|
- **on(1)**
|
||||||
- `resources/assets/js/pages/manage.vue:599`
|
- `resources/assets/js/pages/manage.vue:596`
|
||||||
- **off(1)**
|
- **off(1)**
|
||||||
- `resources/assets/js/pages/manage.vue:617`
|
- `resources/assets/js/pages/manage.vue:614`
|
||||||
|
|
||||||
### `openManageExport`
|
### `openManageExport`
|
||||||
|
|
||||||
- **emit(1)**
|
- **emit(1)**
|
||||||
- `resources/assets/js/pages/manage/application.vue:1115`
|
- `resources/assets/js/pages/manage/application.vue:1101`
|
||||||
- **on(1)**
|
- **on(1)**
|
||||||
- `resources/assets/js/pages/manage.vue:601`
|
- `resources/assets/js/pages/manage.vue:598`
|
||||||
- **off(1)**
|
- **off(1)**
|
||||||
- `resources/assets/js/pages/manage.vue:619`
|
- `resources/assets/js/pages/manage.vue:616`
|
||||||
|
|
||||||
### `openMobileNotification`
|
### `openMobileNotification`
|
||||||
|
|
||||||
- **emit(1)**
|
- **emit(1)**
|
||||||
- `resources/assets/js/pages/manage.vue:1611`
|
- `resources/assets/js/pages/manage.vue:1597`
|
||||||
- **on(1)**
|
- **on(1)**
|
||||||
- `resources/assets/js/components/Mobile/Notification.vue:38`
|
- `resources/assets/js/components/Mobile/Notification.vue:38`
|
||||||
- **off(1)**
|
- **off(1)**
|
||||||
@ -209,25 +200,25 @@
|
|||||||
### `openRecent`
|
### `openRecent`
|
||||||
|
|
||||||
- **emit(1)**
|
- **emit(1)**
|
||||||
- `resources/assets/js/pages/manage/application.vue:1071`
|
- `resources/assets/js/pages/manage/application.vue:1057`
|
||||||
- **on(1)**
|
- **on(1)**
|
||||||
- `resources/assets/js/pages/manage.vue:600`
|
- `resources/assets/js/pages/manage.vue:597`
|
||||||
- **off(1)**
|
- **off(1)**
|
||||||
- `resources/assets/js/pages/manage.vue:618`
|
- `resources/assets/js/pages/manage.vue:615`
|
||||||
|
|
||||||
### `openReport`
|
### `openReport`
|
||||||
|
|
||||||
- **emit(1)**
|
- **emit(1)**
|
||||||
- `resources/assets/js/pages/manage/application.vue:1065`
|
- `resources/assets/js/pages/manage/application.vue:1051`
|
||||||
- **on(1)**
|
- **on(1)**
|
||||||
- `resources/assets/js/pages/manage.vue:598`
|
- `resources/assets/js/pages/manage.vue:595`
|
||||||
- **off(1)**
|
- **off(1)**
|
||||||
- `resources/assets/js/pages/manage.vue:616`
|
- `resources/assets/js/pages/manage.vue:613`
|
||||||
|
|
||||||
### `openSearch`
|
### `openSearch`
|
||||||
|
|
||||||
- **emit(1)**
|
- **emit(1)**
|
||||||
- `resources/assets/js/pages/manage/dashboard.vue:784`
|
- `resources/assets/js/pages/manage/dashboard.vue:256`
|
||||||
- **on(1)**
|
- **on(1)**
|
||||||
- `resources/assets/js/components/SearchBox.vue:128`
|
- `resources/assets/js/components/SearchBox.vue:128`
|
||||||
- **off(1)**
|
- **off(1)**
|
||||||
@ -237,8 +228,8 @@
|
|||||||
|
|
||||||
- **emit(4)**
|
- **emit(4)**
|
||||||
- `resources/assets/js/components/UserAvatar/index.vue:184`
|
- `resources/assets/js/components/UserAvatar/index.vue:184`
|
||||||
- `resources/assets/js/pages/manage/components/DialogWrapper.vue:2963`
|
- `resources/assets/js/pages/manage/components/DialogWrapper.vue:2935`
|
||||||
- `resources/assets/js/pages/manage/components/DialogWrapper.vue:4546`
|
- `resources/assets/js/pages/manage/components/DialogWrapper.vue:4485`
|
||||||
- `resources/assets/js/pages/manage/messenger.vue:1229`
|
- `resources/assets/js/pages/manage/messenger.vue:1229`
|
||||||
- **on(1)**
|
- **on(1)**
|
||||||
- `resources/assets/js/pages/manage/components/UserDetail.vue:166`
|
- `resources/assets/js/pages/manage/components/UserDetail.vue:166`
|
||||||
@ -251,33 +242,33 @@
|
|||||||
- `resources/assets/js/pages/manage/components/ProjectPanel.vue:1775`
|
- `resources/assets/js/pages/manage/components/ProjectPanel.vue:1775`
|
||||||
- `resources/assets/js/pages/manage/components/TaskRow.vue:280`
|
- `resources/assets/js/pages/manage/components/TaskRow.vue:280`
|
||||||
- **on(1)**
|
- **on(1)**
|
||||||
- `resources/assets/js/pages/manage/components/TaskDetail.vue:744`
|
- `resources/assets/js/pages/manage/components/TaskDetail.vue:738`
|
||||||
- **off(1)**
|
- **off(1)**
|
||||||
- `resources/assets/js/pages/manage/components/TaskDetail.vue:751`
|
- `resources/assets/js/pages/manage/components/TaskDetail.vue:745`
|
||||||
|
|
||||||
### `streamMsgData`
|
### `streamMsgData`
|
||||||
|
|
||||||
- **emit(1)**
|
- **emit(1)**
|
||||||
- `resources/assets/js/store/actions.js:4578`
|
- `resources/assets/js/store/actions.js:4440`
|
||||||
- **on(1)**
|
- **on(1)**
|
||||||
- `resources/assets/js/pages/manage/components/DialogWrapper.vue:944`
|
- `resources/assets/js/pages/manage/components/DialogWrapper.vue:943`
|
||||||
- **off(1)**
|
- **off(1)**
|
||||||
- `resources/assets/js/pages/manage/components/DialogWrapper.vue:954`
|
- `resources/assets/js/pages/manage/components/DialogWrapper.vue:953`
|
||||||
|
|
||||||
### `taskRelationUpdate`
|
### `taskRelationUpdate`
|
||||||
|
|
||||||
- **emit(1)**
|
- **emit(1)**
|
||||||
- `resources/assets/js/store/actions.js:5101`
|
- `resources/assets/js/store/actions.js:4963`
|
||||||
- **on(1)**
|
- **on(1)**
|
||||||
- `resources/assets/js/pages/manage/components/TaskDetail.vue:745`
|
- `resources/assets/js/pages/manage/components/TaskDetail.vue:739`
|
||||||
- **off(1)**
|
- **off(1)**
|
||||||
- `resources/assets/js/pages/manage/components/TaskDetail.vue:752`
|
- `resources/assets/js/pages/manage/components/TaskDetail.vue:746`
|
||||||
|
|
||||||
### `updateNotification`
|
### `updateNotification`
|
||||||
|
|
||||||
- **emit(2)**
|
- **emit(2)**
|
||||||
- `resources/assets/js/pages/manage.vue:1103`
|
- `resources/assets/js/pages/manage.vue:1089`
|
||||||
- `resources/assets/js/pages/manage/setting/index.vue:206`
|
- `resources/assets/js/pages/manage/setting/index.vue:191`
|
||||||
- **on(1)**
|
- **on(1)**
|
||||||
- `resources/assets/js/components/RightBottom.vue:65`
|
- `resources/assets/js/components/RightBottom.vue:65`
|
||||||
- **off(1)**
|
- **off(1)**
|
||||||
@ -295,9 +286,9 @@
|
|||||||
### `userActive`
|
### `userActive`
|
||||||
|
|
||||||
- **emit(3)**
|
- **emit(3)**
|
||||||
- `resources/assets/js/store/actions.js:857`
|
- `resources/assets/js/store/actions.js:841`
|
||||||
- `resources/assets/js/store/actions.js:939`
|
- `resources/assets/js/store/actions.js:923`
|
||||||
- `resources/assets/js/store/actions.js:4878`
|
- `resources/assets/js/store/actions.js:4740`
|
||||||
- **on(1)**
|
- **on(1)**
|
||||||
- `resources/assets/js/components/UserAvatar/index.vue:43`
|
- `resources/assets/js/components/UserAvatar/index.vue:43`
|
||||||
- **off(1)**
|
- **off(1)**
|
||||||
@ -306,23 +297,23 @@
|
|||||||
### `websocketMsg`
|
### `websocketMsg`
|
||||||
|
|
||||||
- **emit(1)**
|
- **emit(1)**
|
||||||
- `resources/assets/js/store/actions.js:4895`
|
- `resources/assets/js/store/actions.js:4757`
|
||||||
- **on(2)**
|
- **on(2)**
|
||||||
- `resources/assets/js/pages/manage/components/DialogWrapper.vue:943`
|
- `resources/assets/js/pages/manage/components/DialogWrapper.vue:942`
|
||||||
- `resources/assets/js/pages/manage/components/FileContent.vue:202`
|
- `resources/assets/js/pages/manage/components/FileContent.vue:202`
|
||||||
- **off(2)**
|
- **off(2)**
|
||||||
- `resources/assets/js/pages/manage/components/DialogWrapper.vue:955`
|
- `resources/assets/js/pages/manage/components/DialogWrapper.vue:954`
|
||||||
- `resources/assets/js/pages/manage/components/FileContent.vue:225`
|
- `resources/assets/js/pages/manage/components/FileContent.vue:225`
|
||||||
|
|
||||||
## 动态事件名(无法静态解析)
|
## 动态事件名(无法静态解析)
|
||||||
|
|
||||||
以下调用的第一参数不是字符串字面量,无法静态解析事件名:
|
以下调用的第一参数不是字符串字面量,无法静态解析事件名:
|
||||||
|
|
||||||
- `resources/assets/js/components/MicroApps/index.vue:448` — `emitter.emit(actionName...)`
|
- `resources/assets/js/components/MicroApps/index.vue:375` — `emitter.emit(actionName...)`
|
||||||
|
|
||||||
## 统计
|
## 统计
|
||||||
|
|
||||||
- 事件总数(静态可解析): **30**
|
- 事件总数(静态可解析): **29**
|
||||||
- 只 emit 无 on(疑似死事件): **0**
|
- 只 emit 无 on(疑似死事件): **0**
|
||||||
- 只 on 无 emit(无人发射): **0**
|
- 只 on 无 emit(无人发射): **0**
|
||||||
- 动态事件名调用: **1**
|
- 动态事件名调用: **1**
|
||||||
|
|||||||
@ -7,8 +7,6 @@
|
|||||||
错误的会话
|
错误的会话
|
||||||
打开会话失败
|
打开会话失败
|
||||||
参数错误
|
参数错误
|
||||||
请选择项目
|
|
||||||
无权限访问此文件
|
|
||||||
消息不存在或已被删除
|
消息不存在或已被删除
|
||||||
请设置昵称
|
请设置昵称
|
||||||
请设置联系电话
|
请设置联系电话
|
||||||
|
|||||||
@ -2656,38 +2656,3 @@ LDAP 用户禁止修改邮箱
|
|||||||
[weekday].四
|
[weekday].四
|
||||||
[weekday].五
|
[weekday].五
|
||||||
[weekday].六
|
[weekday].六
|
||||||
AI 助手设置
|
|
||||||
显示悬浮按钮
|
|
||||||
(*) · 任务文件
|
|
||||||
(*) · 项目群聊文件
|
|
||||||
(*) · 全部文件
|
|
||||||
个人聊天文件
|
|
||||||
普通群聊文件
|
|
||||||
全部会话文件
|
|
||||||
全部协作文件
|
|
||||||
汇总项目群聊和项目下任务中的文件
|
|
||||||
来自私聊和普通群聊的文件
|
|
||||||
包含会话、项目群聊和任务中的文件
|
|
||||||
个人会话
|
|
||||||
普通群聊
|
|
||||||
昨天 (*)
|
|
||||||
会话
|
|
||||||
全部会话
|
|
||||||
私聊
|
|
||||||
选择项目
|
|
||||||
项目群聊
|
|
||||||
所有类型
|
|
||||||
文档
|
|
||||||
演示文稿
|
|
||||||
压缩包
|
|
||||||
我发送的
|
|
||||||
宫格
|
|
||||||
已加载(*)个文件
|
|
||||||
发送人
|
|
||||||
打开来源
|
|
||||||
未知成员
|
|
||||||
没有更多文件了
|
|
||||||
没有找到相关文件
|
|
||||||
协作文件
|
|
||||||
搜索名称、会话、项目或任务
|
|
||||||
聊天图片 (*)
|
|
||||||
|
|||||||
@ -38326,437 +38326,5 @@
|
|||||||
"fr": "Aucune donnée de service à afficher",
|
"fr": "Aucune donnée de service à afficher",
|
||||||
"id": "Tidak ada data departemen yang dapat dilihat",
|
"id": "Tidak ada data departemen yang dapat dilihat",
|
||||||
"ru": "Нет данных отдела для просмотра"
|
"ru": "Нет данных отдела для просмотра"
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "AI 助手设置",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "AI 助手設定",
|
|
||||||
"en": "AI Assistant Settings",
|
|
||||||
"ko": "AI 어시스턴트 설정",
|
|
||||||
"ja": "AIアシスタント設定",
|
|
||||||
"de": "KI-Assistent-Einstellungen",
|
|
||||||
"fr": "Paramètres de l’assistant IA",
|
|
||||||
"id": "Pengaturan Asisten AI",
|
|
||||||
"ru": "Настройки ИИ-помощника"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "显示悬浮按钮",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "顯示懸浮按鈕",
|
|
||||||
"en": "Show floating button",
|
|
||||||
"ko": "플로팅 버튼 표시",
|
|
||||||
"ja": "フローティングボタンを表示",
|
|
||||||
"de": "Schwebende Schaltfläche anzeigen",
|
|
||||||
"fr": "Afficher le bouton flottant",
|
|
||||||
"id": "Tampilkan tombol mengambang",
|
|
||||||
"ru": "Показывать плавающую кнопку"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "(%T1) · 任务文件",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "(%T1) · 任務文件",
|
|
||||||
"en": "(%T1) · Task files",
|
|
||||||
"ko": "(%T1) · 작업 파일",
|
|
||||||
"ja": "(%T1) · タスクファイル",
|
|
||||||
"de": "(%T1) · Aufgabendateien",
|
|
||||||
"fr": "(%T1) · Fichiers de tâche",
|
|
||||||
"id": "(%T1) · File tugas",
|
|
||||||
"ru": "(%T1) · Файлы задачи"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "(%T1) · 项目群聊文件",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "(%T1) · 項目群聊文件",
|
|
||||||
"en": "(%T1) · Project chat files",
|
|
||||||
"ko": "(%T1) · 프로젝트 채팅 파일",
|
|
||||||
"ja": "(%T1) · プロジェクトチャットファイル",
|
|
||||||
"de": "(%T1) · Projektchat-Dateien",
|
|
||||||
"fr": "(%T1) · Fichiers du chat de projet",
|
|
||||||
"id": "(%T1) · File obrolan proyek",
|
|
||||||
"ru": "(%T1) · Файлы чата проекта"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "(%T1) · 全部文件",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "(%T1) · 全部文件",
|
|
||||||
"en": "(%T1) · All files",
|
|
||||||
"ko": "(%T1) · 모든 파일",
|
|
||||||
"ja": "(%T1) · すべてのファイル",
|
|
||||||
"de": "(%T1) · Alle Dateien",
|
|
||||||
"fr": "(%T1) · Tous les fichiers",
|
|
||||||
"id": "(%T1) · Semua file",
|
|
||||||
"ru": "(%T1) · Все файлы"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "个人聊天文件",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "個人聊天文件",
|
|
||||||
"en": "Private chat files",
|
|
||||||
"ko": "개인 채팅 파일",
|
|
||||||
"ja": "個人チャットファイル",
|
|
||||||
"de": "Private Chat-Dateien",
|
|
||||||
"fr": "Fichiers de discussion privée",
|
|
||||||
"id": "File obrolan pribadi",
|
|
||||||
"ru": "Файлы личных чатов"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "普通群聊文件",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "普通群聊文件",
|
|
||||||
"en": "Group chat files",
|
|
||||||
"ko": "일반 그룹 채팅 파일",
|
|
||||||
"ja": "通常グループチャットのファイル",
|
|
||||||
"de": "Gruppenchat-Dateien",
|
|
||||||
"fr": "Fichiers de discussion de groupe",
|
|
||||||
"id": "File obrolan grup biasa",
|
|
||||||
"ru": "Файлы обычных групповых чатов"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "全部会话文件",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "全部會話文件",
|
|
||||||
"en": "All conversation files",
|
|
||||||
"ko": "모든 대화 파일",
|
|
||||||
"ja": "すべての会話ファイル",
|
|
||||||
"de": "Alle Konversationsdateien",
|
|
||||||
"fr": "Tous les fichiers de conversation",
|
|
||||||
"id": "Semua file percakapan",
|
|
||||||
"ru": "Все файлы бесед"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "全部协作文件",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "全部協作文件",
|
|
||||||
"en": "All collaboration files",
|
|
||||||
"ko": "모든 협업 파일",
|
|
||||||
"ja": "すべての共同作業ファイル",
|
|
||||||
"de": "Alle Zusammenarbeitsdateien",
|
|
||||||
"fr": "Tous les fichiers collaboratifs",
|
|
||||||
"id": "Semua file kolaborasi",
|
|
||||||
"ru": "Все совместные файлы"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "汇总项目群聊和项目下任务中的文件",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "彙總項目群聊和項目下任務中的文件",
|
|
||||||
"en": "Files from the project chat and its tasks",
|
|
||||||
"ko": "프로젝트 채팅과 프로젝트 작업의 파일 모음",
|
|
||||||
"ja": "プロジェクトチャットとプロジェクト内タスクのファイルを集約",
|
|
||||||
"de": "Dateien aus dem Projektchat und den Projektaufgaben",
|
|
||||||
"fr": "Fichiers du chat de projet et de ses tâches",
|
|
||||||
"id": "File dari obrolan proyek dan tugas di dalamnya",
|
|
||||||
"ru": "Файлы из чата проекта и его задач"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "来自私聊和普通群聊的文件",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "來自私聊和普通群聊的文件",
|
|
||||||
"en": "Files from private and group chats",
|
|
||||||
"ko": "개인 및 일반 그룹 채팅의 파일",
|
|
||||||
"ja": "個人チャットと通常グループチャットのファイル",
|
|
||||||
"de": "Dateien aus privaten Chats und Gruppenchats",
|
|
||||||
"fr": "Fichiers des discussions privées et de groupe",
|
|
||||||
"id": "File dari obrolan pribadi dan grup biasa",
|
|
||||||
"ru": "Файлы из личных и обычных групповых чатов"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "包含会话、项目群聊和任务中的文件",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "包含會話、項目群聊和任務中的文件",
|
|
||||||
"en": "Includes files from conversations, project chats, and tasks",
|
|
||||||
"ko": "대화, 프로젝트 채팅 및 작업의 파일 포함",
|
|
||||||
"ja": "会話、プロジェクトチャット、タスクのファイルを含みます",
|
|
||||||
"de": "Enthält Dateien aus Konversationen, Projektchats und Aufgaben",
|
|
||||||
"fr": "Inclut les fichiers des conversations, chats de projet et tâches",
|
|
||||||
"id": "Mencakup file dari percakapan, obrolan proyek, dan tugas",
|
|
||||||
"ru": "Включает файлы из бесед, чатов проектов и задач"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "个人会话",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "個人會話",
|
|
||||||
"en": "Private conversation",
|
|
||||||
"ko": "개인 대화",
|
|
||||||
"ja": "個人会話",
|
|
||||||
"de": "Private Konversation",
|
|
||||||
"fr": "Conversation privée",
|
|
||||||
"id": "Percakapan pribadi",
|
|
||||||
"ru": "Личная беседа"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "普通群聊",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "普通群聊",
|
|
||||||
"en": "Group chat",
|
|
||||||
"ko": "일반 그룹 채팅",
|
|
||||||
"ja": "通常グループチャット",
|
|
||||||
"de": "Gruppenchat",
|
|
||||||
"fr": "Discussion de groupe",
|
|
||||||
"id": "Obrolan grup biasa",
|
|
||||||
"ru": "Обычный групповой чат"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "昨天 (%T1)",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "昨天 (%T1)",
|
|
||||||
"en": "Yesterday (%T1)",
|
|
||||||
"ko": "어제 (%T1)",
|
|
||||||
"ja": "昨日 (%T1)",
|
|
||||||
"de": "Gestern (%T1)",
|
|
||||||
"fr": "Hier (%T1)",
|
|
||||||
"id": "Kemarin (%T1)",
|
|
||||||
"ru": "Вчера (%T1)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "会话",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "會話",
|
|
||||||
"en": "Conversations",
|
|
||||||
"ko": "대화",
|
|
||||||
"ja": "会話",
|
|
||||||
"de": "Konversationen",
|
|
||||||
"fr": "Conversations",
|
|
||||||
"id": "Percakapan",
|
|
||||||
"ru": "Беседы"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "全部会话",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "全部會話",
|
|
||||||
"en": "All conversations",
|
|
||||||
"ko": "모든 대화",
|
|
||||||
"ja": "すべての会話",
|
|
||||||
"de": "Alle Konversationen",
|
|
||||||
"fr": "Toutes les conversations",
|
|
||||||
"id": "Semua percakapan",
|
|
||||||
"ru": "Все беседы"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "私聊",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "私聊",
|
|
||||||
"en": "Private chats",
|
|
||||||
"ko": "개인 채팅",
|
|
||||||
"ja": "個人チャット",
|
|
||||||
"de": "Private Chats",
|
|
||||||
"fr": "Discussions privées",
|
|
||||||
"id": "Obrolan pribadi",
|
|
||||||
"ru": "Личные чаты"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "选择项目",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "選擇項目",
|
|
||||||
"en": "Select project",
|
|
||||||
"ko": "프로젝트 선택",
|
|
||||||
"ja": "プロジェクトを選択",
|
|
||||||
"de": "Projekt auswählen",
|
|
||||||
"fr": "Sélectionner un projet",
|
|
||||||
"id": "Pilih proyek",
|
|
||||||
"ru": "Выбрать проект"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "项目群聊",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "項目群聊",
|
|
||||||
"en": "Project chat",
|
|
||||||
"ko": "프로젝트 채팅",
|
|
||||||
"ja": "プロジェクトチャット",
|
|
||||||
"de": "Projektchat",
|
|
||||||
"fr": "Chat de projet",
|
|
||||||
"id": "Obrolan proyek",
|
|
||||||
"ru": "Чат проекта"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "所有类型",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "所有類型",
|
|
||||||
"en": "All types",
|
|
||||||
"ko": "모든 유형",
|
|
||||||
"ja": "すべての種類",
|
|
||||||
"de": "Alle Typen",
|
|
||||||
"fr": "Tous les types",
|
|
||||||
"id": "Semua jenis",
|
|
||||||
"ru": "Все типы"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "文档",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "文檔",
|
|
||||||
"en": "Documents",
|
|
||||||
"ko": "문서",
|
|
||||||
"ja": "ドキュメント",
|
|
||||||
"de": "Dokumente",
|
|
||||||
"fr": "Documents",
|
|
||||||
"id": "Dokumen",
|
|
||||||
"ru": "Документы"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "演示文稿",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "演示文稿",
|
|
||||||
"en": "Presentations",
|
|
||||||
"ko": "프레젠테이션",
|
|
||||||
"ja": "プレゼンテーション",
|
|
||||||
"de": "Präsentationen",
|
|
||||||
"fr": "Présentations",
|
|
||||||
"id": "Presentasi",
|
|
||||||
"ru": "Презентации"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "压缩包",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "壓縮包",
|
|
||||||
"en": "Archives",
|
|
||||||
"ko": "압축 파일",
|
|
||||||
"ja": "圧縮ファイル",
|
|
||||||
"de": "Archive",
|
|
||||||
"fr": "Archives",
|
|
||||||
"id": "Arsip",
|
|
||||||
"ru": "Архивы"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "我发送的",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "我發送的",
|
|
||||||
"en": "Sent by me",
|
|
||||||
"ko": "내가 보낸 파일",
|
|
||||||
"ja": "自分が送信",
|
|
||||||
"de": "Von mir gesendet",
|
|
||||||
"fr": "Envoyés par moi",
|
|
||||||
"id": "Dikirim oleh saya",
|
|
||||||
"ru": "Отправлено мной"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "宫格",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "宮格",
|
|
||||||
"en": "Grid",
|
|
||||||
"ko": "그리드",
|
|
||||||
"ja": "グリッド",
|
|
||||||
"de": "Raster",
|
|
||||||
"fr": "Grille",
|
|
||||||
"id": "Kisi",
|
|
||||||
"ru": "Сетка"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "已加载(%T1)个文件",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "已加載(%T1)個文件",
|
|
||||||
"en": "(%T1) files loaded",
|
|
||||||
"ko": "파일 (%T1)개 로드됨",
|
|
||||||
"ja": "(%T1) 件のファイルを読み込み済み",
|
|
||||||
"de": "(%T1) Dateien geladen",
|
|
||||||
"fr": "(%T1) fichiers chargés",
|
|
||||||
"id": "(%T1) file dimuat",
|
|
||||||
"ru": "Загружено файлов: (%T1)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "发送人",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "發送人",
|
|
||||||
"en": "Sender",
|
|
||||||
"ko": "보낸 사람",
|
|
||||||
"ja": "送信者",
|
|
||||||
"de": "Absender",
|
|
||||||
"fr": "Expéditeur",
|
|
||||||
"id": "Pengirim",
|
|
||||||
"ru": "Отправитель"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "打开来源",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "打開來源",
|
|
||||||
"en": "Open source",
|
|
||||||
"ko": "출처 열기",
|
|
||||||
"ja": "送信元を開く",
|
|
||||||
"de": "Quelle öffnen",
|
|
||||||
"fr": "Ouvrir la source",
|
|
||||||
"id": "Buka sumber",
|
|
||||||
"ru": "Открыть источник"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "未知成员",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "未知成員",
|
|
||||||
"en": "Unknown member",
|
|
||||||
"ko": "알 수 없는 멤버",
|
|
||||||
"ja": "不明なメンバー",
|
|
||||||
"de": "Unbekanntes Mitglied",
|
|
||||||
"fr": "Membre inconnu",
|
|
||||||
"id": "Anggota tidak dikenal",
|
|
||||||
"ru": "Неизвестный участник"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "没有更多文件了",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "沒有更多文件了",
|
|
||||||
"en": "No more files",
|
|
||||||
"ko": "더 이상 파일이 없습니다",
|
|
||||||
"ja": "これ以上ファイルはありません",
|
|
||||||
"de": "Keine weiteren Dateien",
|
|
||||||
"fr": "Aucun autre fichier",
|
|
||||||
"id": "Tidak ada file lagi",
|
|
||||||
"ru": "Больше файлов нет"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "没有找到相关文件",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "沒有找到相關文件",
|
|
||||||
"en": "No matching files found",
|
|
||||||
"ko": "관련 파일을 찾을 수 없습니다",
|
|
||||||
"ja": "該当するファイルが見つかりません",
|
|
||||||
"de": "Keine passenden Dateien gefunden",
|
|
||||||
"fr": "Aucun fichier correspondant trouvé",
|
|
||||||
"id": "Tidak ada file yang sesuai",
|
|
||||||
"ru": "Подходящие файлы не найдены"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "协作文件",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "協作文件",
|
|
||||||
"en": "Collaboration files",
|
|
||||||
"ko": "협업 파일",
|
|
||||||
"ja": "共同作業ファイル",
|
|
||||||
"de": "Zusammenarbeitsdateien",
|
|
||||||
"fr": "Fichiers collaboratifs",
|
|
||||||
"id": "File kolaborasi",
|
|
||||||
"ru": "Совместные файлы"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "搜索名称、会话、项目或任务",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "搜索名稱、會話、項目或任務",
|
|
||||||
"en": "Search names, conversations, projects, or tasks",
|
|
||||||
"ko": "이름, 대화, 프로젝트 또는 작업 검색",
|
|
||||||
"ja": "名前、会話、プロジェクト、タスクを検索",
|
|
||||||
"de": "Namen, Konversationen, Projekte oder Aufgaben suchen",
|
|
||||||
"fr": "Rechercher des noms, conversations, projets ou tâches",
|
|
||||||
"id": "Cari nama, percakapan, proyek, atau tugas",
|
|
||||||
"ru": "Поиск по названию, беседе, проекту или задаче"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "无权限访问此文件",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "無權限訪問此文件",
|
|
||||||
"en": "You do not have permission to access this file",
|
|
||||||
"ko": "이 파일에 접근할 권한이 없습니다",
|
|
||||||
"ja": "このファイルにアクセスする権限がありません",
|
|
||||||
"de": "Sie haben keine Berechtigung für diese Datei",
|
|
||||||
"fr": "Vous n’avez pas l’autorisation d’accéder à ce fichier",
|
|
||||||
"id": "Anda tidak memiliki izin untuk mengakses file ini",
|
|
||||||
"ru": "У вас нет доступа к этому файлу"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "聊天图片 (%T1)",
|
|
||||||
"zh": "",
|
|
||||||
"zh-CHT": "聊天圖片 (%T1)",
|
|
||||||
"en": "Chat image (%T1)",
|
|
||||||
"ko": "채팅 이미지 (%T1)",
|
|
||||||
"ja": "チャット画像 (%T1)",
|
|
||||||
"de": "Chatbild (%T1)",
|
|
||||||
"fr": "Image de discussion (%T1)",
|
|
||||||
"id": "Gambar obrolan (%T1)",
|
|
||||||
"ru": "Изображение из чата (%T1)"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1 +1 @@
|
|||||||
["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","",""]
|
["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","",""]
|
||||||
2
public/language/web/de.js
vendored
2
public/language/web/de.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/en.js
vendored
2
public/language/web/en.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/fr.js
vendored
2
public/language/web/fr.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/id.js
vendored
2
public/language/web/id.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/ja.js
vendored
2
public/language/web/ja.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/key.js
vendored
2
public/language/web/key.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/ko.js
vendored
2
public/language/web/ko.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/ru.js
vendored
2
public/language/web/ru.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/zh-CHT.js
vendored
2
public/language/web/zh-CHT.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/zh.js
vendored
2
public/language/web/zh.js
vendored
File diff suppressed because one or more lines are too long
@ -122,7 +122,6 @@ features:
|
|||||||
- file.public-link.howto
|
- file.public-link.howto
|
||||||
- file.preview.concept
|
- file.preview.concept
|
||||||
- file.search.howto
|
- file.search.howto
|
||||||
- file.collaboration.howto
|
|
||||||
- file.version.concept
|
- file.version.concept
|
||||||
|
|
||||||
- id: application
|
- id: application
|
||||||
@ -480,14 +479,13 @@ features:
|
|||||||
scope: end-user
|
scope: end-user
|
||||||
batch: B4
|
batch: B4
|
||||||
priority: P0
|
priority: P0
|
||||||
chunk_count_est: 13
|
chunk_count_est: 12
|
||||||
owner: ~
|
owner: ~
|
||||||
status: drafted
|
status: drafted
|
||||||
chunks:
|
chunks:
|
||||||
- user-settings.privacy.concept
|
- user-settings.privacy.concept
|
||||||
- user-settings.shortcut.concept
|
- user-settings.shortcut.concept
|
||||||
- user-settings.app-sort.howto
|
- user-settings.app-sort.howto
|
||||||
- user-settings.ai-assistant-float.howto
|
|
||||||
- user-settings.checkin.howto
|
- user-settings.checkin.howto
|
||||||
- user-settings.email.howto
|
- user-settings.email.howto
|
||||||
- user-settings.language.howto
|
- user-settings.language.howto
|
||||||
|
|||||||
@ -25,7 +25,7 @@ last_verified: v1.8.69
|
|||||||
# 仪表盘个人视角与部门负责人视角
|
# 仪表盘个人视角与部门负责人视角
|
||||||
|
|
||||||
## 定义
|
## 定义
|
||||||
DooTask 仪表盘是登录后的任务工作台,入口为左侧栏「仪表盘」。所有用户都有个人视角;系统默认开启「部门负责人视角」(管理员可关闭),且当前用户负责或管理至少一个部门时,右上角还会出现「个人视角 / 部门负责人」切换。
|
DooTask 仪表盘是登录后的任务工作台,入口为左侧栏「仪表盘」。所有用户都有个人视角;系统开启「部门负责人视角」且当前用户负责或管理至少一个部门时,右上角还会出现「个人视角 / 部门负责人」切换。
|
||||||
|
|
||||||
## 个人视角
|
## 个人视角
|
||||||
- 顶部卡片:已超期、今日到期、待完成、我协助的;待完成卡片还会提示待开始数量。
|
- 顶部卡片:已超期、今日到期、待完成、我协助的;待完成卡片还会提示待开始数量。
|
||||||
|
|||||||
@ -15,7 +15,7 @@ aliases:
|
|||||||
related_tools: []
|
related_tools: []
|
||||||
related_pages: [dashboard]
|
related_pages: [dashboard]
|
||||||
prerequisites:
|
prerequisites:
|
||||||
- 系统已开启部门负责人视角(默认开启)
|
- 系统已开启部门负责人视角
|
||||||
- 当前用户是至少一个部门的负责人或部门管理员
|
- 当前用户是至少一个部门的负责人或部门管理员
|
||||||
negative:
|
negative:
|
||||||
- 团队仪表盘不统计私密任务和子任务
|
- 团队仪表盘不统计私密任务和子任务
|
||||||
|
|||||||
@ -20,7 +20,7 @@ negative:
|
|||||||
- 一个部门只有一个部门负责人(owner_userid 字段,单值)
|
- 一个部门只有一个部门负责人(owner_userid 字段,单值)
|
||||||
- 部门管理员可以多人(user_department_owners 表)
|
- 部门管理员可以多人(user_department_owners 表)
|
||||||
- 部门负责人不会自动获得部门成员名下任务的修改权限
|
- 部门负责人不会自动获得部门成员名下任务的修改权限
|
||||||
- 系统默认开启负责人视角(管理员可关闭)后,才能使用团队仪表盘和项目只读视角
|
- 只有系统开启负责人视角后,才能使用团队仪表盘和项目只读视角
|
||||||
- 调任 / 离职部门,要由系统管理员通过部门操作完成,用户自己不能改
|
- 调任 / 离职部门,要由系统管理员通过部门操作完成,用户自己不能改
|
||||||
last_verified: v1.8.69
|
last_verified: v1.8.69
|
||||||
---
|
---
|
||||||
|
|||||||
@ -1,44 +0,0 @@
|
|||||||
---
|
|
||||||
id: file.collaboration.howto
|
|
||||||
title: 查看会话、项目和任务中的协作文件
|
|
||||||
type: howto
|
|
||||||
feature: file
|
|
||||||
scope: end-user
|
|
||||||
locale: zh
|
|
||||||
aliases:
|
|
||||||
- 怎么找聊天里发过的文件
|
|
||||||
- 查看一个项目下的所有文件
|
|
||||||
- 找私聊和群聊附件
|
|
||||||
- 汇总任务聊天文件
|
|
||||||
related_tools: [list_files]
|
|
||||||
related_pages: [file]
|
|
||||||
prerequisites:
|
|
||||||
- 当前用户对文件来源的会话、项目或任务有查看权限
|
|
||||||
negative:
|
|
||||||
- 协作文件不包含以文本链接分享的个人文件,也不把聊天表情计入图片附件
|
|
||||||
- 协作文件中不能新建、上传、移动或删除文件,需回到原会话或对应业务页面操作
|
|
||||||
last_verified: v1.8.89
|
|
||||||
---
|
|
||||||
|
|
||||||
# 查看会话、项目和任务中的协作文件
|
|
||||||
|
|
||||||
## 入口
|
|
||||||
- 桌面端和移动端:「文件」→ 顶部「协作文件」
|
|
||||||
|
|
||||||
## 筛选方式
|
|
||||||
1. 选择「全部」,查看有权访问的全部协作文件。
|
|
||||||
2. 选择「会话」,再按「全部会话 / 私聊 / 群聊」筛选;这里的群聊指不属于项目或任务的普通群聊。
|
|
||||||
3. 选择「项目」时默认查看「全部项目」,汇总有权访问的全部未归档项目文件;也可以选择一个具体项目,再按「全部 / 项目群聊 / 任务」筛选。来源筛选中的「全部」同时汇总项目群聊与任务聊天中的文件。
|
|
||||||
4. 可继续按文件类型、所有成员或我发送的内容筛选,也可搜索文件名、会话名、项目名、任务名或发送人。
|
|
||||||
5. 点击文件预览;点击来源可打开对应聊天或任务;下载按钮直接下载原文件。
|
|
||||||
|
|
||||||
文件消息和粘贴、插入到聊天正文中的图片都会汇总到协作文件。一条聊天消息包含多张图片时,每张图片会作为一条独立记录展示。图片会在列表和宫格中直接显示缩略图;缩略图不可用时显示对应的文件类型图标。
|
|
||||||
|
|
||||||
## 权限与范围
|
|
||||||
- 只展示当前用户仍有权访问的来源;退出会话、项目或任务权限变化后,对应文件不再显示。
|
|
||||||
- 已撤回或删除的文件消息、正文图片不显示;编辑消息移除图片后,对应图片也不再显示。
|
|
||||||
- 任务文件遵循任务可见性:项目成员可看全员可见任务,项目负责人、任务成员和指定可见成员按原任务权限查看。
|
|
||||||
|
|
||||||
## 不支持
|
|
||||||
- 不汇总以文本链接方式分享的个人文件。
|
|
||||||
- 协作文件是只读聚合视图,不支持新建、上传、移动、剪切或删除。
|
|
||||||
@ -16,7 +16,7 @@ related_tools: []
|
|||||||
related_pages: [dashboard, project_list]
|
related_pages: [dashboard, project_list]
|
||||||
prerequisites:
|
prerequisites:
|
||||||
- 当前用户是某部门的部门负责人或部门管理员
|
- 当前用户是某部门的部门负责人或部门管理员
|
||||||
- 系统管理员未关闭部门负责人视角(新安装默认开启)
|
- 系统管理员已开启部门负责人视角
|
||||||
negative:
|
negative:
|
||||||
- 项目负责人视角只能只读查看额外项目,不授予修改权限
|
- 项目负责人视角只能只读查看额外项目,不授予修改权限
|
||||||
- 项目可单独关闭负责人视角可见性,关闭后不会被纳入
|
- 项目可单独关闭负责人视角可见性,关闭后不会被纳入
|
||||||
|
|||||||
@ -1,45 +0,0 @@
|
|||||||
---
|
|
||||||
id: user-settings.ai-assistant-float.howto
|
|
||||||
title: 显示或隐藏 AI 助手悬浮按钮
|
|
||||||
type: howto
|
|
||||||
feature: user-settings
|
|
||||||
scope: end-user
|
|
||||||
locale: zh
|
|
||||||
aliases:
|
|
||||||
- 怎么关闭 AI 浮球
|
|
||||||
- 隐藏 AI 助手按钮
|
|
||||||
- 恢复 AI 助手浮球
|
|
||||||
- AI 悬浮按钮设置
|
|
||||||
related_tools: []
|
|
||||||
related_pages: [setting]
|
|
||||||
prerequisites:
|
|
||||||
- 应用市场已安装 ai 插件
|
|
||||||
negative:
|
|
||||||
- 隐藏悬浮按钮不会禁用 AI 助手或卸载 ai 插件
|
|
||||||
- 未安装 ai 插件时不显示「AI 助手设置」菜单
|
|
||||||
last_verified: v1.8.89
|
|
||||||
---
|
|
||||||
|
|
||||||
# 显示或隐藏 AI 助手悬浮按钮
|
|
||||||
|
|
||||||
## 入口
|
|
||||||
|
|
||||||
- 桌面端 Web / Electron:右上角头像 →「设置」→「AI 助手设置」
|
|
||||||
- 移动端:底部 Tabbar「我的」→「设置」→「AI 助手设置」
|
|
||||||
- 只有系统已安装 ai 插件时,设置菜单才会出现。
|
|
||||||
|
|
||||||
## 操作步骤
|
|
||||||
|
|
||||||
1. 进入「AI 助手设置」。
|
|
||||||
2. 打开或关闭「显示悬浮按钮」开关。
|
|
||||||
3. 点击「提交」保存并应用;点击「重置」可恢复进入页面时的值。
|
|
||||||
4. 关闭后如需恢复,返回同一页面重新打开开关并提交。
|
|
||||||
|
|
||||||
## 影响范围
|
|
||||||
|
|
||||||
该设置按当前账号保存在当前设备。关闭后只隐藏页面侧边的 AI 助手浮球,不影响右上角「+ → AI 助手」、Cmd/Ctrl + I 快捷键,也不影响项目、任务和汇报等页面中的 AI 功能。清除客户端缓存后,该开关会恢复为默认开启。
|
|
||||||
|
|
||||||
## 相关
|
|
||||||
|
|
||||||
- AI 助手的其他入口:[[menu-navigation.ai-assistant.menu-map]]
|
|
||||||
- 个人设置入口:[[user-settings.entry.menu-map]]
|
|
||||||
@ -37,7 +37,7 @@ last_verified: v1.8.69
|
|||||||
- 部门负责人视角顶部的部门按钮可打开「选择团队范围」弹窗。
|
- 部门负责人视角顶部的部门按钮可打开「选择团队范围」弹窗。
|
||||||
|
|
||||||
## 部门负责人视角出现条件
|
## 部门负责人视角出现条件
|
||||||
1. 系统设置 `department_owner_project_view` 已开启(新安装或未设置时默认开启,管理员可手动关闭)。
|
1. 系统设置 `department_owner_project_view` 已开启。
|
||||||
2. 当前用户是至少一个部门的负责人或部门管理员。
|
2. 当前用户是至少一个部门的负责人或部门管理员。
|
||||||
|
|
||||||
详细界面说明见 [[dashboard.concept]],部门范围规则见 [[dashboard.team-scope.howto]]。
|
详细界面说明见 [[dashboard.concept]],部门范围规则见 [[dashboard.team-scope.howto]]。
|
||||||
|
|||||||
@ -17,24 +17,23 @@ prerequisites: []
|
|||||||
negative:
|
negative:
|
||||||
- 文件入口不在「应用」二级页面,是左侧栏一级菜单
|
- 文件入口不在「应用」二级页面,是左侧栏一级菜单
|
||||||
- 移动端目前没有专门的「文件」Tabbar,需要从「更多」进
|
- 移动端目前没有专门的「文件」Tabbar,需要从「更多」进
|
||||||
last_verified: v1.8.89
|
last_verified: v1.8.69
|
||||||
---
|
---
|
||||||
|
|
||||||
# 文件入口在哪
|
# 文件入口在哪
|
||||||
|
|
||||||
## 路径
|
## 路径
|
||||||
DooTask 的「文件」是一级导航,集中展示个人文件、共享文件和协作场景中的文件。
|
DooTask 的「文件」是一级导航,相当于个人网盘 + 团队共享盘。
|
||||||
|
|
||||||
- 桌面端 Web:左侧栏「文件」(图标为文件夹)→ 进入个人文件根目录
|
- 桌面端 Web:左侧栏「文件」(图标为文件夹)→ 进入个人文件根目录
|
||||||
- 桌面端 Electron:同上,支持外部窗口拖入上传
|
- 桌面端 Electron:同上,支持外部窗口拖入上传
|
||||||
- 移动端:底部 Tabbar「更多」→「文件」
|
- 移动端:底部 Tabbar「更多」→「文件」
|
||||||
|
|
||||||
## 默认视图
|
## 默认视图
|
||||||
- 顶部一行是板块 Tab:「我的文件」/「共享文件」/「协作文件」
|
- 顶部一行是板块 Tab:「我的文件」/「共享文件」,同一行最右是视图切换(宫格 / 列表),窄屏自动换行
|
||||||
- 「我的文件」:我拥有的全部文件(含已共享出去的,共享不会让它从这里消失)
|
- 「我的文件」:我拥有的全部文件(含已共享出去的,共享不会让它从这里消失)
|
||||||
- 「共享文件」:双向共享——包含「我共享出去的」和「别人共享给我的」,可用「全部 / 我共享的 / 共享给我的」二次筛选
|
- 「共享文件」:双向共享——包含「我共享出去的」和「别人共享给我的」,可用「全部 / 我共享的 / 共享给我的」二次筛选
|
||||||
- 我共享出去的文件会同时出现在「我的文件」和「共享文件」两个板块
|
- 我共享出去的文件会同时出现在「我的文件」和「共享文件」两个板块
|
||||||
- 「协作文件」:汇总当前用户有权访问的私聊、普通群聊、项目群聊和任务聊天中的文件,可按会话或项目继续筛选
|
|
||||||
- 进入子文件夹后,Tab 下方出现面包屑路径(以板块名为起点,可点击回到板块根目录);根目录不显示重复标题
|
- 进入子文件夹后,Tab 下方出现面包屑路径(以板块名为起点,可点击回到板块根目录);根目录不显示重复标题
|
||||||
- 主区域列出当前文件夹下的文件与文件夹(最多 500 条,超出滚动加载)
|
- 主区域列出当前文件夹下的文件与文件夹(最多 500 条,超出滚动加载)
|
||||||
- 板块选择会记住上次停留的 Tab
|
- 板块选择会记住上次停留的 Tab
|
||||||
|
|||||||
@ -17,11 +17,10 @@ prerequisites:
|
|||||||
- 应用市场已安装 ai 插件
|
- 应用市场已安装 ai 插件
|
||||||
- 管理员已在「系统设置 → AI 设置」配置至少一个模型
|
- 管理员已在「系统设置 → AI 设置」配置至少一个模型
|
||||||
negative:
|
negative:
|
||||||
- 未安装 ai 插件时右上「+」菜单不出现「AI 助手」一项,悬浮按钮也不渲染
|
- 未安装 ai 插件时右上「+」菜单不出现「AI 助手」一项,浮动按钮也不渲染
|
||||||
- 用户可在「设置 → AI 助手设置」关闭悬浮按钮,关闭后其他入口仍可使用
|
|
||||||
- 没有左侧栏一级菜单,AI 助手是浮窗形态
|
- 没有左侧栏一级菜单,AI 助手是浮窗形态
|
||||||
- 移动端首次加载会自动收起浮按钮
|
- 移动端首次加载会自动收起浮按钮
|
||||||
last_verified: v1.8.89
|
last_verified: v1.7.90
|
||||||
---
|
---
|
||||||
|
|
||||||
# AI 助手入口在哪
|
# AI 助手入口在哪
|
||||||
@ -33,8 +32,6 @@ last_verified: v1.8.89
|
|||||||
- 移动端:屏幕侧边浮动按钮(默认贴边收起,触摸唤起)
|
- 移动端:屏幕侧边浮动按钮(默认贴边收起,触摸唤起)
|
||||||
- 桌面快捷键:Cmd/Ctrl + I
|
- 桌面快捷键:Cmd/Ctrl + I
|
||||||
|
|
||||||
悬浮按钮可在「设置 → AI 助手设置」中显示或隐藏,详见 [[user-settings.ai-assistant-float.howto]]。
|
|
||||||
|
|
||||||
## 权限要求
|
## 权限要求
|
||||||
- 任何登录用户可用
|
- 任何登录用户可用
|
||||||
- 单个会话由当前账号私有,不会跨账号
|
- 单个会话由当前账号私有,不会跨账号
|
||||||
|
|||||||
@ -34,4 +34,4 @@ last_verified: v1.8.69
|
|||||||
- 部门负责人视角:团队指标、成员任务分配、优先级分布、重点关注任务。
|
- 部门负责人视角:团队指标、成员任务分配、优先级分布、重点关注任务。
|
||||||
|
|
||||||
## 权限
|
## 权限
|
||||||
所有登录用户可访问个人视角。系统默认开启负责人视角(管理员可关闭),且用户管理至少一个部门时,才显示「部门负责人」切换。详细说明见 [[dashboard.entry.menu-map]]。
|
所有登录用户可访问个人视角。系统开启负责人视角且用户管理至少一个部门时,才显示「部门负责人」切换。详细说明见 [[dashboard.entry.menu-map]]。
|
||||||
|
|||||||
@ -18,7 +18,7 @@ prerequisites: []
|
|||||||
negative:
|
negative:
|
||||||
- 个人设置没有移动端 Tabbar 一级入口,需先进「我的」再进设置
|
- 个人设置没有移动端 Tabbar 一级入口,需先进「我的」再进设置
|
||||||
- 未登录用户没有「个人设置」入口
|
- 未登录用户没有「个人设置」入口
|
||||||
last_verified: v1.8.89
|
last_verified: v1.7.90
|
||||||
---
|
---
|
||||||
|
|
||||||
# 个人设置入口
|
# 个人设置入口
|
||||||
@ -40,7 +40,6 @@ last_verified: v1.8.89
|
|||||||
- 语言设置 → [[user-settings.language.howto]]
|
- 语言设置 → [[user-settings.language.howto]]
|
||||||
- 主题设置 → [[user-settings.theme.howto]]
|
- 主题设置 → [[user-settings.theme.howto]]
|
||||||
- 键盘设置(仅 Electron / 移动端)→ [[user-settings.shortcut.concept]]
|
- 键盘设置(仅 Electron / 移动端)→ [[user-settings.shortcut.concept]]
|
||||||
- AI 助手设置(仅安装 ai 插件后显示)→ [[user-settings.ai-assistant-float.howto]]
|
|
||||||
- 系统设置 / License Key(仅系统管理员可见)
|
- 系统设置 / License Key(仅系统管理员可见)
|
||||||
- 更新日志 / 版本 / 登录设备 / 清除缓存 / 退出登录
|
- 更新日志 / 版本 / 登录设备 / 清除缓存 / 退出登录
|
||||||
|
|
||||||
|
|||||||
@ -1,17 +0,0 @@
|
|||||||
function cacheKey(userId) {
|
|
||||||
return `aiAssistant.floatButtonVisible.${userId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadFloatButtonVisible(userId) {
|
|
||||||
if (userId <= 0) {
|
|
||||||
return Promise.resolve(true);
|
|
||||||
}
|
|
||||||
return $A.IDBBoolean(cacheKey(userId), true).catch(() => true);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveFloatButtonVisible(userId, visible) {
|
|
||||||
if (userId <= 0) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
return $A.IDBSet(cacheKey(userId), visible);
|
|
||||||
}
|
|
||||||
@ -27,7 +27,6 @@
|
|||||||
import {mapState} from "vuex";
|
import {mapState} from "vuex";
|
||||||
import emitter from "../../store/events";
|
import emitter from "../../store/events";
|
||||||
import {createOperationModule} from "./operation-module";
|
import {createOperationModule} from "./operation-module";
|
||||||
import {loadFloatButtonVisible} from "./float-button-preference";
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'AIAssistantFloatButton',
|
name: 'AIAssistantFloatButton',
|
||||||
@ -44,8 +43,6 @@ export default {
|
|||||||
},
|
},
|
||||||
dragging: false,
|
dragging: false,
|
||||||
positionLoaded: false,
|
positionLoaded: false,
|
||||||
visibilityLoaded: false,
|
|
||||||
floatButtonVisible: true,
|
|
||||||
cacheKey: 'aiAssistant.floatButtonPosition',
|
cacheKey: 'aiAssistant.floatButtonPosition',
|
||||||
btnSize: 44,
|
btnSize: 44,
|
||||||
collapsedHeight: 48, // 收起时的高度
|
collapsedHeight: 48, // 收起时的高度
|
||||||
@ -71,8 +68,6 @@ export default {
|
|||||||
return this.aiInstalled &&
|
return this.aiInstalled &&
|
||||||
this.userId > 0 &&
|
this.userId > 0 &&
|
||||||
this.positionLoaded &&
|
this.positionLoaded &&
|
||||||
this.visibilityLoaded &&
|
|
||||||
this.floatButtonVisible &&
|
|
||||||
this.routeName !== 'login' &&
|
this.routeName !== 'login' &&
|
||||||
!this.$parent?.showModal;
|
!this.$parent?.showModal;
|
||||||
},
|
},
|
||||||
@ -158,7 +153,6 @@ export default {
|
|||||||
emitter.on('openAIAssistantGlobal', this.onClick);
|
emitter.on('openAIAssistantGlobal', this.onClick);
|
||||||
emitter.on('aiAssistantClosed', this.onAssistantClosed);
|
emitter.on('aiAssistantClosed', this.onAssistantClosed);
|
||||||
emitter.on('aiOperationRequest', this.onOperationRequest);
|
emitter.on('aiOperationRequest', this.onOperationRequest);
|
||||||
emitter.on('aiAssistantFloatButtonVisibilityChanged', this.onVisibilityChanged);
|
|
||||||
this.initOperationModule();
|
this.initOperationModule();
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -167,7 +161,6 @@ export default {
|
|||||||
emitter.off('openAIAssistantGlobal', this.onClick);
|
emitter.off('openAIAssistantGlobal', this.onClick);
|
||||||
emitter.off('aiAssistantClosed', this.onAssistantClosed);
|
emitter.off('aiAssistantClosed', this.onAssistantClosed);
|
||||||
emitter.off('aiOperationRequest', this.onOperationRequest);
|
emitter.off('aiOperationRequest', this.onOperationRequest);
|
||||||
emitter.off('aiAssistantFloatButtonVisibilityChanged', this.onVisibilityChanged);
|
|
||||||
document.removeEventListener('mousemove', this.onMouseMove);
|
document.removeEventListener('mousemove', this.onMouseMove);
|
||||||
document.removeEventListener('mouseup', this.onMouseUp);
|
document.removeEventListener('mouseup', this.onMouseUp);
|
||||||
document.removeEventListener('contextmenu', this.onContextMenu);
|
document.removeEventListener('contextmenu', this.onContextMenu);
|
||||||
@ -178,32 +171,7 @@ export default {
|
|||||||
this.destroyOperationModule();
|
this.destroyOperationModule();
|
||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
|
||||||
userId: {
|
|
||||||
handler(userId) {
|
|
||||||
this.loadVisibility(userId);
|
|
||||||
},
|
|
||||||
immediate: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
async loadVisibility(userId) {
|
|
||||||
this.visibilityLoaded = false;
|
|
||||||
const visible = await loadFloatButtonVisible(userId);
|
|
||||||
if (this.userId === userId) {
|
|
||||||
this.floatButtonVisible = visible;
|
|
||||||
this.visibilityLoaded = userId > 0;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onVisibilityChanged({userId, visible}) {
|
|
||||||
if (this.userId === userId) {
|
|
||||||
this.floatButtonVisible = visible;
|
|
||||||
this.visibilityLoaded = true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载保存的位置
|
* 加载保存的位置
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,833 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="collaboration-files">
|
|
||||||
<div class="collaboration-toolbar">
|
|
||||||
<div class="scope-segment">
|
|
||||||
<button :class="{active: scope === 'all'}" @click="setScope('all')">{{$L('全部')}}</button>
|
|
||||||
<button :class="{active: scope === 'conversation'}" @click="setScope('conversation')">{{$L('会话')}}</button>
|
|
||||||
<button :class="{active: scope === 'project'}" @click="setScope('project')">{{$L('项目')}}</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="scope === 'conversation'" class="sub-segment">
|
|
||||||
<button :class="{active: conversationType === 'all'}" @click="setConversationType('all')">{{$L('全部会话')}}</button>
|
|
||||||
<button :class="{active: conversationType === 'private'}" @click="setConversationType('private')">{{$L('私聊')}}</button>
|
|
||||||
<button :class="{active: conversationType === 'group'}" @click="setConversationType('group')">{{$L('群聊')}}</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-if="scope === 'project'">
|
|
||||||
<Select
|
|
||||||
v-model="projectId"
|
|
||||||
class="project-select"
|
|
||||||
:placeholder="$L('选择项目')"
|
|
||||||
filterable
|
|
||||||
@on-change="reload"
|
|
||||||
style="max-width:auto;">
|
|
||||||
<Option :value="0">{{$L('全部项目')}}</Option>
|
|
||||||
<Option v-for="project in projects" :key="project.id" :value="project.id">{{project.name}}</Option>
|
|
||||||
</Select>
|
|
||||||
<div class="sub-segment">
|
|
||||||
<button :class="{active: projectSource === 'all'}" @click="setProjectSource('all')">{{$L('全部')}}</button>
|
|
||||||
<button :class="{active: projectSource === 'project_chat'}" @click="setProjectSource('project_chat')">{{$L('项目群聊')}}</button>
|
|
||||||
<button :class="{active: projectSource === 'task'}" @click="setProjectSource('task')">{{$L('任务')}}</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="toolbar-full"></div>
|
|
||||||
<Select v-model="fileType" class="type-select" @on-change="reload">
|
|
||||||
<Option value="all">{{$L('所有类型')}}</Option>
|
|
||||||
<Option value="document">{{$L('文档')}}</Option>
|
|
||||||
<Option value="sheet">{{$L('表格')}}</Option>
|
|
||||||
<Option value="slide">{{$L('演示文稿')}}</Option>
|
|
||||||
<Option value="image">{{$L('图片')}}</Option>
|
|
||||||
<Option value="video">{{$L('视频')}}</Option>
|
|
||||||
<Option value="archive">{{$L('压缩包')}}</Option>
|
|
||||||
<Option value="other">{{$L('其他')}}</Option>
|
|
||||||
</Select>
|
|
||||||
<Select v-model="senderScope" class="sender-select" @on-change="reload">
|
|
||||||
<Option value="all">{{$L('所有成员')}}</Option>
|
|
||||||
<Option value="mine">{{$L('我发送的')}}</Option>
|
|
||||||
</Select>
|
|
||||||
<div :class="['view-switch', {table: viewMode === 'list'}]">
|
|
||||||
<div :title="$L('宫格')" @click="viewMode='grid'"><i class="taskfont"></i></div>
|
|
||||||
<div :title="$L('列表')" @click="viewMode='list'"><i class="taskfont"></i></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="collaboration-summary">
|
|
||||||
<div class="summary-icon"><Icon :type="summaryIcon"/></div>
|
|
||||||
<div class="summary-text">
|
|
||||||
<strong>{{summaryTitle}}</strong>
|
|
||||||
<span>{{summarySubtitle}}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="items.length" class="loaded-count">{{$L('已加载(*)个文件', items.length)}}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ref="scroller" class="collaboration-scroll" @scroll="onScroll">
|
|
||||||
<div v-if="initializing || (loading && items.length === 0)" class="initial-loading"><Loading/></div>
|
|
||||||
<template v-else-if="items.length">
|
|
||||||
<div v-if="viewMode === 'list'" class="collaboration-table">
|
|
||||||
<div class="table-head">
|
|
||||||
<span>{{$L('文件名')}}</span>
|
|
||||||
<span>{{$L('来源')}}</span>
|
|
||||||
<span>{{$L('发送人')}}</span>
|
|
||||||
<span>{{$L('时间')}}</span>
|
|
||||||
<span>{{$L('大小')}}</span>
|
|
||||||
<span>{{$L('操作')}}</span>
|
|
||||||
</div>
|
|
||||||
<div v-for="item in items" :key="itemKey(item)" class="table-row">
|
|
||||||
<div class="file-main" @click="preview(item)">
|
|
||||||
<div class="collaboration-file-preview">
|
|
||||||
<img
|
|
||||||
v-if="showThumbnail(item)"
|
|
||||||
class="collaboration-thumbnail"
|
|
||||||
:src="item.image_url"
|
|
||||||
alt=""
|
|
||||||
@error.stop="handleThumbnailError(item)"/>
|
|
||||||
<div v-else :class="['no-dark-content', 'collaboration-file-icon', fileIconType(item)]"></div>
|
|
||||||
</div>
|
|
||||||
<div class="file-text">
|
|
||||||
<AutoTip class="file-title">{{fileName(item)}}</AutoTip>
|
|
||||||
<span>{{fileTypeText(item)}}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="source-main" @click="openSource(item)">
|
|
||||||
<div class="source-text">
|
|
||||||
<span :class="['source-type', item.source_type]">{{sourceTypeText(item)}}</span>
|
|
||||||
<span class="source-name" :title="sourcePath(item)">{{item.source_name}}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<UserAvatar :userid="item.sender.userid" :size="24" showName/>
|
|
||||||
<span class="time-text">{{formatTime(item.created_at)}}</span>
|
|
||||||
<span class="size-text">{{$A.bytesToSize(item.size)}}</span>
|
|
||||||
<div class="row-actions">
|
|
||||||
<ETooltip :content="$L('打开来源')"><button @click="locateMessage(item)"><Icon type="md-open"/></button></ETooltip>
|
|
||||||
<ETooltip :content="$L('下载')"><button @click="download(item)"><Icon type="md-download"/></button></ETooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="collaboration-grid">
|
|
||||||
<div v-for="item in items" :key="itemKey(item)" class="grid-item" @click="preview(item)">
|
|
||||||
<div class="grid-preview">
|
|
||||||
<img
|
|
||||||
v-if="showThumbnail(item)"
|
|
||||||
class="collaboration-thumbnail"
|
|
||||||
:src="item.image_url"
|
|
||||||
alt=""
|
|
||||||
@error.stop="handleThumbnailError(item)"/>
|
|
||||||
<div v-else :class="['no-dark-content', 'collaboration-file-icon', fileIconType(item)]"></div>
|
|
||||||
</div>
|
|
||||||
<AutoTip class="grid-title">{{fileName(item)}}</AutoTip>
|
|
||||||
<div class="grid-source">
|
|
||||||
<span :class="['source-type', item.source_type]">{{sourceTypeText(item)}}</span>
|
|
||||||
<AutoTip class="grid-source-name" :content="sourcePath(item)">{{item.source_name}}</AutoTip>
|
|
||||||
</div>
|
|
||||||
<div class="grid-meta">{{item.sender.nickname || $L('未知成员')}} · {{formatTime(item.created_at)}}</div>
|
|
||||||
<div class="grid-actions" @click.stop>
|
|
||||||
<button :title="$L('打开来源')" @click="locateMessage(item)"><Icon type="md-open"/></button>
|
|
||||||
<button :title="$L('下载')" @click="download(item)"><Icon type="md-download"/></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="loading" class="load-more"><Loading/></div>
|
|
||||||
<div v-else-if="!hasMore" class="list-end">{{$L('没有更多文件了')}}</div>
|
|
||||||
</template>
|
|
||||||
<div v-else class="empty-state">
|
|
||||||
<Icon type="ios-folder-open-outline"/>
|
|
||||||
<p>{{$L('没有找到相关文件')}}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import {mapState} from "vuex";
|
|
||||||
import {openFileInClient} from "../../../utils/file";
|
|
||||||
|
|
||||||
const CACHE_VERSION = 1;
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: "CollaborationFileList",
|
|
||||||
props: {
|
|
||||||
searchKey: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
scope: 'all',
|
|
||||||
conversationType: 'all',
|
|
||||||
projectId: 0,
|
|
||||||
projectSource: 'all',
|
|
||||||
fileType: 'all',
|
|
||||||
senderScope: 'all',
|
|
||||||
viewMode: this.$store.state.collaborationFileViewMode === 'grid' ? 'grid' : 'list',
|
|
||||||
items: [],
|
|
||||||
cursor: 0,
|
|
||||||
hasMore: false,
|
|
||||||
loading: 0,
|
|
||||||
initializing: true,
|
|
||||||
searchTimer: null,
|
|
||||||
requestId: 0,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapState(['cacheProjects', 'collaborationFileCache', 'userId']),
|
|
||||||
projects() {
|
|
||||||
return this.cacheProjects
|
|
||||||
.filter(project => !project.deleted_at && !project.archived_at)
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
},
|
|
||||||
selectedProject() {
|
|
||||||
return this.projects.find(project => project.id == this.projectId);
|
|
||||||
},
|
|
||||||
summaryIcon() {
|
|
||||||
if (this.scope === 'project') return 'ios-briefcase-outline';
|
|
||||||
if (this.scope === 'conversation') return 'ios-chatbubbles-outline';
|
|
||||||
return 'ios-git-merge';
|
|
||||||
},
|
|
||||||
summaryTitle() {
|
|
||||||
if (this.scope === 'project') {
|
|
||||||
const name = this.selectedProject?.name || this.$L('全部项目');
|
|
||||||
if (this.projectSource === 'task') return this.$L('(*) · 任务文件', name);
|
|
||||||
if (this.projectSource === 'project_chat') return this.$L('(*) · 项目群聊文件', name);
|
|
||||||
return this.$L('(*) · 全部文件', name);
|
|
||||||
}
|
|
||||||
if (this.scope === 'conversation') {
|
|
||||||
if (this.conversationType === 'private') return this.$L('个人聊天文件');
|
|
||||||
if (this.conversationType === 'group') return this.$L('普通群聊文件');
|
|
||||||
return this.$L('全部会话文件');
|
|
||||||
}
|
|
||||||
return this.$L('全部协作文件');
|
|
||||||
},
|
|
||||||
summarySubtitle() {
|
|
||||||
if (this.scope === 'project') return this.$L('汇总项目群聊和项目下任务中的文件');
|
|
||||||
if (this.scope === 'conversation') return this.$L('来自私聊和普通群聊的文件');
|
|
||||||
return this.$L('包含会话、项目群聊和任务中的文件');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
searchKey() {
|
|
||||||
clearTimeout(this.searchTimer);
|
|
||||||
this.searchTimer = setTimeout(() => this.reload(), 400);
|
|
||||||
},
|
|
||||||
viewMode(value) {
|
|
||||||
this.$store.commit('collaboration/file/view/save', value);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
created() {
|
|
||||||
if (this.restoreCache(this.collaborationFileCache)) {
|
|
||||||
this.initializing = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.$store.dispatch('getProjects').catch(() => {});
|
|
||||||
this.refresh();
|
|
||||||
this.initializing = false;
|
|
||||||
},
|
|
||||||
beforeDestroy() {
|
|
||||||
clearTimeout(this.searchTimer);
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
setScope(scope) {
|
|
||||||
if (this.scope === scope) return;
|
|
||||||
this.scope = scope;
|
|
||||||
this.reload();
|
|
||||||
},
|
|
||||||
setConversationType(type) {
|
|
||||||
if (this.conversationType === type) return;
|
|
||||||
this.conversationType = type;
|
|
||||||
this.reload();
|
|
||||||
},
|
|
||||||
setProjectSource(source) {
|
|
||||||
if (this.projectSource === source) return;
|
|
||||||
this.projectSource = source;
|
|
||||||
this.reload();
|
|
||||||
},
|
|
||||||
reload() {
|
|
||||||
this.loadFirstPage(false);
|
|
||||||
},
|
|
||||||
refresh() {
|
|
||||||
this.loadFirstPage(true);
|
|
||||||
},
|
|
||||||
loadFirstPage(keepItems) {
|
|
||||||
this.requestId++;
|
|
||||||
this.loading = 0;
|
|
||||||
if (!keepItems) {
|
|
||||||
this.items = [];
|
|
||||||
}
|
|
||||||
this.cursor = 0;
|
|
||||||
this.hasMore = false;
|
|
||||||
this.load(true);
|
|
||||||
},
|
|
||||||
load(replace = false) {
|
|
||||||
if (this.loading || (this.cursor && !this.hasMore)) return;
|
|
||||||
const requestId = ++this.requestId;
|
|
||||||
const requestCursor = replace ? 0 : this.cursor;
|
|
||||||
this.loading++;
|
|
||||||
this.$store.dispatch('call', {
|
|
||||||
url: 'file/collaboration/lists',
|
|
||||||
data: {
|
|
||||||
scope: this.scope,
|
|
||||||
conversation_type: this.conversationType,
|
|
||||||
project_id: this.projectId,
|
|
||||||
project_source: this.projectSource,
|
|
||||||
file_type: this.fileType,
|
|
||||||
sender_id: this.senderScope === 'mine' ? this.userId : 0,
|
|
||||||
key: this.searchKey.trim(),
|
|
||||||
cursor: requestCursor,
|
|
||||||
take: 50,
|
|
||||||
},
|
|
||||||
}).then(({data}) => {
|
|
||||||
if (requestId !== this.requestId) return;
|
|
||||||
if (replace) {
|
|
||||||
this.items = data.list;
|
|
||||||
} else {
|
|
||||||
this.items.push(...data.list);
|
|
||||||
}
|
|
||||||
this.cursor = data.next_cursor;
|
|
||||||
this.hasMore = data.has_more;
|
|
||||||
if (replace && !this.searchKey.trim()) {
|
|
||||||
this.saveCache(data);
|
|
||||||
}
|
|
||||||
}).catch(({msg}) => {
|
|
||||||
if (msg) $A.modalError(msg);
|
|
||||||
}).finally(() => {
|
|
||||||
if (requestId === this.requestId) {
|
|
||||||
this.loading--;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
cacheParams() {
|
|
||||||
return {
|
|
||||||
scope: this.scope,
|
|
||||||
conversationType: this.conversationType,
|
|
||||||
projectId: this.projectId,
|
|
||||||
projectSource: this.projectSource,
|
|
||||||
fileType: this.fileType,
|
|
||||||
senderScope: this.senderScope,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
restoreCache(cache) {
|
|
||||||
if (!$A.isJson(cache)
|
|
||||||
|| cache.version !== CACHE_VERSION
|
|
||||||
|| cache.userId !== this.userId
|
|
||||||
|| !$A.isJson(cache.params)
|
|
||||||
|| !$A.isArray(cache.list)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = cache.params;
|
|
||||||
if (!['all', 'conversation', 'project'].includes(params.scope)
|
|
||||||
|| !['all', 'private', 'group'].includes(params.conversationType)
|
|
||||||
|| !['all', 'project_chat', 'task'].includes(params.projectSource)
|
|
||||||
|| !['all', 'document', 'sheet', 'slide', 'image', 'video', 'archive', 'other'].includes(params.fileType)
|
|
||||||
|| !['all', 'mine'].includes(params.senderScope)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.scope = params.scope;
|
|
||||||
this.conversationType = params.conversationType;
|
|
||||||
this.projectId = Math.max(0, parseInt(params.projectId) || 0);
|
|
||||||
this.projectSource = params.projectSource;
|
|
||||||
this.fileType = params.fileType;
|
|
||||||
this.senderScope = params.senderScope;
|
|
||||||
if (!this.searchKey.trim()) {
|
|
||||||
this.items = cache.list;
|
|
||||||
this.cursor = cache.next_cursor || 0;
|
|
||||||
this.hasMore = cache.has_more === true;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
saveCache(data) {
|
|
||||||
this.$store.commit('collaboration/file/cache/save', {
|
|
||||||
version: CACHE_VERSION,
|
|
||||||
userId: this.userId,
|
|
||||||
params: this.cacheParams(),
|
|
||||||
list: data.list,
|
|
||||||
next_cursor: data.next_cursor,
|
|
||||||
has_more: data.has_more,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onScroll(event) {
|
|
||||||
const target = event.target;
|
|
||||||
if (target.scrollHeight - target.scrollTop - target.clientHeight < 180 && this.hasMore) {
|
|
||||||
this.load();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fileIconType(item) {
|
|
||||||
if (item.file_type === 'sheet') return 'excel';
|
|
||||||
if (item.file_type === 'slide') return 'ppt';
|
|
||||||
if (item.file_type === 'document') return item.ext === 'pdf' ? 'pdf' : 'word';
|
|
||||||
if (item.file_type === 'image') return 'picture';
|
|
||||||
if (item.file_type === 'video') return 'media';
|
|
||||||
if (item.file_type === 'archive') return 'archive';
|
|
||||||
return 'file';
|
|
||||||
},
|
|
||||||
itemKey(item) {
|
|
||||||
return item.attachment_id ? `attachment-${item.attachment_id}` : `message-${item.msg_id}`;
|
|
||||||
},
|
|
||||||
showThumbnail(item) {
|
|
||||||
return !!item.image_url && !item._thumbnailError;
|
|
||||||
},
|
|
||||||
handleThumbnailError(item) {
|
|
||||||
this.$set(item, '_thumbnailError', true);
|
|
||||||
},
|
|
||||||
fileTypeText(item) {
|
|
||||||
const labels = {
|
|
||||||
document: '文档',
|
|
||||||
sheet: '表格',
|
|
||||||
slide: '演示文稿',
|
|
||||||
image: '图片',
|
|
||||||
video: '视频',
|
|
||||||
archive: '压缩包',
|
|
||||||
other: '其他',
|
|
||||||
};
|
|
||||||
return `${this.$L(labels[item.file_type] || '其他')} · ${(item.ext || '').toUpperCase()}`;
|
|
||||||
},
|
|
||||||
fileName(item) {
|
|
||||||
if (item.attachment_source === 'inline_image' && item.generated_name) {
|
|
||||||
const time = $A.dayjs(item.created_at).format('YYYY-MM-DD HH:mm');
|
|
||||||
return this.$L('聊天图片 (*)', `${time} #${(item.attachment_position || 0) + 1}`);
|
|
||||||
}
|
|
||||||
return item.name;
|
|
||||||
},
|
|
||||||
sourceTypeText(item) {
|
|
||||||
const labels = {
|
|
||||||
private: '私聊',
|
|
||||||
group: '群聊',
|
|
||||||
project_chat: '项目群聊',
|
|
||||||
task: '任务',
|
|
||||||
};
|
|
||||||
return this.$L(labels[item.source_type] || '群聊');
|
|
||||||
},
|
|
||||||
sourcePath(item) {
|
|
||||||
if (item.source_type === 'task') return `${item.project_name} / ${item.task_name}`;
|
|
||||||
if (item.source_type === 'project_chat') return item.project_name;
|
|
||||||
return item.source_type === 'private' ? this.$L('个人会话') : this.$L('普通群聊');
|
|
||||||
},
|
|
||||||
formatTime(time) {
|
|
||||||
if (!time) return '-';
|
|
||||||
const value = $A.dayjs(time);
|
|
||||||
if (value.isSame($A.dayjs(), 'day')) return value.format('HH:mm');
|
|
||||||
if (value.isSame($A.dayjs().subtract(1, 'day'), 'day')) return this.$L('昨天 (*)', value.format('HH:mm'));
|
|
||||||
return value.format('YYYY-MM-DD HH:mm');
|
|
||||||
},
|
|
||||||
preview(item) {
|
|
||||||
if (item.attachment_source === 'inline_image' && item.image_url) {
|
|
||||||
this.$store.dispatch('previewImage', item.image_url);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
openFileInClient(this, item, {
|
|
||||||
path: `/single/file/msg/${item.msg_id}`,
|
|
||||||
windowName: `file-msg-${item.msg_id}`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
openSource(item) {
|
|
||||||
if (item.source_type === 'task' && item.task_id) {
|
|
||||||
this.$store.dispatch('openTask', {id: item.task_id, project_id: item.project_id});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.locateMessage(item);
|
|
||||||
},
|
|
||||||
locateMessage(item) {
|
|
||||||
this.$store.dispatch('openDialog', {
|
|
||||||
dialog_id: item.dialog_id,
|
|
||||||
search_msg_id: item.msg_id,
|
|
||||||
}).catch(({msg}) => msg && $A.modalError(msg));
|
|
||||||
},
|
|
||||||
download(item) {
|
|
||||||
const url = item.attachment_id
|
|
||||||
? `file/collaboration/download?attachment_id=${item.attachment_id}`
|
|
||||||
: `dialog/msg/download?msg_id=${item.msg_id}`;
|
|
||||||
$A.modalConfirm({
|
|
||||||
language: false,
|
|
||||||
title: this.$L('下载文件'),
|
|
||||||
okText: this.$L('立即下载'),
|
|
||||||
content: `${this.fileName(item)} (${$A.bytesToSize(item.size)})`,
|
|
||||||
onOk: () => this.$store.dispatch('downUrl', $A.apiUrl(url)),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
@import "../../../../sass/var";
|
|
||||||
|
|
||||||
.collaboration-files {
|
|
||||||
flex: 1;
|
|
||||||
height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.collaboration-toolbar {
|
|
||||||
min-height: 58px;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
margin: 0 32px;
|
|
||||||
padding: 10px 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border-bottom: 1px solid #e8eaec;
|
|
||||||
.toolbar-full { flex: 1; }
|
|
||||||
.project-select { width: 180px; }
|
|
||||||
.type-select { width: 118px; }
|
|
||||||
.sender-select { width: 118px; }
|
|
||||||
::v-deep .project-select .ivu-select-selection {
|
|
||||||
height: 32px;
|
|
||||||
min-height: 32px;
|
|
||||||
}
|
|
||||||
::v-deep .project-select .ivu-select-selected-value,
|
|
||||||
::v-deep .project-select .ivu-select-placeholder {
|
|
||||||
height: 30px;
|
|
||||||
line-height: 30px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.scope-segment {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
width: max-content;
|
|
||||||
height: 32px;
|
|
||||||
padding: 2px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border-radius: 7px;
|
|
||||||
background: #f5f6f7;
|
|
||||||
button {
|
|
||||||
min-width: 58px;
|
|
||||||
height: 28px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 5px;
|
|
||||||
white-space: nowrap;
|
|
||||||
color: $primary-text-color;
|
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
&.active {
|
|
||||||
color: $primary-color;
|
|
||||||
background: #fff;
|
|
||||||
box-shadow: 0 1px 5px rgba(31, 44, 58, 0.12);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.sub-segment {
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
button {
|
|
||||||
flex-shrink: 0;
|
|
||||||
height: 30px;
|
|
||||||
padding: 0 11px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 15px;
|
|
||||||
white-space: nowrap;
|
|
||||||
color: $primary-text-color;
|
|
||||||
background: rgba(0, 0, 0, 0.03);
|
|
||||||
cursor: pointer;
|
|
||||||
&.active {
|
|
||||||
color: $primary-color;
|
|
||||||
border-color: rgba($primary-color, 0.45);
|
|
||||||
background: rgba($primary-color, 0.07);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.view-switch {
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
position: relative;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: #fff;
|
|
||||||
transition: box-shadow 0.2s;
|
|
||||||
&:hover { box-shadow: 0 0 10px #e6ecfa; }
|
|
||||||
&:before {
|
|
||||||
content: "";
|
|
||||||
width: 50%;
|
|
||||||
height: 100%;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: 0;
|
|
||||||
border: 1px solid $primary-color;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: rgba($primary-color, 0.1);
|
|
||||||
transition: left 0.2s;
|
|
||||||
}
|
|
||||||
> div {
|
|
||||||
z-index: 1;
|
|
||||||
width: 32px;
|
|
||||||
height: 30px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: $primary-text-color;
|
|
||||||
cursor: pointer;
|
|
||||||
&:first-child { color: $primary-color; }
|
|
||||||
i { font-size: 17px; }
|
|
||||||
}
|
|
||||||
&.table {
|
|
||||||
&:before { left: 50%; }
|
|
||||||
> div:first-child { color: $primary-text-color; }
|
|
||||||
> div:last-child { color: $primary-color; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.collaboration-summary {
|
|
||||||
min-height: 70px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin: 0 32px;
|
|
||||||
border-bottom: 1px solid #e8eaec;
|
|
||||||
.summary-icon {
|
|
||||||
width: 38px;
|
|
||||||
height: 38px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-right: 12px;
|
|
||||||
border-radius: 7px;
|
|
||||||
color: $primary-color;
|
|
||||||
background: rgba($primary-color, 0.1);
|
|
||||||
i { font-size: 20px; }
|
|
||||||
}
|
|
||||||
.summary-text {
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
strong { font-size: 16px; color: $primary-title-color; }
|
|
||||||
span { margin-top: 4px; font-size: 12px; color: $primary-desc-color; }
|
|
||||||
}
|
|
||||||
.loaded-count { margin-left: auto; color: $primary-desc-color; font-size: 12px; }
|
|
||||||
}
|
|
||||||
.collaboration-scroll {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: auto;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.initial-loading {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 260px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.collaboration-table {
|
|
||||||
margin: 0 32px;
|
|
||||||
}
|
|
||||||
.table-head,
|
|
||||||
.table-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(260px, 1.7fr) minmax(210px, 1.2fr) 130px 150px 90px 80px;
|
|
||||||
align-items: center;
|
|
||||||
column-gap: 16px;
|
|
||||||
padding: 0 12px;
|
|
||||||
}
|
|
||||||
.table-head {
|
|
||||||
height: 44px;
|
|
||||||
color: $primary-desc-color;
|
|
||||||
font-size: 12px;
|
|
||||||
border-bottom: 1px solid #e8eaec;
|
|
||||||
span:last-child { text-align: right; }
|
|
||||||
}
|
|
||||||
.table-row {
|
|
||||||
min-height: 70px;
|
|
||||||
border-bottom: 1px solid #e8eaec;
|
|
||||||
transition: background 0.15s;
|
|
||||||
&:hover { background: rgba($primary-color, 0.025); }
|
|
||||||
}
|
|
||||||
.file-main,
|
|
||||||
.source-main {
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.collaboration-file-preview {
|
|
||||||
width: 38px;
|
|
||||||
height: 38px;
|
|
||||||
flex: none;
|
|
||||||
margin-right: 11px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 5px;
|
|
||||||
.collaboration-file-icon {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.collaboration-thumbnail {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: block;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
.collaboration-file-icon {
|
|
||||||
flex: none;
|
|
||||||
background: center / contain no-repeat url("/images/file/light/other.svg");
|
|
||||||
&.archive { background-image: url("/images/file/light/archive.svg"); }
|
|
||||||
&.excel { background-image: url("/images/file/light/excel.svg"); }
|
|
||||||
&.media { background-image: url("/images/file/light/media.svg"); }
|
|
||||||
&.pdf { background-image: url("/images/file/light/pdf.svg"); }
|
|
||||||
&.picture { background-image: url("/images/file/light/picture.svg"); }
|
|
||||||
&.ppt { background-image: url("/images/file/light/ppt.svg"); }
|
|
||||||
&.word { background-image: url("/images/file/light/word.svg"); }
|
|
||||||
}
|
|
||||||
.file-text,
|
|
||||||
.source-text {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
.file-title { color: $primary-title-color; font-weight: 500; }
|
|
||||||
> span { margin-top: 4px; color: $primary-desc-color; font-size: 11px; }
|
|
||||||
}
|
|
||||||
.source-text {
|
|
||||||
max-height: 40px;
|
|
||||||
overflow: hidden;
|
|
||||||
.source-type,
|
|
||||||
.source-name {
|
|
||||||
display: block;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.source-type {
|
|
||||||
margin-top: 0;
|
|
||||||
color: #5276b3;
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 16px;
|
|
||||||
&.group { color: #705bb1; }
|
|
||||||
&.project_chat { color: #aa6d2e; }
|
|
||||||
&.task { color: $primary-color; }
|
|
||||||
}
|
|
||||||
.source-name {
|
|
||||||
margin-top: 0;
|
|
||||||
color: $primary-title-color;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.time-text,
|
|
||||||
.size-text { color: $primary-text-color; font-size: 12px; }
|
|
||||||
.row-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
button,
|
|
||||||
.ivu-tooltip-rel button {
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 5px;
|
|
||||||
color: $primary-text-color;
|
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
&:hover { color: $primary-color; background: rgba($primary-color, 0.08); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.collaboration-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
margin: 0 32px;
|
|
||||||
padding: 16px 0;
|
|
||||||
}
|
|
||||||
.grid-item {
|
|
||||||
min-height: 164px;
|
|
||||||
padding: 14px;
|
|
||||||
position: relative;
|
|
||||||
border: 1px solid #e8eaec;
|
|
||||||
border-radius: 7px;
|
|
||||||
cursor: pointer;
|
|
||||||
&:hover {
|
|
||||||
border-color: rgba($primary-color, 0.55);
|
|
||||||
.grid-actions { opacity: 1; }
|
|
||||||
}
|
|
||||||
.grid-preview {
|
|
||||||
width: 42px;
|
|
||||||
height: 42px;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 5px;
|
|
||||||
.collaboration-file-icon {
|
|
||||||
width: 38px;
|
|
||||||
height: 38px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.grid-title { display: block; margin-top: 12px; color: $primary-title-color; font-weight: 500; }
|
|
||||||
.grid-source {
|
|
||||||
min-width: 0;
|
|
||||||
margin-top: 8px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
.source-type {
|
|
||||||
color: #5276b3;
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 16px;
|
|
||||||
&.group { color: #705bb1; }
|
|
||||||
&.project_chat { color: #aa6d2e; }
|
|
||||||
&.task { color: $primary-color; }
|
|
||||||
}
|
|
||||||
.grid-source-name { max-width: 100%; color: $primary-text-color; line-height: 20px; }
|
|
||||||
}
|
|
||||||
.grid-meta { margin-top: 8px; color: $primary-desc-color; font-size: 11px; }
|
|
||||||
.grid-actions {
|
|
||||||
opacity: 0;
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
right: 8px;
|
|
||||||
display: flex;
|
|
||||||
button { width: 28px; height: 28px; border: 0; color: $primary-text-color; background: transparent; cursor: pointer; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.empty-state {
|
|
||||||
height: 100%;
|
|
||||||
min-height: 260px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-direction: column;
|
|
||||||
color: $primary-desc-color;
|
|
||||||
i { font-size: 64px; }
|
|
||||||
p { margin-top: 14px; }
|
|
||||||
}
|
|
||||||
.load-more,
|
|
||||||
.list-end {
|
|
||||||
height: 48px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: $primary-desc-color;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.collaboration-toolbar {
|
|
||||||
margin: 0 16px;
|
|
||||||
}
|
|
||||||
.collaboration-summary,
|
|
||||||
.collaboration-table,
|
|
||||||
.collaboration-grid { margin-right: 16px; margin-left: 16px; }
|
|
||||||
.toolbar-full { display: none; }
|
|
||||||
.project-select { flex: 1; min-width: 160px; }
|
|
||||||
.table-head { display: none; }
|
|
||||||
.table-row {
|
|
||||||
grid-template-columns: minmax(0, 1fr) 72px;
|
|
||||||
gap: 6px 10px;
|
|
||||||
padding: 12px 4px;
|
|
||||||
.source-main { grid-column: 1; padding-left: 49px; }
|
|
||||||
> .avatar-wrapper,
|
|
||||||
> .time-text,
|
|
||||||
> .size-text { display: none; }
|
|
||||||
.row-actions { grid-column: 2; grid-row: 1 / 3; }
|
|
||||||
}
|
|
||||||
.collaboration-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
|
|
||||||
.loaded-count { display: none; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,13 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-dashboard">
|
<div class="page-dashboard">
|
||||||
<PageTitle :title="$L('仪表盘')"/>
|
<PageTitle :title="$L('仪表盘')"/>
|
||||||
<Scrollbar class="dashboard-scroller">
|
<Alert v-if="warningMsg" class="dashboard-warning" type="warning" show-icon>
|
||||||
<!--系统信息-->
|
|
||||||
<div v-if="!!warningMsg" class="dashboard-warning">
|
|
||||||
<Alert type="warning" show-icon>
|
|
||||||
<span @click="goForward({name: 'manage-setting-license'})">{{warningMsg}}</span>
|
<span @click="goForward({name: 'manage-setting-license'})">{{warningMsg}}</span>
|
||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
<Scrollbar class="dashboard-scroller">
|
||||||
<div class="dashboard-body">
|
<div class="dashboard-body">
|
||||||
<!--头部-->
|
<!--头部-->
|
||||||
<div class="dashboard-header">
|
<div class="dashboard-header">
|
||||||
|
|||||||
@ -19,7 +19,7 @@
|
|||||||
<Loading v-if="packList.find(({status}) => status !== 'finished')"/>
|
<Loading v-if="packList.find(({status}) => status !== 'finished')"/>
|
||||||
<Button v-else shape="circle" icon="md-arrow-round-down"></Button>
|
<Button v-else shape="circle" icon="md-arrow-round-down"></Button>
|
||||||
</div>
|
</div>
|
||||||
<div :class="['file-search', {collaboration: board === 'collaboration'}]" @click="onSearchFocus">
|
<div class="file-search" @click="onSearchFocus">
|
||||||
<Input
|
<Input
|
||||||
v-model="searchKey"
|
v-model="searchKey"
|
||||||
ref="searchInput"
|
ref="searchInput"
|
||||||
@ -27,10 +27,10 @@
|
|||||||
@on-focus="searchIsFocus=true"
|
@on-focus="searchIsFocus=true"
|
||||||
@on-blur="searchIsFocus=false"
|
@on-blur="searchIsFocus=false"
|
||||||
@on-change="onSearchChange"
|
@on-change="onSearchChange"
|
||||||
:placeholder="$L(board === 'collaboration' ? '搜索名称、会话、项目或任务' : '搜索名称')"
|
:placeholder="$L('搜索名称')"
|
||||||
clearable/>
|
clearable/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="board !== 'collaboration'" class="file-add">
|
<div class="file-add">
|
||||||
<Button shape="circle" icon="md-add" @click.stop="handleRightClick($event, null, true)"></Button>
|
<Button shape="circle" icon="md-add" @click.stop="handleRightClick($event, null, true)"></Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -40,7 +40,6 @@
|
|||||||
<div class="file-tabs-nav">
|
<div class="file-tabs-nav">
|
||||||
<div :class="['file-tab', {active: board === 'mine'}]" @click="switchBoard('mine')">{{$L('我的文件')}}</div>
|
<div :class="['file-tab', {active: board === 'mine'}]" @click="switchBoard('mine')">{{$L('我的文件')}}</div>
|
||||||
<div :class="['file-tab', {active: board === 'shared'}]" @click="switchBoard('shared')">{{$L('共享文件')}}</div>
|
<div :class="['file-tab', {active: board === 'shared'}]" @click="switchBoard('shared')">{{$L('共享文件')}}</div>
|
||||||
<div :class="['file-tab', {active: board === 'collaboration'}]" @click="switchBoard('collaboration')">{{$L('协作文件')}}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="file-tabs-full"></div>
|
<div class="file-tabs-full"></div>
|
||||||
<div v-if="board === 'shared' && pid == 0 && !searchKey" class="file-shared-src">
|
<div v-if="board === 'shared' && pid == 0 && !searchKey" class="file-shared-src">
|
||||||
@ -48,18 +47,12 @@
|
|||||||
<span :class="{on: sharedSrc === 'byme'}" @click="sharedSrc = 'byme'">{{$L('我共享的')}}</span>
|
<span :class="{on: sharedSrc === 'byme'}" @click="sharedSrc = 'byme'">{{$L('我共享的')}}</span>
|
||||||
<span :class="{on: sharedSrc === 'tome'}" @click="sharedSrc = 'tome'">{{$L('共享给我的')}}</span>
|
<span :class="{on: sharedSrc === 'tome'}" @click="sharedSrc = 'tome'">{{$L('共享给我的')}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="board !== 'collaboration'" :class="['switch-button', tableMode]">
|
<div :class="['switch-button', tableMode]">
|
||||||
<div @click="tableMode='block'"><i class="taskfont"></i></div>
|
<div @click="tableMode='block'"><i class="taskfont"></i></div>
|
||||||
<div @click="tableMode='table'"><i class="taskfont"></i></div>
|
<div @click="tableMode='table'"><i class="taskfont"></i></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CollaborationFileList
|
|
||||||
v-if="board === 'collaboration'"
|
|
||||||
ref="collaborationFiles"
|
|
||||||
:search-key="searchKey"/>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div v-show="showNavigator" class="file-navigator">
|
<div v-show="showNavigator" class="file-navigator">
|
||||||
<ul class="scrollbar-hidden" v-show="showBtnText || (!selectedItems.length && !shearFirst)">
|
<ul class="scrollbar-hidden" v-show="showBtnText || (!selectedItems.length && !shearFirst)">
|
||||||
<li v-if="pid > 0 || searchKey" @click="browseFolder(0)">
|
<li v-if="pid > 0 || searchKey" @click="browseFolder(0)">
|
||||||
@ -282,7 +275,6 @@
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="uploadShow && uploadList.length > 0" class="file-upload-list">
|
<div v-if="uploadShow && uploadList.length > 0" class="file-upload-list">
|
||||||
@ -508,7 +500,6 @@ import longpress from "../../directives/longpress";
|
|||||||
import UserSelect from "../../components/UserSelect.vue";
|
import UserSelect from "../../components/UserSelect.vue";
|
||||||
import UserAvatarTip from "../../components/UserAvatar/tip.vue";
|
import UserAvatarTip from "../../components/UserAvatar/tip.vue";
|
||||||
import Forwarder from "./components/Forwarder/index.vue";
|
import Forwarder from "./components/Forwarder/index.vue";
|
||||||
import CollaborationFileList from "./components/CollaborationFileList.vue";
|
|
||||||
import {chunkedUpload, CHUNK_THRESHOLD} from "../../store/chunkedUpload";
|
import {chunkedUpload, CHUNK_THRESHOLD} from "../../store/chunkedUpload";
|
||||||
|
|
||||||
const FilePreview = () => import('./components/FilePreview');
|
const FilePreview = () => import('./components/FilePreview');
|
||||||
@ -516,7 +507,7 @@ const FileContent = () => import('./components/FileContent');
|
|||||||
const FileObject = {sort: null, mode: null, board: null};
|
const FileObject = {sort: null, mode: null, board: null};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {CollaborationFileList, Forwarder, UserAvatarTip, UserSelect, FilePreview, DrawerOverlay, FileContent},
|
components: {Forwarder, UserAvatarTip, UserSelect, FilePreview, DrawerOverlay, FileContent},
|
||||||
directives: {longpress},
|
directives: {longpress},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@ -580,7 +571,7 @@ export default {
|
|||||||
],
|
],
|
||||||
|
|
||||||
tableMode: "",
|
tableMode: "",
|
||||||
board: "mine", // 当前板块:mine=我的文件、shared=共享文件、collaboration=协作文件
|
board: "mine", // 当前板块:mine=我的文件、shared=共享文件
|
||||||
sharedSrc: "all", // 共享板块二次筛选:all=全部、byme=我共享的、tome=共享给我的
|
sharedSrc: "all", // 共享板块二次筛选:all=全部、byme=我共享的、tome=共享给我的
|
||||||
columns: [],
|
columns: [],
|
||||||
|
|
||||||
@ -648,7 +639,7 @@ export default {
|
|||||||
|
|
||||||
created() {
|
created() {
|
||||||
this.tableMode = FileObject.mode
|
this.tableMode = FileObject.mode
|
||||||
this.board = ['mine', 'shared', 'collaboration'].includes(FileObject.board) ? FileObject.board : 'mine'
|
this.board = FileObject.board === 'shared' ? 'shared' : 'mine'
|
||||||
this.columns = [
|
this.columns = [
|
||||||
{
|
{
|
||||||
type: 'selection',
|
type: 'selection',
|
||||||
@ -1181,10 +1172,6 @@ export default {
|
|||||||
if (this.routeName !== 'manage-file') {
|
if (this.routeName !== 'manage-file') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.board === 'collaboration') {
|
|
||||||
this.$refs.collaborationFiles?.refresh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.loadIng++;
|
this.loadIng++;
|
||||||
// 按当前板块拉取(拉多少缓存多少);已有缓存时 fileList 会先渲染缓存,接口回来再静态刷新
|
// 按当前板块拉取(拉多少缓存多少);已有缓存时 fileList 会先渲染缓存,接口回来再静态刷新
|
||||||
this.$store.dispatch("getFiles", {pid: this.pid, scope: this.board}).then(async () => {
|
this.$store.dispatch("getFiles", {pid: this.pid, scope: this.board}).then(async () => {
|
||||||
@ -1525,7 +1512,6 @@ export default {
|
|||||||
this.board = board;
|
this.board = board;
|
||||||
this.sharedSrc = 'all';
|
this.sharedSrc = 'all';
|
||||||
this.selectedItems = [];
|
this.selectedItems = [];
|
||||||
this.contextMenuVisible = false;
|
|
||||||
this.clearShear();
|
this.clearShear();
|
||||||
if (this.pid > 0) {
|
if (this.pid > 0) {
|
||||||
// 从子目录切板块时退回根目录(pid 变化触发加载)
|
// 从子目录切板块时退回根目录(pid 变化触发加载)
|
||||||
@ -2142,9 +2128,6 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
onSearchChange() {
|
onSearchChange() {
|
||||||
if (this.board === 'collaboration') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.searchTimeout && clearTimeout(this.searchTimeout);
|
this.searchTimeout && clearTimeout(this.searchTimeout);
|
||||||
if (this.searchKey.trim() != '') {
|
if (this.searchKey.trim() != '') {
|
||||||
this.searchTimeout = setTimeout(() => {
|
this.searchTimeout = setTimeout(() => {
|
||||||
|
|||||||
@ -1,76 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="setting-item submit">
|
|
||||||
<Form ref="formData" :model="formData" v-bind="formOptions" @submit.native.prevent>
|
|
||||||
<FormItem :label="$L('显示悬浮按钮')">
|
|
||||||
<iSwitch
|
|
||||||
v-model="formData.floatButtonVisible"
|
|
||||||
:disabled="loading"/>
|
|
||||||
</FormItem>
|
|
||||||
</Form>
|
|
||||||
<div class="setting-footer">
|
|
||||||
<Button :loading="loadIng > 0" :disabled="loading" type="primary" @click="submitForm">{{$L('提交')}}</Button>
|
|
||||||
<Button :loading="loadIng > 0" :disabled="loading" @click="resetForm">{{$L('重置')}}</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import {mapState} from "vuex";
|
|
||||||
import emitter from "../../../store/events";
|
|
||||||
import {
|
|
||||||
loadFloatButtonVisible,
|
|
||||||
saveFloatButtonVisible,
|
|
||||||
} from "../../../components/AIAssistant/float-button-preference";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
loadIng: 0,
|
|
||||||
loading: true,
|
|
||||||
formData: {
|
|
||||||
floatButtonVisible: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
...mapState(['userId', 'formOptions']),
|
|
||||||
},
|
|
||||||
|
|
||||||
mounted() {
|
|
||||||
this.loadPreference();
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
async loadPreference() {
|
|
||||||
try {
|
|
||||||
this.$set(this.formData, 'floatButtonVisible', await loadFloatButtonVisible(this.userId));
|
|
||||||
this.formData_bak = $A.cloneJSON(this.formData);
|
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async submitForm() {
|
|
||||||
const visible = this.formData.floatButtonVisible;
|
|
||||||
this.loadIng++;
|
|
||||||
try {
|
|
||||||
await saveFloatButtonVisible(this.userId, visible);
|
|
||||||
emitter.emit('aiAssistantFloatButtonVisibilityChanged', {
|
|
||||||
userId: this.userId,
|
|
||||||
visible,
|
|
||||||
});
|
|
||||||
$A.messageSuccess('保存成功');
|
|
||||||
} catch (error) {
|
|
||||||
$A.modalError(error.message);
|
|
||||||
} finally {
|
|
||||||
this.loadIng--;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
resetForm() {
|
|
||||||
this.formData = $A.cloneJSON(this.formData_bak);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
@ -72,11 +72,7 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(['userInfo', 'userIsAdmin', 'clientNewVersion', 'systemConfig', 'microAppsIds']),
|
...mapState(['userInfo', 'userIsAdmin', 'clientNewVersion', 'systemConfig']),
|
||||||
|
|
||||||
aiInstalled() {
|
|
||||||
return this.microAppsIds?.includes('ai');
|
|
||||||
},
|
|
||||||
|
|
||||||
showContent() {
|
showContent() {
|
||||||
return this.$route.path.match(/^\/manage\/setting\/\w+$/)
|
return this.$route.path.match(/^\/manage\/setting\/\w+$/)
|
||||||
@ -95,10 +91,6 @@ export default {
|
|||||||
menu.push({path: 'keyboard', name: '键盘设置'})
|
menu.push({path: 'keyboard', name: '键盘设置'})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.aiInstalled) {
|
|
||||||
menu.push({path: 'assistant', name: 'AI 助手设置'})
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($A.isDooServer() && this.$isEEUIApp) {
|
if ($A.isDooServer() && this.$isEEUIApp) {
|
||||||
menu.push(...[
|
menu.push(...[
|
||||||
{path: 'privacy', name: '隐私政策', divided: true},
|
{path: 'privacy', name: '隐私政策', divided: true},
|
||||||
@ -147,18 +139,11 @@ export default {
|
|||||||
handler(name) {
|
handler(name) {
|
||||||
if (name === 'manage-setting' && this.windowLandscape) {
|
if (name === 'manage-setting' && this.windowLandscape) {
|
||||||
this.goForward({name: 'manage-setting-personal'}, true);
|
this.goForward({name: 'manage-setting-personal'}, true);
|
||||||
} else if (name === 'manage-setting-assistant' && !this.aiInstalled) {
|
|
||||||
this.goForward({name: 'manage-setting-personal'}, true);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
immediate: true
|
immediate: true
|
||||||
},
|
|
||||||
microAppsIds() {
|
|
||||||
if (this.routeName === 'manage-setting-assistant' && !this.aiInstalled) {
|
|
||||||
this.goForward({name: 'manage-setting-personal'}, true);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
toggleRoute(path) {
|
toggleRoute(path) {
|
||||||
|
|||||||
5
resources/assets/js/routes.js
vendored
5
resources/assets/js/routes.js
vendored
@ -64,11 +64,6 @@ export default [
|
|||||||
path: 'keyboard',
|
path: 'keyboard',
|
||||||
component: () => import('./pages/manage/setting/keyboard.vue'),
|
component: () => import('./pages/manage/setting/keyboard.vue'),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'manage-setting-assistant',
|
|
||||||
path: 'assistant',
|
|
||||||
component: () => import('./pages/manage/setting/assistant.vue'),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'manage-setting-license',
|
name: 'manage-setting-license',
|
||||||
path: 'license',
|
path: 'license',
|
||||||
|
|||||||
4
resources/assets/js/store/actions.js
vendored
4
resources/assets/js/store/actions.js
vendored
@ -1174,8 +1174,7 @@ export default {
|
|||||||
'clientId',
|
'clientId',
|
||||||
'cacheServerUrl',
|
'cacheServerUrl',
|
||||||
'cacheCalendarView',
|
'cacheCalendarView',
|
||||||
'cacheTranslationLanguage',
|
'cacheTranslationLanguage'
|
||||||
'collaborationFileViewMode'
|
|
||||||
],
|
],
|
||||||
array: [
|
array: [
|
||||||
'cacheUserBasic',
|
'cacheUserBasic',
|
||||||
@ -1200,7 +1199,6 @@ export default {
|
|||||||
'userInfo',
|
'userInfo',
|
||||||
'taskRelatedCache',
|
'taskRelatedCache',
|
||||||
'dialogCommonCountCache',
|
'dialogCommonCountCache',
|
||||||
'collaborationFileCache',
|
|
||||||
'mcpServerStatus'
|
'mcpServerStatus'
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
10
resources/assets/js/store/mutations.js
vendored
10
resources/assets/js/store/mutations.js
vendored
@ -278,16 +278,6 @@ export default {
|
|||||||
$A.IDBSave("fileLists", state.fileLists, 600)
|
$A.IDBSave("fileLists", state.fileLists, 600)
|
||||||
},
|
},
|
||||||
|
|
||||||
'collaboration/file/cache/save': function(state, data) {
|
|
||||||
state.collaborationFileCache = data
|
|
||||||
$A.IDBSave("collaborationFileCache", data, 600)
|
|
||||||
},
|
|
||||||
|
|
||||||
'collaboration/file/view/save': function(state, mode) {
|
|
||||||
state.collaborationFileViewMode = mode === 'grid' ? 'grid' : 'list'
|
|
||||||
$A.IDBSave("collaborationFileViewMode", state.collaborationFileViewMode)
|
|
||||||
},
|
|
||||||
|
|
||||||
// 草稿管理
|
// 草稿管理
|
||||||
'draft/set': function(state, {id, content}) {
|
'draft/set': function(state, {id, content}) {
|
||||||
const index = state.dialogDrafts.findIndex(item => item.id === id)
|
const index = state.dialogDrafts.findIndex(item => item.id === id)
|
||||||
|
|||||||
2
resources/assets/js/store/state.js
vendored
2
resources/assets/js/store/state.js
vendored
@ -182,8 +182,6 @@ export default {
|
|||||||
|
|
||||||
// 文件
|
// 文件
|
||||||
fileLists: [],
|
fileLists: [],
|
||||||
collaborationFileCache: {},
|
|
||||||
collaborationFileViewMode: 'list',
|
|
||||||
fileLinks: [],
|
fileLinks: [],
|
||||||
filePackLists: [],
|
filePackLists: [],
|
||||||
fileShakeId: 0,
|
fileShakeId: 0,
|
||||||
|
|||||||
6
resources/assets/sass/components/mobile.scss
vendored
6
resources/assets/sass/components/mobile.scss
vendored
@ -89,12 +89,6 @@
|
|||||||
.ivu-badge-count {
|
.ivu-badge-count {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ivu-badge-dot {
|
|
||||||
top: 6px;
|
|
||||||
right: -24px;
|
|
||||||
transform: translateX(-50%) scale(1.2);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
|
|||||||
8
resources/assets/sass/pages/page-apply.scss
vendored
8
resources/assets/sass/pages/page-apply.scss
vendored
@ -127,10 +127,6 @@
|
|||||||
right: -16px;
|
right: -16px;
|
||||||
top: -16px;
|
top: -16px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
|
|
||||||
.ivu-badge-dot {
|
|
||||||
right: 8px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-sorting {
|
&.is-sorting {
|
||||||
@ -182,10 +178,6 @@
|
|||||||
.apply-box-top-report {
|
.apply-box-top-report {
|
||||||
right: -20px;
|
right: -20px;
|
||||||
top: -20px;
|
top: -20px;
|
||||||
|
|
||||||
.ivu-badge-dot {
|
|
||||||
right: 4px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
resources/assets/sass/pages/page-dashboard.scss
vendored
22
resources/assets/sass/pages/page-dashboard.scss
vendored
@ -5,27 +5,23 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
|
|
||||||
.dashboard-scroller {
|
|
||||||
flex: 1;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-warning {
|
.dashboard-warning {
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 2;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
.ivu-alert {
|
|
||||||
border-top: 0;
|
border-top: 0;
|
||||||
border-top-left-radius: 0;
|
border-top-left-radius: 0;
|
||||||
border-top-right-radius: 0;
|
border-top-right-radius: 0;
|
||||||
margin-bottom: 0;
|
padding-right: 14px;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
> span {
|
> span {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-scroller {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-body {
|
.dashboard-body {
|
||||||
|
|||||||
5
resources/assets/sass/pages/page-file.scss
vendored
5
resources/assets/sass/pages/page-file.scss
vendored
@ -73,11 +73,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
&.collaboration {
|
|
||||||
.ivu-input-wrapper .ivu-input {
|
|
||||||
width: 240px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.file-add {
|
.file-add {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
4
resources/assets/sass/pages/page-manage.scss
vendored
4
resources/assets/sass/pages/page-manage.scss
vendored
@ -83,10 +83,6 @@
|
|||||||
.menu-badge {
|
.menu-badge {
|
||||||
margin-left: 12px;
|
margin-left: 12px;
|
||||||
transform: scale(0.9);
|
transform: scale(0.9);
|
||||||
.ivu-badge-dot {
|
|
||||||
right: 0;
|
|
||||||
transform: translateX(-50%) scale(1.1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
&:first-child {
|
&:first-child {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 此文件由 `php artisan doc:api-map` 生成,勿手改。
|
> 此文件由 `php artisan doc:api-map` 生成,勿手改。
|
||||||
|
|
||||||
接口总数:316
|
接口总数:314
|
||||||
|
|
||||||
## 路由规则
|
## 路由规则
|
||||||
|
|
||||||
@ -302,8 +302,6 @@ API 使用动态路由(见 `routes/web.php`),URL 段映射为控制器方
|
|||||||
|
|
||||||
| URL | 方法名 | HTTP | 说明 |
|
| URL | 方法名 | HTTP | 说明 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| api/file/collaboration/lists | collaboration__lists() | get | 获取协作文件列表 |
|
|
||||||
| api/file/collaboration/download | collaboration__download() | get | 下载协作文件附件 |
|
|
||||||
| api/file/lists | lists() | get | 获取文件列表 |
|
| api/file/lists | lists() | get | 获取文件列表 |
|
||||||
| api/file/one | one() | get | 获取单条数据 |
|
| api/file/one | one() | get | 获取单条数据 |
|
||||||
| api/file/fetch | fetch() | get | 通过路径获取文件文本内容 |
|
| api/file/fetch | fetch() | get | 通过路径获取文件文本内容 |
|
||||||
|
|||||||
@ -1,349 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature;
|
|
||||||
|
|
||||||
use App\Models\Project;
|
|
||||||
use App\Models\ProjectTask;
|
|
||||||
use App\Models\ProjectTaskUser;
|
|
||||||
use App\Models\ProjectUser;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\WebSocketDialog;
|
|
||||||
use App\Models\WebSocketDialogMsg;
|
|
||||||
use App\Models\WebSocketDialogMsgAttachment;
|
|
||||||
use App\Models\WebSocketDialogMsgAttachmentBackfill;
|
|
||||||
use App\Models\WebSocketDialogUser;
|
|
||||||
use App\Exceptions\ApiException;
|
|
||||||
use App\Services\CollaborationFileService;
|
|
||||||
use App\Services\MessageAttachmentService;
|
|
||||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class CollaborationFileServiceTest extends TestCase
|
|
||||||
{
|
|
||||||
use DatabaseTransactions;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
parent::setUp();
|
|
||||||
WebSocketDialogMsgAttachmentBackfill::query()->update([
|
|
||||||
'status' => WebSocketDialogMsgAttachmentBackfill::STATUS_COMPLETED,
|
|
||||||
'completed_at' => now(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function makeUser(string $email): User
|
|
||||||
{
|
|
||||||
$user = User::createInstance([
|
|
||||||
'email' => $email,
|
|
||||||
'userimg' => '',
|
|
||||||
'nickname' => 'File_' . substr(md5($email), 0, 6),
|
|
||||||
'profession' => '',
|
|
||||||
'password' => md5('123456'),
|
|
||||||
]);
|
|
||||||
$user->save();
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function makeDialog(string $type, string $groupType, string $name, array $members): WebSocketDialog
|
|
||||||
{
|
|
||||||
$dialog = WebSocketDialog::createInstance([
|
|
||||||
'type' => $type,
|
|
||||||
'group_type' => $groupType,
|
|
||||||
'name' => $name,
|
|
||||||
]);
|
|
||||||
$dialog->save();
|
|
||||||
foreach ($members as $userid) {
|
|
||||||
WebSocketDialogUser::createInstance([
|
|
||||||
'dialog_id' => $dialog->id,
|
|
||||||
'userid' => $userid,
|
|
||||||
])->save();
|
|
||||||
}
|
|
||||||
return $dialog;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function makeFile(WebSocketDialog $dialog, User $sender, string $name): WebSocketDialogMsg
|
|
||||||
{
|
|
||||||
$message = WebSocketDialogMsg::createInstance([
|
|
||||||
'dialog_id' => $dialog->id,
|
|
||||||
'userid' => $sender->userid,
|
|
||||||
'type' => 'file',
|
|
||||||
'key' => $name,
|
|
||||||
'msg' => json_encode([
|
|
||||||
'name' => $name,
|
|
||||||
'ext' => pathinfo($name, PATHINFO_EXTENSION),
|
|
||||||
'size' => 1024,
|
|
||||||
'path' => 'uploads/test/' . $name,
|
|
||||||
], JSON_UNESCAPED_UNICODE),
|
|
||||||
]);
|
|
||||||
$message->save();
|
|
||||||
MessageAttachmentService::sync($message);
|
|
||||||
return $message;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_lists_classifies_sources_and_applies_access(): void
|
|
||||||
{
|
|
||||||
$viewer = $this->makeUser('collaboration-viewer@test.local');
|
|
||||||
$sender = $this->makeUser('collaboration-sender@test.local');
|
|
||||||
$outsider = $this->makeUser('collaboration-outsider@test.local');
|
|
||||||
|
|
||||||
$private = $this->makeDialog('user', '', '', [$viewer->userid, $sender->userid]);
|
|
||||||
$group = $this->makeDialog('group', 'user', '普通工作群', [$viewer->userid, $sender->userid]);
|
|
||||||
$hidden = $this->makeDialog('group', 'user', '不可见群', [$sender->userid, $outsider->userid]);
|
|
||||||
|
|
||||||
$projectDialog = $this->makeDialog('group', 'project', '项目群', [$viewer->userid, $sender->userid]);
|
|
||||||
$project = Project::createInstance([
|
|
||||||
'name' => '协作文件项目',
|
|
||||||
'desc' => '',
|
|
||||||
'userid' => $sender->userid,
|
|
||||||
'dialog_id' => $projectDialog->id,
|
|
||||||
'personal' => 0,
|
|
||||||
]);
|
|
||||||
$project->save();
|
|
||||||
ProjectUser::updateInsert(
|
|
||||||
['project_id' => $project->id, 'userid' => $viewer->userid],
|
|
||||||
['owner' => 0]
|
|
||||||
);
|
|
||||||
|
|
||||||
$taskDialog = $this->makeDialog('group', 'task', '任务群', [$viewer->userid, $sender->userid]);
|
|
||||||
$task = ProjectTask::createInstance([
|
|
||||||
'project_id' => $project->id,
|
|
||||||
'parent_id' => 0,
|
|
||||||
'name' => '协作文件任务',
|
|
||||||
'dialog_id' => $taskDialog->id,
|
|
||||||
'userid' => $sender->userid,
|
|
||||||
'visibility' => 2,
|
|
||||||
]);
|
|
||||||
$task->save();
|
|
||||||
ProjectTaskUser::createInstance([
|
|
||||||
'project_id' => $project->id,
|
|
||||||
'task_id' => $task->id,
|
|
||||||
'userid' => $viewer->userid,
|
|
||||||
'owner' => 0,
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$this->makeFile($private, $sender, 'private.pdf');
|
|
||||||
$this->makeFile($group, $sender, 'group.docx');
|
|
||||||
$this->makeFile($projectDialog, $sender, 'project.xlsx');
|
|
||||||
$this->makeFile($taskDialog, $sender, 'task.png');
|
|
||||||
$this->makeFile($hidden, $sender, 'hidden.zip');
|
|
||||||
|
|
||||||
$result = CollaborationFileService::lists($viewer, ['take' => 20]);
|
|
||||||
$this->assertSame(['task', 'project_chat', 'group', 'private'], array_column($result['list'], 'source_type'));
|
|
||||||
$this->assertNotContains('hidden.zip', array_column($result['list'], 'name'));
|
|
||||||
$this->assertStringEndsWith('/uploads/test/task.png', $result['list'][0]['image_url']);
|
|
||||||
$this->assertSame('', $result['list'][1]['image_url']);
|
|
||||||
|
|
||||||
$conversation = CollaborationFileService::lists($viewer, [
|
|
||||||
'scope' => 'conversation',
|
|
||||||
'conversation_type' => 'private',
|
|
||||||
]);
|
|
||||||
$this->assertSame(['private.pdf'], array_column($conversation['list'], 'name'));
|
|
||||||
|
|
||||||
$projectFiles = CollaborationFileService::lists($viewer, [
|
|
||||||
'scope' => 'project',
|
|
||||||
'project_id' => $project->id,
|
|
||||||
'project_source' => 'task',
|
|
||||||
]);
|
|
||||||
$this->assertSame(['task.png'], array_column($projectFiles['list'], 'name'));
|
|
||||||
|
|
||||||
$archivedDialog = $this->makeDialog('group', 'project', '归档项目群', [$viewer->userid, $sender->userid]);
|
|
||||||
$archivedProject = Project::createInstance([
|
|
||||||
'name' => '已归档协作文件项目',
|
|
||||||
'desc' => '',
|
|
||||||
'userid' => $sender->userid,
|
|
||||||
'dialog_id' => $archivedDialog->id,
|
|
||||||
'personal' => 0,
|
|
||||||
'archived_at' => now(),
|
|
||||||
]);
|
|
||||||
$archivedProject->save();
|
|
||||||
ProjectUser::updateInsert(
|
|
||||||
['project_id' => $archivedProject->id, 'userid' => $viewer->userid],
|
|
||||||
['owner' => 0]
|
|
||||||
);
|
|
||||||
$this->makeFile($archivedDialog, $sender, 'archived-project.zip');
|
|
||||||
|
|
||||||
$hiddenProjectDialog = $this->makeDialog('group', 'project', '不可见项目群', [$sender->userid, $outsider->userid]);
|
|
||||||
$hiddenProject = Project::createInstance([
|
|
||||||
'name' => '不可见协作文件项目',
|
|
||||||
'desc' => '',
|
|
||||||
'userid' => $sender->userid,
|
|
||||||
'dialog_id' => $hiddenProjectDialog->id,
|
|
||||||
'personal' => 0,
|
|
||||||
]);
|
|
||||||
$hiddenProject->save();
|
|
||||||
ProjectUser::updateInsert(
|
|
||||||
['project_id' => $hiddenProject->id, 'userid' => $sender->userid],
|
|
||||||
['owner' => ProjectUser::OWNER_PRIMARY]
|
|
||||||
);
|
|
||||||
$this->makeFile($hiddenProjectDialog, $sender, 'hidden-project.pdf');
|
|
||||||
|
|
||||||
$allProjectFiles = CollaborationFileService::lists($viewer, [
|
|
||||||
'scope' => 'project',
|
|
||||||
'project_id' => 0,
|
|
||||||
]);
|
|
||||||
$this->assertSame(['task.png', 'project.xlsx'], array_column($allProjectFiles['list'], 'name'));
|
|
||||||
|
|
||||||
$allProjectTaskFiles = CollaborationFileService::lists($viewer, [
|
|
||||||
'scope' => 'project',
|
|
||||||
'project_id' => 0,
|
|
||||||
'project_source' => 'task',
|
|
||||||
]);
|
|
||||||
$this->assertSame(['task.png'], array_column($allProjectTaskFiles['list'], 'name'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_lists_excludes_deleted_messages_and_uses_cursor(): void
|
|
||||||
{
|
|
||||||
$viewer = $this->makeUser('collaboration-page@test.local');
|
|
||||||
$dialog = $this->makeDialog('group', 'user', '分页群', [$viewer->userid]);
|
|
||||||
$first = $this->makeFile($dialog, $viewer, 'first.pdf');
|
|
||||||
$this->makeFile($dialog, $viewer, 'deleted.pdf')->delete();
|
|
||||||
$last = $this->makeFile($dialog, $viewer, 'last.png');
|
|
||||||
|
|
||||||
// The browser sends cursor=0 on the initial request.
|
|
||||||
$page = CollaborationFileService::lists($viewer, ['take' => 1, 'cursor' => '0']);
|
|
||||||
$this->assertSame(['last.png'], array_column($page['list'], 'name'));
|
|
||||||
$this->assertTrue($page['has_more']);
|
|
||||||
$this->assertStringStartsWith("a:{$last->id}:0:", $page['next_cursor']);
|
|
||||||
|
|
||||||
$next = CollaborationFileService::lists($viewer, [
|
|
||||||
'cursor' => $page['next_cursor'],
|
|
||||||
'file_type' => 'document',
|
|
||||||
]);
|
|
||||||
$this->assertSame([$first->id], array_column($next['list'], 'msg_id'));
|
|
||||||
$this->assertFalse($next['has_more']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_lists_paginates_each_inline_image_in_one_message(): void
|
|
||||||
{
|
|
||||||
$viewer = $this->makeUser('collaboration-inline@test.local');
|
|
||||||
$dialog = $this->makeDialog('user', '', '', [$viewer->userid]);
|
|
||||||
$message = WebSocketDialogMsg::createInstance([
|
|
||||||
'dialog_id' => $dialog->id,
|
|
||||||
'userid' => $viewer->userid,
|
|
||||||
'type' => 'text',
|
|
||||||
'key' => '',
|
|
||||||
'msg' => json_encode([
|
|
||||||
'text' => '<p><img class="browse" src="uploads/test/first.png" alt="第一张"/>'
|
|
||||||
. '<img class="browse" src="uploads/test/second.png" alt="第二张"/></p>',
|
|
||||||
], JSON_UNESCAPED_UNICODE),
|
|
||||||
]);
|
|
||||||
$message->save();
|
|
||||||
MessageAttachmentService::sync($message);
|
|
||||||
|
|
||||||
$firstPage = CollaborationFileService::lists($viewer, ['take' => 1]);
|
|
||||||
$this->assertSame(['第一张'], array_column($firstPage['list'], 'name'));
|
|
||||||
$this->assertSame('inline_image', $firstPage['list'][0]['attachment_source']);
|
|
||||||
$this->assertSame($message->id, $firstPage['list'][0]['msg_id']);
|
|
||||||
$this->assertTrue($firstPage['has_more']);
|
|
||||||
|
|
||||||
$secondPage = CollaborationFileService::lists($viewer, [
|
|
||||||
'take' => 1,
|
|
||||||
'cursor' => $firstPage['next_cursor'],
|
|
||||||
]);
|
|
||||||
$this->assertSame(['第二张'], array_column($secondPage['list'], 'name'));
|
|
||||||
$this->assertSame($message->id, $secondPage['list'][0]['msg_id']);
|
|
||||||
$this->assertNotSame($firstPage['list'][0]['attachment_id'], $secondPage['list'][0]['attachment_id']);
|
|
||||||
$this->assertFalse($secondPage['has_more']);
|
|
||||||
|
|
||||||
$legacySecondPage = CollaborationFileService::lists($viewer, [
|
|
||||||
'take' => 1,
|
|
||||||
'cursor' => substr($firstPage['next_cursor'], 2),
|
|
||||||
]);
|
|
||||||
$this->assertSame(['第二张'], array_column($legacySecondPage['list'], 'name'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_lists_falls_back_to_messages_until_index_is_ready(): void
|
|
||||||
{
|
|
||||||
$viewer = $this->makeUser('collaboration-fallback@test.local');
|
|
||||||
$dialog = $this->makeDialog('group', 'user', '回填中群聊', [$viewer->userid]);
|
|
||||||
$message = $this->makeFile($dialog, $viewer, 'fallback.pdf');
|
|
||||||
WebSocketDialogMsgAttachment::whereMsgId($message->id)->delete();
|
|
||||||
WebSocketDialogMsgAttachmentBackfill::query()->update([
|
|
||||||
'status' => WebSocketDialogMsgAttachmentBackfill::STATUS_PENDING,
|
|
||||||
'completed_at' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$result = CollaborationFileService::lists($viewer, []);
|
|
||||||
|
|
||||||
$this->assertSame(['fallback.pdf'], array_column($result['list'], 'name'));
|
|
||||||
$this->assertSame(0, $result['list'][0]['attachment_id']);
|
|
||||||
$this->assertSame('file_message', $result['list'][0]['attachment_source']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_message_cursor_keeps_fallback_source_after_backfill_completes(): void
|
|
||||||
{
|
|
||||||
$viewer = $this->makeUser('collaboration-transition@test.local');
|
|
||||||
$dialog = $this->makeDialog('group', 'user', '回填切换群聊', [$viewer->userid]);
|
|
||||||
$older = $this->makeFile($dialog, $viewer, 'older.pdf');
|
|
||||||
$newer = $this->makeFile($dialog, $viewer, 'newer.pdf');
|
|
||||||
WebSocketDialogMsgAttachmentBackfill::query()->update([
|
|
||||||
'status' => WebSocketDialogMsgAttachmentBackfill::STATUS_PENDING,
|
|
||||||
'completed_at' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$firstPage = CollaborationFileService::lists($viewer, ['take' => 1, 'cursor' => '0']);
|
|
||||||
$this->assertSame([$newer->id], array_column($firstPage['list'], 'msg_id'));
|
|
||||||
$this->assertSame("m:{$newer->id}", $firstPage['next_cursor']);
|
|
||||||
|
|
||||||
WebSocketDialogMsgAttachmentBackfill::query()->update([
|
|
||||||
'status' => WebSocketDialogMsgAttachmentBackfill::STATUS_COMPLETED,
|
|
||||||
'completed_at' => now(),
|
|
||||||
]);
|
|
||||||
$secondPage = CollaborationFileService::lists($viewer, [
|
|
||||||
'take' => 1,
|
|
||||||
'cursor' => $firstPage['next_cursor'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assertSame([$older->id], array_column($secondPage['list'], 'msg_id'));
|
|
||||||
$this->assertSame(0, $secondPage['list'][0]['attachment_id']);
|
|
||||||
|
|
||||||
$legacySecondPage = CollaborationFileService::lists($viewer, [
|
|
||||||
'take' => 1,
|
|
||||||
'cursor' => (string)$newer->id,
|
|
||||||
]);
|
|
||||||
$this->assertSame([$older->id], array_column($legacySecondPage['list'], 'msg_id'));
|
|
||||||
$this->assertSame(0, $legacySecondPage['list'][0]['attachment_id']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_resolve_local_attachment_path_rejects_traversal(): void
|
|
||||||
{
|
|
||||||
$directory = public_path('uploads/tmp/collaboration-file-test');
|
|
||||||
$file = $directory . '/download.txt';
|
|
||||||
$link = $directory . '/escape.env';
|
|
||||||
if (!is_dir($directory)) {
|
|
||||||
mkdir($directory, 0775, true);
|
|
||||||
}
|
|
||||||
file_put_contents($file, 'download');
|
|
||||||
symlink(base_path('.env'), $link);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->assertSame(realpath($file), CollaborationFileService::resolveLocalAttachmentPath(
|
|
||||||
'uploads/tmp/collaboration-file-test/download.txt'
|
|
||||||
));
|
|
||||||
$this->assertNull(CollaborationFileService::resolveLocalAttachmentPath('../.env'));
|
|
||||||
$this->assertNull(CollaborationFileService::resolveLocalAttachmentPath('uploads/../index.php'));
|
|
||||||
$this->assertNull(CollaborationFileService::resolveLocalAttachmentPath(
|
|
||||||
'uploads/tmp/collaboration-file-test/escape.env'
|
|
||||||
));
|
|
||||||
$this->assertNull(CollaborationFileService::resolveLocalAttachmentPath('https://example.com/file.pdf'));
|
|
||||||
} finally {
|
|
||||||
@unlink($link);
|
|
||||||
@unlink($file);
|
|
||||||
@rmdir($directory);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_authorize_attachment_rejects_non_member(): void
|
|
||||||
{
|
|
||||||
$member = $this->makeUser('collaboration-member@test.local');
|
|
||||||
$outsider = $this->makeUser('collaboration-denied@test.local');
|
|
||||||
$dialog = $this->makeDialog('group', 'user', '权限群', [$member->userid]);
|
|
||||||
$message = $this->makeFile($dialog, $member, 'permission.pdf');
|
|
||||||
$attachment = WebSocketDialogMsgAttachment::whereMsgId($message->id)->firstOrFail();
|
|
||||||
|
|
||||||
CollaborationFileService::authorizeAttachment($attachment, $member);
|
|
||||||
$this->expectException(ApiException::class);
|
|
||||||
$this->expectExceptionMessage('无权限访问此文件');
|
|
||||||
CollaborationFileService::authorizeAttachment($attachment, $outsider);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature;
|
|
||||||
|
|
||||||
use App\Models\WebSocketDialogMsg;
|
|
||||||
use App\Models\WebSocketDialogMsgAttachment;
|
|
||||||
use App\Models\WebSocketDialogMsgAttachmentBackfill;
|
|
||||||
use App\Services\MessageAttachmentBackfillService;
|
|
||||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class MessageAttachmentBackfillServiceTest extends TestCase
|
|
||||||
{
|
|
||||||
use DatabaseTransactions;
|
|
||||||
|
|
||||||
public function test_automatic_backfill_advances_persisted_cursor_to_completion(): void
|
|
||||||
{
|
|
||||||
$fileMessage = $this->makeMessage('file', [
|
|
||||||
'name' => 'backfill.pdf',
|
|
||||||
'ext' => 'pdf',
|
|
||||||
'path' => 'uploads/test/backfill.pdf',
|
|
||||||
'size' => 1024,
|
|
||||||
]);
|
|
||||||
$imageMessage = $this->makeMessage('text', [
|
|
||||||
'text' => '<p><img class="browse" width="320" height="180" src="uploads/test/backfill.png_thumb.jpg"/></p>',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$state = WebSocketDialogMsgAttachmentBackfill::query()->firstOrFail();
|
|
||||||
$state->update([
|
|
||||||
'status' => WebSocketDialogMsgAttachmentBackfill::STATUS_PENDING,
|
|
||||||
// 模拟迁移后、服务重启前又产生了一条图片消息。
|
|
||||||
'before_msg_id' => $fileMessage->id,
|
|
||||||
'last_msg_id' => $fileMessage->id - 1,
|
|
||||||
'processed_count' => 0,
|
|
||||||
'attachment_count' => 0,
|
|
||||||
'empty_count' => 0,
|
|
||||||
'failure_count' => 0,
|
|
||||||
'retry_count' => 0,
|
|
||||||
'last_error' => null,
|
|
||||||
'started_at' => null,
|
|
||||||
'completed_at' => null,
|
|
||||||
'next_retry_at' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$result = MessageAttachmentBackfillService::processAutomatic(1, 5);
|
|
||||||
|
|
||||||
$state->refresh();
|
|
||||||
$this->assertSame(WebSocketDialogMsgAttachmentBackfill::STATUS_COMPLETED, $result['status']);
|
|
||||||
$this->assertSame(WebSocketDialogMsgAttachmentBackfill::STATUS_COMPLETED, $state->status);
|
|
||||||
$this->assertSame($imageMessage->id, $state->before_msg_id);
|
|
||||||
$this->assertSame($imageMessage->id, $state->last_msg_id);
|
|
||||||
$this->assertSame(2, $state->processed_count);
|
|
||||||
$this->assertSame(2, $state->attachment_count);
|
|
||||||
$this->assertNotNull($state->completed_at);
|
|
||||||
$this->assertSame(2, WebSocketDialogMsgAttachment::whereIn('msg_id', [
|
|
||||||
$fileMessage->id,
|
|
||||||
$imageMessage->id,
|
|
||||||
])->count());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_automatic_backfill_waits_until_retry_time(): void
|
|
||||||
{
|
|
||||||
$state = WebSocketDialogMsgAttachmentBackfill::query()->firstOrFail();
|
|
||||||
$state->update([
|
|
||||||
'status' => WebSocketDialogMsgAttachmentBackfill::STATUS_FAILED,
|
|
||||||
'next_retry_at' => now()->addMinutes(5),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$result = MessageAttachmentBackfillService::processAutomatic(1, 1);
|
|
||||||
|
|
||||||
$this->assertSame('waiting', $result['status']);
|
|
||||||
$this->assertSame(WebSocketDialogMsgAttachmentBackfill::STATUS_FAILED, $state->fresh()->status);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function makeMessage(string $type, array $data): WebSocketDialogMsg
|
|
||||||
{
|
|
||||||
$message = WebSocketDialogMsg::createInstance([
|
|
||||||
'dialog_id' => 0,
|
|
||||||
'userid' => 0,
|
|
||||||
'type' => $type,
|
|
||||||
'key' => '',
|
|
||||||
'msg' => json_encode($data, JSON_UNESCAPED_UNICODE),
|
|
||||||
]);
|
|
||||||
$message->save();
|
|
||||||
return $message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,85 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Unit;
|
|
||||||
|
|
||||||
use App\Models\WebSocketDialogMsgAttachment;
|
|
||||||
use App\Services\MessageAttachmentService;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class MessageAttachmentServiceTest extends TestCase
|
|
||||||
{
|
|
||||||
public function test_extracts_file_message_metadata(): void
|
|
||||||
{
|
|
||||||
$attachments = MessageAttachmentService::extract('file', [
|
|
||||||
'name' => '方案.pdf',
|
|
||||||
'ext' => 'PDF',
|
|
||||||
'path' => 'uploads/chat/202608/1/plan.pdf',
|
|
||||||
'thumb' => 'images/ext/pdf.png',
|
|
||||||
'size' => 2048,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assertCount(1, $attachments);
|
|
||||||
$this->assertSame(WebSocketDialogMsgAttachment::SOURCE_FILE_MESSAGE, $attachments[0]['source_type']);
|
|
||||||
$this->assertSame(WebSocketDialogMsgAttachment::KIND_FILE, $attachments[0]['kind']);
|
|
||||||
$this->assertSame('pdf', $attachments[0]['ext']);
|
|
||||||
$this->assertSame(2048, $attachments[0]['size']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_classifies_uploaded_image_as_image_attachment(): void
|
|
||||||
{
|
|
||||||
$attachments = MessageAttachmentService::extract('file', [
|
|
||||||
'name' => 'design.png',
|
|
||||||
'ext' => 'png',
|
|
||||||
'path' => 'uploads/chat/202608/1/design.png',
|
|
||||||
'thumb' => 'uploads/chat/202608/1/design.png_thumb.jpg',
|
|
||||||
'size' => 4096,
|
|
||||||
'width' => 1200,
|
|
||||||
'height' => 800,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assertSame(WebSocketDialogMsgAttachment::KIND_IMAGE, $attachments[0]['kind']);
|
|
||||||
$this->assertSame(1200, $attachments[0]['width']);
|
|
||||||
$this->assertSame(800, $attachments[0]['height']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_extracts_each_browse_image_and_ignores_emoticons(): void
|
|
||||||
{
|
|
||||||
$html = <<<'HTML'
|
|
||||||
<p>
|
|
||||||
<img class="browse" width="640" height="480" src="{{RemoteURL}}uploads/chat/202608/1/design.png_thumb.jpg" alt="设计图"/>
|
|
||||||
<img class="emoticon" width="32" height="32" src="images/emoji/smile.png" alt="笑脸"/>
|
|
||||||
<img class="browse extra" width="320" height="240" src="{{RemoteURL}}uploads/chat/202608/1/design.png_thumb.jpg" alt="重复图片"/>
|
|
||||||
</p>
|
|
||||||
HTML;
|
|
||||||
|
|
||||||
$attachments = MessageAttachmentService::extract('text', ['text' => $html]);
|
|
||||||
|
|
||||||
$this->assertCount(2, $attachments);
|
|
||||||
$this->assertSame([0, 1], array_column($attachments, 'position'));
|
|
||||||
$this->assertSame('uploads/chat/202608/1/design.png', $attachments[0]['path']);
|
|
||||||
$this->assertSame('uploads/chat/202608/1/design.png_thumb.jpg', $attachments[0]['thumb']);
|
|
||||||
$this->assertSame('设计图', $attachments[0]['name']);
|
|
||||||
$this->assertSame('重复图片', $attachments[1]['name']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_extracts_legacy_image_token(): void
|
|
||||||
{
|
|
||||||
$attachments = MessageAttachmentService::extract('text', [
|
|
||||||
'text' => '<p>旧消息</p>[:IMAGE:browse:120:80:uploads/chat/202001/1/legacy.webp:旧截图:]',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assertCount(1, $attachments);
|
|
||||||
$this->assertSame('uploads/chat/202001/1/legacy.webp', $attachments[0]['path']);
|
|
||||||
$this->assertSame('webp', $attachments[0]['ext']);
|
|
||||||
$this->assertSame(120, $attachments[0]['width']);
|
|
||||||
$this->assertSame(80, $attachments[0]['height']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_ignores_broken_placeholder_and_non_attachment_messages(): void
|
|
||||||
{
|
|
||||||
$this->assertSame([], MessageAttachmentService::extract('text', [
|
|
||||||
'text' => '<img class="browse" width="90" height="90" src="images/other/imgerr.jpg"/>',
|
|
||||||
]));
|
|
||||||
$this->assertSame([], MessageAttachmentService::extract('meeting', ['name' => '周会']));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user