Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36da18af79 | ||
|
|
363badbc97 | ||
|
|
9be6265220 | ||
|
|
be53e6c6ac | ||
|
|
4eab130313 | ||
|
|
c706c515ee | ||
|
|
8a576595ce | ||
|
|
8c809bbff1 | ||
|
|
08ed396444 | ||
|
|
f5eb84589f | ||
|
|
daca384822 | ||
|
|
0a6e944a9a | ||
|
|
e0d1b08e89 | ||
|
|
6b54b7b1c5 | ||
|
|
adc7fb0d07 | ||
|
|
f969c8145c | ||
|
|
20b5daba50 | ||
|
|
aa2e0acaba | ||
|
|
e57736bcc1 | ||
|
|
a8db8dde7b | ||
|
|
635f6e5d5a | ||
|
|
4875574c6e | ||
|
|
b1d5652bc7 | ||
|
|
025f45df0a | ||
|
|
981a5c9f0f | ||
|
|
88cfd40abe | ||
|
|
cdcf0ff5f3 | ||
|
|
42e355149c | ||
|
|
518364d70d | ||
|
|
f25340c0b3 | ||
|
|
24f607f442 | ||
|
|
6fbddbe77c | ||
|
|
21ba2665b9 | ||
|
|
0888f599a4 | ||
|
|
ef7293704b | ||
|
|
8cd4669b90 | ||
|
|
7f7a82b4b8 | ||
|
|
0863e5529a | ||
|
|
e0ad8ce6c1 | ||
|
|
9f4e5a8335 | ||
|
|
587db459bf | ||
|
|
5b87714acf | ||
|
|
bc54ac9462 | ||
|
|
7e5b31cfb2 | ||
|
|
d81b4ed273 | ||
|
|
0c1a913134 | ||
|
|
7dc641e69e | ||
|
|
18336c870e | ||
|
|
e43588c3b2 | ||
|
|
64649b514e | ||
|
|
24710289e1 | ||
|
|
2a3f05e06f | ||
|
|
0d31106b0f | ||
|
|
fbd1c829a1 | ||
|
|
82d2ca6360 | ||
|
|
717e520556 | ||
|
|
c8ddb511cf | ||
|
|
caf728de8d | ||
|
|
a7cd4d7fa8 | ||
|
|
ddc0046e24 | ||
|
|
1059630b9d | ||
|
|
e1c1fc030f | ||
|
|
09edb14d56 | ||
|
|
f27cef2d66 | ||
|
|
07a2e6df29 | ||
|
|
f521f0df65 | ||
|
|
a67fcd6f02 | ||
|
|
d17f404853 | ||
|
|
8def4addc4 | ||
|
|
0ecaf9740f | ||
|
|
bc75680ee9 | ||
|
|
6a71964592 | ||
|
|
00a2ea3d2f | ||
|
|
95e97333b4 | ||
|
|
9e65500748 | ||
|
|
a2acd6f6e4 | ||
|
|
ee96730268 | ||
|
|
f925f238dd | ||
|
|
39c6ca3e8c | ||
|
|
c798faa8db |
119
.claude/skills/dootask-backup/SKILL.md
Normal file
119
.claude/skills/dootask-backup/SKILL.md
Normal file
@@ -0,0 +1,119 @@
|
||||
---
|
||||
description: 备份 DooTask 数据:数据库(必须)+ public/uploads(排除 tmp,可选)+ docker/appstore/config(可选)。汇总到临时目录并附 README 说明,打包到 backup/ 按日期命名。只读取源数据、绝不删改,失败即停。
|
||||
---
|
||||
|
||||
# DooTask 数据备份
|
||||
|
||||
**刚性技能**——前置检查 → 选可选项 → 确认 → 执行 → 报告。只读取源数据生成归档,**绝不删除或修改任何源数据/既有备份**。任何一步失败立即停止。
|
||||
|
||||
## 备份范围
|
||||
|
||||
| 项 | 来源 | 是否必须 | 说明 |
|
||||
|----|------|---------|------|
|
||||
| 数据库 | `./cmd mysql backup` 产出的 `.sql.gz` | **必须** | 脚本内部用 mysqldump 导出当前库 |
|
||||
| 上传文件 | `public/uploads`(**排除 `public/uploads/tmp`**) | 可选 | 头像/聊天/任务/文件等真实上传数据;`tmp` 是临时目录,可重建,不备份 |
|
||||
| 应用配置 | `docker/appstore/config` | 可选 | 应用市场各应用的配置;含 **root 属主子目录**,收集时可能需 sudo |
|
||||
|
||||
> `docker/appstore/apps` **不在备份范围**——可从应用市场重新安装,无需备份。
|
||||
|
||||
## 前置检查(全部通过才能继续)
|
||||
|
||||
1. **工作目录**:在项目根(存在 `cmd`、`docker-compose.yml`)
|
||||
2. **数据库容器**:`mariadb` 容器在跑(DB 备份依赖它;不在则提示用户先 `./cmd up` 起服务)
|
||||
3. **磁盘空间**:确认 `backup/` 所在盘空间足够(数据库 dump 可能较大)
|
||||
4. **选可选项**:询问用户本次是否包含 `public/uploads` 和 `docker/appstore/config`(**默认两个都含**)
|
||||
|
||||
检查通过、可选项确定后,汇报本次将备份哪些项,**向用户确认一次**再执行。
|
||||
|
||||
## 执行
|
||||
|
||||
用一个统一时间戳贯穿全程:`TS=$(date +%Y%m%d_%H%M%S)`,临时目录 `WORK="tmp/dootask-backup-${TS}"`。
|
||||
|
||||
### 1) 建临时工作目录
|
||||
```shell
|
||||
mkdir -p "$WORK"
|
||||
```
|
||||
(`tmp/` 已被 gitignore,安全)
|
||||
|
||||
### 2) 数据库(必须)
|
||||
```shell
|
||||
./cmd mysql backup
|
||||
```
|
||||
脚本会把 dump 写到 `docker/mysql/backup/<库名>_<时间戳>.sql.gz` 并打印「备份文件:...」。**取该次产出的最新 dump** 复制进工作目录(不用关心它原始落在哪):
|
||||
```shell
|
||||
DB_FILE=$(ls -t docker/mysql/backup/*.sql.gz | head -1)
|
||||
cp "$DB_FILE" "$WORK/"
|
||||
```
|
||||
|
||||
### 3) public/uploads(可选,排除 tmp)
|
||||
```shell
|
||||
rsync -a --exclude='tmp' public/uploads/ "$WORK/uploads/"
|
||||
```
|
||||
> 无 rsync 时用 tar 管道:`mkdir -p "$WORK/uploads" && tar cf - --exclude='./tmp' -C public/uploads . | tar xf - -C "$WORK/uploads"`
|
||||
|
||||
### 4) docker/appstore/config(可选)
|
||||
```shell
|
||||
cp -a docker/appstore/config "$WORK/appstore-config"
|
||||
```
|
||||
> 含 root 属主子目录,若报 `permission denied`:改用 `sudo cp -a ...`,随后把整个工作目录属主归还当前用户,保证后续打包/清理不受阻:
|
||||
> ```shell
|
||||
> sudo chown -R "$(id -u):$(id -g)" "$WORK"
|
||||
> ```
|
||||
|
||||
### 5) 写 README.md(备份说明)
|
||||
在 `$WORK/README.md` 写明本次备份信息,便于日后识别与还原。模板:
|
||||
```markdown
|
||||
# DooTask 备份 — <TS>
|
||||
|
||||
- 备份时间:<人类可读时间>
|
||||
- DooTask 版本:<取自 package.json 的 version>
|
||||
- 包含内容:
|
||||
- 数据库:<DB dump 文件名>(来源 mysqldump 当前库)
|
||||
- 上传文件:uploads/(来源 public/uploads,已排除 tmp) ← 未选则写「未包含」
|
||||
- 应用配置:appstore-config/(来源 docker/appstore/config) ← 未选则写「未包含」
|
||||
- 各项大小:<du -sh 列出工作目录内各项>
|
||||
|
||||
## 还原提示
|
||||
- 数据库:`gunzip < <db>.sql.gz | mysql -u<user> -p<pass> <库名>`,或用 `./cmd mysql recovery` 选对应文件还原。
|
||||
- 上传文件:将 uploads/ 内容覆盖回项目 public/uploads/。
|
||||
- 应用配置:将 appstore-config/ 覆盖回 docker/appstore/config/。
|
||||
```
|
||||
|
||||
### 6) 打包到 backup/,清理临时目录
|
||||
```shell
|
||||
mkdir -p backup
|
||||
tar czf "backup/dootask_backup_${TS}.tar.gz" -C tmp "dootask-backup-${TS}"
|
||||
rm -rf "$WORK"
|
||||
```
|
||||
|
||||
## 报告
|
||||
|
||||
向用户报告:
|
||||
- 最终归档路径:`backup/dootask_backup_<TS>.tar.gz`
|
||||
- 归档大小(`ls -lh`)
|
||||
- 实际包含了哪些项(数据库 + 视选择含/不含 uploads、appstore-config)
|
||||
|
||||
## 失败处理
|
||||
|
||||
- 任何步骤失败立即停止,原样报告错误
|
||||
- **不要**自动重试、不要静默跳过某一项(可选项是否包含由前置确认决定,不在执行中临时变更)
|
||||
- DB 备份失败(如 mariadb 未运行)→ 停止,提示用户起服务后重试
|
||||
- 打包前若工作目录有 root 属主残留导致 tar/rm 失败 → `sudo chown` 归还属主后继续,不要删源数据
|
||||
|
||||
## 禁止项
|
||||
|
||||
| 错误做法 | 正确做法 |
|
||||
|---------|---------|
|
||||
| 为"省空间"删除源数据或既有备份 | 只读取源数据生成归档,源数据一律不动 |
|
||||
| 备份 `public/uploads/tmp` | 排除 tmp(临时、可重建) |
|
||||
| 把 `docker/appstore/apps` 也打进去 | 不在范围,可从应用市场重装 |
|
||||
| 遇 config 的 root 子目录就跳过该项 | `sudo` 收集后 chown 归还,完整备份 |
|
||||
| 不写 README 直接打包 | 每个归档自带 README,便于日后识别还原 |
|
||||
| 把归档写进 git | 归档放 `backup/`(已 gitignore),不提交 |
|
||||
|
||||
## Red Flags —— 出现这些念头立即停下
|
||||
|
||||
- "源数据太大,删点旧的再备份" → 不,备份只读不删
|
||||
- "config 有 root 目录,跳过算了" → 不,sudo 收集后归还属主
|
||||
- "apps 也一起备了更全" → 不,apps 不在范围
|
||||
- "tmp 里临时文件顺手也备了" → 不,明确排除 `public/uploads/tmp`
|
||||
76
.claude/skills/dootask-fix-permission/SKILL.md
Normal file
76
.claude/skills/dootask-fix-permission/SKILL.md
Normal file
@@ -0,0 +1,76 @@
|
||||
---
|
||||
name: dootask-fix-permission
|
||||
description: 修复 DooTask 可写目录(bootstrap/cache、docker、public、storage)的属主/权限:chown 回当前用户 + 目录 chmod 775,对齐 install 的赋权逻辑,赋权不删数据。
|
||||
---
|
||||
|
||||
# DooTask 目录权限修复
|
||||
|
||||
容器内进程常以 **root** 写入挂载目录(`storage`、`public/uploads`、`bootstrap/cache` 等),导致宿主机当前用户对这些文件**没有写权限**,进而触发:
|
||||
|
||||
- `./cmd install` 报「目录【xxx】权限不足」/ 目录权限检测失败
|
||||
- `./cmd build`(vite)报 `EACCES: permission denied, copyfile`(复制 `public/uploads/...` 时)
|
||||
- Laravel 运行时写 `storage`/`bootstrap/cache` 失败
|
||||
|
||||
本技能**对齐 `./cmd install` 的目录赋权逻辑**:对四个可写目录做 `chmod 775`(目录)+ `chown` 回当前用户。
|
||||
|
||||
## 适用目录
|
||||
|
||||
与 install 一致的四个:
|
||||
|
||||
```
|
||||
bootstrap/cache
|
||||
docker
|
||||
public # 含 public/uploads(真实上传数据)
|
||||
storage
|
||||
```
|
||||
|
||||
## 核心原则:赋权,不删数据
|
||||
|
||||
`public/uploads` 含真实上传文件(头像、附件等)。**永远优先 `chown` 改属主,不要删数据。** 即便用户说"清理一下",也只允许清临时目录 `public/uploads/tmp`,**切勿**删 uploads 下其他内容。
|
||||
|
||||
## 前置检查
|
||||
|
||||
1. **工作目录**:在项目根(存在 `cmd` 且这四个目录在)
|
||||
2. **sudo**:改属主需 root(当前文件多为 root 属主)。本机一般可免密 sudo;不行则经 docker 以 root 改权限
|
||||
3. 确认要修的范围:默认四个目录全修;若用户只想解 build 报错,也可只针对 `public`(含 `public/uploads`)
|
||||
|
||||
检查通过后汇报将执行的命令,**向用户确认一次**再执行。
|
||||
|
||||
## 执行
|
||||
|
||||
确认后执行(属主修回当前用户,目录权限 775):
|
||||
|
||||
```shell
|
||||
# 1) 属主修回当前用户(递归)
|
||||
sudo chown -R "$(id -u):$(id -g)" bootstrap/cache docker public storage
|
||||
|
||||
# 2) 目录权限 775(仅目录,对齐 install 的 `find -type d -exec chmod 775`)
|
||||
find bootstrap/cache docker public storage -type d -exec chmod 775 {} \;
|
||||
```
|
||||
|
||||
> 只想解 build 的 uploads 报错时,可只对 `public`:
|
||||
> ```shell
|
||||
> sudo chown -R "$(id -u):$(id -g)" public/uploads
|
||||
> ```
|
||||
|
||||
执行后报告:改了哪些目录、属主/权限现状(可 `ls -ld` 抽查),并提示用户可重试之前失败的 install/build/update。
|
||||
|
||||
## 失败处理
|
||||
|
||||
- `chown` 报权限不足 → 当前用户无 sudo 权限,提示用户用有 root 权限的账户,或经 docker 以 root 执行;不要静默跳过
|
||||
- 任何步骤失败立即停止报告,不自动重试
|
||||
|
||||
## 禁止项
|
||||
|
||||
| 错误做法 | 正确做法 |
|
||||
|---------|---------|
|
||||
| build 报 uploads EACCES 就 `rm` 删文件 | `chown` 修属主,保留数据 |
|
||||
| 删整个 `public/uploads` 清场 | 最多清 `public/uploads/tmp`,别碰真实上传数据 |
|
||||
| 对文件无差别 `chmod 777` | 目录 `chmod 775` + `chown` 回当前用户即可 |
|
||||
| 不加 sudo 直接 chown root 文件 | 改属主需 root |
|
||||
|
||||
## Red Flags —— 出现这些念头立即停下
|
||||
|
||||
- "uploads 复制失败,删掉再 build" → 不,`chown` 赋权,不丢数据
|
||||
- "777 一把梭最省事" → 不,按 install 的 775(目录)+ chown
|
||||
- "权限不够就跳过这个目录" → 不,报告交用户处理 sudo
|
||||
74
.claude/skills/dootask-install/SKILL.md
Normal file
74
.claude/skills/dootask-install/SKILL.md
Normal file
@@ -0,0 +1,74 @@
|
||||
---
|
||||
name: dootask-install
|
||||
description: 首次部署 DooTask:前置检查后执行 `sudo ./cmd install`(建库 + migrate --seed 的重操作),刚性流程、单次确认、失败即停。
|
||||
---
|
||||
|
||||
# DooTask 安装流程
|
||||
|
||||
**刚性技能**——前置检查 → 向用户确认一次 → 执行 → 报告结果。任何一步失败立即停止。
|
||||
|
||||
## 核心原则
|
||||
|
||||
**违反字面规则 = 违反流程精神。** 不要擅自增加、省略、合并步骤,不要为"省事"绕过 sudo 或确认。
|
||||
|
||||
`./cmd install` 已把整套安装封装为单条命令(赋权→起容器→`composer install`→`key:generate`→`migrate --seed`→`up -d`)。本技能的职责是**安装前把关、选对参数、执行前确认、已知失败处理**,而不是把脚本逻辑拆开重做。
|
||||
|
||||
## 前置检查(全部通过才能继续)
|
||||
|
||||
执行前依次确认:
|
||||
|
||||
1. **工作目录**:必须在项目根(存在 `cmd`、`docker-compose.yml`、`.env.docker`)
|
||||
2. **Docker**:`docker` 与 `docker-compose`/`docker compose`(v2+) 可用且 daemon 在跑(脚本 `check_docker` 也会查,但提前确认能更早报错)
|
||||
3. **Node.js ≥ 20**(脚本 `check_node` 会查)
|
||||
4. **APP_ID 不冲突**:若 `.env` 已有 `APP_ID` 且被其他实例占用,脚本 `check_instance` 会报错——此时**停止**,提示用户先清空 `.env` 里的 `APP_ID` 和 `APP_IPPR` 再装
|
||||
5. **sudo**:`./cmd install` 需 root(`check_sudo`),用 `sudo ./cmd install` 执行
|
||||
|
||||
⚠️ **这是重操作**:会创建数据库并执行 `migrate --seed`(灌入种子数据)。在已有数据的环境上重装前务必和用户确认,避免覆盖。
|
||||
|
||||
检查通过后汇报结果,**向用户确认一次**再执行。
|
||||
|
||||
## 参数选择
|
||||
|
||||
| 参数 | 作用 | 何时用 |
|
||||
|------|------|--------|
|
||||
| `--port <端口>` | 指定 HTTP 端口(脚本会做端口占用检测) | 用户要自定义端口,或默认端口被占 |
|
||||
| `--relock` | 删除 `node_modules`/`package-lock.json`/`vendor`/`composer.lock` 后重装 | **谨慎**:仅在依赖锁损坏、用户明确要求重建锁时用,会拖慢安装 |
|
||||
|
||||
不确定时不要自作主张加参数,按需询问用户。
|
||||
|
||||
## 执行
|
||||
|
||||
确认后执行(按用户选择带上参数):
|
||||
|
||||
```shell
|
||||
sudo ./cmd install
|
||||
# 或: sudo ./cmd install --port 8080
|
||||
```
|
||||
|
||||
成功后脚本会输出访问地址并调用 `repassword.sh`。执行完向用户报告:访问地址(`http://127.0.0.1:<APP_PORT>`)、以及数据库密码提示。
|
||||
|
||||
## 失败处理
|
||||
|
||||
- 任何步骤失败立即停止,原样报告错误信息
|
||||
- **不要**自动重试,**不要**自动跳过
|
||||
- 常见失败与对应处理:
|
||||
- `APP_ID(xxx)已被其他实例使用` → 停止,让用户清空 `.env` 的 `APP_ID`/`APP_IPPR` 再装
|
||||
- `端口 xxx 已被占用` → 停止,让用户换 `--port`
|
||||
- `目录【xxx】权限不足` / 目录权限检测失败 → 这是目录属主/权限问题,引导用户用 **dootask-fix-permission** 技能修复后重装
|
||||
- `安装依赖失败`(composer)→ 报告,交用户决定(常因网络/镜像源)
|
||||
|
||||
## 禁止项
|
||||
|
||||
| 错误做法 | 正确做法 |
|
||||
|---------|---------|
|
||||
| 不加 sudo 直接 `./cmd install` | 用 `sudo ./cmd install`(脚本强制 root) |
|
||||
| 失败后"我再试一次"或自动跳过 | 立即停止,交还用户 |
|
||||
| 在已有数据环境上不问就重装 | 先确认会 `migrate --seed`,可能影响现有数据 |
|
||||
| 遇权限报错自己乱 `chmod`/`chown` | 走 dootask-fix-permission 技能统一处理 |
|
||||
| 不问就加 `--relock` | 默认不加;仅用户明确要求或锁损坏时用 |
|
||||
|
||||
## Red Flags —— 出现这些念头立即停下
|
||||
|
||||
- "端口/权限报错了我顺手帮 TA 改一下别的" → 停下,只处理本次报的问题,按指引走对应技能
|
||||
- "种子数据应该没事,直接重装" → 不,先确认是否会覆盖现有数据
|
||||
- "sudo 麻烦,先试试不加" → 不,install 必须 root
|
||||
111
.claude/skills/dootask-release/SKILL.md
Normal file
111
.claude/skills/dootask-release/SKILL.md
Normal file
@@ -0,0 +1,111 @@
|
||||
---
|
||||
name: dootask-release
|
||||
description: 从 `pro` 分支发布 DooTask 前端新版本:刚性顺序流程 translate → version → build → commit → push,前置检查 + 每步确认、失败即停。
|
||||
---
|
||||
|
||||
# DooTask 发布流程
|
||||
|
||||
**刚性技能**——严格按顺序执行,每步向用户确认,任何一步失败立即停止。
|
||||
|
||||
## 核心原则
|
||||
|
||||
**违反字面规则 = 违反流程精神。** 不要擅自增加、省略、合并或重排步骤。
|
||||
|
||||
## 前置检查(全部通过才能继续)
|
||||
|
||||
执行任何发布步骤前,依次检查:
|
||||
|
||||
1. **分支**:必须是 `pro`,否则停止,提示用户切换
|
||||
2. **工作区**:`git status` 必须干净(无未提交变更、无未跟踪文件),否则**停止**并交由用户处理
|
||||
3. **Node.js**:必须 ≥ 20,否则停止
|
||||
|
||||
检查通过后汇报结果,用户确认后再开始执行。
|
||||
|
||||
## 发布步骤
|
||||
|
||||
**每步执行前**向用户确认;**每步执行后**报告结果。
|
||||
|
||||
### Step 1: 翻译
|
||||
```shell
|
||||
npm run translate
|
||||
```
|
||||
更新多语言翻译文件。
|
||||
|
||||
### Step 2: 版本号
|
||||
```shell
|
||||
npm run version
|
||||
```
|
||||
更新版本号。
|
||||
|
||||
### Step 3: 构建前端
|
||||
```shell
|
||||
npm run build
|
||||
```
|
||||
构建前端生产版本。
|
||||
|
||||
> **已知失败**:若 build 报 `public/uploads/...` 的 `EACCES: permission denied, copyfile`,是 vite 复制 `public/` 目录时碰到运行时残留的上传文件——这些文件常为 **root 属主**(容器内 root 进程写入),复制需覆盖写入,构建用户没有写权限。报错路径可能落在 `public/uploads` 下任意子目录(`tmp`、`avatar` 等),不限于 `tmp`。这不是代码问题。**补救(赋权,不删数据)**:把整个 uploads 的属主改回当前用户后重试 build:
|
||||
> ```shell
|
||||
> sudo chown -R "$(id -u):$(id -g)" public/uploads
|
||||
> ```
|
||||
> 需 root(本机可免密 sudo;或经 docker 以 root 改权限)。**优先赋权,不要删**——`public/uploads` 含真实上传数据。即便用户要求清理,也只清临时目录 `public/uploads/tmp`,切勿删 uploads 下其他内容。
|
||||
|
||||
## 最终:提交并推送
|
||||
|
||||
所有步骤完成后:
|
||||
|
||||
1. 通过 `git diff` + `git status` 汇总所有变更,向用户报告摘要
|
||||
2. **询问用户是否提交并推送**
|
||||
3. 用户明确确认后才执行 `git add`、`git commit`、`git push`
|
||||
4. 未确认一律不执行
|
||||
|
||||
提交规范:
|
||||
- 提交信息使用 `release: v<新版本号>`(与历史提交风格一致,参见 `git log --oneline | grep '^release:'`)
|
||||
- **只 add 本次发布相关改动**,按文件名显式添加(例如 `git add package.json public/js/...`),**不要用 `git add -A` 或 `git add .`**,以免卷入未跟踪的本地实验文件
|
||||
|
||||
## push 之后:确认发布工作流(CI 才是真正出包)
|
||||
|
||||
push 到 `pro` 只是触发器,真正的构建/出包由 GitHub Actions 完成——**push 成功 ≠ 发布完成**:
|
||||
|
||||
- **Publish**(`.github/workflows/publish.yml`,push→pro 触发)跑完才算出包;成功后会自动触发 **Sync to Gitee**(镜像同步)。
|
||||
- push 完成后**主动确认** Publish 工作流 `conclusion=success`。优先用 `gh`(未装可临时装;公开仓库也可用 GitHub REST API 免鉴权读取 runs):
|
||||
```shell
|
||||
gh run list --workflow=publish.yml -R kuaifan/dootask -L 1
|
||||
gh run view <run-id> -R kuaifan/dootask --json status,conclusion,url
|
||||
```
|
||||
- 工作流仍在跑时,挂后台轮询、结束即通知用户,**不要在前台死等**。
|
||||
|
||||
### 可选:iOS 发布
|
||||
|
||||
`ios-publish.yml` 是**独立的手动工作流**(`workflow_dispatch`),不随 push 触发。**仅当用户明确要求**发 iOS 时执行:
|
||||
```shell
|
||||
gh workflow run ios-publish.yml --ref pro -R kuaifan/dootask
|
||||
```
|
||||
需 `gh` 已登录且 token 含 `workflow` 权限。触发后同样可挂后台轮询其结果。
|
||||
|
||||
## 失败处理
|
||||
|
||||
- 任何步骤失败立即停止,报告错误信息
|
||||
- **不要**自动重试
|
||||
- **不要**自动跳过失败步骤
|
||||
- 由用户决定如何处理
|
||||
|
||||
## 禁止项(基线测试暴露的反模式)
|
||||
|
||||
| 错误做法 | 正确做法 |
|
||||
|---------|---------|
|
||||
| 遇到脏工作区主动提出修复方案(加 `.gitignore`、先 push 等) | **停下**,报告脏工作区事实,交用户决定 |
|
||||
| 增加 `git tag v1.7.xx` 步骤 | DooTask 现行发布流程**不打 tag**,不要擅自添加 |
|
||||
| `git add -A` / `git add .` | 按文件名显式添加发布相关改动 |
|
||||
| 一次性 add + commit + push,不给确认机会 | 摘要 → 问确认 → 再 add/commit/push 三步分离 |
|
||||
| 把 translate/version/build 顺序自作主张调整 | 顺序固定为 translate → version → build |
|
||||
| 失败后"我再试一次"或"跳过这步" | 立即停止,交还给用户 |
|
||||
|
||||
## Red Flags —— 出现这些念头立即停下
|
||||
|
||||
- "这个脏工作区我来帮 TA 搞定一下" → 停下,交用户
|
||||
- "顺便打个 tag 吧" → 不,没有这一步
|
||||
- "`git add -A` 省事" → 不,显式 add
|
||||
- "翻译这步没改动可以跳" → 不,按顺序执行、执行后报告结果即可
|
||||
- "一起 commit + push 一气呵成" → 必须先让用户确认
|
||||
- "push 上去了,发布就完成了" → 不,push 只是触发器,要确认 GitHub Actions 的 Publish 工作流 success
|
||||
- "build 报 uploads 权限错,我直接删掉" → 优先 `chown` 赋权整个 `public/uploads`(不丢数据);真要删也只删 `tmp`,别碰 uploads 下真实上传数据
|
||||
83
.claude/skills/dootask-update/SKILL.md
Normal file
83
.claude/skills/dootask-update/SKILL.md
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
name: dootask-update
|
||||
description: 更新已部署的 DooTask:前置检查后执行 `sudo ./cmd update`(拉代码 + composer + 迁移 + 重启),本地有改动时停下交用户决定,不自动强制、失败即停。
|
||||
---
|
||||
|
||||
# DooTask 更新流程
|
||||
|
||||
**刚性技能**——前置检查 → 向用户确认一次 → 执行 → 报告结果。任何一步失败立即停止。
|
||||
|
||||
## 核心原则
|
||||
|
||||
**违反字面规则 = 违反流程精神。** 不要擅自加步骤、绕过 sudo/确认,**尤其不要替用户决定强制更新**(会丢本地改动)。
|
||||
|
||||
`./cmd update` 已封装整套更新(检测本地改动→`git fetch`→必要时备份库→`git pull/reset`→`composer install`→`migrate`→重启 php+nginx→写 `UPDATE_TIME`)。本技能职责是**更新前把关、选对参数、处理本地改动这一关键岔路、执行前确认**。
|
||||
|
||||
## 前置检查(全部通过才能继续)
|
||||
|
||||
1. **已安装**:必须存在 `vendor/autoload.php`(脚本会查,没装则报"请先执行安装命令"——此时引导用户走 dootask-install)
|
||||
2. **工作目录**:在项目根
|
||||
3. **当前分支 / 目标分支**:默认更新当前分支;用户要切分支用 `--branch <分支>`。若用户没说,确认是否就更新当前分支
|
||||
4. **本地改动**(关键):`git status` 看是否有未提交改动
|
||||
5. **sudo**:`sudo ./cmd update` 需 root
|
||||
|
||||
检查通过后汇报结果,**向用户确认一次**再执行。
|
||||
|
||||
## 关键岔路:本地有改动
|
||||
|
||||
脚本检测到本地改动时会询问是否强制更新。**强制更新 = `git reset --hard origin/<分支>`,会丢弃所有本地改动。**
|
||||
|
||||
- 发现本地有改动 → **停下**,把改动清单报告用户,让**用户决定**:先提交/暂存改动,还是确认强制更新
|
||||
- **不要**替用户选 `--force`
|
||||
- 只有用户明确说"丢掉改动强制更新"时,才带 `--force`
|
||||
|
||||
## 参数选择
|
||||
|
||||
| 参数 | 作用 | 何时用 |
|
||||
|------|------|--------|
|
||||
| `--branch <分支>` | 切到指定分支再更新 | 用户要换分支(如切 `dev`/`pro`) |
|
||||
| `--force` | 强制更新:`git checkout -f` + `git reset --hard` | **危险**:仅用户明确接受"丢弃本地改动"后 |
|
||||
| `--local` | 本地更新模式:只备份库 + `migrate` + 重启,不拉远程代码 | 代码已就位(如手动改过/CI 拉过),只需迁移+重启 |
|
||||
|
||||
## 数据库
|
||||
|
||||
- 远程模式下,脚本检测到 `database/` 目录有迁移变动会**自动备份数据库**再继续——这是脚本内置的,无需手动。
|
||||
- 但若是大版本升级或用户在意数据,执行前提醒用户:本次可能含库迁移,已有自动备份兜底;如需可先 `./cmd mysql backup` 额外备份。
|
||||
|
||||
## 执行
|
||||
|
||||
确认(含本地改动决策)后执行:
|
||||
|
||||
```shell
|
||||
sudo ./cmd update
|
||||
# 切分支: sudo ./cmd update --branch pro
|
||||
# 强制(丢改动,用户确认后): sudo ./cmd update --force
|
||||
# 本地模式: sudo ./cmd update --local
|
||||
```
|
||||
|
||||
成功后报告:更新到的分支、是否做了库备份/迁移、服务是否重启完成。
|
||||
|
||||
## 失败处理
|
||||
|
||||
- 任何步骤失败立即停止,原样报告错误
|
||||
- **不要**自动重试、不要自动跳过、不要因为 `git pull` 失败就自己改成 `--force`
|
||||
- 常见失败:
|
||||
- `请先执行安装命令` → 走 dootask-install
|
||||
- `代码拉取失败,可能存在冲突` → 报告,让用户决定是否 `--force`(丢改动)或先处理冲突
|
||||
- 重启服务失败 → 脚本会尝试 `down` 后重起;若仍失败,报告交用户
|
||||
|
||||
## 禁止项
|
||||
|
||||
| 错误做法 | 正确做法 |
|
||||
|---------|---------|
|
||||
| 检测到本地改动就自动 `--force` | 停下,报告改动,交用户决定 |
|
||||
| `git pull` 失败就自动改用 `--force` | 报告冲突,交用户 |
|
||||
| 不加 sudo | `sudo ./cmd update` |
|
||||
| 未装就更新 | 先走 dootask-install |
|
||||
| 失败后自动重试/跳过 | 立即停止 |
|
||||
|
||||
## Red Flags —— 出现这些念头立即停下
|
||||
|
||||
- "有点本地改动,强制更新一下就好了" → 不,`--force` 会丢改动,必须用户拍板
|
||||
- "拉取冲突了,我 reset 一下" → 不,交用户决定
|
||||
- "已经装过了吧,直接更新" → 先确认 `vendor/autoload.php` 在
|
||||
432
.github/workflows/ios-publish.yml
vendored
Normal file
432
.github/workflows/ios-publish.yml
vendored
Normal file
@@ -0,0 +1,432 @@
|
||||
name: "iOS Publish"
|
||||
|
||||
# Required GitHub Secrets:
|
||||
#
|
||||
# IOS_CERTIFICATE_BASE64 - Apple distribution certificate (.p12) encoded in base64
|
||||
# IOS_CERTIFICATE_PASSWORD - Password for the .p12 certificate
|
||||
# IOS_PROVISION_PROFILE_BASE64 - App Store provisioning profile (.mobileprovision) encoded in base64
|
||||
# IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64 - Share extension App Store provisioning profile (.mobileprovision) encoded in base64
|
||||
# ASC_API_KEY_P8_BASE64 - App Store Connect API key (.p8) encoded in base64
|
||||
# ASC_API_KEY_ID - App Store Connect API Key ID
|
||||
# ASC_ISSUER_ID - App Store Connect Issuer ID
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ios-publish-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
prepare-assets:
|
||||
name: Prepare iOS Assets
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
outputs:
|
||||
version: ${{ steps.get-version.outputs.version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Get version from package.json
|
||||
id: get-version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Version: $VERSION"
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Install electron dependencies
|
||||
run: |
|
||||
pushd electron
|
||||
npm install
|
||||
popd
|
||||
|
||||
- name: Init mobile submodule
|
||||
run: |
|
||||
git submodule init
|
||||
git submodule update --remote "resources/mobile"
|
||||
|
||||
- name: Build app assets
|
||||
run: ./cmd appbuild publish
|
||||
|
||||
- name: Upload iOS platform artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ios-platform
|
||||
path: resources/mobile/platforms/ios/
|
||||
retention-days: 1
|
||||
|
||||
build-ios:
|
||||
name: Build & Submit iOS
|
||||
needs: prepare-assets
|
||||
runs-on: macos-26
|
||||
timeout-minutes: 60
|
||||
environment: build
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Init mobile submodule
|
||||
run: |
|
||||
git submodule init
|
||||
git submodule update --remote "resources/mobile"
|
||||
|
||||
- name: Download prepared assets
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ios-platform
|
||||
path: resources/mobile/platforms/ios/
|
||||
|
||||
- name: Select Xcode
|
||||
uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: latest-stable
|
||||
|
||||
- name: Install CocoaPods
|
||||
run: |
|
||||
if [ -f "resources/mobile/platforms/ios/eeuiApp/Podfile" ]; then
|
||||
cd resources/mobile/platforms/ios/eeuiApp
|
||||
pod install
|
||||
fi
|
||||
|
||||
- name: Import signing certificate
|
||||
env:
|
||||
IOS_CERTIFICATE_BASE64: ${{ secrets.IOS_CERTIFICATE_BASE64 }}
|
||||
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
||||
run: |
|
||||
# Create temporary keychain
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
KEYCHAIN_PASSWORD=$(openssl rand -hex 20)
|
||||
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
|
||||
# Import certificate
|
||||
CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12
|
||||
echo "$IOS_CERTIFICATE_BASE64" | base64 --decode > "$CERTIFICATE_PATH"
|
||||
security import "$CERTIFICATE_PATH" \
|
||||
-P "$IOS_CERTIFICATE_PASSWORD" \
|
||||
-A \
|
||||
-t cert \
|
||||
-f pkcs12 \
|
||||
-k "$KEYCHAIN_PATH"
|
||||
|
||||
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security list-keychain -d user -s "$KEYCHAIN_PATH"
|
||||
|
||||
- name: Import provisioning profile
|
||||
env:
|
||||
IOS_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_PROVISION_PROFILE_BASE64 }}
|
||||
IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
APP_PROFILE_PATH=$RUNNER_TEMP/app.mobileprovision
|
||||
SHARE_PROFILE_PATH=$RUNNER_TEMP/share-extension.mobileprovision
|
||||
APP_PROFILE_PLIST=$RUNNER_TEMP/app-profile.plist
|
||||
SHARE_PROFILE_PLIST=$RUNNER_TEMP/share-extension-profile.plist
|
||||
|
||||
echo "$IOS_PROVISION_PROFILE_BASE64" | base64 --decode > "$APP_PROFILE_PATH"
|
||||
echo "$IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64" | base64 --decode > "$SHARE_PROFILE_PATH"
|
||||
|
||||
security cms -D -i "$APP_PROFILE_PATH" > "$APP_PROFILE_PLIST"
|
||||
security cms -D -i "$SHARE_PROFILE_PATH" > "$SHARE_PROFILE_PLIST"
|
||||
|
||||
APP_PROFILE_NAME=$(/usr/libexec/PlistBuddy -c "Print :Name" "$APP_PROFILE_PLIST")
|
||||
SHARE_PROFILE_NAME=$(/usr/libexec/PlistBuddy -c "Print :Name" "$SHARE_PROFILE_PLIST")
|
||||
IOS_TEAM_ID=$(/usr/libexec/PlistBuddy -c "Print :TeamIdentifier:0" "$APP_PROFILE_PLIST")
|
||||
APP_PROFILE_APP_ID=$(/usr/libexec/PlistBuddy -c "Print :Entitlements:application-identifier" "$APP_PROFILE_PLIST")
|
||||
SHARE_PROFILE_APP_ID=$(/usr/libexec/PlistBuddy -c "Print :Entitlements:application-identifier" "$SHARE_PROFILE_PLIST")
|
||||
|
||||
if [ "$APP_PROFILE_APP_ID" != "$IOS_TEAM_ID.com.dootask.task" ]; then
|
||||
echo "Expected app profile for $IOS_TEAM_ID.com.dootask.task, got $APP_PROFILE_APP_ID"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$SHARE_PROFILE_APP_ID" != "$IOS_TEAM_ID.com.dootask.task.shareExtension" ]; then
|
||||
echo "Expected share extension profile for $IOS_TEAM_ID.com.dootask.task.shareExtension, got $SHARE_PROFILE_APP_ID"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! /usr/libexec/PlistBuddy -c "Print :Entitlements:aps-environment" "$APP_PROFILE_PLIST" >/dev/null; then
|
||||
echo "The DooTask app profile must include Push Notifications."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! /usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.security.application-groups" "$APP_PROFILE_PLIST" | grep -q "group.im.dootask"; then
|
||||
echo "The DooTask app profile must include App Group group.im.dootask."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! /usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.security.application-groups" "$SHARE_PROFILE_PLIST" | grep -q "group.im.dootask"; then
|
||||
echo "The share extension profile must include App Group group.im.dootask."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
cp "$APP_PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/
|
||||
cp "$SHARE_PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/
|
||||
|
||||
echo "APP_PROFILE_NAME=$APP_PROFILE_NAME" >> $GITHUB_ENV
|
||||
echo "SHARE_PROFILE_NAME=$SHARE_PROFILE_NAME" >> $GITHUB_ENV
|
||||
echo "IOS_TEAM_ID=$IOS_TEAM_ID" >> $GITHUB_ENV
|
||||
|
||||
- name: Configure manual signing
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
ruby <<'RUBY'
|
||||
require 'xcodeproj'
|
||||
|
||||
project_path = 'resources/mobile/platforms/ios/eeuiApp/eeuiApp.xcodeproj'
|
||||
project = Xcodeproj::Project.open(project_path)
|
||||
|
||||
{
|
||||
'DooTask' => ENV.fetch('APP_PROFILE_NAME'),
|
||||
'ShareExtension' => ENV.fetch('SHARE_PROFILE_NAME')
|
||||
}.each do |target_name, profile_name|
|
||||
target = project.targets.find { |item| item.name == target_name }
|
||||
abort "Target #{target_name} not found in #{project_path}" unless target
|
||||
|
||||
target.build_configurations.each do |config|
|
||||
next unless config.name == 'Release'
|
||||
|
||||
config.build_settings['CODE_SIGN_STYLE'] = 'Manual'
|
||||
config.build_settings['DEVELOPMENT_TEAM'] = ENV.fetch('IOS_TEAM_ID')
|
||||
config.build_settings['CODE_SIGN_IDENTITY'] = 'Apple Distribution'
|
||||
config.build_settings['PROVISIONING_PROFILE_SPECIFIER'] = profile_name
|
||||
end
|
||||
end
|
||||
|
||||
project.save
|
||||
RUBY
|
||||
|
||||
- name: Resolve iOS build number
|
||||
env:
|
||||
ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||
ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
ruby <<'RUBY'
|
||||
require 'base64'
|
||||
require 'json'
|
||||
require 'net/http'
|
||||
require 'openssl'
|
||||
require 'uri'
|
||||
|
||||
BUNDLE_ID = 'com.dootask.task'
|
||||
VERSION_CONFIG_PATH = 'resources/mobile/platforms/ios/eeuiApp/Config/Version.xcconfig'
|
||||
|
||||
def base64url(value)
|
||||
Base64.urlsafe_encode64(value).delete('=')
|
||||
end
|
||||
|
||||
def jwt_es256_signature(private_key, unsigned)
|
||||
der_signature = private_key.sign('SHA256', unsigned)
|
||||
sequence = OpenSSL::ASN1.decode(der_signature)
|
||||
|
||||
sequence.value.map { |integer|
|
||||
integer.value.to_s(2).rjust(32, "\0")[-32, 32]
|
||||
}.join
|
||||
end
|
||||
|
||||
def asc_token
|
||||
key_id = ENV.fetch('ASC_API_KEY_ID')
|
||||
issuer_id = ENV.fetch('ASC_ISSUER_ID')
|
||||
private_key = OpenSSL::PKey.read(Base64.decode64(ENV.fetch('ASC_API_KEY_P8_BASE64')))
|
||||
now = Time.now.to_i
|
||||
|
||||
header = { alg: 'ES256', kid: key_id, typ: 'JWT' }
|
||||
payload = {
|
||||
iss: issuer_id,
|
||||
iat: now,
|
||||
exp: now + 20 * 60,
|
||||
aud: 'appstoreconnect-v1'
|
||||
}
|
||||
|
||||
unsigned = "#{base64url(header.to_json)}.#{base64url(payload.to_json)}"
|
||||
signature = jwt_es256_signature(private_key, unsigned)
|
||||
"#{unsigned}.#{base64url(signature)}"
|
||||
end
|
||||
|
||||
def asc_get(path, params, token)
|
||||
uri = URI::HTTPS.build(
|
||||
host: 'api.appstoreconnect.apple.com',
|
||||
path: path,
|
||||
query: URI.encode_www_form(params)
|
||||
)
|
||||
|
||||
request_uri = uri
|
||||
loop do
|
||||
response = Net::HTTP.start(request_uri.host, request_uri.port, use_ssl: true) do |http|
|
||||
request = Net::HTTP::Get.new(request_uri)
|
||||
request['Authorization'] = "Bearer #{token}"
|
||||
http.request(request)
|
||||
end
|
||||
|
||||
unless response.is_a?(Net::HTTPSuccess)
|
||||
abort "App Store Connect API request failed: #{response.code} #{response.body}"
|
||||
end
|
||||
|
||||
parsed = JSON.parse(response.body)
|
||||
yield parsed
|
||||
|
||||
next_link = parsed.dig('links', 'next')
|
||||
break unless next_link
|
||||
|
||||
request_uri = URI(next_link)
|
||||
end
|
||||
end
|
||||
|
||||
token = asc_token
|
||||
app_id = nil
|
||||
|
||||
asc_get('/v1/apps', { 'filter[bundleId]' => BUNDLE_ID, 'limit' => 1 }, token) do |page|
|
||||
app_id = page.fetch('data').first&.fetch('id')
|
||||
end
|
||||
|
||||
abort "App Store Connect app not found for bundle id #{BUNDLE_ID}" unless app_id
|
||||
|
||||
existing_versions = []
|
||||
asc_get('/v1/builds', {
|
||||
'filter[app]' => app_id,
|
||||
'fields[builds]' => 'version',
|
||||
'limit' => 200
|
||||
}, token) do |page|
|
||||
existing_versions.concat(
|
||||
page.fetch('data').map { |build| build.dig('attributes', 'version').to_s }
|
||||
)
|
||||
end
|
||||
|
||||
max_build_number = existing_versions
|
||||
.select { |version| version.match?(/\A\d+\z/) }
|
||||
.map(&:to_i)
|
||||
.max || 0
|
||||
|
||||
next_build_number = max_build_number + 1
|
||||
config_content = File.exist?(VERSION_CONFIG_PATH) ? File.read(VERSION_CONFIG_PATH) : ''
|
||||
|
||||
if config_content.match?(/^VERSION_CODE\s*=/)
|
||||
config_content = config_content.gsub(/^VERSION_CODE\s*=.*$/, "VERSION_CODE = #{next_build_number}")
|
||||
else
|
||||
config_content = "#{config_content.rstrip}\nVERSION_CODE = #{next_build_number}\n"
|
||||
end
|
||||
|
||||
File.write(VERSION_CONFIG_PATH, config_content)
|
||||
File.open(ENV.fetch('GITHUB_ENV'), 'a') { |file| file.puts "IOS_BUILD_NUMBER=#{next_build_number}" }
|
||||
|
||||
puts "Latest App Store Connect build number: #{max_build_number}"
|
||||
puts "Resolved iOS build number: #{next_build_number}"
|
||||
RUBY
|
||||
|
||||
- name: Build archive
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
cd resources/mobile/platforms/ios/eeuiApp
|
||||
xcodebuild archive \
|
||||
-workspace eeuiApp.xcworkspace \
|
||||
-scheme eeuiApp \
|
||||
-configuration Release \
|
||||
-destination "generic/platform=iOS" \
|
||||
-archivePath $RUNNER_TEMP/eeuiApp.xcarchive \
|
||||
-allowProvisioningUpdates \
|
||||
DEVELOPMENT_TEAM=$IOS_TEAM_ID \
|
||||
CODE_SIGN_IDENTITY="Apple Distribution" \
|
||||
CODE_SIGN_STYLE=Manual \
|
||||
| xcpretty
|
||||
|
||||
if [ ! -d "$RUNNER_TEMP/eeuiApp.xcarchive" ]; then
|
||||
echo "Archive was not created at $RUNNER_TEMP/eeuiApp.xcarchive"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Export IPA
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
cd resources/mobile/platforms/ios/eeuiApp
|
||||
|
||||
# Generate ExportOptions.plist
|
||||
cat > $RUNNER_TEMP/ExportOptions.plist << PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key>
|
||||
<string>app-store</string>
|
||||
<key>signingStyle</key>
|
||||
<string>manual</string>
|
||||
<key>teamID</key>
|
||||
<string>${IOS_TEAM_ID}</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
<key>com.dootask.task</key>
|
||||
<string>${APP_PROFILE_NAME}</string>
|
||||
<key>com.dootask.task.shareExtension</key>
|
||||
<string>${SHARE_PROFILE_NAME}</string>
|
||||
</dict>
|
||||
<key>uploadBitcode</key>
|
||||
<false/>
|
||||
<key>uploadSymbols</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
xcodebuild -exportArchive \
|
||||
-archivePath $RUNNER_TEMP/eeuiApp.xcarchive \
|
||||
-exportOptionsPlist $RUNNER_TEMP/ExportOptions.plist \
|
||||
-exportPath $RUNNER_TEMP/ipa-output \
|
||||
-allowProvisioningUpdates \
|
||||
| xcpretty
|
||||
|
||||
- name: Submit to App Store Connect
|
||||
env:
|
||||
ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||
ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Prepare API key
|
||||
mkdir -p ~/private_keys
|
||||
echo "$ASC_API_KEY_P8_BASE64" | base64 --decode > ~/private_keys/AuthKey_${ASC_API_KEY_ID}.p8
|
||||
|
||||
# Find and upload IPA
|
||||
IPA_PATH=$(find $RUNNER_TEMP/ipa-output -name "*.ipa" | head -1)
|
||||
if [ -z "$IPA_PATH" ]; then
|
||||
echo "No IPA file found in $RUNNER_TEMP/ipa-output"
|
||||
exit 1
|
||||
fi
|
||||
echo "Uploading: $IPA_PATH"
|
||||
|
||||
xcrun altool --upload-app \
|
||||
-f "$IPA_PATH" \
|
||||
--type ios \
|
||||
--apiKey "$ASC_API_KEY_ID" \
|
||||
--apiIssuer "$ASC_ISSUER_ID"
|
||||
|
||||
- name: Clean up
|
||||
if: always()
|
||||
run: |
|
||||
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db 2>/dev/null || true
|
||||
rm -f $RUNNER_TEMP/certificate.p12
|
||||
rm -f $RUNNER_TEMP/app.mobileprovision
|
||||
rm -f $RUNNER_TEMP/share-extension.mobileprovision
|
||||
rm -f $RUNNER_TEMP/app-profile.plist
|
||||
rm -f $RUNNER_TEMP/share-extension-profile.plist
|
||||
rm -rf ~/private_keys
|
||||
88
.github/workflows/publish.yml
vendored
88
.github/workflows/publish.yml
vendored
@@ -52,53 +52,18 @@ jobs:
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
// 获取最新的 tag
|
||||
const { data: tags } = await github.rest.repos.listTags({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 1
|
||||
});
|
||||
const fs = require('fs');
|
||||
const version = '${{ needs.check-version.outputs.version }}';
|
||||
|
||||
// 获取提交日志
|
||||
// 从 CHANGELOG.md 提取当前版本段落
|
||||
let changelog = '';
|
||||
if (tags.length > 0) {
|
||||
const { data: commits } = await github.rest.repos.compareCommits({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
base: tags[0].name,
|
||||
head: 'HEAD'
|
||||
});
|
||||
|
||||
// 按类型分组提交
|
||||
const groups = {
|
||||
'feat:': { title: '## Features', commits: new Set() },
|
||||
'fix:': { title: '## Bug Fixes', commits: new Set() },
|
||||
'perf:': { title: '## Performance Improvements', commits: new Set() }
|
||||
};
|
||||
|
||||
// 分类收集提交,使用 Set 去重
|
||||
commits.commits.forEach(commit => {
|
||||
const message = commit.commit.message.split('\n')[0].trim();
|
||||
for (const [prefix, group] of Object.entries(groups)) {
|
||||
if (message.startsWith(prefix)) {
|
||||
// 移除前缀后添加到对应分组
|
||||
const cleanMessage = message.slice(prefix.length).trim();
|
||||
group.commits.add(cleanMessage); // 使用 Set.add 自动去重
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 生成更新日志
|
||||
const sections = [];
|
||||
for (const group of Object.values(groups)) {
|
||||
if (group.commits.size > 0) {
|
||||
sections.push(`${group.title}\n\n${Array.from(group.commits).map(msg => `- ${msg}`).join('\n')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (sections.length > 0) {
|
||||
changelog = '# Changelog\n\n' + sections.join('\n\n');
|
||||
const changelogPath = 'CHANGELOG.md';
|
||||
if (fs.existsSync(changelogPath)) {
|
||||
const content = fs.readFileSync(changelogPath, 'utf-8');
|
||||
const regex = new RegExp(`## \\[${version.replace(/\./g, '\\.')}\\][\\s\\S]*?(?=\\n## \\[|$)`);
|
||||
const match = content.match(regex);
|
||||
if (match) {
|
||||
changelog = match[0].trim();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,8 +71,8 @@ jobs:
|
||||
const { data } = await github.rest.repos.createRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: `v${{ needs.check-version.outputs.version }}`,
|
||||
name: `${{ needs.check-version.outputs.version }}`,
|
||||
tag_name: `v${version}`,
|
||||
name: version,
|
||||
body: changelog || 'No significant changes in this release.',
|
||||
draft: true,
|
||||
prerelease: false
|
||||
@@ -215,7 +180,11 @@ jobs:
|
||||
- name: (Android) Upload File
|
||||
if: matrix.build_type == 'android'
|
||||
env:
|
||||
PUBLISH_KEY: ${{ secrets.PUBLISH_KEY }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
|
||||
run: |
|
||||
node ./electron/build.js android-upload
|
||||
|
||||
@@ -253,7 +222,11 @@ jobs:
|
||||
APPLEIDPASS: ${{ secrets.APPLEIDPASS }}
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
PUBLISH_KEY: ${{ secrets.PUBLISH_KEY }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
@@ -263,7 +236,11 @@ jobs:
|
||||
- name: (Windows) Build Client
|
||||
if: matrix.build_type == 'windows'
|
||||
env:
|
||||
PUBLISH_KEY: ${{ secrets.PUBLISH_KEY }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
shell: bash
|
||||
@@ -294,11 +271,16 @@ jobs:
|
||||
prerelease: false
|
||||
})
|
||||
|
||||
- name: Publish Official
|
||||
- name: Upload Changelog & Publish to Website
|
||||
env:
|
||||
PUBLISH_KEY: ${{ secrets.PUBLISH_KEY }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
|
||||
run: |
|
||||
pushd electron || exit
|
||||
npm install
|
||||
popd || exit
|
||||
node ./electron/build.js published
|
||||
node ./electron/build.js upload-changelog
|
||||
node ./electron/build.js release
|
||||
|
||||
45
.github/workflows/sync-gitee.yml
vendored
Normal file
45
.github/workflows/sync-gitee.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
name: "Sync to Gitee"
|
||||
|
||||
# Required GitHub Secrets:
|
||||
#
|
||||
# GITEE_SSH_PRIVATE_KEY - SSH private key with push access to gitee.com/aipaw/dootask
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Publish"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
name: Push to Gitee
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup SSH key
|
||||
env:
|
||||
GITEE_SSH_PRIVATE_KEY: ${{ secrets.GITEE_SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "$GITEE_SSH_PRIVATE_KEY" > ~/.ssh/gitee_key
|
||||
chmod 600 ~/.ssh/gitee_key
|
||||
cat >> ~/.ssh/config << EOF
|
||||
Host gitee.com
|
||||
HostName gitee.com
|
||||
IdentityFile ~/.ssh/gitee_key
|
||||
StrictHostKeyChecking no
|
||||
EOF
|
||||
|
||||
- name: Push to Gitee
|
||||
run: |
|
||||
git remote add gitee git@gitee.com:aipaw/dootask.git
|
||||
git push gitee pro
|
||||
|
||||
- name: Clean up
|
||||
if: always()
|
||||
run: rm -rf ~/.ssh/gitee_key
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -7,6 +7,7 @@
|
||||
/public/hot
|
||||
/public/tmp
|
||||
/tmp
|
||||
/backup
|
||||
|
||||
# Uploads and user-generated content
|
||||
/public/summary
|
||||
@@ -61,3 +62,6 @@ laravels.pid
|
||||
|
||||
# Documentation
|
||||
README_LOCAL.md
|
||||
|
||||
# playwright
|
||||
.playwright-mcp/
|
||||
|
||||
96
CHANGELOG.md
96
CHANGELOG.md
@@ -2,6 +2,102 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [1.7.81]
|
||||
|
||||
### Features
|
||||
|
||||
- 团队管理中可标记成员邮箱认证状态,成员信息更易管理。
|
||||
- 系统管理员可在任意群组中设置或取消他人的待办,协作管理更灵活。
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 修复 AI 助手消息推送中发送者身份不完整的问题。
|
||||
|
||||
### Performance
|
||||
|
||||
- 优化大文件下载方式,下载更稳定、更高效。
|
||||
|
||||
## [1.7.67]
|
||||
|
||||
### Features
|
||||
|
||||
- 聊天待办现在可以设置提醒时间,到点会引用原消息并提醒相关人员,避免遗漏重要事项。
|
||||
- 团队管理支持管理员创建或批量导入员工账号,并可填写部门、职位等信息,添加成员更方便。
|
||||
- 系统设置新增聊天待办权限控制,可限制其他人员设置或取消聊天待办。
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 设置内容没有变化时不再重复保存,减少无效操作,让使用更稳定。
|
||||
|
||||
### Documentation
|
||||
|
||||
- 补充路由使用限制说明,帮助使用者更清楚地了解规则。
|
||||
- 统一回复语言偏好说明,确保整段回复使用简体中文。
|
||||
|
||||
## [1.7.55]
|
||||
|
||||
### Features
|
||||
|
||||
- 新增部门负责人只读视角,可查看部门成员的项目和任务,并按可见性设置控制展示范围。
|
||||
- 群组、项目和部门支持主负责人 + 副负责人,协作管理更灵活。
|
||||
- 新增共享任务模板,支持跨项目使用、搜索和使用统计,复用常用任务更方便。
|
||||
- 管理页侧边栏支持拖拽调整宽度,使用不同屏幕时更顺手。
|
||||
- 优化任务添加界面,模板浏览和加载提示更清晰。
|
||||
- 项目归档设置选择系统默认规则时,会显示对应提示,减少误操作。
|
||||
- 聊天消息中的表格显示更稳定,单元格内容不再随意换行。
|
||||
- 支持按需调整翻译使用的模型,便于适配不同使用场景。
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 修复权限变更过程中可能出现的可见性或访问异常。
|
||||
- 修复 AI 自动分析开关状态判断不准确的问题。
|
||||
- 修复用户详情页在部分情况下出现横向滚动的问题。
|
||||
- 优化应用发布流程,提升发布稳定性。
|
||||
|
||||
## [1.7.29]
|
||||
|
||||
### Features
|
||||
|
||||
- AI 助手聊天记录现在可自动保存,换设备或重新打开后也能继续查看历史对话。
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 改善 AI 助手中长图的显示清晰度,减少图片被压缩后变模糊的问题。
|
||||
- 修复部分企业账号环境下用户搜索失败、密码规则异常的问题。
|
||||
|
||||
## [1.7.23]
|
||||
|
||||
### Features
|
||||
|
||||
- 支持使用非邮箱形式的用户名登录,登录方式更灵活,也更适合接入常见的企业账号环境。
|
||||
- 进一步优化与 Active Directory 的兼容性,企业用户接入和登录更顺畅。
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 修复部分企业账号环境下的登录问题,提升账号验证的稳定性和成功率。
|
||||
- 修复上传或发布失败时提示不明确的问题,方便更快发现并处理失败情况。
|
||||
|
||||
## [1.7.20]
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 优化了 LDAP 登录方式,更好兼容 Active Directory,企业账号登录更稳定。
|
||||
|
||||
## [1.7.14]
|
||||
|
||||
### Features
|
||||
|
||||
- 新增消息合并转发,支持批量选择后一次转发,分享聊天内容更方便。
|
||||
- 现在可以按项目负责人筛选任务,查找和整理任务更省时。
|
||||
- 支持解除任务关联,调整任务关系更灵活。
|
||||
- 新增 AI 自动分析开关,可按需开启或关闭,使用起来更可控。
|
||||
- 安装和修改设置时会自动检查应用编号与端口是否冲突,减少配置出错和无法启动的情况。
|
||||
- 支持自定义 AI 服务地址,连接和接入方式更灵活。
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 修复了 AI 助手在部分页面显示异常的问题,查看和使用时更稳定。
|
||||
|
||||
## [1.6.89]
|
||||
|
||||
### Features
|
||||
|
||||
25
CLAUDE.md
25
CLAUDE.md
@@ -6,10 +6,17 @@ Laravel 8 (LaravelS/Swoole) + Vue 2 (Vite) + Electron。开源任务/项目管
|
||||
|
||||
所有命令通过 `./cmd` 脚本执行(不要直接运行 `php artisan` 等):
|
||||
|
||||
- `./cmd dev` — 前端开发服务器(Node.js 20+)
|
||||
- `./cmd prod` — 构建前端生产版本
|
||||
- `./cmd artisan ...` / `./cmd composer ...` / `./cmd php ...` — PHP 相关命令
|
||||
|
||||
### AI 不要主动执行的命令
|
||||
|
||||
以下命令仅由用户人工触发,AI 不要主动跑——包括"任务完成后 sanity check"、"看下能不能编译"等场景:
|
||||
|
||||
- `./cmd dev` — 用户已自行运行 dev server,改完会自己 reload;AI 再跑会争抢进程
|
||||
- `./cmd prod` / `./cmd build` — 发版才用,走 `/release` 流程
|
||||
|
||||
前端代码改动只做 Edit/Write,不要为了"验证"启动 dev server。用户明确说"跑一下 / 出包"时除外。
|
||||
|
||||
## Gotchas
|
||||
|
||||
### LaravelS/Swoole
|
||||
@@ -21,7 +28,8 @@ Laravel 8 (LaravelS/Swoole) + Vue 2 (Vite) + Electron。开源任务/项目管
|
||||
|
||||
### 后端
|
||||
|
||||
- **非 REST 路由**:所有 API 通过 `Route::any('api/{resource}/{method}')` 路由到 `InvokeController`,URL 段映射为控制器方法(如 `api/project/lists` → `lists()`,带 action 则用双下划线:`api/project/invite/join` → `invite__join()`)
|
||||
- **非 REST 路由**:API 控制器(继承 `InvokeController`)在 `routes/web.php` 按资源注册路由,URL 段映射为控制器方法(如 `api/project/lists` → `lists()`,带 action 则用双下划线:`api/project/invite/join` → `invite__join()`)
|
||||
- 路由最多两段:方法名最多一个双下划线(`method__action`),不支持 `method__action__xxx`(无对应路由,访问 404)
|
||||
- **响应格式**:统一使用 `Base::retSuccess($msg, $data)` / `Base::retError($msg)`,返回 `{"ret": 1, "msg": "...", "data": {...}}`——不要用 `response()->json()`
|
||||
- 业务异常通过 `App\Exceptions\ApiException` 抛出,不要用通用 Exception
|
||||
- 模型继承 `AbstractModel`,使用 `Model::createInstance($params)` 创建——不要用 `new Model()` 或 `Model::create()`
|
||||
@@ -36,10 +44,19 @@ Laravel 8 (LaravelS/Swoole) + Vue 2 (Vite) + Electron。开源任务/项目管
|
||||
- API 调用使用 `store.dispatch("call", params)`,不要在组件中直接 axios/fetch
|
||||
- `$A.modalXXX`、`$A.messageXXX`、`$A.noticeXXX` 内部自动处理 `$L` 翻译,调用方不要额外包 `$L`。仅当传入 `language: false` 时由调用方自行处理翻译
|
||||
|
||||
### 国际化
|
||||
|
||||
- 新增用户可见文本须追加原文(简体中文)到:前端 `language/original-web.txt`,后端 `language/original-api.txt`(去重)
|
||||
- 前端翻译用 `$L("文本")`,动态值用 `(*)` 占位:`$L('共(*)条', n)`——禁止拼接翻译
|
||||
|
||||
## Playwright 测试
|
||||
|
||||
- Playwright 测试结果放在 `tests/playwright-results/`,包含测试环境、测试用例、结果截图等信息
|
||||
|
||||
## 交互规范
|
||||
|
||||
- **提问时附带建议**:当需要向用户提问或请求澄清时,应同时提供具体的建议选项或推荐方案,帮助用户快速决策,而非仅抛出开放式问题
|
||||
|
||||
## 语言偏好
|
||||
|
||||
- 技术总结和关键结论优先使用简体中文,除非用户明确要求其他语言
|
||||
- 回复一律使用简体中文,除非用户明确要求其他语言
|
||||
|
||||
@@ -22,6 +22,7 @@ English | **[中文文档](./README_CN.md)**
|
||||
- Required: `Docker v20.10+` and `Docker Compose v2.0+`
|
||||
- Supported Systems: `CentOS/Debian/Ubuntu/macOS` and other Linux/Unix systems
|
||||
- Hardware Recommendation: 2+ cores, 4GB+ memory
|
||||
- Database: MariaDB (provided by the default Docker Compose `mariadb` service)
|
||||
- Special Note: Windows users can install Linux environment using WSL2 before installing DooTask.
|
||||
|
||||
### Deploy Project
|
||||
@@ -115,13 +116,15 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
After installing the new project, follow these steps to complete migration:
|
||||
|
||||
1、Backup original database
|
||||
1、Backup the MariaDB database
|
||||
|
||||
```bash
|
||||
# Run command in the old project
|
||||
./cmd mysql backup
|
||||
```
|
||||
|
||||
> `./cmd mysql` is the CLI subcommand name; backups run against the MariaDB container.
|
||||
|
||||
2、Copy the following files and directories from old project to the same paths in new project
|
||||
|
||||
- `Database backup file`
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
- 必须安装:`Docker v20.10+` 和 `Docker Compose v2.0+`
|
||||
- 支持环境:`Centos/Debian/Ubuntu/macOS` 等 linux/unix 系统
|
||||
- 硬件建议:2核4G以上
|
||||
- 数据库:MariaDB(默认 Docker Compose 中的 `mariadb` 服务)
|
||||
- 特别说明:Windows 可以使用 WSL2 安装 Linux 环境后再安装 DooTask。
|
||||
|
||||
### 部署项目
|
||||
@@ -115,13 +116,15 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
在新项目安装好之后按照以下步骤完成项目迁移:
|
||||
|
||||
1、备份原数据库
|
||||
1、备份 MariaDB 数据库
|
||||
|
||||
```bash
|
||||
# 在旧的项目下执行指令
|
||||
./cmd mysql backup
|
||||
```
|
||||
|
||||
> `./cmd mysql` 为 CLI 子命令名称,实际操作的是 MariaDB 容器。
|
||||
|
||||
2、将旧项目以下文件和目录拷贝至新项目同路径位置
|
||||
|
||||
- `数据库备份文件`
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\AiAssistantSession;
|
||||
use App\Models\User;
|
||||
use App\Module\AI;
|
||||
use App\Module\Apps;
|
||||
@@ -155,4 +156,152 @@ class AssistantController extends AbstractController
|
||||
}
|
||||
return $dotProduct / $denominator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话列表
|
||||
*/
|
||||
public function session__list()
|
||||
{
|
||||
$user = User::auth();
|
||||
$sessionKey = trim(Request::input('session_key', 'default'));
|
||||
|
||||
$sessions = AiAssistantSession::where('userid', $user->userid)
|
||||
->where('session_key', $sessionKey)
|
||||
->orderByDesc('updated_at')
|
||||
->get();
|
||||
|
||||
$list = [];
|
||||
foreach ($sessions as $session) {
|
||||
$data = Base::json2array($session->data);
|
||||
$images = Base::json2array($session->images);
|
||||
foreach ($images as $imageId => $path) {
|
||||
$images[$imageId] = Base::fillUrl($path);
|
||||
}
|
||||
$list[] = [
|
||||
'id' => $session->session_id,
|
||||
'title' => $session->title,
|
||||
'responses' => $data,
|
||||
'images' => $images,
|
||||
'sceneKey' => $session->scene_key,
|
||||
'createdAt' => $session->created_at ? $session->created_at->getTimestampMs() : 0,
|
||||
'updatedAt' => $session->updated_at ? $session->updated_at->getTimestampMs() : 0,
|
||||
];
|
||||
}
|
||||
|
||||
return Base::retSuccess('success', $list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存会话
|
||||
*/
|
||||
public function session__save()
|
||||
{
|
||||
$user = User::auth();
|
||||
$sessionKey = trim(Request::input('session_key', 'default'));
|
||||
$sessionId = trim(Request::input('session_id', ''));
|
||||
$sceneKey = trim(Request::input('scene_key', ''));
|
||||
$title = trim(Request::input('title', ''));
|
||||
$data = Request::input('data', []);
|
||||
$newImages = Request::input('new_images', []);
|
||||
|
||||
if (empty($sessionId)) {
|
||||
return Base::retError('session_id 不能为空');
|
||||
}
|
||||
|
||||
$newImageUrls = [];
|
||||
if (is_array($newImages)) {
|
||||
$path = 'uploads/assistant/' . date('Ym') . '/' . $user->userid . '/';
|
||||
foreach ($newImages as $img) {
|
||||
$imageId = $img['imageId'] ?? '';
|
||||
$dataUrl = $img['dataUrl'] ?? '';
|
||||
if (empty($imageId) || empty($dataUrl)) {
|
||||
continue;
|
||||
}
|
||||
$result = Base::image64save([
|
||||
'image64' => $dataUrl,
|
||||
'path' => $path,
|
||||
'autoThumb' => false,
|
||||
]);
|
||||
if (Base::isSuccess($result)) {
|
||||
$newImageUrls[$imageId] = $result['data']['path'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$session = AiAssistantSession::where('userid', $user->userid)
|
||||
->where('session_key', $sessionKey)
|
||||
->where('session_id', $sessionId)
|
||||
->first();
|
||||
|
||||
$imageMap = $newImageUrls;
|
||||
if ($session) {
|
||||
$existingImages = Base::json2array($session->images);
|
||||
$imageMap = array_merge($existingImages, $newImageUrls);
|
||||
}
|
||||
|
||||
$session = AiAssistantSession::createInstance([
|
||||
'userid' => $user->userid,
|
||||
'session_key' => $sessionKey,
|
||||
'session_id' => $sessionId,
|
||||
'scene_key' => $sceneKey,
|
||||
'title' => mb_substr($title, 0, 255),
|
||||
'data' => Base::array2json(is_array($data) ? $data : []),
|
||||
'images' => Base::array2json($imageMap),
|
||||
], $session?->id);
|
||||
$session->save();
|
||||
|
||||
// 仅返回本次新增的图片URL
|
||||
$urls = [];
|
||||
foreach ($newImageUrls as $imageId => $path) {
|
||||
$urls[$imageId] = Base::fillUrl($path);
|
||||
}
|
||||
|
||||
return Base::retSuccess('success', [
|
||||
'image_urls' => $urls,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除会话
|
||||
*/
|
||||
public function session__delete()
|
||||
{
|
||||
$user = User::auth();
|
||||
$sessionKey = trim(Request::input('session_key', 'default'));
|
||||
$sessionId = trim(Request::input('session_id', ''));
|
||||
$clearAll = Request::input('clear_all', false);
|
||||
|
||||
$query = AiAssistantSession::where('userid', $user->userid)
|
||||
->where('session_key', $sessionKey);
|
||||
|
||||
if ($clearAll) {
|
||||
$sessions = $query->get();
|
||||
foreach ($sessions as $session) {
|
||||
$this->deleteSessionImages($session);
|
||||
}
|
||||
$query->delete();
|
||||
} else {
|
||||
if (empty($sessionId)) {
|
||||
return Base::retError('session_id 不能为空');
|
||||
}
|
||||
$session = $query->where('session_id', $sessionId)->first();
|
||||
if ($session) {
|
||||
$this->deleteSessionImages($session);
|
||||
$session->delete();
|
||||
}
|
||||
}
|
||||
|
||||
return Base::retSuccess('success');
|
||||
}
|
||||
|
||||
private function deleteSessionImages(AiAssistantSession $session)
|
||||
{
|
||||
$images = Base::json2array($session->images);
|
||||
foreach ($images as $path) {
|
||||
$fullPath = public_path($path);
|
||||
if (file_exists($fullPath)) {
|
||||
@unlink($fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1670,6 +1670,7 @@ class DialogController extends AbstractController
|
||||
if (!in_array($botType, [
|
||||
'system-msg',
|
||||
'task-alert',
|
||||
'todo-alert',
|
||||
'check-in',
|
||||
'approval-alert',
|
||||
'meeting-alert',
|
||||
@@ -1715,6 +1716,7 @@ class DialogController extends AbstractController
|
||||
* @apiParam {String} text 消息内容
|
||||
* @apiParam {String} [text_type=md] 消息格式:md 或 html
|
||||
* @apiParam {String} [silence=no] 是否静默发送:yes/no
|
||||
* @apiParam {String} [nickname] 自定义发送者昵称(最多20字,留空则显示"AI 助手")
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
@@ -1729,6 +1731,7 @@ class DialogController extends AbstractController
|
||||
$text = trim(Request::input('text'));
|
||||
$text_type = strtolower(trim(Request::input('text_type'))) ?: 'md';
|
||||
$silence = in_array(strtolower(trim(Request::input('silence'))), ['yes', 'true', '1']);
|
||||
$nickname = trim(Request::input('nickname'));
|
||||
$markdown = in_array($text_type, ['md', 'markdown']);
|
||||
//
|
||||
if (empty($dialog_id) && empty($task_id)) {
|
||||
@@ -1740,6 +1743,9 @@ class DialogController extends AbstractController
|
||||
if (mb_strlen($text) > 200000) {
|
||||
return Base::retError('消息内容最大不能超过200000字');
|
||||
}
|
||||
if (mb_strlen($nickname) > 20) {
|
||||
return Base::retError('发送者昵称最多不能超过20字');
|
||||
}
|
||||
//
|
||||
if ($dialog_id) {
|
||||
// Direct dialog mode: verify user is a member
|
||||
@@ -1755,8 +1761,10 @@ class DialogController extends AbstractController
|
||||
}
|
||||
// 任务可见性校验(与 task__one 一致)
|
||||
if ($task->visibility != 1) {
|
||||
$project_userid = ProjectUser::whereProjectId($task->project_id)->whereOwner(1)->value('userid');
|
||||
if ($user->userid != $project_userid) {
|
||||
$projectOwnerids = ProjectUser::whereProjectId($task->project_id)
|
||||
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
|
||||
->pluck('userid')->map(fn($v) => (int)$v)->toArray();
|
||||
if (!in_array($user->userid, $projectOwnerids)) {
|
||||
$visibleUserids = array_merge(
|
||||
ProjectTaskUser::whereTaskId($task_id)->pluck('userid')->toArray(),
|
||||
ProjectTaskUser::whereTaskPid($task_id)->pluck('userid')->toArray(),
|
||||
@@ -1784,6 +1792,9 @@ class DialogController extends AbstractController
|
||||
if ($markdown) {
|
||||
$msgData['type'] = 'md';
|
||||
}
|
||||
if ($nickname !== '') {
|
||||
$msgData['nickname'] = $nickname;
|
||||
}
|
||||
//
|
||||
$result = WebSocketDialogMsg::sendMsg(
|
||||
null,
|
||||
@@ -2334,6 +2345,9 @@ class DialogController extends AbstractController
|
||||
}
|
||||
WebSocketDialog::checkDialog($msgs->first()->dialog_id);
|
||||
foreach ($msgs as $msg) {
|
||||
if (in_array($msg->type, WebSocketDialogMsg::$unforwardableTypes)) {
|
||||
continue;
|
||||
}
|
||||
$res = $msg->forwardMsg($dialogids, $userids, $user, $show_source, $leave_message);
|
||||
if (Base::isSuccess($res)) {
|
||||
$allMsgs = array_merge($allMsgs, $res['data']['msgs']);
|
||||
@@ -2356,12 +2370,12 @@ class DialogController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/dialog/msg/merge-forward 合并转发消息
|
||||
* @api {get} api/dialog/msg/mergeforward 合并转发消息
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup dialog
|
||||
* @apiName msg__merge_forward
|
||||
* @apiName msg__mergeforward
|
||||
*
|
||||
* @apiParam {Array} msg_ids 消息ID数组(最多100条)
|
||||
* @apiParam {Array} dialogids 转发给的对话ID
|
||||
@@ -2373,7 +2387,7 @@ class DialogController extends AbstractController
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function msg__merge_forward()
|
||||
public function msg__mergeforward()
|
||||
{
|
||||
$user = User::auth();
|
||||
//
|
||||
@@ -2396,6 +2410,57 @@ class DialogController extends AbstractController
|
||||
return WebSocketDialogMsg::mergeForwardMsg($msg_ids, $dialogids, $userids, $user, $show_source, $leave_message);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/dialog/msg/mergedetail 合并转发消息详情
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup dialog
|
||||
* @apiName msg__mergedetail
|
||||
*
|
||||
* @apiParam {Number} msg_id 合并转发消息ID
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function msg__mergedetail()
|
||||
{
|
||||
User::auth();
|
||||
//
|
||||
$msg_id = intval(Request::input('msg_id'));
|
||||
if ($msg_id <= 0) {
|
||||
return Base::retError('参数错误');
|
||||
}
|
||||
$dialogMsg = WebSocketDialogMsg::find($msg_id);
|
||||
if (!$dialogMsg || $dialogMsg->type !== 'merge-forward') {
|
||||
return Base::retError('消息不存在或已被删除');
|
||||
}
|
||||
WebSocketDialog::checkDialog($dialogMsg->dialog_id);
|
||||
//
|
||||
$msgData = Base::json2array($dialogMsg->getRawOriginal('msg'));
|
||||
$msgIds = $msgData['msg_ids'] ?? [];
|
||||
if (empty($msgIds)) {
|
||||
return Base::retError('消息不存在或已被删除');
|
||||
}
|
||||
$msgs = WebSocketDialogMsg::withTrashed()
|
||||
->whereIn('id', $msgIds)
|
||||
->orderBy('created_at')
|
||||
->get()
|
||||
->map(function ($msg) {
|
||||
return [
|
||||
'id' => $msg->id,
|
||||
'userid' => $msg->userid,
|
||||
'type' => $msg->type,
|
||||
'msg' => $msg->msg,
|
||||
'created_at' => $msg->created_at->toDateTimeString(),
|
||||
];
|
||||
});
|
||||
return Base::retSuccess('success', [
|
||||
'msgs' => $msgs,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/dialog/msg/emoji emoji回复
|
||||
*
|
||||
@@ -2515,7 +2580,8 @@ class DialogController extends AbstractController
|
||||
} else {
|
||||
$userids = is_array($userids) ? $userids : [];
|
||||
}
|
||||
return $msg->toggleTodoMsg($user->userid, $userids);
|
||||
$remindAt = Request::exists('remind_at') ? (trim(Request::input('remind_at', '')) ?: null) : false;
|
||||
return $msg->toggleTodoMsg($user->userid, $userids, $remindAt);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2548,6 +2614,64 @@ class DialogController extends AbstractController
|
||||
return Base::retSuccess('success', $todo ?: []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/dialog/msg/todoremind 设置/修改/取消待办提醒时间
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup dialog
|
||||
* @apiName msg__todoremind
|
||||
*
|
||||
* @apiParam {Number} msg_id 消息ID
|
||||
* @apiParam {Array} userids 目标成员ID组
|
||||
* @apiParam {String} remind_at 提醒时间(空表示取消提醒)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function msg__todoremind()
|
||||
{
|
||||
$user = User::auth();
|
||||
//
|
||||
$msg_id = intval(Request::input("msg_id"));
|
||||
$userids = Request::input('userids');
|
||||
$userids = is_array($userids) ? array_values(array_filter(array_map('intval', $userids))) : [];
|
||||
$remindAt = trim(Request::input('remind_at', '')) ?: null;
|
||||
//
|
||||
$msg = WebSocketDialogMsg::whereId($msg_id)->first();
|
||||
if (empty($msg)) {
|
||||
return Base::retError("消息不存在或已被删除");
|
||||
}
|
||||
if (in_array($msg->type, ['tag', 'todo', 'notice'])) {
|
||||
return Base::retError('此消息不支持设待办');
|
||||
}
|
||||
$dialog = WebSocketDialog::checkDialog($msg->dialog_id);
|
||||
//
|
||||
if (empty($userids)) {
|
||||
return Base::retError("请选择成员");
|
||||
}
|
||||
// 权限管控(与设/取消待办同一开关与放行规则)
|
||||
if (Base::settingFind('system', 'todo_set_permission') === 'close') {
|
||||
$others = array_diff($userids, [$user->userid]);
|
||||
if ($others && !$dialog->checkTodoOwnerPermission($user->userid)) {
|
||||
return Base::retError('仅群主、项目/任务负责人或系统管理员可设置或取消他人待办');
|
||||
}
|
||||
}
|
||||
//
|
||||
$msg->setTodoRemind($userids, $remindAt);
|
||||
//
|
||||
$upData = [
|
||||
'id' => $msg->id,
|
||||
'todo' => $msg->todo,
|
||||
'todo_done' => $msg->isTodoDone(true),
|
||||
'dialog_id' => $msg->dialog_id,
|
||||
];
|
||||
$dialog->pushMsg('update', $upData);
|
||||
//
|
||||
return Base::retSuccess($remindAt ? '设置成功' : '取消成功', $upData);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/dialog/msg/done 完成待办
|
||||
*
|
||||
@@ -2778,7 +2902,10 @@ class DialogController extends AbstractController
|
||||
return Base::retError('对话不存在或已被删除', ['dialog_id' => $dialog_id], -4003);
|
||||
}
|
||||
} else {
|
||||
$dialog = WebSocketDialog::checkDialog($dialog_id, true);
|
||||
$dialog = WebSocketDialog::checkDialog($dialog_id);
|
||||
if (!$dialog->isOwner(User::userid())) {
|
||||
throw new \App\Exceptions\ApiException('仅群主或群管理员可操作');
|
||||
}
|
||||
}
|
||||
//
|
||||
$data = ['id' => $dialog->id];
|
||||
@@ -2837,7 +2964,11 @@ class DialogController extends AbstractController
|
||||
return Base::retError('请选择群成员');
|
||||
}
|
||||
//
|
||||
$dialog = WebSocketDialog::checkDialog($dialog_id, "auto");
|
||||
$dialog = WebSocketDialog::checkDialog($dialog_id);
|
||||
// 有群主时,仅群主/群管理员可邀请;无群主时,任意成员可邀请
|
||||
if ($dialog->owner_id > 0 && !$dialog->isOwner($user->userid)) {
|
||||
throw new \App\Exceptions\ApiException('仅限群主或群管理员操作');
|
||||
}
|
||||
//
|
||||
$dialog->checkGroup();
|
||||
$dialog->joinGroup($userids, $user->userid);
|
||||
@@ -2927,17 +3058,107 @@ class DialogController extends AbstractController
|
||||
$dialog = WebSocketDialog::checkDialog($dialog_id, $check_owner);
|
||||
//
|
||||
$dialog->checkGroup($check_owner ? 'user' : null);
|
||||
$oldOwnerId = (int)$dialog->owner_id;
|
||||
$dialog->owner_id = $userid;
|
||||
if ($dialog->save()) {
|
||||
$dialog->joinGroup($userid, 0);
|
||||
// 同步 role:原主 role=0、新主 role=1(覆盖即可)
|
||||
if ($oldOwnerId > 0 && $oldOwnerId !== (int)$userid) {
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $oldOwnerId)
|
||||
->update(['role' => 0]);
|
||||
}
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $userid)
|
||||
->update(['role' => 1]);
|
||||
$dialog->pushMsg("groupUpdate", [
|
||||
'id' => $dialog->id,
|
||||
'owner_id' => $dialog->owner_id,
|
||||
'deputy_ids' => $dialog->deputy_ids,
|
||||
]);
|
||||
}
|
||||
return Base::retSuccess('转让成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 任命群管理员(仅群主可操作)
|
||||
*
|
||||
* @apiParam {Number} dialog_id 群对话ID
|
||||
* @apiParam {Number} userid 要任命的群成员 userid
|
||||
*/
|
||||
public function group__adddeputy()
|
||||
{
|
||||
$user = User::auth();
|
||||
$dialog_id = intval(Request::input('dialog_id'));
|
||||
$userid = intval(Request::input('userid'));
|
||||
|
||||
if ($userid <= 0) {
|
||||
return Base::retError('请选择有效的成员');
|
||||
}
|
||||
|
||||
$dialog = WebSocketDialog::checkDialog($dialog_id, true); // checkOwner=true:仅群主
|
||||
$dialog->checkGroup('user'); // 仅普通群
|
||||
|
||||
$member = WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $userid)
|
||||
->first();
|
||||
if (empty($member)) {
|
||||
return Base::retError('该用户不是群成员');
|
||||
}
|
||||
|
||||
if ((int)$member->role === 1) {
|
||||
return Base::retError('不能将群主任命为群管理员');
|
||||
}
|
||||
if ((int)$member->role !== 2) {
|
||||
$member->role = 2;
|
||||
$member->save();
|
||||
$dialog->pushMsg('groupUpdate', [
|
||||
'id' => $dialog->id,
|
||||
'deputy_ids' => $dialog->fresh()->deputy_ids,
|
||||
]);
|
||||
}
|
||||
|
||||
return Base::retSuccess('任命成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 罢免群管理员(仅群主可操作)
|
||||
*
|
||||
* @apiParam {Number} dialog_id 群对话ID
|
||||
* @apiParam {Number} userid 要罢免的群管理员 userid
|
||||
*/
|
||||
public function group__deldeputy()
|
||||
{
|
||||
$user = User::auth();
|
||||
$dialog_id = intval(Request::input('dialog_id'));
|
||||
$userid = intval(Request::input('userid'));
|
||||
|
||||
if ($userid <= 0) {
|
||||
return Base::retError('请选择有效的成员');
|
||||
}
|
||||
|
||||
$dialog = WebSocketDialog::checkDialog($dialog_id, true);
|
||||
$dialog->checkGroup('user');
|
||||
|
||||
$member = WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $userid)
|
||||
->first();
|
||||
if (empty($member)) {
|
||||
return Base::retSuccess('罢免成功'); // 幂等:本来就不是成员
|
||||
}
|
||||
|
||||
if ((int)$member->role === 2) {
|
||||
$member->role = 0;
|
||||
$member->save();
|
||||
$dialog->pushMsg('groupUpdate', [
|
||||
'id' => $dialog->id,
|
||||
'deputy_ids' => $dialog->fresh()->deputy_ids,
|
||||
]);
|
||||
}
|
||||
|
||||
return Base::retSuccess('罢免成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/dialog/group/disband 解散群组
|
||||
*
|
||||
|
||||
@@ -46,6 +46,7 @@ use App\Models\ProjectTaskTemplate;
|
||||
use App\Models\ProjectTag;
|
||||
use App\Models\ProjectTaskRelation;
|
||||
use App\Models\ProjectTaskAiEvent;
|
||||
use App\Models\UserDepartment;
|
||||
use App\Module\AiTaskSuggestion;
|
||||
use App\Observers\ProjectTaskObserver;
|
||||
|
||||
@@ -128,6 +129,7 @@ class ProjectController extends AbstractController
|
||||
public function lists()
|
||||
{
|
||||
$user = User::auth();
|
||||
$departmentView = UserDepartment::ownerViewContext($user);
|
||||
//
|
||||
$all = Request::input('all');
|
||||
$type = Request::input('type', 'all');
|
||||
@@ -141,6 +143,9 @@ class ProjectController extends AbstractController
|
||||
if ($all) {
|
||||
$user->identity('admin');
|
||||
$builder = Project::allData();
|
||||
} elseif ($departmentView['enabled']) {
|
||||
$projectIds = array_values(array_unique(array_merge($departmentView['own_project_ids'], $departmentView['project_ids'])));
|
||||
$builder = Project::allData()->whereIn('projects.id', $projectIds);
|
||||
} else {
|
||||
$builder = Project::authData();
|
||||
}
|
||||
@@ -180,8 +185,9 @@ class ProjectController extends AbstractController
|
||||
->orderBy('project_users.sort')
|
||||
->orderByDesc('projects.id')
|
||||
->paginate(Base::getPaginate(100, 50));
|
||||
$list->transform(function (Project $project) use ($getstatistics, $getuserid, $user) {
|
||||
$list->transform(function (Project $project) use ($getstatistics, $getuserid, $user, $departmentView) {
|
||||
$array = $project->toArray();
|
||||
$array = UserDepartment::appendDepartmentReadonlyProject($array, $departmentView);
|
||||
if ($getuserid == 'yes') {
|
||||
$array['userid_list'] = ProjectUser::whereProjectId($project->id)->pluck('userid')->toArray();
|
||||
}
|
||||
@@ -250,13 +256,15 @@ class ProjectController extends AbstractController
|
||||
public function one()
|
||||
{
|
||||
$user = User::auth();
|
||||
$departmentView = UserDepartment::ownerViewContext($user, true);
|
||||
//
|
||||
$project_id = intval(Request::input('project_id'));
|
||||
//
|
||||
$project = Project::userProject($project_id);
|
||||
$project = Project::findForDepartmentView($project_id);
|
||||
$data = array_merge($project->toArray(), $project->getTaskStatistics($user->userid), [
|
||||
'project_user' => $project->projectUser,
|
||||
]);
|
||||
$data = UserDepartment::appendDepartmentReadonlyProject($data, $departmentView);
|
||||
//
|
||||
return Base::retSuccess('success', $data);
|
||||
}
|
||||
@@ -302,6 +310,8 @@ class ProjectController extends AbstractController
|
||||
* @apiParam {String} [archive_method] 归档方式
|
||||
* @apiParam {Number} [archive_days] 自动归档天数
|
||||
* @apiParam {String} [ai_auto_analyze] AI自动分析(open|close)
|
||||
* @apiParam {String} [task_template_share] 共享模板(open|close)
|
||||
* @apiParam {String} [department_owner_view] 部门负责人视角可见(open|close)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
@@ -317,6 +327,8 @@ class ProjectController extends AbstractController
|
||||
$archive_method = Request::input('archive_method');
|
||||
$archive_days = intval(Request::input('archive_days'));
|
||||
$ai_auto_analyze = Request::input('ai_auto_analyze');
|
||||
$task_template_share = Request::input('task_template_share');
|
||||
$department_owner_view = Request::input('department_owner_view');
|
||||
if (mb_strlen($name) < 2) {
|
||||
return Base::retError('项目名称不可以少于2个字');
|
||||
} elseif (mb_strlen($name) > 32) {
|
||||
@@ -332,7 +344,7 @@ class ProjectController extends AbstractController
|
||||
}
|
||||
//
|
||||
$project = Project::userProject($project_id, true, true);
|
||||
AbstractModel::transaction(function () use ($archive_days, $archive_method, $ai_auto_analyze, $desc, $name, $project) {
|
||||
AbstractModel::transaction(function () use ($archive_days, $archive_method, $ai_auto_analyze, $task_template_share, $department_owner_view, $desc, $name, $project) {
|
||||
if ($project->name != $name) {
|
||||
$project->addLog("修改项目名称", [
|
||||
'change' => [$project->name, $name]
|
||||
@@ -364,6 +376,18 @@ class ProjectController extends AbstractController
|
||||
]);
|
||||
$project->ai_auto_analyze = $ai_auto_analyze;
|
||||
}
|
||||
if (in_array($task_template_share, ['open', 'close']) && $project->task_template_share != $task_template_share) {
|
||||
$project->addLog("修改共享模板", [
|
||||
'change' => [$project->task_template_share, $task_template_share]
|
||||
]);
|
||||
$project->task_template_share = $task_template_share;
|
||||
}
|
||||
if (in_array($department_owner_view, ['open', 'close']) && $project->department_owner_view != $department_owner_view) {
|
||||
$project->addLog("修改负责人视角可见", [
|
||||
'change' => [$project->department_owner_view, $department_owner_view]
|
||||
]);
|
||||
$project->department_owner_view = $department_owner_view;
|
||||
}
|
||||
$project->save();
|
||||
});
|
||||
$project->pushMsg('update');
|
||||
@@ -372,15 +396,16 @@ class ProjectController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/project/user 修改项目成员
|
||||
* @api {post} api/project/user 修改项目成员
|
||||
*
|
||||
* @apiDescription 需要token身份(限:项目负责人)
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup project
|
||||
* @apiName user
|
||||
*
|
||||
* @apiParam {Number} project_id 项目ID
|
||||
* @apiParam {Number} userid 成员ID 或 成员ID组
|
||||
* @apiParam {Number} project_id 项目ID
|
||||
* @apiParam {Number[]} userid 成员userid数组(最终完整列表)
|
||||
* @apiParam {Number[]} [deputy_userid] 项目管理员userid数组(可选,仅负责人有效;必须是 userid 子集)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
@@ -393,6 +418,13 @@ class ProjectController extends AbstractController
|
||||
$project_id = intval(Request::input('project_id'));
|
||||
$userid = Request::input('userid');
|
||||
$userid = is_array($userid) ? $userid : [$userid];
|
||||
$userid = array_values(array_unique(array_map('intval', $userid)));
|
||||
//
|
||||
$deputy_userid = Request::input('deputy_userid');
|
||||
if ($deputy_userid !== null) {
|
||||
$deputy_userid = is_array($deputy_userid) ? $deputy_userid : [$deputy_userid];
|
||||
$deputy_userid = array_values(array_unique(array_map('intval', $deputy_userid)));
|
||||
}
|
||||
//
|
||||
if (count($userid) > 100) {
|
||||
return Base::retError('项目人数最多100个');
|
||||
@@ -400,7 +432,45 @@ class ProjectController extends AbstractController
|
||||
//
|
||||
$project = Project::userProject($project_id, true, true);
|
||||
//
|
||||
$deleteUser = AbstractModel::transaction(function() use ($project, $userid) {
|
||||
// 仅负责人可设置项目管理员;项目管理员/其他角色提交 deputy_userid 一律忽略
|
||||
$isPrimary = (int)$project->owner === ProjectUser::OWNER_PRIMARY;
|
||||
$applyDeputy = $isPrimary && $deputy_userid !== null;
|
||||
//
|
||||
// 业务闭环:项目必须且只能有一个主负责人,最终成员列表必须包含该负责人
|
||||
$primaryOwnerIds = ProjectUser::whereProjectId($project->id)
|
||||
->whereOwner(ProjectUser::OWNER_PRIMARY)
|
||||
->pluck('userid')
|
||||
->map(fn($v) => (int)$v)
|
||||
->toArray();
|
||||
if (count($primaryOwnerIds) !== 1) {
|
||||
return Base::retError('项目负责人数据异常,请先修复项目负责人');
|
||||
}
|
||||
$primaryOwnerId = $primaryOwnerIds[0];
|
||||
if (!in_array($primaryOwnerId, $userid, true)) {
|
||||
return Base::retError('项目成员列表必须包含项目负责人');
|
||||
}
|
||||
// 项目管理员可以管理普通成员,但不能借成员列表移除其他项目管理员
|
||||
if (!$isPrimary) {
|
||||
$currentDeputyIds = ProjectUser::whereProjectId($project->id)
|
||||
->whereOwner(ProjectUser::OWNER_DEPUTY)
|
||||
->pluck('userid')
|
||||
->map(fn($v) => (int)$v)
|
||||
->toArray();
|
||||
if (!empty(array_diff($currentDeputyIds, $userid))) {
|
||||
return Base::retError('项目管理员不能移除项目负责人或项目管理员');
|
||||
}
|
||||
}
|
||||
//
|
||||
if ($applyDeputy) {
|
||||
if (!empty(array_diff($deputy_userid, $userid))) {
|
||||
return Base::retError('项目管理员必须是项目成员');
|
||||
}
|
||||
if (in_array((int)$project->owner_userid, $deputy_userid, true)) {
|
||||
return Base::retError('负责人不能任命为项目管理员');
|
||||
}
|
||||
}
|
||||
//
|
||||
$deleteUser = AbstractModel::transaction(function() use ($project, $userid, $applyDeputy, $deputy_userid) {
|
||||
$array = [];
|
||||
foreach ($userid as $uid) {
|
||||
if ($project->joinProject($uid)) {
|
||||
@@ -408,15 +478,37 @@ class ProjectController extends AbstractController
|
||||
}
|
||||
}
|
||||
$deleteRows = ProjectUser::whereProjectId($project->id)->whereNotIn('userid', $array)->get();
|
||||
$deleteUser = $deleteRows->pluck('userid');
|
||||
$deleteUserids = $deleteRows->pluck('userid');
|
||||
foreach ($deleteRows as $row) {
|
||||
$row->exitProject();
|
||||
}
|
||||
//
|
||||
// 项目管理员 diff(仅负责人有效)
|
||||
if ($applyDeputy) {
|
||||
$currentDeputies = ProjectUser::whereProjectId($project->id)
|
||||
->where('owner', ProjectUser::OWNER_DEPUTY)
|
||||
->pluck('userid')->toArray();
|
||||
$toPromote = array_values(array_diff($deputy_userid, $currentDeputies));
|
||||
$toDemote = array_values(array_diff($currentDeputies, $deputy_userid));
|
||||
if (!empty($toPromote)) {
|
||||
ProjectUser::whereProjectId($project->id)
|
||||
->whereIn('userid', $toPromote)
|
||||
->where('owner', ProjectUser::OWNER_MEMBER)
|
||||
->change(['owner' => ProjectUser::OWNER_DEPUTY]);
|
||||
}
|
||||
if (!empty($toDemote)) {
|
||||
ProjectUser::whereProjectId($project->id)
|
||||
->whereIn('userid', $toDemote)
|
||||
->where('owner', ProjectUser::OWNER_DEPUTY)
|
||||
->change(['owner' => ProjectUser::OWNER_MEMBER]);
|
||||
}
|
||||
}
|
||||
//
|
||||
$project->syncDialogUser();
|
||||
$project->addLog("修改项目成员");
|
||||
$project->user_simple = count($array) . "|" . implode(",", array_slice($array, 0, 3));
|
||||
$project->save();
|
||||
return $deleteUser->toArray();
|
||||
return $deleteUserids->toArray();
|
||||
});
|
||||
//
|
||||
$project->pushMsg('delete', null, $deleteUser);
|
||||
@@ -574,28 +666,138 @@ class ProjectController extends AbstractController
|
||||
$project_id = intval(Request::input('project_id'));
|
||||
$owner_userid = intval(Request::input('owner_userid'));
|
||||
//
|
||||
$project = Project::userProject($project_id, true, true);
|
||||
$project = Project::userProject($project_id, true, 'primary');
|
||||
//
|
||||
if (!User::whereUserid($owner_userid)->exists()) {
|
||||
return Base::retError('成员不存在');
|
||||
}
|
||||
//
|
||||
AbstractModel::transaction(function() use ($owner_userid, $project) {
|
||||
ProjectUser::whereProjectId($project->id)->change(['owner' => 0]);
|
||||
// 仅清除原负责人 owner=1(项目管理员 owner=2 保留)
|
||||
ProjectUser::whereProjectId($project->id)
|
||||
->whereOwner(ProjectUser::OWNER_PRIMARY)
|
||||
->change(['owner' => 0]);
|
||||
// 设新负责人 owner=1(如新负责人原本是项目管理员,从 2 升为 1)
|
||||
ProjectUser::updateInsert([
|
||||
'project_id' => $project->id,
|
||||
'userid' => $owner_userid,
|
||||
], [
|
||||
'owner' => 1,
|
||||
'owner' => ProjectUser::OWNER_PRIMARY,
|
||||
]);
|
||||
// 同步项目群 owner_id
|
||||
if ($project->dialog_id > 0) {
|
||||
$dialog = WebSocketDialog::find($project->dialog_id);
|
||||
if ($dialog) {
|
||||
$dialog->owner_id = $owner_userid;
|
||||
$dialog->save();
|
||||
}
|
||||
}
|
||||
// 同步成员 + role(syncDialogUser 已根据 owner 设置 role)
|
||||
$project->syncDialogUser();
|
||||
$project->addLog("移交项目给", ['userid' => $owner_userid]);
|
||||
});
|
||||
//
|
||||
$project->pushMsg('detail');
|
||||
// pushMsg 带 deputy_userids,前端可直接更新项目管理员列表无需重拉
|
||||
$project->pushMsg('detail', [
|
||||
'owner_userid' => $project->fresh()->owner_userid,
|
||||
'deputy_userids' => $project->fresh()->deputy_userids,
|
||||
]);
|
||||
return Base::retSuccess('移交成功', ['id' => $project->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/project/adddeputy 任命项目管理员(仅负责人可操作)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup project
|
||||
* @apiName adddeputy
|
||||
*
|
||||
* @apiParam {Number} project_id 项目ID
|
||||
* @apiParam {Number} userid 要任命的项目成员 userid
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function adddeputy()
|
||||
{
|
||||
User::auth();
|
||||
$project_id = intval(Request::input('project_id'));
|
||||
$userid = intval(Request::input('userid'));
|
||||
|
||||
if ($userid <= 0) {
|
||||
return Base::retError('请选择有效的成员');
|
||||
}
|
||||
|
||||
$project = Project::userProject($project_id, true, 'primary');
|
||||
|
||||
$member = ProjectUser::where('project_id', $project->id)
|
||||
->where('userid', $userid)->first();
|
||||
if (!$member) {
|
||||
return Base::retError('该用户不是项目成员');
|
||||
}
|
||||
if ((int)$member->owner === ProjectUser::OWNER_PRIMARY) {
|
||||
return Base::retError('不能将负责人任命为项目管理员');
|
||||
}
|
||||
if ((int)$member->owner !== ProjectUser::OWNER_DEPUTY) {
|
||||
AbstractModel::transaction(function() use ($project, $member) {
|
||||
$member->owner = ProjectUser::OWNER_DEPUTY;
|
||||
$member->save();
|
||||
$project->syncDialogUser(); // 同步群 role
|
||||
$project->addLog('任命项目管理员', ['userid' => $member->userid]);
|
||||
});
|
||||
$project->pushMsg('detail', [
|
||||
'deputy_userids' => $project->fresh()->deputy_userids,
|
||||
]);
|
||||
}
|
||||
|
||||
return Base::retSuccess('任命成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/project/deldeputy 罢免项目管理员(仅负责人可操作)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup project
|
||||
* @apiName deldeputy
|
||||
*
|
||||
* @apiParam {Number} project_id 项目ID
|
||||
* @apiParam {Number} userid 要罢免的项目管理员 userid
|
||||
*/
|
||||
public function deldeputy()
|
||||
{
|
||||
User::auth();
|
||||
$project_id = intval(Request::input('project_id'));
|
||||
$userid = intval(Request::input('userid'));
|
||||
|
||||
if ($userid <= 0) {
|
||||
return Base::retError('请选择有效的成员');
|
||||
}
|
||||
|
||||
$project = Project::userProject($project_id, true, 'primary');
|
||||
|
||||
$member = ProjectUser::where('project_id', $project->id)
|
||||
->where('userid', $userid)->first();
|
||||
if (!$member) {
|
||||
return Base::retSuccess('罢免成功'); // 幂等:本来就不是成员
|
||||
}
|
||||
if ((int)$member->owner === ProjectUser::OWNER_DEPUTY) {
|
||||
AbstractModel::transaction(function() use ($project, $member) {
|
||||
$member->owner = ProjectUser::OWNER_MEMBER;
|
||||
$member->save();
|
||||
$project->syncDialogUser();
|
||||
$project->addLog('罢免项目管理员', ['userid' => $member->userid]);
|
||||
});
|
||||
$project->pushMsg('detail', [
|
||||
'deputy_userids' => $project->fresh()->deputy_userids,
|
||||
]);
|
||||
}
|
||||
|
||||
return Base::retSuccess('罢免成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/project/sort 排序任务
|
||||
*
|
||||
@@ -784,7 +986,7 @@ class ProjectController extends AbstractController
|
||||
//
|
||||
$project_id = intval(Request::input('project_id'));
|
||||
//
|
||||
$project = Project::userProject($project_id, null, true);
|
||||
$project = Project::userProject($project_id, null, 'primary');
|
||||
//
|
||||
$project->deleteProject();
|
||||
return Base::retSuccess('删除成功', ['id' => $project->id]);
|
||||
@@ -813,7 +1015,7 @@ class ProjectController extends AbstractController
|
||||
//
|
||||
$project_id = intval(Request::input('project_id'));
|
||||
// 项目
|
||||
$project = Project::userProject($project_id);
|
||||
$project = Project::findForDepartmentView($project_id);
|
||||
//
|
||||
$list = ProjectColumn::whereProjectId($project->id)
|
||||
->orderBy('sort')
|
||||
@@ -1044,6 +1246,7 @@ class ProjectController extends AbstractController
|
||||
{
|
||||
$user = User::auth();
|
||||
$userid = $user->userid;
|
||||
$departmentView = UserDepartment::ownerViewContext($user, true);
|
||||
//
|
||||
$parent_id = intval(Request::input('parent_id'));
|
||||
$project_id = intval(Request::input('project_id'));
|
||||
@@ -1108,7 +1311,7 @@ class ProjectController extends AbstractController
|
||||
if ($parent_id > 0) {
|
||||
$isArchived = str_replace(['all', 'yes', 'no'], [null, false, true], $archived);
|
||||
$isDeleted = str_replace(['all', 'yes', 'no'], [null, false, true], $deleted);
|
||||
ProjectTask::userTask($parent_id, $isArchived, $isDeleted);
|
||||
ProjectTask::findForDepartmentView($parent_id, $isArchived, $isDeleted);
|
||||
$scopeAll = true;
|
||||
$archived = 'all';
|
||||
$builder->where('project_tasks.parent_id', $parent_id);
|
||||
@@ -1116,17 +1319,23 @@ class ProjectController extends AbstractController
|
||||
$builder->where('project_tasks.parent_id', 0);
|
||||
}
|
||||
if ($project_id > 0) {
|
||||
Project::userProject($project_id);
|
||||
if (!UserDepartment::isDepartmentReadonlyProject($departmentView, $project_id)) {
|
||||
Project::userProject($project_id);
|
||||
}
|
||||
$scopeAll = true;
|
||||
$builder->where('project_tasks.project_id', $project_id);
|
||||
}
|
||||
if (!$scopeAll && $scope === 'all_project') {
|
||||
$scopeAll = true;
|
||||
$builder->whereIn('project_tasks.project_id', function ($query) use ($userid) {
|
||||
$query->select('project_id')
|
||||
->from('project_users')
|
||||
->where('userid', $userid);
|
||||
});
|
||||
if ($departmentView['enabled']) {
|
||||
$builder->whereIn('project_tasks.project_id', array_values(array_unique(array_merge($departmentView['own_project_ids'], $departmentView['project_ids']))));
|
||||
} else {
|
||||
$builder->whereIn('project_tasks.project_id', function ($query) use ($userid) {
|
||||
$query->select('project_id')
|
||||
->from('project_users')
|
||||
->where('userid', $userid);
|
||||
});
|
||||
}
|
||||
}
|
||||
if ($scopeAll) {
|
||||
$builder->allData();
|
||||
@@ -1194,7 +1403,7 @@ class ProjectController extends AbstractController
|
||||
// 任务可见性条件
|
||||
$builder->leftJoin('project_users', function ($query) use($userid) {
|
||||
$query->on('project_tasks.project_id', '=', 'project_users.project_id');
|
||||
$query->where('project_users.owner', 1);
|
||||
$query->whereIn('project_users.owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY]);
|
||||
$query->where('project_users.userid', $userid);
|
||||
});
|
||||
$builder->leftJoin('project_task_visibility_users', function ($query) use($userid) {
|
||||
@@ -1253,6 +1462,7 @@ class ProjectController extends AbstractController
|
||||
$data = $list->toArray();
|
||||
// 还原字段
|
||||
foreach($data['data'] as &$item){
|
||||
$item['department_readonly'] = UserDepartment::isDepartmentReadonlyProject($departmentView, intval($item['project_id']));
|
||||
$item['file_num'] = $item['_file_num'] ?: 0;
|
||||
$item['msg_num'] = $item['_msg_num'] ?: 0;
|
||||
$item['sub_num'] = $item['_sub_num'] ?: 0;
|
||||
@@ -1832,15 +2042,18 @@ class ProjectController extends AbstractController
|
||||
public function task__one()
|
||||
{
|
||||
$user = User::auth();
|
||||
$departmentView = UserDepartment::ownerViewContext($user, true);
|
||||
//
|
||||
$task_id = intval(Request::input('task_id'));
|
||||
$archived = Request::input('archived', 'no');
|
||||
//
|
||||
$isArchived = str_replace(['all', 'yes', 'no'], [null, false, true], $archived);
|
||||
$task = ProjectTask::userTask($task_id, $isArchived, true, ['taskUser', 'taskTag']);
|
||||
$task = ProjectTask::findForDepartmentView($task_id, $isArchived, true, ['taskUser', 'taskTag']);
|
||||
// 项目可见性
|
||||
$project_userid = ProjectUser::whereProjectId($task->project_id)->whereOwner(1)->value('userid'); // 项目负责人
|
||||
if ($task->visibility != 1 && $user->userid != $project_userid) {
|
||||
$projectOwnerids = ProjectUser::whereProjectId($task->project_id)
|
||||
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
|
||||
->pluck('userid')->map(fn($v) => (int)$v)->toArray(); // 项目负责人(含项目管理员)
|
||||
if ($task->visibility != 1 && !in_array($user->userid, $projectOwnerids)) {
|
||||
$taskUserids = ProjectTaskUser::whereTaskId($task_id)->pluck('userid')->toArray(); //任务负责人、协助人
|
||||
$subTaskUserids = ProjectTaskUser::whereTaskPid($task_id)->pluck('userid')->toArray(); //子任务负责人、协助人
|
||||
$visibleUserids = ProjectTaskVisibilityUser::whereTaskId($task_id)->pluck('userid')->toArray(); //可见人
|
||||
@@ -1851,6 +2064,7 @@ class ProjectController extends AbstractController
|
||||
}
|
||||
//
|
||||
$data = $task->toArray();
|
||||
$data['department_readonly'] = UserDepartment::isDepartmentReadonlyProject($departmentView, intval($task->project_id));
|
||||
$data['project_name'] = $task->project?->name;
|
||||
$data['column_name'] = $task->projectColumn?->name;
|
||||
$data['visibility_appointor'] = $task->visibility == 1 ? [0] : ProjectTaskVisibilityUser::whereTaskId($task_id)->pluck('userid');
|
||||
@@ -1879,7 +2093,7 @@ class ProjectController extends AbstractController
|
||||
return Base::retError('参数错误', ['task_id' => $task_id]);
|
||||
}
|
||||
//
|
||||
$task = ProjectTask::userTask($task_id);
|
||||
$task = ProjectTask::findForDepartmentView($task_id);
|
||||
//
|
||||
return Base::retSuccess('success', [
|
||||
'id' => $task->id,
|
||||
@@ -1911,7 +2125,7 @@ class ProjectController extends AbstractController
|
||||
return Base::retError('参数错误', ['task_id' => $task_id]);
|
||||
}
|
||||
|
||||
$task = ProjectTask::userTask($task_id, null);
|
||||
$task = ProjectTask::findForDepartmentView($task_id, null);
|
||||
|
||||
$relations = ProjectTaskRelation::whereTaskId($task->id)
|
||||
->orderByDesc('updated_at')
|
||||
@@ -1929,7 +2143,7 @@ class ProjectController extends AbstractController
|
||||
$relatedTasks = [];
|
||||
foreach ($relatedTaskIds as $relatedId) {
|
||||
try {
|
||||
$relatedTask = ProjectTask::userTask($relatedId, null, true, ['project', 'projectColumn']);
|
||||
$relatedTask = ProjectTask::findForDepartmentView($relatedId, null, true, ['project', 'projectColumn']);
|
||||
|
||||
$flowItemParts = explode('|', $relatedTask->flow_item_name ?: '');
|
||||
$flowItemStatus = $flowItemParts[0] ?? '';
|
||||
@@ -2055,7 +2269,7 @@ class ProjectController extends AbstractController
|
||||
$task_id = intval(Request::input('task_id'));
|
||||
$history_id = intval(Request::input('history_id'));
|
||||
//
|
||||
$task = ProjectTask::userTask($task_id, null);
|
||||
$task = ProjectTask::findForDepartmentView($task_id, null);
|
||||
//
|
||||
if ($history_id > 0) {
|
||||
$taskContent = ProjectTaskContent::whereTaskId($task->id)->whereId($history_id)->first();
|
||||
@@ -2095,7 +2309,7 @@ class ProjectController extends AbstractController
|
||||
//
|
||||
$task_id = intval(Request::input('task_id'));
|
||||
//
|
||||
$task = ProjectTask::userTask($task_id, null);
|
||||
$task = ProjectTask::findForDepartmentView($task_id, null);
|
||||
//
|
||||
$data = ProjectTaskContent::select(['id', 'task_id', 'desc', 'userid', 'created_at'])
|
||||
->whereTaskId($task->id)
|
||||
@@ -2124,7 +2338,7 @@ class ProjectController extends AbstractController
|
||||
//
|
||||
$task_id = intval(Request::input('task_id'));
|
||||
//
|
||||
$task = ProjectTask::userTask($task_id, null);
|
||||
$task = ProjectTask::findForDepartmentView($task_id, null);
|
||||
//
|
||||
return Base::retSuccess('success', $task->taskFile);
|
||||
}
|
||||
@@ -2213,7 +2427,7 @@ class ProjectController extends AbstractController
|
||||
$data = $file->toArray();
|
||||
$data['path'] = $file->getRawOriginal('path');
|
||||
//
|
||||
ProjectTask::userTask($file->task_id, null);
|
||||
ProjectTask::findForDepartmentView($file->task_id, null);
|
||||
//
|
||||
UserRecentItem::record(
|
||||
$user->userid,
|
||||
@@ -2254,7 +2468,7 @@ class ProjectController extends AbstractController
|
||||
abort_if(empty($file), 403, "This file not exist.");
|
||||
//
|
||||
try {
|
||||
ProjectTask::userTask($file->task_id, null);
|
||||
ProjectTask::findForDepartmentView($file->task_id, null);
|
||||
} catch (\Throwable $e) {
|
||||
abort(403, $e->getMessage() ?: "This file not support download.");
|
||||
}
|
||||
@@ -2347,7 +2561,9 @@ class ProjectController extends AbstractController
|
||||
if ($data['visibility'] == 1) {
|
||||
$data['is_visible'] = 1;
|
||||
} else {
|
||||
$projectOwner = ProjectUser::whereProjectId($task->project_id)->whereOwner(1)->pluck('userid')->toArray(); // 项目负责人
|
||||
$projectOwner = ProjectUser::whereProjectId($task->project_id)
|
||||
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
|
||||
->pluck('userid')->toArray(); // 项目负责人(含项目管理员)
|
||||
$taskOwnerAndAssists = ProjectTaskUser::select(['userid', 'owner'])->whereTaskId($data['id'])->pluck('userid')->toArray();
|
||||
$visibleIds = array_merge($projectOwner, $taskOwnerAndAssists);
|
||||
$data['is_visible'] = in_array($user->userid, $visibleIds) ? 1 : 0;
|
||||
@@ -2355,6 +2571,21 @@ class ProjectController extends AbstractController
|
||||
|
||||
$task->pushMsg('add', $data);
|
||||
$task->taskPush(null, 0);
|
||||
|
||||
// 应用任务模板使用统计(不影响主流程;非成员、模板已删除或共享模板已关闭时静默忽略)
|
||||
$templateId = intval(Request::input('template_id', 0));
|
||||
if ($templateId > 0) {
|
||||
$tpl = ProjectTaskTemplate::find($templateId);
|
||||
if ($tpl) {
|
||||
$isMember = ProjectUser::where('project_id', $tpl->project_id)
|
||||
->where('userid', $user->userid)->exists();
|
||||
$shareEnabled = ($project->task_template_share ?: 'open') === 'open';
|
||||
if ($isMember && ($tpl->project_id == $project->id || $shareEnabled)) {
|
||||
$tpl->incrementUsage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Base::retSuccess('添加成功', $data);
|
||||
}
|
||||
|
||||
@@ -2398,7 +2629,10 @@ class ProjectController extends AbstractController
|
||||
]);
|
||||
$data = ProjectTask::oneTask($task->id);
|
||||
$pushUserIds = ProjectTaskUser::whereTaskId($task->id)->pluck('userid')->toArray();
|
||||
$pushUserIds[] = ProjectUser::whereProjectId($task->project_id)->whereOwner(1)->value('userid');
|
||||
$ownerids = ProjectUser::whereProjectId($task->project_id)
|
||||
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
|
||||
->pluck('userid')->toArray();
|
||||
$pushUserIds = array_merge($pushUserIds, $ownerids);
|
||||
foreach ($pushUserIds as $userId) {
|
||||
$task->pushMsg('add', $data, $userId);
|
||||
}
|
||||
@@ -3181,7 +3415,7 @@ class ProjectController extends AbstractController
|
||||
//
|
||||
$project_id = intval(Request::input('project_id'));
|
||||
//
|
||||
$project = Project::userProject($project_id, true);
|
||||
$project = Project::findForDepartmentView($project_id, true);
|
||||
//
|
||||
$list = ProjectFlow::with(['ProjectFlowItem'])->whereProjectId($project->id)->get();
|
||||
return Base::retSuccess('success', $list);
|
||||
@@ -3280,10 +3514,10 @@ class ProjectController extends AbstractController
|
||||
//
|
||||
$builder = ProjectLog::select(["*"]);
|
||||
if ($task_id > 0) {
|
||||
$task = ProjectTask::userTask($task_id, null);
|
||||
$task = ProjectTask::findForDepartmentView($task_id, null);
|
||||
$builder->whereTaskId($task->id);
|
||||
} else {
|
||||
$project = Project::userProject($project_id);
|
||||
$project = Project::findForDepartmentView($project_id);
|
||||
$builder->with(['projectTask:id,parent_id,name'])->whereProjectId($project->id)->whereTaskOnly(0);
|
||||
}
|
||||
//
|
||||
@@ -3455,6 +3689,127 @@ class ProjectController extends AbstractController
|
||||
return Base::retSuccess('success', $templates);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/project/task/template_visible 当前用户跨项目可见的全部任务模板
|
||||
*
|
||||
* @apiDescription 返回当前用户加入的所有项目下的任务模板。当前项目的模板优先排序。
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup project
|
||||
* @apiName task__template_visible
|
||||
*
|
||||
* @apiParam {Number} [current_project_id] 当前项目 ID(用于排序优先;可空)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1 正确、0 错误)
|
||||
* @apiSuccess {String} msg 返回信息
|
||||
* @apiSuccess {Object[]} data 模板列表,每条包含 project_id, project_name, name, title, content, sort, is_default, userid, use_count, last_used_at
|
||||
*/
|
||||
public function task__template_visible()
|
||||
{
|
||||
$user = User::auth();
|
||||
$currentProjectId = intval(Request::input('current_project_id', 0));
|
||||
|
||||
$projectIds = ProjectUser::where('userid', $user->userid)->pluck('project_id');
|
||||
$currentProject = $currentProjectId > 0 ? Project::find($currentProjectId) : null;
|
||||
if ($currentProject && ($currentProject->task_template_share ?: 'open') === 'close') {
|
||||
$projectIds = collect($projectIds)->filter(fn($id) => intval($id) === $currentProjectId)->values();
|
||||
}
|
||||
|
||||
$rows = ProjectTaskTemplate::with(['project:id,name'])
|
||||
->whereIn('project_id', $projectIds)
|
||||
->orderByRaw('project_id = ? DESC', [$currentProjectId])
|
||||
->orderBy('sort')
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->map(function ($tpl) {
|
||||
return [
|
||||
'id' => $tpl->id,
|
||||
'project_id' => $tpl->project_id,
|
||||
'project_name' => $tpl->project->name ?? '',
|
||||
'name' => $tpl->name,
|
||||
'title' => $tpl->title,
|
||||
'content' => $tpl->content,
|
||||
'sort' => $tpl->sort,
|
||||
'is_default' => $tpl->is_default,
|
||||
'userid' => $tpl->userid,
|
||||
'use_count' => $tpl->use_count,
|
||||
'last_used_at' => $tpl->last_used_at,
|
||||
];
|
||||
});
|
||||
|
||||
return Base::retSuccess('success', $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/project/task/template_search 跨项目模板搜索分页
|
||||
*
|
||||
* @apiDescription "更多"弹层用。返回当前用户跨项目可见模板,支持关键字 + 分页。
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup project
|
||||
* @apiName task__template_search
|
||||
*
|
||||
* @apiParam {String} [keyword] 关键字(在 name/title/content 上模糊匹配)
|
||||
* @apiParam {Number} [current_project_id] 当前项目 ID(共享模板关闭时仅返回本项目模板)
|
||||
* @apiParam {Number} [page=1] 页码
|
||||
* @apiParam {Number} [page_size=20] 每页条数(最大 50)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码
|
||||
* @apiSuccess {Object} data 含 total / page / page_size / items
|
||||
*/
|
||||
public function task__template_search()
|
||||
{
|
||||
$user = User::auth();
|
||||
$keyword = trim((string) Request::input('keyword', ''));
|
||||
$currentProjectId = intval(Request::input('current_project_id', 0));
|
||||
$page = max(1, intval(Request::input('page', 1)));
|
||||
$pageSize = min(50, max(1, intval(Request::input('page_size', 20))));
|
||||
|
||||
$projectIds = ProjectUser::where('userid', $user->userid)->pluck('project_id');
|
||||
$currentProject = $currentProjectId > 0 ? Project::find($currentProjectId) : null;
|
||||
if ($currentProject && ($currentProject->task_template_share ?: 'open') === 'close') {
|
||||
$projectIds = collect($projectIds)->filter(fn($id) => intval($id) === $currentProjectId)->values();
|
||||
}
|
||||
|
||||
$q = ProjectTaskTemplate::with(['project:id,name', 'user:userid,nickname'])
|
||||
->whereIn('project_id', $projectIds);
|
||||
|
||||
if ($keyword !== '') {
|
||||
$like = '%' . $keyword . '%';
|
||||
$q->where(function ($qq) use ($like) {
|
||||
$qq->where('name', 'like', $like)
|
||||
->orWhere('title', 'like', $like)
|
||||
->orWhere('content', 'like', $like);
|
||||
});
|
||||
}
|
||||
|
||||
$total = (clone $q)->count();
|
||||
$items = $q->orderByDesc('use_count')
|
||||
->orderByDesc('last_used_at')
|
||||
->orderByDesc('created_at')
|
||||
->forPage($page, $pageSize)
|
||||
->get()
|
||||
->map(function ($tpl) {
|
||||
return [
|
||||
'id' => $tpl->id,
|
||||
'project_id' => $tpl->project_id,
|
||||
'project_name' => $tpl->project->name ?? '',
|
||||
'name' => $tpl->name,
|
||||
'title' => $tpl->title,
|
||||
'content' => $tpl->content,
|
||||
'use_count' => $tpl->use_count,
|
||||
'userid' => $tpl->userid,
|
||||
'user_name' => $tpl->user->nickname ?? '',
|
||||
'last_used_at' => $tpl->last_used_at,
|
||||
];
|
||||
});
|
||||
|
||||
return Base::retSuccess('success', [
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'page_size' => $pageSize,
|
||||
'items' => $items,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/project/task/template_save 保存任务模板
|
||||
*
|
||||
|
||||
@@ -94,6 +94,8 @@ class SystemController extends AbstractController
|
||||
'unclaimed_task_reminder',
|
||||
'unclaimed_task_reminder_time',
|
||||
'task_ai_auto_analyze',
|
||||
'department_owner_project_view',
|
||||
'todo_set_permission',
|
||||
])) {
|
||||
unset($all[$key]);
|
||||
}
|
||||
@@ -141,6 +143,7 @@ class SystemController extends AbstractController
|
||||
$setting['archived_day'] = floatval($setting['archived_day']) ?: 7;
|
||||
$setting['task_visible'] = $setting['task_visible'] ?: 'close';
|
||||
$setting['all_group_mute'] = $setting['all_group_mute'] ?: 'open';
|
||||
$setting['todo_set_permission'] = $setting['todo_set_permission'] ?: 'open';
|
||||
$setting['all_group_autoin'] = $setting['all_group_autoin'] ?: 'yes';
|
||||
$setting['user_private_chat_mute'] = $setting['user_private_chat_mute'] ?: 'open';
|
||||
$setting['user_group_chat_mute'] = $setting['user_group_chat_mute'] ?: 'open';
|
||||
@@ -148,6 +151,7 @@ class SystemController extends AbstractController
|
||||
$setting['unclaimed_task_reminder'] = $setting['unclaimed_task_reminder'] ?: 'close';
|
||||
$setting['unclaimed_task_reminder_time'] = $setting['unclaimed_task_reminder_time'] ?: '';
|
||||
$setting['task_ai_auto_analyze'] = $setting['task_ai_auto_analyze'] ?: 'open';
|
||||
$setting['department_owner_project_view'] = $setting['department_owner_project_view'] ?: 'close';
|
||||
$setting['server_timezone'] = config('app.timezone');
|
||||
$setting['server_version'] = Base::getVersion();
|
||||
//
|
||||
@@ -612,6 +616,7 @@ class SystemController extends AbstractController
|
||||
'ldap_password',
|
||||
'ldap_user_dn',
|
||||
'ldap_base_dn',
|
||||
'ldap_login_attr',
|
||||
'ldap_sync_local'
|
||||
])) {
|
||||
unset($all[$key]);
|
||||
@@ -625,6 +630,7 @@ class SystemController extends AbstractController
|
||||
//
|
||||
$setting['ldap_open'] = $setting['ldap_open'] ?: 'close';
|
||||
$setting['ldap_port'] = intval($setting['ldap_port']) ?: 389;
|
||||
$setting['ldap_login_attr'] = $setting['ldap_login_attr'] ?: 'cn';
|
||||
$setting['ldap_sync_local'] = $setting['ldap_sync_local'] ?: 'close';
|
||||
//
|
||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
|
||||
|
||||
@@ -41,6 +41,9 @@ use Illuminate\Support\Facades\DB;
|
||||
use App\Models\UserEmailVerification;
|
||||
use App\Module\AgoraIO\AgoraTokenGenerator;
|
||||
use Swoole\Coroutine;
|
||||
use App\Module\UserImport;
|
||||
use App\Module\UserImportTemplate;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
|
||||
/**
|
||||
* @apiDefine users
|
||||
@@ -404,9 +407,22 @@ class UsersController extends AbstractController
|
||||
$data['nickname_original'] = $user->getRawOriginal('nickname');
|
||||
$data['department_name'] = $user->getDepartmentName();
|
||||
$data['department_owner'] = UserDepartment::where('parent_id',0)->where('owner_userid', $user->userid)->exists(); // 适用默认部门下第1级负责人才能添加部门OKR
|
||||
$data['managed_departments'] = UserDepartment::getManagedDepartments($user->userid)->toArray();
|
||||
return Base::retSuccess('success', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/users/info/managed_departments 获取我可切换负责人视角的部门列表
|
||||
*/
|
||||
public function info__managed_departments()
|
||||
{
|
||||
$user = User::auth();
|
||||
if (Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') {
|
||||
return Base::retSuccess('success', []);
|
||||
}
|
||||
return Base::retSuccess('success', UserDepartment::getManagedDepartments($user->userid));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/users/info/departments 获取我的部门列表
|
||||
*
|
||||
@@ -1078,6 +1094,8 @@ class UsersController extends AbstractController
|
||||
* - clearadmin 取消管理员
|
||||
* - settemp 设为临时帐号
|
||||
* - cleartemp 取消临时身份(取消临时帐号)
|
||||
* - setverity 标记邮箱为已认证
|
||||
* - clearverity 标记邮箱为未认证
|
||||
* - checkin_macs 修改自动签到mac地址(需要参数 checkin_macs)
|
||||
* - checkin_face 修改签到人脸图片(需要参数 checkin_face)
|
||||
* - department 修改部门(需要参数 department)
|
||||
@@ -1141,6 +1159,16 @@ class UsersController extends AbstractController
|
||||
$upArray['identity'] = array_diff($userInfo->identity, ['temp']);
|
||||
break;
|
||||
|
||||
case 'setverity':
|
||||
$msg = '设置成功';
|
||||
$upArray['email_verity'] = 1;
|
||||
break;
|
||||
|
||||
case 'clearverity':
|
||||
$msg = '取消成功';
|
||||
$upArray['email_verity'] = 0;
|
||||
break;
|
||||
|
||||
case 'checkin_macs':
|
||||
$list = is_array($data['checkin_macs']) ? $data['checkin_macs'] : [];
|
||||
$array = [];
|
||||
@@ -1250,7 +1278,7 @@ class UsersController extends AbstractController
|
||||
User::passwordPolicy($password);
|
||||
$upArray['encrypt'] = Base::generatePassword(6);
|
||||
$upArray['password'] = Doo::md5s($password, $upArray['encrypt']);
|
||||
$upArray['changepass'] = 1;
|
||||
$upArray['changepass'] = intval($data['changepass'] ?? 1) === 1 ? 1 : 0;
|
||||
$upLdap['userPassword'] = $password;
|
||||
}
|
||||
// 昵称
|
||||
@@ -1327,6 +1355,101 @@ class UsersController extends AbstractController
|
||||
return Base::retSuccess($msg, $userInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/users/createuser 创建用户(管理员)
|
||||
*
|
||||
* @apiDescription 需要token身份(管理员)
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup users
|
||||
* @apiName createuser
|
||||
*
|
||||
* @apiParam {String} email 邮箱
|
||||
* @apiParam {String} password 初始密码
|
||||
* @apiParam {String} nickname 昵称
|
||||
* @apiParam {Number} [email_verity] 是否标记邮箱为已认证(1是、0否,默认1)
|
||||
* @apiParam {String} [profession] 职位/职称(可选,2-20字)
|
||||
* @apiParam {Array} [department] 部门ID列表(可选,最多10个)
|
||||
*/
|
||||
public function createuser()
|
||||
{
|
||||
User::auth('admin');
|
||||
$email = trim(Request::input('email'));
|
||||
$password = trim(Request::input('password'));
|
||||
$nickname = trim(Request::input('nickname'));
|
||||
$changePass = intval(Request::input('changepass', 1)) === 1;
|
||||
$emailVerity = intval(Request::input('email_verity', 1)) === 1;
|
||||
$profession = trim((string)Request::input('profession', ''));
|
||||
$department = Request::input('department', []);
|
||||
$user = User::createByAdmin($email, $password, $nickname, [
|
||||
'changePass' => $changePass,
|
||||
'emailVerity' => $emailVerity,
|
||||
'profession' => $profession,
|
||||
'department' => is_array($department) ? $department : [],
|
||||
]);
|
||||
return Base::retSuccess('创建成功', $user);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/users/import/preview 批量导入预览(管理员)
|
||||
*
|
||||
* @apiDescription 需要token身份(管理员)。上传 Excel/CSV(列顺序:邮箱、昵称、初始密码、职位(选填)),仅解析+校验、不创建账号
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup users
|
||||
* @apiName import__preview
|
||||
*/
|
||||
public function import__preview()
|
||||
{
|
||||
User::auth('admin');
|
||||
$file = Request::file('file');
|
||||
if (empty($file)) {
|
||||
return Base::retError('请选择文件');
|
||||
}
|
||||
$ext = strtolower($file->getClientOriginalExtension());
|
||||
if (!in_array($ext, ['xls', 'xlsx', 'csv'])) {
|
||||
return Base::retError('仅支持 xls/xlsx/csv 文件');
|
||||
}
|
||||
$sheets = Excel::toArray(new UserImport, $file);
|
||||
$sheet = $sheets[0] ?? [];
|
||||
$rows = User::parseImportRows($sheet);
|
||||
if (empty($rows)) {
|
||||
return Base::retError('文件中没有可导入的数据');
|
||||
}
|
||||
return Base::retSuccess('解析完成', User::importPreview($rows));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/users/import 批量导入用户(管理员)
|
||||
*
|
||||
* @apiDescription 需要token身份(管理员)。提交预览确认后的行数据 rows(每行 {email,nickname,password,profession},可选 department[]、email_verity(1已认证/0未认证,默认0))进行创建
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup users
|
||||
* @apiName import
|
||||
*/
|
||||
public function import()
|
||||
{
|
||||
User::auth('admin');
|
||||
$rows = Request::input('rows');
|
||||
if (!is_array($rows) || empty($rows)) {
|
||||
return Base::retError('没有可导入的数据');
|
||||
}
|
||||
$changePass = intval(Request::input('changepass', 1)) === 1;
|
||||
$result = User::importUsers($rows, $changePass);
|
||||
return Base::retSuccess('导入完成', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/users/import/template 下载批量导入模板(管理员)
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup users
|
||||
* @apiName import__template
|
||||
*/
|
||||
public function import__template()
|
||||
{
|
||||
User::auth('admin');
|
||||
return Excel::download(new UserImportTemplate, 'user_import_template.xlsx');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/users/email/verification 邮箱验证
|
||||
*
|
||||
@@ -2145,6 +2268,65 @@ class UsersController extends AbstractController
|
||||
return Base::retSuccess($id > 0 ? '保存成功' : '新建成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/users/department/adddeputy 任命部门管理员(限管理员)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup users
|
||||
* @apiName department__adddeputy
|
||||
*
|
||||
* @apiParam {Number} id 部门 id
|
||||
* @apiParam {Number} userid 部门管理员 userid
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
*/
|
||||
public function department__adddeputy()
|
||||
{
|
||||
User::auth('admin');
|
||||
$id = intval(Request::input('id'));
|
||||
$userid = intval(Request::input('userid'));
|
||||
|
||||
$dept = UserDepartment::find($id);
|
||||
if (empty($dept)) {
|
||||
return Base::retError('部门不存在或已被删除');
|
||||
}
|
||||
|
||||
// ApiException 由框架统一捕获并 retError 转换
|
||||
$dept->addDeputy($userid);
|
||||
|
||||
Cache::forever("UserDepartment::rand", Base::generatePassword());
|
||||
return Base::retSuccess('任命成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/users/department/deldeputy 罢免部门管理员(限管理员)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup users
|
||||
* @apiName department__deldeputy
|
||||
*
|
||||
* @apiParam {Number} id 部门 id
|
||||
* @apiParam {Number} userid 要罢免的部门管理员 userid
|
||||
*/
|
||||
public function department__deldeputy()
|
||||
{
|
||||
User::auth('admin');
|
||||
$id = intval(Request::input('id'));
|
||||
$userid = intval(Request::input('userid'));
|
||||
|
||||
$dept = UserDepartment::find($id);
|
||||
if (empty($dept)) {
|
||||
return Base::retError('部门不存在或已被删除');
|
||||
}
|
||||
|
||||
$dept->delDeputy($userid);
|
||||
Cache::forever("UserDepartment::rand", Base::generatePassword());
|
||||
return Base::retSuccess('罢免成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/users/department/del 删除部门(限管理员)
|
||||
*
|
||||
@@ -3213,7 +3395,7 @@ class UsersController extends AbstractController
|
||||
return Base::retError('参数错误');
|
||||
}
|
||||
//
|
||||
ProjectTask::userTask($task_id, null, null);
|
||||
ProjectTask::findForDepartmentView($task_id, null, null);
|
||||
//
|
||||
UserTaskBrowse::recordBrowse($user->userid, $task_id);
|
||||
//
|
||||
|
||||
@@ -23,6 +23,7 @@ use App\Tasks\CheckinRemindTask;
|
||||
use App\Tasks\CloseMeetingRoomTask;
|
||||
use App\Tasks\ManticoreSyncTask;
|
||||
use App\Tasks\UnclaimedTaskRemindTask;
|
||||
use App\Tasks\TodoRemindTask;
|
||||
use App\Tasks\AiTaskLoopTask;
|
||||
use Hhxsv5\LaravelS\Swoole\Task\Task;
|
||||
use Laravolt\Avatar\Avatar;
|
||||
@@ -270,6 +271,8 @@ class IndexController extends InvokeController
|
||||
Task::deliver(new JokeSoupTask());
|
||||
// 未领取任务通知
|
||||
Task::deliver(new UnclaimedTaskRemindTask());
|
||||
// 待办提醒
|
||||
Task::deliver(new TodoRemindTask());
|
||||
// 关闭会议室
|
||||
Task::deliver(new CloseMeetingRoomTask());
|
||||
// Manticore Search 同步
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
namespace App\Ldap;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Models\User;
|
||||
use App\Module\Base;
|
||||
use App\Services\RequestContext;
|
||||
use LdapRecord\Configuration\ConfigurationException;
|
||||
use LdapRecord\Container;
|
||||
use LdapRecord\LdapRecordException;
|
||||
@@ -11,20 +13,18 @@ use LdapRecord\Models\Model;
|
||||
|
||||
class LdapUser extends Model
|
||||
{
|
||||
protected static $init = null;
|
||||
/**
|
||||
* The object classes of the LDAP model.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
public static $objectClasses = [
|
||||
'inetOrgPerson',
|
||||
'organizationalPerson',
|
||||
'person',
|
||||
'top',
|
||||
'posixAccount',
|
||||
];
|
||||
|
||||
private static $emailAttrs = ['mail', 'cn', 'uid', 'userPrincipalName'];
|
||||
|
||||
/**
|
||||
* @return mixed|null
|
||||
*/
|
||||
@@ -68,19 +68,29 @@ class LdapUser extends Model
|
||||
return Base::settingFind('thirdAccessSetting', 'ldap_sync_local') === 'open';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取登录属性名
|
||||
* @return string
|
||||
*/
|
||||
public static function getLoginAttr(): string
|
||||
{
|
||||
$attr = Base::settingFind('thirdAccessSetting', 'ldap_login_attr');
|
||||
return in_array($attr, ['cn', 'uid', 'mail', 'sAMAccountName', 'userPrincipalName']) ? $attr : 'cn';
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化配置
|
||||
* @return bool
|
||||
*/
|
||||
public static function initConfig()
|
||||
{
|
||||
if (is_bool(self::$init)) {
|
||||
return self::$init;
|
||||
if (RequestContext::has('ldap_init')) {
|
||||
return RequestContext::get('ldap_init');
|
||||
}
|
||||
//
|
||||
$setting = Base::setting('thirdAccessSetting');
|
||||
if ($setting['ldap_open'] !== 'open') {
|
||||
return self::$init = false;
|
||||
return RequestContext::save('ldap_init', false);
|
||||
}
|
||||
//
|
||||
$connection = Container::getDefaultConnection();
|
||||
@@ -92,15 +102,15 @@ class LdapUser extends Model
|
||||
"username" => $setting['ldap_user_dn'],
|
||||
"password" => $setting['ldap_password'],
|
||||
]);
|
||||
return self::$init = true;
|
||||
return RequestContext::save('ldap_init', true);
|
||||
} catch (ConfigurationException $e) {
|
||||
info($e->getMessage());
|
||||
return self::$init = false;
|
||||
return RequestContext::save('ldap_init', false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取
|
||||
* 通过管理员绑定搜索用户,然后用用户 DN 做 Bind 认证
|
||||
* @param $username
|
||||
* @param $password
|
||||
* @return Model|null
|
||||
@@ -111,16 +121,68 @@ class LdapUser extends Model
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return self::static()
|
||||
->where([
|
||||
'cn' => $username,
|
||||
'userPassword' => $password
|
||||
])->first();
|
||||
$loginAttr = self::getLoginAttr();
|
||||
$row = self::static()
|
||||
->whereRaw($loginAttr, '=', $username)
|
||||
->first();
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
$connection = Container::getDefaultConnection();
|
||||
if (!$connection->auth()->attempt($row->getDn(), $password)) {
|
||||
return null;
|
||||
}
|
||||
// Swoole 下连接共享,必须恢复管理员绑定
|
||||
$connection->auth()->attempt(
|
||||
$connection->getConfiguration()->get('username'),
|
||||
$connection->getConfiguration()->get('password')
|
||||
);
|
||||
return $row;
|
||||
} catch (\Exception $e) {
|
||||
info("[LDAP] auth fail: " . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过邮箱查找 LDAP 用户
|
||||
* @param $email
|
||||
* @return Model|null
|
||||
*/
|
||||
public static function findByEmail($email): ?Model
|
||||
{
|
||||
if (!self::initConfig()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
foreach (self::$emailAttrs as $attr) {
|
||||
$row = self::static()->whereRaw($attr, '=', $email)->first();
|
||||
if ($row) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (\Exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的邮箱(从 LDAP 记录中提取)
|
||||
* @param Model $row
|
||||
* @return string|null
|
||||
*/
|
||||
public static function getUserEmail(Model $row): ?string
|
||||
{
|
||||
foreach (self::$emailAttrs as $attr) {
|
||||
$val = $row->getFirstAttribute($attr);
|
||||
if ($val && Base::isEmail($val)) {
|
||||
return $val;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录
|
||||
* @param $username
|
||||
@@ -138,7 +200,18 @@ class LdapUser extends Model
|
||||
return null;
|
||||
}
|
||||
if (empty($user)) {
|
||||
$user = User::reg($username, $password);
|
||||
$email = self::getUserEmail($row);
|
||||
if (empty($email)) {
|
||||
throw new ApiException('LDAP 用户缺少邮箱属性,请联系管理员配置');
|
||||
}
|
||||
$user = User::whereEmail($email)->first();
|
||||
if (empty($user)) {
|
||||
// LDAP 用户通过 LDAP 认证,本地密码用随机值以满足密码策略
|
||||
$localPassword = Base::generatePassword(16) . 'Aa1!';
|
||||
$user = User::reg($email, $localPassword);
|
||||
} elseif (!$user->isLdap()) {
|
||||
info("[LDAP] merged with existing local account: userid={$user->userid}, email={$email}");
|
||||
}
|
||||
}
|
||||
if ($user) {
|
||||
$userimg = $row->getPhoto();
|
||||
@@ -173,7 +246,7 @@ class LdapUser extends Model
|
||||
}
|
||||
//
|
||||
if (self::isSyncLocal()) {
|
||||
$row = self::userFirst($user->email, $password);
|
||||
$row = self::findByEmail($user->email);
|
||||
if ($row) {
|
||||
return;
|
||||
}
|
||||
@@ -184,17 +257,18 @@ class LdapUser extends Model
|
||||
} else {
|
||||
$userimg = '';
|
||||
}
|
||||
self::static()->create([
|
||||
$attrs = [
|
||||
'cn' => $user->email,
|
||||
'gidNumber' => 0,
|
||||
'homeDirectory' => '/home/ldap/dootask/' . env("APP_NAME"),
|
||||
'sn' => $user->email,
|
||||
'uid' => $user->email,
|
||||
'uidNumber' => $user->userid,
|
||||
'userPassword' => $password,
|
||||
'displayName' => $user->nickname,
|
||||
'jpegPhoto' => $userimg,
|
||||
]);
|
||||
'mail' => $user->email,
|
||||
];
|
||||
if ($userimg) {
|
||||
$attrs['jpegPhoto'] = $userimg;
|
||||
}
|
||||
self::static()->create($attrs);
|
||||
$user->identity = Base::arrayImplode(array_merge(array_diff($user->identity, ['ldap']), ['ldap']));
|
||||
$user->save();
|
||||
} catch (LdapRecordException $e) {
|
||||
@@ -205,11 +279,11 @@ class LdapUser extends Model
|
||||
|
||||
/**
|
||||
* 更新
|
||||
* @param $username
|
||||
* @param $email
|
||||
* @param $array
|
||||
* @return void
|
||||
*/
|
||||
public static function userUpdate($username, $array)
|
||||
public static function userUpdate($email, $array)
|
||||
{
|
||||
if (empty($array)) {
|
||||
return;
|
||||
@@ -218,10 +292,7 @@ class LdapUser extends Model
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$row = self::static()
|
||||
->where([
|
||||
'cn' => $username,
|
||||
])->first();
|
||||
$row = self::findByEmail($email);
|
||||
$row?->update($array);
|
||||
} catch (\Exception $e) {
|
||||
info("[LDAP] update fail: " . $e->getMessage());
|
||||
@@ -230,19 +301,16 @@ class LdapUser extends Model
|
||||
|
||||
/**
|
||||
* 删除
|
||||
* @param $username
|
||||
* @param $email
|
||||
* @return void
|
||||
*/
|
||||
public static function userDelete($username)
|
||||
public static function userDelete($email)
|
||||
{
|
||||
if (!self::initConfig()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$row = self::static()
|
||||
->where([
|
||||
'cn' => $username,
|
||||
])->first();
|
||||
$row = self::findByEmail($email);
|
||||
$row?->delete();
|
||||
} catch (\Exception $e) {
|
||||
info("[LDAP] delete fail: " . $e->getMessage());
|
||||
|
||||
@@ -20,9 +20,7 @@ use Illuminate\Support\Facades\DB;
|
||||
* @method static \Illuminate\Database\Eloquent\Model|object|static|null cancelAppend()
|
||||
* @method static \Illuminate\Database\Eloquent\Model|object|static|null cancelHidden()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|static with($relations)
|
||||
* @method static \Illuminate\Database\Query\Builder|static select($columns = [])
|
||||
* @method static \Illuminate\Database\Query\Builder|static whereIn($column, $values, $boolean = 'and', $not = false)
|
||||
* @method static \Illuminate\Database\Query\Builder|static whereNotIn($column, $values, $boolean = 'and')
|
||||
* @method static \Illuminate\Pagination\LengthAwarePaginator paginate(callable $callback)
|
||||
* @method int change(array $array)
|
||||
* @method int remove()
|
||||
* @mixin \Eloquent
|
||||
@@ -53,6 +51,8 @@ class AbstractModel extends Model
|
||||
|
||||
'read_at',
|
||||
'done_at',
|
||||
'remind_at',
|
||||
'reminded_at',
|
||||
|
||||
'created_at',
|
||||
'updated_at',
|
||||
|
||||
22
app/Models/AiAssistantSession.php
Normal file
22
app/Models/AiAssistantSession.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
/**
|
||||
* AI 助手会话
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $userid
|
||||
* @property string $session_key
|
||||
* @property string $session_id
|
||||
* @property string $scene_key
|
||||
* @property string $title
|
||||
* @property string|null $data
|
||||
* @property string|null $images
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
*/
|
||||
class AiAssistantSession extends AbstractModel
|
||||
{
|
||||
protected $table = 'ai_assistant_sessions';
|
||||
}
|
||||
@@ -22,6 +22,9 @@ use Request;
|
||||
* @property int|null $personal 是否个人项目
|
||||
* @property string|null $archive_method 自动归档方式
|
||||
* @property int|null $archive_days 自动归档天数
|
||||
* @property string|null $ai_auto_analyze AI自动分析
|
||||
* @property string|null $task_template_share 共享模板开关
|
||||
* @property string|null $department_owner_view 部门负责人视角可见开关
|
||||
* @property string|null $user_simple 成员总数|1,2,3
|
||||
* @property int|null $dialog_id 聊天会话ID
|
||||
* @property \Illuminate\Support\Carbon|null $archived_at 归档时间
|
||||
@@ -77,6 +80,7 @@ class Project extends AbstractModel
|
||||
|
||||
protected $appends = [
|
||||
'owner_userid',
|
||||
'deputy_userids',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -92,6 +96,58 @@ class Project extends AbstractModel
|
||||
return $this->appendattrs['owner_userid'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目管理员 userid 列表
|
||||
* @return array
|
||||
*/
|
||||
public function getDeputyUseridsAttribute(): array
|
||||
{
|
||||
if (empty($this->id)) {
|
||||
return [];
|
||||
}
|
||||
return ProjectUser::whereProjectId($this->id)
|
||||
->whereOwner(ProjectUser::OWNER_DEPUTY)
|
||||
->pluck('userid')
|
||||
->map(fn($v) => (int)$v)
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否项目负责人(与 project_users.owner=1 一致)
|
||||
*/
|
||||
public function isPrimaryOwner($userid): bool
|
||||
{
|
||||
if (empty($this->id) || $userid <= 0) {
|
||||
return false;
|
||||
}
|
||||
return ProjectUser::whereProjectId($this->id)
|
||||
->whereUserid($userid)
|
||||
->whereOwner(ProjectUser::OWNER_PRIMARY)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否项目管理员(与 project_users.owner=2 一致)
|
||||
*/
|
||||
public function isDeputyOwner($userid): bool
|
||||
{
|
||||
if (empty($this->id) || $userid <= 0) {
|
||||
return false;
|
||||
}
|
||||
return ProjectUser::whereProjectId($this->id)
|
||||
->whereUserid($userid)
|
||||
->whereOwner(ProjectUser::OWNER_DEPUTY)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否负责人(含项目管理员)
|
||||
*/
|
||||
public function isOwner($userid): bool
|
||||
{
|
||||
return $this->isPrimaryOwner($userid) || $this->isDeputyOwner($userid);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
*/
|
||||
@@ -227,21 +283,40 @@ class Project extends AbstractModel
|
||||
return;
|
||||
}
|
||||
AbstractModel::transaction(function() {
|
||||
$userids = $this->relationUserids();
|
||||
// 拉所有项目成员 + 各自 owner 值
|
||||
$userOwnerMap = ProjectUser::whereProjectId($this->id)
|
||||
->pluck('owner', 'userid');
|
||||
$userids = $userOwnerMap->keys()->map(fn($v) => (int)$v)->toArray();
|
||||
foreach ($userids as $userid) {
|
||||
$owner = (int)$userOwnerMap[$userid];
|
||||
// 巧合:编码完全一致 owner 0/1/2 → role 0/1/2
|
||||
$role = $owner;
|
||||
WebSocketDialogUser::updateInsert([
|
||||
'dialog_id' => $this->dialog_id,
|
||||
'userid' => $userid,
|
||||
], [
|
||||
'important' => 1
|
||||
], function () use ($userid) {
|
||||
'important' => 1,
|
||||
'role' => $role,
|
||||
], function () use ($userid, $role) {
|
||||
return [
|
||||
'important' => 1,
|
||||
'role' => $role,
|
||||
'bot' => User::isBot($userid) ? 1 : 0,
|
||||
];
|
||||
});
|
||||
}
|
||||
WebSocketDialogUser::whereDialogId($this->dialog_id)->whereNotIn('userid', $userids)->whereImportant(1)->remove();
|
||||
WebSocketDialogUser::whereDialogId($this->dialog_id)
|
||||
->whereNotIn('userid', $userids)
|
||||
->whereImportant(1)
|
||||
->remove();
|
||||
// 同步 dialog.owner_id 到主负责人(owner=1):前端「群主」标签依赖此字段,
|
||||
// 必须随项目主负责人变更(含用户离职转移)一起刷新,否则会显示已离职用户
|
||||
$primaryUserid = $userOwnerMap->search(ProjectUser::OWNER_PRIMARY);
|
||||
if ($primaryUserid !== false && (int)$primaryUserid > 0) {
|
||||
WebSocketDialog::whereId($this->dialog_id)
|
||||
->where('owner_id', '!=', (int)$primaryUserid)
|
||||
->update(['owner_id' => (int)$primaryUserid]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -378,7 +453,7 @@ class Project extends AbstractModel
|
||||
// 处理所有者权限
|
||||
if (isset($data['owner'])) {
|
||||
$owners = ProjectUser::whereProjectId($data['id'])
|
||||
->whereOwner(1)
|
||||
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
|
||||
->pluck('userid')
|
||||
->toArray();
|
||||
$recipients = [
|
||||
@@ -599,7 +674,7 @@ class Project extends AbstractModel
|
||||
$column['project_id'] = $project->id;
|
||||
ProjectColumn::createInstance($column)->save();
|
||||
}
|
||||
$dialog = WebSocketDialog::createGroup($project->name, $project->userid, 'project');
|
||||
$dialog = WebSocketDialog::createGroup($project->name, $project->userid, 'project', $project->userid);
|
||||
if (empty($dialog)) {
|
||||
throw new ApiException('创建项目聊天室失败');
|
||||
}
|
||||
@@ -621,7 +696,9 @@ class Project extends AbstractModel
|
||||
* 获取项目信息(用于判断会员是否存在项目内)
|
||||
* @param int $project_id
|
||||
* @param null|bool $archived true:仅限未归档, false:仅限已归档, null:不限制
|
||||
* @param null|bool $mustOwner true:仅限项目负责人, false:仅限非项目负责人, null:不限制
|
||||
* @param null|bool|string $mustOwner true:负责人或项目管理员都可(共享操作);
|
||||
* 'primary':仅负责人(转让/删除/任命项目管理员等独占操作);
|
||||
* false:仅限非负责人;null:不限制
|
||||
* @return self
|
||||
*/
|
||||
public static function userProject($project_id, $archived = true, $mustOwner = null)
|
||||
@@ -639,9 +716,39 @@ class Project extends AbstractModel
|
||||
if ($mustOwner === true && !$project->owner) {
|
||||
throw new ApiException('仅限项目负责人操作', [ 'project_id' => $project_id ]);
|
||||
}
|
||||
if ($mustOwner === 'primary' && (int)$project->owner !== 1) {
|
||||
throw new ApiException('仅限项目负责人操作', [ 'project_id' => $project_id ]);
|
||||
}
|
||||
if ($mustOwner === false && $project->owner) {
|
||||
throw new ApiException('禁止项目负责人操作', [ 'project_id' => $project_id ]);
|
||||
}
|
||||
return $project;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目(含部门负责人只读视角兜底)
|
||||
* @param int $project_id
|
||||
* @param null|bool $archived true:仅限未归档, false:仅限已归档, null:不限制
|
||||
* @param null|bool|string $mustOwner 仅限 null 时尝试部门只读视角
|
||||
* @return self
|
||||
*/
|
||||
public static function findForDepartmentView($project_id, $archived = true, $mustOwner = null)
|
||||
{
|
||||
$user = User::auth();
|
||||
$departmentView = UserDepartment::ownerViewContext($user, true);
|
||||
if (UserDepartment::isDepartmentReadonlyProject($departmentView, intval($project_id)) && $mustOwner === null) {
|
||||
$project = self::allData()->where('projects.id', intval($project_id))->first();
|
||||
if (empty($project)) {
|
||||
throw new ApiException('项目不存在或已被删除', [ 'project_id' => $project_id ], -4001);
|
||||
}
|
||||
if ($archived === true && $project->archived_at != null) {
|
||||
throw new ApiException('项目已归档', [ 'project_id' => $project_id ], -4001);
|
||||
}
|
||||
if ($archived === false && $project->archived_at == null) {
|
||||
throw new ApiException('项目未归档', [ 'project_id' => $project_id ]);
|
||||
}
|
||||
return $project;
|
||||
}
|
||||
return self::userProject($project_id, $archived, $mustOwner);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1991,7 +1991,9 @@ class ProjectTask extends AbstractModel
|
||||
'dialog_id' => $this->dialog_id,
|
||||
];
|
||||
//
|
||||
$projectOwnerids = ProjectUser::whereProjectId($this->project_id)->whereOwner(1)->pluck('userid')->toArray(); // 项目负责人
|
||||
$projectOwnerids = ProjectUser::whereProjectId($this->project_id)
|
||||
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
|
||||
->pluck('userid')->toArray(); // 项目负责人(含项目管理员)
|
||||
//
|
||||
$array = [];
|
||||
if (empty($userids)) {
|
||||
@@ -2256,6 +2258,40 @@ class ProjectTask extends AbstractModel
|
||||
return $task;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务(含部门负责人只读视角兜底)
|
||||
* @param int $task_id
|
||||
* @param null|bool $archived true:仅限未归档, false:仅限已归档, null:不限制
|
||||
* @param null|bool $trashed true:仅限未删除, false:仅限已删除, null:不限制
|
||||
* @param array $with
|
||||
* @return self
|
||||
*/
|
||||
public static function findForDepartmentView($task_id, $archived = true, $trashed = true, $with = [])
|
||||
{
|
||||
$user = User::auth();
|
||||
$departmentView = UserDepartment::ownerViewContext($user, true);
|
||||
if ($departmentView['enabled']) {
|
||||
$builder = self::with($with)->allData()->where('project_tasks.id', intval($task_id));
|
||||
if ($trashed === false) {
|
||||
$builder->onlyTrashed();
|
||||
} elseif ($trashed === null) {
|
||||
$builder->withTrashed();
|
||||
}
|
||||
$task = $builder->first();
|
||||
// 仅"全员可见"(visibility=1)的任务走负责人只读视角;指定成员可见的任务交由 userTask 按可见性校验
|
||||
if (!empty($task) && intval($task->visibility) === 1 && UserDepartment::isDepartmentReadonlyProject($departmentView, intval($task->project_id))) {
|
||||
if ($archived === true && $task->archived_at != null) {
|
||||
throw new ApiException('任务已归档', ['task_id' => $task_id]);
|
||||
}
|
||||
if ($archived === false && $task->archived_at == null) {
|
||||
throw new ApiException('任务未归档', ['task_id' => $task_id]);
|
||||
}
|
||||
return $task;
|
||||
}
|
||||
}
|
||||
return self::userTask($task_id, $archived, $trashed, $with);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建指定周期内的未完成任务查询(用于周报/日报等)
|
||||
* @param int $userid
|
||||
|
||||
@@ -13,6 +13,8 @@ namespace App\Models;
|
||||
* @property int $sort 排序
|
||||
* @property int $is_default 是否默认模板
|
||||
* @property int $userid 创建人
|
||||
* @property int $use_count 累计使用次数
|
||||
* @property \Illuminate\Support\Carbon|null $last_used_at 最近一次使用时间
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read \App\Models\Project $project
|
||||
@@ -52,7 +54,18 @@ class ProjectTaskTemplate extends AbstractModel
|
||||
'content',
|
||||
'sort',
|
||||
'is_default',
|
||||
'userid'
|
||||
'userid',
|
||||
'use_count',
|
||||
'last_used_at'
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be cast.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $casts = [
|
||||
'last_used_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -74,4 +87,17 @@ class ProjectTaskTemplate extends AbstractModel
|
||||
{
|
||||
return $this->belongsTo(User::class, 'userid');
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子递增使用次数并刷新最近使用时间。
|
||||
*/
|
||||
public function incrementUsage(): void
|
||||
{
|
||||
$this->newQuery()
|
||||
->where('id', $this->id)
|
||||
->update([
|
||||
'use_count' => \DB::raw('use_count + 1'),
|
||||
'last_used_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,36 @@ use App\Module\Base;
|
||||
*/
|
||||
class ProjectUser extends AbstractModel
|
||||
{
|
||||
/** @var int 普通成员编码 */
|
||||
const OWNER_MEMBER = 0;
|
||||
/** @var int 项目负责人编码 */
|
||||
const OWNER_PRIMARY = 1;
|
||||
/** @var int 项目管理员编码 */
|
||||
const OWNER_DEPUTY = 2;
|
||||
|
||||
/**
|
||||
* 是否项目负责人(owner=1)
|
||||
*/
|
||||
public function isPrimaryOwner(): bool
|
||||
{
|
||||
return (int)$this->owner === self::OWNER_PRIMARY;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否项目管理员(owner=2)
|
||||
*/
|
||||
public function isDeputyOwner(): bool
|
||||
{
|
||||
return (int)$this->owner === self::OWNER_DEPUTY;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否负责人(含项目管理员)
|
||||
*/
|
||||
public function isOwner(): bool
|
||||
{
|
||||
return $this->isPrimaryOwner() || $this->isDeputyOwner();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasOne
|
||||
@@ -61,12 +91,19 @@ class ProjectUser extends AbstractModel
|
||||
foreach ($list as $item) {
|
||||
$row = self::whereProjectId($item->project_id)->whereUserid($newUserid)->first();
|
||||
if ($row) {
|
||||
// 已存在则删除原数据,判断改变已存在的数据
|
||||
$row->owner = max($row->owner, $item->owner);
|
||||
// 已存在:仅当离职用户是项目负责人(owner=1)时把接收人升为项目负责人;
|
||||
// 离职用户是项目管理员(owner=2)时不传项目管理员身份给接收人(spec:项目管理员不替补)
|
||||
if ((int)$item->owner === self::OWNER_PRIMARY) {
|
||||
$row->owner = self::OWNER_PRIMARY;
|
||||
}
|
||||
// owner=2/0:保留接收人原有 owner 值不变
|
||||
$row->save();
|
||||
$item->delete();
|
||||
} else {
|
||||
// 不存在则改变原数据
|
||||
// 不存在:转移时如果离职用户是项目管理员,降级为普通成员(不带项目管理员身份过户给接收人)
|
||||
if ((int)$item->owner === self::OWNER_DEPUTY) {
|
||||
$item->owner = self::OWNER_MEMBER;
|
||||
}
|
||||
$item->userid = $newUserid;
|
||||
$item->save();
|
||||
}
|
||||
|
||||
@@ -199,7 +199,6 @@ class Setting extends AbstractModel
|
||||
$key = trim((string)($setting[$vendor . '_key'] ?? ''));
|
||||
return match ($vendor) {
|
||||
'ollama' => $key !== '' || !empty($setting['ollama_base_url']),
|
||||
'wenxin' => $key !== '' && !empty($setting['wenxin_secret']),
|
||||
default => $key !== '',
|
||||
};
|
||||
}
|
||||
@@ -501,7 +500,7 @@ class Setting extends AbstractModel
|
||||
}
|
||||
$limitTime = Carbon::parse($dialogMsg->created_at)->addMinutes($limitNum);
|
||||
if ($limitTime->lt(Carbon::now())) {
|
||||
throw new ApiException('已超过' . Doo::translate(Base::forumMinuteDay($limitNum)) . ',' . $error);
|
||||
throw new ApiException('已超过' . Base::forumMinuteDay($limitNum) . ',' . $error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,8 @@ use Carbon\Carbon;
|
||||
*/
|
||||
class User extends AbstractModel
|
||||
{
|
||||
const IMPORT_MAX = 500;
|
||||
|
||||
protected $primaryKey = 'userid';
|
||||
|
||||
protected $hidden = [
|
||||
@@ -425,6 +427,287 @@ class User extends AbstractModel
|
||||
return $createdUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员创建员工账号(复用注册逻辑,强制正式身份,可选首登改密 / 部门 / 职位)
|
||||
* @param string $email
|
||||
* @param string $password
|
||||
* @param string $nickname
|
||||
* @param array $options changePass(bool,默认true) / emailVerity(bool,默认false,标记邮箱已认证) / department(int[]) / profession(string)
|
||||
* @return self
|
||||
* @throws ApiException
|
||||
*/
|
||||
public static function createByAdmin(string $email, $password, string $nickname, array $options = []): self
|
||||
{
|
||||
$nickname = trim($nickname);
|
||||
if (mb_strlen($nickname) < 2 || mb_strlen($nickname) > 20) {
|
||||
throw new ApiException('昵称需为2-20个字');
|
||||
}
|
||||
$changePass = ($options['changePass'] ?? true) ? 1 : 0;
|
||||
$emailVerity = ($options['emailVerity'] ?? false) ? 1 : 0;
|
||||
$profession = trim((string)($options['profession'] ?? ''));
|
||||
// 校验前置(reg 之前快速失败,且可在无 Swoole 环境单测)
|
||||
self::assertValidProfession($profession);
|
||||
$departmentIds = self::assertValidDepartments($options['department'] ?? []);
|
||||
// 复用 reg:邮箱校验/查重、passwordPolicy、Doo::userCreate、az/pinyin、全员群、索引同步、user_onboard hook
|
||||
$user = self::reg($email, $password, ['nickname' => $nickname]);
|
||||
// 管理员显式创建的账号视为正式员工,去除系统 reg_identity 可能带上的 temp
|
||||
if (in_array('temp', $user->identity)) {
|
||||
$user->identity = Base::arrayImplode(array_diff($user->identity, ['temp']));
|
||||
}
|
||||
$user->changepass = $changePass; // 复用现有首登强制改密机制
|
||||
$user->email_verity = $emailVerity; // 管理员可在创建时直接标记邮箱认证状态
|
||||
if ($profession !== '') {
|
||||
$user->profession = $profession;
|
||||
}
|
||||
if ($departmentIds) {
|
||||
$user->department = Base::arrayImplode($departmentIds);
|
||||
}
|
||||
$user->save();
|
||||
// 设置了部门 → 加入对应部门群(复刻 operation 的 type=department 入群逻辑)
|
||||
if ($departmentIds) {
|
||||
$departments = UserDepartment::whereIn('id', $departmentIds)->get();
|
||||
foreach ($departments as $department) {
|
||||
try {
|
||||
if ($department->dialog_id > 0 && $dialog = WebSocketDialog::find($department->dialog_id)) {
|
||||
$dialog->joinGroup([$user->userid], 0, true);
|
||||
$dialog->pushMsg("groupJoin", null, [$user->userid]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 部门入群为尽力投递:单个部门失败不影响账号创建与其他部门
|
||||
\Log::warning('createByAdmin: 部门入群失败', [
|
||||
'userid' => $user->userid,
|
||||
'department_id' => $department->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将上传表格(Excel::toArray 的二维数组)归一化为导入行
|
||||
* @param array $sheet
|
||||
* @return array [{line, email, nickname, password}]
|
||||
*/
|
||||
public static function parseImportRows(array $sheet): array
|
||||
{
|
||||
$rows = [];
|
||||
foreach ($sheet as $index => $cells) {
|
||||
if ($index === 0) {
|
||||
continue; // 表头
|
||||
}
|
||||
$email = trim((string)($cells[0] ?? ''));
|
||||
$nickname = trim((string)($cells[1] ?? ''));
|
||||
$password = trim((string)($cells[2] ?? ''));
|
||||
$profession = trim((string)($cells[3] ?? ''));
|
||||
if ($email === '' && $nickname === '' && $password === '') {
|
||||
continue; // 空行(仅职位有值也视为空行跳过)
|
||||
}
|
||||
$rows[] = [
|
||||
'line' => $index + 1, // 电子表格行号(从 1 开始)
|
||||
'email' => $email,
|
||||
'nickname' => $nickname,
|
||||
'password' => $password,
|
||||
'profession' => $profession,
|
||||
];
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验单条导入行
|
||||
* @param array $row ['email'=>,'nickname'=>,'password'=>,'profession'=>(选填)]
|
||||
* @return string|null 错误文案;null 表示通过
|
||||
*/
|
||||
public static function validateImportRow(array $row): ?string
|
||||
{
|
||||
$email = trim((string)($row['email'] ?? ''));
|
||||
$nickname = trim((string)($row['nickname'] ?? ''));
|
||||
$password = trim((string)($row['password'] ?? ''));
|
||||
if ($email === '' || $nickname === '' || $password === '') {
|
||||
return '邮箱、昵称、初始密码均为必填';
|
||||
}
|
||||
if (!Base::isEmail($email)) {
|
||||
return '邮箱格式不正确';
|
||||
}
|
||||
if (mb_strlen($nickname) < 2 || mb_strlen($nickname) > 20) {
|
||||
return '昵称需为2-20个字';
|
||||
}
|
||||
try {
|
||||
self::passwordPolicy($password);
|
||||
} catch (ApiException $e) {
|
||||
return $e->getMessage();
|
||||
}
|
||||
// 职位/职称选填,填写则校验 2-20 字
|
||||
try {
|
||||
self::assertValidProfession((string)($row['profession'] ?? ''));
|
||||
} catch (ApiException $e) {
|
||||
return $e->getMessage();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验职位/职称:非空时必须 2-20 字(复用 operation 的现有文案)
|
||||
* @param string $profession
|
||||
* @return void
|
||||
* @throws ApiException
|
||||
*/
|
||||
public static function assertValidProfession(string $profession): void
|
||||
{
|
||||
$profession = trim($profession);
|
||||
if ($profession === '') {
|
||||
return;
|
||||
}
|
||||
if (mb_strlen($profession) < 2) {
|
||||
throw new ApiException('职位/职称不可以少于2个字');
|
||||
}
|
||||
if (mb_strlen($profession) > 20) {
|
||||
throw new ApiException('职位/职称最多只能设置20个字');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 规整并校验部门 ID 列表:转正整数去重、最多 10 个、且每个必须存在
|
||||
* @param mixed $ids
|
||||
* @return int[]
|
||||
* @throws ApiException
|
||||
*/
|
||||
public static function assertValidDepartments($ids): array
|
||||
{
|
||||
if (!is_array($ids)) {
|
||||
$ids = [];
|
||||
}
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), fn($v) => $v > 0)));
|
||||
if (count($ids) > 10) {
|
||||
throw new ApiException('最多只可加入10个部门');
|
||||
}
|
||||
if ($ids) {
|
||||
$existing = UserDepartment::whereIn('id', $ids)->pluck('id')->map(fn($v) => (int)$v)->all();
|
||||
if (count($existing) < count($ids)) {
|
||||
throw new ApiException('修改部门不存在');
|
||||
}
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导入用户(部门/职位逐行:department 来自前端逐行设置,profession 来自 Excel 行)
|
||||
* @param array $rows 每行含 email/nickname/password/profession,可选 department(int[])
|
||||
* @param bool $changePass 是否要求首登改密(对本批所有账号生效)
|
||||
* @return array ['total'=>int, 'success'=>int, 'failed'=>[['line','email','reason']]]
|
||||
* @throws ApiException 行数超限
|
||||
*/
|
||||
public static function importUsers(array $rows, bool $changePass = true): array
|
||||
{
|
||||
if (count($rows) > self::IMPORT_MAX) {
|
||||
throw new ApiException('单次最多导入' . self::IMPORT_MAX . '条');
|
||||
}
|
||||
$success = 0;
|
||||
$failed = [];
|
||||
$seen = [];
|
||||
foreach ($rows as $row) {
|
||||
$error = self::validateImportRow($row);
|
||||
if ($error === null) {
|
||||
$emailLower = strtolower(trim((string)$row['email']));
|
||||
if (isset($seen[$emailLower])) {
|
||||
$error = '文件内邮箱重复';
|
||||
} else {
|
||||
$seen[$emailLower] = true;
|
||||
}
|
||||
}
|
||||
if ($error === null) {
|
||||
try {
|
||||
self::createByAdmin($row['email'], $row['password'], $row['nickname'], [
|
||||
'changePass' => $changePass,
|
||||
'emailVerity' => !empty($row['email_verity']),
|
||||
'department' => $row['department'] ?? [],
|
||||
'profession' => $row['profession'] ?? '',
|
||||
]);
|
||||
$success++;
|
||||
continue;
|
||||
} catch (ApiException $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
$failed[] = [
|
||||
'line' => $row['line'] ?? 0,
|
||||
'email' => $row['email'] ?? '',
|
||||
'reason' => $error,
|
||||
];
|
||||
}
|
||||
return [
|
||||
'total' => count($rows),
|
||||
'success' => $success,
|
||||
'failed' => $failed,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导入预览(只解析+校验,不创建任何账号)
|
||||
* 逐行判定 ok/error:必填/邮箱格式/昵称长度/密码策略、文件内邮箱重复、系统中邮箱已存在
|
||||
* @param array $rows parseImportRows 的输出
|
||||
* @return array ['total'=>int,'valid'=>int,'invalid'=>int,'rows'=>[['line','email','nickname','password','status','reason']]]
|
||||
*/
|
||||
public static function importPreview(array $rows): array
|
||||
{
|
||||
if (count($rows) > self::IMPORT_MAX) {
|
||||
throw new ApiException('单次最多导入' . self::IMPORT_MAX . '条');
|
||||
}
|
||||
// 预查系统中已存在的邮箱(小写比较)
|
||||
$emails = [];
|
||||
foreach ($rows as $row) {
|
||||
$e = strtolower(trim((string)($row['email'] ?? '')));
|
||||
if ($e !== '') {
|
||||
$emails[$e] = true;
|
||||
}
|
||||
}
|
||||
$existing = [];
|
||||
if ($emails) {
|
||||
foreach (self::whereIn('email', array_keys($emails))->pluck('email') as $em) {
|
||||
$existing[strtolower($em)] = true;
|
||||
}
|
||||
}
|
||||
$seen = [];
|
||||
$valid = 0;
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$reason = self::validateImportRow($row);
|
||||
$emailLower = strtolower(trim((string)($row['email'] ?? '')));
|
||||
if ($reason === null) {
|
||||
if (isset($seen[$emailLower])) {
|
||||
$reason = '文件内邮箱重复';
|
||||
} else {
|
||||
$seen[$emailLower] = true;
|
||||
if (isset($existing[$emailLower])) {
|
||||
$reason = '邮箱地址已存在';
|
||||
}
|
||||
}
|
||||
}
|
||||
$ok = $reason === null;
|
||||
if ($ok) {
|
||||
$valid++;
|
||||
}
|
||||
$list[] = [
|
||||
'line' => $row['line'] ?? 0,
|
||||
'email' => $row['email'] ?? '',
|
||||
'nickname' => $row['nickname'] ?? '',
|
||||
'password' => $row['password'] ?? '',
|
||||
'profession' => $row['profession'] ?? '',
|
||||
'email_verity' => 1, // 默认标记为已认证,前端可在预览中按行调整
|
||||
'status' => $ok ? 'ok' : 'error',
|
||||
'reason' => $reason ?? '',
|
||||
];
|
||||
}
|
||||
return [
|
||||
'total' => count($rows),
|
||||
'valid' => $valid,
|
||||
'invalid' => count($rows) - $valid,
|
||||
'rows' => $list,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取我的ID
|
||||
* @return int
|
||||
|
||||
@@ -151,6 +151,7 @@ class UserBot extends AbstractModel
|
||||
$name = match ($name) {
|
||||
'system-msg' => '系统消息',
|
||||
'task-alert' => '任务提醒',
|
||||
'todo-alert' => '待办提醒',
|
||||
'check-in' => '签到打卡',
|
||||
'anon-msg' => '匿名消息',
|
||||
'approval-alert' => '审批',
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Module\Base;
|
||||
use Cache;
|
||||
use Request;
|
||||
|
||||
/**
|
||||
* App\Models\UserDepartment
|
||||
@@ -35,6 +37,10 @@ use Cache;
|
||||
*/
|
||||
class UserDepartment extends AbstractModel
|
||||
{
|
||||
protected $appends = [
|
||||
'deputy_userids',
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取所有父级部门
|
||||
* @return array
|
||||
@@ -50,6 +56,55 @@ class UserDepartment extends AbstractModel
|
||||
return $parents;
|
||||
}
|
||||
|
||||
/**
|
||||
* 部门管理员 userid 列表
|
||||
* @return array
|
||||
*/
|
||||
public function getDeputyUseridsAttribute(): array
|
||||
{
|
||||
if (empty($this->id)) {
|
||||
return [];
|
||||
}
|
||||
return \DB::table('user_department_owners')
|
||||
->where('department_id', $this->id)
|
||||
->pluck('userid')
|
||||
->map(fn($v) => (int)$v)
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否部门负责人(与 owner_userid 一致)
|
||||
*/
|
||||
public function isPrimaryOwner($userid): bool
|
||||
{
|
||||
if (empty($this->id) || $userid <= 0) {
|
||||
return false;
|
||||
}
|
||||
return (int)$this->owner_userid === (int)$userid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否部门管理员(在 user_department_owners 表里)
|
||||
*/
|
||||
public function isDeputyOwner($userid): bool
|
||||
{
|
||||
if (empty($this->id) || $userid <= 0) {
|
||||
return false;
|
||||
}
|
||||
return \DB::table('user_department_owners')
|
||||
->where('department_id', $this->id)
|
||||
->where('userid', $userid)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否负责人(含部门管理员)
|
||||
*/
|
||||
public function isOwner($userid): bool
|
||||
{
|
||||
return $this->isPrimaryOwner($userid) || $this->isDeputyOwner($userid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存部门
|
||||
* @param $data
|
||||
@@ -65,18 +120,38 @@ class UserDepartment extends AbstractModel
|
||||
}
|
||||
$this->updateInstance($data);
|
||||
//
|
||||
// 防御:新负责人若残留在 user_department_owners 中(如曾是该部门管理员),清理掉
|
||||
// 否则后续 delDeputy / 罢免接口会把当前部门负责人误移出部门
|
||||
if ($this->id && (int)$this->owner_userid > 0) {
|
||||
\DB::table('user_department_owners')
|
||||
->where('department_id', $this->id)
|
||||
->where('userid', (int)$this->owner_userid)
|
||||
->delete();
|
||||
}
|
||||
//
|
||||
if ($this->dialog_id > 0) {
|
||||
// 已有群
|
||||
$dialog = WebSocketDialog::find($this->dialog_id);
|
||||
if ($dialog) {
|
||||
$oldOwnerId = (int)$dialog->owner_id;
|
||||
$dialog->name = $this->name;
|
||||
$dialog->owner_id = $this->owner_userid;
|
||||
if ($dialog->save()) {
|
||||
$dialog->joinGroup($this->owner_userid, 0, true);
|
||||
// 同步 role:原负责人 role=0、新负责人 role=1(部门管理员 role=2 保留不动)
|
||||
if ($oldOwnerId > 0 && $oldOwnerId !== (int)$this->owner_userid) {
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $oldOwnerId)
|
||||
->update(['role' => 0]);
|
||||
}
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $this->owner_userid)
|
||||
->update(['role' => 1]);
|
||||
$dialog->pushMsg("groupUpdate", [
|
||||
'id' => $dialog->id,
|
||||
'name' => $dialog->name,
|
||||
'owner_id' => $dialog->owner_id,
|
||||
'deputy_ids' => $dialog->fresh()->deputy_ids,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -86,16 +161,33 @@ class UserDepartment extends AbstractModel
|
||||
if (empty($dialog)) {
|
||||
throw new ApiException("选择现有聊天群不存在");
|
||||
}
|
||||
$oldOwnerId = (int)$dialog->owner_id;
|
||||
$dialog->name = $this->name;
|
||||
$dialog->owner_id = $this->owner_userid;
|
||||
$dialog->group_type = 'department';
|
||||
if ($dialog->save()) {
|
||||
$dialog->joinGroup($this->owner_userid, 0, true);
|
||||
// 同步 role:原负责人 role=0、新负责人 role=1、原部门管理员 role=0
|
||||
// 原部门管理员清零:避免 dialog_users.role=2 与 user_department_owners 不一致
|
||||
// (部门管理员关系不带过来,须通过 addDeputy 显式重新任命)
|
||||
if ($oldOwnerId > 0 && $oldOwnerId !== (int)$this->owner_userid) {
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $oldOwnerId)
|
||||
->update(['role' => 0]);
|
||||
}
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', '!=', $this->owner_userid)
|
||||
->where('role', 2)
|
||||
->update(['role' => 0]);
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $this->owner_userid)
|
||||
->update(['role' => 1]);
|
||||
$dialog->pushMsg("groupUpdate", [
|
||||
'id' => $dialog->id,
|
||||
'name' => $dialog->name,
|
||||
'owner_id' => $dialog->owner_id,
|
||||
'group_type' => $dialog->group_type,
|
||||
'deputy_ids' => $dialog->fresh()->deputy_ids,
|
||||
]);
|
||||
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'notice', [
|
||||
'notice' => User::nickname() . " 将此群改为部门群"
|
||||
@@ -116,6 +208,12 @@ class UserDepartment extends AbstractModel
|
||||
$oldUser->department = array_diff($oldUser->department, [$this->id]);
|
||||
$oldUser->department = "," . implode(",", $oldUser->department) . ",";
|
||||
$oldUser->save();
|
||||
// 原主从 users.department 移除后也要退出部门群(保持成员关系=群关系一致)
|
||||
// checkDelete=false:业务流程跳过 owner_id/important 校验
|
||||
if ($this->dialog_id > 0) {
|
||||
$dialog = WebSocketDialog::find($this->dialog_id);
|
||||
$dialog?->exitGroup($oldUser->userid, 'remove', false, true);
|
||||
}
|
||||
}
|
||||
if ($newUser) {
|
||||
$newUser->department = array_diff($newUser->department, [$this->id]);
|
||||
@@ -126,6 +224,123 @@ class UserDepartment extends AbstractModel
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 任命部门管理员
|
||||
* - 部门管理员自动加入 users.department(成为部门成员,与负责人对齐)
|
||||
* - 部门管理员自动加入部门群 + 设 role=2
|
||||
* - 幂等(已是部门管理员不报错)
|
||||
*
|
||||
* @param int $userid
|
||||
* @return void
|
||||
* @throws ApiException
|
||||
*/
|
||||
public function addDeputy($userid)
|
||||
{
|
||||
if ($userid <= 0) {
|
||||
throw new ApiException('请选择有效的成员');
|
||||
}
|
||||
$user = User::whereUserid($userid)->first();
|
||||
if (!$user) {
|
||||
throw new ApiException('该用户不存在');
|
||||
}
|
||||
if ((int)$this->owner_userid === (int)$userid) {
|
||||
throw new ApiException('不能将部门负责人任命为部门管理员');
|
||||
}
|
||||
|
||||
AbstractModel::transaction(function () use ($userid, $user) {
|
||||
// 写部门管理员表(unique key 自动幂等)
|
||||
\DB::table('user_department_owners')->insertOrIgnore([
|
||||
'department_id' => $this->id,
|
||||
'userid' => $userid,
|
||||
]);
|
||||
|
||||
// 加入 users.department(成为部门成员,与负责人对齐)
|
||||
$userDeptIds = $user->department; // accessor 返回数组
|
||||
if (!in_array($this->id, $userDeptIds)) {
|
||||
$userDeptIds = array_merge($userDeptIds, [$this->id]);
|
||||
$user->department = "," . implode(",", $userDeptIds) . ",";
|
||||
$user->save();
|
||||
}
|
||||
|
||||
// 加部门管理员入部门群 + 设 role=2 + important=true
|
||||
if ($this->dialog_id > 0) {
|
||||
$dialog = WebSocketDialog::find($this->dialog_id);
|
||||
if ($dialog) {
|
||||
// joinGroup($userid, $inviter, $important=null, $pushMsg=true)
|
||||
// important=true:部门管理员成员关系不可被普通群操作打散
|
||||
$dialog->joinGroup($userid, 0, true, true);
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $userid)
|
||||
->update(['role' => 2]);
|
||||
$dialog->pushMsg('groupUpdate', [
|
||||
'id' => $dialog->id,
|
||||
'deputy_ids' => $dialog->fresh()->deputy_ids,
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 罢免部门管理员
|
||||
* - 删部门管理员表记录
|
||||
* - 从 users.department 移除该部门 ID(与负责人"离开部门"对齐)
|
||||
* - 退出部门群(成员关系=群关系一致)
|
||||
* - 幂等
|
||||
*
|
||||
* @param int $userid
|
||||
* @return void
|
||||
*/
|
||||
public function delDeputy($userid)
|
||||
{
|
||||
if ($userid <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 防御:当前部门负责人不能被罢免(saveDepartment 应已清理残留,此处兜底)
|
||||
// 仅清理 user_department_owners 中的悬挂记录,绝不联动移除其部门成员关系/部门群成员
|
||||
if ((int)$this->owner_userid === (int)$userid) {
|
||||
\DB::table('user_department_owners')
|
||||
->where('department_id', $this->id)
|
||||
->where('userid', $userid)
|
||||
->delete();
|
||||
return;
|
||||
}
|
||||
|
||||
AbstractModel::transaction(function () use ($userid) {
|
||||
$deleted = \DB::table('user_department_owners')
|
||||
->where('department_id', $this->id)
|
||||
->where('userid', $userid)
|
||||
->delete();
|
||||
|
||||
if ($deleted > 0) {
|
||||
// 从 users.department 移除该部门 ID
|
||||
$user = User::whereUserid($userid)->first();
|
||||
if ($user) {
|
||||
$userDeptIds = $user->department;
|
||||
if (in_array($this->id, $userDeptIds)) {
|
||||
$userDeptIds = array_diff($userDeptIds, [$this->id]);
|
||||
$user->department = "," . implode(",", $userDeptIds) . ",";
|
||||
$user->save();
|
||||
}
|
||||
}
|
||||
|
||||
// 退出部门群(exitGroup 会清除 dialog_users 记录,role 随之消失)
|
||||
if ($this->dialog_id > 0) {
|
||||
$dialog = WebSocketDialog::find($this->dialog_id);
|
||||
if ($dialog) {
|
||||
// checkDelete=false:业务流程跳过 owner_id/important 校验
|
||||
$dialog->exitGroup($userid, 'remove', false, true);
|
||||
$dialog->pushMsg('groupUpdate', [
|
||||
'id' => $dialog->id,
|
||||
'deputy_ids' => $dialog->fresh()->deputy_ids,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除部门
|
||||
* @return void
|
||||
@@ -148,6 +363,8 @@ class UserDepartment extends AbstractModel
|
||||
// 解散群组
|
||||
$dialog = WebSocketDialog::find($this->dialog_id);
|
||||
$dialog?->deleteDialog();
|
||||
// 清理部门管理员记录(防悬挂)
|
||||
\DB::table('user_department_owners')->where('department_id', $this->id)->delete();
|
||||
//
|
||||
$this->delete();
|
||||
}
|
||||
@@ -160,6 +377,7 @@ class UserDepartment extends AbstractModel
|
||||
*/
|
||||
public static function transfer($originalUserid, $newUserid)
|
||||
{
|
||||
// 部门负责人转让(保持现有逻辑)
|
||||
self::whereOwnerUserid($originalUserid)->chunkById(100, function ($list) use ($originalUserid, $newUserid) {
|
||||
/** @var self $item */
|
||||
foreach ($list as $item) {
|
||||
@@ -168,6 +386,11 @@ class UserDepartment extends AbstractModel
|
||||
]);
|
||||
}
|
||||
});
|
||||
// 部门管理员离职清理(新增):直接删除离职用户的所有部门管理员记录
|
||||
// 不需要清群 role —— UserTransfer::exitDialog 会把人踢出所有群,role 随成员关系一起消失
|
||||
\DB::table('user_department_owners')
|
||||
->where('userid', $originalUserid)
|
||||
->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,6 +413,93 @@ class UserDepartment extends AbstractModel
|
||||
return array_unique($subIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户可切换负责人视角的部门(正负责人 + 部门管理员)
|
||||
* @param int $userid
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public static function getManagedDepartments($userid)
|
||||
{
|
||||
$userid = intval($userid);
|
||||
if ($userid <= 0) {
|
||||
return collect();
|
||||
}
|
||||
$deputyDepartmentIds = \DB::table('user_department_owners')
|
||||
->where('userid', $userid)
|
||||
->pluck('department_id')
|
||||
->map(fn($v) => intval($v))
|
||||
->toArray();
|
||||
|
||||
return self::select(['id', 'name', 'parent_id', 'owner_userid'])
|
||||
->where(function ($query) use ($userid, $deputyDepartmentIds) {
|
||||
$query->where('owner_userid', $userid);
|
||||
if ($deputyDepartmentIds) {
|
||||
$query->orWhereIn('id', $deputyDepartmentIds);
|
||||
}
|
||||
})
|
||||
->orderBy('id')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户选择的负责人视角部门范围(含所有下级部门)
|
||||
* @param int $userid
|
||||
* @param array|string|null $selectedIds all/空表示全部可管理部门
|
||||
* @return array
|
||||
*/
|
||||
public static function getManagedDepartmentScopeIds($userid, $selectedIds = null): array
|
||||
{
|
||||
$managedIds = self::getManagedDepartments($userid)->pluck('id')->map(fn($v) => intval($v))->toArray();
|
||||
if (empty($managedIds)) {
|
||||
return [];
|
||||
}
|
||||
if ($selectedIds === 'all' || $selectedIds === null || $selectedIds === '' || $selectedIds === []) {
|
||||
$selected = $managedIds;
|
||||
} else {
|
||||
if (!is_array($selectedIds)) {
|
||||
$selectedIds = explode(',', (string)$selectedIds);
|
||||
}
|
||||
$selected = array_values(array_intersect(
|
||||
array_map('intval', $selectedIds),
|
||||
$managedIds
|
||||
));
|
||||
}
|
||||
if (empty($selected)) {
|
||||
return [];
|
||||
}
|
||||
$scopeIds = [];
|
||||
foreach ($selected as $departmentId) {
|
||||
$scopeIds[] = $departmentId;
|
||||
$scopeIds = array_merge($scopeIds, self::getAllSubDepartmentIds($departmentId));
|
||||
}
|
||||
return array_values(array_unique(array_map('intval', $scopeIds)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取负责人视角可管理的成员 userid
|
||||
* @param int $userid
|
||||
* @param array|string|null $selectedIds
|
||||
* @return array
|
||||
*/
|
||||
public static function getManagedMemberUserids($userid, $selectedIds = null): array
|
||||
{
|
||||
$departmentIds = self::getManagedDepartmentScopeIds($userid, $selectedIds);
|
||||
if (empty($departmentIds)) {
|
||||
return [];
|
||||
}
|
||||
return User::select(['userid'])
|
||||
->where(function ($query) use ($departmentIds) {
|
||||
foreach ($departmentIds as $departmentId) {
|
||||
$query->orWhere('department', 'like', "%,{$departmentId},%");
|
||||
}
|
||||
})
|
||||
->pluck('userid')
|
||||
->map(fn($v) => intval($v))
|
||||
->unique()
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取部门基本信息(缓存时间1小时)
|
||||
* @param int|array $ids
|
||||
@@ -232,4 +542,77 @@ class UserDepartment extends AbstractModel
|
||||
return is_array($ids) ? $result : $result->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 部门负责人视角上下文(只读)。
|
||||
* $defaultAll=true 用于项目内只读辅助接口兜底:前端漏传部门选择时按全部可管理部门判断。
|
||||
*/
|
||||
public static function ownerViewContext(User $user, bool $defaultAll = false): array
|
||||
{
|
||||
$ids = Request::input('department_owner_ids', Request::input('department_ids'));
|
||||
if (($ids === null || $ids === '') && $defaultAll) {
|
||||
$ids = 'all';
|
||||
}
|
||||
$empty = [
|
||||
'enabled' => false,
|
||||
'member_userids' => [],
|
||||
'project_ids' => [],
|
||||
'project_id_map' => [],
|
||||
'own_project_ids' => [],
|
||||
'own_project_id_map' => [],
|
||||
];
|
||||
if ($ids === null || $ids === '' || Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') {
|
||||
return $empty;
|
||||
}
|
||||
$memberUserids = self::getManagedMemberUserids($user->userid, $ids);
|
||||
if (empty($memberUserids)) {
|
||||
return $empty;
|
||||
}
|
||||
// 项目可单独关闭"部门负责人视角可见",关闭后对负责人隐藏(含项目和任务群聊)
|
||||
$projectIds = ProjectUser::whereIn('project_users.userid', $memberUserids)
|
||||
->join('projects', 'projects.id', '=', 'project_users.project_id')
|
||||
->whereNull('projects.deleted_at')
|
||||
->where(function ($query) {
|
||||
$query->where('projects.department_owner_view', '<>', 'close')
|
||||
->orWhereNull('projects.department_owner_view');
|
||||
})
|
||||
->distinct()
|
||||
->pluck('projects.id')
|
||||
->map(fn($v) => intval($v))
|
||||
->values()
|
||||
->toArray();
|
||||
$ownProjectIds = ProjectUser::whereUserid($user->userid)
|
||||
->pluck('project_id')
|
||||
->map(fn($v) => intval($v))
|
||||
->unique()
|
||||
->values()
|
||||
->toArray();
|
||||
return [
|
||||
'enabled' => !empty($projectIds),
|
||||
'member_userids' => $memberUserids,
|
||||
'project_ids' => $projectIds,
|
||||
'project_id_map' => array_fill_keys($projectIds, true),
|
||||
'own_project_ids' => $ownProjectIds,
|
||||
'own_project_id_map' => array_fill_keys($ownProjectIds, true),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断项目是否属于部门只读范围(非本人项目)
|
||||
*/
|
||||
public static function isDepartmentReadonlyProject(array $context, int $projectId): bool
|
||||
{
|
||||
return !empty($context['enabled'])
|
||||
&& isset($context['project_id_map'][$projectId])
|
||||
&& !isset($context['own_project_id_map'][$projectId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为项目数据附加部门只读标记
|
||||
*/
|
||||
public static function appendDepartmentReadonlyProject(array $project, array $context): array
|
||||
{
|
||||
$project['department_readonly'] = self::isDepartmentReadonlyProject($context, intval($project['id']));
|
||||
return $project;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -90,9 +90,15 @@ class UserTransfer extends AbstractModel
|
||||
$dialog->owner_id = $this->new_userid;
|
||||
if ($dialog->save()) {
|
||||
$dialog->joinGroup($this->new_userid, 0);
|
||||
// 同步 role=1:保证 deputy_ids 与 owner_id 一致
|
||||
// 若 new_userid 之前是群管理员(role=2),升为群主后必须从 deputy 列表移出
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $this->new_userid)
|
||||
->update(['role' => 1]);
|
||||
$dialog->pushMsg("groupUpdate", [
|
||||
'id' => $dialog->id,
|
||||
'owner_id' => $dialog->owner_id,
|
||||
'deputy_ids' => $dialog->fresh()->deputy_ids,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@ class WebSocketDialog extends AbstractModel
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $appends = ['deputy_ids'];
|
||||
|
||||
/**
|
||||
* 头像地址
|
||||
* @param $value
|
||||
@@ -260,6 +262,15 @@ class WebSocketDialog extends AbstractModel
|
||||
$data[$field] = $data[$field] ?? null;
|
||||
}
|
||||
}
|
||||
// DB::table 列表/search/beyond 渠道进入的是 stdClass,不会触发 Eloquent $appends。
|
||||
// 这里统一补齐 deputy_ids,保证群管理员入口和标识在所有会话来源中一致。
|
||||
if (($data['type'] ?? null) === 'group' && !array_key_exists('deputy_ids', $data)) {
|
||||
$data['deputy_ids'] = WebSocketDialogUser::whereDialogId($data['id'])
|
||||
->where('role', 2)
|
||||
->pluck('userid')
|
||||
->map(fn($v) => (int)$v)
|
||||
->toArray();
|
||||
}
|
||||
$data['avatar'] = Base::fillUrl($data['avatar']);
|
||||
|
||||
// 会员必要字段
|
||||
@@ -457,11 +468,12 @@ class WebSocketDialog extends AbstractModel
|
||||
* @param int|array $userid 加入的会员ID或会员ID组
|
||||
* @param int $inviter 邀请人
|
||||
* @param bool|null $important 重要人员(null不修改、bool修改)
|
||||
* @param bool $pushMsg 是否推送消息
|
||||
* @return bool
|
||||
*/
|
||||
public function joinGroup($userid, $inviter, $important = null)
|
||||
public function joinGroup($userid, $inviter, $important = null, $pushMsg = true)
|
||||
{
|
||||
AbstractModel::transaction(function () use ($important, $inviter, $userid) {
|
||||
AbstractModel::transaction(function () use ($important, $inviter, $userid, $pushMsg) {
|
||||
foreach (is_array($userid) ? $userid : [$userid] as $value) {
|
||||
if ($value > 0) {
|
||||
$updateData = [
|
||||
@@ -479,7 +491,7 @@ class WebSocketDialog extends AbstractModel
|
||||
'bot' => User::isBot($value) ? 1 : 0
|
||||
]);
|
||||
}, $isInsert);
|
||||
if ($isInsert) {
|
||||
if ($isInsert && $pushMsg) {
|
||||
WebSocketDialogMsg::sendMsg(null, $this->id, 'notice', [
|
||||
'notice' => User::userid2nickname($value) . " 已加入群组"
|
||||
], $inviter, true, true);
|
||||
@@ -487,9 +499,11 @@ class WebSocketDialog extends AbstractModel
|
||||
}
|
||||
}
|
||||
});
|
||||
$data = WebSocketDialog::generatePeople($this->id);
|
||||
$data['id'] = $this->id;
|
||||
$this->pushMsg("groupUpdate", $data);
|
||||
if ($pushMsg) {
|
||||
$data = WebSocketDialog::generatePeople($this->id);
|
||||
$data['id'] = $this->id;
|
||||
$this->pushMsg("groupUpdate", $data);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -515,11 +529,40 @@ class WebSocketDialog extends AbstractModel
|
||||
foreach ($list as $item) {
|
||||
if ($checkDelete) {
|
||||
if ($type === 'remove') {
|
||||
// 移出时:如果是全员群仅允许管理员操作,其他群仅群主或邀请人可以操作
|
||||
// 移出时:如果是全员群仅允许管理员操作,其他群主/群管理员/邀请人可以操作
|
||||
if ($this->group_type === 'all') {
|
||||
User::auth("admin");
|
||||
} elseif (!in_array(User::userid(), [$this->owner_id, $item->inviter])) {
|
||||
throw new ApiException('只有群主或邀请人可以移出成员');
|
||||
} else {
|
||||
$actor = User::userid();
|
||||
// 未认证时拒绝
|
||||
if ($actor <= 0) {
|
||||
throw new ApiException('只有群主或邀请人可以移出成员');
|
||||
}
|
||||
|
||||
// 目标是群主或群管理员时的保护
|
||||
$targetIsPrimaryOwner = $this->isPrimaryOwner($item->userid);
|
||||
$targetIsDeputyOwner = $this->isDeputyOwner($item->userid);
|
||||
|
||||
if ($targetIsPrimaryOwner || $targetIsDeputyOwner) {
|
||||
// 普通邀请人不能移出群主或群管理员
|
||||
$actorIsPrimaryOwner = $this->isPrimaryOwner($actor);
|
||||
$actorIsDeputyOwner = $this->isDeputyOwner($actor);
|
||||
|
||||
if (!$actorIsPrimaryOwner && !$actorIsDeputyOwner) {
|
||||
throw new ApiException('普通成员不能移出群主或群管理员');
|
||||
}
|
||||
|
||||
// 群管理员不能移出群主或其他群管理员
|
||||
if ($actorIsDeputyOwner && !$actorIsPrimaryOwner) {
|
||||
throw new ApiException('群管理员不能移出群主或其他群管理员');
|
||||
}
|
||||
}
|
||||
|
||||
// 普通成员:群主、群管理员、邀请人可移出
|
||||
$allowedActor = $this->isOwner($actor) || $actor === (int)$item->inviter;
|
||||
if (!$allowedActor) {
|
||||
throw new ApiException('只有群主、群管理员或邀请人可以移出成员');
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($item->userid == $this->owner_id) {
|
||||
@@ -547,9 +590,11 @@ class WebSocketDialog extends AbstractModel
|
||||
});
|
||||
});
|
||||
//
|
||||
$data = WebSocketDialog::generatePeople($this->id);
|
||||
$data['id'] = $this->id;
|
||||
$this->pushMsg("groupUpdate", $data);
|
||||
if ($pushMsg) {
|
||||
$data = WebSocketDialog::generatePeople($this->id);
|
||||
$data['id'] = $this->id;
|
||||
$this->pushMsg("groupUpdate", $data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -635,6 +680,93 @@ class WebSocketDialog extends AbstractModel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否群主(与 owner_id 一致)
|
||||
*/
|
||||
public function isPrimaryOwner($userid): bool
|
||||
{
|
||||
return $userid > 0 && (int)$this->owner_id === (int)$userid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否群管理员(仅 web_socket_dialog_users.role=2)
|
||||
*/
|
||||
public function isDeputyOwner($userid): bool
|
||||
{
|
||||
if ($userid <= 0) {
|
||||
return false;
|
||||
}
|
||||
return WebSocketDialogUser::where('dialog_id', $this->id)
|
||||
->where('userid', $userid)
|
||||
->where('role', 2)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否群主(含群管理员)
|
||||
*/
|
||||
public function isOwner($userid): bool
|
||||
{
|
||||
return $this->isPrimaryOwner($userid) || $this->isDeputyOwner($userid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否有权限设置/取消本会话内「他人」的待办
|
||||
* 放行:群主/群管理员、关联项目负责人/项目管理员、关联任务负责人(及任务所属项目负责人/管理员)
|
||||
*
|
||||
* @param int $userid
|
||||
* @return bool
|
||||
*/
|
||||
public function checkTodoOwnerPermission($userid): bool
|
||||
{
|
||||
$userid = intval($userid);
|
||||
if ($userid <= 0) {
|
||||
return false;
|
||||
}
|
||||
// 系统管理员:可管理任意会话的他人待办(与管理员全局管理能力一致,覆盖无群主的全员群等)
|
||||
if (User::find($userid)?->isAdmin()) {
|
||||
return true;
|
||||
}
|
||||
// 群主 / 群管理员
|
||||
if ($this->isOwner($userid)) {
|
||||
return true;
|
||||
}
|
||||
// 关联项目(项目群)负责人 / 项目管理员
|
||||
$project = Project::whereDialogId($this->id)->first();
|
||||
if ($project && $project->isOwner($userid)) {
|
||||
return true;
|
||||
}
|
||||
// 关联任务(任务群)负责人,及任务所属项目负责人 / 管理员
|
||||
$task = ProjectTask::whereDialogId($this->id)->first();
|
||||
if ($task) {
|
||||
if (ProjectTaskUser::whereTaskId($task->id)->whereUserid($userid)->whereOwner(1)->exists()) {
|
||||
return true;
|
||||
}
|
||||
$taskProject = Project::find($task->project_id);
|
||||
if ($taskProject && $taskProject->isOwner($userid)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 群管理员 userid 列表
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getDeputyIdsAttribute(): array
|
||||
{
|
||||
if (!$this->id) {
|
||||
return [];
|
||||
}
|
||||
return WebSocketDialogUser::where('dialog_id', $this->id)
|
||||
->where('role', 2)
|
||||
->pluck('userid')
|
||||
->map(fn($v) => (int)$v)
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查禁言
|
||||
* @param $userid
|
||||
@@ -820,6 +952,13 @@ class WebSocketDialog extends AbstractModel
|
||||
if ($projectId > 0 && ProjectUser::whereProjectId($projectId)->whereUserid($userid)->exists()) {
|
||||
return $dialog;
|
||||
}
|
||||
// 部门负责人只读视角:项目/任务群按项目级共享放行(任务数据另按可见性校验,与普通成员一致)
|
||||
if ($projectId > 0 && $checkOwner === false) {
|
||||
$departmentView = UserDepartment::ownerViewContext(User::auth(), true);
|
||||
if (UserDepartment::isDepartmentReadonlyProject($departmentView, $projectId)) {
|
||||
return $dialog;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'okr':
|
||||
@@ -857,6 +996,7 @@ class WebSocketDialog extends AbstractModel
|
||||
WebSocketDialogUser::createInstance([
|
||||
'dialog_id' => $dialog->id,
|
||||
'userid' => $value,
|
||||
'role' => ($owner_id > 0 && (int)$value === (int)$owner_id) ? 1 : 0,
|
||||
'bot' => User::isBot($value) ? 1 : 0,
|
||||
'important' => !in_array($group_type, ['user', 'all']),
|
||||
'last_at' => in_array($group_type, ['user', 'department', 'all']) ? Carbon::now() : null,
|
||||
|
||||
@@ -414,7 +414,7 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
* @param array $userids 设置给指定会员
|
||||
* @return mixed
|
||||
*/
|
||||
public function toggleTodoMsg($sender, $userids = [])
|
||||
public function toggleTodoMsg($sender, $userids = [], $remindAt = false)
|
||||
{
|
||||
if (in_array($this->type, ['tag', 'todo', 'notice'])) {
|
||||
return Base::retError('此消息不支持设待办');
|
||||
@@ -423,6 +423,14 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
$current = WebSocketDialogMsgTodo::whereMsgId($this->id)->pluck('userid')->toArray();
|
||||
$cancel = array_diff($current, $userids);
|
||||
$setup = array_diff($userids, $current);
|
||||
// 待办操作权限管控(系统开关:禁止其他人员设置/取消待办)
|
||||
if (Base::settingFind('system', 'todo_set_permission') === 'close') {
|
||||
$affected = array_unique(array_merge($cancel, $setup)); // 本次真正影响到的用户
|
||||
$others = array_diff($affected, [$sender]); // 排除"自己"
|
||||
if ($others && !$dialog->checkTodoOwnerPermission($sender)) {
|
||||
return Base::retError('仅群主、项目/任务负责人或系统管理员可设置或取消他人待办');
|
||||
}
|
||||
}
|
||||
//
|
||||
$this->todo = $setup || count($current) > count($cancel) ? $sender : 0;
|
||||
$this->save();
|
||||
@@ -477,12 +485,39 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
];
|
||||
$dialog->pushMsg('update', $upData);
|
||||
//
|
||||
// 提醒时间:仅当调用方显式传入时处理(false=不传则不动既有提醒)
|
||||
if ($remindAt !== false) {
|
||||
$this->setTodoRemind($userids, $remindAt ?: null);
|
||||
}
|
||||
//
|
||||
return Base::retSuccess($this->todo ? '设置成功' : '取消成功', [
|
||||
'add' => $addData,
|
||||
'update' => $upData,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置/取消本消息指定成员待办的提醒时间(纯数据,无推送)。
|
||||
* 改动会把 reminded_at 重置为 null,使其可再次到点提醒。
|
||||
*
|
||||
* @param array $userids 目标成员
|
||||
* @param string|null $remindAt 提醒时间字符串;null/空 表示取消提醒
|
||||
* @return int 受影响行数
|
||||
*/
|
||||
public function setTodoRemind(array $userids, $remindAt = null)
|
||||
{
|
||||
$userids = array_values(array_filter(array_map('intval', $userids)));
|
||||
if (empty($userids)) {
|
||||
return 0;
|
||||
}
|
||||
return WebSocketDialogMsgTodo::whereMsgId($this->id)
|
||||
->whereIn('userid', $userids)
|
||||
->update([
|
||||
'remind_at' => $remindAt ?: null,
|
||||
'reminded_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转发消息
|
||||
* @param array|int $dialogids
|
||||
@@ -533,9 +568,17 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
return $dialogs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 不支持转发的消息类型
|
||||
*/
|
||||
public static $unforwardableTypes = ['tag', 'top', 'todo', 'notice', 'word-chain', 'vote', 'template'];
|
||||
|
||||
public function forwardMsg($dialogids, $userids, $user, $showSource = 1, $leaveMessage = '')
|
||||
{
|
||||
return AbstractModel::transaction(function () use ($dialogids, $user, $userids, $showSource, $leaveMessage) {
|
||||
if (in_array($this->type, self::$unforwardableTypes)) {
|
||||
throw new ApiException('此类型消息不支持转发');
|
||||
}
|
||||
$msgData = Base::json2array($this->getRawOriginal('msg'));
|
||||
$forwardData = is_array($msgData['forward_data']) ? $msgData['forward_data'] : [];
|
||||
$forwardId = $forwardData['id'] ?: $this->id;
|
||||
@@ -601,31 +644,35 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
throw new ApiException('只能合并转发同一对话的消息');
|
||||
}
|
||||
WebSocketDialog::checkDialog($dialogId);
|
||||
// 收集发送者生成标题
|
||||
// 过滤不支持转发的消息类型
|
||||
$msgs = $msgs->filter(function ($msg) {
|
||||
return !in_array($msg->type, self::$unforwardableTypes);
|
||||
});
|
||||
if ($msgs->isEmpty()) {
|
||||
throw new ApiException('所选消息均不支持转发');
|
||||
}
|
||||
// 收集发送者信息
|
||||
$senderIds = $msgs->pluck('userid')->unique()->values()->toArray();
|
||||
$senderNames = User::whereIn('userid', array_slice($senderIds, 0, 2))
|
||||
->pluck('nickname')
|
||||
->toArray();
|
||||
$title = implode(Doo::translate('和'), $senderNames);
|
||||
if (count($senderIds) > 2) {
|
||||
$title .= Doo::translate('等人');
|
||||
}
|
||||
$title .= Doo::translate('的聊天记录');
|
||||
// 组装消息列表
|
||||
$list = [];
|
||||
foreach ($msgs as $msg) {
|
||||
$list[] = [
|
||||
// 组装预览列表(前4条,精简字段)
|
||||
$msgIds = $msgs->pluck('id')->toArray();
|
||||
$preview = [];
|
||||
foreach ($msgs->take(4) as $msg) {
|
||||
$preview[] = [
|
||||
'userid' => $msg->userid,
|
||||
'type' => $msg->type,
|
||||
'msg' => Base::json2array($msg->getRawOriginal('msg')),
|
||||
'created_at' => $msg->created_at->toDateTimeString(),
|
||||
'msg' => self::buildPreviewMsg($msg->type, Base::json2array($msg->getRawOriginal('msg'))),
|
||||
];
|
||||
}
|
||||
// 构建合并转发消息体
|
||||
$msgData = [
|
||||
'title' => $title,
|
||||
'list' => $list,
|
||||
'count' => count($list),
|
||||
'sender_names' => $senderNames,
|
||||
'sender_total' => count($senderIds),
|
||||
'msg_ids' => $msgIds,
|
||||
'preview' => $preview,
|
||||
'count' => count($msgIds),
|
||||
'forward_data' => [
|
||||
'show' => $showSource,
|
||||
'leave' => $leaveMessage ? 1 : 0,
|
||||
@@ -652,6 +699,26 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建预览消息(精简字段)
|
||||
* @param string $type
|
||||
* @param array $msg
|
||||
* @return array
|
||||
*/
|
||||
private static function buildPreviewMsg($type, $msg)
|
||||
{
|
||||
switch ($type) {
|
||||
case 'text':
|
||||
return ['text' => $msg['text'] ?? ''];
|
||||
case 'file':
|
||||
return ['name' => $msg['name'] ?? '', 'ext' => $msg['ext'] ?? ''];
|
||||
case 'location':
|
||||
return ['title' => $msg['title'] ?? ''];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除消息
|
||||
* @param array|int $ids
|
||||
@@ -784,8 +851,7 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
return self::previewTemplateMsg($data['msg']);
|
||||
|
||||
case 'merge-forward':
|
||||
$action = Doo::translate("聊天记录");
|
||||
return "[{$action}] " . Base::cutStr($data['msg']['title'] ?? '', 50);
|
||||
return "[" . Doo::translate("聊天记录") . "]";
|
||||
|
||||
case 'preview':
|
||||
return $data['msg']['preview'];
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* App\Models\WebSocketDialogMsgTodo
|
||||
*
|
||||
@@ -50,4 +52,21 @@ class WebSocketDialogMsgTodo extends AbstractModel
|
||||
}
|
||||
return $this->appendattrs['msgData'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 取到点待提醒的待办行:有提醒时间、未提醒、未完成、提醒时间已到。
|
||||
* 纯查询,无副作用,供 TodoRemindTask 使用。
|
||||
* @return \Illuminate\Database\Eloquent\Collection
|
||||
*/
|
||||
public static function dueReminders()
|
||||
{
|
||||
return self::whereNotNull('remind_at')
|
||||
->whereNull('reminded_at')
|
||||
->whereNull('done_at')
|
||||
->where('remind_at', '<=', Carbon::now())
|
||||
->orderBy('msg_id')
|
||||
->orderBy('id')
|
||||
->limit(500)
|
||||
->get();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,12 +204,6 @@ class AI
|
||||
}
|
||||
|
||||
$apiKey = Base::val($setting, $modelType . '_key');
|
||||
if ($modelType === 'wenxin') {
|
||||
$wenxinSecret = Base::val($setting, 'wenxin_secret');
|
||||
if ($wenxinSecret) {
|
||||
$apiKey = trim(($apiKey ?: '') . ':' . $wenxinSecret);
|
||||
}
|
||||
}
|
||||
if ($modelType === 'ollama' && empty($apiKey)) {
|
||||
$apiKey = Base::strRandom(6);
|
||||
}
|
||||
@@ -239,6 +233,10 @@ class AI
|
||||
$authParams['agency'] = $agency;
|
||||
}
|
||||
|
||||
// 从模型名末尾剥离思考标记,支持以下写法:
|
||||
// 模型名 think / 模型名-thinking / 模型名_reasoning (空格、- 、_ 作分隔)
|
||||
// 模型名(think) / 模型名 ( reasoning ) (括号包裹)
|
||||
// 关键词三选一:think | thinking | reasoning
|
||||
$thinkPatterns = [
|
||||
"/^(.+?)(\s+|\s*[_-]\s*)(think|thinking|reasoning)\s*$/",
|
||||
"/^(.+?)\s*\(\s*(think|thinking|reasoning)\s*\)\s*$/"
|
||||
@@ -249,6 +247,7 @@ class AI
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 命中后把关键词剥掉,只保留前面的真实模型名
|
||||
if ($thinkMatch && !empty($thinkMatch[1])) {
|
||||
$authParams['model_name'] = $thinkMatch[1];
|
||||
}
|
||||
@@ -768,14 +767,6 @@ class AI
|
||||
}
|
||||
$model = trim((string)($setting[$vendor . '_model'] ?? ''));
|
||||
break;
|
||||
case 'wenxin':
|
||||
$secret = trim((string)($setting['wenxin_secret'] ?? ''));
|
||||
if ($key === '' || $secret === '' || $baseUrl === '') {
|
||||
return null;
|
||||
}
|
||||
$key = $key . ':' . $secret;
|
||||
$model = trim((string)($setting[$vendor . '_model'] ?? ''));
|
||||
break;
|
||||
default:
|
||||
if ($key === '' || $baseUrl === '') {
|
||||
return null;
|
||||
|
||||
@@ -14,7 +14,7 @@ use Overtrue\Pinyin\Pinyin;
|
||||
use Redirect;
|
||||
use Request;
|
||||
use Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\File\Exception\FileException;
|
||||
use Symfony\Component\HttpFoundation\File\File;
|
||||
use Validator;
|
||||
@@ -2868,9 +2868,15 @@ class Base
|
||||
|
||||
/**
|
||||
* DownloadFileResponse 下载文件
|
||||
*
|
||||
* 返回 Symfony BinaryFileResponse,在 LaravelS/Swoole 环境下由 StaticResponse 走原生
|
||||
* sendfile() 发送——OS 级零拷贝、不占用 PHP 内存,可支持任意大小文件(如几百 MB 的大文件)。
|
||||
* 切勿改回 StreamedResponse:它会被 LaravelS 用 ob_start()/ob_get_clean() 把整个响应体
|
||||
* 缓冲进 PHP 内存,大文件会撞 memory_limit 导致下载失败。
|
||||
*
|
||||
* @param File|\SplFileInfo|string $file 文件对象或路径
|
||||
* @param string|null $name 下载文件名
|
||||
* @return StreamedResponse
|
||||
* @return BinaryFileResponse
|
||||
*/
|
||||
public static function DownloadFileResponse($file, $name = null)
|
||||
{
|
||||
@@ -2889,12 +2895,6 @@ class Base
|
||||
throw new FileException('File must be readable and exist.');
|
||||
}
|
||||
|
||||
// 获取文件信息
|
||||
$size = $file->getSize();
|
||||
if ($size === false || $size < 0) {
|
||||
throw new FileException('Unable to determine file size.');
|
||||
}
|
||||
|
||||
// 处理文件名
|
||||
if (empty($name)) {
|
||||
$name = basename($file->getPathname());
|
||||
@@ -2912,83 +2912,27 @@ class Base
|
||||
$mimeType = 'application/octet-stream';
|
||||
}
|
||||
|
||||
// 处理 Range 请求
|
||||
$start = 0;
|
||||
$end = $size - 1;
|
||||
$length = $size;
|
||||
$isRangeRequest = false;
|
||||
// BinaryFileResponse:autoEtag=false 避免对大文件做 md5/sha1 全文件哈希,autoLastModified=true 取 mtime(开销极小)
|
||||
$response = new BinaryFileResponse($file, 200, [], true, null, false, true);
|
||||
$response->headers->set('Content-Type', $mimeType);
|
||||
$response->headers->set('Cache-Control', 'private, no-transform, no-store, must-revalidate, max-age=0');
|
||||
// filename 兜底为纯 ASCII,filename* 用 UTF-8 编码,兼容含中文/特殊字符的文件名
|
||||
$asciiName = preg_replace('/[^\x20-\x7e]/', '_', $name);
|
||||
$response->headers->set('Content-Disposition', sprintf(
|
||||
'attachment; filename="%s"; filename*=UTF-8\'\'%s',
|
||||
$asciiName,
|
||||
rawurlencode($name)
|
||||
));
|
||||
|
||||
if (isset($_SERVER['HTTP_RANGE'])) {
|
||||
$range = str_replace('bytes=', '', $_SERVER['HTTP_RANGE']);
|
||||
if (preg_match('/^(\d+)-(\d*)$/', $range, $matches)) {
|
||||
$start = intval($matches[1]);
|
||||
$end = !empty($matches[2]) ? intval($matches[2]) : $size - 1;
|
||||
|
||||
// 验证范围的有效性
|
||||
if ($start >= 0 && $end < $size && $start <= $end) {
|
||||
$length = $end - $start + 1;
|
||||
$isRangeRequest = true;
|
||||
} else {
|
||||
$start = 0;
|
||||
$end = $size - 1;
|
||||
}
|
||||
}
|
||||
// LaravelS/Swoole 下 StaticResponse 用 sendfile() 整文件发送,不支持分段;
|
||||
// 若放任 Symfony 处理 Range 会返回 206 头却仍发送完整文件,导致内容错位/损坏。
|
||||
// 故在 Swoole 环境下移除 Range 请求头,始终以 200 返回完整文件。
|
||||
if (app()->bound('swoole')) {
|
||||
Request::instance()->headers->remove('Range');
|
||||
$response->headers->set('Accept-Ranges', 'none');
|
||||
}
|
||||
|
||||
// 设置基本响应头
|
||||
$headers = [
|
||||
'Content-Type' => $mimeType,
|
||||
'Content-Disposition' => sprintf(
|
||||
'attachment; filename="%s"; filename*=UTF-8\'\'%s',
|
||||
$name,
|
||||
rawurlencode($name)
|
||||
),
|
||||
'Accept-Ranges' => 'bytes',
|
||||
'Cache-Control' => 'private, no-transform, no-store, must-revalidate, max-age=0',
|
||||
'Content-Length' => $length,
|
||||
'Last-Modified' => gmdate('D, d M Y H:i:s', $file->getMTime()) . ' GMT',
|
||||
'ETag' => sprintf('"%s"', md5_file($file->getPathname()))
|
||||
];
|
||||
|
||||
if ($isRangeRequest) {
|
||||
$headers['Content-Range'] = "bytes {$start}-{$end}/{$size}";
|
||||
$statusCode = 206;
|
||||
} else {
|
||||
$statusCode = 200;
|
||||
}
|
||||
|
||||
// 创建流式响应
|
||||
return new StreamedResponse(
|
||||
function () use ($file, $start, $length) {
|
||||
$handle = fopen($file->getPathname(), 'rb');
|
||||
if ($handle === false) {
|
||||
throw new FileException('Cannot open file for reading');
|
||||
}
|
||||
|
||||
if (fseek($handle, $start) === -1) {
|
||||
fclose($handle);
|
||||
throw new FileException('Cannot seek to position ' . $start);
|
||||
}
|
||||
|
||||
$remaining = $length;
|
||||
$bufferSize = 8192; // 8KB chunks
|
||||
|
||||
while ($remaining > 0 && !feof($handle)) {
|
||||
$readSize = min($bufferSize, $remaining);
|
||||
$buffer = fread($handle, $readSize);
|
||||
if ($buffer === false) {
|
||||
break;
|
||||
}
|
||||
echo $buffer;
|
||||
flush();
|
||||
$remaining -= strlen($buffer);
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
},
|
||||
$statusCode,
|
||||
$headers
|
||||
);
|
||||
return $response;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('File download failed', [
|
||||
'error' => $e->getMessage(),
|
||||
|
||||
13
app/Module/UserImport.php
Normal file
13
app/Module/UserImport.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module;
|
||||
|
||||
use Maatwebsite\Excel\Concerns\ToArray;
|
||||
|
||||
class UserImport implements ToArray
|
||||
{
|
||||
public function array(array $array)
|
||||
{
|
||||
return $array;
|
||||
}
|
||||
}
|
||||
21
app/Module/UserImportTemplate.php
Normal file
21
app/Module/UserImportTemplate.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module;
|
||||
|
||||
use Maatwebsite\Excel\Concerns\FromArray;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
|
||||
class UserImportTemplate implements FromArray, WithHeadings
|
||||
{
|
||||
public function array(): array
|
||||
{
|
||||
return [
|
||||
['employee@example.com', '张三', 'Abc123456', '工程师'],
|
||||
];
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return ['邮箱(必填)', '昵称(必填,2-20字)', '初始密码(必填,6-32位)', '职位(选填,2-20字)'];
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,9 @@ class ProjectTaskObserver extends AbstractObserver
|
||||
return ProjectUser::whereProjectId($projectTask->project_id)->pluck('userid')->toArray();
|
||||
}
|
||||
if (in_array('projectOwnerUser', $dataType)) {
|
||||
return ProjectUser::whereProjectId($projectTask->project_id)->where('owner', 1)->pluck('userid')->toArray();
|
||||
return ProjectUser::whereProjectId($projectTask->project_id)
|
||||
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
|
||||
->pluck('userid')->toArray();
|
||||
}
|
||||
$array = [];
|
||||
if (in_array('task', $dataType)) {
|
||||
|
||||
@@ -490,10 +490,6 @@ class BotReceiveMsgTask extends AbstractTask
|
||||
if ($dialog->session_id) {
|
||||
$extras['context_key'] = 'session_' . $dialog->session_id;
|
||||
}
|
||||
// 设置文心一言的API密钥
|
||||
if ($type === 'wenxin') {
|
||||
$extras['api_key'] .= ':' . $setting['wenxin_secret'];
|
||||
}
|
||||
// 群聊清理上下文(群聊不使用上下文)
|
||||
if ($dialog->type === 'group') {
|
||||
$extras['before_clear'] = 1;
|
||||
|
||||
86
app/Tasks/TodoRemindTask.php
Normal file
86
app/Tasks/TodoRemindTask.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tasks;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\WebSocketDialog;
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
use App\Models\WebSocketDialogMsgTodo;
|
||||
use App\Module\Doo;
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* 待办提醒:到点由 todo-alert 机器人在原会话发一条「引用原消息 + @被指派成员」的普通文本
|
||||
* (同一消息同批到点的成员合并一条)。
|
||||
*/
|
||||
class TodoRemindTask extends AbstractTask
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造提醒文本:每个被提醒成员一个 @ span + 提示语。
|
||||
* 直接拼 <span class="mention user" data-id> 是因为 sendMsg 不会调用 formatMsg,
|
||||
* 文本会原样入库,msgJoinGroup 据此 span 正则提取 @。
|
||||
*/
|
||||
public static function buildRemindText(array $mentionUserids): string
|
||||
{
|
||||
$nicknames = User::whereIn('userid', $mentionUserids)->pluck('nickname', 'userid');
|
||||
$mentionText = '';
|
||||
foreach ($mentionUserids as $uid) {
|
||||
$name = $nicknames[$uid] ?? $uid;
|
||||
$mentionText .= "<span class=\"mention user\" data-id=\"{$uid}\">@{$name}</span> ";
|
||||
}
|
||||
return $mentionText . Doo::translate('你有一条待办到提醒时间啦');
|
||||
}
|
||||
|
||||
public function start()
|
||||
{
|
||||
$rows = WebSocketDialogMsgTodo::dueReminders();
|
||||
if ($rows->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
$botUser = User::botGetOrCreate('todo-alert');
|
||||
if (empty($botUser)) {
|
||||
return;
|
||||
}
|
||||
foreach ($rows->groupBy('msg_id') as $msgId => $group) {
|
||||
$rowIds = $group->pluck('id')->toArray();
|
||||
$userids = $group->pluck('userid')->map('intval')->values()->toArray();
|
||||
//
|
||||
$msg = WebSocketDialogMsg::find($msgId);
|
||||
$dialog = $msg ? WebSocketDialog::find($msg->dialog_id) : null;
|
||||
if (empty($msg) || empty($dialog)) {
|
||||
// 原消息/会话已不存在:标记已提醒,避免空转重复扫描
|
||||
WebSocketDialogMsgTodo::whereIn('id', $rowIds)->update(['reminded_at' => Carbon::now()]);
|
||||
continue;
|
||||
}
|
||||
//
|
||||
$memberIds = $dialog->dialogUser->pluck('userid')->map('intval')->values()->toArray();
|
||||
$mentionUserids = array_values(array_intersect($userids, $memberIds));
|
||||
if (empty($mentionUserids)) {
|
||||
// 被指派人都已退群:没人可 @,标记已提醒避免空转重复扫描
|
||||
WebSocketDialogMsgTodo::whereIn('id', $rowIds)->update(['reminded_at' => Carbon::now()]);
|
||||
continue;
|
||||
}
|
||||
$res = WebSocketDialogMsg::sendMsg(
|
||||
"reply-{$msg->id}", // 引用原消息 → reply_data 自动填充
|
||||
$dialog->id,
|
||||
'text', // 普通文本
|
||||
['text' => self::buildRemindText($mentionUserids)],
|
||||
$botUser->userid,
|
||||
false, false, false // push_self / push_retry / push_silence
|
||||
);
|
||||
//
|
||||
if (\App\Module\Base::isSuccess($res)) {
|
||||
WebSocketDialogMsgTodo::whereIn('id', $rowIds)->update(['reminded_at' => Carbon::now()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function end()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -189,7 +189,12 @@ class WebSocketDialogMsgTask extends AbstractTask
|
||||
if ($umengUserid) {
|
||||
$setting = Base::setting('appPushSetting');
|
||||
if ($setting['push'] === 'open') {
|
||||
$umengTitle = User::userid2nickname($msg->userid);
|
||||
if ($msg->userid == -1) {
|
||||
// AI 助手虚拟用户没有会员记录,取自定义昵称或默认名称
|
||||
$umengTitle = ($msg->msg['nickname'] ?? '') ?: Doo::translate('AI 助手');
|
||||
} else {
|
||||
$umengTitle = User::userid2nickname($msg->userid);
|
||||
}
|
||||
$umengBody = WebSocketDialogMsg::previewMsg($msg);
|
||||
if ($dialog->type == 'group') {
|
||||
$umengBody = $umengTitle . ': ' . $umengBody;
|
||||
|
||||
4
bin/version.js
vendored
4
bin/version.js
vendored
@@ -11,7 +11,7 @@ const packageFile = path.resolve(process.cwd(), "package.json");
|
||||
const changeFile = path.resolve(process.cwd(), "CHANGELOG.md");
|
||||
|
||||
const verOffset = 6394; // 版本号偏移量
|
||||
const codeOffset = 34; // 代码版本号偏移量
|
||||
const codeOffset = 35; // 代码版本号偏移量
|
||||
|
||||
const envFilePath = path.resolve(process.cwd(), ".env");
|
||||
const defaultAiSystemPrompt = "你是一位软件发布日志编辑专家。请产出 Markdown 更新日志,面向普通用户,以通俗友好的简体中文描述更新带来的直接好处,避免技术术语。所有章节标题必须以 `### ` 开头并保持英文 Title Case(例如 `### Features`、`### Bug Fixes`、`### Performance`、`### Documentation` 等)。每个章节内的条目按用户价值和影响范围排序,将更重要、影响更广的更新放在前面。";
|
||||
@@ -229,7 +229,7 @@ async function enhanceWithAI(version, changelogSection) {
|
||||
return changelogSection;
|
||||
}
|
||||
const proxyUrl = (process.env.OPENAI_PROXY_URL || "").trim();
|
||||
const explicitApiUrl = process.env.CHANGELOG_AI_URL || process.env.OPENAI_API_URL;
|
||||
const explicitApiUrl = process.env.CHANGELOG_AI_URL || process.env.OPENAI_API_URL || process.env.OPENAI_BASE_URL;
|
||||
const apiUrl = resolveApiEndpoint(explicitApiUrl);
|
||||
const dispatcher = createProxyDispatcher(proxyUrl);
|
||||
const model = process.env.CHANGELOG_AI_MODEL || process.env.OPENAI_API_MODEL || "gpt-4o-mini";
|
||||
|
||||
43
cmd
43
cmd
@@ -391,8 +391,13 @@ env_set() {
|
||||
local val=$2
|
||||
local exist=`cat ${WORK_DIR}/.env | grep "^$key="`
|
||||
if [ -z "$exist" ]; then
|
||||
echo "" >> $WORK_DIR/.env
|
||||
echo "$key=$val" >> $WORK_DIR/.env
|
||||
else
|
||||
# 值未变化则直接返回,避免无谓重写 .env(重写会改 mtime,触发 vite 全量重启/前端刷新)
|
||||
if [[ "$(env_get "$key")" == "$val" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ `uname` == 'Linux' ]]; then
|
||||
sed -i "/^${key}=/c\\${key}=${val}" ${WORK_DIR}/.env
|
||||
else
|
||||
@@ -501,10 +506,36 @@ DooTask 管理脚本
|
||||
EOF
|
||||
}
|
||||
|
||||
# 检测APP_ID是否与其他实例冲突
|
||||
check_instance() {
|
||||
local app_id=$(env_get APP_ID)
|
||||
local container_name="dootask-php-${app_id}"
|
||||
local mount_path=$(docker inspect "$container_name" --format '{{range .Mounts}}{{if eq .Destination "/var/www"}}{{.Source}}{{end}}{{end}}' 2>/dev/null)
|
||||
if [[ -n "$mount_path" ]] && [[ "$mount_path" != "$WORK_DIR" ]]; then
|
||||
error "APP_ID(${app_id})已被其他实例使用:${mount_path}"
|
||||
error "请先清空 .env 中的 APP_ID 和 APP_IPPR 再重新安装"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 检测端口是否被占用
|
||||
# 参数1: 端口号, 参数2: 当前端口号(可选,相同则跳过检测)
|
||||
check_port() {
|
||||
local port=$1
|
||||
local current_port=$2
|
||||
if [[ "$port" -gt 0 ]] && [[ "$port" != "$current_port" ]]; then
|
||||
if ! docker run --rm -p "${port}:80" --entrypoint true nginx:alpine 2>/dev/null; then
|
||||
error "端口 ${port} 已被占用,请指定其他端口"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# 安装函数
|
||||
handle_install() {
|
||||
check_sudo
|
||||
|
||||
check_instance
|
||||
|
||||
local relock=$(arg_get relock)
|
||||
local port=$(arg_get port)
|
||||
|
||||
@@ -561,8 +592,17 @@ handle_install() {
|
||||
done
|
||||
|
||||
# 设置端口
|
||||
local old_port=$(env_get APP_PORT)
|
||||
[[ "$port" -gt 0 ]] && env_set APP_PORT "$port"
|
||||
|
||||
# 检测端口占用(首次安装或端口变更时)
|
||||
local new_port=$(env_get APP_PORT)
|
||||
if [ -z "$(docker_name nginx)" ] || [[ "$new_port" != "$old_port" ]]; then
|
||||
check_port "$new_port"
|
||||
local ssl_port=$(env_get APP_SSL_PORT)
|
||||
check_port "$ssl_port"
|
||||
fi
|
||||
|
||||
# 启动PHP容器
|
||||
$COMPOSE up php -d
|
||||
|
||||
@@ -763,6 +803,7 @@ case "$1" in
|
||||
;;
|
||||
"port")
|
||||
shift 1
|
||||
check_port "$1" "$(env_get APP_PORT)"
|
||||
env_set APP_PORT "$1"
|
||||
$COMPOSE up -d
|
||||
success "修改成功"
|
||||
|
||||
@@ -13,6 +13,9 @@ class CreateManticoreSyncFailuresTable extends Migration
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
if (Schema::hasTable('manticore_sync_failures')) {
|
||||
return;
|
||||
}
|
||||
Schema::create('manticore_sync_failures', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('data_type', 20)->comment('数据类型: msg/file/task/project/user');
|
||||
|
||||
@@ -8,6 +8,9 @@ class CreateProjectTaskAiEventsTable extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
if (Schema::hasTable('project_task_ai_events')) {
|
||||
return;
|
||||
}
|
||||
Schema::create('project_task_ai_events', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->bigInteger('task_id')->comment('任务ID');
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateAiAssistantSessionsTable extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
if (Schema::hasTable('ai_assistant_sessions')) {
|
||||
return;
|
||||
}
|
||||
Schema::create('ai_assistant_sessions', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->bigInteger('userid')->default(0)->comment('用户ID');
|
||||
$table->string('session_key', 100)->default('')->comment('场景分类key');
|
||||
$table->string('session_id', 100)->default('')->comment('前端生成的会话ID');
|
||||
$table->string('scene_key', 200)->default('')->comment('具体场景标识');
|
||||
$table->string('title', 255)->default('')->comment('会话标题');
|
||||
$table->longText('data')->nullable()->comment('responses JSON');
|
||||
$table->longText('images')->nullable()->comment('图片映射 {imageId: relativePath}');
|
||||
$table->timestamps();
|
||||
$table->index('userid', 'idx_userid');
|
||||
$table->unique(['userid', 'session_key', 'session_id'], 'uk_user_session');
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::dropIfExists('ai_assistant_sessions');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddRoleToWebSocketDialogUsers extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('web_socket_dialog_users', function (Blueprint $table) {
|
||||
if (!Schema::hasColumn('web_socket_dialog_users', 'role')) {
|
||||
$table->tinyInteger('role')->default(0)->after('userid')
|
||||
->comment('0=普通成员 1=群主 2=群管理员');
|
||||
$table->index(['dialog_id', 'role'], 'idx_dialog_role');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('web_socket_dialog_users', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('web_socket_dialog_users', 'role')) {
|
||||
$table->dropIndex('idx_dialog_role');
|
||||
$table->dropColumn('role');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BackfillDialogOwnerRole extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
$prefix = DB::getTablePrefix();
|
||||
// 把每个群里 userid = web_socket_dialogs.owner_id 的成员记录设为 role=1(主群主)
|
||||
// 幂等:仅当 role=0 时才更新
|
||||
DB::statement("
|
||||
UPDATE {$prefix}web_socket_dialog_users du
|
||||
INNER JOIN {$prefix}web_socket_dialogs d ON d.id = du.dialog_id
|
||||
SET du.role = 1
|
||||
WHERE d.owner_id > 0
|
||||
AND du.userid = d.owner_id
|
||||
AND du.role = 0
|
||||
");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
$prefix = DB::getTablePrefix();
|
||||
// 回滚:把 role=1 的记录全部回到 role=0
|
||||
DB::statement("UPDATE {$prefix}web_socket_dialog_users SET role = 0 WHERE role = 1");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateUserDepartmentOwnersTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
if (!Schema::hasTable('user_department_owners')) {
|
||||
Schema::create('user_department_owners', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->unsignedBigInteger('department_id')->comment('部门ID');
|
||||
$table->unsignedBigInteger('userid')->comment('部门管理员 userid');
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
$table->unique(['department_id', 'userid'], 'uniq_dept_user');
|
||||
$table->index('userid', 'idx_userid');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
if (Schema::hasTable('user_department_owners')) {
|
||||
Schema::dropIfExists('user_department_owners');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BackfillProjectDialogPrimaryOwner extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* 修复历史项目群聊未登记群主的问题:
|
||||
* - 早期 Project::addProject 调 createGroup 时未传 owner_id 第 4 参
|
||||
* (在 commit 3a9001e09 才补上),导致老项目群 dialogs.owner_id = 0
|
||||
* - 这些群也因此被 2026_04_30_000002_backfill_dialog_owner_role 跳过
|
||||
* (那条迁移要求 owner_id > 0),主负责人那条 dialog_users.role 仍为 0
|
||||
*
|
||||
* 本迁移仅处理 group_type = 'project' 且未软删的项目:
|
||||
* (a) dialogs.owner_id = 0 → 按 project_users.owner=1 回填
|
||||
* (b) 同一批群里主负责人那条 dialog_users.role = 0 → 设为 1
|
||||
*
|
||||
* 全部带幂等条件,可重跑。
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
$prefix = DB::getTablePrefix();
|
||||
|
||||
// (a) 回填 dialogs.owner_id
|
||||
DB::statement("
|
||||
UPDATE {$prefix}web_socket_dialogs d
|
||||
INNER JOIN {$prefix}projects p ON p.dialog_id = d.id
|
||||
INNER JOIN {$prefix}project_users pu ON pu.project_id = p.id AND pu.owner = 1
|
||||
SET d.owner_id = pu.userid
|
||||
WHERE d.owner_id = 0
|
||||
AND d.group_type = 'project'
|
||||
AND p.deleted_at IS NULL
|
||||
");
|
||||
|
||||
// (b) 把这些项目群里主负责人那条 dialog_users.role 设为 1
|
||||
// 不依赖 (a) 的结果,直接按 project_users.owner=1 反查,幂等条件 du.role=0
|
||||
DB::statement("
|
||||
UPDATE {$prefix}web_socket_dialog_users du
|
||||
INNER JOIN {$prefix}projects p ON p.dialog_id = du.dialog_id
|
||||
INNER JOIN {$prefix}project_users pu
|
||||
ON pu.project_id = p.id
|
||||
AND pu.userid = du.userid
|
||||
AND pu.owner = 1
|
||||
SET du.role = 1
|
||||
WHERE du.role = 0
|
||||
AND p.deleted_at IS NULL
|
||||
");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* 数据回填类迁移不提供精确回滚——回滚会丢失原本就正确的数据。
|
||||
* 如需重置,请手动操作。
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BackfillDialogRoleConsistency extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* 兜底修复 role/owner_id 一致性:
|
||||
* - 部门群 owner_id 与 user_departments.owner_userid 对齐
|
||||
* - owner_id > 0 的群确保 owner 成员存在且 role=1
|
||||
* - 同一群中非 owner 的 role=1 降为普通成员(不影响 role=2 管理员)
|
||||
* - 历史 owner_id=0 的普通用户群按最早的非机器人成员回填群主
|
||||
* - 清理部门负责人残留的 user_department_owners 记录
|
||||
*
|
||||
* 全部语句带幂等条件,可重复运行。
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
$prefix = DB::getTablePrefix();
|
||||
|
||||
// 1) 部门群 owner_id 以 user_departments.owner_userid 为准
|
||||
DB::statement("\n UPDATE {$prefix}web_socket_dialogs d\n INNER JOIN {$prefix}user_departments ud ON ud.dialog_id = d.id\n SET d.owner_id = ud.owner_userid\n WHERE d.type = 'group'\n AND d.group_type = 'department'\n AND d.deleted_at IS NULL\n AND ud.owner_userid > 0\n AND d.owner_id != ud.owner_userid\n ");
|
||||
|
||||
// 2) 历史普通用户群 owner_id=0:按最早加入的非机器人成员回填群主
|
||||
DB::statement("\n UPDATE {$prefix}web_socket_dialogs d\n INNER JOIN (\n SELECT du.dialog_id, MIN(du.id) AS min_id\n FROM {$prefix}web_socket_dialog_users du\n WHERE du.userid > 0 AND du.bot = 0\n GROUP BY du.dialog_id\n ) first_du ON first_du.dialog_id = d.id\n INNER JOIN {$prefix}web_socket_dialog_users owner_du ON owner_du.id = first_du.min_id\n SET d.owner_id = owner_du.userid\n WHERE d.type = 'group'\n AND d.group_type = 'user'\n AND d.deleted_at IS NULL\n AND d.owner_id = 0\n ");
|
||||
|
||||
// 3) owner_id > 0 但 owner 不在群成员表时,补一条成员记录(仅补真实存在的用户)
|
||||
DB::statement("\n INSERT INTO {$prefix}web_socket_dialog_users\n (dialog_id, userid, role, bot, important, last_at, created_at, updated_at)\n SELECT\n d.id,\n d.owner_id,\n 1,\n COALESCE(u.bot, 0),\n CASE WHEN d.group_type IN ('user', 'all') THEN 0 ELSE 1 END,\n CASE WHEN d.group_type IN ('user', 'department', 'all') THEN NOW(3) ELSE NULL END,\n NOW(3),\n NOW(3)\n FROM {$prefix}web_socket_dialogs d\n INNER JOIN {$prefix}users u ON u.userid = d.owner_id\n LEFT JOIN {$prefix}web_socket_dialog_users du\n ON du.dialog_id = d.id AND du.userid = d.owner_id\n WHERE d.type = 'group'\n AND d.deleted_at IS NULL\n AND d.owner_id > 0\n AND du.id IS NULL\n ");
|
||||
|
||||
// 4) owner 成员设为 role=1;业务群 owner 同时保持 important=1
|
||||
DB::statement("\n UPDATE {$prefix}web_socket_dialog_users du\n INNER JOIN {$prefix}web_socket_dialogs d ON d.id = du.dialog_id\n SET du.role = 1,\n du.important = CASE WHEN d.group_type IN ('user', 'all') THEN du.important ELSE 1 END\n WHERE d.type = 'group'\n AND d.deleted_at IS NULL\n AND d.owner_id > 0\n AND du.userid = d.owner_id\n AND (du.role != 1 OR (d.group_type NOT IN ('user', 'all') AND du.important != 1))\n ");
|
||||
|
||||
// 5) 同一群里非 owner 的 role=1 降为普通成员,避免多个主群主
|
||||
DB::statement("\n UPDATE {$prefix}web_socket_dialog_users du\n INNER JOIN {$prefix}web_socket_dialogs d ON d.id = du.dialog_id\n SET du.role = 0\n WHERE d.type = 'group'\n AND d.deleted_at IS NULL\n AND d.owner_id > 0\n AND du.role = 1\n AND du.userid != d.owner_id\n ");
|
||||
|
||||
// 6) 部门负责人不能同时残留在部门管理员表
|
||||
DB::statement("\n DELETE udo FROM {$prefix}user_department_owners udo\n INNER JOIN {$prefix}user_departments ud ON ud.id = udo.department_id\n WHERE ud.owner_userid = udo.userid\n ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* 数据修复类迁移不提供精确回滚,避免破坏已校准的数据。
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddUseCountToProjectTaskTemplates extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
Schema::table('project_task_templates', function (Blueprint $table) {
|
||||
$table->unsignedInteger('use_count')->default(0)->after('is_default')->comment('累计使用次数');
|
||||
$table->timestamp('last_used_at')->nullable()->after('use_count')->comment('最近一次使用时间');
|
||||
$table->index(['use_count', 'last_used_at'], 'idx_template_usage');
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::table('project_task_templates', function (Blueprint $table) {
|
||||
$table->dropIndex('idx_template_usage');
|
||||
$table->dropColumn(['use_count', 'last_used_at']);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddTaskTemplateShareToProjectsTable extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table) {
|
||||
if (!Schema::hasColumn('projects', 'task_template_share')) {
|
||||
$table->string('task_template_share', 20)->default('open')->after('ai_auto_analyze')->comment('共享模板开关');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('projects', 'task_template_share')) {
|
||||
$table->dropColumn('task_template_share');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddDepartmentOwnerViewToProjectsTable extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table) {
|
||||
if (!Schema::hasColumn('projects', 'department_owner_view')) {
|
||||
$table->string('department_owner_view', 20)->default('open')->after('task_template_share')->comment('部门负责人视角可见开关');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('projects', 'department_owner_view')) {
|
||||
$table->dropColumn('department_owner_view');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddRemindToWebSocketDialogMsgTodos extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
Schema::table('web_socket_dialog_msg_todos', function (Blueprint $table) {
|
||||
$table->timestamp('remind_at')->nullable()->comment('提醒时间')->after('done_at');
|
||||
$table->timestamp('reminded_at')->nullable()->comment('已提醒时间')->after('remind_at');
|
||||
$table->index(['remind_at', 'reminded_at', 'done_at'], 'idx_todo_remind');
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::table('web_socket_dialog_msg_todos', function (Blueprint $table) {
|
||||
$table->dropIndex('idx_todo_remind');
|
||||
$table->dropColumn(['remind_at', 'reminded_at']);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,7 @@ services:
|
||||
appstore:
|
||||
container_name: "dootask-appstore-${APP_ID}"
|
||||
privileged: true
|
||||
image: "dootask/appstore:0.3.9"
|
||||
image: "dootask/appstore:0.4.3"
|
||||
volumes:
|
||||
- shared_data:/usr/share/dootask
|
||||
- ${HOST_DOCKER_SOCK:-/var/run/docker.sock}:/var/run/docker.sock
|
||||
|
||||
395
electron/build.js
vendored
395
electron/build.js
vendored
@@ -6,13 +6,14 @@ const child_process = require('child_process');
|
||||
const ora = require('ora');
|
||||
const yauzl = require('yauzl');
|
||||
const axios = require('axios');
|
||||
const FormData =require('form-data');
|
||||
const tar = require('tar');
|
||||
const utils = require('./lib/utils');
|
||||
const r2 = require('./lib/r2');
|
||||
const { buildReleaseIndex } = require('./lib/release-index');
|
||||
const config = require('../package.json')
|
||||
const env = require('dotenv').config({ path: './.env' })
|
||||
const argv = process.argv;
|
||||
const {BUILD_FRONTEND, APPLEID, APPLEIDPASS, GITHUB_TOKEN, GITHUB_REPOSITORY, PUBLISH_KEY} = process.env;
|
||||
const {BUILD_FRONTEND, APPLEID, APPLEIDPASS, GITHUB_TOKEN, GITHUB_REPOSITORY} = process.env;
|
||||
|
||||
const electronDir = path.resolve(__dirname, "public");
|
||||
const nativeCachePath = path.resolve(__dirname, ".native");
|
||||
@@ -25,6 +26,9 @@ const architectures = ["arm64", "x64"];
|
||||
let buildChecked = false,
|
||||
updaterChecked = false;
|
||||
|
||||
const shellQuote = (value) => `'${String(value).replace(/'/g, `'\\''`)}'`;
|
||||
const elapsedSeconds = (startTime) => `${((Date.now() - startTime) / 1000).toFixed(1)}s`;
|
||||
|
||||
/**
|
||||
* 检测并下载更新器
|
||||
*/
|
||||
@@ -308,234 +312,23 @@ function changeLog() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 封装 axios 自动重试
|
||||
* @param data // {axios: object{}, onRetry: function, retryNumber: number}
|
||||
* @returns {Promise<unknown>}
|
||||
* 上传单个文件到 R2 的 draft/<version>/ 目录(带进度/spinner)
|
||||
*/
|
||||
function axiosAutoTry(data) {
|
||||
return new Promise((resolve, reject) => {
|
||||
axios(data.axios).then(result => {
|
||||
resolve(result)
|
||||
}).catch(error => {
|
||||
if (typeof data.retryNumber == 'number' && data.retryNumber > 0) {
|
||||
data.retryNumber--;
|
||||
if (typeof data.onRetry === "function") {
|
||||
data.onRetry()
|
||||
}
|
||||
if (error.code == 'ECONNABORTED' || error.code == 'ECONNRESET') {
|
||||
// 中止,超时
|
||||
return resolve(axiosAutoTry(data))
|
||||
} else {
|
||||
if (error.response && error.response.status == 407) {
|
||||
// 代理407
|
||||
return setTimeout(v => {
|
||||
resolve(axiosAutoTry(data))
|
||||
}, 500 + Math.random() * 500)
|
||||
} else if (error.response && error.response.status == 503) {
|
||||
// 服务器异常
|
||||
return setTimeout(v => {
|
||||
resolve(axiosAutoTry(data))
|
||||
}, 1000 + Math.random() * 500)
|
||||
} else if (error.response && error.response.status == 429) {
|
||||
// 并发超过限制
|
||||
return setTimeout(v => {
|
||||
resolve(axiosAutoTry(data))
|
||||
}, 1000 + Math.random() * 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传app应用
|
||||
* @param url
|
||||
*/
|
||||
function androidUpload(url) {
|
||||
if (!PUBLISH_KEY) {
|
||||
console.error("缺少 PUBLISH_KEY 环境变量");
|
||||
process.exit()
|
||||
async function uploadDraftFile(client, localFile, version) {
|
||||
const filename = path.basename(localFile);
|
||||
const key = `draft/${version}/${filename}`;
|
||||
const startTime = Date.now();
|
||||
const spinner = ora(`Upload [0%] ${filename}`).start();
|
||||
try {
|
||||
await r2.uploadFile(client, localFile, key, (loaded, total) => {
|
||||
const pct = Math.min(99, Math.round((loaded / total) * 100)) + '%';
|
||||
spinner.text = `Upload [${pct}] ${filename}`;
|
||||
});
|
||||
} catch (error) {
|
||||
spinner.fail(`Upload [fail] ${filename} (${elapsedSeconds(startTime)}): ${error.message || error}`);
|
||||
throw error;
|
||||
}
|
||||
const releaseDir = path.resolve(__dirname, "../resources/mobile/platforms/android/eeuiApp/app/build/outputs/apk/release");
|
||||
if (!fs.existsSync(releaseDir)) {
|
||||
console.error("发布文件未找到");
|
||||
process.exit()
|
||||
}
|
||||
fs.readdir(releaseDir, async (err, files) => {
|
||||
if (err) {
|
||||
console.warn(err)
|
||||
} else {
|
||||
const uploadOras = {}
|
||||
for (const filename of files) {
|
||||
const localFile = path.join(releaseDir, filename)
|
||||
if (/\.apk$/.test(filename) && fs.existsSync(localFile)) {
|
||||
const fileStat = fs.statSync(localFile)
|
||||
if (fileStat.isFile()) {
|
||||
uploadOras[filename] = ora(`Upload [0%] ${filename}`).start()
|
||||
const formData = new FormData()
|
||||
formData.append("file", fs.createReadStream(localFile));
|
||||
formData.append("action", "draft");
|
||||
await axiosAutoTry({
|
||||
axios: {
|
||||
method: 'post',
|
||||
url: url,
|
||||
data: formData,
|
||||
headers: {
|
||||
'Publish-Version': config.version,
|
||||
'Publish-Key': PUBLISH_KEY,
|
||||
'Content-Type': 'multipart/form-data;boundary=' + formData.getBoundary(),
|
||||
},
|
||||
onUploadProgress: progress => {
|
||||
const complete = Math.min(99, Math.round(progress.loaded / progress.total * 100 | 0)) + '%'
|
||||
uploadOras[filename].text = `Upload [${complete}] ${filename}`
|
||||
},
|
||||
},
|
||||
onRetry: _ => {
|
||||
uploadOras[filename].warn(`Upload [retry] ${filename}`)
|
||||
uploadOras[filename] = ora(`Upload [0%] ${filename}`).start()
|
||||
},
|
||||
retryNumber: 3
|
||||
}).then(({status, data}) => {
|
||||
if (status !== 200) {
|
||||
uploadOras[filename].fail(`Upload [fail:${status}] ${filename}`)
|
||||
return
|
||||
}
|
||||
if (!utils.isJson(data)) {
|
||||
uploadOras[filename].fail(`Upload [fail:not json] ${filename}`)
|
||||
return
|
||||
}
|
||||
if (data.ret !== 1) {
|
||||
uploadOras[filename].fail(`Upload [fail:ret ${data.ret}] ${filename}`)
|
||||
return
|
||||
}
|
||||
uploadOras[filename].succeed(`Upload [100%] ${filename}`)
|
||||
}).catch(_ => {
|
||||
uploadOras[filename].fail(`Upload [fail] ${filename}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知发布完成
|
||||
* @param url
|
||||
*/
|
||||
async function published(url) {
|
||||
if (!PUBLISH_KEY) {
|
||||
console.error("缺少 PUBLISH_KEY 环境变量");
|
||||
process.exit()
|
||||
}
|
||||
const spinner = ora('完成发布...').start();
|
||||
const formData = new FormData()
|
||||
formData.append("action", "release");
|
||||
await axiosAutoTry({
|
||||
axios: {
|
||||
method: 'post',
|
||||
url: url,
|
||||
data: formData,
|
||||
headers: {
|
||||
'Publish-Version': config.version,
|
||||
'Publish-Key': PUBLISH_KEY,
|
||||
},
|
||||
},
|
||||
retryNumber: 3
|
||||
}).then(({status, data}) => {
|
||||
if (status !== 200) {
|
||||
spinner.fail('发布失败, status: ' + status)
|
||||
return
|
||||
}
|
||||
if (!utils.isJson(data)) {
|
||||
spinner.fail('发布失败, not json')
|
||||
return
|
||||
}
|
||||
if (data.ret !== 1) {
|
||||
spinner.fail(`发布失败, ${JSON.stringify(data)}`)
|
||||
return
|
||||
}
|
||||
spinner.succeed('发布完成')
|
||||
}).catch(_ => {
|
||||
spinner.fail('发布失败')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用发布
|
||||
* @param url
|
||||
* @param key
|
||||
* @param version
|
||||
* @param output
|
||||
*/
|
||||
function genericPublish({url, key, version, output}) {
|
||||
if (!/https?:\/\//i.test(url)) {
|
||||
console.warn("发布地址无效: " + url)
|
||||
return
|
||||
}
|
||||
const filePath = path.resolve(__dirname, output)
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn("发布文件未找到: " + filePath)
|
||||
return
|
||||
}
|
||||
fs.readdir(filePath, async (err, files) => {
|
||||
if (err) {
|
||||
console.warn(err)
|
||||
} else {
|
||||
const uploadOras = {}
|
||||
for (const filename of files) {
|
||||
const localFile = path.join(filePath, filename)
|
||||
if (fs.existsSync(localFile)) {
|
||||
const fileStat = fs.statSync(localFile)
|
||||
if (fileStat.isFile()) {
|
||||
uploadOras[filename] = ora(`Upload [0%] ${filename}`).start()
|
||||
const formData = new FormData()
|
||||
formData.append("file", fs.createReadStream(localFile));
|
||||
formData.append("action", "draft");
|
||||
await axiosAutoTry({
|
||||
axios: {
|
||||
method: 'post',
|
||||
url: url,
|
||||
data: formData,
|
||||
headers: {
|
||||
'Publish-Version': version,
|
||||
'Publish-Key': key,
|
||||
'Content-Type': 'multipart/form-data;boundary=' + formData.getBoundary(),
|
||||
},
|
||||
onUploadProgress: progress => {
|
||||
const complete = Math.min(99, Math.round(progress.loaded / progress.total * 100 | 0)) + '%'
|
||||
uploadOras[filename].text = `Upload [${complete}] ${filename}`
|
||||
},
|
||||
},
|
||||
onRetry: _ => {
|
||||
uploadOras[filename].warn(`Upload [retry] ${filename}`)
|
||||
uploadOras[filename] = ora(`Upload [0%] ${filename}`).start()
|
||||
},
|
||||
retryNumber: 3
|
||||
}).then(({status, data}) => {
|
||||
if (status !== 200) {
|
||||
uploadOras[filename].fail(`Upload [fail:${status}] ${filename}`)
|
||||
return
|
||||
}
|
||||
if (!utils.isJson(data)) {
|
||||
uploadOras[filename].fail(`Upload [fail:not json] ${filename}`)
|
||||
return
|
||||
}
|
||||
if (data.ret !== 1) {
|
||||
uploadOras[filename].fail(`Upload [fail:ret ${data.ret}] ${filename}`)
|
||||
return
|
||||
}
|
||||
uploadOras[filename].succeed(`Upload [100%] ${filename}`)
|
||||
}).catch(_ => {
|
||||
uploadOras[filename].fail(`Upload [fail] ${filename}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
spinner.succeed(`Upload [100%] ${filename} (${elapsedSeconds(startTime)})`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -609,8 +402,8 @@ async function startBuild(data) {
|
||||
//
|
||||
if (data.id === 'app') {
|
||||
const eeuiDir = path.resolve(__dirname, "../resources/mobile");
|
||||
const eeuiRun = `docker run --rm -v ${eeuiDir}:/work -w /work kuaifan/eeui-cli:0.0.1`
|
||||
const publicDir = path.resolve(__dirname, "../resources/mobile/src/public");
|
||||
const containerName = `dootask-eeui-${Date.now()}-${process.pid}`;
|
||||
fse.removeSync(publicDir)
|
||||
fse.copySync(electronDir, publicDir)
|
||||
if (argv[3] === "publish") {
|
||||
@@ -628,10 +421,19 @@ async function startBuild(data) {
|
||||
fs.writeFileSync(xcconfigFile, xcconfigResult, 'utf8')
|
||||
}
|
||||
if (['build', 'publish'].includes(argv[3])) {
|
||||
if (!fs.existsSync(path.resolve(eeuiDir, "node_modules"))) {
|
||||
child_process.execSync(`${eeuiRun} npm install`, {stdio: "inherit", cwd: "resources/mobile"});
|
||||
child_process.execSync(
|
||||
`docker run -d --name ${containerName} -v ${shellQuote(eeuiDir)}:/work -w /work kuaifan/eeui-cli:0.0.1 sleep infinity`,
|
||||
{stdio: "ignore", cwd: "resources/mobile"}
|
||||
);
|
||||
try {
|
||||
if (!fs.existsSync(path.resolve(eeuiDir, "node_modules"))) {
|
||||
child_process.execSync(`docker exec ${containerName} npm install`, {stdio: "inherit", cwd: "resources/mobile"});
|
||||
}
|
||||
child_process.execSync(`docker exec ${containerName} node /work/scripts/patch-eeui-build.js`, {stdio: "inherit", cwd: "resources/mobile"});
|
||||
child_process.execSync(`docker exec ${containerName} eeui build --simple`, {stdio: "inherit", cwd: "resources/mobile"});
|
||||
} finally {
|
||||
child_process.execSync(`docker rm -f ${containerName}`, {stdio: "ignore", cwd: "resources/mobile"});
|
||||
}
|
||||
child_process.execSync(`${eeuiRun} eeui build --simple`, {stdio: "inherit", cwd: "resources/mobile"});
|
||||
} else {
|
||||
[
|
||||
path.resolve(publicDir, "../../platforms/ios/eeuiApp/bundlejs/eeui/public"),
|
||||
@@ -698,18 +500,24 @@ async function startBuild(data) {
|
||||
fs.writeFileSync(packageFile, JSON.stringify(appConfig, null, 4), 'utf8');
|
||||
child_process.execSync(`npm run ${platform}-publish`, {stdio: "inherit", cwd: "electron"});
|
||||
}
|
||||
// generic (build or publish)
|
||||
appConfig.build.publish = data.publish
|
||||
// generic (build or publish) —— 有 R2_PUBLIC_URL 时自动更新源指向 R2 release/
|
||||
appConfig.build.publish = r2.R2_PUBLIC_URL
|
||||
? { provider: 'generic', url: `${r2.R2_PUBLIC_URL.replace(/\/+$/, '')}/release` }
|
||||
: data.publish
|
||||
appConfig.build.directories.output = `${output}-generic`;
|
||||
fs.writeFileSync(packageFile, JSON.stringify(appConfig, null, 4), 'utf8');
|
||||
child_process.execSync(`npm run ${platform}`, {stdio: "inherit", cwd: "electron"});
|
||||
if (publish === true && PUBLISH_KEY) {
|
||||
genericPublish({
|
||||
url: appConfig.build.publish.url,
|
||||
key: PUBLISH_KEY,
|
||||
version: config.version,
|
||||
output: appConfig.build.directories.output
|
||||
})
|
||||
if (publish === true && r2.r2Configured()) {
|
||||
const client = r2.createR2Client()
|
||||
const outputDir = path.resolve(__dirname, appConfig.build.directories.output)
|
||||
if (fs.existsSync(outputDir)) {
|
||||
const files = fs.readdirSync(outputDir)
|
||||
for (const filename of files) {
|
||||
const localFile = path.join(outputDir, filename)
|
||||
if (!fs.statSync(localFile).isFile()) continue
|
||||
await uploadDraftFile(client, localFile, config.version)
|
||||
}
|
||||
}
|
||||
}
|
||||
// package.json Recovery
|
||||
recoveryPackage(true)
|
||||
@@ -745,18 +553,101 @@ if (["dev"].includes(argv[2])) {
|
||||
}
|
||||
})
|
||||
} else if (["android-upload"].includes(argv[2])) {
|
||||
// 上传安卓文件(GitHub Actions)
|
||||
config.app.forEach(({publish}) => {
|
||||
if (publish.provider === 'generic') {
|
||||
androidUpload(publish.url)
|
||||
// 上传安卓文件到 R2 draft(GitHub Actions)
|
||||
(async () => {
|
||||
if (!r2.r2Configured()) {
|
||||
console.error("缺少 R2_* 环境变量(R2_ACCESS_KEY_ID/R2_SECRET_ACCESS_KEY/R2_ENDPOINT/R2_BUCKET)")
|
||||
process.exit(1)
|
||||
}
|
||||
const client = r2.createR2Client()
|
||||
const releaseDir = path.resolve(__dirname, "../resources/mobile/platforms/android/eeuiApp/app/build/outputs/apk/release");
|
||||
if (!fs.existsSync(releaseDir)) {
|
||||
console.error("发布文件未找到")
|
||||
process.exit(1)
|
||||
}
|
||||
const files = fs.readdirSync(releaseDir)
|
||||
for (const filename of files) {
|
||||
const localFile = path.join(releaseDir, filename)
|
||||
if (/\.apk$/.test(filename) && fs.existsSync(localFile) && fs.statSync(localFile).isFile()) {
|
||||
await uploadDraftFile(client, localFile, config.version)
|
||||
}
|
||||
}
|
||||
})().catch(err => {
|
||||
console.error(err.message || err)
|
||||
process.exit(1)
|
||||
})
|
||||
} else if (["published"].includes(argv[2])) {
|
||||
// 发布完成(GitHub Actions)
|
||||
config.app.forEach(async ({publish}) => {
|
||||
if (publish.provider === 'generic') {
|
||||
await published(publish.url)
|
||||
} else if (["release"].includes(argv[2])) {
|
||||
// R2 内提升:draft/<version> → release/(当前版扁平,旧版归档 release/<prev>/)
|
||||
(async () => {
|
||||
if (!r2.r2Configured()) {
|
||||
console.error("缺少 R2_* 环境变量")
|
||||
process.exit(1)
|
||||
}
|
||||
const client = r2.createR2Client()
|
||||
const version = config.version
|
||||
const draftPrefix = `draft/${version}/`
|
||||
const draftKeys = await r2.listKeys(client, draftPrefix)
|
||||
if (!draftKeys.length) {
|
||||
console.error(`draft/${version}/ 为空,无法发布`)
|
||||
process.exit(1)
|
||||
}
|
||||
const names = draftKeys.map(k => k.slice(draftPrefix.length))
|
||||
|
||||
// 读 manifest 取上一发布版
|
||||
const manifest = JSON.parse(await r2.getText(client, 'manifest.json') || '{"draft":null,"release":null}')
|
||||
const prev = manifest.release
|
||||
|
||||
// 1. 归档上一版扁平文件 → release/<prev>/
|
||||
if (prev && prev !== version) {
|
||||
const prevRootKeys = await r2.listKeys(client, 'release/', '/')
|
||||
for (const key of prevRootKeys) {
|
||||
const name = key.slice('release/'.length)
|
||||
await r2.copyObject(client, key, `release/${prev}/${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 清空扁平根层(仅根层对象,版本归档子目录不动)
|
||||
const rootKeys = await r2.listKeys(client, 'release/', '/')
|
||||
await r2.deleteKeys(client, rootKeys)
|
||||
|
||||
// 3. 铺新扁平:安装包/blockmap/zip 先,latest*.yml 最后
|
||||
const ymls = names.filter(n => /\.ya?ml$/i.test(n))
|
||||
const others = names.filter(n => !/\.ya?ml$/i.test(n))
|
||||
for (const name of others) await r2.copyObject(client, `${draftPrefix}${name}`, `release/${name}`)
|
||||
for (const name of ymls) await r2.copyObject(client, `${draftPrefix}${name}`, `release/${name}`)
|
||||
|
||||
// 4. 下载索引
|
||||
const index = buildReleaseIndex(names)
|
||||
await r2.putText(client, 'release/index.json', JSON.stringify({ version, files: index }, null, 2))
|
||||
|
||||
// 5. 更新 manifest,清理 draft
|
||||
await r2.putText(client, 'manifest.json', JSON.stringify({ draft: null, release: version }, null, 2))
|
||||
await r2.deleteKeys(client, draftKeys)
|
||||
|
||||
console.log(`Release published: v${version}`)
|
||||
})().catch(err => {
|
||||
console.error(err.message || err)
|
||||
process.exit(1)
|
||||
})
|
||||
} else if (["upload-changelog"].includes(argv[2])) {
|
||||
// 上传 changelog 到 R2(GitHub Actions)
|
||||
(async () => {
|
||||
if (!r2.r2Configured()) {
|
||||
console.error("缺少 R2_* 环境变量")
|
||||
process.exit(1)
|
||||
}
|
||||
const changelogPath = path.resolve(__dirname, "../CHANGELOG.md")
|
||||
if (!fs.existsSync(changelogPath)) {
|
||||
console.error("CHANGELOG.md 未找到")
|
||||
process.exit(1)
|
||||
}
|
||||
const client = r2.createR2Client()
|
||||
const content = fs.readFileSync(changelogPath, 'utf8')
|
||||
await r2.putText(client, 'changelog.md', content)
|
||||
console.log('Changelog uploaded')
|
||||
})().catch(err => {
|
||||
console.error(err.message || err)
|
||||
process.exit(1)
|
||||
})
|
||||
} else if (["all", "win", "mac"].includes(argv[2])) {
|
||||
// 自动编译(GitHub Actions)
|
||||
@@ -907,8 +798,8 @@ if (["dev"].includes(argv[2])) {
|
||||
|
||||
// 发布判断环境变量
|
||||
if (answers.publish) {
|
||||
if (!PUBLISH_KEY && (!GITHUB_TOKEN || !utils.strExists(GITHUB_REPOSITORY, "/"))) {
|
||||
console.error("发布需要 PUBLISH_KEY 或 GitHub Token 和 Repository, 请检查环境变量!");
|
||||
if (!r2.r2Configured() && !(GITHUB_TOKEN && utils.strExists(GITHUB_REPOSITORY, "/"))) {
|
||||
console.error("发布需要 R2_* 或 GITHUB_TOKEN + GITHUB_REPOSITORY, 请检查环境变量!");
|
||||
process.exit()
|
||||
}
|
||||
}
|
||||
|
||||
125
electron/lib/mcp.js
vendored
125
electron/lib/mcp.js
vendored
@@ -4,7 +4,7 @@
|
||||
* DooTask 的 Electron 客户端集成了 Model Context Protocol (MCP) 服务,
|
||||
* 允许 AI 助手(如 Claude)直接与 DooTask 任务进行交互。
|
||||
*
|
||||
* 提供的工具(共 27 个):
|
||||
* 提供的工具(共 29 个):
|
||||
*
|
||||
* === 用户管理 ===
|
||||
* - get_users_basic - 批量获取用户基础信息(1-50个),便于匹配负责人/协助人
|
||||
@@ -43,6 +43,7 @@
|
||||
* === 消息通知 ===
|
||||
* - search_dialogs - 按名称搜索群聊或联系人,返回 dialog_id/userid
|
||||
* - send_message - 发送消息到对话(支持 dialog_id 或 userid)
|
||||
* - send_task_ai_message - 以AI助手身份发送消息到任务对话,支持自定义发送者昵称
|
||||
* - get_message_list - 获取对话消息记录(支持 dialog_id 或 userid)
|
||||
*
|
||||
* === 智能搜索 ===
|
||||
@@ -228,7 +229,7 @@ class DooTaskMCP {
|
||||
return { error: 'Result contains non-serializable data' };
|
||||
}
|
||||
} catch (error) {
|
||||
return { error: error.msg || error.message || String(error) || 'API request failed' };
|
||||
return { error: error.msg || error.message || String(error) || 'API request failed', ret: error.ret, data: error.data };
|
||||
}
|
||||
})()
|
||||
`);
|
||||
@@ -242,6 +243,10 @@ class DooTaskMCP {
|
||||
const result = await Promise.race([executePromise, timeoutPromise]);
|
||||
|
||||
if (result && result.error) {
|
||||
// 多结束/开始状态(-4005/-4006):保留 ret 与 flow_items 交给工具处理,不直接抛错
|
||||
if (result.ret === -4005 || result.ret === -4006) {
|
||||
return result;
|
||||
}
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
@@ -591,14 +596,38 @@ class DooTaskMCP {
|
||||
task_id: z.number()
|
||||
.min(1)
|
||||
.describe('要标记完成的任务ID'),
|
||||
flow_item_id: z.number()
|
||||
.optional()
|
||||
.describe('工作流状态ID'),
|
||||
}),
|
||||
execute: async (params) => {
|
||||
const now = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
||||
|
||||
const result = await this.request('POST', 'project/task/update', {
|
||||
const requestData = {
|
||||
task_id: params.task_id,
|
||||
complete_at: now,
|
||||
});
|
||||
};
|
||||
if (params.flow_item_id) {
|
||||
requestData.flow_item_id = params.flow_item_id;
|
||||
}
|
||||
|
||||
const result = await this.request('POST', 'project/task/update', requestData);
|
||||
|
||||
// 处理多结束状态的情况
|
||||
if (result.ret === -4005) {
|
||||
const flowItems = result.data?.flow_items || [];
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
message: '存在多个结束状态,请选择要使用的状态后重新调用此工具,并指定flow_item_id参数',
|
||||
task_id: params.task_id,
|
||||
flow_items: flowItems,
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
@@ -720,6 +749,9 @@ class DooTaskMCP {
|
||||
complete_at: z.union([z.string(), z.boolean()])
|
||||
.optional()
|
||||
.describe('完成时间。传时间字符串标记完成,传false标记未完成'),
|
||||
flow_item_id: z.number()
|
||||
.optional()
|
||||
.describe('工作流状态ID'),
|
||||
}),
|
||||
execute: async (params) => {
|
||||
const requestData = {
|
||||
@@ -734,9 +766,42 @@ class DooTaskMCP {
|
||||
if (params.start_at !== undefined) requestData.start_at = params.start_at;
|
||||
if (params.end_at !== undefined) requestData.end_at = params.end_at;
|
||||
if (params.complete_at !== undefined) requestData.complete_at = params.complete_at;
|
||||
if (params.flow_item_id !== undefined) requestData.flow_item_id = params.flow_item_id;
|
||||
|
||||
const result = await this.request('POST', 'project/task/update', requestData);
|
||||
|
||||
// 处理多结束状态的情况(标记完成时)
|
||||
if (result.ret === -4005) {
|
||||
const flowItems = result.data?.flow_items || [];
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
message: '存在多个结束状态,请选择要使用的状态后重新调用此工具,并指定flow_item_id参数',
|
||||
task_id: params.task_id,
|
||||
flow_items: flowItems,
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
// 处理多开始状态的情况(取消完成时)
|
||||
if (result.ret === -4006) {
|
||||
const flowItems = result.data?.flow_items || [];
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
message: '存在多个开始状态,请选择要使用的状态后重新调用此工具,并指定flow_item_id参数',
|
||||
task_id: params.task_id,
|
||||
flow_items: flowItems,
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
@@ -1291,6 +1356,58 @@ class DooTaskMCP {
|
||||
}
|
||||
});
|
||||
|
||||
// 以AI助手身份发送消息到任务对话
|
||||
this.mcp.addTool({
|
||||
name: 'send_task_ai_message',
|
||||
description: '以AI助手身份发送消息到任务对话。应在每个重要里程碑、遇到阻塞、以及全部完成时主动调用。',
|
||||
parameters: z.object({
|
||||
task_id: z.number()
|
||||
.describe('目标任务ID'),
|
||||
text: z.string()
|
||||
.min(1)
|
||||
.describe('消息内容,支持 Markdown'),
|
||||
nickname: z.string()
|
||||
.max(20)
|
||||
.optional()
|
||||
.describe('自定义发送者昵称(最多20字),不传或留空时默认显示“AI 助手”'),
|
||||
silence: z.boolean()
|
||||
.optional()
|
||||
.describe('静默发送,不触发推送提醒'),
|
||||
}),
|
||||
execute: async (params) => {
|
||||
const payload = {
|
||||
task_id: params.task_id,
|
||||
text: params.text,
|
||||
text_type: 'md',
|
||||
};
|
||||
|
||||
if (params.nickname !== undefined) {
|
||||
payload.nickname = params.nickname;
|
||||
}
|
||||
|
||||
if (params.silence !== undefined) {
|
||||
payload.silence = params.silence ? 'yes' : 'no';
|
||||
}
|
||||
|
||||
const result = await this.request('POST', 'dialog/msg/send_ai_assistant', payload);
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
task_id: params.task_id,
|
||||
message: result.data,
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// 获取对话消息列表
|
||||
this.mcp.addTool({
|
||||
name: 'get_message_list',
|
||||
|
||||
133
electron/lib/r2.js
vendored
Normal file
133
electron/lib/r2.js
vendored
Normal file
@@ -0,0 +1,133 @@
|
||||
const fs = require('fs');
|
||||
const {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
CopyObjectCommand,
|
||||
DeleteObjectsCommand,
|
||||
ListObjectsV2Command,
|
||||
} = require('@aws-sdk/client-s3');
|
||||
const { Upload } = require('@aws-sdk/lib-storage');
|
||||
|
||||
const {
|
||||
R2_ACCESS_KEY_ID,
|
||||
R2_SECRET_ACCESS_KEY,
|
||||
R2_ENDPOINT,
|
||||
R2_BUCKET,
|
||||
R2_PUBLIC_URL,
|
||||
} = process.env;
|
||||
|
||||
function r2Configured() {
|
||||
return !!(R2_ACCESS_KEY_ID && R2_SECRET_ACCESS_KEY && R2_ENDPOINT && R2_BUCKET);
|
||||
}
|
||||
|
||||
function createR2Client() {
|
||||
return new S3Client({
|
||||
region: 'auto',
|
||||
endpoint: R2_ENDPOINT,
|
||||
credentials: {
|
||||
accessKeyId: R2_ACCESS_KEY_ID,
|
||||
secretAccessKey: R2_SECRET_ACCESS_KEY,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function contentTypeFor(name) {
|
||||
if (/\.ya?ml$/i.test(name)) return 'text/yaml';
|
||||
if (/\.json$/i.test(name)) return 'application/json';
|
||||
if (/\.md$/i.test(name)) return 'text/markdown; charset=utf-8';
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
/** 流式上传本地文件,onProgress(loaded, total) */
|
||||
async function uploadFile(client, localFile, key, onProgress) {
|
||||
const total = fs.statSync(localFile).size;
|
||||
const upload = new Upload({
|
||||
client,
|
||||
params: {
|
||||
Bucket: R2_BUCKET,
|
||||
Key: key,
|
||||
Body: fs.createReadStream(localFile),
|
||||
ContentType: contentTypeFor(key),
|
||||
},
|
||||
});
|
||||
if (onProgress) {
|
||||
upload.on('httpUploadProgress', (p) => onProgress(p.loaded || 0, total));
|
||||
}
|
||||
await upload.done();
|
||||
}
|
||||
|
||||
/** 写入文本对象 */
|
||||
async function putText(client, key, text) {
|
||||
await client.send(new PutObjectCommand({
|
||||
Bucket: R2_BUCKET,
|
||||
Key: key,
|
||||
Body: text,
|
||||
ContentType: contentTypeFor(key),
|
||||
}));
|
||||
}
|
||||
|
||||
/** 读取文本对象,不存在返回 null */
|
||||
async function getText(client, key) {
|
||||
try {
|
||||
const res = await client.send(new GetObjectCommand({ Bucket: R2_BUCKET, Key: key }));
|
||||
return await res.Body.transformToString();
|
||||
} catch (err) {
|
||||
if (err.name === 'NoSuchKey' || err.$metadata?.httpStatusCode === 404) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** 桶内服务端复制(文件名为安全 ASCII,无需额外编码) */
|
||||
async function copyObject(client, srcKey, destKey) {
|
||||
await client.send(new CopyObjectCommand({
|
||||
Bucket: R2_BUCKET,
|
||||
CopySource: `${R2_BUCKET}/${srcKey}`,
|
||||
Key: destKey,
|
||||
ContentType: contentTypeFor(destKey),
|
||||
MetadataDirective: 'REPLACE',
|
||||
}));
|
||||
}
|
||||
|
||||
/** 列举 key;delimiter='/' 时仅返回该前缀下的根层对象(子目录归 CommonPrefixes,不返回) */
|
||||
async function listKeys(client, prefix, delimiter) {
|
||||
const keys = [];
|
||||
let token;
|
||||
do {
|
||||
const res = await client.send(new ListObjectsV2Command({
|
||||
Bucket: R2_BUCKET,
|
||||
Prefix: prefix,
|
||||
Delimiter: delimiter,
|
||||
ContinuationToken: token,
|
||||
}));
|
||||
for (const o of res.Contents || []) keys.push(o.Key);
|
||||
token = res.IsTruncated ? res.NextContinuationToken : undefined;
|
||||
} while (token);
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** 批量删除(每批 1000) */
|
||||
async function deleteKeys(client, keys) {
|
||||
for (let i = 0; i < keys.length; i += 1000) {
|
||||
const batch = keys.slice(i, i + 1000);
|
||||
if (!batch.length) continue;
|
||||
await client.send(new DeleteObjectsCommand({
|
||||
Bucket: R2_BUCKET,
|
||||
Delete: { Objects: batch.map((Key) => ({ Key })) },
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
r2Configured,
|
||||
createR2Client,
|
||||
contentTypeFor,
|
||||
uploadFile,
|
||||
putText,
|
||||
getText,
|
||||
copyObject,
|
||||
listKeys,
|
||||
deleteKeys,
|
||||
R2_BUCKET,
|
||||
R2_PUBLIC_URL,
|
||||
};
|
||||
46
electron/lib/release-index.js
vendored
Normal file
46
electron/lib/release-index.js
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
// 仅这些扩展名进入下载索引(排除 .zip:mac 自动更新增量包,非下载按钮目标)
|
||||
const DOWNLOAD_EXTS = ['.dmg', '.exe', '.msi', '.appimage', '.deb', '.rpm', '.apk', '.pkg'];
|
||||
|
||||
/**
|
||||
* 从文件名解析 platform/arch(与官网 storage.ts 规则保持一致)
|
||||
* @returns {{platform: string, arch: string|null}|null}
|
||||
*/
|
||||
function parseFilename(filename) {
|
||||
const lower = filename.toLowerCase();
|
||||
if (lower.endsWith('.apk')) {
|
||||
return { platform: 'android', arch: null };
|
||||
}
|
||||
if (!DOWNLOAD_EXTS.some((ext) => lower.endsWith(ext))) {
|
||||
return null;
|
||||
}
|
||||
let platform = null;
|
||||
if (/-mac-/i.test(filename) || lower.endsWith('.dmg') || lower.endsWith('.pkg')) {
|
||||
platform = 'mac';
|
||||
} else if (/-win-/i.test(filename) || /-win\./i.test(filename) || lower.endsWith('.msi')) {
|
||||
platform = 'win';
|
||||
} else if (/-linux-/i.test(filename) || lower.endsWith('.appimage') || lower.endsWith('.deb') || lower.endsWith('.rpm')) {
|
||||
platform = 'linux';
|
||||
}
|
||||
if (!platform) return null;
|
||||
let arch = null;
|
||||
if (/-arm64[.-]/i.test(filename)) arch = 'arm64';
|
||||
else if (/-x64[.-]/i.test(filename)) arch = 'x64';
|
||||
return { platform, arch };
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成下载索引:{ "<platform>": { "<arch|default>": filename } }
|
||||
*/
|
||||
function buildReleaseIndex(filenames) {
|
||||
const index = {};
|
||||
for (const filename of filenames) {
|
||||
const parsed = parseFilename(filename);
|
||||
if (!parsed) continue;
|
||||
const archKey = parsed.arch || 'default';
|
||||
index[parsed.platform] = index[parsed.platform] || {};
|
||||
index[parsed.platform][archKey] = filename;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
module.exports = { parseFilename, buildReleaseIndex, DOWNLOAD_EXTS };
|
||||
36
electron/lib/release-index.test.js
vendored
Normal file
36
electron/lib/release-index.test.js
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { parseFilename, buildReleaseIndex } = require('./release-index');
|
||||
|
||||
test('parseFilename: win exe x64', () => {
|
||||
assert.deepStrictEqual(parseFilename('DooTask-v1.7.56-win-x64.exe'), { platform: 'win', arch: 'x64' });
|
||||
});
|
||||
|
||||
test('parseFilename: mac dmg arm64', () => {
|
||||
assert.deepStrictEqual(parseFilename('DooTask-v1.7.56-mac-arm64.dmg'), { platform: 'mac', arch: 'arm64' });
|
||||
});
|
||||
|
||||
test('parseFilename: android apk has null arch', () => {
|
||||
assert.deepStrictEqual(parseFilename('app-release.apk'), { platform: 'android', arch: null });
|
||||
});
|
||||
|
||||
test('parseFilename: ignores yml/blockmap/zip', () => {
|
||||
assert.strictEqual(parseFilename('latest.yml'), null);
|
||||
assert.strictEqual(parseFilename('DooTask-v1.7.56-win-x64.exe.blockmap'), null);
|
||||
assert.strictEqual(parseFilename('DooTask-v1.7.56-mac-arm64.zip'), null);
|
||||
});
|
||||
|
||||
test('buildReleaseIndex: groups by platform/arch, .zip never overwrites .dmg', () => {
|
||||
const index = buildReleaseIndex([
|
||||
'DooTask-v1.7.56-mac-arm64.dmg',
|
||||
'DooTask-v1.7.56-mac-arm64.zip',
|
||||
'DooTask-v1.7.56-win-x64.exe',
|
||||
'latest.yml',
|
||||
'app-release.apk',
|
||||
]);
|
||||
assert.deepStrictEqual(index, {
|
||||
mac: { arm64: 'DooTask-v1.7.56-mac-arm64.dmg' },
|
||||
win: { x64: 'DooTask-v1.7.56-win-x64.exe' },
|
||||
android: { default: 'app-release.apk' },
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,8 @@
|
||||
"ora": "^4.1.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1052.0",
|
||||
"@aws-sdk/lib-storage": "^3.1052.0",
|
||||
"@dootask/electron-dl": "^4.0.0-rc.2",
|
||||
"axios": "^1.11.0",
|
||||
"crc": "^3.8.0",
|
||||
@@ -60,8 +62,8 @@
|
||||
"request": "^2.88.2",
|
||||
"tar": "^7.4.3",
|
||||
"turndown": "^7.2.2",
|
||||
"zod": "^3.23.8",
|
||||
"yauzl": "^3.2.0"
|
||||
"yauzl": "^3.2.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"trayIcon": {
|
||||
"dev": {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
```dotenv
|
||||
OPENAI_API_KEY=你的OpenAI密钥
|
||||
OPENAI_BASE_URL=可选的自定义API地址
|
||||
OPENAI_PROXY_URL=可选的代理地址
|
||||
```
|
||||
|
||||
|
||||
@@ -560,8 +560,6 @@ webhook地址最长仅支持255个字符。
|
||||
(*)将(*)移出群组
|
||||
(*)退出群组
|
||||
(*)已加入群组
|
||||
(*)当前正在共享,无法移动到另一个共享文件夹内
|
||||
(*)内含有共享文件,无法移动到另一个共享文件夹内
|
||||
处理错误
|
||||
仅限所有者或创建者操作
|
||||
没有修改写入权限
|
||||
@@ -575,13 +573,11 @@ webhook地址最长仅支持255个字符。
|
||||
子任务负责人填写错误
|
||||
任务负责人填写错误
|
||||
(*)负责或参与的未完成任务最多不能超过(*)个
|
||||
(*)已被其他成员设置
|
||||
邮件发送超时,请检查邮箱配置是否正确
|
||||
群主不可移出
|
||||
部门成员、项目人员或任务人员不可移出
|
||||
群主不可退出
|
||||
部门成员、项目人员或任务人员不可退出
|
||||
当前客户端版本(*)过低,最低版本要求(*)。
|
||||
验证码不能为空
|
||||
别名不能为空
|
||||
别名的长度在(*)个字符
|
||||
@@ -607,7 +603,6 @@ webhook地址最长仅支持255个字符。
|
||||
(*)的周报[(*)][(*)月第(*)周]
|
||||
(*)的日报[(*)]
|
||||
考勤机
|
||||
手动签到
|
||||
|
||||
(*)评论了(*)的「(**)」审批
|
||||
抄送(*)提交的「(**)」记录
|
||||
@@ -615,7 +610,6 @@ webhook地址最长仅支持255个字符。
|
||||
您发起的「(**)」已通过
|
||||
您发起的「(**)」被(*)拒绝
|
||||
|
||||
消息不存在或已被删除
|
||||
此消息不支持翻译
|
||||
消息内容为空
|
||||
翻译失败
|
||||
@@ -629,26 +623,15 @@ webhook地址最长仅支持255个字符。
|
||||
任务结束时间
|
||||
任务计划用时
|
||||
超时时间
|
||||
负责人
|
||||
创建人
|
||||
|
||||
(*)等(*)位成员的任务统计
|
||||
(*)的任务统计
|
||||
任务ID
|
||||
父级任务ID
|
||||
所属项目
|
||||
任务标题
|
||||
任务开始时间
|
||||
任务结束时间
|
||||
完成时间
|
||||
归档时间
|
||||
任务计划用时
|
||||
实际完成用时
|
||||
超时时间
|
||||
开发用时
|
||||
验收/测试用时
|
||||
负责人
|
||||
创建人
|
||||
状态
|
||||
|
||||
审批记录
|
||||
@@ -656,7 +639,6 @@ webhook地址最长仅支持255个字符。
|
||||
标题
|
||||
申请状态
|
||||
发起时间
|
||||
完成时间
|
||||
发起人工号
|
||||
发起人User ID
|
||||
发起人姓名
|
||||
@@ -665,7 +647,6 @@ webhook地址最长仅支持255个字符。
|
||||
部门负责人
|
||||
历史审批人
|
||||
历史办理人
|
||||
审批记录
|
||||
当前处理人
|
||||
审批节点
|
||||
审批人数
|
||||
@@ -819,7 +800,6 @@ AI机器人不存在
|
||||
|
||||
选择模型
|
||||
当前对话不支持
|
||||
会话不存在或已被删除
|
||||
开启新会话
|
||||
历史会话
|
||||
|
||||
@@ -839,13 +819,11 @@ AI机器人不存在
|
||||
(*)天(*)小时(*)分钟
|
||||
(*)天(*)小时
|
||||
(*)天(*)分钟
|
||||
(*)天
|
||||
(*)小时(*)分钟
|
||||
(*)小时
|
||||
(*)分钟
|
||||
|
||||
任务不存在或已被删除
|
||||
文件不存在或已被删除
|
||||
报告不存在或已被删除
|
||||
文件读取失败:(*)
|
||||
|
||||
@@ -919,7 +897,6 @@ URL格式不正确
|
||||
报告内容为空,无法进行分析
|
||||
工作汇报分析失败
|
||||
工作汇报分析结果为空
|
||||
缺少ID参数
|
||||
无权访问该工作汇报
|
||||
生成AI分析失败
|
||||
工作汇报内容不能为空
|
||||
@@ -937,14 +914,11 @@ URL格式不正确
|
||||
会员不存在
|
||||
请输入个性标签
|
||||
标签名称最多只能设置(*)个字
|
||||
标签已存在
|
||||
每位会员最多添加(*)个标签
|
||||
参数错误
|
||||
标签不存在
|
||||
无权操作该标签
|
||||
已取消认可
|
||||
认可成功
|
||||
选择模型
|
||||
请先配置 AI 助手
|
||||
请先在「AI 助手」设置中配置 (*)
|
||||
今日未完成的工作
|
||||
@@ -963,3 +937,60 @@ AI建议:采纳(*)建议
|
||||
消息内容格式错误
|
||||
AI 调用失败
|
||||
AI 返回内容为空
|
||||
|
||||
修改AI自动分析
|
||||
关联不存在
|
||||
只能合并转发同一对话的消息
|
||||
所选消息均不支持转发
|
||||
无法创建任务对话
|
||||
最多转发(*)条消息
|
||||
此类型消息不支持转发
|
||||
没有权限操作此任务
|
||||
请选择要转发的消息
|
||||
LDAP 用户缺少邮箱属性,请联系管理员配置
|
||||
群管理员
|
||||
任命群管理员
|
||||
罢免群管理员
|
||||
该用户不是群成员
|
||||
不能将群主任命为群管理员
|
||||
仅群主或群管理员可操作
|
||||
仅限群主或群管理员操作
|
||||
群管理员不能移出群主或其他群管理员
|
||||
请选择有效的成员
|
||||
任命成功
|
||||
罢免成功
|
||||
项目管理员
|
||||
任命项目管理员
|
||||
罢免项目管理员
|
||||
该用户不是项目成员
|
||||
不能将负责人任命为项目管理员
|
||||
不能将部门负责人任命为部门管理员
|
||||
该用户不存在
|
||||
无权操作此模板
|
||||
修改共享模板
|
||||
修改负责人视角可见
|
||||
项目负责人数据异常,请先修复项目负责人
|
||||
项目成员列表必须包含项目负责人
|
||||
项目管理员不能移除项目负责人或项目管理员
|
||||
项目管理员必须是项目成员
|
||||
负责人不能任命为项目管理员
|
||||
普通成员不能移出群主或群管理员
|
||||
只有群主、群管理员或邀请人可以移出成员
|
||||
仅群主、项目/任务负责人或系统管理员可设置或取消他人待办
|
||||
请选择文件
|
||||
仅支持 xls/xlsx/csv 文件
|
||||
文件中没有可导入的数据
|
||||
导入完成
|
||||
昵称需为2-20个字
|
||||
邮箱、昵称、初始密码均为必填
|
||||
邮箱格式不正确
|
||||
文件内邮箱重复
|
||||
单次最多导入500条
|
||||
没有可导入的数据
|
||||
解析完成
|
||||
|
||||
请选择成员
|
||||
待办提醒
|
||||
你有一条待办到提醒时间啦
|
||||
发送者昵称最多不能超过20字
|
||||
AI 助手
|
||||
|
||||
@@ -1668,6 +1668,12 @@ WiFi签到延迟时长为±1分钟。
|
||||
|
||||
你确定将【(*)】设为管理员吗?
|
||||
你确定取消【(*)】管理员身份吗?
|
||||
你确定将【(*)】的邮箱标记为已认证吗?
|
||||
你确定将【(*)】的邮箱标记为未认证吗?
|
||||
标记邮箱为已认证
|
||||
标记邮箱为未认证
|
||||
标记选中(*)项为已认证
|
||||
标记选中(*)项为未认证
|
||||
|
||||
你确定要取消任务时间吗?
|
||||
更新子任务
|
||||
@@ -1689,7 +1695,6 @@ WiFi签到延迟时长为±1分钟。
|
||||
|
||||
该任务尚未被领取,点击这里
|
||||
考勤机
|
||||
手动签到
|
||||
签到备注
|
||||
重复打卡提醒
|
||||
|
||||
@@ -1728,7 +1733,6 @@ WiFi签到延迟时长为±1分钟。
|
||||
插入链接
|
||||
请输入完整的链接地址
|
||||
|
||||
自动通过,审批人与发起人为同一人
|
||||
自动通过,审批人已审核
|
||||
|
||||
你确定要删除项目吗?
|
||||
@@ -1757,9 +1761,6 @@ WiFi签到延迟时长为±1分钟。
|
||||
定位失败
|
||||
位置
|
||||
你选择的位置「(*)」不在签到范围内
|
||||
定位签到
|
||||
通过在签到打卡机器人发送位置签到
|
||||
签到备注
|
||||
百度地图AK
|
||||
腾讯地图Key
|
||||
高德地图Key
|
||||
@@ -1813,7 +1814,6 @@ WiFi签到延迟时长为±1分钟。
|
||||
系统别名
|
||||
用于网页默认标题、邮件发送等
|
||||
|
||||
权限设置
|
||||
打包权限
|
||||
允许所有人
|
||||
仅限管理员
|
||||
@@ -1882,7 +1882,6 @@ WiFi签到延迟时长为±1分钟。
|
||||
只有在项目中才能创建任务
|
||||
项目不存在
|
||||
只有在任务中才能创建子任务
|
||||
任务不存在
|
||||
未知类型
|
||||
未找到内容
|
||||
再见
|
||||
@@ -1901,7 +1900,6 @@ WiFi签到延迟时长为±1分钟。
|
||||
请输入标签描述
|
||||
标签颜色
|
||||
使用示例标签
|
||||
取消默认
|
||||
编辑标签
|
||||
确定要删除该标签吗?
|
||||
标签
|
||||
@@ -1909,7 +1907,6 @@ WiFi签到延迟时长为±1分钟。
|
||||
暂无标签
|
||||
添加标签
|
||||
请选择示例标签
|
||||
全部保存成功
|
||||
|
||||
消息详情
|
||||
长文本
|
||||
@@ -2022,7 +2019,6 @@ API请求的URL路径
|
||||
附言
|
||||
|
||||
任务不存在或已被删除
|
||||
文件不存在或已被删除
|
||||
报告不存在或已被删除
|
||||
文件读取失败:(*)
|
||||
独立窗口显示
|
||||
@@ -2037,7 +2033,6 @@ API请求的URL路径
|
||||
删除机器人:(*)
|
||||
|
||||
默认:90天
|
||||
机器人名称
|
||||
|
||||
后退
|
||||
前进
|
||||
@@ -2089,12 +2084,8 @@ OKR群组
|
||||
会话名称
|
||||
值
|
||||
结果
|
||||
名称
|
||||
命令
|
||||
必填
|
||||
接口地址
|
||||
清理时间
|
||||
类型
|
||||
该机器人不支持
|
||||
说明
|
||||
属性
|
||||
@@ -2214,7 +2205,6 @@ Webhook事件
|
||||
打开会话
|
||||
成员加入
|
||||
成员退出
|
||||
是否拨打电话给(*)?
|
||||
是否发送邮件给(*)?
|
||||
|
||||
个人信息
|
||||
@@ -2283,7 +2273,6 @@ URL不能为空
|
||||
AI 搜索
|
||||
AI 项目助手
|
||||
AI 汇报分析
|
||||
AI 整理汇报
|
||||
AI 任务助手
|
||||
AI 消息助手
|
||||
欢迎使用 AI 助手
|
||||
@@ -2329,3 +2318,125 @@ AI 消息助手
|
||||
指派
|
||||
关联
|
||||
采纳
|
||||
|
||||
逐条转发
|
||||
合并转发
|
||||
复制原文
|
||||
所属部门
|
||||
留空则不修改密码
|
||||
职位
|
||||
请输入电话号码
|
||||
正在编辑帐号【ID:(*)】的信息。
|
||||
编辑用户信息
|
||||
|
||||
AI任务分析
|
||||
关闭后所有项目将不再自动分析任务。
|
||||
关闭后本项目将不再自动分析任务。
|
||||
新建任务后AI自动分析并给出建议。
|
||||
(最多(*)条)
|
||||
最多选择(*)条消息
|
||||
系统已关闭AI任务分析功能。
|
||||
聊天记录
|
||||
确定要解除与任务 #(*) 的关联吗?
|
||||
共(*)条消息
|
||||
已选(*)条
|
||||
(*)的聊天记录
|
||||
(*)和(*)的聊天记录
|
||||
(*)和(*)等人的聊天记录
|
||||
|
||||
生日
|
||||
请选择生日
|
||||
登录属性
|
||||
用于匹配登录用户名的 LDAP 属性,Active Directory 请选择 sAMAccountName
|
||||
请输入帐号
|
||||
群管理员
|
||||
任命群管理员
|
||||
罢免群管理员
|
||||
确定要罢免该群管理员吗?
|
||||
还没有群管理员
|
||||
添加群管理员
|
||||
确定将 (*) 任命为群管理员吗?
|
||||
项目管理员
|
||||
任命项目管理员
|
||||
罢免项目管理员
|
||||
确定要罢免该项目管理员吗?
|
||||
还没有项目管理员
|
||||
添加项目管理员
|
||||
确定将 (*) 任命为项目管理员吗?
|
||||
部门管理员
|
||||
任命部门管理员
|
||||
罢免部门管理员
|
||||
请选择部门管理员
|
||||
确定将 (*) 任命为部门管理员吗?
|
||||
部门管理员享有部门群的群管理员权限
|
||||
即将罢免项目管理员
|
||||
请确认以下操作,注意此操作不可逆!
|
||||
移除成员负责的任务将变成无负责人。
|
||||
搜索模板
|
||||
来自(*)
|
||||
暂无可用模板
|
||||
加载中
|
||||
共享模板
|
||||
开启后,添加任务时可使用其他项目共享的任务模板。
|
||||
关闭后,添加任务时仅加载本项目模板,不显示其他项目共享模板。
|
||||
根据系统设置的自动归档规则执行
|
||||
负责人视角
|
||||
开启后,部门负责人可只读查看本项目及其全员可见任务。
|
||||
关闭后,本项目及其群聊对部门负责人视角隐藏。
|
||||
个人项目,只读查看
|
||||
负责人视角,只读查看
|
||||
我的项目
|
||||
没有任何与"(*)"相关的结果
|
||||
标记未选
|
||||
标记已选
|
||||
可查看所选部门及所有下级部门成员参与的项目和任务,仅支持只读查看。
|
||||
反选
|
||||
切换失败
|
||||
当前为负责人视角:你可查看项目和任务,并参与讨论,但不能编辑项目或任务。
|
||||
选择项目管理员
|
||||
即将移除
|
||||
当前为负责人视角,并参与讨论,但不能编辑任务。
|
||||
部门负责人视角
|
||||
开启后,部门负责人/部门管理员可只读查看本部门及下级部门成员参与的项目和项目内全部任务。
|
||||
部门管理员同步失败
|
||||
待办设置权限
|
||||
允许:所有成员可设置/取消他人待办。
|
||||
禁止:仅本人、系统管理员、群主(含群管理员)、项目负责人(含项目管理员)、任务负责人可设置/取消待办。
|
||||
|
||||
批量导入用户
|
||||
请按模板填写后上传,列顺序:邮箱、昵称、初始密码、职位(选填);单次最多导入500条。
|
||||
下载模板
|
||||
导入结果:共(*)条,成功(*)条,失败(*)条
|
||||
行号
|
||||
失败原因
|
||||
导入失败
|
||||
仅支持 xls/xlsx/csv 文件
|
||||
创建用户
|
||||
批量导入
|
||||
初始密码
|
||||
请输入邮箱
|
||||
请输入初始密码
|
||||
员工首次登录需修改密码
|
||||
邮箱、昵称、初始密码均为必填
|
||||
员工下次登录需修改密码
|
||||
重新选择文件
|
||||
共(*)条 · 可导入(*)条 · 错误(*)条
|
||||
点击查看明文
|
||||
原因
|
||||
可导入
|
||||
错误
|
||||
确定导入(*)条
|
||||
解析失败
|
||||
|
||||
设置部门到选中(*)项
|
||||
提醒时间
|
||||
不提醒
|
||||
1 小时后
|
||||
今晚 20:00
|
||||
明早 9:00
|
||||
成功导入(*)条
|
||||
标记完成
|
||||
暂无待办
|
||||
暂无完成
|
||||
取消提醒
|
||||
确定取消该成员的提醒时间吗?
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -75,6 +75,8 @@ if ($openAiKey === '') {
|
||||
exit(1);
|
||||
}
|
||||
$openAiProxy = trim(language_env_value('OPENAI_PROXY_URL', $languageEnv) ?? '');
|
||||
$openAiBaseUrl = trim(language_env_value('OPENAI_BASE_URL', $languageEnv) ?? '');
|
||||
$openAiModel = trim(language_env_value('OPENAI_API_MODEL', $languageEnv) ?? '');
|
||||
|
||||
// 读取所有要翻译的内容
|
||||
$originals = [];
|
||||
@@ -170,11 +172,14 @@ if (count($needs) > 0) {
|
||||
// 开始翻译
|
||||
print_r("正在翻译:" . (count($keys) + $done) . "/" . count($needs) . "...\n");
|
||||
$openAi = new OpenAi($openAiKey);
|
||||
if ($openAiBaseUrl !== '') {
|
||||
$openAi->setBaseURL(rtrim(preg_replace('#/v\d+/?$#', '', $openAiBaseUrl), '/'));
|
||||
}
|
||||
if ($openAiProxy !== '') {
|
||||
$openAi->setProxy($openAiProxy);
|
||||
}
|
||||
$result = $openAi->chat([
|
||||
"model" => "gpt-5.2",
|
||||
"model" => $openAiModel,
|
||||
"reasoning_effort" => "low",
|
||||
'messages' => [
|
||||
[
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "DooTask",
|
||||
"version": "1.6.89",
|
||||
"codeVerson": 228,
|
||||
"version": "1.7.81",
|
||||
"codeVerson": 236,
|
||||
"description": "DooTask is task management system.",
|
||||
"scripts": {
|
||||
"start": "./cmd dev",
|
||||
@@ -90,7 +90,7 @@
|
||||
],
|
||||
"publish": {
|
||||
"provider": "generic",
|
||||
"url": "https://www.dootask.com/desktop/publish"
|
||||
"url": "https://www.dootask.com/api/download/update"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
BIN
public/images/avatar/default_assistant.png
Normal file
BIN
public/images/avatar/default_assistant.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
1
public/js/build/404.7c452c49.js
vendored
Normal file
1
public/js/build/404.7c452c49.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{n as m}from"./app.003a6843.js";import"./jquery.de0a5c6b.js";import"./@babel.9410f858.js";import"./dayjs.169453f2.js";import"./localforage.1d5f26a4.js";import"./markdown-it.0450edb4.js";import"./mdurl.ce6c1dd8.js";import"./uc.micro.8d343c98.js";import"./entities.48a44fec.js";import"./linkify-it.c5e8196e.js";import"./punycode.js.4b3f125a.js";import"./highlight.js.cbbfb885.js";import"./markdown-it-link-attributes.e1d5d151.js";import"./@traptitech.acea8861.js";import"./vue.adba9046.js";import"./vuex.cc7cb26e.js";import"./openpgp_hi.15f91b1d.js";import"./axios.37c7f908.js";import"./mitt.1ea0a2a3.js";import"./quill-hi.ca2ea0cc.js";import"./parchment.d5c5924e.js";import"./quill-delta.385a10bf.js";import"./fast-diff.f17881f3.js";import"./lodash.clonedeep.3cc09a31.js";import"./lodash.isequal.dbdc2157.js";import"./eventemitter3.78b735ad.js";import"./lodash-es.76e3a28b.js";import"./quill-mention-hi.4eeb5a2d.js";import"./view-design-hi.f1128b4d.js";import"./html-to-md.f297036e.js";import"./lodash.8fcd6fd4.js";import"./vue-router.2d566cd7.js";import"./vue-clipboard2.fd43a5bc.js";import"./clipboard.37b37361.js";import"./vuedraggable.f464b992.js";import"./sortablejs.3488b922.js";import"./vue-resize-observer.5af23a43.js";import"./element-sea.f8a64907.js";import"./deepmerge.cecf392e.js";import"./resize-observer-polyfill.5d591c5f.js";import"./throttle-debounce.7c3948b2.js";import"./babel-helper-vue-jsx-merge-props.5ed215c3.js";import"./normalize-wheel.2a034b9f.js";import"./async-validator.dca2b951.js";import"./babel-runtime.4773988a.js";import"./core-js.314b4a1d.js";var p=function(){var t=this,r=t.$createElement;return t._self._c,t._m(0)},e=[function(){var t=this,r=t.$createElement,i=t._self._c||r;return i("div",{staticClass:"page-404"},[i("div",{staticClass:"flex-center position-ref full-height"},[i("div",{staticClass:"code"},[t._v("404")]),i("div",{staticClass:"message"},[t._v("Not Found")])])])}];const s={},o={};var _=m(s,p,e,!1,n,"7d7154a8",null,null);function n(t){for(let r in o)this[r]=o[r]}var it=function(){return _.exports}();export{it as default};
|
||||
1
public/js/build/404.85da9b1f.js
vendored
1
public/js/build/404.85da9b1f.js
vendored
@@ -1 +0,0 @@
|
||||
import{n as m}from"./app.5029512e.js";import"./jquery.2060430b.js";import"./@babel.ad55b12f.js";import"./dayjs.dfa73531.js";import"./localforage.3e4ebad6.js";import"./markdown-it.bda97caf.js";import"./mdurl.ce6c1dd8.js";import"./uc.micro.8d343c98.js";import"./entities.48a44fec.js";import"./linkify-it.c5e8196e.js";import"./punycode.js.4b3f125a.js";import"./highlight.js.cbbfb885.js";import"./markdown-it-link-attributes.e1d5d151.js";import"./@traptitech.561c583d.js";import"./vue.baba6da0.js";import"./vuex.cc7cb26e.js";import"./openpgp_hi.15f91b1d.js";import"./axios.554fcc10.js";import"./mitt.1ea0a2a3.js";import"./quill-hi.978f208d.js";import"./parchment.d5c5924e.js";import"./quill-delta.4f1e4697.js";import"./fast-diff.f17881f3.js";import"./lodash.clonedeep.8fb065bb.js";import"./lodash.isequal.84238944.js";import"./eventemitter3.78b735ad.js";import"./lodash-es.df04b444.js";import"./quill-mention-hi.2a6582e4.js";import"./view-design-hi.e9360295.js";import"./html-to-md.96d5de37.js";import"./lodash.2fa8f497.js";import"./vue-router.2d566cd7.js";import"./vue-clipboard2.89894d1b.js";import"./clipboard.6caea48c.js";import"./vuedraggable.e8809463.js";import"./sortablejs.b1e23af3.js";import"./vue-resize-observer.df5b985e.js";import"./element-sea.9d03b085.js";import"./deepmerge.cecf392e.js";import"./resize-observer-polyfill.18409ee0.js";import"./throttle-debounce.7c3948b2.js";import"./babel-helper-vue-jsx-merge-props.5ed215c3.js";import"./normalize-wheel.2a034b9f.js";import"./async-validator.e1b191c9.js";import"./babel-runtime.4773988a.js";import"./core-js.314b4a1d.js";var p=function(){var t=this,r=t.$createElement;return t._self._c,t._m(0)},e=[function(){var t=this,r=t.$createElement,i=t._self._c||r;return i("div",{staticClass:"page-404"},[i("div",{staticClass:"flex-center position-ref full-height"},[i("div",{staticClass:"code"},[t._v("404")]),i("div",{staticClass:"message"},[t._v("Not Found")])])])}];const s={},o={};var _=m(s,p,e,!1,n,"7d7154a8",null,null);function n(t){for(let r in o)this[r]=o[r]}var it=function(){return _.exports}();export{it as default};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
263
public/js/build/@traptitech.561c583d.js
vendored
263
public/js/build/@traptitech.561c583d.js
vendored
File diff suppressed because one or more lines are too long
267
public/js/build/@traptitech.acea8861.js
vendored
Normal file
267
public/js/build/@traptitech.acea8861.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
public/js/build/CheckinExport.20eb51ab.js
vendored
Normal file
1
public/js/build/CheckinExport.20eb51ab.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
public/js/build/CheckinExport.249b236e.js
vendored
1
public/js/build/CheckinExport.249b236e.js
vendored
File diff suppressed because one or more lines are too long
1
public/js/build/CheckinExport.88eee51f.css
vendored
Normal file
1
public/js/build/CheckinExport.88eee51f.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.checkin-field .ivu-form-item-label{color:#f90;font-weight:500}.checkin-mac-header[data-v-eb58f07c]{margin-bottom:8px;font-weight:500;color:#606266}.checkin-mac-item[data-v-eb58f07c]{margin-bottom:8px}.checkin-mac-item .ivu-col[data-v-eb58f07c]{padding-right:8px}.checkin-mac-item .ivu-col[data-v-eb58f07c]:last-child{padding-right:0}.checkin-mac-del[data-v-eb58f07c]{display:flex;align-items:center;justify-content:center;cursor:pointer;color:red}.checkin-mac-del[data-v-eb58f07c]:hover{opacity:.8}.form-tip[data-v-eb58f07c]{font-size:12px;color:#999;margin-top:4px}.user-tags-preview[data-v-eb58f07c]{display:flex;align-items:center;flex-wrap:wrap;gap:8px;min-height:32px}.user-tags-preview .tag-pill[data-v-eb58f07c]{cursor:pointer;padding:6px 12px;border-radius:12px;font-size:13px;-webkit-user-select:none;-moz-user-select:none;user-select:none;background-color:#f5f5f5;color:#606266;line-height:14px;height:26px;max-width:160px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.user-tags-preview .tag-pill.is-recognized[data-v-eb58f07c]{color:#67c23a}.user-tags-preview .tag-pill span[data-v-eb58f07c]{padding-left:8px;position:relative}.user-tags-preview .tag-pill span[data-v-eb58f07c]:before{content:"";position:absolute;left:2px;top:50%;transform:translateY(-50%);width:2px;height:2px;border-radius:50%;background-color:currentColor}.user-tags-preview .tags-empty[data-v-eb58f07c]{color:#909399}.user-tags-preview .tags-total[data-v-eb58f07c]{color:#909399;font-size:12px}.user-tags-preview .manage-button[data-v-eb58f07c]{margin-left:auto;display:inline-flex;align-items:center;gap:4px}.import-user-modal .import-tip[data-v-9d8f7ae8]{color:#808695;margin-bottom:12px}.import-user-modal .import-actions[data-v-9d8f7ae8]{display:flex;gap:12px;align-items:center}.import-user-modal .import-option[data-v-9d8f7ae8]{margin-top:12px}.import-user-modal .import-batch-label[data-v-9d8f7ae8]{flex-shrink:0;min-width:64px;color:#515a6e}.import-user-modal .import-setdept[data-v-9d8f7ae8]{display:flex;align-items:center;gap:8px;margin-top:12px}.import-user-modal .import-setdept .import-setdept-select[data-v-9d8f7ae8]{width:auto}.import-user-modal .import-setverity[data-v-9d8f7ae8]{display:flex;align-items:center;gap:8px;margin-top:12px}.import-user-modal .import-preview[data-v-9d8f7ae8],.import-user-modal .import-result[data-v-9d8f7ae8]{margin-top:16px}.import-user-modal[data-v-9d8f7ae8] .ivu-table-cell{white-space:nowrap}.import-user-modal[data-v-9d8f7ae8] .pwd-cell{cursor:pointer;letter-spacing:1px;-webkit-user-select:none;-moz-user-select:none;user-select:none}.import-user-modal[data-v-9d8f7ae8] .pwd-cell:hover{color:#2d8cf0}.import-user-modal[data-v-9d8f7ae8] .import-row-error td{background-color:#fff2f0}
|
||||
1
public/js/build/CheckinExport.d9659445.css
vendored
1
public/js/build/CheckinExport.d9659445.css
vendored
@@ -1 +0,0 @@
|
||||
.checkin-field .ivu-form-item-label{color:#f90;font-weight:500}.checkin-mac-header[data-v-39d6b3fb]{margin-bottom:8px;font-weight:500;color:#606266}.checkin-mac-item[data-v-39d6b3fb]{margin-bottom:8px}.checkin-mac-item .ivu-col[data-v-39d6b3fb]{padding-right:8px}.checkin-mac-item .ivu-col[data-v-39d6b3fb]:last-child{padding-right:0}.checkin-mac-del[data-v-39d6b3fb]{display:flex;align-items:center;justify-content:center;cursor:pointer;color:red}.checkin-mac-del[data-v-39d6b3fb]:hover{opacity:.8}.form-tip[data-v-39d6b3fb]{font-size:12px;color:#999;margin-top:4px}.user-tags-preview[data-v-39d6b3fb]{display:flex;align-items:center;flex-wrap:wrap;gap:8px;min-height:32px}.user-tags-preview .tag-pill[data-v-39d6b3fb]{cursor:pointer;padding:6px 12px;border-radius:12px;font-size:13px;-webkit-user-select:none;-moz-user-select:none;user-select:none;background-color:#f5f5f5;color:#606266;line-height:14px;height:26px;max-width:160px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.user-tags-preview .tag-pill.is-recognized[data-v-39d6b3fb]{color:#67c23a}.user-tags-preview .tag-pill span[data-v-39d6b3fb]{padding-left:8px;position:relative}.user-tags-preview .tag-pill span[data-v-39d6b3fb]:before{content:"";position:absolute;left:2px;top:50%;transform:translateY(-50%);width:2px;height:2px;border-radius:50%;background-color:currentColor}.user-tags-preview .tags-empty[data-v-39d6b3fb]{color:#909399}.user-tags-preview .tags-total[data-v-39d6b3fb]{color:#909399;font-size:12px}.user-tags-preview .manage-button[data-v-39d6b3fb]{margin-left:auto;display:inline-flex;align-items:center;gap:4px}
|
||||
1
public/js/build/DepartmentOwnerView.08f91720.js
vendored
Normal file
1
public/js/build/DepartmentOwnerView.08f91720.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{m as i}from"./vuex.cc7cb26e.js";import{n as o}from"./app.003a6843.js";var d=function(){var t=this,a=t.$createElement,e=t._self._c||a;return e("Modal",{attrs:{value:t.value,title:t.$L("\u8D1F\u8D23\u4EBA\u89C6\u89D2"),"mask-closable":!1,width:"520"},on:{input:function(s){return t.$emit("input",s)}}},[e("div",{staticClass:"department-owner-view-modal"},[e("Alert",{attrs:{type:"info","show-icon":""}},[t._v(" "+t._s(t.$L("\u53EF\u67E5\u770B\u6240\u9009\u90E8\u95E8\u53CA\u6240\u6709\u4E0B\u7EA7\u90E8\u95E8\u6210\u5458\u53C2\u4E0E\u7684\u9879\u76EE\u548C\u4EFB\u52A1\uFF0C\u4EC5\u652F\u6301\u53EA\u8BFB\u67E5\u770B\u3002"))+" ")]),t.managedDepartments.length>1?e("div",{staticClass:"department-owner-view-actions"},[e("a",{attrs:{href:"javascript:void(0)"},on:{click:function(s){t.draftIds=[]}}},[t._v(t._s(t.$L("\u6E05\u7A7A")))]),e("a",{attrs:{href:"javascript:void(0)"},on:{click:function(s){t.draftIds=t.managedDepartments.map(function(n){return n.id})}}},[t._v(t._s(t.$L("\u5168\u9009")))]),e("a",{attrs:{href:"javascript:void(0)"},on:{click:t.reverseDraft}},[t._v(t._s(t.$L("\u53CD\u9009")))])]):t._e(),e("CheckboxGroup",{staticClass:"department-owner-view-list",model:{value:t.draftIds,callback:function(s){t.draftIds=s},expression:"draftIds"}},t._l(t.managedDepartments,function(s){return e("div",{key:s.id,class:["department-owner-view-item",t.draftIds.includes(s.id)?"active":""],on:{click:function(n){return t.toggleDraft(s.id)}}},[e("div",{staticClass:"department-owner-view-icon"},[e("i",{staticClass:"taskfont"},[t._v("\uE75C")])]),e("div",{staticClass:"department-owner-view-name"},[t._v(t._s(s.name))]),e("Checkbox",{staticClass:"department-owner-view-checkbox",attrs:{label:s.id},nativeOn:{click:function(n){n.stopPropagation()}}},[e("span")])],1)}),0)],1),e("div",{staticClass:"adaption",attrs:{slot:"footer"},slot:"footer"},[e("Button",{attrs:{type:"default",disabled:t.applyLoading},on:{click:function(s){return t.$emit("input",!1)}}},[t._v(t._s(t.$L("\u53D6\u6D88")))]),e("Button",{attrs:{type:"primary",loading:t.applyLoading},on:{click:t.apply}},[t._v(t._s(t.$L("\u786E\u5B9A")))])],1)])},l=[];const c={name:"DepartmentOwnerView",props:{value:Boolean},data(){return{draftIds:[],applyLoading:!1}},computed:{...i(["userInfo","cacheDepartmentOwnerIds"]),managedDepartments(){return(this.userInfo.managed_departments||[]).map(t=>({...t,id:parseInt(t.id)}))}},watch:{value:{immediate:!0,handler(t){t?this.draftIds=(this.cacheDepartmentOwnerIds||[]).slice():this.applyLoading=!1}}},methods:{toggleDraft(t){t=parseInt(t);const a=this.draftIds.indexOf(t);a>-1?this.draftIds.splice(a,1):this.draftIds.push(t)},reverseDraft(){const t=this.draftIds.map(a=>parseInt(a));this.draftIds=this.managedDepartments.map(a=>a.id).filter(a=>!t.includes(a))},async apply(){if(!this.applyLoading){this.applyLoading=!0;try{await this.$store.dispatch("setDepartmentOwnerIds",this.draftIds),this.$emit("input",!1)}catch(t){$A.modalError((t==null?void 0:t.msg)||this.$L("\u5207\u6362\u5931\u8D25"))}finally{this.applyLoading=!1}}}}},r={};var p=o(c,d,l,!1,f,"624ab3e4",null,null);function f(t){for(let a in r)this[a]=r[a]}var u=function(){return p.exports}();export{u as D};
|
||||
1
public/js/build/DepartmentOwnerView.ecabb08d.css
vendored
Normal file
1
public/js/build/DepartmentOwnerView.ecabb08d.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.department-owner-view-modal .department-owner-view-actions[data-v-624ab3e4]{display:flex;justify-content:flex-end;gap:14px;margin:12px 8px 0}.department-owner-view-modal .department-owner-view-list[data-v-624ab3e4]{display:flex;flex-direction:column;margin-top:10px}.department-owner-view-modal .department-owner-view-item[data-v-624ab3e4]{display:flex;align-items:center;padding:10px 12px;cursor:pointer}.department-owner-view-modal .department-owner-view-icon[data-v-624ab3e4]{width:28px;height:28px;border-radius:50%;background-color:#5bc7b0;color:#fff;display:flex;align-items:center;justify-content:center;margin-right:10px}.department-owner-view-modal .department-owner-view-name[data-v-624ab3e4]{flex:1}.department-owner-view-modal .department-owner-view-checkbox[data-v-624ab3e4]{margin-right:0}
|
||||
4
public/js/build/DialogWrapper.7a5bb3ac.js
vendored
4
public/js/build/DialogWrapper.7a5bb3ac.js
vendored
File diff suppressed because one or more lines are too long
4
public/js/build/DialogWrapper.aaa21ecc.js
vendored
Normal file
4
public/js/build/DialogWrapper.aaa21ecc.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
import{m as s}from"./vuex.cc7cb26e.js";import{I as m}from"./IFrame.7173ab75.js";import{n as p,l as o}from"./app.5029512e.js";import"./jquery.2060430b.js";import"./@babel.ad55b12f.js";import"./dayjs.dfa73531.js";import"./localforage.3e4ebad6.js";import"./markdown-it.bda97caf.js";import"./mdurl.ce6c1dd8.js";import"./uc.micro.8d343c98.js";import"./entities.48a44fec.js";import"./linkify-it.c5e8196e.js";import"./punycode.js.4b3f125a.js";import"./highlight.js.cbbfb885.js";import"./markdown-it-link-attributes.e1d5d151.js";import"./@traptitech.561c583d.js";import"./vue.baba6da0.js";import"./openpgp_hi.15f91b1d.js";import"./axios.554fcc10.js";import"./mitt.1ea0a2a3.js";import"./quill-hi.978f208d.js";import"./parchment.d5c5924e.js";import"./quill-delta.4f1e4697.js";import"./fast-diff.f17881f3.js";import"./lodash.clonedeep.8fb065bb.js";import"./lodash.isequal.84238944.js";import"./eventemitter3.78b735ad.js";import"./lodash-es.df04b444.js";import"./quill-mention-hi.2a6582e4.js";import"./view-design-hi.e9360295.js";import"./html-to-md.96d5de37.js";import"./lodash.2fa8f497.js";import"./vue-router.2d566cd7.js";import"./vue-clipboard2.89894d1b.js";import"./clipboard.6caea48c.js";import"./vuedraggable.e8809463.js";import"./sortablejs.b1e23af3.js";import"./vue-resize-observer.df5b985e.js";import"./element-sea.9d03b085.js";import"./deepmerge.cecf392e.js";import"./resize-observer-polyfill.18409ee0.js";import"./throttle-debounce.7c3948b2.js";import"./babel-helper-vue-jsx-merge-props.5ed215c3.js";import"./normalize-wheel.2a034b9f.js";import"./async-validator.e1b191c9.js";import"./babel-runtime.4773988a.js";import"./core-js.314b4a1d.js";var l=function(){var t=this,e=t.$createElement,i=t._self._c||e;return i("div",{staticClass:"drawio-content"},[i("IFrame",{ref:"frame",staticClass:"drawio-iframe",attrs:{src:t.url},on:{"on-message":t.onMessage}}),t.loadIng?i("div",{staticClass:"drawio-loading"},[i("Loading")],1):t._e()],1)},d=[];const u={name:"Drawio",components:{IFrame:m},props:{value:{type:Object,default:function(){return{}}},title:{type:String,default:""},readOnly:{type:Boolean,default:!1}},data(){return{loadIng:!0,url:null,bakData:""}},created(){let t=o;switch(o){case"zh-CHT":t="zh-tw";break}let e=this.readOnly?1:0,i=this.readOnly?0:1,n=this.themeName==="dark"?"dark":"kennedy",r=`?title=${this.title?encodeURIComponent(this.title):""}&chrome=${i}&lightbox=${e}&ui=${n}&lang=${t}&offline=1&pwa=0&embed=1&noLangIcon=1&noExitBtn=1&noSaveBtn=1&saveAndExit=0&spin=1&proto=json`;this.$Electron?this.url=$A.originUrl(`drawio/webapp/index.html${r}`):this.url=$A.mainUrl(`drawio/webapp/${r}`)},mounted(){window.addEventListener("message",this.handleMessage)},beforeDestroy(){window.removeEventListener("message",this.handleMessage)},watch:{value:{handler(t){this.bakData!=$A.jsonStringify(t)&&(this.bakData=$A.jsonStringify(t),this.updateContent())},deep:!0}},computed:{...s(["themeName"])},methods:{formatZoom(t){return t+"%"},updateContent(){this.$refs.frame.postMessage(JSON.stringify({action:"load",autosave:1,xml:this.value.xml}))},onMessage(t){switch(t.event){case"init":this.loadIng=!1,this.updateContent();break;case"load":typeof this.value.xml=="undefined"&&this.$refs.frame.postMessage(JSON.stringify({action:"template"}));break;case"autosave":const e={xml:t.xml};this.bakData=$A.jsonStringify(e),this.$emit("input",e);break;case"save":this.$emit("saveData");break}}}},a={};var c=p(u,l,d,!1,h,"39021859",null,null);function h(t){for(let e in a)this[e]=a[e]}var pt=function(){return c.exports}();export{pt as default};
|
||||
import{m as s}from"./vuex.cc7cb26e.js";import{I as m}from"./IFrame.4b35a35f.js";import{n as p,l as o}from"./app.003a6843.js";import"./jquery.de0a5c6b.js";import"./@babel.9410f858.js";import"./dayjs.169453f2.js";import"./localforage.1d5f26a4.js";import"./markdown-it.0450edb4.js";import"./mdurl.ce6c1dd8.js";import"./uc.micro.8d343c98.js";import"./entities.48a44fec.js";import"./linkify-it.c5e8196e.js";import"./punycode.js.4b3f125a.js";import"./highlight.js.cbbfb885.js";import"./markdown-it-link-attributes.e1d5d151.js";import"./@traptitech.acea8861.js";import"./vue.adba9046.js";import"./openpgp_hi.15f91b1d.js";import"./axios.37c7f908.js";import"./mitt.1ea0a2a3.js";import"./quill-hi.ca2ea0cc.js";import"./parchment.d5c5924e.js";import"./quill-delta.385a10bf.js";import"./fast-diff.f17881f3.js";import"./lodash.clonedeep.3cc09a31.js";import"./lodash.isequal.dbdc2157.js";import"./eventemitter3.78b735ad.js";import"./lodash-es.76e3a28b.js";import"./quill-mention-hi.4eeb5a2d.js";import"./view-design-hi.f1128b4d.js";import"./html-to-md.f297036e.js";import"./lodash.8fcd6fd4.js";import"./vue-router.2d566cd7.js";import"./vue-clipboard2.fd43a5bc.js";import"./clipboard.37b37361.js";import"./vuedraggable.f464b992.js";import"./sortablejs.3488b922.js";import"./vue-resize-observer.5af23a43.js";import"./element-sea.f8a64907.js";import"./deepmerge.cecf392e.js";import"./resize-observer-polyfill.5d591c5f.js";import"./throttle-debounce.7c3948b2.js";import"./babel-helper-vue-jsx-merge-props.5ed215c3.js";import"./normalize-wheel.2a034b9f.js";import"./async-validator.dca2b951.js";import"./babel-runtime.4773988a.js";import"./core-js.314b4a1d.js";var l=function(){var t=this,e=t.$createElement,i=t._self._c||e;return i("div",{staticClass:"drawio-content"},[i("IFrame",{ref:"frame",staticClass:"drawio-iframe",attrs:{src:t.url},on:{"on-message":t.onMessage}}),t.loadIng?i("div",{staticClass:"drawio-loading"},[i("Loading")],1):t._e()],1)},d=[];const u={name:"Drawio",components:{IFrame:m},props:{value:{type:Object,default:function(){return{}}},title:{type:String,default:""},readOnly:{type:Boolean,default:!1}},data(){return{loadIng:!0,url:null,bakData:""}},created(){let t=o;switch(o){case"zh-CHT":t="zh-tw";break}let e=this.readOnly?1:0,i=this.readOnly?0:1,n=this.themeName==="dark"?"dark":"kennedy",r=`?title=${this.title?encodeURIComponent(this.title):""}&chrome=${i}&lightbox=${e}&ui=${n}&lang=${t}&offline=1&pwa=0&embed=1&noLangIcon=1&noExitBtn=1&noSaveBtn=1&saveAndExit=0&spin=1&proto=json`;this.$Electron?this.url=$A.originUrl(`drawio/webapp/index.html${r}`):this.url=$A.mainUrl(`drawio/webapp/${r}`)},mounted(){window.addEventListener("message",this.handleMessage)},beforeDestroy(){window.removeEventListener("message",this.handleMessage)},watch:{value:{handler(t){this.bakData!=$A.jsonStringify(t)&&(this.bakData=$A.jsonStringify(t),this.updateContent())},deep:!0}},computed:{...s(["themeName"])},methods:{formatZoom(t){return t+"%"},updateContent(){this.$refs.frame.postMessage(JSON.stringify({action:"load",autosave:1,xml:this.value.xml}))},onMessage(t){switch(t.event){case"init":this.loadIng=!1,this.updateContent();break;case"load":typeof this.value.xml=="undefined"&&this.$refs.frame.postMessage(JSON.stringify({action:"template"}));break;case"autosave":const e={xml:t.xml};this.bakData=$A.jsonStringify(e),this.$emit("input",e);break;case"save":this.$emit("saveData");break}}}},a={};var c=p(u,l,d,!1,h,"39021859",null,null);function h(t){for(let e in a)this[e]=a[e]}var pt=function(){return c.exports}();export{pt as default};
|
||||
File diff suppressed because one or more lines are too long
1
public/js/build/FilePreview.92fbe21e.js
vendored
Normal file
1
public/js/build/FilePreview.92fbe21e.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
public/js/build/FilePreview.cb6f2328.js
vendored
1
public/js/build/FilePreview.cb6f2328.js
vendored
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
import{n}from"./app.5029512e.js";var i=function(){var e=this,s=e.$createElement,r=e._self._c||s;return r("iframe",{directives:[{name:"show",rawName:"v-show",value:e.src,expression:"src"}],ref:"iframe",attrs:{src:e.src}})},a=[];const o={name:"IFrame",props:{src:{type:String,default:""}},mounted(){this.$refs.iframe.addEventListener("load",this.handleLoad),window.addEventListener("message",this.handleMessage)},beforeDestroy(){this.$refs.iframe.removeEventListener("load",this.handleLoad),window.removeEventListener("message",this.handleMessage)},methods:{handleLoad(){this.$emit("on-load")},handleMessage({data:e,source:s}){var r;s===((r=this.$refs.iframe)==null?void 0:r.contentWindow)&&(e=$A.jsonParse(e),e.source==="fileView"&&e.action==="picture"&&this.$store.dispatch("previewImage",{index:e.params.index,list:e.params.array}),this.$emit("on-message",e))},postMessage(e,s="*"){this.$refs.iframe&&this.$refs.iframe.contentWindow.postMessage(e,s)}}},t={};var m=n(o,i,a,!1,c,null,null,null);function c(e){for(let s in t)this[s]=t[s]}var l=function(){return m.exports}();export{l as I};
|
||||
import{n}from"./app.003a6843.js";var i=function(){var e=this,s=e.$createElement,r=e._self._c||s;return r("iframe",{directives:[{name:"show",rawName:"v-show",value:e.src,expression:"src"}],ref:"iframe",attrs:{src:e.src}})},a=[];const o={name:"IFrame",props:{src:{type:String,default:""}},mounted(){this.$refs.iframe.addEventListener("load",this.handleLoad),window.addEventListener("message",this.handleMessage)},beforeDestroy(){this.$refs.iframe.removeEventListener("load",this.handleLoad),window.removeEventListener("message",this.handleMessage)},methods:{handleLoad(){this.$emit("on-load")},handleMessage({data:e,source:s}){var r;s===((r=this.$refs.iframe)==null?void 0:r.contentWindow)&&(e=$A.jsonParse(e),e.source==="fileView"&&e.action==="picture"&&this.$store.dispatch("previewImage",{index:e.params.index,list:e.params.array}),this.$emit("on-message",e))},postMessage(e,s="*"){this.$refs.iframe&&this.$refs.iframe.contentWindow.postMessage(e,s)}}},t={};var m=n(o,i,a,!1,c,null,null,null);function c(e){for(let s in t)this[s]=t[s]}var l=function(){return m.exports}();export{l as I};
|
||||
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 @@
|
||||
import{n as r}from"./app.5029512e.js";var a=function(){var t=this,n=t.$createElement,e=t._self._c||n;return t.windowTouch?e("div",[e("Button",{attrs:{loading:t.loading,type:"primary",icon:"ios-search"},on:{click:t.onSearch}},[t._v(t._s(t.$L("\u641C\u7D22")))]),t.filtering?e("Button",{attrs:{type:"text"},on:{click:t.onCancelFilter}},[t._v(t._s(t.$L("\u53D6\u6D88\u7B5B\u9009")))]):e("Button",{attrs:{loading:t.loading,type:"text",icon:"md-refresh"},on:{click:t.onRefresh}},[t._v(t._s(t.$L("\u5237\u65B0")))])],1):e("Tooltip",{attrs:{theme:"light",placement:t.placement,"transfer-class-name":"search-button-clear",transfer:""}},[e("Button",{attrs:{loading:t.loading,type:"primary",icon:"ios-search"},on:{click:t.onSearch}},[t._v(t._s(t.$L("\u641C\u7D22")))]),e("div",{attrs:{slot:"content"},slot:"content"},[t.filtering?e("Button",{attrs:{type:"text"},on:{click:t.onCancelFilter}},[t._v(t._s(t.$L("\u53D6\u6D88\u7B5B\u9009")))]):e("Button",{attrs:{loading:t.loading,type:"text"},on:{click:t.onRefresh}},[t._v(t._s(t.$L("\u5237\u65B0")))])],1)],1)},i=[];const l={name:"SearchButton",props:{loading:{type:Boolean,default:!1},filtering:{type:Boolean,default:!1},placement:{type:String,default:"bottom"}},methods:{onSearch(){this.$emit("search")},onRefresh(){this.$emit("refresh")},onCancelFilter(){this.$emit("cancelFilter")}}},o={};var s=r(l,a,i,!1,c,null,null,null);function c(t){for(let n in o)this[n]=o[n]}var h=function(){return s.exports}();export{h as S};
|
||||
import{n as r}from"./app.003a6843.js";var a=function(){var t=this,n=t.$createElement,e=t._self._c||n;return t.windowTouch?e("div",[e("Button",{attrs:{loading:t.loading,type:"primary",icon:"ios-search"},on:{click:t.onSearch}},[t._v(t._s(t.$L("\u641C\u7D22")))]),t.filtering?e("Button",{attrs:{type:"text"},on:{click:t.onCancelFilter}},[t._v(t._s(t.$L("\u53D6\u6D88\u7B5B\u9009")))]):e("Button",{attrs:{loading:t.loading,type:"text",icon:"md-refresh"},on:{click:t.onRefresh}},[t._v(t._s(t.$L("\u5237\u65B0")))])],1):e("Tooltip",{attrs:{theme:"light",placement:t.placement,"transfer-class-name":"search-button-clear",transfer:""}},[e("Button",{attrs:{loading:t.loading,type:"primary",icon:"ios-search"},on:{click:t.onSearch}},[t._v(t._s(t.$L("\u641C\u7D22")))]),e("div",{attrs:{slot:"content"},slot:"content"},[t.filtering?e("Button",{attrs:{type:"text"},on:{click:t.onCancelFilter}},[t._v(t._s(t.$L("\u53D6\u6D88\u7B5B\u9009")))]):e("Button",{attrs:{loading:t.loading,type:"text"},on:{click:t.onRefresh}},[t._v(t._s(t.$L("\u5237\u65B0")))])],1)],1)},i=[];const l={name:"SearchButton",props:{loading:{type:Boolean,default:!1},filtering:{type:Boolean,default:!1},placement:{type:String,default:"bottom"}},methods:{onSearch(){this.$emit("search")},onRefresh(){this.$emit("refresh")},onCancelFilter(){this.$emit("cancelFilter")}}},o={};var s=r(l,a,i,!1,c,null,null,null);function c(t){for(let n in o)this[n]=o[n]}var h=function(){return s.exports}();export{h as S};
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user