Compare commits

...

47 Commits
v1.8.45 ... pro

Author SHA1 Message Date
kuaifan
08da0b8976 fix: 修复项目目录和静态文件权限 2026-07-24 16:20:10 +08:00
kuaifan
b914a99b62 fix(dashboard): 更新仪表盘警告信息样式,使用粘性定位 2026-07-21 19:38:40 +08:00
kuaifan
4c9951c667 feat: 默认开启部门负责人视角 2026-07-21 09:06:36 +00:00
kuaifan
93c604a554 release: v1.8.89 2026-07-20 14:21:51 +00:00
kuaifan
e4e1cd9b82 fix(dashboard): 优化移动端个人视角布局 2026-07-20 14:08:12 +00:00
kuaifan
6f22fd2706 fix(ai-assistant): respect model visibility settings 2026-07-20 10:48:33 +00:00
kuaifan
44bd541d00 fix: 仪表盘待完成默认展开 2026-07-20 09:13:32 +00:00
kuaifan
95b20325be fix(i18n): normalize common action translations 2026-07-20 09:06:37 +00:00
kuaifan
df4658cddf feat(i18n): support contextual translation keys 2026-07-20 07:25:10 +00:00
kuaifan
df6c1fc04d refactor(i18n): 完成前端国际化改造 2026-07-20 02:35:07 +00:00
kuaifan
c9b3515aef refactor(manticore): 简化向量批量分块收尾 2026-07-19 07:01:19 +00:00
kuaifan
48c2c16111 docs(ai-kb): 同步仪表盘与负责人视角 2026-07-19 00:21:42 +00:00
kuaifan
71ca6ba249 feat(dashboard): 完善负责人视角界面与范围切换 2026-07-18 23:43:10 +00:00
kuaifan
7ad27e3326 feat(dashboard): 增加负责人视角真实数据接口 2026-07-18 23:42:58 +00:00
kuaifan
4b92ecb422 feat(dashboard): 完善个人视角空状态 2026-07-18 04:47:12 +00:00
kuaifan
6666605f7d feat: 完善管理后台团队仪表盘 2026-07-18 01:13:43 +00:00
kuaifan
844db09c12 fix(dialog): 撤回占位预存防闪动,重新编辑后占位保留至过期
- 确认撤回时先预存占位记录(原消息在列表时被去重压制不显示),消息移除的同一次重算中占位原地顶上,消除 WS 推送与接口响应竞争导致的先删后加闪动
- 撤回失败且消息仍在列表时才清除预存记录,兼容"接口报错但服务端实际已撤回"的情况
- 点"重新编辑"不再删除占位,保留至 5 分钟过期,期间可反复回填(对齐微信行为),同步更新 ai-kb 文档
2026-07-16 22:11:04 +00:00
kuaifan
f7bc724e13 feat(dialog): 撤回文本消息后本地显示"重新编辑"占位
- 撤回自己的文本/MD 消息后,原位置显示仅本人可见的"你撤回了一条消息 重新编辑"占位(仿微信)
- 占位纯本地存储(IndexedDB 持久化,Electron 多窗口 syncDispatch 同步),5 分钟过期自动清除
- 点"重新编辑"回填原内容到输入框(富文本还原 @提及/表情,先退出引用/编辑模式),占位随即移除
- 占位沿用原消息 id 排序定位,仅在已加载消息范围内合并显示,不干扰翻页与已读上报
- 同步更新 ai-kb recall 文档与前端语言文件
2026-07-16 11:20:37 +00:00
kuaifan
966c7b4ef0 feat(microapp): iframe 同源 HTTP 5xx 检测与内联错误层,错误态强制显示胶囊
- iframe.vue 读 responseStatus 检测同源 5xx(宁可漏判不误判),与原生 error 统一走 load-error
- index.vue 新增可读的内联错误层(重试/关闭),覆盖深色下被反色成一条线的错误页
- 检测到错误时强制显示胶囊,保证关闭/更多入口
2026-07-16 06:26:39 +00:00
kuaifan
f8eaab5a50 fix(search): 评审修复——重建自愈加探针表与锁内复检,向量模型变更自动重建
- 重建前用探针表验证 ai 端点,探不通不动真表不写 marker(下次自愈重试)
- 锁内复检 marker,消除并发进程重复重建窗口
- 检测 embedding 模型变更(vector:model 对比查询侧缓存)自动触发重建
- Ihttp 返回体 code 非 0/200 时视为失败(401/500 不再被当成功)
- 查询向量缓存键掺入模型指纹,换模型后不再命中旧维度缓存
- 批量写入按行数+字节双预算分块、块内延迟渲染 SQL,降低峰值内存
- user 同步加脏检查,字段未变不重复触发向量重算
- 日志截断超长 SQL;文档与注释同步 KB_INGEST_TOKEN 退役、截断长度对齐
2026-07-15 14:17:01 +00:00
kuaifan
746337c2f6 feat(search): 语义搜索迁移至 Manticore Auto Embeddings,向量走 ai 插件免费模型
- embedding 统一改走 ai 插件 /embeddings(免费向量模型,1024 维),删除第三方 key 分支
- 5 张向量表启用引擎 Auto Embeddings(MODEL_NAME/FROM/API_URL),写入/更新时引擎自动生成向量
- 表定义使用派生密钥 sha256(APP_KEY:embeddings),避免主密钥进入引擎元数据
- 写入改为 REPLACE(单行原子)与多行批量 REPLACE(整语句一次向量化调用,失败回退逐行)
- 结构指纹 vector:schema 标记:版本/模型/端点/APP_KEY 变化时自动清空重建并重置同步指针
- 退役 PHP 向量管道:删除 generate-vectors 命令、双指针、withVector 分支与批量向量生成方法
- 根治缺陷:全文重同步不再抹掉向量;内容编辑后向量由引擎自动重算,不再过期
2026-07-15 10:45:20 +00:00
kuaifan
e4fbf9693a fix(http): Ihttp 响应头解析前初始化 headers 数组,消除未定义索引告警 2026-07-15 10:45:01 +00:00
kuaifan
13ba1d3bd7 feat(file): 我的文件纳入已共享文件,修复板块切换缓存丢失
- 「我的文件」改为展示我拥有的全部(含已共享出去的),与「共享文件」在“我共享的”上刻意重叠
- 缓存改为 stale-while-revalidate:按板块懒加载,切回该板块/目录先读缓存再拉接口静态刷新
- getFiles 清理过期行按 scope 限定范围,避免刷新一个板块误删另一板块(同为 pid=0)的缓存
- 微调 file-tabs 来源筛选字号与面包屑上边距
- 同步 ai-kb 文件入口说明(last_verified v1.8.69)
2026-07-14 12:16:22 +00:00
kuaifan
f1ea0ed3dc release: v1.8.69 2026-07-14 09:47:03 +00:00
kuaifan
7b25f243ab feat(file): 文件页拆分「我的/共享」两个板块并合并工具栏为单行
- 顶部 Tab 切换:我的文件(私有 share=0)/ 共享文件(双向,含我共享出去的 + 别人共享给我的),共享板块支持「全部/我共享的/共享给我的」二次筛选
- 后端 File::getFileList 增加 scope 参数,按板块拆分根目录查询;FileController::lists 透传并校验
- 前端用 board/sharedSrc 取代原「仅显示我的」复选框,board 持久化到 IDB
- Tab、子筛选、视图切换合并到同一行(flex 自动换行,视图切换靠最右)
- 根目录不再重复显示板块标题,仅进入子文件夹时显示面包屑(板块名为可点击回根入口)
- 同步更新 ai-kb 文件入口说明
2026-07-14 03:49:59 +00:00
kuaifan
9b14120055 fix(application): 自定义应用可见范围统一 app 顶层,修复全员可见失效与编辑回填重置
前后端对 visible_to 存放层级约定不一致导致两个 bug:
- 新增应用设为全员可见,普通成员仍看不到(前端存在 menu 级,后端只认 app 级,落库恒回落为 admin)
- 再次编辑时可见范围被重置为"仅管理员"(响应无条件剥离 visible_to,回填丢失)

统一以 app 顶层为唯一权威来源:
- 前端保存时提到 app 顶层,回填时从 app 顶层读回(数组转下拉框字符串)
- 后端响应新增 keepVisible,管理员保存/读取场景保留 visible_to,普通成员仍剥离

同步 ai-kb application/menu-admin last_verified。
2026-07-13 23:54:23 +00:00
kuaifan
e72758f313 fix(task): 已删除/已归档项目不再生成周期任务,残留任务详情可打开可删除
- LoopTask 周期生成仅处理未删除、未归档项目的任务,从源头杜绝残留繁殖
- userTask 兜底改用 withTrashed,任务相关成员可打开已删项目下的任务详情
- task/one 返回 project_deleted/project_archived 标记,详情顶部横幅提示并提供删除按钮
- task/remove 对已删除/已归档项目放行删除(限项目负责人、任务负责人/协助人/创建人)
- 顺手修复 addTask 优先级匹配 reset() 传临时值的 notice 隐患,改用 collect()->first()
- 同步 ai-kb(recurring/delete-restore)与国际化文案
2026-07-13 23:04:36 +00:00
kuaifan
746a0bf9a1 feat(ai-bot): 增加 AIBotModels2Array 方法的可见性过滤选项 2026-07-13 13:33:11 +00:00
kuaifan
18163e1e82 release: v1.8.64 2026-07-02 14:01:34 +00:00
kuaifan
9a3d9d3b9c feat(license): 多授权选择登录 + 离线 license SN/MAC 绑定校验加固
- 在线授权登录时账号名下有多条可用授权则弹窗选择,选定后复用验证码确认签发(login/confirm)
- 新增 Doo::licenseDecode / licenseBindingError;保存离线 license 与创建用户前校验付费档 SN/MAC 与本机匹配
- 诊断展示与校验门槛纳入不限人数(people==0)档
2026-07-02 13:29:51 +00:00
kuaifan
c9f5296b73 feat(license): 在线异常浮告警卡+离线SN/MAC标签化,统一两Tab排版
- 在线:正常态只显示核心信息,提醒/过期/设备不匹配时浮出告警卡,SN/MAC 收进折叠诊断;frozen 状态文案统一为「已过期」
- 离线:SN/MAC 改行尾标签+失配整行标红,IP/域名/创建时间折叠进「更多」
- 统一在线/离线信息行的字号/行高/间距/label
- 清理死样式 .information 与死变量 onlineIng
- 同步 ai-kb online.md 状态措辞并登记新增文案
2026-07-02 07:45:16 +00:00
kuaifan
f45f86601e fix(ai-assistant): Modal 模式移动端恢复居中弹框,不再沉浸全屏
移动端仅聊天(chat)浮窗保留全屏沉浸,Modal 模式两端统一为居中模态,
回退 f6067d1bd 对 Modal 的移动端全屏处理;删除随之失效的
.ai-assistant-modal .is-mobile-fullscreen 样式块。
2026-07-01 07:39:50 +00:00
kuaifan
184fb27680 feat(upload): 分片上传统一链路,5 场景突破单文件 1G 限制
- 新增 api/upload/{init,chunk,merge} 入口与 ChunkUpload 模块(5MB 分片、3 并发、Redis 状态机)
- 5 场景接入:文件柜 / 聊天 / 任务附件 / 头像&系统图片 / 编辑器粘贴
- 秒传:同用户同 hash 复用 FileContent 物理文件,零传输
- 续传:Redis + localStorage 双索引,24h TTL
- 与老接口对齐:pid 锁、≤300 上限、webkit_relative_path 目录递归、overwrite 替换语义
- init 阶段读 file_upload_limit 拦截超限,避免传完分片才报错
- DeleteTmpTask 加 tmp_chunks case 兜底清理 24h 未合并目录
- files 表新增 hash 列(migration)
- 前端 chunkedUpload wrapper:主线程 spark-md5 流式 + 指数退避重试
- ai-kb 同步:upload / file-upload-limit / upload-size-limit 三个 chunk
2026-06-30 04:30:09 +00:00
kuaifan
c04187fe47 fix(appstore): 更新应用商店镜像版本至 0.5.4 2026-06-29 15:45:46 +00:00
kuaifan
c067991e3e fix(users): 登录验证码图片在 intervention/image v4 下返回 {} 无法加载
mews/captcha 3.5 的 toDataUri() 在 intervention/image v4 返回 DataUri 对象而非
字符串,json 序列化成 {} 导致前端 img 加载失败。控制器侧强制转为字符串修复。
2026-06-29 13:56:30 +00:00
kuaifan
b589307ebb perf(apps): 角标快照判定改用 isInstalled,自定义微应用一次性建 set
userBadges 原先用 Apps::appMenuConfig() 判定应用是否存在,每个不重复
app_id 都要解析两个 yaml(loadInstalledConfig + readPluginMenus),且
自定义微应用每行都重新 foreach setting。

- 插件应用走 Apps::isInstalled(只读一个 yaml + RequestContext 请求级缓存)
- 自定义微应用循环外一次性建 id set,O(1) 命中
- 行为不变(已在线实测):installed 时正常返回;status=uninstalled 时过滤
2026-06-29 13:38:05 +00:00
kuaifan
4a18ff0315 fix(apps): 角标快照过滤已卸载应用的残留行
应用卸载后 app_badges 表行未被清理(主程序侧无卸载回调),
导致 hydrate 后父级『应用』入口聚合仍把残留 count 算进去。

userBadges 返回前用 Apps::appMenuConfig() 判定应用是否仍存在
(同时覆盖插件应用与自定义微应用),不存在则跳过;按 app_id 缓
存判定结果,避免逐行重复读 yaml。
2026-06-29 12:16:19 +00:00
kuaifan
09439b555c fix(apps): 插件菜单角标补初始同步接口 badge/list
插件应用菜单来自 appstore installed(不含 count/dot),原先只有自定义微
应用经 microapp_menu 注入角标,导致插件角标只能靠实时 WS、刷新即丢。

- 新增 GET api/apps/badge/list(用户 token 鉴权)返回 userBadges 快照
  (插件 + 自定义统一覆盖),前端 updateMicroAppsStatus 拉取后 hydrateMap
- appBadges store hydrate -> hydrateMap(按角标 map 初始化,不再依赖 menu_items)
- 删除已冗余的 Badge::attachMenuBadges(调用 + 方法定义)
2026-06-29 11:57:18 +00:00
kuaifan
420d46d5cc feat(apps): 新增应用菜单角标(数字/红点,per-user 实时推送)
插件/微应用可在自己的菜单入口显示数字或红点角标,插件未打开也生效。

- 后端:新增 app_badges 表 + AppBadge 模型 + Module/Badge 业务编排 +
  AppsController(badge__set 应用密钥鉴权 / badge__clear 用户鉴权)
- 每应用独立密钥 APP_SECRET:按 appid 持久化于 appstore config.yml,鉴权校验
- 推送:复用 PushTask 下发 appBadge WS 消息;microapp_menu 附带初始角标
- 前端:appBadges Vuex module + WS 处理 + 三处菜单渲染(应用卡片/主菜单入口/
  父『应用』入口聚合)+ 移动端 Tabbar + 打开即清(badge_clear_on_open)
- 用户离职级联清理;同步 ai-kb 角标知识
2026-06-29 02:32:19 +00:00
kuaifan
eb672eaef1 fix(file): 文件管理删除入口收敛为所有者/创建者,拦截只读用户
- file.vue:右键「删除」项及工具栏批量「删除」按钮对非所有者/创建者禁用,
  与后端 file/remove 的 permissionFind(...,1000) 规则对齐(修复只读用户可见可点删除入口)
- 同步 ai-kb:delete-restore chunk 更正前置权限为「所有者或创建者」并补只读无权限说明
2026-06-27 03:11:01 +00:00
kuaifan
bc0fe9c748 feat(ai): AI 助手模型下拉框将 Doo AI 分组排在最前
调整 AIBotMap 键顺序,使 dooai 分组优先于其他服务商展示。
2026-06-26 02:33:01 +00:00
kuaifan
c4904bdbe2 chore(appstore): 升级 appstore 镜像至 0.5.3 2026-06-26 01:26:04 +00:00
kuaifan
b2d54576d0 fix(system): demo 环境豁免 dooai_key 打码,修复 /ai/gateway/* 401
system_setting=disabled 时按 _key 结尾统一打码会误伤官方网关 token
(dooai_key),前端读出打码串后调 /ai/gateway/me 等接口被网关判为非法
token 返回 401。将 dooai_key 从打码规则中单独豁免,原样返回供鉴权。
2026-06-26 01:16:43 +00:00
kuaifan
da36d3b319 fix(cmd): 修复安装 nginx 未自动启动、卸载残留命名卷、root 更新被 git 拦截
- install/update: 新增 wait_php_healthy,等 php 健康后兜底拉起 nginx,
  规避 depends_on 时序竞态导致"显示完成却访问不到"
- uninstall: down 增加 --volumes,清除 shared_data/redis_data 命名卷
- update: git 操作前加幂等 safe.directory 白名单,规避以 root 操作普通
  用户克隆仓库时被 git 归属检查拦截

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 06:51:21 +00:00
kuaifan
e5a88c2957 feat: 新增 bin/install 一键安装/升级脚本并支持 CLI 双语
- bin/install:curl 一行命令按当前目录自动判断 空目录安装 / 续装 / 升级;升级时取线上最新 cmd 执行,规避旧 cmd 导致的两次升级;输出按 locale 中英双语,判空时忽略系统垃圾文件
- cmd:所有用户可见输出按 locale 中英双语(msg 查表 + (*) 占位),业务逻辑不变
- README / README_CN:安装段补充一键脚本命令、升级段补充一键命令并移除升级重试提示;删除 0.x 迁移到 1.x 章节

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 02:45:21 +00:00
kuaifan
0896f09878 fix: 覆盖一次性 PHP 容器入口命令 2026-06-23 15:55:23 +00:00
kuaifan
7ca85bfe6b fix: 使用一次性 PHP 容器执行更新命令 2026-06-23 15:15:56 +00:00
345 changed files with 16160 additions and 5145 deletions

View File

@ -1,64 +1,79 @@
---
name: dootask-fix-permission
description: 修复 DooTask 可写目录bootstrap/cache、docker、public、storage的属主/权限chown 回当前用户 + 目录 chmod 775对齐 install 的赋权逻辑,赋权不删数据。
description: 修复 DooTask 整个项目的目录和文件权限:根目录 chmod 755bootstrap/cache、docker、public、storage chown 回调用用户且目录 chmod 775public 文件补充所有用户读权限。用于 Nginx 静态文件 403/Permission denied、install/build EACCES 或可写目录检测失败;优先使用 sudo ./cmd permission,赋权不删数据。
---
# DooTask 目录权限修复
容器内进程常以 **root** 写入挂载目录(`storage``public/uploads``bootstrap/cache` 等),导致宿主机当前用户对这些文件**没有写权限**,进而触发
项目根目录如果缺少 `x` 穿越权限Nginx 即使看到 `public` 中的文件也无法访问。容器内进程常以 **root** 写入挂载目录(`storage``public/uploads``bootstrap/cache` 等),导致宿主机当前用户没有写权限。常见现象
- `./cmd install` 报「目录【xxx】权限不足」/ 目录权限检测失败
- `./cmd build`vite`EACCES: permission denied, copyfile`(复制 `public/uploads/...` 时)
- Nginx 日志出现 `stat() failed (13: Permission denied)`,静态文件请求被回退到 Laravel 首页
- `./cmd install` 报「目录【xxx】权限不足」/目录权限检测失败
- `./cmd build`vite`EACCES: permission denied, copyfile`
- Laravel 运行时写 `storage`/`bootstrap/cache` 失败
本技能**对齐 `./cmd install` 的目录赋权逻辑**:对四个可写目录做 `chmod 775`(目录)+ `chown` 回当前用户
对齐 `./cmd permission`/`./cmd install` 的赋权逻辑:项目根目录设为 `755`;四个可写目录做 `chmod 775`(仅目录)+ `chown` 回调用 sudo 的用户;`public` 普通文件用 `a+r` 补充 Nginx 所需读权限
## 适用目录
与 install 一致的四个:
```
```text
. # 项目根目录,只修本层为 755
bootstrap/cache
docker
public # 含 public/uploads真实上传数据
public # 目录 775普通文件 a+r含真实上传数据
storage
```
## 核心原则:赋权,不删数据
`public/uploads` 含真实上传文件(头像、附件等)**永远优先 `chown` 改属主,不要删数据。** 即便用户说"清理一下",也只允许清临时目录 `public/uploads/tmp`**切勿**删 uploads 下其他内容。
`public/uploads` 含真实上传文件。永远优先 `chown` 改属主,不要删数据。即便用户说“清理一下”,也只允许清理临时目录 `public/uploads/tmp`,切勿删除 uploads 下其他内容。
## 前置检查
1. **工作目录**:在项目根(存在 `cmd` 且这四个目录在)
2. **sudo**:改属主需 root当前文件多为 root 属主)。本机一般可免密 sudo不行则经 docker 以 root 改权限
3. 确认要修的范围:默认四个目录全修;若用户只想解 build 报错,也可只针对 `public`(含 `public/uploads`
1. 在项目根目录执行,确认存在 `cmd` 和上述四个目录。
2. 用 `ls -ld .` 检查项目根目录是否缺少组/其他用户的 `x` 穿越权限。
3. 确认可使用 sudo改 root 属主的文件或目录需要 root 权限。
4. 用 `find public -type f ! -perm -004 -print` 检查 Nginx 用户可能无法读取的静态文件。
5. 默认修复项目根目录、四个可写目录和 `public` 文件;若用户只想解决 build 的 uploads 报错,可只处理 `public/uploads`
检查通过后汇报将执行的命令,**向用户确认一次**再执行。
检查通过后汇报将执行的命令,向用户确认一次再执行。
## 执行
确认后执行(属主修回当前用户,目录权限 775
确认后优先执行独立权限修复命令(不依赖 Docker 正在运行
```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 {} \;
sudo ./cmd permission
```
> 只想解 build 的 uploads 报错时,可只对 `public`
> ```shell
> sudo chown -R "$(id -u):$(id -g)" public/uploads
> ```
旧版 `cmd` 没有 `permission` 命令时,手动执行:
执行后报告:改了哪些目录、属主/权限现状(可 `ls -ld` 抽查),并提示用户可重试之前失败的 install/build/update。
```shell
# 1) 保证 Nginx 可穿越项目根目录(不递归)
sudo chmod 755 .
# 2) 可写目录属主修回当前用户(递归)
sudo chown -R "$(id -u):$(id -g)" bootstrap/cache docker public storage
# 3) 可写目录权限 775仅目录
find bootstrap/cache docker public storage -type d -exec chmod 775 {} \;
# 4) public 普通文件只补充读权限,保留现有写入/执行位
find public -type f -exec chmod a+r {} \;
```
只想解决 build 的 uploads 报错时,可只执行:
```shell
sudo chown -R "$(id -u):$(id -g)" public/uploads
```
执行后用 `ls -ld . bootstrap/cache docker public storage` 抽查目录,并用 `find public -type f ! -perm -004 -print` 确认不再有缺少 others 读权限的静态文件。然后重试之前失败的静态文件访问、install/build/update。
## 失败处理
- `chown` 报权限不足 → 当前用户无 sudo 权限,提示用户用有 root 权限的账户,或经 docker 以 root 执行;不要静默跳过
- 任何步骤失败立即停止报告,不自动重试
- `chmod`/`chown` 报权限不足:立即停止,提示使用有 root 权限的账户,或经 docker 以 root 执行;不要静默跳过
- 任何步骤失败立即停止报告,不自动重试
## 禁止项
@ -66,11 +81,15 @@ find bootstrap/cache docker public storage -type d -exec chmod 775 {} \;
|---------|---------|
| build 报 uploads EACCES 就 `rm` 删文件 | `chown` 修属主,保留数据 |
| 删整个 `public/uploads` 清场 | 最多清 `public/uploads/tmp`,别碰真实上传数据 |
| 对文件无差别 `chmod 777` | 目录 `chmod 775` + `chown` 回当前用户即可 |
| 对文件无差别 `chmod 777` | 可写目录 `chmod 775` + `chown` 回当前用户 |
| 把 `public` 所有文件强制改为 `644` | 用 `chmod a+r` 只补读权限,保留现有权限位 |
| 递归 `chmod 755` 整个项目 | 只对项目根目录执行 `chmod 755 .` |
| 不加 sudo 直接 chown root 文件 | 改属主需 root |
## Red Flags —— 出现这些念头立即停下
## Red Flags
- "uploads 复制失败,删掉再 build" → 不,`chown` 赋权,不丢数据
- "777 一把梭最省事" → 不,按 install 的 775目录+ chown
- "权限不够就跳过这个目录" → 不,报告交用户处理 sudo
- “uploads 复制失败,删掉再 build” → 不,`chown` 赋权,不丢数据。
- “777 一把梭最省事” → 不,按 install 的 775目录+ chown。
- “根目录不可穿越,递归 chmod 全仓库” → 不,只修项目根目录为 755。
- “静态文件 403给整个项目所有文件加读权限” → 不,只对 `public` 普通文件执行 `a+r`
- “权限不够就跳过这个目录” → 不,报告交用户处理 sudo。

View File

@ -44,6 +44,8 @@ description: 从 `pro` 分支发布 DooTask 前端新版本:翻译 → 版本
多语言数据流:`language/original-{web,api}.txt`(原文/简体中文)→ 经翻译写入 `language/translate.json`(含 9 种语言)→ 生成 `public/language/{web,api}/*`
上下文翻译键用于保持短文案的界面宽度,同时区分不同语义,格式固定为 `[lower_snake_case].原文`,例如 `[weekday].一``[task_unit].个`。上下文前缀只参与翻译查找,不参与显示;运行时完整键缺失时会回退普通原文键。普通 key 的 `zh` 留空,上下文 key 的 `zh` 必须填写去掉前缀后的原文。
**1.1 检测差异**
```shell
@ -51,14 +53,15 @@ php .claude/skills/dootask-release/scripts/language.php diff
```
输出 JSON
- `formatErrorCount > 0`translate.json **已有条目**含 raw `(*)`/`(**)` key、字段结构错误、非法/不连续的参数编号或规范化重复 → **停止**,报告 `formatErrors`,交用户修复
- `regexErrorCount > 0`translate.json **已有条目**的占位符与某语言值不一致 → **停止**,报告 `regexErrors`,交用户修复(这是历史数据问题,不要自行猜测修改)
- `redundantCount > 0`translate.json 里有、但原文已删除的条目 → 仅作提示apply 时会自动剔除,不致命)
- `needsCount == 0`:无新文案 → **跳到 1.4 直接生成**
- `needsCount > 0``needs` 数组即待翻译清单,每项 `key` 已转成占位符形式(如 `(%T1)`)→ 进入 1.2
- `needsCount > 0``needs` 数组即待翻译清单,每项 `key` 已转成占位符形式(如 `(%T1)`;上下文 key 还会带自动填充的 `zh` → 进入 1.2
**1.2 翻译**
`needs` 里的每个 `key`,翻成 8 种语言(`zh` 留空、`key` 原样保留):`zh-CHT` `en` `ko` `ja` `de` `fr` `id` `ru`
`needs` 里的每个 `key`,翻成 8 种语言(`key` 原样保留):`zh-CHT` `en` `ko` `ja` `de` `fr` `id` `ru`普通 key 的 `zh` 留空;上下文 key 的 `zh` 使用 `needs` 已给出的原文,不得留空或改写。
要求:贴合「项目任务管理系统」语境;占位符 `(%T1)`/`(%M1)` 等原样保留、不可增删改,位置可随目标语言语序调整:
@ -68,7 +71,7 @@ php .claude/skills/dootask-release/scripts/language.php diff
| (%T1)提交的「(%M2)」待你审批 | '(%M2)' submitted by (%T1) is waiting for your approval |
把结果写成一个 JSON 数组文件(建议放 `/tmp/dootask-release-translated.json`,避免污染工作区),每个元素含全部 10 个字段,顺序为:
`key, zh, zh-CHT, en, ko, ja, de, fr, id, ru``zh``""`
`key, zh, zh-CHT, en, ko, ja, de, fr, id, ru`
```json
[
@ -76,13 +79,21 @@ php .claude/skills/dootask-release/scripts/language.php diff
]
```
上下文短词示例:
```json
[
{"key":"[task_unit].个","zh":"个","zh-CHT":"個","en":"tasks","ko":"개 작업","ja":"タスク","de":"Aufgaben","fr":"tâches","id":"tugas","ru":"задач"}
]
```
**1.3 合并进 translate.json**
```shell
php .claude/skills/dootask-release/scripts/language.php apply /tmp/dootask-release-translated.json
```
脚本会校验字段完整性与占位符完整性、追加新条目、剔除冗余项,并按项目原生格式写回 `translate.json`。任一条不合格会报错停止,按提示修正翻译后重试。
脚本会校验字段完整性、占位符完整性与上下文键格式,追加新条目、剔除冗余项,并按项目原生格式写回 `translate.json`参数化 key 必须使用 `(%T1)`/`(%M1)` 形式,禁止输入 raw `(*)`/`(**)`;编号须按出现顺序从 1 连续递增,各语言值可调整占位符顺序,但占位符的类型、编号和数量必须与 key 完全一致。上下文 key 必须使用 `[lower_snake_case].原文`,且 `zh` 必须与原文部分完全一致。输入 JSON 内相同或规范化后相同的 key 会被拒绝;已存在于 `translate.json` 的非待补项也会被拒绝,避免覆盖歧义。任一条不合格会明确报错停止,按提示修正翻译后重试。
**1.4 生成前端/后端语言文件**
@ -90,7 +101,7 @@ php .claude/skills/dootask-release/scripts/language.php apply /tmp/dootask-relea
php .claude/skills/dootask-release/scripts/language.php generate
```
`translate.json` 字节级重新生成 `public/language/web/*.js``public/language/api/*.json`(排序/转义与项目原生工具完全一致,正常情况下 diff 只包含本次新增条目)。
`translate.json` 字节级重新生成 `public/language/web/*.js``public/language/api/*.json`(排序/转义与项目原生工具完全一致,正常情况下 diff 只包含本次新增条目)。生成前会再次执行格式与占位符校验;存在 raw `(*)`/`(**)` key 或占位符错误时不会写入生成文件。
**1.5 报告**:用 `git status --short language public/language` 汇总本步改动,向用户报告新增了多少条翻译。

View File

@ -6,7 +6,8 @@
//
// 子命令:
// language.php diff
// —— 输出 JSONneeds(待翻译key 已转成 (%T1)/(%M1) 形式) / redundants(冗余,提示) / regexErrors(占位符错乱,致命)
// —— 输出 JSONneeds(待翻译key 已转成 (%T1)/(%M1) 形式) / redundants(冗余,提示)
// / formatErrors(raw 占位符、字段或编号错误,致命) / regexErrors(各语言占位符错乱,致命)
// language.php apply <translated.json>
// —— 把新翻译合并进 translate.json追加 + 剔除冗余),不生成 public 文件
// language.php generate
@ -41,43 +42,169 @@ function read_generateds(): array
return [$originals, $generateds];
}
// ---- 公共:构建 translations 映射normalizedKey -> obj并收集冗余/占位符错乱 ----
// ---- 公共:占位符与条目结构校验 ----
function parameter_tokens(string $value): array
{
preg_match_all('/\(%[TM]\d+\)/', $value, $matches);
return $matches[0];
}
function normalize_key(string $key): string
{
return preg_replace(["/\(%T\d+\)/", "/\(%M\d+\)/"], ["(*)", "(**)"], $key);
}
function context_source_key(string $key): ?string
{
if (preg_match('/^\[([a-z][a-z0-9_]*)]\.([\s\S]+)$/', $key, $matches)) {
return $matches[2];
}
return null;
}
function validate_original_contexts(array $originals): array
{
$errors = [];
foreach ($originals as $index => $key) {
if (preg_match('/^\[[^\]]+]\./', $key) && context_source_key($key) === null) {
$errors[] = [
'location' => "originals[index $index]",
'message' => "上下文翻译键格式错误:$key",
];
}
}
return $errors;
}
function validate_entry(array $obj, string $location, bool $requireTranslations): array
{
$formatErrors = [];
$regexErrors = [];
foreach ($GLOBALS['LANG_FIELDS'] as $field) {
if (!array_key_exists($field, $obj)) {
$formatErrors[] = ['location' => "$location.$field", 'message' => "缺少字段 $field"];
} elseif (!is_string($obj[$field])) {
$formatErrors[] = ['location' => "$location.$field", 'message' => "字段 $field 必须是字符串"];
} elseif ($requireTranslations && $field !== 'key' && $field !== 'zh' && $obj[$field] === '') {
$formatErrors[] = ['location' => "$location.$field", 'message' => "字段 $field 不得为空"];
}
}
if (!isset($obj['key']) || !is_string($obj['key'])) {
return [$formatErrors, $regexErrors];
}
$key = $obj['key'];
if (preg_match('/^\[[^\]]+]\./', $key)) {
$sourceKey = context_source_key($key);
if ($sourceKey === null) {
$formatErrors[] = ['location' => "$location.key", 'message' => "上下文翻译键格式错误:$key"];
} elseif (($obj['zh'] ?? null) !== $sourceKey) {
$formatErrors[] = [
'location' => "$location.zh",
'message' => "上下文翻译键必须填写原文:$sourceKey",
];
}
}
if (preg_match('/\(\*{1,2}\)/', $key)) {
$formatErrors[] = [
'location' => "$location.key",
'message' => "key 不得包含 raw (*)/(**),必须使用 (%T1)/(%M1)$key",
];
}
$withoutValidParameters = preg_replace('/\(%[TM]\d+\)/', '', $key);
if (str_contains($withoutValidParameters, '(%')) {
$formatErrors[] = ['location' => "$location.key", 'message' => "存在非法参数占位符:$key"];
}
$keyTokens = parameter_tokens($key);
foreach ($keyTokens as $index => $token) {
preg_match('/\d+/', $token, $number);
if ((int)$number[0] !== $index + 1) {
$formatErrors[] = [
'location' => "$location.key",
'message' => "参数编号必须按出现顺序从 1 连续递增:$key",
];
break;
}
}
$expected = $keyTokens;
sort($expected);
foreach ($GLOBALS['LANG_FIELDS'] as $field) {
if ($field === 'key' || !isset($obj[$field]) || !is_string($obj[$field]) || $obj[$field] === '') {
continue;
}
$actual = parameter_tokens($obj[$field]);
sort($actual);
if ($actual !== $expected) {
$regexErrors[] = [
'location' => "$location.$field",
'key' => $key,
'field' => $field,
'value' => $obj[$field],
'expected' => $expected,
'actual' => $actual,
'message' => "参数占位符缺失、类型或编号不一致",
];
}
}
return [$formatErrors, $regexErrors];
}
function print_validation_errors(array $formatErrors, array $regexErrors): void
{
foreach ($formatErrors as $error) {
fwrite(STDERR, "格式错误 {$error['location']}{$error['message']}\n");
}
foreach ($regexErrors as $error) {
fwrite(STDERR, "占位符错误 {$error['location']}{$error['message']}key={$error['key']}\n");
}
}
// ---- 公共:构建 translations 映射normalizedKey -> obj并收集冗余/格式/占位符错乱 ----
function build_translations(array $originals): array
{
$translations = [];
$redundants = [];
$regrror = [];
$regexErrors = [];
$formatErrors = validate_original_contexts($originals);
if (!file_exists("translate.json")) {
fwrite(STDERR, "translate.json not exists\n");
exit(1);
}
$tmps = json_decode(file_get_contents("translate.json"), true);
foreach ($tmps as $obj) {
if (!isset($obj['key'])) {
if (!is_array($tmps)) {
$formatErrors[] = ['location' => 'translate.json', 'message' => '根数据必须是 JSON 数组'];
return [$translations, $redundants, $regexErrors, $formatErrors];
}
foreach ($tmps as $index => $obj) {
$location = "translate.json[index $index]";
if (!is_array($obj)) {
$formatErrors[] = ['location' => $location, 'message' => '条目必须是对象'];
continue;
}
[$entryFormatErrors, $entryRegexErrors] = validate_entry($obj, $location, false);
$formatErrors = array_merge($formatErrors, $entryFormatErrors);
$regexErrors = array_merge($regexErrors, $entryRegexErrors);
if (!isset($obj['key']) || !is_string($obj['key'])) {
continue;
}
$currentKey = $obj['key'];
$originalKey = preg_replace(["/\(%T\d+\)/", "/\(%M\d+\)/"], ["(*)", "(**)"], $currentKey);
$originalKey = normalize_key($currentKey);
if (!in_array($originalKey, $originals)) {
$redundants[$originalKey] = $obj;
continue;
}
$translations[$originalKey] = $obj;
if (preg_match_all('/\(%[TM]\d+\)/', $currentKey, $matches)) {
foreach ($matches[0] as $match) {
foreach ($obj as $k => $v) {
if (empty($v)) {
continue;
}
if (!str_contains($v, $match)) {
$regrror[$originalKey] = ['key' => $currentKey, 'field' => $k, 'value' => $v, 'match' => $match];
continue 2;
}
}
}
if (isset($translations[$originalKey])) {
$formatErrors[] = [
'location' => $location,
'message' => "规范化后 key 重复:$originalKey",
];
continue;
}
$translations[$originalKey] = $obj;
}
return [$translations, $redundants, $regrror];
return [$translations, $redundants, $regexErrors, $formatErrors];
}
// ---- 公共:由 translate.json + originals 重新生成 public 文件 ----
@ -127,7 +254,7 @@ function generate(array $generateds, array $translations): void
if ($cmd === 'diff') {
[$originals, $generateds] = read_generateds();
[$translations, $redundants, $regrror] = build_translations($originals);
[$translations, $redundants, $regexErrors, $formatErrors] = build_translations($originals);
// 需要翻译的数据(对齐 translate.php 150-169占位符按单一计数器编号
$needs = [];
@ -147,20 +274,27 @@ if ($cmd === 'diff') {
$label = strlen($m[1]) > 1 ? "M" : "T";
return "(%" . $label . $c++ . ")";
}, $key);
$needsOut[] = ['key' => $converted];
$need = ['key' => $converted];
$contextSource = context_source_key($converted);
if ($contextSource !== null) {
$need['zh'] = $contextSource;
}
$needsOut[] = $need;
}
echo json_encode([
'needsCount' => count($needsOut),
'redundantCount' => count($redundants),
'regexErrorCount' => count($regrror),
'formatErrorCount' => count($formatErrors),
'regexErrorCount' => count($regexErrors),
'needs' => $needsOut,
'redundants' => array_keys($redundants),
'regexErrors' => array_values($regrror),
'formatErrors' => array_values($formatErrors),
'regexErrors' => array_values($regexErrors),
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
if (count($regrror) > 0) {
exit(2); // 已有数据占位符错乱,需先修复
if (count($formatErrors) > 0 || count($regexErrors) > 0) {
exit(2); // 已有数据格式或占位符错乱,需先修复
}
exit(0);
}
@ -172,9 +306,10 @@ if ($cmd === 'apply') {
exit(1);
}
[$originals, $generateds] = read_generateds();
[$translations, $redundants, $regrror] = build_translations($originals);
if (count($regrror) > 0) {
fwrite(STDERR, "translate.json 已有条目占位符错乱,请先修复再发版。\n");
[$translations, $redundants, $regexErrors, $formatErrors] = build_translations($originals);
if (count($formatErrors) > 0 || count($regexErrors) > 0) {
print_validation_errors($formatErrors, $regexErrors);
fwrite(STDERR, "translate.json 已有条目格式或占位符错误,请先修复再发版。\n");
exit(2);
}
@ -183,45 +318,49 @@ if ($cmd === 'apply') {
fwrite(STDERR, "translated.json 必须是数组\n");
exit(1);
}
$added = 0;
foreach ($incoming as $raw) {
foreach ($GLOBALS['LANG_FIELDS'] as $f) {
if (!array_key_exists($f, $raw)) {
fwrite(STDERR, "新翻译缺字段 \"$f\"" . json_encode($raw, JSON_UNESCAPED_UNICODE) . "\n");
exit(1);
}
$originalSet = array_flip($originals);
$incomingSeen = [];
$itemsToAdd = [];
foreach ($incoming as $index => $raw) {
if (!is_array($raw)) {
fwrite(STDERR, "新翻译 translated.json[index $index] 必须是对象\n");
exit(1);
}
// 占位符完整性key 里每个 (%T1)/(%M1) 必须出现在每个非空语言值里
if (preg_match_all('/\(%[TM]\d+\)/', $raw['key'], $m)) {
foreach ($m[0] as $match) {
foreach ($GLOBALS['LANG_FIELDS'] as $f) {
if ($f === 'key' || $f === 'zh') {
continue;
}
if (empty($raw[$f])) {
continue;
}
if (!str_contains($raw[$f], $match)) {
fwrite(STDERR, "占位符 $match 在字段 \"$f\" 缺失:{$raw['key']}\n");
exit(1);
}
}
}
[$incomingFormatErrors, $incomingRegexErrors] = validate_entry($raw, "translated.json[index $index]", true);
if (count($incomingFormatErrors) > 0 || count($incomingRegexErrors) > 0) {
print_validation_errors($incomingFormatErrors, $incomingRegexErrors);
exit(1);
}
// 规范化:固定字段顺序 + zh 置空
// 规范化:固定字段顺序;上下文翻译键保留 zh 作为实际显示原文
$item = [];
foreach ($GLOBALS['LANG_FIELDS'] as $f) {
$item[$f] = $f === 'zh' ? '' : $raw[$f];
$item[$f] = $f === 'zh' && context_source_key($raw['key']) === null ? '' : $raw[$f];
}
$originalKey = preg_replace(["/\(%T\d+\)/", "/\(%M\d+\)/"], ["(*)", "(**)"], $item['key']);
$originalKey = normalize_key($item['key']);
if (!isset($originalSet[$originalKey])) {
fwrite(STDERR, "新翻译 key 不在 original-web.txt/original-api.txt{$item['key']}\n");
exit(1);
}
if (isset($incomingSeen[$originalKey])) {
fwrite(STDERR, "新翻译输入内部重复translated.json[index {$incomingSeen[$originalKey]}] 与 translated.json[index $index] 规范化后均为「$originalKey」\n");
exit(1);
}
if (isset($translations[$originalKey])) {
fwrite(STDERR, "新翻译 key 已存在于 translate.json非待补项或存在覆盖歧义{$item['key']}(规范化:$originalKey\n");
exit(1);
}
$incomingSeen[$originalKey] = $index;
$itemsToAdd[$originalKey] = $item;
}
foreach ($itemsToAdd as $originalKey => $item) {
$translations[$originalKey] = $item;
$added++;
}
// array_values现有条目去冗余在前新条目追加在后
file_put_contents("translate.json", json_encode(array_values($translations), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
echo json_encode([
'added' => $added,
'added' => count($itemsToAdd),
'total' => count($translations),
'droppedRedundant' => count($redundants),
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
@ -230,7 +369,12 @@ if ($cmd === 'apply') {
if ($cmd === 'generate') {
[$originals, $generateds] = read_generateds();
[$translations] = build_translations($originals);
[$translations, $redundants, $regexErrors, $formatErrors] = build_translations($originals);
if (count($formatErrors) > 0 || count($regexErrors) > 0) {
print_validation_errors($formatErrors, $regexErrors);
fwrite(STDERR, "translate.json 存在格式或占位符错误,已停止生成。\n");
exit(2);
}
generate($generateds, $translations);
exit(0);
}

View File

@ -36,10 +36,8 @@ jobs:
- name: ESLint
run: npm run lint
# 存量缺失文案 93 条(见 scripts/check-language.mjs 输出),清零后移除 continue-on-error 改为强制
- name: Language Check
run: npm run check:lang
continue-on-error: true
phpunit:
runs-on: ubuntu-latest

View File

@ -2,6 +2,60 @@
All notable changes to this project will be documented in this file.
## [1.8.89]
### Features
- 管理后台仪表盘新增负责人视角,可按部门查看团队任务;个人视角的待办、逾期、今日和即将到期任务展示也更清晰。
- 语义搜索升级为更智能的自动向量检索,搜索相关内容更准确,内容更新后会自动保持索引同步。
- 撤回聊天文字或 Markdown 消息后支持「重新编辑」,可在有效期内恢复原内容继续修改。
- 微应用加载失败时会显示清晰的错误提示,并提供重试和关闭入口。
- 「我的文件」现在包含已共享文件,切换板块时优先展示缓存内容,查找文件更顺畅。
### Bug Fixes
- 修复 AI 助手模型隐藏设置未完全生效的问题,正在使用的模型被隐藏时会自动切换到可用模型。
- 修复仪表盘待完成分组默认展开状态不一致的问题。
- 修复撤回消息、文件切换及部分网络异常场景下的显示和状态同步问题。
### Performance
- 优化仪表盘移动端布局与任务分组样式,减少页面拥挤,浏览和操作更顺手。
## [1.8.69]
### Features
- 文件页全新分区:拆分「我的文件」与「共享文件」两个板块,共享板块还能进一步筛选「我共享的」和「共享给我的」;上方工具栏合并为一行,查找和切换更清爽。
- AI 助手支持隐藏模型:可将部分模型对普通用户隐藏,选择列表更简洁;当正在使用的模型被隐藏时,会自动切换到可用模型,避免选择异常。
### Bug Fixes
- 修复自定义应用「全员可见」设置有时不生效、以及再次编辑时可见范围被重置为「仅管理员」的问题。
- 修复已删除 / 已归档的项目仍会继续生成周期任务的问题;对这些项目下遗留的任务,相关成员现在也能正常打开详情并删除。
## [1.8.64]
### Features
- 大文件上传全面升级:上传单个文件不再受 1G 限制,文件柜、聊天、任务附件、头像与系统图片、编辑器粘贴等场景统一支持;相同文件可「秒传」无需重复上传,上传中断后也能从断点继续,更快更稳。
- 新增应用菜单角标:插件与微应用可在菜单入口显示数字或红点提醒,应用未打开也能收到,打开后自动清除,手机端底部导航同样支持。
- 登录支持多授权选择:当账号名下有多个可用授权时,登录时可弹窗选择本次要使用的授权。
- 授权页面重新排版:正常状态只展示核心信息,出现到期提醒、已过期或设备不匹配时会浮出醒目提示,授权详情可展开查看,状态更清晰。
- 新增一键安装 / 升级脚本:一行命令即可完成部署或升级,命令行提示同时支持中文与英文。
- AI 助手选择模型时将官方「Doo AI」分组排到下拉框最前选择更方便。
### Bug Fixes
- 修复部分环境下登录验证码图片无法显示的问题。
- 修复只读成员仍能看到并点击文件「删除」入口的问题,删除权限现与实际权限保持一致。
- 修复手机端 AI 助手在弹窗模式下显示异常的问题,恢复为居中弹框。
- 修复安装后网页服务偶尔未自动启动、卸载后残留数据、以管理员身份更新被拦截等部署问题。
### Performance
- 优化应用菜单角标的计算方式,减少重复读取,加载更快。
## [1.8.45]
### Features

View File

@ -68,6 +68,7 @@ Laravel 13 (LaravelS/Swoole, PHP 8.4) + Vue 2 (Vite) + Electron。开源任务/
- 新增用户可见文本须追加原文(简体中文)到:前端 `language/original-web.txt`,后端 `language/original-api.txt`(去重)
- 前端翻译用 `$L("文本")`,动态值用 `(*)` 占位:`$L('共(*)条', n)`——禁止拼接翻译
- 单字或短词因上下文不同可能产生歧义时,使用 `[lower_snake_case].原文` 上下文键,例如 `$L('[weekday].一')``$L('[task_unit].个')`;前缀只参与翻译查找,界面仍显示原文。此类键须完整登记到 `original-web.txt`,且 `translate.json``zh` 必须填写去除前缀后的原文;普通键的 `zh` 仍留空
## ai-kb 同步规则

View File

@ -9,14 +9,6 @@ English | **[中文文档](./README_CN.md)**
- Group Number: `546574618`
## 📍 Migration from 0.x to 1.x
- Please ensure to back up your data before upgrading!
- If the upgrade fails, try running `./cmd update` multiple times.
- If you encounter "Container xxx not found" during upgrade, run `./cmd reup` and then execute `./cmd update`.
- If you see a 502 error after upgrading, run `./cmd reup` to restart the services.
- If you encounter "Application 'xxx' not installed" after upgrading, log in with the admin account and install the relevant applications from the App Store.
## Installation Requirements
- Required: `Docker v20.10+` and `Docker Compose v2.0+`
@ -27,6 +19,16 @@ English | **[中文文档](./README_CN.md)**
### Deploy Project
**Option 1: One-line script (recommended)**
Run it in an empty directory to clone and install automatically; run it inside an existing installation to check and upgrade:
```bash
curl -fsSL https://raw.githubusercontent.com/kuaifan/dootask/pro/bin/install | bash
```
**Option 2: Manual deployment**
```bash
# 1、Clone the project to your local machine or server
@ -105,11 +107,18 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
**Note: Please backup your data before upgrading!**
Recommended: use the one-line script (run it inside an existing installation; it pulls the latest code and finishes the upgrade in a single run):
```bash
curl -fsSL https://raw.githubusercontent.com/kuaifan/dootask/pro/bin/install | bash
```
Or use the local command:
```bash
./cmd update
```
* Please retry if upgrade fails across major versions.
* If you encounter 502 errors after upgrade, run `./cmd reup` to restart services.
## Project Migration

View File

@ -9,14 +9,6 @@
- QQ群号: `546574618`
## 📍 0.x 迁移到 1.x
- 升级时请务必备份好数据!
- 如果升级失败请尝试执行 `./cmd update` 重试几次。
- 如果升级中出现 `没有找到 xxx 容器` 的提示,请运行 `./cmd reup` 后再执行 `./cmd update`
- 如果升级后出现502错误请运行 `./cmd reup` 重启服务即可。
- 如果升级后出现 `应用「xxx」未安装` 的提示,请使用管理员账号进入应用商店安装相关应用。
## 安装程序
- 必须安装:`Docker v20.10+``Docker Compose v2.0+`
@ -27,6 +19,16 @@
### 部署项目
**方式一:一键脚本(推荐)**
在空目录中执行即自动克隆并安装;在已安装目录中执行则自动检查并升级:
```bash
curl -fsSL https://raw.githubusercontent.com/kuaifan/dootask/pro/bin/install | bash
```
**方式二:手动部署**
```bash
# 1、克隆项目到您的本地或服务器
@ -105,11 +107,18 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
**注意:在升级之前请备份好你的数据!**
推荐使用一键脚本升级(在已安装目录中执行,自动拉取最新代码并完成升级,无需重复执行):
```bash
curl -fsSL https://raw.githubusercontent.com/kuaifan/dootask/pro/bin/install | bash
```
或使用本地命令:
```bash
./cmd update
```
* 跨越大版本升级失败时请重试执行一次。
* 如果升级后出现502请运行 `./cmd reup` 重启服务即可。
## 迁移项目

View File

@ -1,205 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Console\Commands\Traits\ManticoreSyncLock;
use App\Models\File;
use App\Models\Project;
use App\Models\ProjectTask;
use App\Models\User;
use App\Models\WebSocketDialogMsg;
use App\Module\Apps;
use App\Module\Manticore\ManticoreFile;
use App\Module\Manticore\ManticoreKeyValue;
use App\Module\Manticore\ManticoreMsg;
use App\Module\Manticore\ManticoreProject;
use App\Module\Manticore\ManticoreTask;
use App\Module\Manticore\ManticoreUser;
use Illuminate\Console\Command;
/**
* 异步向量生成命令
*
* 用于后台批量生成已索引数据的向量,与全文索引解耦
* 使用双指针追踪sync:xxxLastId全文已同步 vector:xxxLastId向量已生成
*
* 运行模式:
* - 持续处理直到所有待处理数据完成
* - 每批处理完成后休眠几秒,避免 API 过载
* - 定时器只作为兜底触发机制
*/
class GenerateManticoreVectors extends Command
{
use ManticoreSyncLock;
protected $signature = 'manticore:generate-vectors
{--type=all : 类型 (msg/file/task/project/user/all)}
{--batch=50 : 每批 embedding 数量}
{--sleep=3 : 每批处理后休眠秒数}
{--reset : 重置向量进度指针}';
protected $description = '批量生成 Manticore 已索引数据的向量';
/**
* 类型配置
*/
private const TYPE_CONFIG = [
'msg' => [
'syncKey' => 'sync:manticoreMsgLastId',
'vectorKey' => 'vector:manticoreMsgLastId',
'class' => ManticoreMsg::class,
'model' => WebSocketDialogMsg::class,
'idField' => 'id',
],
'file' => [
'syncKey' => 'sync:manticoreFileLastId',
'vectorKey' => 'vector:manticoreFileLastId',
'class' => ManticoreFile::class,
'model' => File::class,
'idField' => 'id',
],
'task' => [
'syncKey' => 'sync:manticoreTaskLastId',
'vectorKey' => 'vector:manticoreTaskLastId',
'class' => ManticoreTask::class,
'model' => ProjectTask::class,
'idField' => 'id',
],
'project' => [
'syncKey' => 'sync:manticoreProjectLastId',
'vectorKey' => 'vector:manticoreProjectLastId',
'class' => ManticoreProject::class,
'model' => Project::class,
'idField' => 'id',
],
'user' => [
'syncKey' => 'sync:manticoreUserLastId',
'vectorKey' => 'vector:manticoreUserLastId',
'class' => ManticoreUser::class,
'model' => User::class,
'idField' => 'userid',
],
];
public function handle(): int
{
if (!Apps::isInstalled("search")) {
$this->error("应用「Manticore Search」未安装");
return 1;
}
if (!Apps::isInstalled("ai")) {
$this->error("应用「AI」未安装无法生成向量");
return 1;
}
$this->registerSignalHandlers();
if (!$this->acquireLock()) {
return 1;
}
$type = $this->option('type');
$batchSize = intval($this->option('batch'));
$sleepSeconds = intval($this->option('sleep'));
$reset = $this->option('reset');
if ($type === 'all') {
$types = array_keys(self::TYPE_CONFIG);
} else {
if (!isset(self::TYPE_CONFIG[$type])) {
$this->error("未知类型: {$type}。可用类型: msg, file, task, project, user, all");
$this->releaseLock();
return 1;
}
$types = [$type];
}
// 持续处理直到所有类型都没有待处理数据
$round = 0;
do {
$round++;
$totalPending = 0;
foreach ($types as $t) {
if ($this->shouldStop) {
break;
}
$pending = $this->processType($t, $batchSize, $reset && $round === 1);
$totalPending += $pending;
}
// 如果还有待处理数据,休眠后继续
if ($totalPending > 0 && !$this->shouldStop) {
$this->info("\n--- 第 {$round} 轮完成,剩余 {$totalPending} 条待处理,{$sleepSeconds} 秒后继续 ---\n");
sleep($sleepSeconds);
$this->setLock(); // 刷新锁
}
} while ($totalPending > 0 && !$this->shouldStop);
$this->info("\n向量生成完成(共 {$round} 轮)");
$this->releaseLock();
return 0;
}
/**
* 处理单个类型的向量生成(每次处理一批)
*
* @param string $type 类型
* @param int $batchSize 每批数量
* @param bool $reset 是否重置进度
* @return int 剩余待处理数量
*/
private function processType(string $type, int $batchSize, bool $reset): int
{
$config = self::TYPE_CONFIG[$type];
// 获取进度指针
$syncLastId = intval(ManticoreKeyValue::get($config['syncKey'], 0));
$vectorLastId = $reset ? 0 : intval(ManticoreKeyValue::get($config['vectorKey'], 0));
if ($reset) {
ManticoreKeyValue::set($config['vectorKey'], 0);
$this->info("[{$type}] 已重置向量进度指针");
}
// 计算待处理范围
$pendingCount = $syncLastId - $vectorLastId;
if ($pendingCount <= 0) {
return 0;
}
// 获取待处理的 ID 列表(每次处理 batchSize * 5 条,让 generateVectorsBatch 内部再分批调用 API
$modelClass = $config['model'];
$idField = $config['idField'];
$fetchCount = $batchSize * 5;
$ids = $modelClass::where($idField, '>', $vectorLastId)
->where($idField, '<=', $syncLastId)
->orderBy($idField)
->limit($fetchCount)
->pluck($idField)
->toArray();
if (empty($ids)) {
return 0;
}
// 批量生成向量
$manticoreClass = $config['class'];
$successCount = $manticoreClass::generateVectorsBatch($ids, $batchSize);
$currentLastId = end($ids);
// 更新向量进度指针
ManticoreKeyValue::set($config['vectorKey'], $currentLastId);
$remaining = $pendingCount - count($ids);
$this->info("[{$type}] 处理 " . count($ids) . " 条,成功 {$successCount}ID: {$vectorLastId} -> {$currentLastId},剩余 {$remaining}");
// 刷新锁
$this->setLock();
return max(0, $remaining);
}
}

View File

@ -0,0 +1,94 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\User;
use App\Module\Badge;
use App\Module\Base;
use Request;
/**
* 插件 / 微应用相关接口。
*
* 动态路由routes/web.php
* api/apps/badge/set -> badge__set() 应用密钥鉴权,绝对设置/清除角标
* api/apps/badge/clear -> badge__clear() 当前用户 token 鉴权,清除自己的角标
* api/apps/badge/list -> badge__list() 当前用户 token 鉴权,拉取自己全部角标(初始同步)
*/
class AppsController extends AbstractController
{
/**
* @api {post} api/apps/badge/set 设置角标(应用密钥鉴权)
*
* @apiDescription 由插件服务端使用 APP_SECRET 调用,对 (appid, 菜单, 每个 userid) 绝对设置角标(幂等覆盖)。
* @apiVersion 1.0.0
* @apiGroup apps
* @apiName badge__set
*
* @apiParam {String} appid 应用ID
* @apiParam {String} secret 应用密钥APP_SECRET
* @apiParam {Number|Number[]} userid 目标用户ID单个或数组
* @apiParam {String} [menu_key] 菜单稳定标识;留空表示该应用第一个菜单
* @apiParam {Number} [count=0] 角标数字
* @apiParam {Boolean} [dot=false] 是否显示红点count=0 dot=false 即清除)
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息
* @apiSuccess {Object} data 返回数据
*/
public function badge__set()
{
return Base::retSuccess('success', Badge::set(
trim(Request::input('appid', '')),
trim(Request::input('secret', '')),
Request::input('userid'),
trim(Request::input('menu_key', '')),
Request::input('count', 0),
Request::input('dot', false)
));
}
/**
* @api {post} api/apps/badge/clear 清除角标(当前用户 token 鉴权)
*
* @apiDescription 供前端在 badge_clear_on_open=true 的菜单打开时调用,清除当前用户在该应用该菜单的角标。
* @apiVersion 1.0.0
* @apiGroup apps
* @apiName badge__clear
*
* @apiParam {String} appid 应用ID
* @apiParam {String} [menu_key] 菜单稳定标识;留空表示该应用第一个菜单
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息
* @apiSuccess {Object} data 返回数据
*/
public function badge__clear()
{
$user = User::auth();
return Base::retSuccess('success', Badge::clearForUser(
(int)$user->userid,
trim(Request::input('appid', '')),
trim(Request::input('menu_key', ''))
));
}
/**
* @api {get} api/apps/badge/list 拉取自己全部角标
*
* @apiDescription 供前端初始同步:返回当前用户全部应用(插件 + 自定义微应用)的角标快照。
* 数据结构 app_id => menu_key => {count, dot},与前端 store map 结构一致。
* @apiVersion 1.0.0
* @apiGroup apps
* @apiName badge__list
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息
* @apiSuccess {Object} data 返回数据
*/
public function badge__list()
{
$user = User::auth();
return Base::retSuccess('success', Badge::userBadges((int)$user->userid));
}
}

View File

@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\User;
use App\Module\Base;
use App\Module\DashboardTeam;
use Request;
/**
* @apiDefine dashboard
*
* 仪表盘
*/
class DashboardController extends AbstractController
{
/**
* @api {get} api/dashboard/team/stats 负责人视角统计
*
* @apiDescription 需要token身份。返回所选管理部门含下级部门的团队任务统计。
* @apiVersion 1.0.0
* @apiGroup dashboard
* @apiName team__stats
*
* @apiParam {String} [department_owner_ids] 所选管理部门ID逗号分隔不传表示全部
* @apiParam {Number} [refresh] 1 时主动刷新当前统计缓存
*
* @apiSuccess {Number} ret 返回状态码
* @apiSuccess {String} msg 返回信息
* @apiSuccess {Object} data 团队统计数据
*/
public function team__stats()
{
$user = User::auth();
$context = DashboardTeam::context($user, Request::input('department_owner_ids'));
return Base::retSuccess('success', DashboardTeam::stats($context, intval(Request::input('refresh')) === 1));
}
/**
* @api {get} api/dashboard/team/tasks 负责人视角任务列表
*
* @apiDescription 需要token身份。按关注类型、成员或优先级分页返回团队任务。
* @apiVersion 1.0.0
* @apiGroup dashboard
* @apiName team__tasks
*
* @apiParam {String} [department_owner_ids] 所选管理部门ID逗号分隔不传表示全部
* @apiParam {String} [type] 任务类型uncompleted/overdue/soon/hi/noowner
* @apiParam {Number} [member_id] 成员ID传入后优先于 type
* @apiParam {Number} [level] 优先级;-1 表示未设置,传入后优先于 type
* @apiParam {Number} [page] 当前页
* @apiParam {Number} [pagesize] 每页数量默认20最大50
*
* @apiSuccess {Number} ret 返回状态码
* @apiSuccess {String} msg 返回信息
* @apiSuccess {Object} data 分页任务数据
*/
public function team__tasks()
{
$user = User::auth();
$context = DashboardTeam::context($user, Request::input('department_owner_ids'));
$memberId = intval(Request::input('member_id'));
$levelValue = Request::input('level');
$level = $levelValue !== null && $levelValue !== '' ? intval($levelValue) : null;
$type = trim((string)Request::input('type'));
if ($memberId > 0) {
if (!in_array($memberId, $context['member_userids'], true)) {
return Base::retError('参数错误');
}
} elseif ($level !== null) {
if ($level !== -1 && !in_array($level, DashboardTeam::priorityLevels(), true)) {
return Base::retError('参数错误');
}
} elseif (!in_array($type, ['uncompleted', 'overdue', 'soon', 'hi', 'noowner'], true)) {
return Base::retError('参数错误');
}
return Base::retSuccess('success', DashboardTeam::tasks($context, [
'type' => $type,
'member_id' => $memberId,
'level' => $level,
]));
}
}

View File

@ -41,6 +41,7 @@ class FileController extends AbstractController
* @apiName lists
*
* @apiParam {Number} [pid] 父级ID
* @apiParam {String} [scope] 板块范围根目录生效mine=我的文件、shared=共享文件、all=全部(默认)
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@ -51,8 +52,12 @@ class FileController extends AbstractController
$user = User::auth();
//
$pid = intval(Request::input('pid'));
$scope = Request::input('scope', 'all');
if (!in_array($scope, ['mine', 'shared', 'all'])) {
$scope = 'all';
}
//
return Base::retSuccess('success', (new File)->getFileList($user, $pid));
return Base::retSuccess('success', (new File)->getFileList($user, $pid, 'all', true, $scope));
}
/**

View File

@ -13,6 +13,7 @@ use Request;
* 动态路由routes/web.php
* api/license/email/send -> email__send()
* api/license/login -> login()
* api/license/login/confirm -> login__confirm()
* api/license/trial -> trial()
* api/license/status -> status()
* api/license/refresh -> refresh()
@ -49,6 +50,25 @@ class LicenseController extends AbstractController
return Base::retSuccess('授权成功', $data);
}
/**
* 多条可用授权时,用户选定后确认签发(复用验证码)
*/
public function login__confirm()
{
User::auth('admin');
$email = trim(Request::input('email'));
$code = trim(Request::input('code'));
$entitlementId = (int)Request::input('entitlement_id');
if ($email === '' || $code === '') {
return Base::retError('请输入邮箱和验证码');
}
if ($entitlementId <= 0) {
return Base::retError('请选择要使用的授权');
}
$data = OnlineLicense::loginConfirm($email, $code, $entitlementId);
return Base::retSuccess('授权成功', $data);
}
/**
* 邮箱 + 验证码申请试用并签发
*/

View File

@ -2271,9 +2271,12 @@ class ProjectController extends AbstractController
}
//
$data = $task->toArray();
$data['department_readonly'] = UserDepartment::isDepartmentReadonlyProject($departmentView, intval($task->project_id));
$data['project_name'] = $task->project?->name;
$taskProject = Project::withTrashed()->find($task->project_id);
$data['project_name'] = $taskProject?->name;
$data['project_deleted'] = (!$taskProject || $taskProject->trashed()) ? 1 : 0;
$data['project_archived'] = $taskProject?->archived_at ? 1 : 0;
$data['column_name'] = $task->projectColumn?->name;
$data['department_readonly'] = UserDepartment::isDepartmentReadonlyProject($departmentView, intval($task->project_id));
$data['visibility_appointor'] = $task->visibility == 1 ? [0] : ProjectTaskVisibilityUser::whereTaskId($task_id)->pluck('userid');
return Base::retSuccess('success', $data);
}
@ -3205,8 +3208,27 @@ class ProjectController extends AbstractController
//
$task = ProjectTask::userTask($task_id, null, $type !== 'recovery');
//
$project = Project::userProject($task->project_id);
ProjectPermission::userTaskPermission($project, ProjectPermission::TASK_REMOVE, $task);
try {
$project = Project::userProject($task->project_id);
ProjectPermission::userTaskPermission($project, ProjectPermission::TASK_REMOVE, $task);
} catch (\Throwable $e) {
if ($type == 'recovery') {
throw $e;
}
// 项目已删除/已归档时放行删除操作(限:管理员、原项目负责人、任务负责人/协助人/创建人)
$project = Project::withTrashed()->find($task->project_id);
$projectInvalid = !$project || $project->trashed() || $project->archived_at;
if (!$projectInvalid) {
throw $e;
}
$isProjectOwner = ProjectUser::whereProjectId($task->project_id)
->whereUserid(Doo::userId())
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
->exists();
if (!$isProjectOwner && !$task->permission(3)) {
throw new ApiException('仅项目负责人或任务相关成员删除');
}
}
//
if ($type == 'recovery') {
$task->restoreTask();

View File

@ -154,7 +154,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['department_owner_project_view'] = $setting['department_owner_project_view'] ?: 'open';
$setting['server_timezone'] = config('app.timezone');
$setting['server_version'] = Base::getVersion();
// 指定人员名单仅管理员可见
@ -348,6 +348,10 @@ class SystemController extends AbstractController
if (empty($item)) {
continue;
}
// dooai_key 是官方网关 token需原样返回供鉴权
if ($key === 'dooai_key') {
continue;
}
if (str_ends_with($key, '_key') || str_ends_with($key, '_secret')) {
$setting[$key] = substr($item, 0, 4) . str_repeat('*', strlen($item) - 8) . substr($item, -4);
}
@ -774,14 +778,14 @@ class SystemController extends AbstractController
}
$apps = Setting::normalizeCustomMicroApps($list);
$setting = Base::setting('microapp_menu', $apps);
$setting = Setting::formatCustomMicroAppsForResponse($setting);
$setting = Setting::formatCustomMicroAppsForResponse($setting, true);
} else {
$setting = Base::setting('microapp_menu');
if (!is_array($setting)) {
$setting = [];
}
$setting = Setting::filterCustomMicroAppsForUser($setting, $user);
$setting = Setting::formatCustomMicroAppsForResponse($setting);
$setting = Setting::formatCustomMicroAppsForResponse($setting, $user && $user->isAdmin());
}
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting);
}
@ -857,6 +861,14 @@ class SystemController extends AbstractController
$type = trim(Request::input('type'));
if ($type == 'save') {
$license = Request::input('license');
// 解密失败sn 为空)视为无效 license
$decoded = Doo::licenseDecode($license);
if ((string)($decoded['sn'] ?? '') === '') {
return Base::retError('LICENSE 格式错误');
}
if ($err = Doo::licenseBindingError($decoded)) {
return Base::retError($err);
}
Doo::licenseSave($license);
// 离线/在线互斥:保存离线 license 即退出在线模式(尽力释放座位+清在线标志,不删除刚写入的文件)
OnlineLicense::switchToOffline();
@ -871,8 +883,8 @@ class SystemController extends AbstractController
'user_count' => User::whereBot(0)->whereNull('disable_at')->count(),
'error' => []
];
if ($data['info']['people'] > 3) {
// 小于3人的License不检查
if ($data['info']['people'] == 0 || $data['info']['people'] > 3) {
// 付费档才检查 SN/MAC
if ($data['info']['sn'] != $data['doo_sn']) {
$data['error'][] = '终端SN与License不匹配';
}

View File

@ -0,0 +1,129 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\User;
use App\Module\Base;
use App\Module\ChunkUpload;
use Request;
/**
* 分片上传统一入口。
*
* 动态路由routes/web.php
* api/upload/init -> init() 启动一个上传会话(含秒传 / 续传命中)
* api/upload/chunk -> chunk() 接收一个分片
* api/upload/merge -> merge() 合并分片并按 scene 入库
*
* 小文件(<10MB不走此接口前端直接调用各 scene 的老接口(透明降级)。
*/
class UploadController extends AbstractController
{
/**
* @api {post} api/upload/init 启动上传会话
*
* @apiDescription 提交文件 hash/size/name/scene/scene_params返回 upload_id 与已收分片列表;
* 若同用户曾上传过同 hash 文件,直接返回 done=true(秒传)。
* @apiGroup upload
* @apiName init
*
* @apiParam {String} hash 文件 md5小写 32 字符)
* @apiParam {Number} size 文件大小(字节)
* @apiParam {String} name 原始文件名(含扩展名)
* @apiParam {String} scene 场景file_cabinet | dialog_file | image | generic_file
* @apiParam {Object} [scene_params] 场景参数(如 file_cabinet pid/cover/webkit_relative_path
*
* @apiSuccess {Number} ret
* @apiSuccess {Object} data done / upload_id / chunk_size / chunk_count / received 或秒传 file
*/
public function init()
{
$user = User::auth();
$result = ChunkUpload::start($user, [
'hash' => Request::input('hash', ''),
'size' => Request::input('size', 0),
'name' => Request::input('name', ''),
'scene' => Request::input('scene', ''),
'scene_params' => Request::input('scene_params', []),
]);
return $result;
}
/**
* @api {post} api/upload/chunk 上传一个分片
*
* @apiDescription multipart 请求blob 字段为分片数据。
* @apiGroup upload
* @apiName chunk
*
* @apiParam {String} upload_id init 返回的 upload_id
* @apiParam {Number} index 分片序号0-based
* @apiParam {File} blob 分片数据
*
* @apiSuccess {Number} ret
* @apiSuccess {Object} data upload_id 与最新 received[]
*/
public function chunk()
{
$user = User::auth();
$uploadId = trim(Request::input('upload_id', ''));
$index = intval(Request::input('index', -1));
$blob = Request::file('blob');
if ($uploadId === '') {
return Base::retError('upload_id 不能为空');
}
return ChunkUpload::receive($user, $uploadId, $index, $blob);
}
/**
* @api {post} api/upload/merge 合并分片并入库
*
* @apiDescription 全部分片到齐后调用;后端按 scene 路由到对应入库逻辑,返回与该 scene 老接口对齐的数据。
* @apiGroup upload
* @apiName merge
*
* @apiParam {String} upload_id init 返回的 upload_id
*
* @apiSuccess {Number} ret
* @apiSuccess {Object} data scene 入库返回数据
*/
public function merge()
{
$user = User::auth();
$uploadId = trim(Request::input('upload_id', ''));
if ($uploadId === '') {
return Base::retError('upload_id 不能为空');
}
try {
return ChunkUpload::merge($user, $uploadId);
} catch (\Exception $e) {
if (str_contains($e->getMessage(), 'Failed to acquire lock')) {
return Base::retError('合并繁忙,请稍后再试');
}
return Base::retError($e->getMessage());
}
}
/**
* @api {post} api/upload/cancel 取消上传会话
*
* @apiDescription 调用方主动放弃一次分片上传时调用:删除 Redis meta/chunks/hash 索引并清掉分片目录。
* 会话已过期或归属其他用户时静默成功,避免给前端取消按钮回写"取消失败"
* @apiGroup upload
* @apiName cancel
*
* @apiParam {String} upload_id init 返回的 upload_id
*
* @apiSuccess {Number} ret
*/
public function cancel()
{
$user = User::auth();
$uploadId = trim(Request::input('upload_id', ''));
if ($uploadId === '') {
return Base::retError('upload_id 不能为空');
}
ChunkUpload::cancelByUser($user, $uploadId);
return Base::retSuccess('已取消');
}
}

View File

@ -276,6 +276,7 @@ class UsersController extends AbstractController
public function login__codejson()
{
$captcha = Captcha::create('default', true);
$captcha['img'] = (string)$captcha['img'];
return Base::retSuccess('请求成功', $captcha);
}
@ -417,7 +418,7 @@ class UsersController extends AbstractController
public function info__managed_departments()
{
$user = User::auth();
if (Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') {
if (Base::settingFind('system', 'department_owner_project_view', 'open') !== 'open') {
return Base::retSuccess('success', []);
}
return Base::retSuccess('success', UserDepartment::getManagedDepartments($user->userid));

48
app/Models/AppBadge.php Normal file
View File

@ -0,0 +1,48 @@
<?php
namespace App\Models;
/**
* App\Models\AppBadge
*
* 插件/微应用菜单角标(每个 (app_id, menu_key, userid) 一行,仅存非清除态)
*
* @property int $id
* @property string $app_id 应用ID
* @property string $menu_key 菜单稳定标识(空串=第一个菜单)
* @property int $userid 用户ID
* @property int $count 角标数字
* @property bool $dot 是否显示红点
* @property \Illuminate\Support\Carbon|null $updated_at
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge query()
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereAppId($value)
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereCount($value)
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereDot($value)
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereMenuKey($value)
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereUserid($value)
* @mixin \Eloquent
*/
class AppBadge extends AbstractModel
{
protected $table = 'app_badges';
const CREATED_AT = null;
protected $fillable = [
'app_id',
'menu_key',
'userid',
'count',
'dot',
];
protected $casts = [
'userid' => 'integer',
'count' => 'integer',
'dot' => 'boolean',
];
}

View File

@ -178,28 +178,26 @@ class File extends AbstractModel
* @param int $pid
* @param string $type
* @param bool $isGetparent
* @param string $scope 板块范围根目录生效mine=仅我的私有文件shared=共享文件(别人共享给我的+我共享出去的all=全部(默认,兼容旧调用)
* @return array
*/
public function getFileList($user, int $pid, $type = "all", $isGetparent = true)
public function getFileList($user, int $pid, $type = "all", $isGetparent = true, $scope = "all")
{
$permission = 1000;
$userids = $user->isTemp() ? [$user->userid] : [0, $user->userid];
$builder = File::wherePid($pid)
->when($type == 'dir', function ($q) {
$q->whereType('folder');
});
//
if ($pid > 0) {
// 目录内:按权限返回子级(不区分板块)
File::permissionFind($pid, $userids, 0, $permission);
} else {
$builder->whereUserid($user->userid);
}
//
$array = $builder->take(500)->get()->toArray();
foreach ($array as &$item) {
$item['permission'] = $permission;
}
//
if ($pid > 0) {
$array = File::wherePid($pid)
->when($type == 'dir', function ($q) {
$q->whereType('folder');
})
->take(500)->get()->toArray();
foreach ($array as &$item) {
$item['permission'] = $permission;
}
unset($item);
// 遍历获取父级
if ($isGetparent) {
while ($pid > 0) {
@ -230,24 +228,58 @@ class File extends AbstractModel
$array = array_values($array);
}
} else {
// 获取共享相关
DB::statement("SET SQL_MODE=''");
$pre = DB::connection()->getTablePrefix();
$list = File::select(["files.*", DB::raw("MAX({$pre}file_users.permission) as permission")])
->join('file_users', 'files.id', '=', 'file_users.file_id')
->where('files.userid', '!=', $user->userid)
->whereIn('file_users.userid', $userids)
->groupBy('files.id')
->take(100)
->when($type == 'dir', function ($q) {
$q->where('files.type', 'folder');
})
->get();
if ($list->isNotEmpty()) {
foreach ($list as $file) {
$temp = $file->toArray();
$temp['pid'] = 0;
$array[] = $temp;
// 根目录:按板块拆分
$array = [];
// 我的文件我拥有的全部含已共享出去的mine 与 all 一致
if ($scope === 'mine' || $scope === 'all') {
$mine = File::wherePid(0)
->whereUserid($user->userid)
->when($type == 'dir', function ($q) {
$q->whereType('folder');
})
->take(500)->get()->toArray();
foreach ($mine as &$item) {
$item['permission'] = $permission;
}
unset($item);
$array = array_merge($array, $mine);
}
// 共享文件
if ($scope === 'shared' || $scope === 'all') {
// 别人共享给我的
DB::statement("SET SQL_MODE=''");
$pre = DB::connection()->getTablePrefix();
$list = File::select(["files.*", DB::raw("MAX({$pre}file_users.permission) as permission")])
->join('file_users', 'files.id', '=', 'file_users.file_id')
->where('files.userid', '!=', $user->userid)
->whereIn('file_users.userid', $userids)
->groupBy('files.id')
->take(100)
->when($type == 'dir', function ($q) {
$q->where('files.type', 'folder');
})
->get();
if ($list->isNotEmpty()) {
foreach ($list as $file) {
$temp = $file->toArray();
$temp['pid'] = 0;
$array[] = $temp;
}
}
// 我共享出去的(仅 shared 板块补充all 板块已包含在“我的文件”里)
if ($scope === 'shared') {
$mineShared = File::wherePid(0)
->whereUserid($user->userid)
->where('share', 1)
->when($type == 'dir', function ($q) {
$q->whereType('folder');
})
->take(500)->get()->toArray();
foreach ($mineShared as &$item) {
$item['permission'] = $permission;
}
unset($item);
$array = array_merge($array, $mineShared);
}
}
}
@ -267,20 +299,74 @@ class File extends AbstractModel
* @return array
*/
public function contentUpload($user, int $pid, $webkitRelativePath, $overwrite = false)
{
[$pid, $userid, $addItem] = $this->contentUploadPrep($user, $pid, $webkitRelativePath);
$data = Base::upload([
"file" => Request::file('files'),
"type" => 'more',
"autoThumb" => false,
"path" => 'uploads/tmp/file/' . date("Ym") . '/',
"quality" => true,
]);
if (Base::isError($data)) {
throw new ApiException($data['msg']);
}
return $this->contentUploadCommit($user, $userid, $pid, $data['data'], $addItem, $webkitRelativePath, null, $overwrite);
}
/**
* contentUpload 同一入库链路,但接收已落盘的本地文件而非 Request 上传文件。
* 供分片上传 merge 阶段调用。
*
* @param user $user
* @param int $pid
* @param string $localPath 合并后的完整文件绝对路径
* @param string $originalName 原始文件名(含扩展名)
* @param string $webkitRelativePath
* @param string|null $hash 文件 md5用于秒传索引
* @param bool $overwrite
* @return array
*/
public function contentUploadFromPath($user, int $pid, string $localPath, string $originalName, $webkitRelativePath, $hash = null, $overwrite = false)
{
[$pid, $userid, $addItem] = $this->contentUploadPrep($user, $pid, $webkitRelativePath);
$data = Base::uploadFromPath([
"path_local" => $localPath,
"name" => $originalName,
"type" => 'more',
"autoThumb" => false,
"path" => 'uploads/tmp/file/' . date("Ym") . '/',
"quality" => true,
]);
if (Base::isError($data)) {
throw new ApiException($data['msg']);
}
return $this->contentUploadCommit($user, $userid, $pid, $data['data'], $addItem, $webkitRelativePath, $hash, $overwrite);
}
/**
* 上传前置:权限/计数校验 + webkitRelativePath 拆出来的中间文件夹创建。
* 失败抛 ApiException成功返回 [最终 pid, 拥有者 userid, 已创建的中间文件夹列表]
*
* @param user $user
* @param int $pid
* @param string $webkitRelativePath
* @return array{0:int, 1:int, 2:array}
*/
public function contentUploadPrep($user, int $pid, $webkitRelativePath): array
{
$userid = $user->userid;
if ($pid > 0) {
if (File::wherePid($pid)->count() >= 300) {
return Base::retError('每个文件夹里最多只能创建300个文件或文件夹');
throw new ApiException('每个文件夹里最多只能创建300个文件或文件夹');
}
$row = File::permissionFind($pid, $user, 1);
$userid = $row->userid;
} else {
if (File::whereUserid($user->userid)->wherePid(0)->count() >= 300) {
return Base::retError('每个文件夹里最多只能创建300个文件或文件夹');
throw new ApiException('每个文件夹里最多只能创建300个文件或文件夹');
}
}
//
$dirs = explode("/", $webkitRelativePath);
$addItem = [];
while (count($dirs) > 1) {
@ -297,12 +383,10 @@ class File extends AbstractModel
'created_id' => $user->userid,
]);
$dirRow->handleDuplicateName();
if ($dirRow->saveBeforePP()) {
$addItem[] = File::find($dirRow->id);
if (!$dirRow->saveBeforePP()) {
throw new ApiException('创建文件夹失败');
}
}
if (empty($dirRow)) {
throw new ApiException('创建文件夹失败');
$addItem[] = File::find($dirRow->id);
}
$pid = $dirRow->id;
});
@ -311,20 +395,24 @@ class File extends AbstractModel
}
}
}
//
$path = 'uploads/tmp/file/' . date("Ym") . '/';
$data = Base::upload([
"file" => Request::file('files'),
"type" => 'more',
"autoThumb" => false,
"path" => $path,
"quality" => true
]);
if (Base::isError($data)) {
throw new ApiException($data['msg']);
}
$data = $data['data'];
//
return [$pid, $userid, $addItem];
}
/**
* 上传后置ext type 映射 + File 记录创建 + uploadMove + FileContent 入库。
*
* @param user $user
* @param int $userid 目标文件拥有者
* @param int $pid
* @param array $data Base::upload/uploadFromPath 返回的 data 部分
* @param array $addItem prep 阶段累积的中间文件夹
* @param string $webkitRelativePath
* @param string|null $hash 可选,写入 files.hash秒传索引
* @param bool $overwrite
* @return array{data: array, addItem: array}
*/
private function contentUploadCommit($user, int $userid, int $pid, array $data, array $addItem, $webkitRelativePath, $hash, bool $overwrite): array
{
$type = match ($data['ext']) {
'text', 'md', 'markdown' => 'document',
'drawio' => 'drawio',
@ -356,7 +444,6 @@ class File extends AbstractModel
if ($data['ext'] == 'markdown') {
$data['ext'] = 'md';
}
$file = null;
$params = [
'pid' => $pid,
'name' => Base::rightDelete($data['name'], '.' . $data['ext']),
@ -365,6 +452,7 @@ class File extends AbstractModel
'userid' => $userid,
'created_id' => $user->userid,
];
$file = null;
if ($overwrite) {
$file = self::wherePid($params['pid'])->whereExt($params['ext'])->whereName($params['name'])->first();
}
@ -373,11 +461,12 @@ class File extends AbstractModel
$file = File::createInstance($params);
$file->handleDuplicateName();
}
// 开始创建
return AbstractModel::transaction(function () use ($overwrite, $addItem, $webkitRelativePath, $type, $user, $data, $file) {
return AbstractModel::transaction(function () use ($overwrite, $addItem, $webkitRelativePath, $type, $user, $data, $file, $hash) {
$file->size = $data['size'] * 1024;
if ($hash) {
$file->hash = $hash;
}
$file->saveBeforePP();
//
$data = Base::uploadMove($data, "uploads/file/" . $file->type . "/" . date("Ym") . "/" . $file->id . "/");
$content = [
'from' => '',
@ -389,25 +478,20 @@ class File extends AbstractModel
$content['width'] = $data['width'];
$content['height'] = $data['height'];
}
$content = FileContent::createInstance([
FileContent::createInstance([
'fid' => $file->id,
'content' => $content,
'text' => '',
'size' => $file->size,
'userid' => $user->userid,
]);
$content->save();
//
])->save();
$tmpRow = File::find($file->id);
$tmpRow->pushMsg('add', $tmpRow);
//
$data = File::handleImageUrl($tmpRow->toArray());
$data['full_name'] = $webkitRelativePath ?: ($data['name'] . '.' . $data['ext']);
$data['overwrite'] = $overwrite ? 1 : 0;
//
$addItem[] = $data;
return ['data' => $data, 'addItem' => $addItem];
$row = File::handleImageUrl($tmpRow->toArray());
$row['full_name'] = $webkitRelativePath ?: ($row['name'] . '.' . $row['ext']);
$row['overwrite'] = $overwrite ? 1 : 0;
$addItem[] = $row;
return ['data' => $row, 'addItem' => $addItem];
});
}

View File

@ -90,6 +90,24 @@ class ManticoreSyncFailure extends AbstractModel
->delete();
}
/**
* 批量清除同步成功记录(供批量写入路径使用,避免逐条删除)
*
* @param string $dataType 数据类型
* @param array $dataIds 数据ID列表
* @param string $action 操作类型
*/
public static function removeSuccessBatch(string $dataType, array $dataIds, string $action): void
{
if (empty($dataIds)) {
return;
}
self::where('data_type', $dataType)
->whereIn('data_id', $dataIds)
->where('action', $action)
->delete();
}
/**
* 获取待重试的记录
* 根据重试次数决定间隔1次=1分钟2次=5分钟3次=15分钟4次+=30分钟

View File

@ -482,7 +482,7 @@ class ProjectTask extends AbstractModel
if ($parent_id == 0) {
$priorityList = Setting::normalizeTaskPriorityList(Base::setting('priority'));
if ($p_level > 0) {
$matched = reset(array_filter($priorityList, fn($item) => intval($item['priority']) === $p_level)) ?: null;
$matched = collect($priorityList)->first(fn($item) => intval($item['priority']) === $p_level);
} else {
$matched = Setting::getDefaultTaskPriorityItem($priorityList);
}
@ -2246,7 +2246,7 @@ class ProjectTask extends AbstractModel
$project = Project::userProject($task->project_id);
} catch (\Throwable $e) {
if ($task->owner !== null || $task->permission(4)) {
$project = Project::find($task->project_id);
$project = Project::withTrashed()->find($task->project_id);
if (empty($project)) {
throw new ApiException('项目不存在或已被删除', [ 'task_id' => $task_id ], -4002);
}

View File

@ -216,10 +216,11 @@ class Setting extends AbstractModel
/**
* AI 机器人模型转数组
* @param $models
* @param bool $retValue
* @param bool $retValue 仅返回模型 value 列表
* @param bool $visibleOnly 仅返回可见模型(跳过标记 hidden 的项,供展示给终端用户的场景)
* @return array
*/
public static function AIBotModels2Array($models, $retValue = false)
public static function AIBotModels2Array($models, $retValue = false, $visibleOnly = false)
{
$list = null;
if (is_array($models)) {
@ -244,6 +245,10 @@ class Setting extends AbstractModel
if ($value === '') {
continue;
}
// 隐藏模型:仍保存在设置中,但展示给终端用户时跳过($visibleOnly
if ($visibleOnly && !empty($item['hidden'])) {
continue;
}
$label = trim((string)($item['name'] ?? $item['label'] ?? ''));
$thinking = strtolower(trim((string)($item['thinking'] ?? 'off')));
if (!in_array($thinking, ['off', 'low', 'medium', 'high'], true)) {
@ -353,18 +358,25 @@ class Setting extends AbstractModel
/**
* 将存储结构转换成 appstore 接口同款格式
* @param array $apps
* @param bool $keepVisible 是否保留可见范围(仅管理员编辑场景需要,用于回填)
* @return array
*/
public static function formatCustomMicroAppsForResponse(array $apps)
public static function formatCustomMicroAppsForResponse(array $apps, bool $keepVisible = false)
{
return array_values(array_map(function ($app) {
unset($app['visible_to']);
return array_values(array_map(function ($app) use ($keepVisible) {
if ($keepVisible) {
$app['visible_to'] = self::normalizeCustomMicroVisible($app['visible_to'] ?? ['admin']);
} else {
unset($app['visible_to']);
}
if (!empty($app['menu_items']) && is_array($app['menu_items'])) {
$app['menu_items'] = array_values(array_map(function ($menu) {
$menu['keep_alive'] = isset($menu['keep_alive']) ? (bool)$menu['keep_alive'] : true;
$menu['disable_scope_css'] = (bool)($menu['disable_scope_css'] ?? false);
$menu['auto_dark_theme'] = isset($menu['auto_dark_theme']) ? (bool)$menu['auto_dark_theme'] : true;
$menu['transparent'] = (bool)($menu['transparent'] ?? false);
$menu['key'] = isset($menu['key']) ? (string)$menu['key'] : '';
$menu['badge_clear_on_open'] = (bool)($menu['badge_clear_on_open'] ?? false);
if (isset($menu['visible_to'])) {
unset($menu['visible_to']);
}
@ -454,6 +466,9 @@ class Setting extends AbstractModel
if (!empty($menu['capsule']) && is_array($menu['capsule'])) {
$payload['capsule'] = Base::newTrim($menu['capsule']);
}
// 角标:菜单稳定标识 与 打开时是否自动清零
$payload['key'] = Base::newTrim($menu['key'] ?? '');
$payload['badge_clear_on_open'] = (bool)($menu['badge_clear_on_open'] ?? false);
return $payload;
}
@ -462,7 +477,7 @@ class Setting extends AbstractModel
* @param mixed $value
* @return array
*/
protected static function normalizeCustomMicroVisible($value)
public static function normalizeCustomMicroVisible($value)
{
if (is_array($value)) {
$list = array_filter(array_map('trim', $value));
@ -485,7 +500,7 @@ class Setting extends AbstractModel
* @param int $userId
* @return bool
*/
protected static function isCustomMicroVisibleTo(array $visible, bool $isAdmin, int $userId)
public static function isCustomMicroVisibleTo(array $visible, bool $isAdmin, int $userId)
{
if (in_array('all', $visible)) {
return true;

View File

@ -394,6 +394,10 @@ class User extends AbstractModel
}
// 密码
self::passwordPolicy($password);
// license
if ($err = Doo::licenseBindingError(Doo::license())) {
throw new ApiException($err);
}
// 开始注册
$user = Doo::userCreate($email, $password);
if ($other) {

View File

@ -273,8 +273,11 @@ class UserBot extends AbstractModel
if ($match[1] === "ai-") {
$aibotSetting = Base::setting('aibotSetting');
$aibotModel = $aibotSetting[$match[2] . '_model'];
$aibotModels = Setting::AIBotModels2Array($aibotSetting[$match[2] . '_models']);
$aibotModels = Setting::AIBotModels2Array($aibotSetting[$match[2] . '_models'], false, true);
if ($aibotModels) {
if (!in_array($aibotModel, array_column($aibotModels, 'value'), true)) {
$aibotModel = $aibotModels[0]['value'];
}
$menus = array_merge(
[
[

View File

@ -561,7 +561,7 @@ class UserDepartment extends AbstractModel
'own_project_ids' => [],
'own_project_id_map' => [],
];
if ($ids === null || $ids === '' || Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') {
if ($ids === null || $ids === '' || Base::settingFind('system', 'department_owner_project_view', 'open') !== 'open') {
return $empty;
}
$memberUserids = self::getManagedMemberUserids($user->userid, $ids);
@ -653,7 +653,7 @@ class UserDepartment extends AbstractModel
return $result;
}
// 部门负责人只读视角
if (Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') {
if (Base::settingFind('system', 'department_owner_project_view', 'open') !== 'open') {
return $result;
}
$memberUserids = self::getManagedMemberUserids($viewer->userid, 'all');

View File

@ -1100,6 +1100,46 @@ class WebSocketDialog extends AbstractModel
});
}
/**
* sendMsgFiles 同链路,但接收已落盘的本地文件(分片合并产物),跳过 Base::upload。
*
* @param User $user
* @param int[] $dialogIds
* @param string $localPath 已落盘绝对路径
* @param string $originalName 原始文件名
* @param int $replyId
* @param bool $imageAttachment 任务群组中图片是否也作为附件保存
* @return array
*/
public static function sendMsgFilesFromPath($user, $dialogIds, string $localPath, string $originalName, int $replyId = 0, bool $imageAttachment = false)
{
$first = null;
$fileName = $originalName;
$resolve = function ($path) use (&$first, &$fileName, $localPath, $originalName) {
if ($first !== null) {
return self::copyFileDataTo($first, $path);
}
$setting = Base::setting("system");
$data = Base::uploadFromPath([
"path_local" => $localPath,
"name" => $originalName,
"type" => 'more',
"path" => $path,
"fileName" => $fileName,
"quality" => true,
"convertVideo" => $setting['convert_video'] === 'open',
"compressVideo" => $setting['compress_video'] === 'open',
]);
if (Base::isError($data)) {
throw new ApiException($data['msg']);
}
$first = $data['data'];
$fileName = $first['name'];
return $first;
};
return self::dispatchFileMessages($user, $dialogIds, $replyId, $imageAttachment, $resolve);
}
/**
* 发送消息文件
*
@ -1114,24 +1154,18 @@ class WebSocketDialog extends AbstractModel
*/
public static function sendMsgFiles($user, $dialogIds, $files, $image64, $fileName, $replyId, $imageAttachment)
{
$filePath = '';
$result = [];
$data = [];
foreach ($dialogIds as $dialog_id) {
$dialog = WebSocketDialog::checkDialog($dialog_id);
$action = $replyId > 0 ? "reply-$replyId" : "";
$path = "uploads/chat/" . date("Ym") . "/" . $dialog_id . "/";
$first = null;
$resolve = function ($path) use (&$first, &$fileName, $files, $image64) {
if ($first !== null) {
return self::copyFileDataTo($first, $path);
}
if ($image64) {
$data = Base::image64save([
"image64" => $image64,
"path" => $path,
"fileName" => $fileName,
"quality" => true
"quality" => true,
]);
} else if ($filePath) {
Base::makeDir(public_path($path));
copy($filePath, public_path($path) . basename($filePath));
} else {
$setting = Base::setting("system");
$data = Base::upload([
@ -1147,19 +1181,41 @@ class WebSocketDialog extends AbstractModel
if (Base::isError($data)) {
throw new ApiException($data['msg']);
}
$fileData = $data['data'];
$filePath = $fileData['file'];
$fileName = $fileData['name'];
$fileData['thumb'] = Base::unFillUrl($fileData['thumb']);
$fileData['size'] *= 1024;
$first = $data['data'];
$fileName = $first['name'];
return $first;
};
return self::dispatchFileMessages($user, $dialogIds, $replyId, $imageAttachment, $resolve);
}
// 任务群组保存文件
/**
* 遍历多个 dialog 发送文件消息:每个 dialog 取一份 fileData 任务群组建附件 sendMsg。
* fileData 的策略由 $resolve(path) 决定(首次 upload后续 copy
*
* @param User $user
* @param int[] $dialogIds
* @param int $replyId
* @param bool $imageAttachment
* @param callable $resolve fn(string $path): array 返回该 dialog fileData
* @return array sendMsg 的最终返回(最后一个 dialog 的结果)
*/
private static function dispatchFileMessages($user, array $dialogIds, int $replyId, bool $imageAttachment, callable $resolve): array
{
$result = [];
foreach ($dialogIds as $dialog_id) {
$dialog = WebSocketDialog::checkDialog($dialog_id);
$action = $replyId > 0 ? "reply-$replyId" : "";
$path = "uploads/chat/" . date("Ym") . "/" . $dialog_id . "/";
$fileData = $resolve($path);
$fileData['thumb'] = Base::unFillUrl($fileData['thumb'] ?? '');
$fileData['size'] *= 1024;
$task = null;
if ($dialog->group_type === 'task') {
// 如果是图片不保存
// 图片消息默认不作为任务附件存档,除非显式 $imageAttachment
if ($imageAttachment || !in_array($fileData['ext'], File::imageExt)) {
$task = ProjectTask::whereDialogId($dialog->id)->first();
if ($task) {
$file = ProjectTaskFile::createInstance([
ProjectTaskFile::createInstance([
'project_id' => $task->project_id,
'task_id' => $task->id,
'name' => $fileData['name'],
@ -1168,20 +1224,30 @@ class WebSocketDialog extends AbstractModel
'path' => $fileData['path'],
'thumb' => $fileData['thumb'],
'userid' => $user->userid,
]);
$file->save();
])->save();
}
}
}
// 发送消息
$result = WebSocketDialogMsg::sendMsg($action, $dialog_id, 'file', $fileData, $user->userid);
if (Base::isSuccess($result)) {
if (isset($task)) {
$result['data']['task_id'] = $task->id;
}
if (Base::isSuccess($result) && $task) {
$result['data']['task_id'] = $task->id;
}
}
return $result;
}
/**
* 把首个 dialog 上传得到的物理文件 copy 到后续 dialog 的目录,返回更新后的 fileData。
*/
private static function copyFileDataTo(array $first, string $path): array
{
Base::makeDir(public_path($path));
$target = public_path($path) . basename($first['file']);
copy($first['file'], $target);
$copy = $first;
$copy['file'] = $target;
$copy['path'] = $path . basename($first['file']);
$copy['url'] = Base::fillUrl($copy['path']);
return $copy;
}
}

View File

@ -900,7 +900,84 @@ class AI
}
/**
* 通过 OpenAI 兼容接口获取文本的 Embedding 向量
* 调用 ai 插件的 /embeddings 端点批量向量化(免费向量模型,零配置)。
*
* 走主程序 ai 插件的内网调用,共享主程序 APP_KEY 鉴权;
* 向量维度由 ai 插件的模型决定,主程序不再传 dimensions。
*
* @param array $texts 文本数组
* @return array retSuccess(data=向量数组的数组,与输入同序,缺失位置为 []) / retError
*/
protected static function requestPluginEmbeddings(array $texts)
{
$texts = array_values($texts);
$count = count($texts);
if ($count === 0) {
return Base::retSuccess("success", []);
}
$post = json_encode(["input" => $texts]);
$headers = [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . (string) config('app.key'),
];
$timeout = $count > 1 ? 120 : 30;
$res = Ihttp::ihttp_request(self::embeddingsUrl(), $post, $headers, $timeout);
if (Base::isError($res)) {
return Base::retError("Embedding 接口请求失败", $res);
}
$resData = Base::json2array($res['data']);
// 先识别端点的业务错误Ihttp 对 401/500 等带响应体的状态同样返回成功标志,
// 必须按响应里的 code 字段判错,否则鉴权错配等故障只会报"格式错误"难以定位
$resCode = intval($resData['code'] ?? 0);
if ($resCode !== 0 && $resCode !== 200) {
return Base::retError("Embedding 接口错误 [{$resCode}]: " . ($resData['error'] ?? 'unknown'), $resData);
}
if (empty($resData['data']) || !is_array($resData['data'])) {
return Base::retError("Embedding 接口返回数据格式错误", $resData);
}
// 记录当前实际生效的向量模型(查询缓存键与模型变化检测依赖它)
$model = (string) ($resData['model'] ?? '');
if ($model !== '' && Cache::get('ai:embedding_model') !== $model) {
Cache::forever('ai:embedding_model', $model);
}
// 按 index 回填,保证与输入顺序对齐,缺失位置留 []
$vectors = array_fill(0, $count, []);
foreach ($resData['data'] as $item) {
$idx = $item['index'] ?? null;
if ($idx === null || !isset($vectors[$idx])) {
continue;
}
$embedding = $item['embedding'] ?? [];
if (is_array($embedding) && !empty($embedding)) {
$vectors[$idx] = $embedding;
}
}
return Base::retSuccess("success", $vectors);
}
/**
* ai 插件 /embeddings 端点地址
*
* 查询侧(本类)与 Manticore 表定义ManticoreBase::vectorColumnDDL共用的唯一来源
* 两侧必须指向同一端点,否则查询向量与存量向量来自不同模型导致语义搜索错乱。
*/
public static function embeddingsUrl(): string
{
$host = config('dootask.ai_host', 'ai');
$port = (int) config('dootask.ai_port', 5001);
return "http://{$host}:{$port}/embeddings";
}
/**
* 通过 ai 插件的免费向量模型获取文本的 Embedding 向量
*
* @param string $text 需要转换的文本
* @param bool $noCache 是否禁用缓存
@ -916,50 +993,24 @@ class AI
return Base::retError('文本内容不能为空');
}
// 截断过长的文本(OpenAI 限制 8191 tokens约 32K 字符)
// 截断过长的文本(约 32K 字符)
$text = mb_substr($text, 0, 30000);
$cacheKey = "openAIEmbedding::" . md5($text);
// 缓存键带上当前生效的模型标识:切换向量模型后旧查询缓存自动失效,
// 避免最长 7 天内用旧模型向量去搜新模型索引(模型未知时为空串)
$model = (string) Cache::get('ai:embedding_model', '');
$cacheKey = "embeddingV2::" . md5($model . '|' . $text);
if ($noCache) {
Cache::forget($cacheKey);
}
$provider = self::resolveEmbeddingProvider();
if (!$provider) {
return Base::retError("请先在「AI 助手」设置中配置支持 Embedding 的 AI 服务");
}
$result = Cache::remember($cacheKey, Carbon::now()->addDays(7), function () use ($text, $provider) {
$payload = [
"model" => $provider['model'],
"input" => $text,
];
// 统一向量维度为 1536与 Manticore 配置一致)
// OpenAI、智谱等支持 dimensions 参数的厂商需要显式指定
$supportsDimensions = in_array($provider['vendor'], ['openai', 'zhipu']);
if ($supportsDimensions) {
$payload['dimensions'] = 1536;
}
$post = json_encode($payload);
$ai = new self($post);
$ai->setProvider($provider);
$ai->setUrlPath('/embeddings');
$ai->setTimeout(30);
$res = $ai->request(true);
$result = Cache::remember($cacheKey, Carbon::now()->addDays(7), function () use ($text) {
$res = self::requestPluginEmbeddings([$text]);
if (Base::isError($res)) {
return Base::retError("Embedding 请求失败", $res);
return $res;
}
$resData = Base::json2array($res['data']);
if (empty($resData['data'][0]['embedding'])) {
return Base::retError("Embedding 接口返回数据格式错误", $resData);
}
$embedding = $resData['data'][0]['embedding'];
$embedding = $res['data'][0] ?? [];
if (!is_array($embedding) || empty($embedding)) {
return Base::retError("Embedding 向量为空");
}
@ -973,193 +1024,4 @@ class AI
return $result;
}
/**
* 批量获取文本的 Embedding 向量
* OpenAI API 原生支持批量输入,一次请求处理多个文本
*
* @param array $texts 文本数组(最多 100 条)
* @param bool $noCache 是否禁用缓存
* @return array 返回结果,成功时 data 为向量数组的数组(与输入顺序对应)
*/
public static function getBatchEmbeddings(array $texts, $noCache = false)
{
if (!Apps::isInstalled('ai')) {
return Base::retError('应用「AI Assistant」未安装');
}
if (empty($texts)) {
return Base::retSuccess("success", []);
}
// 限制批量大小
// OpenAI 限制:最多 2048 条,单次请求合计最多 300,000 tokens
// 这里限制 500 条,假设平均每条 500 tokens合计 250,000 tokens
$texts = array_slice($texts, 0, 500);
// 准备结果数组,并检查缓存
$results = [];
$uncachedTexts = [];
$uncachedIndices = [];
foreach ($texts as $index => $text) {
if (empty($text)) {
$results[$index] = [];
continue;
}
// 截断过长的文本
$text = mb_substr($text, 0, 30000);
$texts[$index] = $text; // 更新截断后的文本
$cacheKey = "openAIEmbedding::" . md5($text);
if ($noCache) {
Cache::forget($cacheKey);
}
// 检查缓存
if (!$noCache && Cache::has($cacheKey)) {
$cached = Cache::get($cacheKey);
if (Base::isSuccess($cached)) {
$results[$index] = $cached['data'];
continue;
}
}
// 未命中缓存,加入待请求列表
$uncachedTexts[] = $text;
$uncachedIndices[] = $index;
}
// 如果所有文本都在缓存中
if (empty($uncachedTexts)) {
// 按原始顺序返回
ksort($results);
return Base::retSuccess("success", array_values($results));
}
// 获取 provider
$provider = self::resolveEmbeddingProvider();
if (!$provider) {
return Base::retError("请先在「AI 助手」设置中配置支持 Embedding 的 AI 服务");
}
// 构建批量请求
$payload = [
"model" => $provider['model'],
"input" => $uncachedTexts,
];
$supportsDimensions = in_array($provider['vendor'], ['openai', 'zhipu']);
if ($supportsDimensions) {
$payload['dimensions'] = 1536;
}
$post = json_encode($payload);
$ai = new self($post);
$ai->setProvider($provider);
$ai->setUrlPath('/embeddings');
$ai->setTimeout(120); // 批量请求需要更长超时
$res = $ai->request(true);
if (Base::isError($res)) {
return Base::retError("批量 Embedding 请求失败", $res);
}
$resData = Base::json2array($res['data']);
if (empty($resData['data'])) {
return Base::retError("Embedding 接口返回数据格式错误", $resData);
}
// 处理返回的向量并写入缓存
foreach ($resData['data'] as $item) {
$itemIndex = $item['index'] ?? null;
if ($itemIndex === null || !isset($uncachedIndices[$itemIndex])) {
continue;
}
$originalIndex = $uncachedIndices[$itemIndex];
$embedding = $item['embedding'] ?? [];
if (!empty($embedding) && is_array($embedding)) {
$results[$originalIndex] = $embedding;
} else {
$results[$originalIndex] = [];
}
}
// 填充未获取到向量的位置
foreach ($uncachedIndices as $originalIndex) {
if (!isset($results[$originalIndex])) {
$results[$originalIndex] = [];
}
}
// 按原始顺序返回
ksort($results);
return Base::retSuccess("success", array_values($results));
}
/**
* 获取 Embedding 模型配置
*
* @return array|null
*/
protected static function resolveEmbeddingProvider()
{
$setting = Base::setting('aibotSetting');
if (!is_array($setting)) {
$setting = [];
}
// 优先使用 OpenAI支持 embedding 接口)
$key = trim((string)($setting['openai_key'] ?? ''));
if ($key !== '') {
$baseUrl = trim((string)($setting['openai_base_url'] ?? ''));
$baseUrl = $baseUrl ?: 'https://api.openai.com/v1';
$agency = trim((string)($setting['openai_agency'] ?? ''));
return [
'vendor' => 'openai',
'model' => 'text-embedding-3-small',
'api_key' => $key,
'base_url' => rtrim($baseUrl, '/'),
'agency' => $agency,
];
}
$vendorDefaults = [
'deepseek' => [
'base_url' => 'https://api.deepseek.com',
'model' => 'deepseek-embedding',
],
'zhipu' => [
'base_url' => 'https://open.bigmodel.cn/api/paas/v4',
'model' => 'embedding-3',
],
];
// 尝试其他支持 embedding 的服务(如 deepseek、zhipu、qianwen 等)
foreach ($vendorDefaults as $vendor => $defaults) {
$key = trim((string)($setting[$vendor . '_key'] ?? ''));
if ($key !== '') {
$baseUrl = trim((string)($setting[$vendor . '_base_url'] ?? ''));
$baseUrl = $baseUrl ?: $defaults['base_url']; // 使用配置或默认值
$agency = trim((string)($setting[$vendor . '_agency'] ?? ''));
return [
'vendor' => $vendor,
'model' => $defaults['model'],
'api_key' => $key,
'base_url' => rtrim($baseUrl, '/'),
'agency' => $agency,
];
}
}
return null;
}
}

View File

@ -3,6 +3,7 @@
namespace App\Module;
use App\Exceptions\ApiException;
use App\Models\Setting;
use App\Models\User;
use App\Models\UserDepartment;
use App\Services\RequestContext;
@ -29,14 +30,7 @@ class Apps
return (bool) RequestContext::get($key, false);
}
$configFile = base_path('docker/appstore/config/' . $appId . '/config.yml');
$installed = false;
if (file_exists($configFile)) {
$configData = Yaml::parseFile($configFile);
$installed = $configData['status'] === 'installed';
}
return RequestContext::save($key, $installed);
return RequestContext::save($key, self::loadInstalledConfig($appId) !== null);
}
/**
@ -62,6 +56,180 @@ class Apps
}
}
/**
* appstore 目录下的绝对路径(统一 docker/appstore 前缀)。
*
* @param string $relative 相对 docker/appstore 的路径
* @return string
*/
private static function appstorePath(string $relative): string
{
return base_path('docker/appstore/' . ltrim($relative, '/'));
}
/**
* 读取并校验某应用的 appstore config.yml仅当文件存在且 status=installed 时返回解析后的配置数组。
*
* @param string $appId
* @return array|null
*/
private static function loadInstalledConfig(string $appId): ?array
{
$appId = trim($appId);
if ($appId === '' || $appId === 'appstore') {
return null;
}
$configFile = self::appstorePath("config/{$appId}/config.yml");
if (!file_exists($configFile)) {
return null;
}
$config = Yaml::parseFile($configFile);
if (!is_array($config) || ($config['status'] ?? '') !== 'installed') {
return null;
}
return $config;
}
/**
* 将单个 menu_items 项映射为角标用的菜单配置。
*
* @param array $menu
* @param mixed $visibleDefault 应用级默认可见范围
* @return array ['key'=>string,'visible'=>array,'badge_clear_on_open'=>bool]
*/
private static function mapMenuItem(array $menu, $visibleDefault): array
{
return [
'key' => trim((string)($menu['key'] ?? '')),
'visible' => Setting::normalizeCustomMicroVisible($menu['visible_to'] ?? $visibleDefault),
'badge_clear_on_open' => (bool)($menu['badge_clear_on_open'] ?? false),
];
}
/**
* 获取(必要时生成并持久化)应用的独立密钥 APP_SECRET。
*
* 与全局 APP_KEY 不同APP_SECRET 每个已安装应用独立、唯一,持久化在应用自身的
* docker/appstore/config/{appid}/config.yml与其它每应用安装参数同源
* appstore 安装链路按内置 compose 变量 APP_SECRET 注入插件容器。
* 此处主程序侧负责生成/持久化与校验;首次需要时若不存在则惰性生成,保证主程序可独立验证。
*
* @param string $appId 应用ID
* @return string 应用密钥;应用未安装或非插件应用时返回空字符串
*/
public static function appSecret(string $appId): string
{
$appId = trim($appId);
$config = self::loadInstalledConfig($appId);
if ($config === null) {
return '';
}
$secret = trim((string)($config['app_secret'] ?? ''));
if ($secret !== '') {
return $secret;
}
// 首次需要时生成并持久化(按 appid 唯一)
$secret = Base::generatePassword(48);
$config['app_secret'] = $secret;
try {
file_put_contents(self::appstorePath("config/{$appId}/config.yml"), Yaml::dump($config, 4, 2));
} catch (\Throwable $e) {
info('[app_badge] persist app_secret fail', ['appid' => $appId, 'error' => $e->getMessage()]);
return '';
}
return $secret;
}
/**
* 解析应用的菜单角标配置(菜单 key 列表与各自的可见范围)。
*
* 同时覆盖两类应用:
* - 插件应用:读取 docker/appstore/apps/{appid}/{version}/config.yml menu_items
* - 自定义微应用:读取 microapp_menu 设置
*
* @param string $appId 应用ID
* @return array|null ['source'=>'plugin'|'custom', 'menus'=>[['key'=>string,'visible'=>array,'badge_clear_on_open'=>bool], ...]];应用不存在返回 null
*/
public static function appMenuConfig(string $appId): ?array
{
$appId = trim($appId);
if ($appId === '') {
return null;
}
// 插件应用
$config = self::loadInstalledConfig($appId);
if ($config !== null) {
$version = trim((string)($config['install_version'] ?? ''));
return [
'source' => 'plugin',
'menus' => self::readPluginMenus($appId, $version),
];
}
// 自定义微应用
$apps = Base::setting('microapp_menu');
if (is_array($apps)) {
foreach ($apps as $app) {
if (!is_array($app) || trim((string)($app['id'] ?? '')) !== $appId) {
continue;
}
$appVisibleDefault = $app['visible_to'] ?? 'admin';
$menus = [];
foreach (($app['menu_items'] ?? []) as $menu) {
if (!is_array($menu)) {
continue;
}
$menus[] = self::mapMenuItem($menu, $appVisibleDefault);
}
return ['source' => 'custom', 'menus' => $menus];
}
}
return null;
}
/**
* 读取插件包 config.yml 的菜单配置。
*
* @param string $appId
* @param string $version 已安装版本
* @return array
*/
private static function readPluginMenus(string $appId, string $version): array
{
$paths = [];
if ($version !== '') {
$paths[] = self::appstorePath("apps/{$appId}/{$version}/config.yml");
}
$paths[] = self::appstorePath("apps/{$appId}/config.yml");
$pkg = null;
foreach ($paths as $p) {
if (file_exists($p) && is_readable($p)) {
try {
$pkg = Yaml::parseFile($p);
} catch (\Throwable $e) {
$pkg = null;
}
if (is_array($pkg)) {
break;
}
}
}
$menus = [];
if (is_array($pkg) && !empty($pkg['menu_items']) && is_array($pkg['menu_items'])) {
$appVisibleDefault = $pkg['visible_to'] ?? 'all';
foreach ($pkg['menu_items'] as $menu) {
if (!is_array($menu)) {
continue;
}
$menus[] = self::mapMenuItem($menu, $appVisibleDefault);
}
}
if (empty($menus)) {
// 读不到包配置(权限/缺失)时退化为单一默认菜单,仍可对第一个菜单设角标
$menus[] = ['key' => '', 'visible' => ['all'], 'badge_clear_on_open' => false];
}
return $menus;
}
/**
* Dispatch user lifecycle hook to appstore (user_onboard/user_offboard/user_update).
*

329
app/Module/Badge.php Normal file
View File

@ -0,0 +1,329 @@
<?php
namespace App\Module;
use App\Exceptions\ApiException;
use App\Models\AppBadge;
use App\Models\Setting;
use App\Models\User;
use App\Tasks\PushTask;
/**
* 插件 / 微应用菜单角标业务编排。
*
* 角标真值归插件(应用密钥写入),主程序仅作为存储与分发:
* - 绝对设置/清除 app_badges 行(仅存非清除态)
* - 通过 WebSocketPushTask向在线用户实时推送 appBadge 消息
* - 提供初始同步所需的用户角标快照
*/
class Badge
{
/**
* 设置角标(应用密钥鉴权场景):校验密钥/菜单/可见性,绝对设置并实时推送。
*
* @param string $appId
* @param string $secret 请求携带的应用密钥
* @param mixed $userid 目标用户ID单个或数组
* @param string $menuKeyInput 请求中的 menu_key可空留空取第一个菜单
* @param mixed $count 角标数字
* @param mixed $dot 是否红点
* @return array 响应数据
* @throws ApiException 参数/密钥/应用/菜单校验失败
*/
public static function set(string $appId, string $secret, $userid, string $menuKeyInput, $count, $dot): array
{
if ($appId === '') {
throw new ApiException('参数错误');
}
if ($secret === '') {
throw new ApiException('密钥无效');
}
$expect = Apps::appSecret($appId);
if ($expect === '' || !hash_equals($expect, $secret)) {
throw new ApiException('密钥无效');
}
$menu = self::resolveAppMenu($appId, $menuKeyInput);
$menuKey = (string)($menu['key'] ?? '');
$userids = self::normalizeUserids($userid);
if (empty($userids)) {
throw new ApiException('参数错误');
}
// 仅保留对该应用菜单有可见权限的用户
$userids = self::filterVisibleUserids($menu, $userids);
if (empty($userids)) {
return ['appid' => $appId, 'menu_key' => $menuKey, 'affected' => 0];
}
$count = max(0, intval($count));
$dot = filter_var($dot, FILTER_VALIDATE_BOOLEAN);
self::applySet($appId, $menuKey, $userids, $count, $dot);
self::push($appId, $menuKey, $userids, $count, $dot);
return [
'appid' => $appId,
'menu_key' => $menuKey,
'count' => $count,
'dot' => $dot,
'affected' => count($userids),
];
}
/**
* 清除指定用户在某应用某菜单的角标(用户 token 鉴权场景),并推送多端一致。
*
* @param int $userid 当前用户ID
* @param string $appId
* @param string $menuKeyInput 请求中的 menu_key可空
* @return array 响应数据
* @throws ApiException 参数/应用/菜单校验失败
*/
public static function clearForUser(int $userid, string $appId, string $menuKeyInput): array
{
if ($userid <= 0) {
throw new ApiException('参数错误');
}
if ($appId === '') {
throw new ApiException('参数错误');
}
$menu = self::resolveAppMenu($appId, $menuKeyInput);
$menuKey = (string)($menu['key'] ?? '');
AppBadge::whereAppId($appId)->whereMenuKey($menuKey)->whereUserid($userid)->delete();
// 推送给该用户的所有在线端,保证多端一致
self::push($appId, $menuKey, [$userid], 0, false);
return [
'appid' => $appId,
'menu_key' => $menuKey,
];
}
/**
* 解析应用菜单配置并定位目标菜单。
*
* @param string $appId
* @param string $menuKeyInput
* @return array 命中的菜单配置
* @throws ApiException 应用未安装或菜单不存在
*/
private static function resolveAppMenu(string $appId, string $menuKeyInput): array
{
$config = Apps::appMenuConfig($appId);
if ($config === null) {
throw new ApiException('应用未安装');
}
$menu = self::resolveMenu($config['menus'], $menuKeyInput);
if ($menu === null) {
throw new ApiException('菜单不存在');
}
return $menu;
}
/**
* 归一化目标用户ID单值/数组 -> 去重去零的整型数组。
*
* @param mixed $userid
* @return int[]
*/
private static function normalizeUserids($userid): array
{
if (is_string($userid) || is_numeric($userid)) {
$userid = [$userid];
}
if (!is_array($userid)) {
return [];
}
return self::intIds($userid);
}
/**
* 数组 -> 去重去零的整型数组。
*
* @param array $ids
* @return int[]
*/
private static function intIds(array $ids): array
{
return array_values(array_unique(array_filter(array_map('intval', $ids), fn($v) => $v > 0)));
}
/**
* 解析目标菜单menu_key 为空时取第一个菜单;否则必须命中已声明的菜单 key。
*
* @param array $menus appMenuConfig 返回的 menus
* @param string $menuKey 请求中的 menu_key可空
* @return array|null 命中的菜单配置;非法 menu_key 返回 null
*/
private static function resolveMenu(array $menus, string $menuKey): ?array
{
if ($menuKey === '') {
return $menus[0] ?? ['key' => '', 'visible' => ['all'], 'badge_clear_on_open' => false];
}
foreach ($menus as $menu) {
if (($menu['key'] ?? '') === $menuKey) {
return $menu;
}
}
return null;
}
/**
* 按菜单可见范围过滤目标用户,仅保留对该应用菜单有权限的用户。
*
* @param array $menu 命中的菜单配置(含 visible
* @param int[] $userids
* @return int[] 允许的用户ID
*/
private static function filterVisibleUserids(array $menu, array $userids): array
{
if (empty($userids)) {
return [];
}
$visible = $menu['visible'] ?? ['all'];
if (in_array('all', $visible)) {
return $userids;
}
$allowed = [];
$users = User::whereIn('userid', $userids)->get(['userid', 'identity']);
foreach ($users as $user) {
if (Setting::isCustomMicroVisibleTo($visible, $user->isAdmin(), (int)$user->userid)) {
$allowed[] = (int)$user->userid;
}
}
return $allowed;
}
/**
* 绝对设置角标幂等。count=0 dot=false 即清除(删行)。
*
* @param string $appId
* @param string $menuKey
* @param int[] $userids
* @param int $count
* @param bool $dot
* @return void
*/
private static function applySet(string $appId, string $menuKey, array $userids, int $count, bool $dot): void
{
if (empty($userids)) {
return;
}
// 清除态:一条 whereIn 删除
if ($count === 0 && !$dot) {
AppBadge::whereAppId($appId)->whereMenuKey($menuKey)->whereIn('userid', $userids)->delete();
return;
}
// 非清除态:依赖唯一键 (app_id,menu_key,userid) 批量 upsert
$now = date('Y-m-d H:i:s');
$rows = array_map(fn($uid) => [
'app_id' => $appId,
'menu_key' => $menuKey,
'userid' => (int)$uid,
'count' => $count,
'dot' => $dot,
'updated_at' => $now,
], $userids);
AppBadge::upsert($rows, ['app_id', 'menu_key', 'userid'], ['count', 'dot', 'updated_at']);
}
/**
* 清除某应用的全部角标(应用卸载时)。
*
* @param string $appId
* @return void
*/
public static function clearByApp(string $appId): void
{
$appId = trim($appId);
if ($appId === '') {
return;
}
AppBadge::whereAppId($appId)->delete();
}
/**
* 清除某用户的全部角标(用户离职时)。
*
* @param int $userid
* @return void
*/
public static function clearByUser(int $userid): void
{
if ($userid <= 0) {
return;
}
AppBadge::whereUserid($userid)->delete();
}
/**
* 获取用户当前全部角标快照,用于前端初始同步。
* 过滤掉应用已不存在(卸载 / 自定义微应用被删除)的行,避免父级聚合统计残留数据。
*
* @param int $userid
* @return array app_id => menu_key => ['count'=>int,'dot'=>bool]
*/
public static function userBadges(int $userid): array
{
$map = [];
if ($userid <= 0) {
return $map;
}
$rows = AppBadge::whereUserid($userid)->get(['app_id', 'menu_key', 'count', 'dot']);
if ($rows->isEmpty()) {
return $map;
}
// 自定义微应用 id 集合一次性收,避免每行重复 foreach
$customIds = [];
$customApps = Base::setting('microapp_menu');
if (is_array($customApps)) {
foreach ($customApps as $app) {
if (is_array($app) && !empty($app['id'])) {
$customIds[(string)$app['id']] = true;
}
}
}
// 按 app_id 缓存判定结果:插件应用走 Apps::isInstalled单 yaml + 请求级缓存),
// 自定义微应用查 set避免每行都读 yaml / 遍历 setting。
$exists = [];
foreach ($rows as $row) {
$appId = (string)$row->app_id;
if (!isset($exists[$appId])) {
$exists[$appId] = isset($customIds[$appId]) || Apps::isInstalled($appId);
}
if (!$exists[$appId]) {
continue;
}
$map[$appId][$row->menu_key] = [
'count' => (int)$row->count,
'dot' => (bool)$row->dot,
];
}
return $map;
}
/**
* 向在线用户实时推送角标变更(仅投递,不补发离线)。
*
* @param string $appId
* @param string $menuKey
* @param int[] $userids
* @param int $count
* @param bool $dot
* @return void
*/
public static function push(string $appId, string $menuKey, array $userids, int $count, bool $dot): void
{
$userids = self::intIds($userids);
if (empty($userids)) {
return;
}
PushTask::push([
'userid' => $userids,
'msg' => [
'type' => 'appBadge',
'data' => [
'appid' => $appId,
'menu_key' => $menuKey,
'count' => $count,
'dot' => $dot,
],
],
], false);
}
}

View File

@ -2074,8 +2074,20 @@ class Base
*/
public static function upload($param)
{
// 可选 key 默认值,下游直接访问不会 undefined index
$param += [
'chmod' => 0644,
'saveName' => null,
'scale' => null,
'size' => 0,
'fileName' => null,
'quality' => null,
'autoThumb' => null,
'convertVideo' => null,
'compressVideo' => null,
];
$file = $param['file'];
$chmod = $param['chmod'] ?: 0644;
$chmod = $param['chmod'];
if (empty($file)) {
return Base::retError("您没有选择要上传的文件");
}
@ -2130,6 +2142,9 @@ class Base
$limitSize = intval($param['size']);
if ($limitSize <= 0) {
$fileUploadLimit = intval(Base::settingFind('system', 'file_upload_limit', 0));
if ($fileUploadLimit <= 0) {
$fileUploadLimit = 1024;
}
$limitSize = $fileUploadLimit * 1024;
}
try {
@ -2317,6 +2332,27 @@ class Base
}
}
/**
* 把本地文件包装成 UploadedFile(test=true) 转给 Base::upload复用全套上传逻辑。
* @param array $param path_local + name 为本方法特有,其余与 Base::upload 一致
*/
public static function uploadFromPath(array $param)
{
$localPath = $param['path_local'] ?? '';
$name = $param['name'] ?? '';
if (!$localPath || !is_file($localPath)) {
return Base::retError('源文件不存在');
}
if (!$name) {
$name = basename($localPath);
}
unset($param['path_local'], $param['name']);
// test=true → UploadedFile::move 走 rename(),绕开 move_uploaded_file 的 is_uploaded_file 校验
$param['file'] = new \Illuminate\Http\UploadedFile($localPath, $name, null, null, true);
$param['fileName'] = $param['fileName'] ?? $name;
return self::upload($param);
}
/**
* 上传文件移动
* @param array $uploadResult

511
app/Module/ChunkUpload.php Normal file
View File

@ -0,0 +1,511 @@
<?php
namespace App\Module;
use App\Exceptions\ApiException;
use App\Models\File as FileModel;
use App\Models\FileContent;
use App\Models\User;
use App\Models\WebSocketDialog;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Redis;
/**
* 分片上传核心:状态机与磁盘/Redis 调度。
*
* 流程start receive × N merge (scene dispatcher) cleanup
*
* Redis key:
* upload:{upload_id} JSON 元数据 (TTL 24h)
* upload:{upload_id}:chunks SET 已收分片 index (TTL 24h)
* upload:hash:{userid}:{hash} upload_id 反查(续传 / hash 复用) (TTL 24h)
*
* 磁盘:
* uploads/tmp/chunks/{userid}/{upload_id}/{index}
*/
class ChunkUpload
{
/** 单个分片大小5MB。注意要小于 Swoole package_max_length 1G */
const CHUNK_SIZE = 5 * 1024 * 1024;
/** 状态/反查索引 TTL24h */
const STATE_TTL = 86400;
/** 单文件硬上限KB系统设置之外的兜底保护10G */
const MAX_FILE_KB = 10 * 1024 * 1024;
/** 支持的 scene 枚举 */
const SCENES = ['file_cabinet', 'dialog_file', 'image', 'generic_file'];
/**
* 启动上传。
* - 同用户同 hash 命中 files 秒传
* - 同用户同 hash 命中 upload 反查 续传
* - 否则新建 upload_id
*
* @param User $user
* @param array $param [hash, size(B), name, scene, scene_params(array)]
* @return array
*/
public static function start(User $user, array $param): array
{
$hash = strtolower(trim($param['hash'] ?? ''));
$size = intval($param['size'] ?? 0);
$name = trim($param['name'] ?? '');
$scene = trim($param['scene'] ?? '');
$sceneParams = $param['scene_params'] ?? [];
if (!is_array($sceneParams)) {
$sceneParams = [];
}
if (strlen($hash) !== 32) {
return Base::retError('文件 hash 格式错误');
}
if ($size <= 0) {
return Base::retError('文件大小无效');
}
if (intval(ceil($size / 1024)) > self::MAX_FILE_KB) {
return Base::retError('文件超过系统支持的最大尺寸');
}
// init 时拦截系统配置上限,避免传完分片才在 merge 阶段被 Base::upload 拒绝
$fileUploadLimit = intval(Base::settingFind('system', 'file_upload_limit', 0));
if ($fileUploadLimit <= 0) {
$fileUploadLimit = 1024;
}
if ($size > $fileUploadLimit * 1024 * 1024) {
return Base::retError('文件大小超限,最大限制:' . $fileUploadLimit . 'MB');
}
if ($name === '') {
return Base::retError('文件名不能为空');
}
if (!in_array($scene, self::SCENES, true)) {
return Base::retError('不支持的上传场景');
}
// 1) 秒传:同用户已上传过同 hash 文件 → 直接复用入库
$hit = self::trySecondPass($user, $scene, $hash, $name, $sceneParams);
if ($hit !== null) {
return Base::retSuccess('success', $hit);
}
// 2) 续传:同用户同 hash 有未完成上传
$reuseKey = self::keyHashIndex($user->userid, $hash);
$existingId = Redis::get($reuseKey);
if ($existingId) {
$meta = self::loadMeta($existingId);
if ($meta && $meta['userid'] === $user->userid && $meta['hash'] === $hash) {
return Base::retSuccess('success', self::sessionView($existingId, $meta));
}
// 反查指向了已失效的 upload_id清掉
Redis::del($reuseKey);
}
// 3) 新建
$uploadId = Base::generatePassword(32);
$chunkCount = intval(ceil($size / self::CHUNK_SIZE));
$meta = [
'hash' => $hash,
'size' => $size,
'name' => $name,
'scene' => $scene,
'scene_params' => $sceneParams,
'userid' => intval($user->userid),
'chunk_size' => self::CHUNK_SIZE,
'chunk_count' => $chunkCount,
'created_at' => time(),
];
Redis::setex(self::keyMeta($uploadId), self::STATE_TTL, json_encode($meta, JSON_UNESCAPED_UNICODE));
Redis::setex($reuseKey, self::STATE_TTL, $uploadId);
Base::makeDir(self::chunkDir($user->userid, $uploadId));
return Base::retSuccess('success', self::sessionView($uploadId, $meta));
}
/**
* 接收一个分片。
*
* @param User $user
* @param string $uploadId
* @param int $index 分片序号0-based
* @param UploadedFile|null $blob
* @return array
*/
public static function receive(User $user, string $uploadId, int $index, $blob): array
{
$meta = self::loadMeta($uploadId);
if (!$meta) {
return Base::retError('上传会话不存在或已过期');
}
if ($meta['userid'] !== intval($user->userid)) {
return Base::retError('上传会话归属错误');
}
if ($index < 0 || $index >= $meta['chunk_count']) {
return Base::retError('分片序号超出范围');
}
if (!$blob || !$blob->isValid()) {
return Base::retError('分片数据无效');
}
// 最后一片可能小于 CHUNK_SIZE其余必须等于
$isLast = $index === $meta['chunk_count'] - 1;
$chunkSize = $blob->getSize();
if (!$isLast && $chunkSize !== self::CHUNK_SIZE) {
return Base::retError('分片大小不符合预期');
}
if ($isLast) {
$expectLast = $meta['size'] - self::CHUNK_SIZE * ($meta['chunk_count'] - 1);
if ($chunkSize !== $expectLast) {
return Base::retError('末尾分片大小不符合预期');
}
}
$dir = self::chunkDir($user->userid, $uploadId);
Base::makeDir($dir);
$blob->move($dir, (string)$index);
// 记录已收 + 续期三个相关 key
Redis::sadd(self::keyChunks($uploadId), $index);
Redis::expire(self::keyChunks($uploadId), self::STATE_TTL);
Redis::expire(self::keyMeta($uploadId), self::STATE_TTL);
Redis::expire(self::keyHashIndex($user->userid, $meta['hash']), self::STATE_TTL);
return Base::retSuccess('success', [
'upload_id' => $uploadId,
'received' => self::receivedList($uploadId),
]);
}
/**
* 合并分片并入库。需要在 Lock 内调用。
*
* @param User $user
* @param string $uploadId
* @return array scene 入库返回结构(与 retSuccess/retError 对齐)
*/
public static function merge(User $user, string $uploadId): array
{
$meta = self::loadMeta($uploadId);
if (!$meta) {
return Base::retError('上传会话不存在或已过期');
}
if ($meta['userid'] !== intval($user->userid)) {
return Base::retError('上传会话归属错误');
}
$received = self::receivedList($uploadId);
if (count($received) !== $meta['chunk_count']) {
return Base::retError('分片不完整,无法合并');
}
return Lock::withLock("upload:merge:{$uploadId}", function () use ($user, $uploadId, $meta) {
$dir = self::chunkDir($user->userid, $uploadId);
$mergedPath = $dir . '/merged.' . substr($meta['hash'], 0, 8);
$writeFp = @fopen($mergedPath, 'wb');
if (!$writeFp) {
return Base::retError('无法创建合并文件');
}
// 拼接与 md5 同步进行:一遍磁盘读完成"写文件 + 算 hash"
$hashCtx = hash_init('md5');
try {
for ($i = 0; $i < $meta['chunk_count']; $i++) {
$partPath = $dir . '/' . $i;
$readFp = @fopen($partPath, 'rb');
if (!$readFp) {
return Base::retError("分片读取失败:{$i}");
}
while (!feof($readFp)) {
$buf = fread($readFp, 1024 * 1024);
if ($buf === false) {
fclose($readFp);
return Base::retError("分片读取失败:{$i}");
}
fwrite($writeFp, $buf);
hash_update($hashCtx, $buf);
}
fclose($readFp);
}
} finally {
fclose($writeFp);
}
$actualHash = hash_final($hashCtx);
if ($actualHash !== $meta['hash']) {
@unlink($mergedPath);
return Base::retError('文件校验失败,请重试');
}
// 调用 scene 入库
$result = self::dispatch($user, $meta, $mergedPath);
// 清理(无论成功失败都清,失败用户重新启 upload
self::cleanup($user->userid, $uploadId, $meta['hash']);
return $result;
}, 60000, 60000);
}
/**
* 用户主动取消:校验归属后清理。会话不存在或归属错误一律静默成功,前端取消按钮不需要分支处理。
*/
public static function cancelByUser(User $user, string $uploadId): void
{
$meta = self::loadMeta($uploadId);
if (!$meta || intval($meta['userid'] ?? 0) !== $user->userid) {
return;
}
self::cleanup($user->userid, $uploadId, $meta['hash'] ?? '');
}
/**
* 清理一个 upload_id 的所有状态。
*/
public static function cleanup(int $userid, string $uploadId, string $hash = ''): void
{
Redis::del(self::keyMeta($uploadId));
Redis::del(self::keyChunks($uploadId));
if ($hash) {
Redis::del(self::keyHashIndex($userid, $hash));
}
$dir = self::chunkDir($userid, $uploadId);
if (is_dir($dir)) {
Base::deleteDirAndFile($dir);
}
}
// ===== scene dispatcher =====
/**
* 把合并后的本地文件交给对应 scene 入库。
* 返回结构对齐各 scene 老接口的 retSuccess。
*/
protected static function dispatch(User $user, array $meta, string $mergedPath): array
{
$scene = $meta['scene'];
$name = $meta['name'];
$hash = $meta['hash'];
$params = $meta['scene_params'] ?? [];
switch ($scene) {
case 'file_cabinet':
$pid = intval($params['pid'] ?? 0);
$webkitRelativePath = strval($params['webkit_relative_path'] ?? $name);
$overwrite = boolval($params['overwrite'] ?? false);
// pid 锁避免与并发上传的 handleDuplicateName / 中间目录创建竞态
try {
return Lock::withLock("file:upload:{$user->userid}:{$pid}", function () use ($user, $pid, $mergedPath, $name, $webkitRelativePath, $hash, $overwrite) {
$result = (new FileModel)->contentUploadFromPath($user, $pid, $mergedPath, $name, $webkitRelativePath, $hash, $overwrite);
$outName = $result['data']['name'] ?? $name;
return Base::retSuccess($outName . ' 上传成功', $result['addItem']);
}, 120000, 120000);
} catch (ApiException $e) {
return Base::retError($e->getMessage());
} catch (\Exception $e) {
if (str_contains($e->getMessage(), 'Failed to acquire lock')) {
return Base::retError('上传繁忙,请稍后再试');
}
return Base::retError($e->getMessage());
}
case 'image':
// 头像 / 系统图片 / 编辑器粘贴图片,对齐 system/imgupload
$width = intval($params['width'] ?? 0);
$height = intval($params['height'] ?? 0);
$whcut = strval($params['whcut'] ?? 'percentage');
$whcut = match ($whcut) {
'1' => 'cover',
'0' => 'contain',
'cover', 'contain' => $whcut,
default => 'percentage',
};
$scale = [$width ?: 2160, $height ?: 4160, $whcut];
$imagePath = "uploads/user/picture/" . $user->userid . "/" . date("Ym") . "/";
$data = Base::uploadFromPath([
"path_local" => $mergedPath,
"name" => $name,
"type" => 'image',
"path" => $imagePath,
"scale" => $scale,
"quality" => true,
]);
if (Base::isError($data)) {
return $data;
}
return Base::retSuccess('success', $data['data']);
case 'generic_file':
// 编辑器粘贴文件 / 系统通用文件,对齐 system/fileupload
$filePath = "uploads/user/file/" . $user->userid . "/" . date("Ym") . "/";
$data = Base::uploadFromPath([
"path_local" => $mergedPath,
"name" => $name,
"type" => 'file',
"path" => $filePath,
"quality" => true,
]);
return $data;
case 'dialog_file':
// 聊天发文件 + 任务附件共用同一接入(任务附件本质是任务对话流的一条消息)
$dialogIds = $params['dialog_ids'] ?? [];
if (!is_array($dialogIds)) {
$dialogIds = [$dialogIds];
}
$dialogIds = array_values(array_filter(array_map('intval', $dialogIds)));
if (empty($dialogIds)) {
return Base::retError('dialog_ids 不能为空');
}
$replyId = intval($params['reply_id'] ?? 0);
$imageAttachment = boolval($params['image_attachment'] ?? false);
try {
return WebSocketDialog::sendMsgFilesFromPath($user, $dialogIds, $mergedPath, $name, $replyId, $imageAttachment);
} catch (ApiException $e) {
return Base::retError($e->getMessage());
}
default:
return Base::retError("scene 暂未实现:{$scene}");
}
}
/**
* hash 命中则在目标位置复用源 FileContent 指向的物理文件,零字节传输。
* 未命中返回 null 让上层走真上传。
*/
protected static function trySecondPass(User $user, string $scene, string $hash, string $name, array $sceneParams): ?array
{
if ($scene !== 'file_cabinet') {
return null;
}
$hit = FileModel::whereUserid($user->userid)->whereHash($hash)->whereNull('deleted_at')->first();
if (!$hit) {
return null;
}
$srcContent = FileContent::whereFid($hit->id)->orderByDesc('id')->first();
if (!$srcContent) {
return null;
}
$contentArr = is_array($srcContent->content)
? $srcContent->content
: json_decode($srcContent->content, true);
if (empty($contentArr['url'])) {
return null;
}
$rawPid = intval($sceneParams['pid'] ?? 0);
$webkitRelativePath = strval($sceneParams['webkit_relative_path'] ?? $name);
$overwrite = boolval($sceneParams['overwrite'] ?? false);
try {
return Lock::withLock("file:upload:{$user->userid}:{$rawPid}", function () use ($user, $rawPid, $webkitRelativePath, $overwrite, $hit, $hash, $name, $contentArr) {
[$pid, $userid, $addItem] = (new FileModel)->contentUploadPrep($user, $rawPid, $webkitRelativePath);
$ext = $hit->ext;
$bareName = Base::rightDelete($name, '.' . $ext);
$existing = null;
if ($overwrite) {
$existing = FileModel::wherePid($pid)->whereName($bareName)->whereExt($ext)->whereNull('deleted_at')->first();
}
if ($existing) {
$existing->size = $hit->size;
$existing->hash = $hash;
$existing->type = $hit->type;
if (!$existing->saveBeforePP()) {
throw new ApiException('秒传保存失败');
}
FileContent::createInstance([
'fid' => $existing->id,
'content' => $contentArr,
'text' => '',
'size' => $existing->size,
'userid' => $user->userid,
])->save();
$created = FileModel::find($existing->id);
$overwriteFlag = 1;
} else {
$newFile = FileModel::createInstance([
'pid' => $pid,
'name' => $bareName,
'type' => $hit->type,
'ext' => $ext,
'size' => $hit->size,
'hash' => $hash,
'userid' => $userid,
'created_id' => $user->userid,
]);
$newFile->handleDuplicateName();
if (!$newFile->saveBeforePP()) {
throw new ApiException('秒传保存失败');
}
FileContent::createInstance([
'fid' => $newFile->id,
'content' => $contentArr,
'text' => '',
'size' => $newFile->size,
'userid' => $user->userid,
])->save();
$created = FileModel::find($newFile->id);
$overwriteFlag = 0;
}
$created->pushMsg($overwriteFlag ? 'update' : 'add', $created);
$data = FileModel::handleImageUrl($created->toArray());
$data['full_name'] = $name;
$data['overwrite'] = $overwriteFlag;
$addItem[] = $data;
return [
'done' => true,
'instant' => true,
'addItem' => $addItem,
'msg' => $name . ' 秒传成功',
];
}, 120000, 120000);
} catch (\Throwable $_e) {
// 退化到真上传:错误由 dispatch 阶段权威报出,避免两条路径错误码不一致
return null;
}
}
// ===== helpers =====
protected static function keyMeta(string $uploadId): string
{
return "upload:{$uploadId}";
}
protected static function keyChunks(string $uploadId): string
{
return "upload:{$uploadId}:chunks";
}
protected static function keyHashIndex(int $userid, string $hash): string
{
return "upload:hash:{$userid}:{$hash}";
}
protected static function chunkDir(int $userid, string $uploadId): string
{
return public_path("uploads/tmp/chunks/{$userid}/{$uploadId}");
}
protected static function loadMeta(string $uploadId): ?array
{
$raw = Redis::get(self::keyMeta($uploadId));
if (!$raw) {
return null;
}
$data = json_decode($raw, true);
return is_array($data) ? $data : null;
}
protected static function receivedList(string $uploadId): array
{
$list = Redis::smembers(self::keyChunks($uploadId)) ?: [];
$list = array_map('intval', $list);
sort($list);
return $list;
}
protected static function sessionView(string $uploadId, array $meta): array
{
return [
'done' => false,
'upload_id' => $uploadId,
'chunk_size' => $meta['chunk_size'],
'chunk_count' => $meta['chunk_count'],
'received' => self::receivedList($uploadId),
];
}
}

View File

@ -0,0 +1,538 @@
<?php
namespace App\Module;
use App\Exceptions\ApiException;
use App\Models\ProjectUser;
use App\Models\Setting;
use App\Models\User;
use App\Models\UserDepartment;
use Carbon\Carbon;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
* 仪表盘负责人视角数据聚合。
*
* 口径:所选管理部门(含下级部门)的在职成员所参与、且允许负责人查看的未归档项目;
* 仅统计主任务、全员可见任务,任务需由范围内成员负责或当前没有负责人。
*/
class DashboardTeam
{
public const SOON_DAYS = 3;
public const HIGH_PRIORITY_COUNT = 2;
public const STATS_CACHE_SECONDS = 30;
/**
* 解析并校验负责人视角范围。
*/
public static function context(User $user, $selectedDepartmentIds = null): array
{
if (Base::settingFind('system', 'department_owner_project_view', 'open') !== 'open') {
throw new ApiException('未开启部门负责人视角功能');
}
$managedIds = UserDepartment::getManagedDepartments($user->userid)
->pluck('id')
->map(fn($id) => intval($id))
->values()
->toArray();
if (empty($managedIds)) {
throw new ApiException('没有可查看的部门数据');
}
$selectedIds = self::normalizeSelectedDepartmentIds($selectedDepartmentIds, $managedIds);
if (empty($selectedIds)) {
throw new ApiException('没有可查看的部门数据');
}
$departmentIds = self::expandDepartmentIds($selectedIds);
$members = self::loadMembers($departmentIds);
$memberUserids = $members->pluck('userid')->map(fn($id) => intval($id))->values()->toArray();
$projectIds = self::loadProjectIds($memberUserids);
$ownProjectIds = empty($projectIds) ? [] : ProjectUser::whereUserid($user->userid)
->whereIn('project_id', $projectIds)
->pluck('project_id')
->map(fn($id) => intval($id))
->unique()
->values()
->toArray();
return [
'viewer_userid' => intval($user->userid),
'selected_department_ids' => $selectedIds,
'department_ids' => $departmentIds,
'member_userids' => $memberUserids,
'member_map' => $members->keyBy('userid')->map(fn(User $member) => [
'userid' => intval($member->userid),
'nickname' => $member->nickname,
'userimg' => $member->userimg,
])->toArray(),
'project_ids' => $projectIds,
'own_project_id_map' => array_fill_keys($ownProjectIds, true),
];
}
/**
* 团队统计。
*/
public static function stats(array $context, bool $refresh = false): array
{
$cacheKey = 'dashboard:team:stats:v3:' . $context['viewer_userid'] . ':' . sha1(json_encode([
$context['selected_department_ids'],
$context['department_ids'],
$context['member_userids'],
$context['project_ids'],
]));
if ($refresh) {
Cache::forget($cacheKey);
}
return Cache::remember($cacheKey, now()->addSeconds(self::STATS_CACHE_SECONDS), function () use ($context) {
$now = Carbon::now();
$taskAlias = DB::getTablePrefix() . 't';
$soonEnd = $now->clone()->addDays(self::SOON_DAYS)->endOfDay();
$weekStart = $now->clone()->startOfWeek(Carbon::MONDAY)->startOfDay();
$nextWeekStart = $weekStart->clone()->addWeek();
$lastWeekStart = $weekStart->clone()->subWeek();
$base = self::baseTaskBuilder($context);
$core = (clone $base)->selectRaw("
SUM(CASE WHEN {$taskAlias}.complete_at IS NULL THEN 1 ELSE 0 END) AS stat_uncompleted,
SUM(CASE WHEN {$taskAlias}.complete_at IS NULL AND {$taskAlias}.end_at IS NOT NULL AND {$taskAlias}.end_at < ? THEN 1 ELSE 0 END) AS stat_overdue,
SUM(CASE WHEN {$taskAlias}.complete_at IS NULL AND {$taskAlias}.end_at IS NOT NULL AND {$taskAlias}.end_at >= ? AND {$taskAlias}.end_at <= ? THEN 1 ELSE 0 END) AS stat_due_soon,
SUM(CASE WHEN {$taskAlias}.complete_at >= ? AND {$taskAlias}.complete_at < ? THEN 1 ELSE 0 END) AS stat_week_completed,
SUM(CASE WHEN {$taskAlias}.complete_at >= ? AND {$taskAlias}.complete_at < ? THEN 1 ELSE 0 END) AS stat_last_week_completed
", [
$now->toDateTimeString(),
$now->toDateTimeString(),
$soonEnd->toDateTimeString(),
$weekStart->toDateTimeString(),
$nextWeekStart->toDateTimeString(),
$lastWeekStart->toDateTimeString(),
$weekStart->toDateTimeString(),
])->first();
$noOwner = (clone $base)
->whereNull('t.complete_at')
->whereNotExists(self::ownerExistsQuery('t.id'))
->count();
$members = self::memberDistribution($context, $now);
return [
'generated_at' => $now->toDateTimeString(),
'member_count' => count($context['member_userids']),
'blocks' => [
'uncompleted' => intval($core->stat_uncompleted ?? 0),
'overdue' => intval($core->stat_overdue ?? 0),
'overdue_owner_count' => count(array_filter($members, fn($member) => $member['overdue'] > 0)),
'due_soon' => intval($core->stat_due_soon ?? 0),
'week_completed' => intval($core->stat_week_completed ?? 0),
'last_week_completed' => intval($core->stat_last_week_completed ?? 0),
'no_owner' => intval($noOwner),
],
'priority' => self::priorityDistribution($context),
'members' => $members,
'high_levels' => self::highPriorityLevels(),
];
});
}
/**
* 分页任务列表。
*/
public static function tasks(array $context, array $filters)
{
$now = Carbon::now();
$soonEnd = $now->clone()->addDays(self::SOON_DAYS)->endOfDay();
$memberId = intval($filters['member_id'] ?? 0);
$level = $filters['level'] ?? null;
$type = $filters['type'] ?? '';
$builder = self::baseTaskBuilder($context)->whereNull('t.complete_at');
if ($memberId > 0) {
$builder->whereExists(self::ownerExistsQuery('t.id', [$memberId]));
} elseif ($level !== null) {
if (intval($level) === -1) {
$levels = self::priorityLevels();
if (!empty($levels)) {
$builder->whereNotIn('t.p_level', $levels);
}
} else {
$builder->where('t.p_level', intval($level));
}
} else {
switch ($type) {
case 'overdue':
$builder->whereNotNull('t.end_at')->where('t.end_at', '<', $now);
break;
case 'soon':
$builder->whereNotNull('t.end_at')->whereBetween('t.end_at', [$now, $soonEnd]);
break;
case 'hi':
$levels = self::highPriorityLevels();
$builder->whereIn('t.p_level', $levels ?: [-1]);
break;
case 'noowner':
$builder->whereNotExists(self::ownerExistsQuery('t.id'));
break;
}
}
$taskAlias = DB::getTablePrefix() . 't';
$builder->leftJoin('project_flow_items as fi', 'fi.id', '=', 't.flow_item_id')
->select([
't.id',
't.parent_id',
't.project_id',
't.column_id',
't.name',
't.end_at',
't.p_level',
't.p_name',
't.p_color',
't.flow_item_id',
't.flow_item_name',
'p.name as project_name',
'fi.status as flow_item_status',
'fi.color as flow_item_color',
])
->orderByRaw("{$taskAlias}.end_at IS NULL")
->orderBy('t.end_at')
->orderByDesc('t.id');
$list = $builder->paginate(Base::getPaginate(50, 20));
$taskIds = $list->getCollection()->pluck('id')->map(fn($id) => intval($id))->toArray();
$owners = self::taskOwners($taskIds, $context['member_userids'], $memberId);
$ownProjectMap = $context['own_project_id_map'];
$list->setCollection($list->getCollection()->map(function ($task) use ($owners, $ownProjectMap) {
$item = (array)$task;
$item['id'] = intval($item['id']);
$item['parent_id'] = intval($item['parent_id']);
$item['project_id'] = intval($item['project_id']);
$item['column_id'] = intval($item['column_id']);
$item['p_level'] = intval($item['p_level']);
$flowParts = explode('|', $item['flow_item_name'] ?: '');
if (count($flowParts) >= 2) {
$item['flow_item_status'] = $item['flow_item_status'] ?: ($flowParts[0] ?? '');
$item['flow_item_name'] = $flowParts[1] ?? $item['flow_item_name'];
$item['flow_item_color'] = $item['flow_item_color'] ?: ($flowParts[2] ?? '');
}
$item['owners'] = $owners[$item['id']] ?? [];
$item['owner'] = $item['owners'][0] ?? null;
$item['department_readonly'] = !isset($ownProjectMap[$item['project_id']]);
return $item;
}));
return $list;
}
/**
* 当前系统优先级ID列表保持设置顺序
* @return array<int>
*/
public static function priorityLevels(): array
{
return array_map(fn($item) => intval($item['priority']), self::priorityList());
}
/**
* 基础任务范围:主任务、全员可见、未归档项目,且由团队成员负责或没有负责人。
*/
protected static function baseTaskBuilder(array $context): Builder
{
$projectIds = $context['project_ids'] ?: [0];
$memberUserids = $context['member_userids'];
return DB::table('project_tasks as t')
->join('projects as p', 'p.id', '=', 't.project_id')
->whereIn('t.project_id', $projectIds)
->where('t.parent_id', 0)
->where('t.visibility', 1)
->whereNull('t.archived_at')
->whereNull('t.deleted_at')
->whereNull('p.archived_at')
->whereNull('p.deleted_at')
->where(function (Builder $query) {
$query->where('p.department_owner_view', '<>', 'close')
->orWhereNull('p.department_owner_view');
})
->where(function (Builder $query) use ($memberUserids) {
if (empty($memberUserids)) {
$query->whereRaw('1 = 0');
return;
}
$query->whereExists(self::ownerExistsQuery('t.id', $memberUserids))
->orWhereNotExists(self::ownerExistsQuery('t.id'));
});
}
/**
* 负责人存在子查询。
*/
protected static function ownerExistsQuery(string $taskColumn, ?array $userids = null): callable
{
return function (Builder $query) use ($taskColumn, $userids) {
$query->selectRaw('1')
->from('project_task_users as owner_scope')
->whereColumn('owner_scope.task_id', $taskColumn)
->where('owner_scope.owner', 1);
if ($userids !== null) {
$query->whereIn('owner_scope.userid', $userids ?: [0]);
}
};
}
/**
* 优先级分布(包含无负责人任务)。
*/
protected static function priorityDistribution(array $context): array
{
$taskAlias = DB::getTablePrefix() . 't';
$rows = self::baseTaskBuilder($context)
->whereNull('t.complete_at')
->selectRaw("{$taskAlias}.p_level, COUNT(*) AS stat_num")
->groupBy('t.p_level')
->pluck('stat_num', 't.p_level');
$result = [];
$matched = [];
foreach (self::priorityList() as $item) {
$level = intval($item['priority']);
$matched[] = $level;
$result[] = [
'level' => $level,
'name' => $item['name'],
'color' => $item['color'],
'num' => intval($rows->get($level, 0)),
];
}
$unset = 0;
foreach ($rows as $level => $num) {
if (!in_array(intval($level), $matched, true)) {
$unset += intval($num);
}
}
if ($unset > 0) {
$result[] = [
'level' => -1,
'name' => '',
'color' => '#c5c8ce',
'num' => $unset,
];
}
return $result;
}
/**
* 成员任务分配。流程阶段与超期风险独立统计,无工作流的普通未完成任务归入待处理;
* 多负责人任务会分别计入每个负责人的工作量,团队总数仍按任务去重。
*/
protected static function memberDistribution(array $context, Carbon $now): array
{
$memberUserids = $context['member_userids'];
if (empty($memberUserids) || empty($context['project_ids'])) {
return array_values(array_map(fn($member) => array_merge($member, [
'total' => 0,
'overdue' => 0,
'segments' => ['progress' => 0, 'start' => 0, 'test' => 0],
]), $context['member_map']));
}
$nowText = $now->toDateTimeString();
$prefix = DB::getTablePrefix();
$taskAlias = $prefix . 't';
$taskUserAlias = $prefix . 'tu';
$flowAlias = $prefix . 'fi';
$rows = DB::table('project_task_users as tu')
->join('project_tasks as t', 't.id', '=', 'tu.task_id')
->join('projects as p', 'p.id', '=', 't.project_id')
->leftJoin('project_flow_items as fi', 'fi.id', '=', 't.flow_item_id')
->whereIn('tu.userid', $memberUserids)
->where('tu.owner', 1)
->whereIn('t.project_id', $context['project_ids'])
->where('t.parent_id', 0)
->where('t.visibility', 1)
->whereNull('t.complete_at')
->whereNull('t.archived_at')
->whereNull('t.deleted_at')
->whereNull('p.archived_at')
->whereNull('p.deleted_at')
->where(function (Builder $query) {
$query->where('p.department_owner_view', '<>', 'close')
->orWhereNull('p.department_owner_view');
})
->selectRaw("
{$taskUserAlias}.userid,
COUNT(DISTINCT {$taskAlias}.id) AS stat_total,
COUNT(DISTINCT CASE WHEN {$taskAlias}.end_at IS NOT NULL AND {$taskAlias}.end_at < ? THEN {$taskAlias}.id END) AS stat_overdue,
COUNT(DISTINCT CASE WHEN {$flowAlias}.status = 'progress' THEN {$taskAlias}.id END) AS stat_progress,
COUNT(DISTINCT CASE WHEN {$flowAlias}.status = 'test' THEN {$taskAlias}.id END) AS stat_test,
COUNT(DISTINCT CASE WHEN {$flowAlias}.status IS NULL OR {$flowAlias}.status NOT IN ('progress', 'test') THEN {$taskAlias}.id END) AS stat_start
", [$nowText])
->groupBy('tu.userid')
->get()
->keyBy(fn($row) => intval($row->userid));
$members = [];
foreach ($context['member_map'] as $userid => $member) {
$row = $rows->get(intval($userid));
$members[] = array_merge($member, [
'total' => intval($row->stat_total ?? 0),
'overdue' => intval($row->stat_overdue ?? 0),
'segments' => [
'progress' => intval($row->stat_progress ?? 0),
'start' => intval($row->stat_start ?? 0),
'test' => intval($row->stat_test ?? 0),
],
]);
}
usort($members, function ($a, $b) {
return $b['overdue'] <=> $a['overdue']
?: $b['total'] <=> $a['total']
?: $a['userid'] <=> $b['userid'];
});
return $members;
}
/**
* 任务负责人数据,范围内成员优先展示;成员筛选时将该成员置首。
*/
protected static function taskOwners(array $taskIds, array $memberUserids, int $preferredUserid = 0): array
{
if (empty($taskIds)) {
return [];
}
$rows = DB::table('project_task_users')
->whereIn('task_id', $taskIds)
->where('owner', 1)
->orderBy('id')
->get(['task_id', 'userid']);
$userids = $rows->pluck('userid')->map(fn($id) => intval($id))->unique()->values()->toArray();
$users = empty($userids) ? collect() : User::select(User::$basicField)
->whereIn('userid', $userids)
->get()
->keyBy('userid');
$memberMap = array_fill_keys($memberUserids, true);
$owners = [];
foreach ($rows as $row) {
$userid = intval($row->userid);
$user = $users->get($userid);
$owners[intval($row->task_id)][] = [
'userid' => $userid,
'nickname' => $user?->nickname ?? '',
];
}
foreach ($owners as &$list) {
usort($list, function ($a, $b) use ($memberMap, $preferredUserid) {
if ($preferredUserid > 0) {
$preferred = ($b['userid'] === $preferredUserid) <=> ($a['userid'] === $preferredUserid);
if ($preferred !== 0) {
return $preferred;
}
}
$managed = isset($memberMap[$b['userid']]) <=> isset($memberMap[$a['userid']]);
return $managed !== 0 ? $managed : $a['userid'] <=> $b['userid'];
});
}
unset($list);
return $owners;
}
/**
* 在全部部门中一次性展开下级部门,避免递归 N+1
*/
protected static function expandDepartmentIds(array $selectedIds): array
{
$children = UserDepartment::select(['id', 'parent_id'])
->get()
->groupBy(fn(UserDepartment $department) => intval($department->parent_id));
$result = [];
$queue = array_values(array_unique(array_map('intval', $selectedIds)));
while (!empty($queue)) {
$departmentId = array_shift($queue);
if ($departmentId <= 0 || isset($result[$departmentId])) {
continue;
}
$result[$departmentId] = true;
foreach ($children->get($departmentId, collect()) as $child) {
$queue[] = intval($child->id);
}
}
return array_keys($result);
}
/**
* 当前范围内的在职非机器人成员。users.department 为现有权限口径的权威数据源。
*/
protected static function loadMembers(array $departmentIds): Collection
{
if (empty($departmentIds)) {
return collect();
}
return User::select(User::$basicField)
->whereNull('disable_at')
->where('bot', 0)
->where(function ($query) use ($departmentIds) {
foreach ($departmentIds as $departmentId) {
$query->orWhere('department', 'like', "%,{$departmentId},%");
}
})
->orderBy('userid')
->get();
}
/**
* 成员参与且允许负责人查看的活动项目。
*/
protected static function loadProjectIds(array $memberUserids): array
{
if (empty($memberUserids)) {
return [];
}
return DB::table('project_users as pu')
->join('projects as p', 'p.id', '=', 'pu.project_id')
->whereIn('pu.userid', $memberUserids)
->whereNull('p.archived_at')
->whereNull('p.deleted_at')
->where(function (Builder $query) {
$query->where('p.department_owner_view', '<>', 'close')
->orWhereNull('p.department_owner_view');
})
->distinct()
->orderBy('p.id')
->pluck('p.id')
->map(fn($id) => intval($id))
->toArray();
}
protected static function normalizeSelectedDepartmentIds($selectedIds, array $managedIds): array
{
if ($selectedIds === null || $selectedIds === '' || $selectedIds === 'all' || $selectedIds === []) {
return $managedIds;
}
if (!is_array($selectedIds)) {
$selectedIds = explode(',', (string)$selectedIds);
}
return array_values(array_unique(array_intersect(
array_map('intval', $selectedIds),
$managedIds
)));
}
protected static function highPriorityLevels(): array
{
return array_slice(self::priorityLevels(), 0, self::HIGH_PRIORITY_COUNT);
}
protected static function priorityList(): array
{
return Setting::normalizeTaskPriorityList(Base::setting('priority'));
}
}

View File

@ -81,6 +81,39 @@ class Doo
self::load()->licenseSave($license);
}
/**
* 解析License取字段
* @param $license
* @return array
*/
public static function licenseDecode($license): array
{
return self::load()->licenseDecode($license);
}
/**
* 校验 license SN/MAC 是否与本机匹配。通过返回 null,否则返回错误文案。
* @param array $info license 信息,含 people/sn/macmac 兼容数组或逗号串)
* @return string|null
*/
public static function licenseBindingError(array $info): ?string
{
$people = (int)($info['people'] ?? 0);
if (!($people === 0 || $people > 3)) {
return null;
}
if ((string)($info['sn'] ?? '') !== self::dooSN()) {
return '终端SN与License不匹配';
}
$mac = $info['mac'] ?? [];
$licenseMacs = array_filter(array_map('trim', is_array($mac) ? $mac : explode(',', (string)$mac)));
$curMacs = self::macs();
if ($licenseMacs && $curMacs && !array_intersect($licenseMacs, $curMacs)) {
return '终端MAC与License不匹配';
}
return null;
}
/**
* 当前会员ID来自请求的token
* @return int

View File

@ -196,13 +196,14 @@ class Ihttp
$rlt['status'] = $matches[3];
$rlt['responseline'] = $split2[0];
$header = explode("\r\n", $split2[1]);
$rlt['headers'] = [];
$isgzip = false;
$ischunk = false;
foreach ($header as $v) {
$row = explode(':', $v);
$key = trim($row[0]);
$value = trim(substr($v, strlen($row[0]) + 1));
if (is_array($rlt['headers'][$key])) {
if (isset($rlt['headers'][$key]) && is_array($rlt['headers'][$key])) {
$rlt['headers'][$key][] = $value;
} elseif (!empty($rlt['headers'][$key])) {
$temp = $rlt['headers'][$key];

View File

@ -8,6 +8,7 @@ use App\Module\Base;
use App\Module\AI;
use PDO;
use PDOException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
/**
@ -21,6 +22,30 @@ class ManticoreBase
private static ?PDO $pdo = null;
private static bool $initialized = false;
/**
* 向量表结构版本;修改表结构/向量列参数时递增,触发已部署实例自动重建
*/
private const SCHEMA_VERSION = 1;
/**
* Auto Embeddings MODEL_NAME。必须用 Manticore 不认识的名字:
* 已知名字(如 text-embedding-ada-002)会按引擎硬编码维度校验,
* 未知名字才会在建表时向 API_URL 探测真实维度(免费模型为 1024)。
* 实际模型由 ai 插件的 EMBEDDING_MODEL 决定,此处仅为路由标签。
*/
private const EMBEDDING_MODEL_NAME = 'openai/qwen3-embedding';
/**
* 批量写入分块上限行数与字节预算Manticore max_allowed_packet 默认 128MB取保守值
*/
private const BATCH_CHUNK_ROWS = 30;
private const BATCH_CHUNK_BYTES = 8388608;
/**
* 5 张向量表名(键值即 VECTOR_TABLE_CONFIG type
*/
private const VECTOR_TABLES = ['msg', 'file', 'task', 'project', 'user'];
private string $host;
private int $port;
@ -70,13 +95,169 @@ class ManticoreBase
/**
* 初始化表结构
*
* 向量列使用 Manticore Auto EmbeddingsMODEL_NAME/API_URL 指向 ai 插件 /embeddings
* 引擎在写入/更新行时自动按 FROM 字段生成向量,无需 PHP 侧生成。
* key_values 中的 vector:schema 标记记录当前结构指纹(结构版本/模型/端点/APP_KEY 哈希),
* 不匹配首次安装、老版本升级、APP_KEY 轮换)即整体重建并重置同步指针,触发全量重灌。
*/
private function initializeTables(PDO $pdo): void
{
try {
// 创建文件向量表
// charset_table='non_cjk, cjk' 同时支持英文和中日韩文字
// 键值表必须最先建(用于读取/持久化结构标记与同步指针)
$pdo->exec("
CREATE TABLE IF NOT EXISTS key_values (
id BIGINT,
k STRING,
v TEXT
)
");
$expected = self::schemaMarker();
if (self::kvGetPdo($pdo, 'vector:schema') === $expected
&& self::allVectorTablesExist($pdo)
&& !self::embeddingModelChanged($pdo)) {
return;
}
// 并发进程只允许一个执行重建;抢锁失败的进程本轮写入失败会进重试队列,无碍
$lock = Cache::lock('manticore:schema-rebuild', 300);
if (!$lock->get()) {
return;
}
try {
// 拿到锁时可能另一进程刚完成重建marker 写入在锁内),先复检避免重复重建
if (self::kvGetPdo($pdo, 'vector:schema') === $expected
&& self::allVectorTablesExist($pdo)
&& !self::embeddingModelChanged($pdo)) {
return;
}
// 先用一次性探针表验证 ai 端点可用CREATE 会向端点探测维度、未就绪则抛错),
// 通过后才 DROP 现有表——避免 ai 未就绪时销毁旧索引后建不回来
$pdo->exec("DROP TABLE IF EXISTS _schema_probe");
$pdo->exec("CREATE TABLE _schema_probe (t TEXT, " . self::vectorColumnDDL('t') . ")");
$pdo->exec("DROP TABLE IF EXISTS _schema_probe");
foreach (self::vectorTableDDLs() as $table => $ddl) {
$pdo->exec("DROP TABLE IF EXISTS {$table}");
$pdo->exec($ddl);
}
self::resetSyncPointersPdo($pdo);
// 清理旧向量管道遗留键vector:dim 与 vector:*LastId 指针)
$legacy = ["'vector:dim'"];
foreach (self::VECTOR_TABLES as $t) {
$legacy[] = "'vector:manticore" . ucfirst($t) . "LastId'";
}
$pdo->exec("DELETE FROM key_values WHERE k IN (" . implode(',', $legacy) . ")");
self::rememberEmbeddingModel($pdo);
// marker 最后写入且在锁内:写入即代表重建完整成功
self::kvSetPdo($pdo, 'vector:schema', $expected);
Log::info("Manticore vector tables rebuilt for auto-embeddings schema {$expected}");
} catch (\Throwable $e) {
// 重建失败(如 ai 插件未就绪/未升级):不写 marker下个进程重试可自愈
Log::error('Manticore schema rebuild failed: ' . $e->getMessage());
} finally {
$lock->release();
}
} catch (\Throwable $e) {
Log::error('Manticore initializeTables failed: ' . $e->getMessage());
}
}
/**
* ai 插件向量化端点地址(唯一来源在 AI::embeddingsUrl查询侧与表定义共用
*/
private static function embeddingsApiUrl(): string
{
return AI::embeddingsUrl();
}
/**
* 写入表定义的派生密钥sha256(APP_KEY:embeddings),与 ai 插件约定一致。
* 不直接写 APP_KEYLaravel 主密钥),避免其落入搜索引擎元数据/数据卷;
* 派生值不可反推,且仅授予 /embeddings 调用权限。
*/
private static function embeddingsApiKey(): string
{
return hash('sha256', config('app.key') . ':embeddings');
}
/**
* 当前向量表结构指纹:结构版本/模型名/端点/APP_KEY 哈希任一变化都会触发整体重建
*/
private static function schemaMarker(): string
{
return md5(self::SCHEMA_VERSION . '|' . self::EMBEDDING_MODEL_NAME . '|'
. self::embeddingsApiUrl() . '|' . hash('sha256', (string) config('app.key')));
}
/**
* 检测 ai 插件实际生效的向量模型是否与建表时不一致(不一致需重建,否则维度可能不匹配)。
*
* 实际模型EMBEDDING_MODEL env对主程序不可见由查询侧在成功请求后
* 写入缓存 ai:embedding_model建表时的模型记录在 key_values vector:model。
* 任一侧未知时不触发(返回 false),已知且存量缺失时顺手补记。
*/
private static function embeddingModelChanged(PDO $pdo): bool
{
$live = (string) Cache::get('ai:embedding_model', '');
if ($live === '') {
return false;
}
$stored = self::kvGetPdo($pdo, 'vector:model');
if ($stored === null || $stored === '') {
// 旧部署/首次:补记当前模型,不触发重建
self::kvSetPdo($pdo, 'vector:model', $live);
return false;
}
return $stored !== $live;
}
/**
* 重建成功后记录当前生效的向量模型(优先取查询侧维护的缓存值)
*/
private static function rememberEmbeddingModel(PDO $pdo): void
{
$live = (string) Cache::get('ai:embedding_model', '');
if ($live !== '') {
self::kvSetPdo($pdo, 'vector:model', $live);
} else {
// 未知则清掉存量,待查询侧探得后由 embeddingModelChanged 补记
$pdo->exec("DELETE FROM key_values WHERE k = 'vector:model'");
}
}
/**
* 生成 Auto Embeddings 向量列定义(引擎按 FROM 字段自动生成/更新向量)
*
* 注意不写 KNN_DIMS MODEL_NAME 互斥),维度由引擎建表时向端点探测。
*
* @param string $from 参与向量化的字段(镜像旧 PHP 管道的拼接字段,逗号分隔)
*/
private static function vectorColumnDDL(string $from): string
{
return "content_vector float_vector knn_type='hnsw' hnsw_similarity='cosine'"
. " MODEL_NAME='" . self::EMBEDDING_MODEL_NAME . "'"
. " FROM='{$from}'"
. " API_KEY='" . self::embeddingsApiKey() . "'"
. " API_URL='" . self::embeddingsApiUrl() . "'"
. " API_TIMEOUT='60'";
}
/**
* 生成 5 张向量表的建表语句
*
* charset_table='non_cjk, cjk' 同时支持英文和中日韩文字
*
* @return array [table => DDL]
*/
private static function vectorTableDDLs(): array
{
$tail = "\n ) charset_table='non_cjk, cjk' morphology='icu_chinese'";
return [
'file_vectors' => "
CREATE TABLE IF NOT EXISTS file_vectors (
id BIGINT,
file_id BIGINT,
@ -87,21 +268,8 @@ class ManticoreBase
file_ext STRING,
content TEXT,
allowed_users MULTI,
content_vector float_vector knn_type='hnsw' knn_dims='1536' hnsw_similarity='cosine'
) charset_table='non_cjk, cjk' morphology='icu_chinese'
");
// 创建键值存储表
$pdo->exec("
CREATE TABLE IF NOT EXISTS key_values (
id BIGINT,
k STRING,
v TEXT
)
");
// 创建用户向量表
$pdo->exec("
" . self::vectorColumnDDL('file_name,content') . $tail,
'user_vectors' => "
CREATE TABLE IF NOT EXISTS user_vectors (
id BIGINT,
userid BIGINT,
@ -110,12 +278,8 @@ class ManticoreBase
profession TEXT,
tags TEXT,
introduction TEXT,
content_vector float_vector knn_type='hnsw' knn_dims='1536' hnsw_similarity='cosine'
) charset_table='non_cjk, cjk' morphology='icu_chinese'
");
// 创建项目向量表
$pdo->exec("
" . self::vectorColumnDDL('nickname,email,profession,tags,introduction') . $tail,
'project_vectors' => "
CREATE TABLE IF NOT EXISTS project_vectors (
id BIGINT,
project_id BIGINT,
@ -124,12 +288,8 @@ class ManticoreBase
project_name TEXT,
project_desc TEXT,
allowed_users MULTI,
content_vector float_vector knn_type='hnsw' knn_dims='1536' hnsw_similarity='cosine'
) charset_table='non_cjk, cjk' morphology='icu_chinese'
");
// 创建任务向量表
$pdo->exec("
" . self::vectorColumnDDL('project_name,project_desc') . $tail,
'task_vectors' => "
CREATE TABLE IF NOT EXISTS task_vectors (
id BIGINT,
task_id BIGINT,
@ -140,12 +300,8 @@ class ManticoreBase
task_desc TEXT,
task_content TEXT,
allowed_users MULTI,
content_vector float_vector knn_type='hnsw' knn_dims='1536' hnsw_similarity='cosine'
) charset_table='non_cjk, cjk' morphology='icu_chinese'
");
// 创建消息向量表
$pdo->exec("
" . self::vectorColumnDDL('task_name,task_desc,task_content') . $tail,
'msg_vectors' => "
CREATE TABLE IF NOT EXISTS msg_vectors (
id BIGINT,
msg_id BIGINT,
@ -155,13 +311,69 @@ class ManticoreBase
content TEXT,
allowed_users MULTI,
created_at BIGINT,
content_vector float_vector knn_type='hnsw' knn_dims='1536' hnsw_similarity='cosine'
) charset_table='non_cjk, cjk' morphology='icu_chinese'
");
" . self::vectorColumnDDL('content') . $tail,
];
}
// Tables initialized successfully
} catch (PDOException $e) {
// 表可能已存在,忽略初始化错误
/**
* 检查 5 张向量表是否都已存在
*/
private static function allVectorTablesExist(PDO $pdo): bool
{
try {
$stmt = $pdo->query("SHOW TABLES");
$existing = [];
foreach ($stmt->fetchAll(PDO::FETCH_NUM) as $row) {
$existing[$row[0]] = true;
}
foreach (array_keys(self::vectorTableDDLs()) as $table) {
if (!isset($existing[$table])) {
return false;
}
}
return true;
} catch (\Throwable $e) {
return false;
}
}
/**
* 直接用 PDO 读取 key_values避免初始化期通过 ManticoreKeyValue 造成递归)
*/
private static function kvGetPdo(PDO $pdo, string $key): ?string
{
try {
$stmt = $pdo->prepare("SELECT v FROM key_values WHERE k = ?");
$stmt->execute([$key]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return ($row && isset($row['v'])) ? (string)$row['v'] : null;
} catch (\Throwable $e) {
return null;
}
}
/**
* 直接用 PDO 写入 key_values ManticoreKeyValue::set id 规则)
*/
private static function kvSetPdo(PDO $pdo, string $key, string $value): void
{
try {
$del = $pdo->prepare("DELETE FROM key_values WHERE k = ?");
$del->execute([$key]);
$ins = $pdo->prepare("INSERT INTO key_values (id, k, v) VALUES (?, ?, ?)");
$ins->execute([abs(crc32($key)), $key, $value]);
} catch (\Throwable $e) {
// 忽略
}
}
/**
* 重置全文同步进度指针sync:*),触发全量重灌(向量由引擎随行自动生成)
*/
private static function resetSyncPointersPdo(PDO $pdo): void
{
foreach (self::VECTOR_TABLES as $t) {
self::kvSetPdo($pdo, "sync:manticore" . ucfirst($t) . "LastId", '0');
}
}
@ -256,13 +468,17 @@ class ManticoreBase
*/
public function executeRaw(string $sql): bool
{
// 日志上下文只保留 SQL 前 2KB多行批量语句可达数 MB完整写入会淹没日志
$sqlPreview = strlen($sql) > 2048
? substr($sql, 0, 2048) . ' ...[+' . (strlen($sql) - 2048) . ' bytes]'
: $sql;
return $this->runWithRetry(
function (PDO $pdo) use ($sql) {
$pdo->exec($sql);
return true;
},
false,
['sql' => $sql]
['sql' => $sqlPreview]
);
}
@ -1810,10 +2026,10 @@ class ManticoreBase
];
/**
* 通用向量插入方法
* 通用单行写入方法REPLACE向量由引擎 Auto Embeddings 自动生成)
*
* 使用 executeRaw 直接执行 SQL避免 Manticore prepared statement
* 无法解析 MVA 和向量字段括号语法的问题。
* 无法解析 MVA 字段括号语法的问题。
*
* @param string $type 类型: msg/file/task/project/user
* @param array $data 数据,键名对应字段名
@ -1828,8 +2044,6 @@ class ManticoreBase
$config = self::VECTOR_TABLE_CONFIG[$type];
$table = $config['table'];
$pk = $config['pk'];
$fields = $config['fields'];
$mvaFields = $config['mva_fields'];
// 检查主键
$pkValue = $data[$pk] ?? 0;
@ -1838,44 +2052,11 @@ class ManticoreBase
}
$instance = new self();
[$fieldList, $valueList] = $instance->buildRowValues($config, $data);
// 先删除已存在的记录
$instance->execute("DELETE FROM {$table} WHERE {$pk} = ?", [$pkValue]);
// 构建字段列表和值
$fieldList = [];
$valueList = [];
// 处理普通字段
foreach ($fields as $field) {
$fieldList[] = $field;
$value = $data[$field] ?? ($field === 'created_at' ? time() : (in_array($field, self::NUMERIC_FIELDS) ? 0 : ''));
if (in_array($field, self::NUMERIC_FIELDS)) {
$valueList[] = (int)$value;
} else {
$valueList[] = $instance->quoteValue((string)$value);
}
}
// 处理 MVA 字段
foreach ($mvaFields as $mvaField) {
$fieldList[] = $mvaField;
$mvaData = $data[$mvaField] ?? [];
$valueList[] = !empty($mvaData)
? '(' . implode(',', array_map('intval', $mvaData)) . ')'
: '()';
}
// 处理向量字段
$vectorValue = $data['content_vector'] ?? null;
if ($vectorValue) {
$fieldList[] = 'content_vector';
$valueList[] = str_replace(['[', ']'], ['(', ')'], $vectorValue);
}
// 构建并执行 SQL
$sql = "INSERT INTO {$table} (" . implode(', ', $fieldList) . ") VALUES (" . implode(', ', $valueList) . ")";
// REPLACE 按 id 原子替换整行,向量列由引擎按 FROM 字段自动重新生成。
// 前提:所有 upsertXxxVector 均强制 id = 主键值,故 REPLACE(按 id) 与按主键去重等价
$sql = "REPLACE INTO {$table} (" . implode(', ', $fieldList) . ") VALUES (" . implode(', ', $valueList) . ")";
$result = $instance->executeRaw($sql);
@ -1885,12 +2066,46 @@ class ManticoreBase
ManticoreSyncFailure::removeSuccess($type, $pkValue, 'sync');
} else {
// 失败则记录
ManticoreSyncFailure::recordFailure($type, $pkValue, 'sync', "INSERT failed for {$table}");
ManticoreSyncFailure::recordFailure($type, $pkValue, 'sync', "REPLACE failed for {$table}");
}
return $result;
}
/**
* 构建一行数据的字段列表与内联值(普通字段 + MVA 字段,向量列由引擎自动生成,不在此列)
*
* @param array $config VECTOR_TABLE_CONFIG 中的类型配置
* @param array $data 行数据
* @return array [fieldList, valueList]
*/
private function buildRowValues(array $config, array $data): array
{
$fieldList = [];
$valueList = [];
foreach ($config['fields'] as $field) {
$fieldList[] = $field;
$value = $data[$field] ?? ($field === 'created_at' ? time() : (in_array($field, self::NUMERIC_FIELDS) ? 0 : ''));
if (in_array($field, self::NUMERIC_FIELDS)) {
$valueList[] = (int)$value;
} else {
$valueList[] = $this->quoteValue((string)$value);
}
}
foreach ($config['mva_fields'] as $mvaField) {
$fieldList[] = $mvaField;
$mvaData = $data[$mvaField] ?? [];
$valueList[] = !empty($mvaData)
? '(' . implode(',', array_map('intval', $mvaData)) . ')'
: '()';
}
return [$fieldList, $valueList];
}
/**
* 通用向量删除方法
*
@ -1924,179 +2139,92 @@ class ManticoreBase
}
/**
* 通用批量更新向量方法(高性能版本)
* 通用批量写入方法:每块一条多行 REPLACE向量由引擎 Auto Embeddings 自动生成
*
* 优化:将 N 条记录的 3N 次操作减少为 N+2 次操作
* 1. 批量 SELECT 获取现有记录 (1)
* 2. 预构建所有 INSERT SQL验证数据完整性
* 3. 批量 DELETE 删除旧记录 (1)
* 4. 逐条 INSERT 新记录带向量 (N次因向量字段无法批量绑定)
* 引擎对一条多行语句只调用一次向量化接口(实测 30 0.9s
* 多行语句失败是原子的;整块失败时回退逐行 upsertVector
* 使单条坏行不毒化整批、且失败按真实主键记入重试表。
*
* @param string $type 类型: msg/file/task/project/user
* @param array $vectorData 向量数据 [pk_value => vectorStr, ...]
* @return int 成功更新的数量
* @param array $rows 行数据数组(与 upsertVector $data 同构,需含 id 与主键)
* @return int 成功写入的数量
*/
public static function batchUpdateVectors(string $type, array $vectorData): int
public static function batchUpsertVectors(string $type, array $rows): int
{
if (empty($vectorData) || !isset(self::VECTOR_TABLE_CONFIG[$type])) {
if (empty($rows) || !isset(self::VECTOR_TABLE_CONFIG[$type])) {
return 0;
}
$config = self::VECTOR_TABLE_CONFIG[$type];
$table = $config['table'];
$pk = $config['pk'];
$fields = $config['fields'];
$mvaFields = $config['mva_fields'];
$instance = new self();
$ids = array_keys($vectorData);
// 1. 批量查询现有记录
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$existingRows = $instance->query(
"SELECT * FROM {$table} WHERE {$pk} IN ({$placeholders})",
$ids
);
if (empty($existingRows)) {
return 0;
}
// 建立 pk => row 的映射
$existingMap = [];
foreach ($existingRows as $row) {
$existingMap[$row[$pk]] = $row;
}
$idsToUpdate = array_keys($existingMap);
if (empty($idsToUpdate)) {
return 0;
}
// 2. 预构建所有 INSERT 语句(在删除前验证数据完整性)
$insertStatements = [];
foreach ($idsToUpdate as $pkValue) {
$existing = $existingMap[$pkValue];
$vectorStr = $vectorData[$pkValue] ?? null;
if (empty($vectorStr)) {
// 剔除无主键行;按内容长度估算分块(不在此渲染 SQL块内惰性渲染以压低内存峰值
$pending = [];
foreach ($rows as $row) {
if (($row[$pk] ?? 0) <= 0) {
continue;
}
// Manticore 向量使用 () 格式
$vectorStr = str_replace(['[', ']'], ['(', ')'], $vectorStr);
// 构建字段列表和值(直接内联值,不使用参数绑定)
$fieldList = $fields;
$quotedValues = [];
foreach ($fields as $field) {
$value = $existing[$field] ?? null;
// 处理默认值:数值字段用 0时间戳字段用当前时间其他用空字符串
if ($value === null) {
if ($field === 'created_at') {
$value = time();
} elseif (in_array($field, self::NUMERIC_FIELDS)) {
$value = 0;
} else {
$value = '';
}
}
// 根据字段类型处理值
if (in_array($field, self::NUMERIC_FIELDS)) {
$quotedValues[] = (int)$value;
} else {
$quotedValues[] = $instance->quoteValue((string)$value);
$bytes = 64;
foreach ($row as $value) {
if (is_string($value)) {
$bytes += strlen($value);
}
}
// 构建 MVA 字段
$mvaValuesStr = [];
foreach ($mvaFields as $mvaField) {
$fieldList[] = $mvaField;
$mvaValuesStr[] = !empty($existing[$mvaField])
? '(' . $existing[$mvaField] . ')'
: '()';
}
// 添加向量字段
$fieldList[] = 'content_vector';
// 构建 SQL所有值直接内联使用 executeRaw 避免 prepared statement 解析问题)
$allValues = implode(', ', array_merge($quotedValues, $mvaValuesStr, [$vectorStr]));
$sql = "INSERT INTO {$table} (" . implode(', ', $fieldList) . ") VALUES ({$allValues})";
$insertStatements[] = ['sql' => $sql, 'pk' => $pkValue];
$pending[] = ['pk' => $row[$pk], 'bytes' => $bytes, 'row' => $row];
}
// 如果没有有效的插入语句,直接返回
if (empty($insertStatements)) {
if (empty($pending)) {
return 0;
}
// 3. 批量删除旧记录(只删除有有效向量的记录)
$validPks = array_column($insertStatements, 'pk');
$deletePlaceholders = implode(',', array_fill(0, count($validPks), '?'));
$instance->execute(
"DELETE FROM {$table} WHERE {$pk} IN ({$deletePlaceholders})",
$validPks
);
// 分块:行数上限 + 字节预算(文件内容可达 10 万字符/行)
$chunks = [];
$current = [];
$currentBytes = 0;
foreach ($pending as $item) {
if (!empty($current)
&& (count($current) >= self::BATCH_CHUNK_ROWS || $currentBytes + $item['bytes'] > self::BATCH_CHUNK_BYTES)) {
$chunks[] = $current;
$current = [];
$currentBytes = 0;
}
$current[] = $item;
$currentBytes += $item['bytes'];
}
$chunks[] = $current;
// 4. 逐条插入新记录(使用 executeRaw 避免 prepared statement 解析问题)
$successCount = 0;
foreach ($insertStatements as $stmt) {
if ($instance->executeRaw($stmt['sql'])) {
$successCount++;
// 成功则删除失败记录(如果有)
ManticoreSyncFailure::removeSuccess($type, $stmt['pk'], 'sync');
foreach ($chunks as $chunk) {
// 块内渲染,执行后即释放,内存峰值 = 原始行 + 单块 SQL
$fieldListRef = null;
$valuesSql = [];
foreach ($chunk as $item) {
[$fieldList, $valueList] = $instance->buildRowValues($config, $item['row']);
$fieldListRef = $fieldList;
$valuesSql[] = '(' . implode(', ', $valueList) . ')';
}
$sql = "REPLACE INTO {$table} (" . implode(', ', $fieldListRef) . ") VALUES "
. implode(', ', $valuesSql);
unset($valuesSql);
$ok = $instance->executeRaw($sql);
unset($sql);
if ($ok) {
$successCount += count($chunk);
ManticoreSyncFailure::removeSuccessBatch($type, array_column($chunk, 'pk'), 'sync');
} else {
// 失败则记录
ManticoreSyncFailure::recordFailure($type, $stmt['pk'], 'sync', "Batch INSERT failed for {$table}");
// 整块失败回退逐行REPLACE 幂等,重复写已生效行无害)
foreach ($chunk as $item) {
if (self::upsertVector($type, $item['row'])) {
$successCount++;
}
}
}
}
return $successCount;
}
/**
* 批量更新消息向量(兼容方法)
*/
public static function batchUpdateMsgVectors(array $vectorData): int
{
return self::batchUpdateVectors('msg', $vectorData);
}
/**
* 批量更新文件向量
*/
public static function batchUpdateFileVectors(array $vectorData): int
{
return self::batchUpdateVectors('file', $vectorData);
}
/**
* 批量更新任务向量
*/
public static function batchUpdateTaskVectors(array $vectorData): int
{
return self::batchUpdateVectors('task', $vectorData);
}
/**
* 批量更新项目向量
*/
public static function batchUpdateProjectVectors(array $vectorData): int
{
return self::batchUpdateVectors('project', $vectorData);
}
/**
* 批量更新用户向量
*/
public static function batchUpdateUserVectors(array $vectorData): int
{
return self::batchUpdateVectors('user', $vectorData);
}
// ==============================
// 通用工具方法
// ==============================

View File

@ -8,7 +8,6 @@ use App\Models\FileUser;
use App\Module\Apps;
use App\Module\Base;
use App\Module\TextExtractor;
use App\Module\AI;
use Illuminate\Support\Facades\Log;
/**
@ -38,7 +37,9 @@ class ManticoreFile
public const SEARCHABLE_TYPES = ['document', 'word', 'excel', 'ppt', 'txt', 'md', 'text', 'code'];
/**
* 最大内容长度(字符)- 提取后的文本内容限制
* 最大内容长度(字符)- 提取后的文本内容限制(服务全文检索范围)。
* 注意:向量化输入在 ai 插件侧另有 30000 字符上限main.py _EMBEDDING_INPUT_MAX_CHARS
* 超出部分只参与全文检索、不参与语义向量。
*/
public const MAX_CONTENT_LENGTH = 100000; // 100K 字符
@ -214,13 +215,12 @@ class ManticoreFile
// ==============================
/**
* 同步单个文件到 Manticore allowed_users
* 同步单个文件到 Manticore allowed_users,向量由引擎 Auto Embeddings 自动生成
*
* @param File $file 文件模型
* @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成)
* @return bool 是否成功
*/
public static function sync(File $file, bool $withVector = false): bool
public static function sync(File $file): bool
{
if (!Apps::isInstalled("search")) {
return false;
@ -240,42 +240,7 @@ class ManticoreFile
}
try {
// 提取文件内容
$content = self::extractFileContent($file);
// 限制提取后的内容长度
$content = mb_substr($content, 0, self::MAX_CONTENT_LENGTH);
// 只有明确要求时才生成向量(默认不生成,由后台任务处理)
$embedding = null;
if ($withVector && Apps::isInstalled('ai')) {
// 向量内容包含文件名和文件内容
$vectorContent = self::buildVectorContent($file->name, $content);
if (!empty($vectorContent)) {
$embeddingResult = ManticoreBase::getEmbedding($vectorContent);
if (!empty($embeddingResult)) {
$embedding = '[' . implode(',', $embeddingResult) . ']';
}
}
}
// 获取文件的 allowed_users
$allowedUsers = self::getAllowedUsers($file);
// 写入 Manticore含 allowed_users
$result = ManticoreBase::upsertFileVector([
'file_id' => $file->id,
'userid' => $file->userid,
'pshare' => $file->pshare ?? 0,
'file_name' => $file->name,
'file_type' => $file->type,
'file_ext' => $file->ext,
'content' => $content,
'content_vector' => $embedding,
'allowed_users' => $allowedUsers,
]);
return $result;
return ManticoreBase::upsertFileVector(self::buildRow($file));
} catch (\Exception $e) {
Log::error('Manticore sync error: ' . $e->getMessage(), [
'file_id' => $file->id,
@ -285,6 +250,30 @@ class ManticoreFile
}
}
/**
* 构建文件索引行数据(含提取的文件内容与 allowed_users
*
* @param File $file 文件模型
* @return array 行数据
*/
private static function buildRow(File $file): array
{
// 提取文件内容并限制长度
$content = mb_substr(self::extractFileContent($file), 0, self::MAX_CONTENT_LENGTH);
return [
'id' => $file->id,
'file_id' => $file->id,
'userid' => $file->userid,
'pshare' => $file->pshare ?? 0,
'file_name' => $file->name,
'file_type' => $file->type,
'file_ext' => $file->ext,
'content' => $content,
'allowed_users' => self::getAllowedUsers($file),
];
}
/**
* 根据文件扩展名获取最大文件大小限制
*
@ -317,25 +306,41 @@ class ManticoreFile
}
/**
* 批量同步文件
* 批量同步文件(每块一条多行 REPLACE向量由引擎自动生成
*
* @param iterable $files 文件列表
* @param bool $withVector 是否同时生成向量
* @return int 成功同步的数量
*/
public static function batchSync(iterable $files, bool $withVector = false): int
public static function batchSync(iterable $files): int
{
if (!Apps::isInstalled("search")) {
return 0;
}
$count = 0;
$rows = [];
foreach ($files as $file) {
if (self::sync($file, $withVector)) {
// 文件夹不索引
if ($file->type === 'folder') {
$count++;
continue;
}
// 超限文件删除旧索引
if ($file->size > self::getMaxFileSizeByExt($file->ext)) {
self::delete($file->id);
$count++;
continue;
}
try {
$rows[] = self::buildRow($file);
} catch (\Exception $e) {
Log::error('Manticore file batchSync build error: ' . $e->getMessage(), [
'file_id' => $file->id,
]);
}
}
return $count;
return $count + ManticoreBase::batchUpsertVectors('file', $rows);
}
/**
@ -497,27 +502,6 @@ class ManticoreFile
return $result['data'] ?? '';
}
/**
* 构建用于生成向量的内容
* 包含文件名和文件内容,确保语义搜索能匹配文件名
*
* @param string $fileName 文件名
* @param string $content 文件内容
* @return string 用于生成向量的文本
*/
private static function buildVectorContent(string $fileName, string $content): string
{
$parts = [];
if (!empty($fileName)) {
$parts[] = $fileName;
}
if (!empty($content)) {
$parts[] = $content;
}
return implode(' ', $parts);
}
/**
* 清空所有索引
@ -577,94 +561,4 @@ class ManticoreFile
return false;
}
}
// ==============================
// 批量向量生成方法
// ==============================
/**
* 批量生成文件向量
* 用于后台异步处理,将已索引文件的向量批量生成
*
* @param array $fileIds 文件ID数组
* @param int $batchSize 每批 embedding 数量默认20
* @return int 成功处理的数量
*/
public static function generateVectorsBatch(array $fileIds, int $batchSize = 20): int
{
if (!Apps::isInstalled("search") || !Apps::isInstalled("ai") || empty($fileIds)) {
return 0;
}
try {
// 1. 查询文件信息
$files = File::whereIn('id', $fileIds)
->where('type', '!=', 'folder')
->get();
if ($files->isEmpty()) {
return 0;
}
// 2. 提取每个文件的内容(包含文件名)
$fileContents = [];
foreach ($files as $file) {
// 检查文件大小限制
$maxSize = self::getMaxFileSizeByExt($file->ext);
if ($file->size > $maxSize) {
continue;
}
$content = self::extractFileContent($file);
// 向量内容包含文件名和文件内容
$vectorContent = self::buildVectorContent($file->name, $content);
if (!empty($vectorContent)) {
// 限制内容长度
$vectorContent = mb_substr($vectorContent, 0, self::MAX_CONTENT_LENGTH);
$fileContents[$file->id] = $vectorContent;
}
}
if (empty($fileContents)) {
return 0;
}
// 3. 分批处理
$successCount = 0;
$chunks = array_chunk($fileContents, $batchSize, true);
foreach ($chunks as $chunk) {
$texts = array_values($chunk);
$ids = array_keys($chunk);
// 4. 批量获取 embedding
$result = AI::getBatchEmbeddings($texts);
if (!Base::isSuccess($result) || empty($result['data'])) {
continue;
}
$embeddings = $result['data'];
// 5. 构建批量更新数据
$vectorData = [];
foreach ($ids as $index => $fileId) {
if (!isset($embeddings[$index]) || empty($embeddings[$index])) {
continue;
}
$vectorData[$fileId] = '[' . implode(',', $embeddings[$index]) . ']';
}
// 6. 批量更新向量
if (!empty($vectorData)) {
$batchCount = ManticoreBase::batchUpdateFileVectors($vectorData);
$successCount += $batchCount;
}
}
return $successCount;
} catch (\Exception $e) {
Log::error('ManticoreFile generateVectorsBatch error: ' . $e->getMessage());
return 0;
}
}
}

View File

@ -5,8 +5,6 @@ namespace App\Module\Manticore;
use App\Models\WebSocketDialogMsg;
use App\Models\WebSocketDialogUser;
use App\Module\Apps;
use App\Module\Base;
use App\Module\AI;
use Carbon\Carbon;
use DB;
use Illuminate\Support\Facades\Log;
@ -39,7 +37,8 @@ class ManticoreMsg
public const INDEXABLE_TYPES = ['text', 'file', 'record', 'meeting', 'vote'];
/**
* 最大内容长度(字符)
* 最大内容长度(字符)。向量化输入在 ai 插件侧另有 30000 字符上限
* main.py _EMBEDDING_INPUT_MAX_CHARS超出部分只参与全文检索
*/
public const MAX_CONTENT_LENGTH = 50000; // 50K 字符
@ -328,13 +327,12 @@ class ManticoreMsg
// ==============================
/**
* 同步单个消息到 Manticore allowed_users
* 同步单个消息到 Manticore allowed_users,向量由引擎 Auto Embeddings 自动生成
*
* @param WebSocketDialogMsg $msg 消息模型
* @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成)
* @return bool 是否成功
*/
public static function sync(WebSocketDialogMsg $msg, bool $withVector = false): bool
public static function sync(WebSocketDialogMsg $msg): bool
{
if (!Apps::isInstalled("search")) {
return false;
@ -347,37 +345,7 @@ class ManticoreMsg
}
try {
// 提取消息内容(使用 key 字段)
$content = $msg->key ?? '';
// 限制内容长度
$content = mb_substr($content, 0, self::MAX_CONTENT_LENGTH);
// 只有明确要求时才生成向量(默认不生成,由后台任务处理)
$embedding = null;
if ($withVector && !empty($content) && Apps::isInstalled('ai')) {
$embeddingResult = ManticoreBase::getEmbedding($content);
if (!empty($embeddingResult)) {
$embedding = '[' . implode(',', $embeddingResult) . ']';
}
}
// 获取消息的 allowed_users
$allowedUsers = self::getAllowedUsers($msg);
// 写入 Manticore含 allowed_users
$result = ManticoreBase::upsertMsgVector([
'msg_id' => $msg->id,
'dialog_id' => $msg->dialog_id,
'userid' => $msg->userid,
'msg_type' => $msg->type,
'content' => $content,
'content_vector' => $embedding,
'allowed_users' => $allowedUsers,
'created_at' => $msg->created_at ? $msg->created_at->timestamp : time(),
]);
return $result;
return ManticoreBase::upsertMsgVector(self::buildRow($msg));
} catch (\Exception $e) {
Log::error('Manticore msg sync error: ' . $e->getMessage(), [
'msg_id' => $msg->id,
@ -388,106 +356,59 @@ class ManticoreMsg
}
/**
* 批量同步消息
* 构建消息索引行数据(含 allowed_users
*
* @param WebSocketDialogMsg $msg 消息模型
* @return array 行数据
*/
private static function buildRow(WebSocketDialogMsg $msg): array
{
// 提取消息内容(使用 key 字段)并限制长度
$content = mb_substr($msg->key ?? '', 0, self::MAX_CONTENT_LENGTH);
return [
'id' => $msg->id,
'msg_id' => $msg->id,
'dialog_id' => $msg->dialog_id,
'userid' => $msg->userid,
'msg_type' => $msg->type,
'content' => $content,
'allowed_users' => self::getAllowedUsers($msg),
'created_at' => $msg->created_at ? $msg->created_at->timestamp : time(),
];
}
/**
* 批量同步消息(每块一条多行 REPLACE向量由引擎自动生成
*
* @param iterable $msgs 消息列表
* @param bool $withVector 是否同时生成向量
* @return int 成功同步的数量
*/
public static function batchSync(iterable $msgs, bool $withVector = false): int
public static function batchSync(iterable $msgs): int
{
if (!Apps::isInstalled("search")) {
return 0;
}
$count = 0;
$rows = [];
foreach ($msgs as $msg) {
if (self::sync($msg, $withVector)) {
$count++;
}
}
return $count;
}
/**
* 批量生成向量(供后台任务调用)
*
* @param array $msgIds 消息ID数组
* @param int $batchSize 每批 embedding 数量
* @return int 成功生成向量的数量
*/
public static function generateVectorsBatch(array $msgIds, int $batchSize = 20): int
{
if (!Apps::isInstalled("search") || !Apps::isInstalled('ai') || empty($msgIds)) {
return 0;
}
$count = 0;
// 分批处理
foreach (array_chunk($msgIds, $batchSize) as $batchIds) {
// 获取消息
$msgs = WebSocketDialogMsg::whereIn('id', $batchIds)
->whereIn('type', self::INDEXABLE_TYPES)
->where('bot', '!=', 1)
->whereNotNull('key')
->where('key', '!=', '')
->get()
->keyBy('id');
if ($msgs->isEmpty()) {
if (!self::shouldIndex($msg)) {
if (ManticoreBase::deleteMsgVector($msg->id)) {
$count++;
}
continue;
}
// 准备文本
$texts = [];
$idsArray = [];
foreach ($batchIds as $id) {
if (isset($msgs[$id])) {
$content = mb_substr($msgs[$id]->key ?? '', 0, self::MAX_CONTENT_LENGTH);
if (!empty($content)) {
$texts[] = $content;
$idsArray[] = $id;
}
}
}
if (empty($texts)) {
continue;
}
// 批量获取 embeddings
$result = AI::getBatchEmbeddings($texts);
if (Base::isError($result)) {
continue;
}
$embeddings = $result['data'] ?? [];
// 构建批量更新数据 [msg_id => vectorStr]
$vectorData = [];
foreach ($embeddings as $index => $embedding) {
if (empty($embedding) || !is_array($embedding)) {
continue;
}
$msgId = $idsArray[$index] ?? null;
if (!$msgId) {
continue;
}
$vectorData[$msgId] = '[' . implode(',', $embedding) . ']';
}
// 批量更新向量(优化:减少数据库操作次数)
if (!empty($vectorData)) {
$batchCount = ManticoreBase::batchUpdateMsgVectors($vectorData);
$count += $batchCount;
try {
$rows[] = self::buildRow($msg);
} catch (\Exception $e) {
Log::error('Manticore msg batchSync build error: ' . $e->getMessage(), [
'msg_id' => $msg->id,
]);
}
}
return $count;
return $count + ManticoreBase::batchUpsertVectors('msg', $rows);
}
/**

View File

@ -5,8 +5,6 @@ namespace App\Module\Manticore;
use App\Models\Project;
use App\Models\ProjectUser;
use App\Module\Apps;
use App\Module\Base;
use App\Module\AI;
use Illuminate\Support\Facades\Log;
/**
@ -122,13 +120,12 @@ class ManticoreProject
}
/**
* 同步单个项目到 Manticore allowed_users
* 同步单个项目到 Manticore allowed_users,向量由引擎 Auto Embeddings 自动生成
*
* @param Project $project 项目模型
* @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成)
* @return bool 是否成功
*/
public static function sync(Project $project, bool $withVector = false): bool
public static function sync(Project $project): bool
{
if (!Apps::isInstalled("search")) {
return false;
@ -140,33 +137,7 @@ class ManticoreProject
}
try {
// 构建用于搜索的文本内容
$searchableContent = self::buildSearchableContent($project);
// 只有明确要求时才生成向量(默认不生成,由后台任务处理)
$embedding = null;
if ($withVector && !empty($searchableContent) && Apps::isInstalled('ai')) {
$embeddingResult = ManticoreBase::getEmbedding($searchableContent);
if (!empty($embeddingResult)) {
$embedding = '[' . implode(',', $embeddingResult) . ']';
}
}
// 获取项目成员列表(作为 allowed_users
$allowedUsers = self::getAllowedUsers($project->id);
// 写入 Manticore含 allowed_users
$result = ManticoreBase::upsertProjectVector([
'project_id' => $project->id,
'userid' => $project->userid ?? 0,
'personal' => $project->personal ?? 0,
'project_name' => $project->name ?? '',
'project_desc' => $project->desc ?? '',
'content_vector' => $embedding,
'allowed_users' => $allowedUsers,
]);
return $result;
return ManticoreBase::upsertProjectVector(self::buildRow($project));
} catch (\Exception $e) {
Log::error('Manticore project sync error: ' . $e->getMessage(), [
'project_id' => $project->id,
@ -177,45 +148,55 @@ class ManticoreProject
}
/**
* 构建可搜索的文本内容
* 构建项目索引行数据(含 allowed_users
*
* @param Project $project 项目模型
* @return string 可搜索的文本
* @return array 行数据
*/
private static function buildSearchableContent(Project $project): string
private static function buildRow(Project $project): array
{
$parts = [];
if (!empty($project->name)) {
$parts[] = $project->name;
}
if (!empty($project->desc)) {
$parts[] = $project->desc;
}
return implode(' ', $parts);
return [
'id' => $project->id,
'project_id' => $project->id,
'userid' => $project->userid ?? 0,
'personal' => $project->personal ?? 0,
'project_name' => $project->name ?? '',
'project_desc' => $project->desc ?? '',
'allowed_users' => self::getAllowedUsers($project->id),
];
}
/**
* 批量同步项目
* 批量同步项目(每块一条多行 REPLACE向量由引擎自动生成
*
* @param iterable $projects 项目列表
* @param bool $withVector 是否同时生成向量
* @return int 成功同步的数量
*/
public static function batchSync(iterable $projects, bool $withVector = false): int
public static function batchSync(iterable $projects): int
{
if (!Apps::isInstalled("search")) {
return 0;
}
$count = 0;
$rows = [];
foreach ($projects as $project) {
if (self::sync($project, $withVector)) {
$count++;
if ($project->archived_at) {
if (self::delete($project->id)) {
$count++;
}
continue;
}
try {
$rows[] = self::buildRow($project);
} catch (\Exception $e) {
Log::error('Manticore project batchSync build error: ' . $e->getMessage(), [
'project_id' => $project->id,
]);
}
}
return $count;
return $count + ManticoreBase::batchUpsertVectors('project', $rows);
}
/**
@ -286,84 +267,4 @@ class ManticoreProject
return false;
}
}
// ==============================
// 批量向量生成方法
// ==============================
/**
* 批量生成项目向量
* 用于后台异步处理,将已索引项目的向量批量生成
*
* @param array $projectIds 项目ID数组
* @param int $batchSize 每批 embedding 数量默认20
* @return int 成功处理的数量
*/
public static function generateVectorsBatch(array $projectIds, int $batchSize = 20): int
{
if (!Apps::isInstalled("search") || !Apps::isInstalled("ai") || empty($projectIds)) {
return 0;
}
try {
// 1. 查询项目信息
$projects = Project::whereIn('id', $projectIds)
->whereNull('archived_at')
->get();
if ($projects->isEmpty()) {
return 0;
}
// 2. 提取每个项目的内容
$projectContents = [];
foreach ($projects as $project) {
$searchableContent = self::buildSearchableContent($project);
if (!empty($searchableContent)) {
$projectContents[$project->id] = $searchableContent;
}
}
if (empty($projectContents)) {
return 0;
}
// 3. 分批处理
$successCount = 0;
$chunks = array_chunk($projectContents, $batchSize, true);
foreach ($chunks as $chunk) {
$texts = array_values($chunk);
$ids = array_keys($chunk);
// 4. 批量获取 embedding
$result = AI::getBatchEmbeddings($texts);
if (!Base::isSuccess($result) || empty($result['data'])) {
continue;
}
$embeddings = $result['data'];
// 5. 构建批量更新数据
$vectorData = [];
foreach ($ids as $index => $projectId) {
if (!isset($embeddings[$index]) || empty($embeddings[$index])) {
continue;
}
$vectorData[$projectId] = '[' . implode(',', $embeddings[$index]) . ']';
}
// 6. 批量更新向量
if (!empty($vectorData)) {
$batchCount = ManticoreBase::batchUpdateProjectVectors($vectorData);
$successCount += $batchCount;
}
}
return $successCount;
} catch (\Exception $e) {
Log::error('ManticoreProject generateVectorsBatch error: ' . $e->getMessage());
return 0;
}
}
}

View File

@ -9,7 +9,6 @@ use App\Models\ProjectTaskVisibilityUser;
use App\Models\ProjectUser;
use App\Module\Apps;
use App\Module\Base;
use App\Module\AI;
use Illuminate\Support\Facades\Log;
/**
@ -42,7 +41,8 @@ use Illuminate\Support\Facades\Log;
class ManticoreTask
{
/**
* 最大内容长度(字符)
* 最大内容长度(字符)。向量化输入在 ai 插件侧另有 30000 字符上限
* main.py _EMBEDDING_INPUT_MAX_CHARS超出部分只参与全文检索
*/
public const MAX_CONTENT_LENGTH = 50000; // 50K 字符
@ -189,13 +189,12 @@ class ManticoreTask
// ==============================
/**
* 同步单个任务到 Manticore allowed_users
* 同步单个任务到 Manticore allowed_users,向量由引擎 Auto Embeddings 自动生成
*
* @param ProjectTask $task 任务模型
* @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成)
* @return bool 是否成功
*/
public static function sync(ProjectTask $task, bool $withVector = false): bool
public static function sync(ProjectTask $task): bool
{
if (!Apps::isInstalled("search")) {
return false;
@ -207,38 +206,7 @@ class ManticoreTask
}
try {
// 获取任务详细内容
$taskContent = self::getTaskContent($task);
// 构建用于搜索的文本内容
$searchableContent = self::buildSearchableContent($task, $taskContent);
// 只有明确要求时才生成向量(默认不生成,由后台任务处理)
$embedding = null;
if ($withVector && !empty($searchableContent) && Apps::isInstalled('ai')) {
$embeddingResult = ManticoreBase::getEmbedding($searchableContent);
if (!empty($embeddingResult)) {
$embedding = '[' . implode(',', $embeddingResult) . ']';
}
}
// 获取任务的 allowed_users
$allowedUsers = self::getAllowedUsers($task);
// 写入 Manticore含 allowed_users
$result = ManticoreBase::upsertTaskVector([
'task_id' => $task->id,
'project_id' => $task->project_id ?? 0,
'userid' => $task->userid ?? 0,
'visibility' => $task->visibility ?? 1,
'task_name' => $task->name ?? '',
'task_desc' => $task->desc ?? '',
'task_content' => $taskContent,
'content_vector' => $embedding,
'allowed_users' => $allowedUsers,
]);
return $result;
return ManticoreBase::upsertTaskVector(self::buildRow($task));
} catch (\Exception $e) {
Log::error('Manticore task sync error: ' . $e->getMessage(), [
'task_id' => $task->id,
@ -248,6 +216,27 @@ class ManticoreTask
}
}
/**
* 构建任务索引行数据(含详细内容与 allowed_users
*
* @param ProjectTask $task 任务模型
* @return array 行数据
*/
private static function buildRow(ProjectTask $task): array
{
return [
'id' => $task->id,
'task_id' => $task->id,
'project_id' => $task->project_id ?? 0,
'userid' => $task->userid ?? 0,
'visibility' => $task->visibility ?? 1,
'task_name' => $task->name ?? '',
'task_desc' => $task->desc ?? '',
'task_content' => self::getTaskContent($task),
'allowed_users' => self::getAllowedUsers($task),
];
}
/**
* 获取任务详细内容
*
@ -311,49 +300,36 @@ class ManticoreTask
}
/**
* 构建可搜索的文本内容
*
* @param ProjectTask $task 任务模型
* @param string $taskContent 任务详细内容
* @return string 可搜索的文本
*/
private static function buildSearchableContent(ProjectTask $task, string $taskContent): string
{
$parts = [];
if (!empty($task->name)) {
$parts[] = $task->name;
}
if (!empty($task->desc)) {
$parts[] = $task->desc;
}
if (!empty($taskContent)) {
$parts[] = $taskContent;
}
return implode(' ', $parts);
}
/**
* 批量同步任务
* 批量同步任务(每块一条多行 REPLACE向量由引擎自动生成
*
* @param iterable $tasks 任务列表
* @param bool $withVector 是否同时生成向量
* @return int 成功同步的数量
*/
public static function batchSync(iterable $tasks, bool $withVector = false): int
public static function batchSync(iterable $tasks): int
{
if (!Apps::isInstalled("search")) {
return 0;
}
$count = 0;
$rows = [];
foreach ($tasks as $task) {
if (self::sync($task, $withVector)) {
$count++;
if ($task->archived_at || $task->deleted_at) {
if (self::delete($task->id)) {
$count++;
}
continue;
}
try {
$rows[] = self::buildRow($task);
} catch (\Exception $e) {
Log::error('Manticore task batchSync build error: ' . $e->getMessage(), [
'task_id' => $task->id,
]);
}
}
return $count;
return $count + ManticoreBase::batchUpsertVectors('task', $rows);
}
/**
@ -506,88 +482,4 @@ class ManticoreTask
Log::error('Manticore cascadeToChildren error: ' . $e->getMessage(), ['task_id' => $taskId]);
}
}
// ==============================
// 批量向量生成方法
// ==============================
/**
* 批量生成任务向量
* 用于后台异步处理,将已索引任务的向量批量生成
*
* @param array $taskIds 任务ID数组
* @param int $batchSize 每批 embedding 数量默认20
* @return int 成功处理的数量
*/
public static function generateVectorsBatch(array $taskIds, int $batchSize = 20): int
{
if (!Apps::isInstalled("search") || !Apps::isInstalled("ai") || empty($taskIds)) {
return 0;
}
try {
// 1. 查询任务信息
$tasks = ProjectTask::whereIn('id', $taskIds)
->whereNull('deleted_at')
->whereNull('archived_at')
->get();
if ($tasks->isEmpty()) {
return 0;
}
// 2. 提取每个任务的内容
$taskContents = [];
foreach ($tasks as $task) {
$taskContent = self::getTaskContent($task);
$searchableContent = self::buildSearchableContent($task, $taskContent);
if (!empty($searchableContent)) {
// 限制内容长度
$searchableContent = mb_substr($searchableContent, 0, self::MAX_CONTENT_LENGTH);
$taskContents[$task->id] = $searchableContent;
}
}
if (empty($taskContents)) {
return 0;
}
// 3. 分批处理
$successCount = 0;
$chunks = array_chunk($taskContents, $batchSize, true);
foreach ($chunks as $chunk) {
$texts = array_values($chunk);
$ids = array_keys($chunk);
// 4. 批量获取 embedding
$result = AI::getBatchEmbeddings($texts);
if (!Base::isSuccess($result) || empty($result['data'])) {
continue;
}
$embeddings = $result['data'];
// 5. 构建批量更新数据
$vectorData = [];
foreach ($ids as $index => $taskId) {
if (!isset($embeddings[$index]) || empty($embeddings[$index])) {
continue;
}
$vectorData[$taskId] = '[' . implode(',', $embeddings[$index]) . ']';
}
// 6. 批量更新向量
if (!empty($vectorData)) {
$batchCount = ManticoreBase::batchUpdateTaskVectors($vectorData);
$successCount += $batchCount;
}
}
return $successCount;
} catch (\Exception $e) {
Log::error('ManticoreTask generateVectorsBatch error: ' . $e->getMessage());
return 0;
}
}
}

View File

@ -5,8 +5,6 @@ namespace App\Module\Manticore;
use App\Models\User;
use App\Models\UserTag;
use App\Module\Apps;
use App\Module\Base;
use App\Module\AI;
use Illuminate\Support\Facades\Log;
/**
@ -123,13 +121,12 @@ class ManticoreUser
}
/**
* 同步单个用户到 Manticore
* 同步单个用户到 Manticore(向量由引擎 Auto Embeddings 按行内文本自动生成)
*
* @param User $user 用户模型
* @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成)
* @return bool 是否成功
*/
public static function sync(User $user, bool $withVector = false): bool
public static function sync(User $user): bool
{
if (!Apps::isInstalled("search")) {
return false;
@ -146,33 +143,13 @@ class ManticoreUser
}
try {
// 获取用户标签Top 10
$tags = self::getUserTags($user->userid);
// 构建用于搜索的文本内容
$searchableContent = self::buildSearchableContent($user, $tags);
// 只有明确要求时才生成向量(默认不生成,由后台任务处理)
$embedding = null;
if ($withVector && !empty($searchableContent) && Apps::isInstalled('ai')) {
$embeddingResult = ManticoreBase::getEmbedding($searchableContent);
if (!empty($embeddingResult)) {
$embedding = '[' . implode(',', $embeddingResult) . ']';
}
$row = self::buildRow($user);
// 脏检查:与已索引行完全一致则跳过。标签点赞/识别等高频事件经常不改变
// Top-10 标签文本,跳过可省一次真实的向量化调用与整行重写
if (self::rowUnchanged($row)) {
return true;
}
// 写入 Manticore
$result = ManticoreBase::upsertUserVector([
'userid' => $user->userid,
'nickname' => $user->nickname ?? '',
'email' => $user->email ?? '',
'profession' => $user->profession ?? '',
'tags' => $tags,
'introduction' => $user->introduction ?? '',
'content_vector' => $embedding,
]);
return $result;
return ManticoreBase::upsertUserVector($row);
} catch (\Exception $e) {
Log::error('Manticore user sync error: ' . $e->getMessage(), [
'userid' => $user->userid,
@ -183,55 +160,81 @@ class ManticoreUser
}
/**
* 构建可搜索的文本内容
*
* @param User $user 用户模型
* @param string $tags 用户标签(空格分隔)
* @return string 可搜索的文本
* 判断待写入行与当前已索引行是否完全一致(文本字段逐一比较)
*/
private static function buildSearchableContent(User $user, string $tags = ''): string
private static function rowUnchanged(array $row): bool
{
$parts = [];
if (!empty($user->nickname)) {
$parts[] = $user->nickname;
$existing = (new ManticoreBase())->queryOne(
"SELECT nickname, email, profession, tags, introduction FROM user_vectors WHERE userid = ?",
[$row['userid']]
);
if (!$existing) {
return false;
}
if (!empty($user->email)) {
$parts[] = $user->email;
foreach (['nickname', 'email', 'profession', 'tags', 'introduction'] as $field) {
if ((string) ($existing[$field] ?? '') !== (string) $row[$field]) {
return false;
}
}
if (!empty($user->profession)) {
$parts[] = $user->profession;
}
if (!empty($tags)) {
$parts[] = $tags;
}
if (!empty($user->introduction)) {
$parts[] = $user->introduction;
}
return implode(' ', $parts);
return true;
}
/**
* 批量同步用户
* 构建用户索引行数据(含标签 Top 10
*
* @param User $user 用户模型
* @return array 行数据
*/
private static function buildRow(User $user): array
{
$tags = self::getUserTags($user->userid);
return [
'id' => $user->userid,
'userid' => $user->userid,
'nickname' => $user->nickname ?? '',
'email' => $user->email ?? '',
'profession' => $user->profession ?? '',
'tags' => $tags,
'introduction' => $user->introduction ?? '',
];
}
/**
* 批量同步用户(每块一条多行 REPLACE向量由引擎自动生成
*
* @param iterable $users 用户列表
* @param bool $withVector 是否同时生成向量
* @return int 成功同步的数量
*/
public static function batchSync(iterable $users, bool $withVector = false): int
public static function batchSync(iterable $users): int
{
if (!Apps::isInstalled("search")) {
return 0;
}
$count = 0;
$rows = [];
foreach ($users as $user) {
if (self::sync($user, $withVector)) {
if ($user->bot) {
$count++;
continue;
}
if ($user->disable_at) {
if (self::delete($user->userid)) {
$count++;
}
continue;
}
try {
$rows[] = self::buildRow($user);
} catch (\Exception $e) {
Log::error('Manticore user batchSync build error: ' . $e->getMessage(), [
'userid' => $user->userid,
]);
}
}
return $count;
return $count + ManticoreBase::batchUpsertVectors('user', $rows);
}
/**
@ -276,87 +279,4 @@ class ManticoreUser
return ManticoreBase::getIndexedUserCount();
}
// ==============================
// 批量向量生成方法
// ==============================
/**
* 批量生成用户向量
* 用于后台异步处理,将已索引用户的向量批量生成
*
* @param array $userIds 用户ID数组
* @param int $batchSize 每批 embedding 数量默认20
* @return int 成功处理的数量
*/
public static function generateVectorsBatch(array $userIds, int $batchSize = 20): int
{
if (!Apps::isInstalled("search") || !Apps::isInstalled("ai") || empty($userIds)) {
return 0;
}
try {
// 1. 查询用户信息
$users = User::whereIn('userid', $userIds)
->where('bot', 0)
->whereNull('disable_at')
->get();
if ($users->isEmpty()) {
return 0;
}
// 2. 提取每个用户的内容(包含标签)
$userContents = [];
foreach ($users as $user) {
$tags = self::getUserTags($user->userid);
$searchableContent = self::buildSearchableContent($user, $tags);
if (!empty($searchableContent)) {
$userContents[$user->userid] = $searchableContent;
}
}
if (empty($userContents)) {
return 0;
}
// 3. 分批处理
$successCount = 0;
$chunks = array_chunk($userContents, $batchSize, true);
foreach ($chunks as $chunk) {
$texts = array_values($chunk);
$ids = array_keys($chunk);
// 4. 批量获取 embedding
$result = AI::getBatchEmbeddings($texts);
if (!Base::isSuccess($result) || empty($result['data'])) {
continue;
}
$embeddings = $result['data'];
// 5. 构建批量更新数据
$vectorData = [];
foreach ($ids as $index => $userid) {
if (!isset($embeddings[$index]) || empty($embeddings[$index])) {
continue;
}
$vectorData[$userid] = '[' . implode(',', $embeddings[$index]) . ']';
}
// 6. 批量更新向量
if (!empty($vectorData)) {
$batchCount = ManticoreBase::batchUpdateUserVectors($vectorData);
$successCount += $batchCount;
}
}
return $successCount;
} catch (\Exception $e) {
Log::error('ManticoreUser generateVectorsBatch error: ' . $e->getMessage());
return 0;
}
}
}

View File

@ -176,6 +176,8 @@ class OnlineLicense
/**
* 邮箱 + 验证码登录并签发。失败抛 ApiException。
* 本机有多条可用授权时appstore 返回 select_required + candidates
* 此处不签发、原样返回候选,由前端选定后走 loginConfirm()
*/
public static function login(string $email, string $code): array
{
@ -183,6 +185,35 @@ class OnlineLicense
if (!$r['ok']) {
throw new ApiException($r['message']);
}
$d = $r['data'];
if (($d['status'] ?? '') === 'select_required') {
return [
'select_required' => true,
'candidates' => $d['candidates'] ?? [],
];
}
$status = self::applyIssue($email, $d);
if (!in_array($status, ['issued', 'renewed'], true)) {
throw new ApiException(self::statusHint($status));
}
return self::status();
}
/**
* 多条可用授权时,用户选定 $entitlementId 后确认签发(复用同一验证码)。失败抛 ApiException。
*/
public static function loginConfirm(string $email, string $code, int $entitlementId): array
{
$payload = array_merge([
'email' => $email,
'code' => $code,
'entitlement_id' => $entitlementId,
'lang' => self::lang(),
], self::fingerprint());
$r = self::call('login/confirm', $payload);
if (!$r['ok']) {
throw new ApiException($r['message']);
}
$status = self::applyIssue($email, $r['data']);
if (!in_array($status, ['issued', 'renewed'], true)) {
throw new ApiException(self::statusHint($status));

View File

@ -4,6 +4,7 @@ namespace App\Observers;
use App\Models\User;
use App\Module\Apps;
use App\Module\Badge;
use App\Tasks\ManticoreSyncTask;
class UserObserver extends AbstractObserver
@ -80,6 +81,8 @@ class UserObserver extends AbstractObserver
} elseif (!$originalDisableAt && $currentDisableAt) {
// disable_at 从 null 变为有值 → 离职 (offboarded)
Apps::dispatchUserHook($user, 'user_offboard', 'offboarded');
// 离职清除该用户全部应用角标
Badge::clearByUser((int)$user->userid);
}
return;
}
@ -122,6 +125,9 @@ class UserObserver extends AbstractObserver
if (!$user->bot) {
Apps::dispatchUserHook($user, 'user_offboard', 'delete');
}
// 清除该用户全部应用角标
Badge::clearByUser((int)$user->userid);
}
}

View File

@ -94,6 +94,27 @@ class DeleteTmpTask extends AbstractTask
}
break;
case 'tmp_chunks':
// 分片上传残留upload_id 目录超过 hours 小时未合并则整目录清掉
$chunksRoot = public_path('uploads/tmp/chunks');
if (!is_dir($chunksRoot)) {
break;
}
$cutoff = time() - 3600 * $this->hours;
foreach (glob($chunksRoot . '/*', GLOB_ONLYDIR) ?: [] as $userDir) {
foreach (glob($userDir . '/*', GLOB_ONLYDIR) ?: [] as $uploadDir) {
$mtime = @filemtime($uploadDir);
if ($mtime && $mtime < $cutoff) {
Base::deleteDirAndFile($uploadDir);
}
}
// 顺手清理空 user 目录
if (count(scandir($userDir) ?: []) <= 2) {
@rmdir($userDir);
}
}
break;
case 'user_device':
UserDevice::where('expired_at', '<', Carbon::now()->subHours($this->hours))
->orderBy('id')

View File

@ -23,7 +23,10 @@ class LoopTask extends AbstractTask
ProjectTask::whereBetween('loop_at', [
Carbon::now()->subMinutes(10),
Carbon::now()
])->chunkById(100, function ($list) {
])->whereHas('project', function ($query) {
// 仅处理未删除、未归档项目的任务Project 软删除由全局作用域排除)
$query->whereNull('archived_at');
})->chunkById(100, function ($list) {
/** @var ProjectTask $item */
foreach ($list as $item) {
if ($item->parent_id > 0) {

View File

@ -235,11 +235,8 @@ class ManticoreSyncTask extends AbstractTask
}
Cache::put("ManticoreSyncTask:CheckTime", time(), Carbon::now()->addMinutes(5));
// 执行增量全文索引同步
// 执行增量全文索引同步(向量由 Manticore Auto Embeddings 随行自动生成)
$this->runIncrementalSync();
// 执行向量生成
$this->runVectorGeneration();
}
/**
@ -261,22 +258,6 @@ class ManticoreSyncTask extends AbstractTask
@shell_exec("php /var/www/artisan manticore:retry-failures 2>&1 &");
}
/**
* 执行向量生成(兜底触发)
*
* 命令内部有锁机制,如果已在运行会自动跳过
* 命令会持续处理直到无待处理数据,然后自动退出
*/
private function runVectorGeneration(): void
{
if (!Apps::isInstalled("ai")) {
return;
}
// 启动向量生成命令
@shell_exec("php /var/www/artisan manticore:generate-vectors --type=all --batch=50 2>&1 &");
}
public function end()
{
}

296
bin/install Executable file
View File

@ -0,0 +1,296 @@
#!/bin/bash
#
# DooTask 一键安装 / 升级脚本
#
# 用法(在目标目录执行):
# curl -fsSL https://raw.githubusercontent.com/kuaifan/dootask/pro/bin/install | bash
#
# 脚本会根据「当前目录」自动判断该做什么,无需额外参数:
# - 空目录 : 全新安装(克隆代码到当前目录 + ./cmd install
# - 已克隆但未安装 : 继续安装(./cmd install
# - 已安装 : 检查更新,确认后用「线上最新 cmd」执行升级
# - 非空且不是 DooTask : 拒绝操作并提示(绝不在此克隆或重置)
#
# 升级一次到位的关键:升级时从「线上 raw」取最新 cmd 到临时文件执行,
# 既不依赖用户机器上那份可能过时的 cmd也不写本地 .git规避属主/权限问题),
# 真正的 git pull / 依赖 / 迁移 / 重启全部交给这份最新 cmd避免「升两次」。
#
# 输出语言:仅当 locale 明确是中文 UTF-8 时显示中文,否则一律英文。
#
set -u
# ---------- 配置 ----------
BRANCH="pro" # 全新安装默认分支(升级时跟随当前分支)
REPO_GITHUB="https://github.com/kuaifan/dootask.git"
REPO_GITEE="https://gitee.com/aipaw/dootask.git"
# raw 基址:升级时取版本号与最新 cmd 用。后期可把 RAW_PRIMARY 换成官网映射的域名。
RAW_PRIMARY="https://raw.githubusercontent.com/kuaifan/dootask" # https://<base>/<branch>/<path>
RAW_FALLBACK="https://cdn.jsdelivr.net/gh/kuaifan/dootask" # https://<base>@<branch>/<path>
# ---------- 语言判定 ----------
# 默认英文;仅当 locale 明确是「中文 UTF-8」时才用中文中文非 UTF-8 如 GBK 也用英文以免乱码)。
DT_LANG="en"
__loc="${LC_ALL:-${LC_MESSAGES:-${LANG:-}}}"
case "$__loc" in
zh_*|zh-*|zh)
case "$__loc" in
*[Uu][Tt][Ff]*) DT_LANG="zh" ;; # 明确 UTF-8 → 中文
*.*) DT_LANG="en" ;; # 其他编码(如 .GBK→ 英文,避免乱码
*) DT_LANG="zh" ;; # 无编码后缀(裸 zh_CN→ 现代默认 UTF-8
esac
;;
esac
unset __loc
# ---------- 文案 ----------
# 调用处只写中文(动态值用 (*) 占位,顺序对应后续参数,与前端 $L 风格一致)。
# 中文环境直接用原文;英文环境在此集中查表翻译,未登记的中文原样返回。
msg() {
local tpl="$1"; shift
local out="$tpl"
if [ "$DT_LANG" != "zh" ]; then
case "$tpl" in
"成功") out="OK" ;;
"警告") out="WARN" ;;
"错误") out="ERROR" ;;
"未知") out="unknown" ;;
"git 未安装,请先安装后重试")
out="git is not installed. Please install it and retry." ;;
"curl 未安装,请先安装后重试")
out="curl is not installed. Please install it and retry." ;;
"Docker 未安装,请先安装后重试")
out="Docker is not installed. Please install it and retry." ;;
"docker-compose或 docker compose 插件)未安装,请先安装后重试")
out="docker-compose (or the docker compose plugin) is not installed. Please install it and retry." ;;
"当前目录为空,开始全新安装 DooTask ...")
out="Current directory is empty. Starting a fresh DooTask installation..." ;;
"克隆代码GitHub...")
out="Cloning source (GitHub)..." ;;
"GitHub 克隆失败,尝试 Gitee 镜像 ...")
out="GitHub clone failed, trying the Gitee mirror..." ;;
"代码克隆失败,请检查网络后重试")
out="Failed to clone the source. Please check your network and retry." ;;
"代码克隆完成")
out="Source cloned." ;;
"执行安装 ...")
out="Running installation..." ;;
"DooTask 安装完成")
out="DooTask installation complete." ;;
"检测到已克隆但尚未安装,执行安装 ...")
out="Repository found but not yet installed. Running installation..." ;;
"检测到已安装的 DooTask正在检查更新 ...")
out="Existing DooTask installation detected. Checking for updates..." ;;
"无法获取远程版本信息(分支 (*)),请检查网络后重试")
out="Unable to fetch remote version info (branch (*)). Please check your network and retry." ;;
"当前已是最新版本v(*)")
out="Already up to date (v(*))." ;;
"发现新版本:当前 v(*) → 最新 v(*)(分支 (*)")
out="New version available: current v(*) -> latest v(*) (branch (*))." ;;
"是否立即升级?")
out="Upgrade now?" ;;
"已取消升级")
out="Upgrade cancelled." ;;
"获取最新 cmd 失败,请检查网络后重试")
out="Failed to fetch the latest cmd. Please check your network and retry." ;;
"开始升级 ...")
out="Starting upgrade..." ;;
"DooTask 升级完成")
out="DooTask upgrade complete." ;;
"当前目录非空,且不是 DooTask 项目目录。")
out="Current directory is not empty and is not a DooTask project." ;;
"请在「空目录」中执行全新安装,或进入「已安装的 DooTask 目录」执行升级。")
out="Run this in an empty directory for a fresh install, or inside an existing DooTask directory to upgrade." ;;
esac
fi
# 动态值:依次把 (*) 替换为参数
local a
for a in "$@"; do
out="${out/(\*)/$a}"
done
printf '%s' "$out"
}
# ---------- 输出 ----------
if [ -t 1 ]; then
Red="\033[31m"; Green="\033[32m"; Yellow="\033[33m"; Blue="\033[36m"; Font="\033[0m"
else
Red=""; Green=""; Yellow=""; Blue=""; Font=""
fi
info() { echo -e "${Blue}==>${Font} $1"; }
success() { echo -e "${Green}[$(msg 成功)]${Font} $1"; }
warning() { echo -e "${Yellow}[$(msg 警告)]${Font} $1"; }
error() { echo -e "${Red}[$(msg 错误)]${Font} $1" >&2; }
die() { error "$1"; exit 1; }
# ---------- 交互输入 ----------
# curl | bash 时 stdin 被管道占用,交互一律从 /dev/tty 读,否则 read 会读到 EOF
has_tty() { [ -e /dev/tty ]; }
confirm() {
# $1=提示语,默认 Y无终端时返回失败不擅自执行需确认的操作
local prompt="$1" ans
has_tty || return 1
read -r -p "$prompt [Y/n] " ans < /dev/tty
[[ -z "$ans" || "$ans" =~ ^[Yy]([Ee][Ss])?$ ]]
}
# ---------- 提权执行 ----------
# install / update 需要 root统一用 bash 执行脚本(规避 /tmp noexec
# 交互(含 cmd 内部的 read 与 sudo 密码)接到 /dev/tty。
# git clone 不走这里,用当前用户执行,避免代码属主变成 root。
run_cmd() {
local script="$1"; shift
local stdin_src="/dev/stdin"
has_tty && stdin_src="/dev/tty"
if [ "$(id -u)" -eq 0 ]; then
bash "$script" "$@" < "$stdin_src"
else
sudo bash "$script" "$@" < "$stdin_src"
fi
}
# ---------- 前置检查 ----------
precheck() {
command -v git >/dev/null 2>&1 || die "$(msg 'git 未安装,请先安装后重试')"
command -v curl >/dev/null 2>&1 || die "$(msg 'curl 未安装,请先安装后重试')"
command -v docker >/dev/null 2>&1 || die "$(msg 'Docker 未安装,请先安装后重试')"
if ! docker compose version >/dev/null 2>&1 && ! docker-compose version >/dev/null 2>&1; then
die "$(msg 'docker-compose或 docker compose 插件)未安装,请先安装后重试')"
fi
}
# ---------- 工具 ----------
# 从 raw 取「指定分支的文件」到 stdout主源失败时回退 jsdelivr 镜像
fetch_raw() {
# $1=branch, $2=path
curl -fsSL "${RAW_PRIMARY}/$1/$2" 2>/dev/null \
|| curl -fsSL "${RAW_FALLBACK}@$1/$2" 2>/dev/null
}
# 从 stdin 读取 package.json 内容并提取版本号
read_pkg_version() {
grep -m1 '"version"' | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/'
}
is_dootask_project() {
# 只读 .git不写当前用户读取一般文件不受属主影响
if [ -d .git ] && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
local url; url="$(git config --get remote.origin.url 2>/dev/null || true)"
[[ "$url" == *dootask* ]] && return 0
fi
[ -f cmd ] && [ -f docker-compose.yml ] && return 0
return 1
}
is_installed() { [ -f vendor/autoload.php ]; }
# 可忽略的系统垃圾文件(判断空目录时忽略;全新安装前会清除以便 git clone .
_IGNORABLE=".DS_Store .localized .Spotlight-V100 .fseventsd .TemporaryItems .Trashes .DocumentRevisions-V100 .VolumeIcon.icns .AppleDouble .AppleDB .AppleDesktop Thumbs.db ehthumbs.db desktop.ini .directory"
# 是否「可忽略的系统文件」(含 macOS AppleDouble 的 ._xxx
_is_ignorable() {
case " $_IGNORABLE " in *" $1 "*) return 0 ;; esac
case "$1" in ._*) return 0 ;; esac
return 1
}
# 当前目录是否「实质为空」:只剩可忽略的系统垃圾文件也算空
dir_empty() {
local f
while IFS= read -r f; do
_is_ignorable "${f##*/}" || return 1
done < <(find . -maxdepth 1 -mindepth 1 2>/dev/null)
return 0
}
# 清除可忽略的系统垃圾文件(仅白名单),确保 git clone . 不被这些文件挡住
clean_ignorable() {
local f
while IFS= read -r f; do
_is_ignorable "${f##*/}" && rm -rf "$f"
done < <(find . -maxdepth 1 -mindepth 1 2>/dev/null)
}
current_branch() { git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "$BRANCH"; }
# ---------- 动作:全新安装 ----------
do_fresh_install() {
info "$(msg '当前目录为空,开始全新安装 DooTask ...')"
clean_ignorable # 清掉 .DS_Store 等系统垃圾,确保 git clone . 不被挡住
info "$(msg '克隆代码GitHub...')"
if ! git clone --depth=1 --branch "$BRANCH" "$REPO_GITHUB" . 2>/dev/null; then
warning "$(msg 'GitHub 克隆失败,尝试 Gitee 镜像 ...')"
rm -rf .git # 仅清理 clone 残留;若工作树仍有残留,下一步 clone 会报错而非误删
git clone --depth=1 --branch "$BRANCH" "$REPO_GITEE" . \
|| die "$(msg '代码克隆失败,请检查网络后重试')"
fi
success "$(msg '代码克隆完成')"
info "$(msg '执行安装 ...')"
run_cmd ./cmd install
success "$(msg 'DooTask 安装完成')"
}
# ---------- 动作:续装 ----------
do_install() {
info "$(msg '检测到已克隆但尚未安装,执行安装 ...')"
run_cmd ./cmd install
success "$(msg 'DooTask 安装完成')"
}
# ---------- 动作:升级 ----------
do_upgrade() {
info "$(msg '检测到已安装的 DooTask正在检查更新 ...')"
local branch; branch="$(current_branch)"
local local_ver remote_ver
local_ver="$( [ -f package.json ] && read_pkg_version < package.json )"
remote_ver="$(fetch_raw "$branch" package.json | read_pkg_version)"
[ -z "$local_ver" ] && local_ver="$(msg 未知)"
# 取不到远程版本(网络/分支异常)→ 报错,避免误判
[ -z "$remote_ver" ] && die "$(msg '无法获取远程版本信息(分支 (*)),请检查网络后重试' "$branch")"
if [ "$local_ver" = "$remote_ver" ]; then
success "$(msg '当前已是最新版本v(*)' "$local_ver")"
exit 0
fi
echo
info "$(msg '发现新版本:当前 v(*) → 最新 v(*)(分支 (*)' "$local_ver" "$remote_ver" "$branch")"
if ! confirm "$(msg '是否立即升级?')"; then
warning "$(msg '已取消升级')"
exit 0
fi
# 从 raw 取「线上最新 cmd」到临时文件执行不碰本地 .git、不依赖磁盘旧 cmd
# 真正的 git pull / 装依赖 / 迁移 / 重启由这份最新 cmd 完成,一次到位。
local tmp; tmp="$(mktemp)"
if ! fetch_raw "$branch" cmd > "$tmp" || [ ! -s "$tmp" ]; then
rm -f "$tmp"; die "$(msg '获取最新 cmd 失败,请检查网络后重试')"
fi
info "$(msg '开始升级 ...')"
run_cmd "$tmp" update
rm -f "$tmp"
success "$(msg 'DooTask 升级完成')"
}
# ---------- 主流程 ----------
main() {
precheck
if is_dootask_project; then
if is_installed; then
do_upgrade
else
do_install
fi
elif dir_empty; then
do_fresh_install
else
error "$(msg '当前目录非空,且不是 DooTask 项目目录。')"
error "$(msg '请在「空目录」中执行全新安装,或进入「已安装的 DooTask 目录」执行升级。')"
exit 1
fi
}
main "$@"

400
cmd
View File

@ -9,10 +9,106 @@ YellowBG="\033[43;37m"
RedBG="\033[41;37m"
Font="\033[0m"
# 语言判定:默认英文;仅当 locale 明确是「中文 UTF-8」时才用中文中文非 UTF-8 如 GBK 也用英文以免乱码)。
DT_LANG="en"
__loc="${LC_ALL:-${LC_MESSAGES:-${LANG:-}}}"
case "$__loc" in
zh_*|zh-*|zh)
case "$__loc" in
*[Uu][Tt][Ff]*) DT_LANG="zh" ;;
*.*) DT_LANG="en" ;;
*) DT_LANG="zh" ;;
esac
;;
esac
unset __loc
# 文案:调用处只写中文(动态值用 (*) 占位,顺序对应后续参数)。
# 中文环境直接用原文;英文环境在此集中查表翻译,未登记的中文原样返回。
msg() {
local tpl="$1"; shift
local out="$tpl"
if [ "$DT_LANG" != "zh" ]; then
case "$tpl" in
"警告") out="WARN" ;;
"错误") out="ERROR" ;;
"地址") out="URL" ;;
"(*) 完成") out="(*) done" ;;
"(*) 失败") out="(*) failed" ;;
"备份数据库") out="Backing up database" ;;
"还原数据库") out="Restoring database" ;;
"无法创建脚本副本") out="Failed to create script copy" ;;
"没有找到 (*) 容器!") out="Container (*) not found!" ;;
"请使用 sudo 运行此脚本") out="Please run this script with sudo" ;;
"未安装 Docker") out="Docker is not installed!" ;;
"未安装 Docker-compose") out="Docker-compose is not installed!" ;;
"Docker-compose 版本过低请升级至v2+") out="Docker-compose is too old. Please upgrade to v2+!" ;;
"未安装 npm") out="npm is not installed!" ;;
"未安装 Node.js") out="Node.js is not installed!" ;;
"Node.js 版本过低请升级至v20+") out="Node.js is too old. Please upgrade to v20+!" ;;
"备份文件:(*)") out="Backup file: (*)" ;;
"没有备份文件!") out="No backup files found!" ;;
"可用备份列表:") out="Available backups:" ;;
"请输入备份文件编号还原:") out="Enter the backup number to restore: " ;;
"编号无效,请重新输入。") out="Invalid number, please try again." ;;
"HTTP服务端口不是80是否修改并继续操作 [Y/n]") out="HTTP port is not 80. Change it and continue? [Y/n]" ;;
"HTTPS服务端口不是443是否修改并继续操作 [Y/n]") out="HTTPS port is not 443. Change it and continue? [Y/n]" ;;
"继续操作") out="Continuing" ;;
"操作终止") out="Operation aborted" ;;
"任务已存在,无需添加。") out="Cron job already exists, skipped." ;;
"任务已添加。") out="Cron job added." ;;
"设置env参数失败") out="Failed to set env variable!" ;;
"APP_ID(*))已被其他实例使用:(*)") out="APP_ID ((*)) is already used by another instance: (*)" ;;
"请先清空 .env 中的 APP_ID 和 APP_IPPR 再重新安装") out="Please clear APP_ID and APP_IPPR in .env, then reinstall" ;;
"端口 (*) 已被占用,请指定其他端口") out="Port (*) is already in use, please specify another port" ;;
"目录权限检测失败!请检查目录权限设置") out="Directory permission check failed! Please check directory permissions" ;;
"目录【(*)】权限不足!") out="Directory [(*)] is not writable!" ;;
"项目权限修复失败") out="Failed to repair project permissions" ;;
"项目权限修复完成") out="Project permissions repaired" ;;
"安装依赖失败") out="Failed to install dependencies" ;;
"安装依赖失败,请重试!") out="Failed to install dependencies, please retry!" ;;
"生成密钥失败") out="Failed to generate app key" ;;
"数据库迁移失败") out="Database migration failed" ;;
"安装完成") out="Installation complete" ;;
"请先执行安装命令") out="Please run the install command first" ;;
"检测到本地修改,是否强制更新?[Y/n]") out="Local changes detected. Force update? [Y/n]" ;;
"取消更新,请先处理本地修改") out="Update cancelled, please handle local changes first" ;;
"获取远程更新失败") out="Failed to fetch remote updates" ;;
"设置远程Fetch配置失败") out="Failed to set remote fetch config" ;;
"获取远程分支 (*) 失败") out="Failed to fetch remote branch (*)" ;;
"切换分支到 (*) 失败") out="Failed to switch to branch (*)" ;;
"数据库有迁移变动,执行数据库备份...") out="Database migrations changed, backing up database..." ;;
"数据库备份失败") out="Database backup failed" ;;
"数据库备份完成") out="Database backup complete" ;;
"强制更新代码失败") out="Failed to force-update code" ;;
"代码拉取失败,可能存在冲突,请使用 --force 参数") out="Failed to pull code (possible conflict), please use --force" ;;
"更新PHP依赖失败") out="Failed to update PHP dependencies" ;;
"执行数据库备份...") out="Backing up database..." ;;
"重启服务失败") out="Failed to restart services" ;;
"更新完成") out="Update complete" ;;
"警告:此操作将永久删除以下内容:") out="WARNING: This will permanently delete:" ;;
"- 数据库") out="- Database" ;;
"- 应用程序") out="- Application" ;;
"- 日志文件") out="- Log files" ;;
"确认要继续卸载吗?(y/N): ") out="Confirm uninstall? (y/N): " ;;
"开始卸载...") out="Uninstalling..." ;;
"终止卸载。") out="Uninstall aborted." ;;
"卸载完成") out="Uninstall complete" ;;
"修改成功") out="Changed successfully" ;;
esac
fi
# 动态值:依次把 (*) 替换为参数
local a
for a in "$@"; do
out="${out/(\*)/$a}"
done
printf '%s' "$out"
}
# 通知信息
OK="${Green}[OK]${Font}"
Warn="${Yellow}[警告]${Font}"
Error="${Red}[错误]${Font}"
Warn="${Yellow}[$(msg 警告)]${Font}"
Error="${Red}[$(msg 错误)]${Font}"
# 基本参数
WORK_DIR="$(pwd)"
@ -28,7 +124,7 @@ fi
# 缓存执行
if [ -z "$CACHED_EXECUTION" ] && [ "$1" == "update" ]; then
if ! cat "$0" > ._cmd 2>/dev/null; then
error "无法创建脚本副本"
error "$(msg '无法创建脚本副本')"
exit 1
fi
chmod +x ._cmd
@ -42,10 +138,10 @@ fi
# 判断是否成功
judge() {
if [[ 0 -eq $? ]]; then
success "$1 完成"
success "$(msg '(*) 完成' "$1")"
sleep 1
else
error "$1 失败"
error "$(msg '(*) 失败' "$1")"
exit 1
fi
}
@ -128,7 +224,50 @@ switch_debug() {
# 检查是否有sudo
check_sudo() {
if [ "$EUID" -ne 0 ]; then
error "请使用 sudo 运行此脚本"
error "$(msg '请使用 sudo 运行此脚本')"
exit 1
fi
}
# 修复项目权限
permission_fix() {
local owner_uid="${SUDO_UID:-$(id -u)}"
local owner_gid="${SUDO_GID:-$(id -g)}"
volumes=(
"bootstrap/cache"
"docker"
"public"
"storage"
)
chmod 755 "${WORK_DIR}"
if [ $? -ne 0 ]; then
error "$(msg '项目权限修复失败')"
exit 1
fi
for vol in "${volumes[@]}"; do
tmp_path="${WORK_DIR}/${vol}"
mkdir -p "${tmp_path}"
if [ $? -ne 0 ]; then
error "$(msg '项目权限修复失败')"
exit 1
fi
chown -R "${owner_uid}:${owner_gid}" "${tmp_path}"
if [ $? -ne 0 ]; then
error "$(msg '项目权限修复失败')"
exit 1
fi
find "${tmp_path}" -type d -exec chmod 775 {} \;
if [ $? -ne 0 ]; then
error "$(msg '项目权限修复失败')"
exit 1
fi
done
find "${WORK_DIR}/public" -type f -exec chmod a+r {} \;
if [ $? -ne 0 ]; then
error "$(msg '项目权限修复失败')"
exit 1
fi
}
@ -137,21 +276,21 @@ check_sudo() {
check_docker() {
docker --version &> /dev/null
if [ $? -ne 0 ]; then
error "未安装 Docker"
error "$(msg '未安装 Docker')"
exit 1
fi
docker-compose version &> /dev/null
if [ $? -ne 0 ]; then
docker compose version &> /dev/null
if [ $? -ne 0 ]; then
error "未安装 Docker-compose"
error "$(msg '未安装 Docker-compose')"
exit 1
fi
COMPOSE="docker compose"
fi
if [[ -n `$COMPOSE version | grep -E "\s+v1\."` ]]; then
$COMPOSE version
error "Docker-compose 版本过低请升级至v2+"
error "$(msg 'Docker-compose 版本过低请升级至v2+')"
exit 1
fi
}
@ -160,17 +299,17 @@ check_docker() {
check_node() {
npm --version &> /dev/null
if [ $? -ne 0 ]; then
error "未安装 npm"
error "$(msg '未安装 npm')"
exit 1
fi
node --version &> /dev/null
if [ $? -ne 0 ]; then
error "未安装 Node.js"
error "$(msg '未安装 Node.js')"
exit 1
fi
if [[ -n `node --version | grep -E "v1"` ]]; then
node --version
error "Node.js 版本过低请升级至v20+"
error "$(msg 'Node.js 版本过低请升级至v20+')"
exit 1
fi
}
@ -180,6 +319,19 @@ docker_name() {
echo `$COMPOSE ps | awk '{print $1}' | grep "\-$1\-"`
}
# 等待 php 容器健康(最多约 90s
wait_php_healthy() {
local name st wait=0
name="$(docker_name php)"
[ -z "$name" ] && return 0
while [ $wait -lt 90 ]; do
st="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$name" 2>/dev/null)"
{ [ "$st" = "healthy" ] || [ "$st" = "running" ]; } && break
sleep 3
wait=$((wait + 3))
done
}
# 编译前端
web_build() {
local type=$1
@ -244,12 +396,24 @@ container_exec() {
local cmd=$@
local name=$(docker_name "$container")
if [ -z "$name" ]; then
error "没有找到 ${container} 容器!"
error "$(msg '没有找到 (*) 容器!' "$container")"
exit 1
fi
docker exec $TTY_FLAG "$name" /bin/sh -c "$cmd"
}
# 使用当前 docker-compose.yml 定义的服务镜像执行一次性容器命令
container_run() {
local container=$1
shift 1
local cmd=$@
if [ -t 0 ] && [ -t 1 ]; then
$COMPOSE run --rm --entrypoint /bin/sh "$container" -c "$cmd"
else
$COMPOSE run --rm -T --entrypoint /bin/sh "$container" -c "$cmd"
fi
}
# 备份数据库、还原数据库
mysql_snapshot() {
if [ "$1" = "backup" ]; then
@ -260,8 +424,8 @@ mysql_snapshot() {
mkdir -p ${WORK_DIR}/docker/mysql/backup
filename="${WORK_DIR}/docker/mysql/backup/${database}_$(date "+%Y%m%d%H%M%S").sql.gz"
container_exec mariadb "exec mysqldump --databases $database -u${username} -p${password}" | gzip > $filename
judge "备份数据库"
[ -f "$filename" ] && echo "备份文件:${filename}"
judge "$(msg '备份数据库')"
[ -f "$filename" ] && echo "$(msg '备份文件:(*)' "$filename")"
elif [ "$1" = "recovery" ]; then
database=$(env_get DB_DATABASE)
username=$(env_get DB_USERNAME)
@ -272,31 +436,31 @@ mysql_snapshot() {
backup_files=("${WORK_DIR}/docker/mysql/backup/"*.sql.gz)
shopt -u nullglob
if [ ${#backup_files[@]} -eq 0 ]; then
error "没有备份文件!"
error "$(msg '没有备份文件!')"
exit 1
fi
echo "可用备份列表:"
echo "$(msg '可用备份列表:')"
for idx in "${!backup_files[@]}"; do
printf "%2d) %s\n" "$((idx + 1))" "$(basename "${backup_files[$idx]}")"
done
while true; do
read -rp "请输入备份文件编号还原:" selection
read -rp "$(msg '请输入备份文件编号还原:')" selection
if [[ "$selection" =~ ^[0-9]+$ ]] && [ "$selection" -ge 1 ] && [ "$selection" -le ${#backup_files[@]} ]; then
break
fi
warning "编号无效,请重新输入。"
warning "$(msg '编号无效,请重新输入。')"
done
filename="${backup_files[$((selection - 1))]}"
inputname="$(basename "$filename")"
container_name=`docker_name mariadb`
if [ -z "$container_name" ]; then
error "没有找到 mariadb 容器!"
error "$(msg '没有找到 (*) 容器!' mariadb)"
exit 1
fi
docker cp "$filename" "${container_name}:/"
container_exec mariadb "gunzip < '/${inputname}' | mysql -u${username} -p${password} $database"
container_exec php "php artisan migrate"
judge "还原数据库"
judge "$(msg '还原数据库')"
fi
}
@ -327,33 +491,33 @@ remove_by_network() {
https_auto() {
restart_nginx="n"
if [[ "$(env_get APP_PORT)" != "80" ]]; then
warning "HTTP服务端口不是80是否修改并继续操作 [Y/n]"
warning "$(msg 'HTTP服务端口不是80是否修改并继续操作 [Y/n]')"
read -r continue_http
[[ -z ${continue_http} ]] && continue_http="Y"
case $continue_http in
[yY][eE][sS] | [yY])
success "继续操作"
success "$(msg '继续操作')"
env_set "APP_PORT" "80"
restart_nginx="y"
;;
*)
error "操作终止"
error "$(msg '操作终止')"
exit 1
;;
esac
fi
if [[ "$(env_get APP_SSL_PORT)" != "443" ]]; then
warning "HTTPS服务端口不是443是否修改并继续操作 [Y/n]"
warning "$(msg 'HTTPS服务端口不是443是否修改并继续操作 [Y/n]')"
read -r continue_https
[[ -z ${continue_https} ]] && continue_https="Y"
case $continue_https in
[yY][eE][sS] | [yY])
success "继续操作"
success "$(msg '继续操作')"
env_set "APP_SSL_PORT" "443"
restart_nginx="y"
;;
*)
error "操作终止"
error "$(msg '操作终止')"
exit 1
;;
esac
@ -368,13 +532,13 @@ https_auto() {
new_job="* 6 * * * docker run --rm -v $(pwd):/work nginx:alpine sh /work/bin/https renew"
current_crontab=$(crontab -l 2>/dev/null)
if ! echo "$current_crontab" | grep -v "https renew"; then
echo "任务已存在,无需添加。"
echo "$(msg '任务已存在,无需添加。')"
else
crontab -l |{
cat
echo "$new_job"
} | crontab -
echo "任务已添加。"
echo "$(msg '任务已添加。')"
fi
}
@ -404,7 +568,7 @@ env_set() {
docker run $TTY_FLAG --rm -v ${WORK_DIR}:/www nginx:alpine sh -c "sed -i "/^${key}=/c\\${key}=${val}" /www/.env"
fi
if [ $? -ne 0 ]; then
error "设置env参数失败"
error "$(msg '设置env参数失败')"
exit 1
fi
fi
@ -456,7 +620,8 @@ arg_get() {
# 显示帮助信息
show_help() {
cat << 'EOF'
if [ "$DT_LANG" = "zh" ]; then
cat << 'EOF'
DooTask 管理脚本
用法: ./cmd <命令> [参数]
@ -465,6 +630,7 @@ DooTask 管理脚本
install 安装 DooTask (支持 --port <端口> --relock)
update 更新 DooTask (支持 --branch <分支> --force --local)
uninstall 卸载 DooTask
permission 修复整个项目的权限
⚙️ 配置管理:
port <端口> 修改服务端口
@ -501,9 +667,62 @@ DooTask 管理脚本
示例:
./cmd install --port 8080 安装并指定端口 8080
./cmd update --branch dev 切换到 dev 分支并更新
./cmd permission 修复整个项目的权限
./cmd mysql backup 备份数据库
./cmd artisan migrate 执行数据库迁移
EOF
else
cat << 'EOF'
DooTask Management Script
Usage: ./cmd <command> [options]
📦 Core:
install Install DooTask (supports --port <port> --relock)
update Update DooTask (supports --branch <branch> --force --local)
uninstall Uninstall DooTask
permission Repair permissions for the whole project
⚙️ Configuration:
port <port> Change service port
url <address> Change access URL
env <key> <value> Set environment variable
debug [true|false] Toggle debug mode
repassword [username] Reset database password
🚀 Build:
serve, dev Start dev mode
build, prod Production build
electron Build desktop app
🔧 Services:
up [service] Start containers
down [service] Stop containers
restart [service] Restart containers
reup Rebuild and start
💾 Database:
mysql backup Back up database
mysql recovery Restore database
🛠️ Dev tools:
artisan <command> Run Laravel Artisan command
composer <command> Run Composer command
php <command> Run PHP command
📚 Others:
doc Generate API docs
https Configure HTTPS
--help, -h Show this help
Examples:
./cmd install --port 8080 Install on port 8080
./cmd update --branch dev Switch to dev branch and update
./cmd permission Repair permissions for the whole project
./cmd mysql backup Back up database
./cmd artisan migrate Run database migration
EOF
fi
}
# 检测APP_ID是否与其他实例冲突
@ -512,8 +731,8 @@ check_instance() {
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 再重新安装"
error "$(msg 'APP_ID(*))已被其他实例使用:(*)' "$app_id" "$mount_path")"
error "$(msg '请先清空 .env 中的 APP_ID 和 APP_IPPR 再重新安装')"
exit 1
fi
}
@ -525,7 +744,7 @@ check_port() {
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} 已被占用,请指定其他端口"
error "$(msg '端口 (*) 已被占用,请指定其他端口' "$port")"
exit 1
fi
fi
@ -544,20 +763,12 @@ handle_install() {
rm -rf node_modules package-lock.json vendor composer.lock
fi
# 目录权限设置
volumes=(
"bootstrap/cache"
"docker"
"public"
"storage"
)
# 目录和静态文件权限设置
permission_fix
cmda=""
cmdb=""
for vol in "${volumes[@]}"; do
tmp_path="${WORK_DIR}/${vol}"
mkdir -p "${tmp_path}"
find "${tmp_path}" -type d -exec chmod 775 {} \;
rm -f "${tmp_path}/dootask.lock"
cmda="${cmda} -v ${tmp_path}:/usr/share/${vol}"
cmdb="${cmdb} touch /usr/share/${vol}/dootask.lock &&"
@ -570,13 +781,13 @@ handle_install() {
writable="yes"
docker run --rm ${cmda} nginx:alpine sh -c "${cmdb} touch /usr/share/docker/dootask.lock" &> /dev/null
if [ $? -ne 0 ]; then
error "目录权限检测失败!请检查目录权限设置"
error "$(msg '目录权限检测失败!请检查目录权限设置')"
exit 1
fi
for vol in "${volumes[@]}"; do
if [ ! -f "${vol}/dootask.lock" ]; then
if [ $remaining -lt 0 ]; then
error "目录【${vol}】权限不足!"
error "$(msg '目录【(*)】权限不足!' "$vol")"
exit 1
else
writable="no"
@ -607,28 +818,32 @@ handle_install() {
$COMPOSE up php -d
# 安装PHP依赖
exec_judge "container_exec php 'composer install --optimize-autoloader'" "安装依赖失败"
exec_judge "container_exec php 'composer install --optimize-autoloader'" "$(msg '安装依赖失败')"
# 最终检查
if [ ! -f "${WORK_DIR}/vendor/autoload.php" ]; then
error "安装依赖失败,请重试!"
error "$(msg '安装依赖失败,请重试!')"
exit 1
fi
# 生成应用密钥
[[ -z "$(env_get APP_KEY)" ]] && exec_judge "container_exec php 'php artisan key:generate'" "生成密钥失败"
[[ -z "$(env_get APP_KEY)" ]] && exec_judge "container_exec php 'php artisan key:generate'" "$(msg '生成密钥失败')"
# 设置生产模式
switch_debug "false"
# 数据库迁移
exec_judge "container_exec php 'php artisan migrate --seed'" "数据库迁移失败"
exec_judge "container_exec php 'php artisan migrate --seed'" "$(msg '数据库迁移失败')"
# 启动所有容器
$COMPOSE up -d --remove-orphans
success "安装完成"
echo -e "地址: http://${GreenBG}127.0.0.1:$(env_get APP_PORT)${Font}"
# 兜底拉起 nginx避免首启时序竞态
wait_php_healthy
[ -z "$(docker_name nginx)" ] && $COMPOSE up -d --remove-orphans
success "$(msg '安装完成')"
echo -e "$(msg '地址'): http://${GreenBG}127.0.0.1:$(env_get APP_PORT)${Font}"
container_exec mariadb "sh /etc/mysql/repassword.sh"
}
@ -642,7 +857,7 @@ handle_update() {
# 检查是否已经安装
if [ ! -f "${WORK_DIR}/vendor/autoload.php" ]; then
error "请先执行安装命令"
error "$(msg '请先执行安装命令')"
exit 1
fi
@ -652,10 +867,13 @@ handle_update() {
fi
if [[ -z "$is_local" ]]; then
# 信任项目目录,避免 git 归属检查拦截
git config --global --get-all safe.directory 2>/dev/null | grep -qxF "${WORK_DIR}" \
|| git config --global --add safe.directory "${WORK_DIR}" 2>/dev/null
# 检查本地修改
if ! git diff --quiet || ! git diff --cached --quiet; then
if [[ "$force_update" != "yes" ]]; then
warning "检测到本地修改,是否强制更新?[Y/n]"
warning "$(msg '检测到本地修改,是否强制更新?[Y/n]')"
read -r confirm_force
[[ -z ${confirm_force} ]] && confirm_force="Y"
case $confirm_force in
@ -663,7 +881,7 @@ handle_update() {
force_update="yes"
;;
*)
error "取消更新,请先处理本地修改"
error "$(msg '取消更新,请先处理本地修改')"
exit 1
;;
esac
@ -671,21 +889,21 @@ handle_update() {
fi
# 远程更新模式
exec_judge "git fetch --all" "获取远程更新失败"
exec_judge "git fetch --all" "$(msg '获取远程更新失败')"
# 确定目标分支
if [[ -n "$target_branch" ]]; then
current_branch="$target_branch"
if ! git config --get "branch.${current_branch}.remote" | grep -q "origin"; then
exec_judge "git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'" "设置远程Fetch配置失败"
exec_judge "git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'" "$(msg '设置远程Fetch配置失败')"
fi
if ! git show-ref --verify --quiet refs/heads/${current_branch}; then
exec_judge "git fetch origin ${current_branch}:${current_branch}" "获取远程分支 ${current_branch} 失败"
exec_judge "git fetch origin ${current_branch}:${current_branch}" "$(msg '获取远程分支 (*) 失败' "$current_branch")"
fi
if [[ "$force_update" == "yes" ]]; then
exec_judge "git checkout -f ${current_branch}" "切换分支到 ${current_branch} 失败"
exec_judge "git checkout -f ${current_branch}" "$(msg '切换分支到 (*) 失败' "$current_branch")"
else
exec_judge "git checkout ${current_branch}" "切换分支到 ${current_branch} 失败"
exec_judge "git checkout ${current_branch}" "$(msg '切换分支到 (*) 失败' "$current_branch")"
fi
else
current_branch=$(git branch | sed -n -e 's/^\* \(.*\)/\1/p')
@ -694,27 +912,27 @@ handle_update() {
# 检查数据库迁移变动
db_changes=$(git diff --name-only HEAD..origin/${current_branch} 2>/dev/null | grep -E "^database/" || true)
if [[ -n "$db_changes" ]]; then
echo "数据库有迁移变动,执行数据库备份..."
exec_judge "mysql_snapshot backup" "数据库备份失败" "数据库备份完成"
echo "$(msg '数据库有迁移变动,执行数据库备份...')"
exec_judge "mysql_snapshot backup" "$(msg '数据库备份失败')" "$(msg '数据库备份完成')"
fi
# 更新代码
if [[ "$force_update" == "yes" ]]; then
exec_judge "git reset --hard origin/${current_branch}" "强制更新代码失败"
exec_judge "git reset --hard origin/${current_branch}" "$(msg '强制更新代码失败')"
else
exec_judge "git pull --ff-only origin ${current_branch}" "代码拉取失败,可能存在冲突,请使用 --force 参数"
exec_judge "git pull --ff-only origin ${current_branch}" "$(msg '代码拉取失败,可能存在冲突,请使用 --force 参数')"
fi
# 更新依赖
exec_judge "container_exec php 'composer install --optimize-autoloader'" "更新PHP依赖失败"
exec_judge "container_run php 'composer install --optimize-autoloader'" "$(msg '更新PHP依赖失败')"
else
# 本地更新模式
echo "执行数据库备份..."
exec_judge "mysql_snapshot backup" "数据库备份失败" "数据库备份完成"
echo "$(msg '执行数据库备份...')"
exec_judge "mysql_snapshot backup" "$(msg '数据库备份失败')" "$(msg '数据库备份完成')"
fi
# 数据库迁移
exec_judge "container_exec php 'php artisan migrate'" "数据库迁移失败"
exec_judge "container_run php 'php artisan migrate'" "$(msg '数据库迁移失败')"
# 停止服务
$COMPOSE stop php nginx &> /dev/null
@ -724,30 +942,34 @@ handle_update() {
$COMPOSE up -d --remove-orphans
if [[ 0 -ne $? ]]; then
$COMPOSE down --remove-orphans
exec_judge "$COMPOSE up -d" "重启服务失败"
exec_judge "$COMPOSE up -d" "$(msg '重启服务失败')"
fi
# 兜底拉起 nginx避免首启时序竞态
wait_php_healthy
[ -z "$(docker_name nginx)" ] && $COMPOSE up -d --remove-orphans
env_set UPDATE_TIME "$(date +%s)"
success "更新完成"
success "$(msg '更新完成')"
}
# 卸载函数
handle_uninstall() {
check_sudo
# 确认卸载
echo -e "${RedBG}警告:此操作将永久删除以下内容:${Font}"
echo "- 数据库"
echo "- 应用程序"
echo "- 日志文件"
echo -e "${RedBG}$(msg '警告:此操作将永久删除以下内容:')${Font}"
echo "$(msg '- 数据库')"
echo "$(msg '- 应用程序')"
echo "$(msg '- 日志文件')"
echo ""
read -rp "确认要继续卸载吗?(y/N): " confirm_uninstall
read -rp "$(msg '确认要继续卸载吗?(y/N): ')" confirm_uninstall
[[ -z ${confirm_uninstall} ]] && confirm_uninstall="N"
case $confirm_uninstall in
[yY][eE][sS] | [yY])
echo -e "${RedBG}开始卸载...${Font}"
echo -e "${RedBG}$(msg '开始卸载...')${Font}"
;;
*)
echo -e "${GreenBG}终止卸载。${Font}"
echo -e "${GreenBG}$(msg '终止卸载。')${Font}"
exit 1
;;
esac
@ -755,8 +977,8 @@ handle_uninstall() {
# 清理网络相关容器
remove_by_network
# 停止并删除容器
$COMPOSE down --remove-orphans
# 停止并删除容器(含命名卷)
$COMPOSE down --remove-orphans --volumes
# 重置调试模式
env_set APP_DEBUG "false"
@ -768,7 +990,7 @@ handle_uninstall() {
find "./docker/appstore/log" -name "*.log" -delete 2>/dev/null
find "./storage/logs" -name "*.log" -delete 2>/dev/null
success "卸载完成"
success "$(msg '卸载完成')"
}
####################################################################################
@ -781,8 +1003,8 @@ if [[ "$1" == "help" ]] || [[ "$1" == "--help" ]] || [[ "$1" == "-h" ]] || [[ $#
exit 0
fi
# 非electron命令需要检查Docker环境
if [[ "$1" != "electron" ]]; then
# 非electron和permission命令需要检查Docker环境
if [[ "$1" != "electron" ]] && [[ "$1" != "permission" ]]; then
check_docker
env_init
fi
@ -801,19 +1023,25 @@ case "$1" in
shift 1
handle_uninstall
;;
"permission")
shift 1
check_sudo
permission_fix
success "$(msg '项目权限修复完成')"
;;
"port")
shift 1
check_port "$1" "$(env_get APP_PORT)"
env_set APP_PORT "$1"
$COMPOSE up -d
success "修改成功"
echo -e "地址: http://${GreenBG}127.0.0.1:$(env_get APP_PORT)${Font}"
success "$(msg '修改成功')"
echo -e "$(msg '地址'): http://${GreenBG}127.0.0.1:$(env_get APP_PORT)${Font}"
;;
"url")
shift 1
env_set APP_URL "$1"
restart_php
success "修改成功"
success "$(msg '修改成功')"
;;
"env")
shift 1
@ -821,7 +1049,7 @@ case "$1" in
env_set $1 "$2"
fi
restart_php
success "修改成功"
success "$(msg '修改成功')"
;;
"repassword")
shift 1

View File

@ -26,6 +26,12 @@ return [
// Manticore 全文搜索服务端口ManticoreBase
'search_port' => env('SEARCH_PORT', 9306),
// AI 插件服务主机AI::getEmbedding 走 ai 插件 /embeddings 免费向量模型)
'ai_host' => env('AI_HOST', 'ai'),
// AI 插件服务端口AI::getEmbedding
'ai_port' => env('AI_PORT', 5001),
// 文件回收站自动清空天数DeleteTmpTask
'auto_empty_file_recycle' => env('AUTO_EMPTY_FILE_RECYCLE', 365),

View File

@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateAppBadgesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
if (Schema::hasTable('app_badges')) {
return;
}
Schema::create('app_badges', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('app_id', 100)->default('')->comment('应用IDappstore 插件 appid 或自定义微应用 id');
$table->string('menu_key', 100)->default('')->comment('菜单稳定标识;空串表示该应用的第一个菜单');
$table->bigInteger('userid')->comment('用户ID');
$table->integer('count')->default(0)->comment('角标数字');
$table->boolean('dot')->default(false)->comment('是否显示红点');
$table->timestamp('updated_at')->nullable()->comment('更新时间');
//
$table->unique(['app_id', 'menu_key', 'userid'], 'app_badges_unique');
$table->index('userid', 'app_badges_userid');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('app_badges');
}
}

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::table('files', function (Blueprint $table) {
$table->char('hash', 32)->nullable()->after('size')->comment('文件内容 md5分片上传秒传用');
$table->index('hash', 'files_hash_index');
});
}
public function down()
{
Schema::table('files', function (Blueprint $table) {
$table->dropIndex('files_hash_index');
$table->dropColumn('hash');
});
}
};

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::table('project_tasks', function (Blueprint $table) {
$table->index(
['project_id', 'parent_id', 'visibility', 'archived_at', 'deleted_at', 'complete_at', 'end_at'],
'idx_pt_dashboard_due'
);
$table->index(
['project_id', 'parent_id', 'visibility', 'archived_at', 'deleted_at', 'complete_at', 'p_level'],
'idx_pt_dashboard_priority'
);
});
Schema::table('project_task_users', function (Blueprint $table) {
$table->index(['task_id', 'owner', 'userid'], 'idx_ptu_task_owner_user');
$table->index(['userid', 'owner', 'task_id'], 'idx_ptu_user_owner_task');
});
}
public function down()
{
Schema::table('project_tasks', function (Blueprint $table) {
$table->dropIndex('idx_pt_dashboard_due');
$table->dropIndex('idx_pt_dashboard_priority');
});
Schema::table('project_task_users', function (Blueprint $table) {
$table->dropIndex('idx_ptu_task_owner_user');
$table->dropIndex('idx_ptu_user_owner_task');
});
}
};

View File

@ -99,7 +99,7 @@ services:
appstore:
container_name: "dootask-appstore-${APP_ID}"
privileged: true
image: "dootask/appstore:0.5.2"
image: "dootask/appstore:0.5.4"
volumes:
- shared_data:/usr/share/dootask
- ${HOST_DOCKER_SOCK:-/var/run/docker.sock}:/var/run/docker.sock

View File

@ -12,6 +12,7 @@
"allowJs": true,
"checkJs": false,
"jsx": "preserve",
"ignoreDeprecations": "6.0",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,

View File

@ -1018,3 +1018,28 @@ AI 助手
在线授权已失效,已回落到基础版
请输入邮箱
请输入邮箱和验证码
密钥无效
应用未安装
菜单不存在
源文件不存在
文件 hash 格式错误
文件大小无效
文件超过系统支持的最大尺寸
文件名不能为空
不支持的上传场景
上传会话不存在或已过期
上传会话归属错误
分片序号超出范围
分片数据无效
分片大小不符合预期
末尾分片大小不符合预期
分片读取失败:(*)
分片不完整,无法合并
无法创建合并文件
文件校验失败,请重试
scene 暂未实现:(*)
upload_id 不能为空
合并繁忙,请稍后再试
仅项目负责人或任务相关成员删除
未开启部门负责人视角功能
没有可查看的部门数据

View File

@ -1,10 +1,5 @@
模版满足多种团队协作场景,同时支持自定义模版,满足团队个性化场景管理需求,可直观的查看项目的进展情况,团队协作更方便。
2、若你是团队的所有者请在删除您的帐号前转移所有权。例如该帐号所创建的项目可将项目移交他人或删除项目以及文件夹。
汇集文档、电子表格、思维笔记等多种在线工具,汇聚企业知识资源于一处,支持多人实时协同编辑,让团队协作更便捷。
多平台应用支持,打开客户端即可跟进项目任务进度, 同时让你在工作中每一个步骤都能拥有更高效愉悦的体验。
1、您将无法查看该帐号内的任何信息包括帐号信息、文件记录、聊天记录、项目信息、团队成员信息等。
通过灵活的任务日历,轻松安排每一天的日程,把任务拆解到每天,让工作目标更清晰,时间分配更合理。
针对项目和任务建立群组,工作问题可及时沟通,促进团队快速协作,提高团队工作效率。
帐号删除后,该帐号将无法正常登录且无法恢复,帐号下的所有数据也将被删除。
复杂大于或等于6个字符包含数字、字母大小写或者特殊字符。
不会向忽略的邮箱地址发送邮件,可使用换行分割多个地址。
@ -24,7 +19,6 @@
开启后可以直接使用 LDAP 帐号密码登录
必填:发送聊天内容前必须设置昵称。
移除成员负责的任务将变成无负责人,
首页底部:首页底部网站备案号等信息
为确保帐号安全,请确认是本人操作
修改邮箱和删除帐号需要邮箱验证码
关闭签到功能再开启需要重新安装。
@ -42,7 +36,6 @@
+ 输入子任务,回车添加子任务
4、请保证帐号未被暂停使用。
只能设置单个状态为验收/测试
以下是你当前的任务统计数据
可通过此链接直接加入项目。
项目不存在或不在成员列表内
简单大于或等于6个字符。
@ -54,14 +47,12 @@
任务列表不存在或已被删除
密码错误,请输入正确密码
开放:所有人都可以发言。
强大易用的协同创作云文档
注销前,请确认一下事项
签到前后时间收到消息通知
签到提醒对象3天内有签到的成员法定工作日
该状态下任务自动标记完成
输入您的信息以创建帐户。
仅支持Openwrt系统的路由器
便捷易用的项目管理模板
允许成员自己修改MAC地址
可设置多个状态为进行中
可通过此链接浏览文件。
@ -70,7 +61,6 @@
注意:离职操作不可逆!
请输入正确的邮箱地址!
项目负责人无法退出项目
高效便捷的团队沟通工具
以 http:// 或 https:// 开头
请开启您PHP环境的openssl
两次密码输入不一致!
@ -90,17 +80,13 @@
仅限项目负责人修改
仅限项目负责人删除
仅限项目负责人操作
任务描述,回车创建
你好,扫码确认登录
你确定要登出系统吗?
你确认领取任务吗?
列表名称,回车创建
同步修改子任务时间
子任务不支持此功能
最后在线于很久以前
注意此操作不可逆!
浏览图片空间的图片
清晰直观的任务日历
确认密码输入不一致
请先修改登录密码!
请填写正确的旧密码
@ -108,7 +94,6 @@
请输入正确的邀请码
请输入正确的验证码
请重新输入新密码!
轻量级任务管理工具
输入群名称(选填)
电话长度至少6位
不支持复制文件夹
@ -139,8 +124,6 @@
超出文件大小限制
输入您的电子邮件
选择任务协助人员
选择转发指定成员
选择转发最近聊天
邮箱、昵称、职位
请输入会议频道ID
第二次任务提醒
@ -158,9 +141,7 @@
帐号或密码错误
我的待完成任务
拖动到这里发送
支持多平台应用
文件格式不正确
最后在线于刚刚
最近打开的任务
未保存计划时间
正在上传文件...
@ -263,14 +244,9 @@ ID、名称、描述...
验证码已失效
使用 SSO 登录
PPT 演示文稿
同步滚动:关
同步滚动:开
Excel 工作表
html转markdown
上传文件夹
不是发送人
今日待完成
代码块主题
任务不存在
任务优先级
任务已完成
@ -296,7 +272,6 @@ html转markdown
工作流设置
已删除任务
已完成任务
已完成列表
已归档任务
开启工作流
归档的任务
@ -314,7 +289,6 @@ html转markdown
最晚可延后
服务器版本
未完成任务
未完成列表
未开放注册
未知的消息
标记未完成
@ -357,6 +331,21 @@ SMTP服务器
任务提醒
职位/职称
验收/测试
(*) 超期
未来 (*) 天内暂无到期任务
暂无高优先级任务
所有任务均已分配负责人
当前筛选条件下暂无任务
可以切换上方条件查看其他任务
数据更新说明
页面统计数据在 (*) 秒内复用,重点关注任务列表除外。
上次更新:(*)
立即刷新
刷新成功
查看全部 (*) 个优先级
任务概况
流程阶段
未完成总数
上传图片
上传失败
上传成功
@ -392,14 +381,11 @@ SMTP服务器
允许修改
允许注册
全员群组
全屏查看
全屏编辑
全部文件
全部禁言
共享权限
共享设置
准备发布
分屏显示
创建时间
创建群组
创建项目
@ -560,7 +546,6 @@ SMTP服务器
紧凑经典
经典天盘
结束状态
缩小查看
群组设置
聊天昵称
聊天资料
@ -628,7 +613,6 @@ LDAP 地址
LDAP 端口
MD编辑器
Word 文档
导出XLSX
MAC地址
扫一扫
上个月
@ -648,9 +632,6 @@ MAC地址
到期后
天空蓝
子任务
导出CSV
导出TXT
导出XLS
已删除
已取消
已完成
@ -870,36 +851,19 @@ Pro版
颜色
黄色
默认
(*)秒
(*)是一款轻量级的开源在线项目任务管理工具提供各类文档协作工具、在线思维导图、在线流程图、项目管理、任务分发、即时IM文件管理等工具。
(*)负责的部门、项目、任务和文件将移交给交接人;同时退出所有群(如果是群主则转让给交接人)
流转到【(*)】时,[任务负责人] 和 [项目管理员] 可以修改状态。
文件(*)格式不正确,请上传(*)格式的图片。
流转到【(*)】时添加状态负责人至任务负责人。
每个文件夹里最多只能创建(*)个文件或文件夹
文件(*)格式不正确,仅支持上传:(*)
正在进行帐号【(*)】MAC地址修改。
正在进行帐号【(*)】离职操作。
正在进行帐号【(*)】部门修改。
文件大小超限,最大限制:(*)KB
职位/职称最多只能设置(*)个字
任务描述最多只能设置(*)个字
你确定要归档项目【(*)】吗?
文件(*)太大,不能超过:(*)'
文件名称最多只能设置(*)个字
文件格式错误,限制类型:(*)
项目介绍最多只能设置(*)个字
@ -913,8 +877,6 @@ Pro版
消息内容最大不能超过(*)字
项目列表最多不能超过(*)个
项目名称不可以少于(*)个字
最多只能上传(*)张图片。
最多只能选择(*)张图片。
密码最多只能设置(*)位数
密码设置不能小于(*)位数
描述最多只能设置(*)个字
@ -1044,8 +1006,6 @@ Pro版
删除人员
删除的任务
你确定要还原删除吗?
转换成markdown
请输入html代码...
请输入License...
详细信息
域名
@ -1347,9 +1307,7 @@ APP 推送
你确定取消待办吗?
取消成功
请等待打包完成
选择一个项目查看更多任务
首页
无相关数据
权限设置
任务列权限
添加列
@ -1374,7 +1332,6 @@ APP 推送
接龙
参与接龙
发起接龙
发起接龙,参与接龙目前共(*)人
请输入接龙主题
请输入接龙内容
@ -1382,7 +1339,6 @@ APP 推送
重复内容将不再计入接龙结果
返回编辑
继续发送
接龙结果
选择群组发起接龙
来自
@ -1399,7 +1355,6 @@ APP 推送
单选
请选择后投票
立即投票
再次发送
再次发送投票?
结束投票
@ -1434,6 +1389,16 @@ License Key
私聊禁言
群聊禁言
默认不限制
默认 1G
准备中...
上传中...
合并中...
上传已暂停
网络异常,重试中...
上传初始化失败
分片上传失败
合并失败
分片重试耗尽
开放:所有人都可以在全员群组发言。
开放:所有人都可以相互发起个人聊天。
开放:允许个人群组聊天发言。
@ -1712,11 +1677,11 @@ WiFi签到延迟时长为±1分钟。
注意:此操作不可恢复,部门下的成员将移至默认部门。
维护中...
(*)评论了(*)的「(**)」审批
抄送(*)提交的「(**)」记录
(*)提交的「(**)」待你审批
您发起的「(**)」已通过
您发起的「(**)」被(*)拒绝
(*)评论了(*)的「(*)」审批
抄送(*)提交的「(*)」记录
(*)提交的「(*)」待你审批
您发起的「(*)」已通过
您发起的「(*)」被(*)拒绝
翻译
从不
@ -1776,7 +1741,6 @@ WiFi签到延迟时长为±1分钟。
签到半径设置
半径
经度
纬度
地图类型
@ -2082,7 +2046,6 @@ OKR群组
邀请地址不存在或已被删除!
会话名称
结果
命令
接口地址
@ -2326,7 +2289,6 @@ AI 消息助手
留空则不修改密码
职位
请输入电话号码
正在编辑帐号【ID:(*)】的信息。
编辑用户信息
AI任务分析
@ -2489,3 +2451,208 @@ AI任务分析
(*)秒后重发
请输入邮箱和验证码
刷新中
诊断详情
授权 SN
当前 SN
授权 MAC
当前 MAC
授权与当前设备不匹配
检测到设备标识SN已变更在线授权可能已失效。请重新登录授权或先在原设备退出以释放座位。
在线授权已过期
新增用户已受限,请尽快联网以自动续期恢复。
检测到网卡MAC变化
系统会在下次续期时自动恢复授权,通常无需处理。
续期失败,请检查网络
授权仍然有效,联网后会自动续期恢复。
授权即将到期
请保持联网,系统会自动为你续期。
重新登录授权
匹配
不匹配
收起
更多信息
提示
将释放当前设备占用的授权座位并回到登录,确定继续?
已过期
选择要使用的授权
当前设备使用中
确定授权
当前任务所属项目已被删除
当前任务所属项目已归档
我的文件
共享文件
我共享的
共享给我的
服务器返回错误HTTP (*)
你撤回了一条消息
重新编辑
四象限
个人视角
部门任务总览
(*) 人
最久 (*) 天
今天 (*) 项到期
还有 (*) 项
待完成
我协助的
(*)月(*)日
有 (*) 项已超期,建议先处理
有 (*) 项今日到期
今日无到期任务
团队有 (*) 项已超期,涉及 (*) 人
团队有 (*) 项已超期
团队本周已完成 (*) 项
查看任务
涉及 (*) 人
(*) 天内到期
本周完成
较上周
与上周持平
成员任务分配
(*) 项
未分配
查看全部 (*) 人
优先级分布
未完成 (*) 项
点击任一档位可下钻到任务列表
高优先级
重点关注任务
未分配负责人
待分配
全部显示完毕
逾期 (*) 天
逾期 (*) 小时
夜深了,(*)
早上好,(*)
中午好,(*)
下午好,(*)
晚上好,(*)
服务器时间
暂无已超期任务
暂无今日到期任务
暂无待完成任务
暂无协助的任务
没有超期任务,保持住
今天只有这一项,处理完就轻松了
数量较多,仅显示最近 (*) 项,展开其余 (*) 项
我的部门
待开始
暂无待开始任务
太棒了,任务全部清空
本周完成了 (*) 项任务
欢迎使用 DooTask
项目是任务与协作的起点,创建一个开始
创建第一个项目
已有团队?请同事把你加入项目即可
近期完成
本周 (*) 项
当前没有待处理任务
可以新建一项任务,或等待新的工作安排
选择需要统计的部门,包含所选部门及其所有下级部门。
部门范围与负责人仪表盘保持一致;开启后,可只读查看范围内成员参与的项目和任务。
项目负责人视角
开启后在项目列表中显示额外的只读项目
选择团队范围
在项目列表中启用
+(*)位
(*)条回复
AgoraIO 声网
会话中断
确定要清空所有历史会话吗?
新窗口打开
组件加载失败!
没有更新描述。
已全选
已选部分
你的姓名
请输入你的姓名
会议中
未命名应用
应用 ID
应用名称
菜单标题
菜单位置
应用中心 - 常用
应用中心 - 管理
主导航
可见范围
仅管理员
图标地址
菜单 URL
背景颜色
保持激活状态
自动暗黑模式
沉浸式
透明背景
禁用作用域样式
可能要发的照片
其他任务
点击查看
处理
没有符合条件的数据
正常
暂无介绍
关联子任务
最多只能选择(*)个标签
来源
未知
分享时间
分享人
标题
任务状态
已还原
下个周期
LDAP 用户禁止修改邮箱
共(*)个
标签名称最多只能设置(*)个字
添加失败
未开启通知权限
使用端到端加密传输数据。
删除前,请确认以下事项:
已清楚风险,确定删除
只能输入字母或数字
授权
不支持单独查看此消息
报告详情
验证邮箱
您的邮箱已通过验证
今后您可以通过此邮箱重置您的帐号密码
链接已过期,已重新发送
请设置昵称
请设置联系电话
文件 (*) 上传失败 (*)
文件 (*) 格式不正确,请上传 jpg、jpeg、webp、gif、png 格式的图片。
文件 (*) 太大,不能超过:(*)
最多只能上传 (*) 张图片。
最多只能选择 (*) 张图片。
文件 (*) 上传失败,(*)
文件 (*) 格式不正确,仅支持上传:(*)
很久以前
@我的消息
确定要删除记录"(*)"吗?
添加(*)
共(*)个文件仅显示最新50个
正在进行帐号【ID:(*), (*)】离职操作。
(*) 负责的部门、项目、任务和文件将移交给交接人;同时退出所有群(如果是群主则转让给交接人)
正在编辑帐号【ID:(*), (*)】的信息。
打包下载(*)
[calendar_view].日
[calendar_view].周
[calendar_view].月
[day_unit].天
[distance_unit].米
[example_label].例
[initiator_label].由
[recurrence_prefix].每
[self_sender].你
[task_unit].个
[task_unit].项
[todo_target].给
[vote_unit].票
[weekday].日
[weekday].一
[weekday].二
[weekday].三
[weekday].四
[weekday].五
[weekday].六

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{
"name": "DooTask",
"version": "1.8.45",
"codeVerson": 238,
"version": "1.8.89",
"codeVerson": 241,
"description": "DooTask is task management system.",
"scripts": {
"start": "./cmd dev",
@ -62,6 +62,7 @@
"resolve-url-loader": "^4.0.0",
"sass": "1.77.4",
"sass-loader": "14.2.1",
"spark-md5": "^3.0.2",
"stylus": "^0.59.0",
"stylus-loader": "^7.1.0",
"tinymce": "^5.10.3",

View File

@ -1 +1 @@
import{n as m}from"./app.f8ae8cc3.js";import"./jquery.24b9d090.js";import"./@babel.9410f858.js";import"./dayjs.19659d6c.js";import"./localforage.8c2def53.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};
import{n as m}from"./app.1e00fc89.js";import"./jquery.99163cb9.js";import"./@babel.9410f858.js";import"./dayjs.0bb0f368.js";import"./localforage.6e50a401.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};

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

View File

@ -1 +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}
.checkin-field .ivu-form-item-label{color:#f90;font-weight:500}.checkin-mac-header[data-v-7e50b959]{margin-bottom:8px;font-weight:500;color:#606266}.checkin-mac-item[data-v-7e50b959]{margin-bottom:8px}.checkin-mac-item .ivu-col[data-v-7e50b959]{padding-right:8px}.checkin-mac-item .ivu-col[data-v-7e50b959]:last-child{padding-right:0}.checkin-mac-del[data-v-7e50b959]{display:flex;align-items:center;justify-content:center;cursor:pointer;color:red}.checkin-mac-del[data-v-7e50b959]:hover{opacity:.8}.form-tip[data-v-7e50b959]{font-size:12px;color:#999;margin-top:4px}.user-tags-preview[data-v-7e50b959]{display:flex;align-items:center;flex-wrap:wrap;gap:8px;min-height:32px}.user-tags-preview .tag-pill[data-v-7e50b959]{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-7e50b959]{color:#67c23a}.user-tags-preview .tag-pill span[data-v-7e50b959]{padding-left:8px;position:relative}.user-tags-preview .tag-pill span[data-v-7e50b959]: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-7e50b959]{color:#909399}.user-tags-preview .tags-total[data-v-7e50b959]{color:#909399;font-size:12px}.user-tags-preview .manage-button[data-v-7e50b959]{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}

View File

@ -1 +0,0 @@
import{m as i}from"./vuex.cc7cb26e.js";import{n as o}from"./app.f8ae8cc3.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};

View File

@ -0,0 +1 @@
import{m as i}from"./vuex.cc7cb26e.js";import{n as d}from"./app.1e00fc89.js";var o=function(){var t=this,a=t.$createElement,e=t._self._c||a;return e("Modal",{attrs:{value:t.value,title:t.modalTitle,"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.scopeOnly?[t._v(t._s(t.$L("\u9009\u62E9\u9700\u8981\u7EDF\u8BA1\u7684\u90E8\u95E8\uFF0C\u5305\u542B\u6240\u9009\u90E8\u95E8\u53CA\u5176\u6240\u6709\u4E0B\u7EA7\u90E8\u95E8\u3002")))]:[t._v(t._s(t.$L("\u90E8\u95E8\u8303\u56F4\u4E0E\u8D1F\u8D23\u4EBA\u4EEA\u8868\u76D8\u4FDD\u6301\u4E00\u81F4\uFF1B\u5F00\u542F\u540E\uFF0C\u53EF\u53EA\u8BFB\u67E5\u770B\u8303\u56F4\u5185\u6210\u5458\u53C2\u4E0E\u7684\u9879\u76EE\u548C\u4EFB\u52A1\u3002")))]],2),t.scopeOnly?t._e():e("div",{staticClass:"department-owner-view-switch"},[e("div",[e("strong",[t._v(t._s(t.$L("\u5728\u9879\u76EE\u5217\u8868\u4E2D\u542F\u7528")))]),e("span",{staticClass:"department-owner-view-switch-desc"},[t._v(t._s(t.$L("\u5F00\u542F\u540E\u5728\u9879\u76EE\u5217\u8868\u4E2D\u663E\u793A\u989D\u5916\u7684\u53EA\u8BFB\u9879\u76EE")))])]),e("iSwitch",{model:{value:t.draftProjectViewEnabled,callback:function(s){t.draftProjectViewEnabled=s},expression:"draftProjectViewEnabled"}})],1),t.scopeOnly||t.draftProjectViewEnabled?[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)]:t._e()],2),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",disabled:t.draftIds.length===0,loading:t.applyLoading},on:{click:t.apply}},[t._v(t._s(t.$L("\u786E\u5B9A")))])],1)])},l=[];const c={name:"DepartmentOwnerView",props:{value:Boolean,scopeOnly:Boolean},data(){return{draftIds:[],draftProjectViewEnabled:!1,applyLoading:!1}},computed:{...i(["userInfo","cacheDepartmentOwnerIds","departmentOwnerProjectViewEnabled"]),modalTitle(){return this.scopeOnly?this.$L("\u9009\u62E9\u56E2\u961F\u8303\u56F4"):this.$L("\u9879\u76EE\u8D1F\u8D23\u4EBA\u89C6\u89D2")},managedDepartments(){return(this.userInfo.managed_departments||[]).map(t=>({...t,id:parseInt(t.id)}))}},watch:{value:{immediate:!0,handler(t){if(t){const a=(this.cacheDepartmentOwnerIds||[]).slice();this.draftIds=a.length>0?a:this.managedDepartments.map(e=>e.id),this.draftProjectViewEnabled=this.departmentOwnerProjectViewEnabled}else 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.scopeOnly?this.draftIds:{ids:this.draftIds,projectViewEnabled:this.draftProjectViewEnabled}),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=d(c,o,l,!1,f,"61d09b1f",null,null);function f(t){for(let a in r)this[a]=r[a]}var u=function(){return p.exports}();export{u as D};

View File

@ -0,0 +1 @@
.department-owner-view-modal .department-owner-view-actions[data-v-61d09b1f]{display:flex;justify-content:flex-end;gap:14px;margin:12px 8px 0}.department-owner-view-modal .department-owner-view-switch[data-v-61d09b1f]{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:14px 8px 4px}.department-owner-view-modal .department-owner-view-switch>div[data-v-61d09b1f]{display:flex;flex-direction:column;gap:3px;min-width:0}.department-owner-view-modal .department-owner-view-switch strong[data-v-61d09b1f]{font-weight:500}.department-owner-view-modal .department-owner-view-switch .department-owner-view-switch-desc[data-v-61d09b1f]{color:#999;font-size:12px;line-height:18px}.department-owner-view-modal .department-owner-view-list[data-v-61d09b1f]{display:flex;flex-direction:column;margin-top:10px}.department-owner-view-modal .department-owner-view-item[data-v-61d09b1f]{display:flex;align-items:center;padding:10px 12px;cursor:pointer}.department-owner-view-modal .department-owner-view-icon[data-v-61d09b1f]{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-61d09b1f]{flex:1}.department-owner-view-modal .department-owner-view-checkbox[data-v-61d09b1f]{margin-right:0}

View File

@ -1 +0,0 @@
.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}

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/Drawio.6950c35e.css vendored Normal file
View File

@ -0,0 +1 @@
.drawio-content[data-v-86e491c2]{position:absolute;top:0;left:0;width:100%;height:100%}.drawio-content .drawio-iframe[data-v-86e491c2]{position:absolute;top:0;left:0;width:100%;height:100%;background:0 0;border:0;float:none;margin:-1px 0 0;max-width:none;outline:0;padding:0}.drawio-content .drawio-loading[data-v-86e491c2]{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}

View File

@ -1 +0,0 @@
.drawio-content[data-v-39021859]{position:absolute;top:0;left:0;width:100%;height:100%}.drawio-content .drawio-iframe[data-v-39021859]{position:absolute;top:0;left:0;width:100%;height:100%;background:0 0;border:0;float:none;margin:-1px 0 0;max-width:none;outline:0;padding:0}.drawio-content .drawio-loading[data-v-39021859]{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}

View File

@ -1 +1 @@
import{m as s}from"./vuex.cc7cb26e.js";import{I as m}from"./IFrame.c85a6115.js";import{n as p,l as o}from"./app.f8ae8cc3.js";import"./jquery.24b9d090.js";import"./@babel.9410f858.js";import"./dayjs.19659d6c.js";import"./localforage.8c2def53.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};
import{m as s}from"./vuex.cc7cb26e.js";import{I as m}from"./IFrame.02468084.js";import{n as p,l as o}from"./app.1e00fc89.js";import"./jquery.99163cb9.js";import"./@babel.9410f858.js";import"./dayjs.0bb0f368.js";import"./localforage.6e50a401.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,"86e491c2",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

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

View File

@ -1 +1 @@
import{n}from"./app.f8ae8cc3.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.1e00fc89.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};

1
public/js/build/ImgUpload.be73943e.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

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
.component-only-office[data-v-60cfc883]{position:absolute;top:0;left:0;right:0;bottom:0;display:flex;align-items:center;justify-content:center}.component-only-office .placeholder[data-v-60cfc883]{flex:1;width:100%;height:100%}.component-only-office .office-loading[data-v-60cfc883]{position:absolute;top:0;left:0;right:0;bottom:0;display:flex;align-items:center;justify-content:center;z-index:2}.component-only-office .load-error{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);z-index:1;padding:8px;display:flex;align-items:center}.component-only-office .load-error .ivu-alert-icon{position:static;margin-right:8px;margin-left:4px}
.component-only-office[data-v-5e3aa26e]{position:absolute;top:0;left:0;right:0;bottom:0;display:flex;align-items:center;justify-content:center}.component-only-office .placeholder[data-v-5e3aa26e]{flex:1;width:100%;height:100%}.component-only-office .office-loading[data-v-5e3aa26e]{position:absolute;top:0;left:0;right:0;bottom:0;display:flex;align-items:center;justify-content:center;z-index:2}.component-only-office .load-error{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);z-index:1;padding:8px;display:flex;align-items:center}.component-only-office .load-error .ivu-alert-icon{position:static;margin-right:8px;margin-left:4px}

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

View File

@ -1 +1 @@
import{n as r}from"./app.f8ae8cc3.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.1e00fc89.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};

1
public/js/build/TEditor.37a62d41.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

View File

@ -1 +1 @@
.task-editor[data-v-4e70a0a5]{position:relative;word-break:break-all}.task-editor[data-v-4e70a0a5] .mce-content-body,.task-editor[data-v-4e70a0a5] .task-editor-content{line-height:1.6}.task-editor[data-v-4e70a0a5] p{margin:.3em 0}.task-editor[data-v-4e70a0a5] blockquote,.task-editor[data-v-4e70a0a5] pre,.task-editor[data-v-4e70a0a5] ul,.task-editor[data-v-4e70a0a5] ol{margin:1em 0}.task-editor[data-v-4e70a0a5] ul,.task-editor[data-v-4e70a0a5] ol{margin-left:1.5em;padding-left:1.5em}.task-editor[data-v-4e70a0a5] li{margin:.25em 0}.task-editor[data-v-4e70a0a5] h1{margin:.67em 0}.task-editor[data-v-4e70a0a5] h2{margin:.83em 0}.task-editor[data-v-4e70a0a5] h3{margin:1em 0}.task-editor[data-v-4e70a0a5] h4{margin:1.33em 0}.task-editor[data-v-4e70a0a5] h5{margin:1.67em 0}.task-editor[data-v-4e70a0a5] h6{margin:2.33em 0}.task-editor .task-editor-operate[data-v-4e70a0a5]{position:absolute;top:0;left:0;width:1px;opacity:0;visibility:hidden;pointer-events:none}.task-tag-select[data-v-e09d999e]{width:100%;display:flex;flex-direction:column}.task-tag-select.no-search .search-box[data-v-e09d999e]{display:none}.task-tag-select.no-search .tag-list .tag-item[data-v-e09d999e]:first-child{margin-top:0}.task-tag-select .search-box[data-v-e09d999e]{padding-bottom:8px;border-bottom:1px solid #eee}.task-tag-select .search-box .search-input[data-v-e09d999e]{width:100%;height:34px;padding:0 12px;border:1px solid #dcdfe6;border-radius:4px;outline:none}.task-tag-select .search-box .search-input[data-v-e09d999e]:focus{border-color:#84c56a}.task-tag-select .tag-list[data-v-e09d999e]{flex:1;overflow-y:auto;max-height:300px;margin:0 -12px;padding:0 12px}.task-tag-select .tag-list .tag-item[data-v-e09d999e]{display:flex;align-items:flex-start;padding:8px 12px;cursor:pointer;border-radius:6px;margin-bottom:6px}.task-tag-select .tag-list .tag-item[data-v-e09d999e]:first-child{margin-top:12px}.task-tag-select .tag-list .tag-item[data-v-e09d999e]:last-child{margin-bottom:12px}.task-tag-select .tag-list .tag-item[data-v-e09d999e]:hover{background-color:#f5f7fa}.task-tag-select .tag-list .tag-item.is-selected[data-v-e09d999e]{background-color:#ecf5ff}.task-tag-select .tag-list .tag-item .tag-color[data-v-e09d999e]{width:16px;height:16px;border-radius:4px;margin-right:8px;margin-top:2px}.task-tag-select .tag-list .tag-item .tag-info[data-v-e09d999e]{flex:1}.task-tag-select .tag-list .tag-item .tag-info .tag-name[data-v-e09d999e]{line-height:20px;font-size:14px;color:#303133}.task-tag-select .tag-list .tag-item .tag-info .tag-desc[data-v-e09d999e]{font-size:12px;color:#909399;margin-top:2px}.task-tag-select .tag-list .tag-item .tag-check[data-v-e09d999e]{color:#84c56a;margin-left:12px;height:20px;display:flex;align-items:center}.task-tag-select .tag-list .no-data[data-v-e09d999e]{text-align:center;color:#909399;padding:24px 0;margin-bottom:12px}.task-tag-select .footer-box[data-v-e09d999e]{border-top:1px solid #eee;padding-top:8px}.task-tag-select .footer-box .add-button[data-v-e09d999e]{display:flex;align-items:center;justify-content:center;padding:4px 0 2px;cursor:pointer;color:#84c56a;border-radius:6px;transition:color .2s}.task-tag-select .footer-box .add-button[data-v-e09d999e]:hover{color:#a2d98d}.task-tag-select .footer-box .add-button i[data-v-e09d999e]{margin-right:4px}.task-content-history .ivu-page[data-v-a0030d34]{margin-top:12px;display:flex;align-items:center;justify-content:center}
.task-editor[data-v-4e70a0a5]{position:relative;word-break:break-all}.task-editor[data-v-4e70a0a5] .mce-content-body,.task-editor[data-v-4e70a0a5] .task-editor-content{line-height:1.6}.task-editor[data-v-4e70a0a5] p{margin:.3em 0}.task-editor[data-v-4e70a0a5] blockquote,.task-editor[data-v-4e70a0a5] pre,.task-editor[data-v-4e70a0a5] ul,.task-editor[data-v-4e70a0a5] ol{margin:1em 0}.task-editor[data-v-4e70a0a5] ul,.task-editor[data-v-4e70a0a5] ol{margin-left:1.5em;padding-left:1.5em}.task-editor[data-v-4e70a0a5] li{margin:.25em 0}.task-editor[data-v-4e70a0a5] h1{margin:.67em 0}.task-editor[data-v-4e70a0a5] h2{margin:.83em 0}.task-editor[data-v-4e70a0a5] h3{margin:1em 0}.task-editor[data-v-4e70a0a5] h4{margin:1.33em 0}.task-editor[data-v-4e70a0a5] h5{margin:1.67em 0}.task-editor[data-v-4e70a0a5] h6{margin:2.33em 0}.task-editor .task-editor-operate[data-v-4e70a0a5]{position:absolute;top:0;left:0;width:1px;opacity:0;visibility:hidden;pointer-events:none}.task-tag-select[data-v-5700cf7a]{width:100%;display:flex;flex-direction:column}.task-tag-select.no-search .search-box[data-v-5700cf7a]{display:none}.task-tag-select.no-search .tag-list .tag-item[data-v-5700cf7a]:first-child{margin-top:0}.task-tag-select .search-box[data-v-5700cf7a]{padding-bottom:8px;border-bottom:1px solid #eee}.task-tag-select .search-box .search-input[data-v-5700cf7a]{width:100%;height:34px;padding:0 12px;border:1px solid #dcdfe6;border-radius:4px;outline:none}.task-tag-select .search-box .search-input[data-v-5700cf7a]:focus{border-color:#84c56a}.task-tag-select .tag-list[data-v-5700cf7a]{flex:1;overflow-y:auto;max-height:300px;margin:0 -12px;padding:0 12px}.task-tag-select .tag-list .tag-item[data-v-5700cf7a]{display:flex;align-items:flex-start;padding:8px 12px;cursor:pointer;border-radius:6px;margin-bottom:6px}.task-tag-select .tag-list .tag-item[data-v-5700cf7a]:first-child{margin-top:12px}.task-tag-select .tag-list .tag-item[data-v-5700cf7a]:last-child{margin-bottom:12px}.task-tag-select .tag-list .tag-item[data-v-5700cf7a]:hover{background-color:#f5f7fa}.task-tag-select .tag-list .tag-item.is-selected[data-v-5700cf7a]{background-color:#ecf5ff}.task-tag-select .tag-list .tag-item .tag-color[data-v-5700cf7a]{width:16px;height:16px;border-radius:4px;margin-right:8px;margin-top:2px}.task-tag-select .tag-list .tag-item .tag-info[data-v-5700cf7a]{flex:1}.task-tag-select .tag-list .tag-item .tag-info .tag-name[data-v-5700cf7a]{line-height:20px;font-size:14px;color:#303133}.task-tag-select .tag-list .tag-item .tag-info .tag-desc[data-v-5700cf7a]{font-size:12px;color:#909399;margin-top:2px}.task-tag-select .tag-list .tag-item .tag-check[data-v-5700cf7a]{color:#84c56a;margin-left:12px;height:20px;display:flex;align-items:center}.task-tag-select .tag-list .no-data[data-v-5700cf7a]{text-align:center;color:#909399;padding:24px 0;margin-bottom:12px}.task-tag-select .footer-box[data-v-5700cf7a]{border-top:1px solid #eee;padding-top:8px}.task-tag-select .footer-box .add-button[data-v-5700cf7a]{display:flex;align-items:center;justify-content:center;padding:4px 0 2px;cursor:pointer;color:#84c56a;border-radius:6px;transition:color .2s}.task-tag-select .footer-box .add-button[data-v-5700cf7a]:hover{color:#a2d98d}.task-tag-select .footer-box .add-button i[data-v-5700cf7a]{margin-right:4px}.task-content-history .ivu-page[data-v-a0030d34]{margin-top:12px;display:flex;align-items:center;justify-content:center}

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

7
public/js/build/app.fcecaabb.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
.map-select-container[data-v-b1904dc8]{display:flex;gap:20px;height:500px}@media (width < 768px){.map-select-container[data-v-b1904dc8]{flex-direction:column;height:700px}.map-select-container .map-radius-control[data-v-b1904dc8]{width:100%;border-left:0;padding-left:0}}.map-select-iframe-container[data-v-b1904dc8]{flex:1}.map-select-point-iframe[data-v-b1904dc8]{width:100%;height:100%;border:0;border-radius:12px}.map-radius-control[data-v-b1904dc8]{width:280px;border-left:1px solid #e8e8e8;padding-left:20px;display:flex;flex-direction:column}.radius-control-header[data-v-b1904dc8]{margin-bottom:15px}.radius-control-header h4[data-v-b1904dc8]{margin:0;font-size:16px;font-weight:600;color:#333}.radius-control-body[data-v-b1904dc8]{flex:1;display:flex;flex-direction:column}.location-info[data-v-b1904dc8]{margin:15px 0;padding:12px;background:#f8f9fa;border-radius:6px}.info-item[data-v-b1904dc8]{display:flex;justify-content:space-between;margin-bottom:8px;font-size:14px}.info-item[data-v-b1904dc8]:last-child{margin-bottom:0}.info-label[data-v-b1904dc8]{color:#666}.info-value[data-v-b1904dc8]{color:#333;font-weight:500}.radius-control-tip[data-v-b1904dc8]{background:#f0f8ff;padding:12px;border-radius:6px;border-left:3px solid #007cff;font-size:13px;color:#333;line-height:1.4;margin-top:auto}

View File

@ -1 +0,0 @@
.map-select-container[data-v-6135b0b6]{display:flex;gap:20px;height:500px}@media (width < 768px){.map-select-container[data-v-6135b0b6]{flex-direction:column;height:700px}.map-select-container .map-radius-control[data-v-6135b0b6]{width:100%;border-left:0;padding-left:0}}.map-select-iframe-container[data-v-6135b0b6]{flex:1}.map-select-point-iframe[data-v-6135b0b6]{width:100%;height:100%;border:0;border-radius:12px}.map-radius-control[data-v-6135b0b6]{width:280px;border-left:1px solid #e8e8e8;padding-left:20px;display:flex;flex-direction:column}.radius-control-header[data-v-6135b0b6]{margin-bottom:15px}.radius-control-header h4[data-v-6135b0b6]{margin:0;font-size:16px;font-weight:600;color:#333}.radius-control-body[data-v-6135b0b6]{flex:1;display:flex;flex-direction:column}.location-info[data-v-6135b0b6]{margin:15px 0;padding:12px;background:#f8f9fa;border-radius:6px}.info-item[data-v-6135b0b6]{display:flex;justify-content:space-between;margin-bottom:8px;font-size:14px}.info-item[data-v-6135b0b6]:last-child{margin-bottom:0}.info-label[data-v-6135b0b6]{color:#666}.info-value[data-v-6135b0b6]{color:#333;font-weight:500}.radius-control-tip[data-v-6135b0b6]{background:#f0f8ff;padding:12px;border-radius:6px;border-left:3px solid #007cff;font-size:13px;color:#333;line-height:1.4;margin-top:auto}

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/apps.2030d910.js vendored Normal file
View File

@ -0,0 +1 @@
import{m}from"./vuex.cc7cb26e.js";import{M as e}from"./index.4ff51c76.js";import{n as a}from"./app.1e00fc89.js";import"./vue.adba9046.js";import"./@babel.9410f858.js";import"./view-design-hi.f1128b4d.js";import"./@micro-zoe.39406924.js";import"./DialogWrapper.d635cc01.js";import"./chunkedUpload.b0e8368a.js";import"./axios.37c7f908.js";import"./spark-md5.0443325f.js";import"./index.978d6d4c.js";import"./vue-virtual-scroll-list-hi.74ad83f0.js";import"./lodash.8fcd6fd4.js";import"./ImgUpload.be73943e.js";import"./webhook.378987f3.js";import"./jquery.99163cb9.js";import"./dayjs.0bb0f368.js";import"./localforage.6e50a401.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"./openpgp_hi.15f91b1d.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"./html-to-md.f297036e.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 n=function(){var t=this,o=t.$createElement,r=t._self._c||o;return r("MicroApps",{ref:"app",attrs:{"window-type":"popout"}})},s=[];const u={components:{MicroApps:e},computed:{...m(["userIsAdmin"])},async mounted(){const{name:t}=this.$route.params;if(!t){$A.modalError("\u5E94\u7528\u4E0D\u5B58\u5728");return}if(t==="iframe-test"){if(!this.userIsAdmin){$A.modalError("\u4EC5\u7BA1\u7406\u5458\u53EF\u4F7F\u7528\u6B64\u529F\u80FD");return}let{url:r}=this.$route.query;if(!r){if(r=await this.promptIframeUrl(),!r)return;this.$router.replace({path:this.$route.path,query:{...this.$route.query,url:r}}).catch(()=>{})}await this.$refs.app.onOpen({id:"iframe-test",name:"iframe-test",url:r,type:"iframe",transparent:!0,keep_alive:!1});return}const o=(await $A.IDBArray("cacheMicroApps")).reverse().find(r=>r.name===t);if(!o){$A.modalError("\u5E94\u7528\u4E0D\u5B58\u5728");return}await this.$refs.app.onOpen(o)},methods:{promptIframeUrl(){return new Promise((t,o)=>{$A.modalInput({title:this.$L("\u8BF7\u8F93\u5165 URL"),placeholder:"https://example.com",onOk:r=>{const i=(r||"").trim();if(!i)return this.$L("URL\u4E0D\u80FD\u4E3A\u7A7A");t(i)},onCancel:()=>o()})}).catch(()=>null)}}},p={};var c=a(u,n,s,!1,l,null,null,null);function l(t){for(let o in p)this[o]=p[o]}var hr=function(){return c.exports}();export{hr as default};

View File

@ -1 +0,0 @@
import{m}from"./vuex.cc7cb26e.js";import{M as e}from"./index.30bc8b66.js";import{n as a}from"./app.f8ae8cc3.js";import"./vue.adba9046.js";import"./@babel.9410f858.js";import"./view-design-hi.f1128b4d.js";import"./@micro-zoe.39406924.js";import"./DialogWrapper.1505b441.js";import"./index.775d359c.js";import"./vue-virtual-scroll-list-hi.74ad83f0.js";import"./lodash.8fcd6fd4.js";import"./ImgUpload.fed3b06e.js";import"./webhook.378987f3.js";import"./jquery.24b9d090.js";import"./dayjs.19659d6c.js";import"./localforage.8c2def53.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"./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"./html-to-md.f297036e.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 n=function(){var t=this,o=t.$createElement,r=t._self._c||o;return r("MicroApps",{ref:"app",attrs:{"window-type":"popout"}})},s=[];const u={components:{MicroApps:e},computed:{...m(["userIsAdmin"])},async mounted(){const{name:t}=this.$route.params;if(!t){$A.modalError("\u5E94\u7528\u4E0D\u5B58\u5728");return}if(t==="iframe-test"){if(!this.userIsAdmin){$A.modalError("\u4EC5\u7BA1\u7406\u5458\u53EF\u4F7F\u7528\u6B64\u529F\u80FD");return}let{url:r}=this.$route.query;if(!r){if(r=await this.promptIframeUrl(),!r)return;this.$router.replace({path:this.$route.path,query:{...this.$route.query,url:r}}).catch(()=>{})}await this.$refs.app.onOpen({id:"iframe-test",name:"iframe-test",url:r,type:"iframe",transparent:!0,keep_alive:!1});return}const o=(await $A.IDBArray("cacheMicroApps")).reverse().find(r=>r.name===t);if(!o){$A.modalError("\u5E94\u7528\u4E0D\u5B58\u5728");return}await this.$refs.app.onOpen(o)},methods:{promptIframeUrl(){return new Promise((t,o)=>{$A.modalInput({title:this.$L("\u8BF7\u8F93\u5165 URL"),placeholder:"https://example.com",onOk:r=>{const i=(r||"").trim();if(!i)return this.$L("URL\u4E0D\u80FD\u4E3A\u7A7A");t(i)},onCancel:()=>o()})}).catch(()=>null)}}},p={};var c=a(u,n,s,!1,l,null,null,null);function l(t){for(let o in p)this[o]=p[o]}var lr=function(){return c.exports}();export{lr as default};

Some files were not shown because too many files have changed in this diff Show More