Compare commits

..

180 Commits
v1.6.10 ... pro

Author SHA1 Message Date
kuaifan
20c3fa91fb refactor(https): 协议识别下沉到 nginx,TrustProxies 只信 X-Forwarded-Proto
- nginx 经 APP_SCHEME 环境变量(envsubst 模板)统一控制 X-Forwarded-Proto
- TrustProxies 信任内网代理但仅采信 X-Forwarded-Proto,防 Host 注入
- 移除 WebApi 中间件的硬编码强制 https
- getSchemeAndHost 优先用当前请求 scheme/host,保留非请求上下文兜底
- cmd https 切换后改用 compose up -d 重建 nginx 容器使 envsubst 生效

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:52:38 +00:00
kuaifan
c03867304e release: v1.7.90
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 23:06:30 +00:00
kuaifan
b595120d62 fix(base): readableBytes 补类型声明并修正拼接类型告警
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:06:19 +00:00
kuaifan
8e66f0bfb3 feat(dialog): 管理员可设置全员群名称
- 后端 group__edit 放开全员群改名(仅系统管理员 admin=1)
- formatData/getGroupName 用 ALL_GROUP_DEFAULT_NAME 哨兵区分"未自定义",
  避免回退逻辑被默认种子名短路导致 i18n 丢失
- 前端 canModifyName/编辑入口对全员群管理员放开,改名请求带 admin=1

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 21:57:49 +00:00
kuaifan
e9ea1adc5d chore(electron): drawio submodule 升级 24.7.17 -> 30.0.4
桌面端打包以 resources/drawio submodule 为完整基底,再覆盖 AppStore 下载的
drawio 插件定制文件。将基底从 jgraph/drawio v24.7.17 升到 v30.0.4,与
system-plugins 的 drawio 30.0.4 插件对齐(30.0.4 的 bootstrap.js 仍加载
PreConfig.js,electron/drawio.js 的 EXPORT_URL 注入无需改动)。

注意:桌面端重新打包需在 drawio 插件 30.0.4 发布到 AppStore 之后进行,
否则 build 时下载的「latest」仍是 24.7.17,会与 30.0.4 基底版本错配。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 21:57:37 +00:00
kuaifan
2eee171a50 feat(project): 系统设置新增「创建项目」权限开关
在系统设置「项目相关」新增「创建项目」权限范围(复选):
所有人 / 部门负责人(含部门管理员)/ 指定人员,三者与「所有人」互斥;
系统管理员始终可创建,不受开关限制。未授权用户隐藏「新建项目」入口
(顶部下拉、快捷键、移动端、应用宫格),后端 Project::userCanCreate()
兜底拦截,个人项目(注册自动创建)不受限。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 21:57:24 +00:00
kuaifan
fd6a8a3650 feat(user): 会员卡片支持查看该会员参与的项目和任务
- 新增权限闸门 UserDepartment::userWorksContext(本人/管理员/部门负责人只读,排除机器人与系统账号)
- 新增接口 project/user/projects、project/user/tasks、project/user/counts
- users/extra 返回 works_visible 标记控制入口显隐
- 会员卡片新增「项目与任务」入口,弹出 UserWorksModal(项目/待办/已完成三 Tab、角标计数、工作流状态徽章、懒加载)
- 部门只读视角下任务仅展示全员可见(visibility=1),与 findForDepartmentView 对齐
- 补充 i18n 文案与暗色样式

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 03:24:50 +00:00
kuaifan
84a90b7760 feat(approve): 审批详情支持删除审批
- ApproveController 新增 process__delById 代理,转发至审批插件 process/delById;
  服务端注入 is_admin(仅发起人或管理员可删)
- 审批详情页新增「删除」按钮(仅已结束的审批可见),删除后独立路由返回上一页、
  嵌入模式刷新列表

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:44:37 +00:00
kuaifan
7335c59b68 chore(release): 移除被 dootask-release 技能取代的翻译/版本脚本
- 删 language/translate.php(OpenAI 翻译)+ composer.json/lock + README
- 删 bin/version.js(git-cliff + OpenAI 更新日志)+ cliff.toml
- package.json 去掉 version/translate 两条 script,cmd 去掉 translate 子命令
- README_PUBLISH 指向 dootask-release 技能

翻译/版本号/更新日志改由 dootask-release 技能完成;CI 不受影响(只读 package.json version + CHANGELOG.md)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:54:37 +00:00
kuaifan
035c9d9d3d feat(skill): 新增 dootask-release 发版技能
翻译与更新日志在技能内直接产出,版本号计算/差异检测/语言文件生成等
机械步骤交给本地脚本(language.php、version_bump.js,host 直跑、不进容器)。
language.php 用 php 以字节级对齐项目原生产物;脚本相对自身定位项目根,与 cwd 无关。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:19:23 +00:00
kuaifan
36da18af79 release: v1.7.81 2026-06-03 02:20:11 +00:00
kuaifan
363badbc97 chore(appstore): 升级 appstore 镜像至 0.4.3
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 01:54:46 +00:00
kuaifan
9be6265220 fix(download): 大文件下载改用 BinaryFileResponse 走 sendfile
StreamedResponse 在 LaravelS/Swoole 下被 DynamicResponse 用 ob_start/
ob_get_clean 整体缓冲进 PHP 内存,约 700MB 文件会撞 memory_limit 导致
下载失败;且每次请求对整文件 md5_file 生成 ETag 开销巨大。

改为返回 BinaryFileResponse,由 LaravelS StaticResponse 走 Swoole 原生
sendfile(),OS 级零拷贝、不占 PHP 内存,可支持任意大小文件。去掉 ETag
全文件哈希改用 mtime。Swoole 环境下关闭 Range 分段(sendfile 只能整文件
发送,避免 206 头与整文件 body 错位),非 Swoole 环境保留原生 Range。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:37:52 +00:00
kuaifan
be53e6c6ac feat(skill): 新增 dootask-backup 数据备份技能
- 备份数据库(必须) + public/uploads(排除 tmp,可选) + docker/appstore/config(可选)
- 汇总到 tmp/ 临时目录并附 README 说明,打包到 backup/ 按日期命名
- 只读取源数据、绝不删改,失败即停
- .gitignore 忽略 /backup,避免归档进入版本库

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:04:29 +00:00
kuaifan
4eab130313 feat(electron-mcp): 对齐 dootask-mcp,新增 send_task_ai_message 等
- 新增 send_task_ai_message 工具(dialog/msg/send_ai_assistant),支持
  自定义发送者昵称 nickname(≤20)与 silence
- complete_task 增加 flow_item_id 参数及多结束状态(-4005)重选处理
- update_task 增加 flow_item_id 参数及多结束/开始状态(-4005/-4006)处理
- request() 捕获 ret/data 并对 -4005/-4006 放行交工具处理(向后兼容)
- 同步头部工具清单注释(27→29)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 07:10:36 +00:00
kuaifan
c706c515ee fix(dialog): AI 助手消息推送补全发送者身份
AI 助手为虚拟用户(userid=-1)无会员记录,userid2nickname 返回空串,
导致群聊推送拼成 ": 内容"。友盟 App 推送与前端桌面/移动通知改为取
msg.nickname 或默认"AI 助手"作为发送者名,显示为 "名称: 内容"。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 06:10:02 +00:00
kuaifan
8a576595ce feat(dialog): send_ai_assistant 支持自定义发送者昵称
接口新增可选 nickname 参数(最长20字),写入消息体 msg.nickname;
前端群聊昵称与回复预览对 AI 助手消息(userid=-1)显示自定义昵称,
留空回退默认"AI 助手"。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 06:09:50 +00:00
kuaifan
8c809bbff1 feat(team): 团队管理支持标记成员邮箱认证状态
- 成员行操作菜单新增「标记邮箱为已认证/未认证」,复用 users.email_verity
  字段与 api/users/operation 接口,新增 setverity/clearverity 操作类型
- 创建用户:邮箱下方新增「标记邮箱为已认证」复选框(默认勾选),
  「首次登录需改密」复选框移到初始密码下方
- 批量导入:预览列表邮箱右侧显示主题色已认证图标(错误行不显示),
  支持勾选行后批量标记已/未认证;部门与认证批量行加标签对齐、
  三个批量按钮样式随选中状态统一
- createByAdmin 新增 emailVerity 选项,createuser/import 透传逐行认证状态
- 新增导入预览默认认证状态单测

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 05:49:33 +00:00
kuaifan
08ed396444 feat(skill): 新增安装/更新/修复权限技能,四技能描述精简为命令触发
- dootask-install:前置检查 + sudo ./cmd install(建库 + migrate --seed)
- dootask-update:前置检查 + sudo ./cmd update,本地改动停下交用户决定
- dootask-fix-permission:对齐 install 赋权逻辑,chown + chmod 775,赋权不删数据
- 四个技能均为命令显式触发,description 去掉关键词堆砌,精简为一句功能说明

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 02:14:36 +00:00
kuaifan
f5eb84589f docs(skill): release 改名 dootask-release,补 CI 确认/iOS/uploads 赋权
- 新增 push 后确认 Publish 工作流、可选 iOS 发布(gh workflow run)
- 构建 EACCES:改为 chown 赋权整个 public/uploads(不删数据),删除仅限 tmp
- 同步 Red Flags;目录与 name 统一为 dootask-release

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:19:29 +00:00
kuaifan
daca384822 feat(todo): 待办设置权限放开系统管理员(任意群可设/取消他人待办)
- checkTodoOwnerPermission 最高优先级放行系统管理员,覆盖无群主的全员群等场景
- 同步设/取消待办、到点提醒接口报错文案与系统设置描述
- 补充管理员放行测试(user/project/全员群)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 15:02:20 +00:00
kuaifan
0a6e944a9a Merge pull request #301 from robertsilen/pro
docs: mention MariaDB in README
2026-06-01 22:20:35 +08:00
kuaifan
e0d1b08e89 release: v1.7.67 2026-06-01 13:04:00 +00:00
kuaifan
6b54b7b1c5 feat(todo): 聊天待办支持提醒时间(到点引用原消息+@提及)
给消息待办增加可选「提醒时间」,到点由 todo-alert 机器人对原消息发起
reply、正文 @ 仍在群内的被指派成员,完全复用原生回复/提及链路(定向未读、
红点、绕过会话免打扰、App 推送);被指派人全部退群则跳过发送并标记已提醒。
设/改/取消提醒的权限沿用 todo_set_permission 开关与 checkTodoOwnerPermission。

后端:
- 迁移:web_socket_dialog_msg_todos 增加 remind_at/reminded_at 及索引,
  注册为日期字段
- WebSocketDialogMsgTodo::dueReminders() 选取到点(未提醒/未完成)待办(limit 500)
- WebSocketDialogMsg::setTodoRemind() 纯数据写入(改时间重置 reminded_at),
  接入 toggleTodoMsg($remindAt) 与 msg__todo 透传
- 接口 msg__todoremind 设置/修改/取消提醒(权限闸门、消息类型校验、
  pushMsg 同步 todo_done)
- TodoRemindTask 到点按消息发提醒(reminded_at 防重复、迟发补发、原消息/
  会话删除兜底),buildRemindText 生成 <span class="mention user"> 文本,
  接入 crontab;登记 todo-alert 机器人
- msgJoinGroup 从提醒文本中提取被 @ 成员

前端:
- 设待办弹窗新增「提醒时间」(预设 + 自定义 DatePicker)
- 待办详情浮层每条待办可查看/修改/取消提醒:DatePicker on-clear「清空」
  二次确认后取消,无时间时仅关闭面板不发请求
- 待办浮层窄屏(≤500px)改为 待办/完成 tab 切换,宽屏维持双列;列表为空
  展示空状态占位;提醒时间用 Icon 替换 emoji
- 时间读写对齐项目任务时间的时区约定

测试:tests/Feature/TodoRemindTest(数据/选取/写入/权限决策/buildRemindText/
text mention 提取),TodoSetPermissionTest 无回归。

任务 #124 后续增强。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 12:08:34 +00:00
kuaifan
adc7fb0d07 docs(claude): 补充非 REST 路由最多两段的限制说明
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:52:27 +00:00
kuaifan
f969c8145c fix(cmd): env_set 值未变化时跳过写入,避免无谓重写 .env
env_set 原先对已存在的键无条件 sed -i 重写 .env,即使新值与当前值相同也会
改变文件 mtime。开发模式下 vite 监听 .env,任何文件事件都会触发整服务重启,
导致前端反复刷新。写入前复用 env_get 比较,值未变化则直接返回。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 01:27:42 +00:00
kuaifan
20b5daba50 feat(manage): 团队管理支持管理员创建/批量导入员工账号(含部门、职位)
单个创建:邮箱/昵称/初始密码 + 可选首登改密、职位、部门(多选,选子部门自动补选上级,并加入对应部门群)。

批量导入:上传 Excel/CSV → 预览逐行校验 → 确认后导入。职位为模板第4列(选填,逐行解析校验),部门在预览表按行勾选后由底部设置部门到选中写入;导入按行返回结果(全成功关弹窗+成功提示;含失败留弹窗显示失败明细;仅 success>0 才刷新列表)。

后端:User::createByAdmin 选项数组化 + 校验助手 assertValidProfession/assertValidDepartments;importUsers 逐行 department/profession;UsersController createuser/import;UserImport/UserImportTemplate(含职位列)。

测试:tests/Feature/AdminCreateUserTest、tests/Unit/UserImportParseTest。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 01:26:34 +00:00
kuaifan
aa2e0acaba feat(dialog): 系统设置支持禁止其他人员设置/取消聊天待办
新增系统级开关 todo_set_permission(open=允许默认 / close=禁止)。
开关为禁止时,仅本人、群主/群管理员、项目负责人/项目管理员、任务负责人
可设置或取消聊天消息待办,其他人由后端拦截;默认允许,保持现有行为。

- SystemController::setting 接入开关读写(白名单 + 默认 open)
- WebSocketDialog::checkTodoOwnerPermission 角色判断(复用 isOwner 等)
- WebSocketDialogMsg::toggleTodoMsg 内权限闸门:close 且影响到他人且
  非放行角色时 retError;仅影响自己始终放行;open 时行为零变化
- SystemSetting.vue「消息相关」新增「待办设置权限」开关 UI
- 国际化文案(original-api.txt / original-web.txt)
- TodoSetPermissionTest 覆盖角色判断、闸门决策及真实拦截路径(8 用例)

任务 #124。系统后台 admin 不特殊放行;「完成待办」不在本次范围。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 15:12:46 +00:00
kuaifan
e57736bcc1 docs: 统一语言偏好为整段回复使用简体中文
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 23:40:53 +00:00
kuaifan
a8db8dde7b i18n(task): 任务只读提示文案改为"负责人视角"
将部门只读提示从"当前为负责人,并参与讨论,但不能编辑任务。"
调整为"当前为负责人视角,并参与讨论,但不能编辑任务。",
并同步更新 original-web.txt、translate.json 及各语言编译产物。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 23:32:24 +00:00
Robert Silén
635f6e5d5a Merge branch 'kuaifan:pro' into pro 2026-05-25 15:04:30 +03:00
Robert Silén
4875574c6e add MariaDB to README 2026-05-25 15:04:16 +03:00
kuaifan
b1d5652bc7 refactor(electron): 发布存储从自建服务迁移到 Cloudflare R2
替换 UPLOAD_TOKEN/UPLOAD_URL 为 R2(S3 兼容)对象存储:
- 新增 r2.js 封装上传/复制/删除/列举等操作
- 新增 release-index.js 从文件名解析平台/架构生成下载索引
- CI 环境变量同步切换为 R2_* 系列

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:31:46 +00:00
kuaifan
025f45df0a feat(upload): 添加上传进度显示和错误处理,记录上传耗时 2026-05-22 14:55:34 +08:00
kuaifan
981a5c9f0f ci: resolve iOS build number from App Store Connect 2026-05-22 10:13:25 +08:00
kuaifan
88cfd40abe ci: fix iOS archive signing 2026-05-22 08:32:13 +08:00
kuaifan
cdcf0ff5f3 release: v1.7.55
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:07:02 +00:00
kuaifan
42e355149c chore(i18n): 补全 v1.7.29 后遗漏的原文并去重
补登 v1.7.29 至今新增的用户可见文本:
- original-web.txt 新增 16 条(部门负责人视角、项目管理员相关等)
- original-api.txt 新增 7 条(项目负责人/成员校验、群成员移出等错误消息)
同时清理两文件历史重复内容行(web -18、api -26),
translate.php 读取时本就 array_unique,去重不影响翻译产物。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:49:44 +00:00
kuaifan
518364d70d feat(translate): 支持通过 OPENAI_API_MODEL 环境变量配置翻译模型
翻译脚本不再硬编码 gpt-5.2,改为从 .env 读取 OPENAI_API_MODEL,
便于切换不同模型。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:22:11 +00:00
kuaifan
f25340c0b3 ci: update iOS publish workflow 2026-05-21 23:57:45 +08:00
kuaifan
24f607f442 fix: hide horizontal overflow in user detail 2026-05-21 19:50:38 +08:00
kuaifan
6fbddbe77c fix: run app publish in disposable eeui container 2026-05-21 19:49:50 +08:00
kuaifan
21ba2665b9 chore: 添加 .agents 到 .claude 的软链接 2026-05-21 18:17:25 +08:00
kuaifan
0888f599a4 feat(manage): 管理页侧边栏支持拖拽调整宽度并修复菜单条件渲染
- 新增 ResizeLine 组件实现侧边栏宽度拖拽调整,范围200-420px,持久化至 localStorage
- 修复 v-for/v-else-if 同级指令优先级问题,将 v-else-if 提升至 template 包裹层

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:41:00 +00:00
kuaifan
ef7293704b refactor(manage): 收口部门负责人ID规范化逻辑并简化后端对话可见性校验
- 后端:任务群/项目群统一按项目级共享判断,不再区分任务可见性
- 前端:新增 department/owner/ids/save mutation 及 normalizeIntArray 工具函数
- 前端:departmentOwnerReadonlyUrls 从 action 局部变量提升至 state
- 前端:修复 TaskDetail 提示文本多余空格

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 06:06:39 +00:00
kuaifan
8cd4669b90 refactor(manage): 部门负责人只读视角统一使用禁用态UserSelect组件
用 disabled 属性的 UserSelect 替代独立的 UserAvatar 只读展示,
消除双份渲染逻辑,负责人/协助人员/可见人员统一使用同一组件路径。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:01:15 +00:00
kuaifan
7f7a82b4b8 feat(manage): 部门负责人视角支持项目级可见开关并尊重任务可见性
新增项目级"负责人视角"开关(projects.department_owner_view,默认开启),
项目负责人可关闭,关闭后该项目及其群聊对部门负责人视角隐藏。同时将负责人
只读视角调整为尊重任务可见性:仅"全员可见"任务可被查看/进入任务群,指定
成员可见的任务仅对被指定成员开放。

- 新增 projects.department_owner_view 字段(migration)
- ProjectController::update 支持读写该开关
- UserDepartment::ownerViewContext 过滤已关闭项目,并合并为单次 JOIN 查询
- ProjectTask::findForDepartmentView / task__one / tasks 列表尊重任务可见性
- WebSocketDialog::checkDialog 任务群按可见性放行
- 前端项目设置新增开关(仅系统开启该功能时显示)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:46:11 +00:00
kuaifan
0863e5529a feat(manage): 实现部门负责人视角,支持只读查看部门成员项目与任务
部门负责人/部门管理员可通过系统配置开启,选择管理部门后只读查看
本部门及下级部门成员的全部项目和任务。前端自动根据 department_readonly
标记禁用编辑操作,后端统一注入负责人视角上下文控制数据访问边界。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 00:14:36 +00:00
kuaifan
e0ad8ce6c1 docs: 添加 AGENTS.md 项目指南文件 2026-05-21 00:13:09 +00:00
kuaifan
9f4e5a8335 fix(project): 修正AI自动分析开关状态判断变量名
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:18:28 +00:00
kuaifan
587db459bf feat(dialog): 聊天消息 Markdown 表格单元格强制不换行
DialogMarkdown 根节点新增 .dialog-markdown 类,统一规则放 markdown.less,删除 dialog-wrapper.scss 里仅覆盖 thead th 的旧局部规则。所有走 DialogMarkdown 组件的入口(聊天消息、bot 模板、单条消息分享页、AI 助手)一次性生效;任务描述、报告编辑等直接调用 MarkdownConver 的场景不受影响。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:40:57 +00:00
kuaifan
5b87714acf feat(project): 项目归档设置选择系统默认时显示规则提示
当 archive_method 为 'system' 时显示提示文案,告知用户将按系统设置的自动归档规则执行,避免用户误以为未生效。
2026-05-11 10:03:25 +00:00
kuaifan
bc54ac9462 feat(docs): 更新开发命令说明,明确AI不应主动执行的命令 2026-05-11 03:45:52 +00:00
kuaifan
7e5b31cfb2 feat(template): 添加共享模板功能,支持项目间模板使用控制 2026-05-11 03:26:59 +00:00
kuaifan
d81b4ed273 refactor: 优化API文档注释格式;调整AbstractModel方法注释 2026-05-11 02:50:14 +00:00
kuaifan
0c1a913134 feat(TaskAdd): 优化任务添加界面,调整模板浏览器和加载提示样式
Co-authored-by: Copilot <copilot@github.com>
2026-05-11 02:03:50 +00:00
kuaifan
7dc641e69e feat(template): 添加跨项目任务模板支持,增加使用统计和搜索功能 2026-05-11 01:13:54 +00:00
kuaifan
18336c870e feat(docs): 添加 Playwright 测试结果存放说明 2026-05-09 15:36:31 +00:00
kuaifan
e43588c3b2 fix(multi-owner): close permission lifecycle gaps 2026-05-09 12:31:54 +00:00
kuaifan
64649b514e feat(multi-owner): 群/项目/部门支持主+副双负责人体系 2026-05-09 12:29:38 +00:00
kuaifan
24710289e1 feat(multi-owner): 群/项目/部门支持主+副双负责人体系
- 群组:新增 web_socket_dialog_users.role(1=主、2=副),主可任命/罢免副群主,副可邀请/移出普通成员
- 项目:project_users.owner 扩展为 0/1/2(成员/主/副),主独占转让和删除,副共享日常管理;任务可见性、通知、分配等下游逻辑统一用「主+副」
- 部门:新增 user_department_owners 表存储副负责人;部门群同步副群主,赋予群管理员权限
- 转移用户时副身份不替补、降级为普通成员
- 配套 migration/backfill、API、前端 UI、i18n 词条与三项 Feature 测试
- .gitignore 忽略 .playwright-mcp/
2026-05-03 00:05:31 +00:00
kuaifan
2a3f05e06f docs(ai): 注释模型名思考标记剥离规则
说明 think/thinking/reasoning 后缀的支持写法(空格、- 、_、括号),便于后续维护识别匹配范围。
2026-05-03 00:03:32 +00:00
kuaifan
0d31106b0f release: v1.7.29
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 01:23:51 +00:00
kuaifan
fbd1c829a1 fix(ai): AI助手图片压缩阈值从1024提升到1568,减少长图模糊
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 05:12:16 +00:00
kuaifan
82d2ca6360 feat(ai): AI助手聊天记录服务端持久化
- 图片缓存不再二次压缩,用户预览与AI收到的图片质量一致
- 新增 ai_assistant_sessions 表及 AiAssistantSession 模型
- 新增会话 API:session/list、session/save、session/delete
- 前端会话存储从 IndexedDB 切换为服务端 API,图片落盘到 uploads/assistant/{YYYYMM}/{user_id}/
- saveSessionStore 添加防抖,删除会话时同步清理内存缓存

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 04:22:35 +00:00
kuaifan
717e520556 fix(ldap): 修复 AD 环境下用户搜索失败和密码策略冲突
- objectClasses 移除 inetOrgPerson 和 organizationalPerson,仅保留 person + top
  AD 用户的 objectClass 是 user 而非 inetOrgPerson,导致 LdapRecord 搜索过滤不到用户
- LDAP 用户首次创建本地账号时使用随机密码,避免 LDAP 密码不满足本地密码策略

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 02:05:14 +00:00
kuaifan
c8ddb511cf feat(ci): 添加 Gitee 同步工作流 2026-04-16 22:26:06 +00:00
kuaifan
caf728de8d feat(ci): 添加 iOS 手动构建并提交 App Store 工作流
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 22:13:22 +00:00
kuaifan
a7cd4d7fa8 release: v1.7.23
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:02:06 +00:00
kuaifan
ddc0046e24 chore(claude): 将 /release 命令转换为 skill
- 新增 .claude/skills/release/SKILL.md:CSO 描述、前置检查、三步发布流程
- 删除 .claude/commands/release.md
- 补充基线测试暴露的反模式约束(禁止自动修复脏工作区、禁止 git tag、禁止 git add -A)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:13:29 +00:00
kuaifan
1059630b9d feat(ldap): 支持非邮箱用户名登录,完善 AD 兼容性
- 登录页放宽校验:登录模式允许任意账号格式,注册模式仍强制邮箱
- 登录属性新增 userPrincipalName 选项(AD 常用且通常是邮箱格式)
- LDAP 用户缺少邮箱属性时返回明确错误提示,替代误导性的"请输入正确的邮箱地址"
- LDAP 登录合并已有本地账号时记录 info 日志,便于审计

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 11:48:40 +00:00
kuaifan
e1c1fc030f release: v1.7.20
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 04:25:07 +00:00
kuaifan
09edb14d56 fix(ldap): 使用 LDAP Bind 认证替代 userPassword 查询,兼容 Active Directory
- 认证方式从 userPassword 属性过滤改为标准 LDAP Bind,兼容所有 LDAP 服务器
- 新增可配置的登录属性(cn/uid/mail/sAMAccountName),AD 用户选 sAMAccountName 即可
- 移除 posixAccount objectClass,兼容 AD 目录结构
- 同步创建用户时移除 POSIX 专属属性,添加 mail 属性
- 用户查找改用 findByEmail 按 mail/cn/uid/userPrincipalName 依次匹配
- initConfig 从静态变量缓存改为 RequestContext 请求级缓存,修复 Swoole 下配置变更不生效的问题
- 默认登录属性为 cn,与旧版本行为一致,确保向后兼容

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:18:36 +00:00
kuaifan
f27cef2d66 fix(build): exit with code 1 on upload/release failure
WebsitePublisher methods now throw on failure instead of silently
continuing. CLI entry points catch errors and exit(1) so GitHub
Actions correctly marks the job as failed.
2026-04-06 05:28:56 +00:00
kuaifan
07a2e6df29 fix(build): put version field before file in upload form data 2026-04-06 03:38:17 +00:00
kuaifan
f521f0df65 fix(build): print detailed error info on upload failure
Show error code/message in retry warnings and failure logs
to help diagnose upload issues in CI.
2026-04-06 03:16:32 +00:00
kuaifan
a67fcd6f02 feat: connect publish pipeline to dootask-website API
- Refactor build.js: replace androidUpload/genericPublish/published with
  unified WebsitePublisher class using Authorization Bearer auth
- New CLI commands: upload-changelog, release
- Update auto-update URL to /api/download/update (legacy compat on website)
- publish.yml: use CHANGELOG.md for GitHub Release body, replace
  PUBLISH_KEY with UPLOAD_TOKEN + UPLOAD_URL
2026-04-05 23:04:34 +00:00
kuaifan
d17f404853 release: v1.7.14
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:43:58 +00:00
kuaifan
8def4addc4 fix(chat): 修复 AI 助手(userid=-1)在多处显示异常的问题
在 UserAvatar 组件中统一处理 AI 助手虚拟用户,避免各组件重复判断;
同时修复 @提及、回复引用、转发消息等场景下的 undefined 和空白显示问题,
并过滤批量用户请求中的无效 userid。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:34:27 +00:00
kuaifan
0ecaf9740f feat(i18n): 添加用户编辑和生日相关翻译原文
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:59:54 +00:00
kuaifan
bc75680ee9 feat: 添加 /release 发布流程 skill
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:51:20 +00:00
kuaifan
6a71964592 feat(dialog): 重构合并转发功能
- 合并转发消息体改为存储 msg_ids + preview,不再存储完整消息列表
- 新增 mergedetail API 按需加载合并转发详情
- 详情展示从 Modal 改为 DrawerOverlay,支持完整消息渲染
- 统一不可转发消息类型过滤(tag/top/todo/notice/word-chain/vote/template)
- 合并转发标题改为前端国际化拼接
- DialogWrapper 支持 staticMsgs 静态模式用于详情渲染
- 优化多选操作栏和转发确认界面样式
2026-04-05 09:31:41 +00:00
kuaifan
00a2ea3d2f docs: 精简 CLAUDE.md 国际化规范
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:08:47 +00:00
kuaifan
95e97333b4 feat(translate): 支持自定义 OPENAI_BASE_URL 配置
在翻译脚本和版本发布脚本中增加 OPENAI_BASE_URL 环境变量支持,
允许用户配置自定义的 OpenAI API 地址。自动处理 /v1 路径重复问题。
2026-04-05 09:02:04 +00:00
kuaifan
9e65500748 refactor(ai): 简化AI模块逻辑 2026-04-04 23:18:21 +00:00
kuaifan
a2acd6f6e4 feat(install): 安装时检测 APP_ID 是否与其他实例冲突
防止复制项目目录到另一个位置安装时,因 APP_ID 相同导致容器名和网络冲突。
通过 docker inspect 对比容器挂载路径与当前工作目录判断。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 08:33:48 +00:00
kuaifan
ee96730268 feat(install): 安装和修改端口时检测端口是否被占用
通过 Docker 试绑定端口的方式检测占用,避免安装流程走到最后才因端口冲突失败。
仅在首次安装或端口变更时检测,重装且端口不变时跳过。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 08:23:17 +00:00
kuaifan
f925f238dd chore(appstore): 升级 appstore 镜像版本至 0.4.0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 08:23:10 +00:00
kuaifan
39c6ca3e8c feat(env): 在设置环境变量时确保.env文件存在 2026-04-04 01:38:39 +00:00
kuaifan
c798faa8db feat(migration): 添加表存在检查以避免重复创建表 2026-04-04 00:58:45 +00:00
kuaifan
ed2f843815 feat(middleware): 优化 WebApi 中的 HTTPS 强制设置逻辑 2026-04-04 07:48:04 +08:00
kuaifan
984b98e4fc feat(task): 实现消息合并转发功能,支持批量选择和转发消息 2026-04-04 07:43:26 +08:00
kuaifan
4b32472d64 feat(task): 增加AI自动分析开关(系统级+项目级)
系统设置新增 task_ai_auto_analyze 开关控制全局AI任务分析;项目设置新增 ai_auto_analyze 开关,系统关闭时项目无法开启。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:51:38 +08:00
kuaifan
fc171bc71f chore: 更新子项目提交哈希 2026-04-02 14:34:06 +08:00
kuaifan
cc80fa83e0 chore: 删除 Graphiti 长期记忆集成文档 2026-03-31 16:23:52 +08:00
kuaifan
782ba4a151 docs: optimize CLAUDE.md — remove discoverable content, add critical gotchas
Remove self-description header, 38-line command listing, directory trees,
and standard Laravel patterns that Claude can infer from code.

Add 6 project-specific gotchas Claude would get wrong: non-REST routing
(InvokeController), custom response envelope (Base::retSuccess/retError),
AbstractModel::createInstance, Doo::userId auth, manual validation (no
FormRequest), and Swoole Task (not Laravel Queue).

122 lines → 46 lines.
2026-03-13 10:49:03 +00:00
kuaifan
04708cedb6 feat(task): 增加解除任务关联功能
支持用户在任务详情中解除误关联的任务,权限与修改任务一致(项目负责人、任务负责人、任务协助人)。

- 新增 ProjectTaskRelation::deleteRelation() 删除双向关联并推送 WebSocket
- 新增 API POST /api/project/task/related/delete 接口
- 前端关联任务列表 hover 显示删除按钮,点击确认后解除关联

Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-09 06:38:13 +00:00
kuaifan
4068966700 feat(auth): token/expire 接口支持 refresh 参数刷新 token
- token/expire 接口新增可选参数 refresh=1,当 token 剩余有效期不足总有效期
  的 1/3 时返回新 token
- 将 users/info 移动端的硬编码 7 天刷新阈值统一改为总有效期的 1/3

Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-04 14:49:41 +00:00
kuaifan
3ce8cf381a chore: 更新子项目提交哈希 2026-03-04 11:40:00 +00:00
kuaifan
f78d3f3aff feat(dialog): add send_ai_assistant endpoint for AI assistant identity messaging
New endpoint POST api/dialog/msg/send_ai_assistant sends messages
as the AI assistant identity (userid=-1). Supports both dialog_id
(direct) and task_id (with auto-creation) parameters.

Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-22 09:05:56 +00:00
kuaifan
c60dff0950 feat(api): add with_extend param to task/lists endpoint
Supports optional `with_extend` query parameter (comma-separated).
When `project_name` or `column_name` is included, the API returns
these fields inline with each task via eager loading.

Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-22 02:44:05 +00:00
kuaifan
f2d49ee104 feat(task): 支持根据项目所有者筛选任务 2026-02-22 01:45:14 +00:00
kuaifan
a248d81230 build 2026-01-26 08:13:38 +08:00
kuaifan
1ac6bad2bb fix(task): 修复工作流切换时完成状态处理逻辑
- 恢复工作流切换时通过 $data['complete_at'] 设置完成状态,确保走统一处理入口
- 修复工作流切换时主任务完成状态校验被跳过的问题
- 修复工作流切换时 $updateMarking['is_update_project'] 未设置的问题
- checkAndAutoSetFlowItem 仅在用户单独提交 complete_at 时调用

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-22 07:34:09 +00:00
kuaifan
37de721df9 feat(task): 前端支持多工作流状态选择
- 处理 -4005/-4006 错误码,弹出工作流状态选择菜单
- 新增 showFlowItemSelector 方法展示可选状态列表
- 选择状态后自动更新任务的 flow_item_id 和 complete_at

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 05:32:27 +00:00
kuaifan
773eead827 feat(ai): AI 任务建议支持多语言输出
- 新增 getUserLanguageInfo 方法获取用户语言偏好
- 新增 getLocalizedTitles 方法,支持 9 种语言的标题和提示文案
- 调整 AI Prompt,根据用户语言输出对应语言的建议内容
- 相似任务检测阈值从 0.7 调整为 0.5
- 完善方法注释文档

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 05:32:20 +00:00
kuaifan
c4dd04ccb6 fix(task): 修复任务完成/取消完成时工作流状态自动切换逻辑
- 重构 flow_item_id 变更时的完成状态处理,使用 completeTask 方法替代直接赋值
- 新增 checkAndAutoSetFlowItem 方法,支持自动设置唯一的开始/结束状态
- 存在多个开始/结束状态时抛出带状态列表的错误(-4005/-4006),由前端引导用户选择
- 修复 complete_at 与 flow_item_id 同时存在时的重复处理问题

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 05:30:32 +00:00
kuaifan
2cdde37069 fix(observer): 修复 UserObserver 调用 private 方法 authInfo() 的错误
将 User::authInfo() 改为 User::userid(),因为 authInfo() 是 private 方法,
Observer 无法访问。userid() 是 public 方法,内部会正确调用 authInfo()。

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-21 16:58:11 +00:00
kuaifan
f68f759418 fix(ai): 更新提示信息为本地化文本 2026-01-21 15:56:03 +00:00
kuaifan
801d0b24ab perf(ai): 缩短 AI 任务分析延迟时间至 10 秒
将 AiTaskLoopTask 的 DELAY_SECONDS 从 60 秒减少到 10 秒,
使新建任务更快获得 AI 建议。

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-21 15:30:07 +00:00
kuaifan
29be29b9cf feat(ai): 优化 AI 提示词并完善建议交互功能
- 优化后端提示词:描述生成、子任务拆分、负责人推荐,新增栏目信息,去掉无效的 similar_count
- 优化前端提示词:去掉硬性字数限制,即时消息改为简短输出
- 新增 :::ai-action{...}::: 语法处理,支持单独采纳/忽略 assignee 和 similar
- 采纳/忽略后更新消息状态显示
- 负责人改为追加模式,保留现有负责人
- 新增任务关联功能,similar 采纳时自动创建双向关联
- 相似度阈值从 0.7 调整为 0.5,搜索结果增加到 200

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-21 15:30:07 +00:00
kuaifan
c253044f61 fix(ai): 更新 AI 助手头像显示逻辑和样式 2026-01-21 15:30:07 +00:00
kuaifan
9acf7d2046 fix(ai): 调整 AI 建议执行条件
1. subtasks: 标题长度阈值从 10 改为 5
2. similar: 启用向量搜索查找相似任务

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-21 15:30:07 +00:00
kuaifan
3911af7b51 fix(ai): 修复描述格式和负责人重复问题
1. 描述建议:AI 返回 Markdown,前端用 MarkdownConver 转 HTML
2. 负责人推荐:排除已分配的任务成员
3. 解析负责人推荐时去重,防止 AI 返回重复用户

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-21 15:30:07 +00:00
kuaifan
6b722b7ed7 fix(ai): 修正 AiTaskLoopTask 中 Apps 类的命名空间
App\Models\Apps -> App\Module\Apps

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-21 15:30:07 +00:00
kuaifan
6a00b87f72 fix(ai): 修正 API 路由地址格式
将 ai-apply/ai-dismiss 改为 ai_apply/ai_dismiss,
匹配 Laravel 路由方法命名转换规则(task__ai_apply -> ai_apply)

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-21 15:30:07 +00:00
kuaifan
0a97039d75 refactor(ai): 重构 AI 建议功能并完善向量搜索
1. 重构 task__ai_apply 接口:移除业务逻辑,仅负责状态更新和日志记录,
   返回建议数据由前端调用现有接口处理(taskUpdate/taskAddSub)

2. 实现 searchSimilarByEmbedding 向量搜索:
   - 使用 ManticoreBase::taskVectorSearch 进行向量搜索
   - 按 project_id 过滤同项目任务
   - 排除当前任务及其子任务
   - 设置 0.7 相似度阈值,最多返回 5 个结果

3. 更新 AI 助手头像:将文字 "AI" 替换为 SVG 图标

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-21 15:30:06 +00:00
kuaifan
cb56a01622 fix(ai): fix URL parsing for ai-apply/ai-dismiss links
The regex pattern (\w+) didn't match 'ai-apply' or 'ai-dismiss' because
\w doesn't include hyphens, causing all AI suggestion buttons to fail.

Fix by handling AI links before the regex match using startsWith().
Remove dead switch cases that were never reached.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-21 15:30:06 +00:00
kuaifan
452af4bd2f fix(ai): address issues from second code review
- Add STATUS_APPLIED and STATUS_DISMISSED constants to model
- Add markApplied() and markDismissed() methods
- Update event status after apply/dismiss actions (prevent duplicate ops)
- Validate related_task_id exists and user has permission
- Filter empty or overly long subtask names before creation

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-21 15:30:06 +00:00
kuaifan
75073d4320 fix(ai): address security and robustness issues from code review
Security fixes:
- Add escapeUserInput() to prevent Prompt injection via user input
- Validate msgId belongs to dialogId in updateMessageStatus()
- Add type parameter whitelist validation in ai-apply/ai-dismiss
- Add event record validation in task__ai_dismiss

Robustness fixes:
- Use atomic update for markProcessing to prevent concurrent processing
- Add subtask count limit check before creation (max 50)
- Disable similar task feature until vector search is implemented
- Fix Promise anti-pattern in frontend actions

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-21 15:30:06 +00:00
kuaifan
d4d7a0d69f feat(ai): add AI::invoke() method for task suggestions
- Add generic invoke() static method to AI module for custom chat completion
- Fix AiTaskSuggestion::callAi() to properly handle AI::invoke() response
- Fix findSimilarTasks() to properly handle AI::getEmbedding() response

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:30:06 +00:00
kuaifan
165ad03024 feat(ai): add ai-apply/ai-dismiss protocol handlers 2026-01-21 15:30:06 +00:00
kuaifan
3603cf9889 feat(ai): display AI assistant avatar for userid=-1
When a message has userid=-1 (AI assistant), display a special AI avatar
with gradient styling instead of the regular UserAvatar component.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:30:06 +00:00
kuaifan
027662ebab feat(ai): add ai-apply and ai-dismiss API endpoints 2026-01-21 15:30:06 +00:00
kuaifan
106465b932 feat(ai): add AiTaskLoopTask timer and register to crontab 2026-01-21 15:30:06 +00:00
kuaifan
eef4c6fbe5 feat(ai): add AiTaskAnalyzeTask async task 2026-01-21 15:30:06 +00:00
kuaifan
916ae97ca7 feat(ai): add AiTaskSuggestion module with prompt templates
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:30:06 +00:00
kuaifan
841405505d feat(ai): add ProjectTaskAiEvent model 2026-01-21 15:30:06 +00:00
kuaifan
22a653bb0f feat(ai): add project_task_ai_events migration 2026-01-21 15:30:06 +00:00
kuaifan
3482e4b1a8 fix(file): 修复日期格式文件名被误转换导致创建失败的问题
newDateString 函数在处理请求参数时会将所有符合日期格式的字符串
(如 "2026-01-15")转换为完整日期时间格式("2026-01-15 00:00:00"),
导致文件名中出现冒号,触发后端文件名校验错误。

修复方案:
- 直接调用时(key=null),保持原有行为用于显示格式化
- 递归处理对象属性时,仅对白名单字段(times、*_at)进行转换
- 其他字段(如 name)保持原值不转换

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 22:33:45 +00:00
kuaifan
9097369b0c fix(ai-assistant): 修复图片预览调用不存在方法的错误
将 $A.previewFile 替换为 this.$store.dispatch("previewImage"),
解决 TypeError: $A.previewFile is not a function 错误。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 14:58:37 +00:00
kuaifan
95c6b53f10 fix(ai-assistant): 优化图片压缩逻辑避免重复质量压缩
- 新增 forceCompress 参数控制是否强制质量压缩
- compressImageForAI: 始终进行质量压缩(发送给 AI)
- saveImageToCache: 仅在需要缩小尺寸时才压缩(避免已压缩图片被重复压缩)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 14:52:57 +00:00
kuaifan
f7d5040b02 feat(ai-assistant): 支持拖放和粘贴上传图片
- 新增拖放上传:可将图片拖放到对话窗口任意位置
- 新增粘贴上传:在输入框中可直接粘贴剪贴板图片
- 提取 handleImageFiles 通用方法供多种上传方式复用
- 添加拖放时的视觉反馈(虚线边框 + 提示遮罩)
- 使用计数器方式正确处理嵌套元素和拖出窗口的情况

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 14:45:38 +00:00
kuaifan
26b7f83d35 no message 2026-01-20 14:45:02 +00:00
kuaifan
07b99c6e75 fix(ai-assistant): 修复 SSE 连接失败时状态未正确更新的问题
当 SSE 连接一开始就失败时,响应状态保持 'waiting' 而非 'streaming',
导致 onFailed 回调不会更新状态,UI 一直显示 loading。

现在同时处理 'streaming' 和 'waiting' 状态,并标记为错误状态显示失败提示。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 14:20:50 +00:00
kuaifan
cb5e7e2cc7 refactor(ai): 优化 AI 提示词构建逻辑
- withLanguagePreferencePrompt: 修复无语言标签时占位符未添加的问题
- handleBeforeSend: 简化操作会话提示词,移除冗余的工具名称说明

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 13:47:25 +00:00
kuaifan
2180998e81 feat(ai-assistant): 添加图片发送功能支持多模态对话
- 支持上传图片并压缩(当前消息 1024px,历史 512px)
- 图片独立缓存存储,使用占位符 [IMG:xxx] 替代 base64
- 新增 prompt-image.vue 组件展示历史图片缩略图
- 后端 AI.php 支持多模态消息格式处理
- 添加图片缓存清理机制(删除会话时同步清理)
- 优化 parsePromptContent 避免重复调用
- 会话标题自动过滤图片占位符

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 09:31:34 +00:00
kuaifan
478876ddc1 feat(workflow): 在工作流配置中添加规则摘要展示
在工作流展开后的配置表格上方添加规则摘要区块,根据实际配置动态展示:
- 状态负责人规则:区分添加模式、流转模式、剔除模式的不同描述
- 限制负责人规则:显示仅限任务负责人和项目管理员修改状态
- 关联列表规则:显示流转时自动移动至指定列表

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 01:55:00 +00:00
kuaifan
ae021fd148 fix(push): 修复友盟延迟推送已读检查失效的问题
消息ID取值路径错误,导致延迟推送时无法正确判断消息已读状态,
用户在PC端阅读消息后APP仍会收到重复推送。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 00:44:45 +00:00
kuaifan
f36317b081 fix(operation-client): 修正 WebSocket 路径格式并优化连接 URL 生成 2026-01-19 10:52:22 +08:00
kuaifan
066a5a619c build 2026-01-19 09:43:37 +08:00
kuaifan
654793156d feat(micro-app): 添加额外的事件发射器方法以支持动态事件处理 2026-01-19 09:19:27 +08:00
kuaifan
ba65378c6b fix(package): 更新 view-design-hi 依赖版本至 4.7.0-80 2026-01-19 01:14:05 +00:00
kuaifan
cb6c50b071 fix(ai-assistant): 修复弹窗和下拉菜单被其他弹窗遮挡的问题
- 使用 window.modalTransferIndex + 1000 作为动态 z-index
  - 添加定时刷新机制:弹窗可见时 5 秒刷新,不可见时 20 秒刷新
  - modal.vue 通过 zIndex prop 接收并应用 z-index
  - float-button.vue 通过 $parent.topZIndex 获取 z-index
  - Dropdown 和 Select 使用 ViewUI 新增的 z-index prop
2026-01-19 01:13:18 +00:00
kuaifan
2cb67fafe7 feat(ai-assistant): 支持任务弹窗和对话弹窗的场景检测
- 在 page-context.js 的 getPageContext 和 getSceneKey 函数中优先检测弹窗状态
  - 当 taskId > 0 时使用 single-task 上下文
  - 当 dialogModalShow && dialogId > 0 时使用 single-dialog 上下文
  - 在 welcome-prompts.js 中添加弹窗场景检测逻辑
  - 提取 formatPrompts 辅助函数减少代码重复
  - 在 index.vue 的 welcomePromptsKey 中监听 taskId 和 dialogModalShow 变化
2026-01-18 23:52:26 +00:00
kuaifan
8eaba6f364 fix(ai-assistant): 优化流式响应期间的 loading 状态显示
- 修改 loading 显示条件,streaming 状态时继续显示 loading icon
  - SSEClient 添加可选的 onFailed 回调,处理连接失败情况
  - 修复 done 事件处理,确保状态正确转为 completed
  - 解决工具调用期间 loading 动画过早消失的问题
2026-01-18 14:44:03 +00:00
kuaifan
c4f0fb5a3d feat(ai-assistant): 合并连续工具使用的显示
在 Markdown 渲染前预处理文本,将连续的 tool-use 标签合并为一行显示:
  - 连续相同工具显示计数(如 get_page_context x 2)
  - 不同工具用逗号分隔
  - 工具间的空行不会打断合并
2026-01-18 13:36:53 +00:00
kuaifan
59ad79fa58 feat(ai-assistant): 支持上下键切换历史输入
- 按 ↑ 键切换到上一条历史输入(光标在第一行时生效)
  - 按 ↓ 键切换到下一条历史输入(光标在最后一行时生效)
  - 历史记录使用 IndexedDB 持久化存储,最多保存 50 条
  - 重复输入会移动到末尾而非重复添加
  - 弹窗关闭时自动重置导航状态
2026-01-18 13:20:13 +00:00
kuaifan
c65f0276bd feat(ai-assistant): 支持编辑历史问题并重新发送
- 鼠标悬停历史问题时显示编辑图标
  - 点击编辑后在原位置显示内联编辑器
  - 支持 Enter 发送、Shift+Enter 换行、Esc 取消
  - 发送后删除该问题及之后的对话历史,重新发送编辑后的问题
  - 正确处理中文输入法组合状态,避免误触发提交
2026-01-18 12:56:16 +00:00
kuaifan
f8b335a003 feat(ai-assistant): 增加元素向量匹配与关键词搜索能力
- 新增后端 match_elements API,使用向量相似度匹配页面元素
  - 页面上下文采集支持关键词过滤,按 name/aria-label/placeholder/title 匹配
  - 关键词匹配失败时自动降级为向量搜索
  - 改进 findElementByRef 函数,使用 selector + name 双重匹配提高准确性
2026-01-18 11:50:27 +00:00
kuaifan
0ac4b546ba feat(ai-assistant): 实现 AI 前端操作能力
新增三个 MCP 工具的前端支持:
  - get_page_context: 基于 ARIA 角色收集页面元素,支持分页和区域筛选
  - execute_action: 执行导航操作(打开任务/对话、切换项目/页面)
  - execute_element_action: 元素级操作(click/type/select/focus/scroll/hover)

  新增文件:
  - operation-client.js: WebSocket 客户端,处理与 MCP Server 的通信
  - page-context-collector.js: 页面上下文收集器,ref 系统和 cursor:pointer 扫描
  - action-executor.js: 操作执行器,支持智能解析如 open_task_123
  - operation-module.js: 模块编排,整合上述模块

  修改文件:
  - float-button.vue: 集成 operation-module,AI 助手打开时启用
  - index.vue: 发射关闭事件供 float-button 监听
2026-01-18 01:35:13 +00:00
kuaifan
07a41ca0ac feat(ai-assistant): 扩充提示词库并优化随机选择策略
- 为提示词增加 type(query/action/sync/review)和 pin 属性
  - 新增 selectPrompts 函数:优先展示 pin 提示,按类型多样化抽样
  - 各场景提示词数量扩充 2-3 倍,覆盖更多常见操作
  - 部分场景使用动态数据(如 taskName、userName、groupName)个性化提示
2026-01-17 02:24:42 +00:00
kuaifan
347465fc4d feat(ai-assistant): 按场景隔离会话存储
- 将 sessionStore 从对象改为数组,每个场景独立存储
  - sessionCacheKey 改为 sessionCacheKeyPrefix,拼接场景 key 动态生成
  - initSession 改为异步方法,切换场景时按需加载对应数据
  - 使用防抖更新 displayWelcomePrompts,避免场景切换时闪屏
  - 修复输入框文字颜色样式
2026-01-17 02:24:31 +00:00
kuaifan
acb9cd317c feat(ai-assistant): 增加 SVG 图标和随机选择提示功能 2026-01-16 14:42:04 +00:00
kuaifan
b7213f8c47 feat(ai-assistant): 添加全屏切换功能
- 添加全屏按钮,支持点击或双击标题栏切换全屏
  - 全屏时禁用拖动和调整大小
  - 全屏状态下占满视口(保留 12px 边距)
  - 关闭窗口时自动退出全屏状态
2026-01-16 10:26:57 +00:00
kuaifan
a3caf5ebdf feat(ai-assistant): 支持拖动边缘调整聊天窗口大小
- 添加 8 个方向的调整大小控制点(四边 + 四角)
  - 支持从任意边缘或角落拖动调整窗口尺寸
  - 尺寸自动保存到 IndexedDB,下次打开时恢复
  - 窗口大小限制:最小 380×400,最大 800×900
  - 视口尺寸变化时自动调整窗口大小和位置
2026-01-16 10:24:41 +00:00
kuaifan
87dd07ef23 feat(ai-assistant): 基于场景标识管理会话恢复
- 新增 getSceneKey 函数,根据路由和实体生成唯一场景标识
  - 会话初始化改为按 sceneKey 匹配历史记录,相同场景恢复会话
  - 统一全局 AI 助手打开方式,manage.vue 通过事件触发 float-button
  - resumeSession 超时时间统一为 86400 秒(1天)
2026-01-16 08:49:25 +00:00
kuaifan
0cefb7eaff feat(task): 兼容 start_at/end_at 参数,统一转换为 times
- 新增 ProjectTask::normalizeTimes() 方法统一处理时间参数
  - 支持只传 end_at 时自动补充 start_at
  - 支持只传 start_at 时保留已有 end_at
2026-01-16 08:37:32 +00:00
kuaifan
ff87de9f44 feat(manage): 优化快捷键事件处理 2026-01-16 08:28:39 +00:00
kuaifan
22de7de87c feat(manage): 优化新建菜单并添加 AI 助手快捷键
- 主按钮从「新建项目」改为「新建任务」
  - 下拉菜单首位添加「AI 助手」选项(需安装 AI 插件)
  - 添加 Ctrl/Cmd+I 快捷键打开 AI 助手
  - 键盘设置页面同步显示 AI 助手快捷键
2026-01-16 07:46:50 +00:00
kuaifan
53dd9dca0f feat(ai-assistant): 浮动按钮支持拖拽到边缘自动收起
- 拖拽按钮到屏幕边缘(≤12px)松开后自动收起为窄条
  - 鼠标悬停窄条时自动展开,离开 1 秒后收起
  - 点击收起状态的窄条直接打开 AI 助手
  - 收起/展开过渡动画平滑,按钮中心位置保持不变
  - 仅在 AI 插件安装后显示浮动按钮
2026-01-16 07:46:41 +00:00
kuaifan
12d6bbea19 feat(mcp): 增强文件工具支持文本内容读取
- get_file_detail: 添加 with_content 参数提取文本
  - 新增 fetch_file_content 工具通过路径获取内容
2026-01-16 01:41:36 +00:00
kuaifan
23b06327d6 feat(file): 添加文件内容提取 API 支持分页读取
- FileController: 新增 fetch API 通过路径获取文本内容
  - FileController: one API 支持 with_text 参数提取文本
  - ManticoreFile: 实现分页提取 extractFileContentPaginated
  - TextExtractor: 添加 truncate 参数支持内容截取
2026-01-16 01:41:28 +00:00
kuaifan
6c22e373f7 build 2026-01-16 03:11:32 +08:00
kuaifan
4ebbb387ee no message 2026-01-16 03:08:25 +08:00
kuaifan
9234fe3ed1 feat(ai-assistant): 添加欢迎界面快捷提示功能和交互优化
主要变更:
  - 新增场景化快捷提示,根据页面类型显示相关操作建议
  - 重新设计欢迎界面 UI,支持图标和可点击的提示卡片
  - 修复浮动按钮点击判断逻辑(移动距离<5px 且 按下时间<200ms)
  - 优化加载状态显示,移除冗余文案
  - 支持 base64 编码格式的文件链接
2026-01-16 02:31:13 +08:00
kuaifan
70be6619e9 refactor(chat-input): 简化任务搜索逻辑
移除项目 ID 筛选条件,统一使用 scope: 'all_project' 搜索所有项目的任务。
2026-01-16 01:23:59 +08:00
kuaifan
c8c27e808f fix(chat-input): 修复 @ 提及下拉框层级问题
设置 mention 下拉容器的 zIndex 为 modalTransferIndex + 1000,
  确保在弹窗等高层级元素中正常显示。
2026-01-16 01:14:19 +08:00
kuaifan
9cb8c92492 fix(electron): 修复客户端 loadHash 域名判断逻辑
修复当 mainDomain 为 "public" 时无法正确判断域名的问题,
  改为从缓存的 cacheServerUrl 获取实际域名进行比较。
  同时修正跳转时错误使用 url 变量的问题,改为正确的 loadHash。
2026-01-16 01:08:03 +08:00
kuaifan
f4f9ee1d3d fix(ai-assistant): 修复深色模式反转样式和交互优化
- 将 no-dark-content 类从容器移动到 SVG 元素,修复深色模式样式问题
  - 添加深色模式反转时的悬浮按钮和聊天窗口样式适配
  - 支持 Escape 键关闭聊天模式窗口
  - 移除多余空白行
2026-01-16 01:07:54 +08:00
kuaifan
138336711f no message 2026-01-16 00:20:52 +08:00
kuaifan
2163bb0bff fix(electron): 修复客户端下载功能无法启动的问题
- 将 onRenderer 参数从 mainWindow 改为 getMainWindow 函数,解决模块加载时 mainWindow 为 null 导致下载无法触发的问题
  - 处理 InterruptedError 错误,避免下载中断时抛出未处理异常
2026-01-16 00:20:52 +08:00
kuaifan
bc460f0da8 fix(ai-assistant): 修复 SSE 流式响应 done 事件错误处理
- 解析 done 事件的 payload 检查是否携带错误信息
  - 移除错误提示中对 response.error 的直接展示
2026-01-15 16:18:53 +00:00
kuaifan
ad66811f49 refactor(ai-assistant): 重构页面上下文配置,支持更多页面类型
- 简化上下文提示词,移除能力范围描述
  - 新增多个独立页面上下文支持:单任务、单对话、单文件、工作汇报等
  - 传递路由参数给上下文函数,以获取实体 ID
  - 移除不必要的 title 属性
2026-01-15 16:18:42 +00:00
kuaifan
70ad8c394a feat(ai-assistant): 添加聊天窗口模式和页面上下文感知
- 新增 chat 显示模式,支持可拖拽的悬浮聊天窗口
  - 新增 page-context.js,根据当前路由提供针对性系统提示词
  - 优化浮动按钮:添加淡入淡出动画、修复右键菜单拖动问题、更新配色
  - 重构 Modal 为独立组件,支持 modal/chat 双模式切换
  - 恢复会话时自动滚动到底部
2026-01-15 15:06:38 +00:00
kuaifan
32ffecb905 feat(ai-assistant): 为各场景添加自定义标题并优化浮动按钮显示
- 为项目创建、任务创建、消息编写、汇报编辑、汇报分析场景的 AI 助手添加专属标题
  - 在模态框显示时自动隐藏浮动按钮,避免 UI 重叠
2026-01-15 10:48:56 +00:00
kuaifan
b794ba7a6b refactor(ui): 优化客户端下载入口位置
- 将仪表盘页面的客户端下载链接移至右上角用户菜单
  - 登录页保留右下角客户端下载链接
  - 新增 clientDownloadUrl 全局状态,统一管理下载地址
  - AI 浮动按钮在登录页不显示
2026-01-15 09:09:58 +00:00
kuaifan
07360a8d2c feat(manticore): 添加同步失败自动重试机制
- 新增 ManticoreSyncFailure 模型记录同步失败的条目
  - 添加 RetryManticoreSync 命令实现失败重试逻辑
  - ManticoreBase 增加 runWithRetry 包装器,连接断开时自动重连
  - 统一 deleteVector 方法,减少重复代码
  - 修复 quoteValue 传入非字符串的类型问题
2026-01-15 08:28:55 +00:00
kuaifan
fb7731ddcd feat(ai-assistant): 添加全局浮动按钮入口
- 新增 float-button.vue 组件,支持拖拽定位和位置持久化
  - 将 AIAssistant.vue 重构为目录结构(index.vue + float-button.vue)
  - 浮动按钮位置基于四角存储,窗口缩放时保持相对位置
  - 点击浮动按钮打开 AI 助手对话框
2026-01-15 08:18:34 +00:00
kuaifan
13a25e3011 fix(manticore): 修复向量表插入时的 SQL 语法错误
- 新增 executeRaw() 方法直接执行 SQL,避免 prepared statement 解析问题
  - 新增 quoteValue() 方法安全转义 SQL 值
  - 新增通用 upsertVector() 方法统一处理所有向量表插入
  - 简化 upsertMsgVector/TaskVector/FileVector/ProjectVector/UserVector 为单行调用
  - 统一 NUMERIC_FIELDS 常量,消除代码重复
  - 更新 batchUpdateVectors() 使用统一常量
2026-01-15 00:47:33 +00:00
385 changed files with 31189 additions and 7509 deletions

1
.agents Symbolic link
View File

@@ -0,0 +1 @@
.claude

View File

@@ -1,55 +0,0 @@
# Graphiti 长期记忆集成
本项目使用 Graphiti 作为「长期记忆层」,用于持久化用户偏好、工作流程、重要约束和关键事实。
**统一 group_id**: `dootask-main`
## 任务开始前(读取记忆)
在进行实质性工作(写代码、设计方案、做大改动)前,应先通过 Graphiti 查询已有记忆:
- 使用节点搜索(如 `search_nodes`)在 `group_id = "dootask-main"` 下查找与当前任务相关的 Preference / Procedure / Requirement
- 使用事实搜索(如 `search_facts`)查找相关事实与实体关系
- 查询语句中可包含任务类型Bug 修复 / 重构 / 新功能等、涉及模块任务、项目、对话、WebSocket、报表等以及关键字 `dootask`
发现与当前任务高度相关的偏好 / 流程 / 约束时,应优先遵守;如存在冲突,应在回答中说明并做合理选择。
## 什么时候写入 Graphiti
| 类型 | 说明 | 示例 |
|------|------|------|
| **偏好 (Preferences)** | 用户表达持续性偏好时 | 语言、输出格式、技术选型 |
| **流程 (Procedures)** | 形成稳定的开发/发布/调试流程时 | 可复用步骤 |
| **约束 (Requirements)** | 项目长期有效的决策 | 不再支持某版本、架构约定 |
| **事实 (Facts)** | 模块边界、服务调用关系、外部集成方式 | AgoraIO、Manticore Search |
### 写入建议
- 默认使用 `source: "text"`,在 `episode_body` 中用简洁结构化自然语言描述背景、类型、范围、具体内容
- 需要结构化数据时可用 `source: "json"`,保证 `episode_body` 是合法 JSON 字符串
- 所有写入默认使用 `group_id: "dootask-main"`
## 更新与更正
- 偏好 / 流程发生变化时,新增一条 episode 说明新约定,并标明这是对旧习惯的更新
- 用户要求「忘记」某些记忆时,可通过删除或更正相关 episode / 关系的方式处理
- 尽量通过新增 episode 记录「更正 / 废弃说明」,而不是直接改写历史事实
## 使用原则
- **尊重已存偏好**:编码风格、回答结构、工具选择等应对齐已知偏好
- **遵循已有流程**:若图谱中已有与当前任务匹配的 Procedure应尽量按步骤执行
- **利用事实**:理解系统行为、模块边界、历史决策时优先查已存 Facts
- **代码优先**:如 Graphiti 与当前代码实际冲突,应以代码实际为准,并视情况新增 episode 更新事实
## 不要写入的内容
- 敏感信息(密钥、密码、隐私数据)
- 只与当前一次任务相关、未来不会复用的临时信息
- 体量巨大的原始数据(完整日志、长脚本全文),应只存摘要和关键结论
## 最佳实践
1. **先查再做**:在提出方案或改动架构前,优先查阅 Graphiti 中已有的设计、偏好和约束
2. **能复用就沉淀**:只要发现某个偏好 / 流程 / 约束未来会反复用到,就尽快写入 Graphiti
3. **保持一致**:确保 Graphiti 中的记忆与实际代码长期保持一致,避免「记忆漂移」

View File

@@ -0,0 +1,119 @@
---
description: 备份 DooTask 数据:数据库(必须)+ public/uploads排除 tmp可选+ docker/appstore/config可选。汇总到临时目录并附 README 说明,打包到 backup/ 按日期命名。只读取源数据、绝不删改,失败即停。
---
# DooTask 数据备份
**刚性技能**——前置检查 → 选可选项 → 确认 → 执行 → 报告。只读取源数据生成归档,**绝不删除或修改任何源数据/既有备份**。任何一步失败立即停止。
## 备份范围
| 项 | 来源 | 是否必须 | 说明 |
|----|------|---------|------|
| 数据库 | `./cmd mysql backup` 产出的 `.sql.gz` | **必须** | 脚本内部用 mysqldump 导出当前库 |
| 上传文件 | `public/uploads`**排除 `public/uploads/tmp`** | 可选 | 头像/聊天/任务/文件等真实上传数据;`tmp` 是临时目录,可重建,不备份 |
| 应用配置 | `docker/appstore/config` | 可选 | 应用市场各应用的配置;含 **root 属主子目录**,收集时可能需 sudo |
> `docker/appstore/apps` **不在备份范围**——可从应用市场重新安装,无需备份。
## 前置检查(全部通过才能继续)
1. **工作目录**:在项目根(存在 `cmd``docker-compose.yml`
2. **数据库容器**`mariadb` 容器在跑DB 备份依赖它;不在则提示用户先 `./cmd up` 起服务)
3. **磁盘空间**:确认 `backup/` 所在盘空间足够(数据库 dump 可能较大)
4. **选可选项**:询问用户本次是否包含 `public/uploads``docker/appstore/config`**默认两个都含**
检查通过、可选项确定后,汇报本次将备份哪些项,**向用户确认一次**再执行。
## 执行
用一个统一时间戳贯穿全程:`TS=$(date +%Y%m%d_%H%M%S)`,临时目录 `WORK="tmp/dootask-backup-${TS}"`
### 1) 建临时工作目录
```shell
mkdir -p "$WORK"
```
`tmp/` 已被 gitignore安全
### 2) 数据库(必须)
```shell
./cmd mysql backup
```
脚本会把 dump 写到 `docker/mysql/backup/<库名>_<时间戳>.sql.gz` 并打印「备份文件:...」。**取该次产出的最新 dump** 复制进工作目录(不用关心它原始落在哪):
```shell
DB_FILE=$(ls -t docker/mysql/backup/*.sql.gz | head -1)
cp "$DB_FILE" "$WORK/"
```
### 3) public/uploads可选排除 tmp
```shell
rsync -a --exclude='tmp' public/uploads/ "$WORK/uploads/"
```
> 无 rsync 时用 tar 管道:`mkdir -p "$WORK/uploads" && tar cf - --exclude='./tmp' -C public/uploads . | tar xf - -C "$WORK/uploads"`
### 4) docker/appstore/config可选
```shell
cp -a docker/appstore/config "$WORK/appstore-config"
```
> 含 root 属主子目录,若报 `permission denied`:改用 `sudo cp -a ...`,随后把整个工作目录属主归还当前用户,保证后续打包/清理不受阻:
> ```shell
> sudo chown -R "$(id -u):$(id -g)" "$WORK"
> ```
### 5) 写 README.md备份说明
`$WORK/README.md` 写明本次备份信息,便于日后识别与还原。模板:
```markdown
# DooTask 备份 — <TS>
- 备份时间:<人类可读时间>
- DooTask 版本:<取自 package.json 的 version>
- 包含内容:
- 数据库:<DB dump 文件名>(来源 mysqldump 当前库)
- 上传文件uploads/(来源 public/uploads已排除 tmp ← 未选则写「未包含」
- 应用配置appstore-config/(来源 docker/appstore/config ← 未选则写「未包含」
- 各项大小:<du -sh 列出工作目录内各项>
## 还原提示
- 数据库:`gunzip < <db>.sql.gz | mysql -u<user> -p<pass> <库名>`,或用 `./cmd mysql recovery` 选对应文件还原。
- 上传文件:将 uploads/ 内容覆盖回项目 public/uploads/。
- 应用配置:将 appstore-config/ 覆盖回 docker/appstore/config/。
```
### 6) 打包到 backup/,清理临时目录
```shell
mkdir -p backup
tar czf "backup/dootask_backup_${TS}.tar.gz" -C tmp "dootask-backup-${TS}"
rm -rf "$WORK"
```
## 报告
向用户报告:
- 最终归档路径:`backup/dootask_backup_<TS>.tar.gz`
- 归档大小(`ls -lh`
- 实际包含了哪些项(数据库 + 视选择含/不含 uploads、appstore-config
## 失败处理
- 任何步骤失败立即停止,原样报告错误
- **不要**自动重试、不要静默跳过某一项(可选项是否包含由前置确认决定,不在执行中临时变更)
- DB 备份失败(如 mariadb 未运行)→ 停止,提示用户起服务后重试
- 打包前若工作目录有 root 属主残留导致 tar/rm 失败 → `sudo chown` 归还属主后继续,不要删源数据
## 禁止项
| 错误做法 | 正确做法 |
|---------|---------|
| 为"省空间"删除源数据或既有备份 | 只读取源数据生成归档,源数据一律不动 |
| 备份 `public/uploads/tmp` | 排除 tmp临时、可重建 |
| 把 `docker/appstore/apps` 也打进去 | 不在范围,可从应用市场重装 |
| 遇 config 的 root 子目录就跳过该项 | `sudo` 收集后 chown 归还,完整备份 |
| 不写 README 直接打包 | 每个归档自带 README便于日后识别还原 |
| 把归档写进 git | 归档放 `backup/`(已 gitignore不提交 |
## Red Flags —— 出现这些念头立即停下
- "源数据太大,删点旧的再备份" → 不,备份只读不删
- "config 有 root 目录,跳过算了" → 不sudo 收集后归还属主
- "apps 也一起备了更全" → 不apps 不在范围
- "tmp 里临时文件顺手也备了" → 不,明确排除 `public/uploads/tmp`

View File

@@ -0,0 +1,76 @@
---
name: dootask-fix-permission
description: 修复 DooTask 可写目录bootstrap/cache、docker、public、storage的属主/权限chown 回当前用户 + 目录 chmod 775对齐 install 的赋权逻辑,赋权不删数据。
---
# DooTask 目录权限修复
容器内进程常以 **root** 写入挂载目录(`storage``public/uploads``bootstrap/cache` 等),导致宿主机当前用户对这些文件**没有写权限**,进而触发:
- `./cmd install` 报「目录【xxx】权限不足」/ 目录权限检测失败
- `./cmd build`vite`EACCES: permission denied, copyfile`(复制 `public/uploads/...` 时)
- Laravel 运行时写 `storage`/`bootstrap/cache` 失败
本技能**对齐 `./cmd install` 的目录赋权逻辑**:对四个可写目录做 `chmod 775`(目录)+ `chown` 回当前用户。
## 适用目录
与 install 一致的四个:
```
bootstrap/cache
docker
public # 含 public/uploads真实上传数据
storage
```
## 核心原则:赋权,不删数据
`public/uploads` 含真实上传文件(头像、附件等)。**永远优先 `chown` 改属主,不要删数据。** 即便用户说"清理一下",也只允许清临时目录 `public/uploads/tmp`**切勿**删 uploads 下其他内容。
## 前置检查
1. **工作目录**:在项目根(存在 `cmd` 且这四个目录在)
2. **sudo**:改属主需 root当前文件多为 root 属主)。本机一般可免密 sudo不行则经 docker 以 root 改权限
3. 确认要修的范围:默认四个目录全修;若用户只想解 build 报错,也可只针对 `public`(含 `public/uploads`
检查通过后汇报将执行的命令,**向用户确认一次**再执行。
## 执行
确认后执行(属主修回当前用户,目录权限 775
```shell
# 1) 属主修回当前用户(递归)
sudo chown -R "$(id -u):$(id -g)" bootstrap/cache docker public storage
# 2) 目录权限 775仅目录对齐 install 的 `find -type d -exec chmod 775`
find bootstrap/cache docker public storage -type d -exec chmod 775 {} \;
```
> 只想解 build 的 uploads 报错时,可只对 `public`
> ```shell
> sudo chown -R "$(id -u):$(id -g)" public/uploads
> ```
执行后报告:改了哪些目录、属主/权限现状(可 `ls -ld` 抽查),并提示用户可重试之前失败的 install/build/update。
## 失败处理
- `chown` 报权限不足 → 当前用户无 sudo 权限,提示用户用有 root 权限的账户,或经 docker 以 root 执行;不要静默跳过
- 任何步骤失败立即停止报告,不自动重试
## 禁止项
| 错误做法 | 正确做法 |
|---------|---------|
| build 报 uploads EACCES 就 `rm` 删文件 | `chown` 修属主,保留数据 |
| 删整个 `public/uploads` 清场 | 最多清 `public/uploads/tmp`,别碰真实上传数据 |
| 对文件无差别 `chmod 777` | 目录 `chmod 775` + `chown` 回当前用户即可 |
| 不加 sudo 直接 chown root 文件 | 改属主需 root |
## Red Flags —— 出现这些念头立即停下
- "uploads 复制失败,删掉再 build" → 不,`chown` 赋权,不丢数据
- "777 一把梭最省事" → 不,按 install 的 775目录+ chown
- "权限不够就跳过这个目录" → 不,报告交用户处理 sudo

View File

@@ -0,0 +1,74 @@
---
name: dootask-install
description: 首次部署 DooTask前置检查后执行 `sudo ./cmd install`(建库 + migrate --seed 的重操作),刚性流程、单次确认、失败即停。
---
# DooTask 安装流程
**刚性技能**——前置检查 → 向用户确认一次 → 执行 → 报告结果。任何一步失败立即停止。
## 核心原则
**违反字面规则 = 违反流程精神。** 不要擅自增加、省略、合并步骤,不要为"省事"绕过 sudo 或确认。
`./cmd install` 已把整套安装封装为单条命令(赋权→起容器→`composer install``key:generate``migrate --seed``up -d`)。本技能的职责是**安装前把关、选对参数、执行前确认、已知失败处理**,而不是把脚本逻辑拆开重做。
## 前置检查(全部通过才能继续)
执行前依次确认:
1. **工作目录**:必须在项目根(存在 `cmd``docker-compose.yml``.env.docker`
2. **Docker**`docker``docker-compose`/`docker compose`(v2+) 可用且 daemon 在跑(脚本 `check_docker` 也会查,但提前确认能更早报错)
3. **Node.js ≥ 20**(脚本 `check_node` 会查)
4. **APP_ID 不冲突**:若 `.env` 已有 `APP_ID` 且被其他实例占用,脚本 `check_instance` 会报错——此时**停止**,提示用户先清空 `.env` 里的 `APP_ID``APP_IPPR` 再装
5. **sudo**`./cmd install` 需 root`check_sudo`),用 `sudo ./cmd install` 执行
⚠️ **这是重操作**:会创建数据库并执行 `migrate --seed`(灌入种子数据)。在已有数据的环境上重装前务必和用户确认,避免覆盖。
检查通过后汇报结果,**向用户确认一次**再执行。
## 参数选择
| 参数 | 作用 | 何时用 |
|------|------|--------|
| `--port <端口>` | 指定 HTTP 端口(脚本会做端口占用检测) | 用户要自定义端口,或默认端口被占 |
| `--relock` | 删除 `node_modules`/`package-lock.json`/`vendor`/`composer.lock` 后重装 | **谨慎**:仅在依赖锁损坏、用户明确要求重建锁时用,会拖慢安装 |
不确定时不要自作主张加参数,按需询问用户。
## 执行
确认后执行(按用户选择带上参数):
```shell
sudo ./cmd install
# 或: sudo ./cmd install --port 8080
```
成功后脚本会输出访问地址并调用 `repassword.sh`。执行完向用户报告:访问地址(`http://127.0.0.1:<APP_PORT>`)、以及数据库密码提示。
## 失败处理
- 任何步骤失败立即停止,原样报告错误信息
- **不要**自动重试,**不要**自动跳过
- 常见失败与对应处理:
- `APP_IDxxx已被其他实例使用` → 停止,让用户清空 `.env``APP_ID`/`APP_IPPR` 再装
- `端口 xxx 已被占用` → 停止,让用户换 `--port`
- `目录【xxx】权限不足` / 目录权限检测失败 → 这是目录属主/权限问题,引导用户用 **dootask-fix-permission** 技能修复后重装
- `安装依赖失败`composer→ 报告,交用户决定(常因网络/镜像源)
## 禁止项
| 错误做法 | 正确做法 |
|---------|---------|
| 不加 sudo 直接 `./cmd install` | 用 `sudo ./cmd install`(脚本强制 root |
| 失败后"我再试一次"或自动跳过 | 立即停止,交还用户 |
| 在已有数据环境上不问就重装 | 先确认会 `migrate --seed`,可能影响现有数据 |
| 遇权限报错自己乱 `chmod`/`chown` | 走 dootask-fix-permission 技能统一处理 |
| 不问就加 `--relock` | 默认不加;仅用户明确要求或锁损坏时用 |
## Red Flags —— 出现这些念头立即停下
- "端口/权限报错了我顺手帮 TA 改一下别的" → 停下,只处理本次报的问题,按指引走对应技能
- "种子数据应该没事,直接重装" → 不,先确认是否会覆盖现有数据
- "sudo 麻烦,先试试不加" → 不install 必须 root

View File

@@ -0,0 +1,204 @@
---
name: dootask-release
description: 从 `pro` 分支发布 DooTask 前端新版本:翻译 → 版本号/更新日志 → 构建 → 提交推送,刚性顺序、每步确认、失败即停。
---
# DooTask 发布流程
**刚性技能**——严格按顺序执行,每步向用户确认,任何一步失败立即停止。
## 核心原则
按固定顺序执行不增删、合并或重排步骤。翻译Step 1和更新日志Step 2由你直接产出脚本只做确定性机械工作算版本号、检测差异、字节级生成语言文件
## 前置检查(全部通过才能继续)
执行任何发布步骤前,依次检查:
1. **分支**:必须是 `pro`,否则停止,提示用户切换
2. **工作区**`git status` 必须干净(无未提交变更、无未跟踪文件),否则**停止**并交由用户处理
3. **Node.js**`node --version` 必须 ≥ 20
4. **PHP**`php --version` 必须可用Step 1 的脚本依赖本地 php无需容器。若 host 无 php停止并提示用户
检查通过后汇报结果,用户确认后再开始执行。
## 发布步骤
**每步执行前**向用户确认;**每步执行后**报告结果。
开始前先把这份清单复制到你的回复里,逐项勾选、跟踪进度:
```
发布进度:
- [ ] 前置检查(分支 pro / 工作区干净 / node≥20 / php 可用)
- [ ] Step 1 翻译diff → 翻译 → apply → generate
- [ ] Step 2 版本号 + CHANGELOG
- [ ] Step 3 构建(./cmd prod
- [ ] 汇总变更 → 用户确认 → commit + push
- [ ] 确认 GitHub Actions Publish 工作流 success
```
---
### Step 1: 翻译
多语言数据流:`language/original-{web,api}.txt`(原文/简体中文)→ 经翻译写入 `language/translate.json`(含 9 种语言)→ 生成 `public/language/{web,api}/*`
**1.1 检测差异**
```shell
php .claude/skills/dootask-release/scripts/language.php diff
```
输出 JSON
- `regexErrorCount > 0`translate.json **已有条目**的占位符与某语言值不一致 → **停止**,报告 `regexErrors`,交用户修复(这是历史数据问题,不要自行猜测修改)
- `redundantCount > 0`translate.json 里有、但原文已删除的条目 → 仅作提示apply 时会自动剔除,不致命)
- `needsCount == 0`:无新文案 → **跳到 1.4 直接生成**
- `needsCount > 0``needs` 数组即待翻译清单,每项 `key` 已转成占位符形式(如 `(%T1)`)→ 进入 1.2
**1.2 翻译**
`needs` 里的每个 `key`,翻成 8 种语言(`zh` 留空、`key` 原样保留):`zh-CHT` `en` `ko` `ja` `de` `fr` `id` `ru`
要求:贴合「项目任务管理系统」语境;占位符 `(%T1)`/`(%M1)` 等原样保留、不可增删改,位置可随目标语言语序调整:
| 原文 | 翻成英语 |
|---|---|
| (%T1)的周报[(%T2)][(%T3)月第(%T4)周] | Weekly report of (%T1) [(%T2)] [Week (%T4) of month (%T3)] |
| (%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``""`)。
```json
[
{"key":"...(%T1)...","zh":"","zh-CHT":"...","en":"...","ko":"...","ja":"...","de":"...","fr":"...","id":"...","ru":"..."}
]
```
**1.3 合并进 translate.json**
```shell
php .claude/skills/dootask-release/scripts/language.php apply /tmp/dootask-release-translated.json
```
脚本会校验字段完整性与占位符完整性、追加新条目、剔除冗余项,并按项目原生格式写回 `translate.json`。任一条不合格会报错停止,按提示修正翻译后重试。
**1.4 生成前端/后端语言文件**
```shell
php .claude/skills/dootask-release/scripts/language.php generate
```
`translate.json` 字节级重新生成 `public/language/web/*.js``public/language/api/*.json`(排序/转义与项目原生工具完全一致,正常情况下 diff 只包含本次新增条目)。
**1.5 报告**:用 `git status --short language public/language` 汇总本步改动,向用户报告新增了多少条翻译。
---
### Step 2: 版本号 + 更新日志
**2.1 计算并写入版本号**
```shell
node .claude/skills/dootask-release/scripts/version_bump.js
```
脚本据 git 历史算出新 `version``codeVerson` 并写入 `package.json`,输出 JSON 含:`version``prevVersion``changelogRange`(如 `<上次release提交>..HEAD`,用于下一步圈定本次更新范围)。
**2.2 撰写 CHANGELOG**
读取本次区间的提交:
```shell
git log <changelogRange> --stat
```
`--stat` 会带上每个提交的完整描述正文 + 改动文件清单;光看标题不够时用 `git show <hash>` 看具体代码改动。
`CHANGELOG.md` 现有格式,在文件顶部 `# Changelog` 说明段之后、紧挨上一个 `## [...]` 之前,插入新版本区段:
```markdown
## [<version>]
### Features
- ...
### Bug Fixes
- ...
### Performance
- ...
```
撰写要求(对齐项目历史风格):
- 小节标题用**英文 Title Case**`Features` / `Bug Fixes` / `Performance` / `Documentation` / `Security` / `Miscellaneous`**不要译成中文****没有内容的小节整段省略**。
- 条目正文用**通俗友好的简体中文**,面向**普通用户**描述更新带来的直接好处,**避免技术术语**(如 refactor、merge branch、commit lint、bump deps 等)。
- 过滤掉对用户无意义的提交(纯构建/依赖/CI/合并提交、本技能自身的脚手架改动等)。
- 仅凭提交标题无法判断是否对用户有价值时,结合提交的完整描述正文和实际代码改动(`git show <hash>`)再决定,不要只看一行就下结论。
- 合并相似项;每个小节内**按用户价值与影响范围排序,重要的在前**。
**2.3 报告**:展示新版本号与你写的 changelog 区段,请用户过目。
---
### Step 3: 构建前端
```shell
./cmd prod
```
构建前端生产版本。用 `./cmd prod`,不要换成裸跑 vite它还负责 node 检查、清 `public/js/build`、debug 切换)。
> **已知失败**build 报 `public/uploads/...` 的 `EACCES: permission denied, copyfile`,是 vite 复制 `public/` 时撞到 root 属主的运行时上传文件(不限于 `tmp``avatar` 等都可能)。补救是赋权、不是删数据——把 uploads 属主改回当前用户后重试:
> ```shell
> sudo chown -R "$(id -u):$(id -g)" public/uploads
> ```
> `public/uploads` 是真实上传数据,**不要删**;即便要清也只清 `public/uploads/tmp`。
---
## 最终:提交并推送
所有步骤完成后:
1. 通过 `git diff` + `git status` 汇总所有变更,向用户报告摘要
2. **询问用户是否提交并推送**
3. 用户明确确认后才执行 `git add``git commit``git push`
4. 未确认一律不执行
提交规范:
- 提交信息使用 `release: v<新版本号>`(与历史一致,参见 `git log --oneline | grep '^release:'`
- **只 add 本次发布相关改动**,按文件名/目录显式添加(例如 `git add package.json CHANGELOG.md language/translate.json public/language public/js`),不要用 `git add -A` / `git add .`,以免卷入未跟踪的本地实验文件
- 不打 git tag现行发布流程不使用 tag
- 确认前先核对:`/tmp/dootask-release-translated.json` 等临时文件不在仓库内,工作区不应残留发布无关的未跟踪文件
## push 之后确认发布工作流CI 才是真正出包)
push 到 `pro` 只是触发器,真正的构建/出包由 GitHub Actions 完成——**push 成功 ≠ 发布完成**
- **Publish**`.github/workflows/publish.yml`push→pro 触发)跑完才算出包;成功后会自动触发 **Sync to Gitee**(镜像同步)。
- push 完成后**主动确认** Publish 工作流 `conclusion=success`。优先用 `gh`(未装可临时装;公开仓库也可用 GitHub REST API 免鉴权读取 runs
```shell
gh run list --workflow=publish.yml -R kuaifan/dootask -L 1
gh run view <run-id> -R kuaifan/dootask --json status,conclusion,url
```
- 工作流仍在跑时,挂后台轮询、结束即通知用户,**不要在前台死等**。
### iOS 发布(询问后决定)
`ios-publish.yml` 是**独立的手动工作流**`workflow_dispatch`),不随 push 触发。Publish 成功后,用 options 或 AskUserQuestion 形式提问是否同时发布 iOS选项发布 iOS / 不发布):
- 选「发布 iOS」才执行
```shell
gh workflow run ios-publish.yml --ref pro -R kuaifan/dootask
```
需 `gh` 已登录且 token 含 `workflow` 权限;触发后可挂后台轮询结果。
- 选「不发布」则结束。
## 失败处理
任何步骤失败立即停止、报告错误信息,交用户决定;不要自动重试或跳过。

View File

@@ -0,0 +1,239 @@
<?php
// DooTask 发布——翻译流水线(纯本地 phphost 直接跑,不进容器、不调 OpenAI、不需 autoload
// 逐行对齐 language/translate.php 的检测/保存/生成逻辑,唯独把"调用外部模型翻译"那一段抽走,
// 翻译改在技能流程内完成。用 php 而非 node 的唯一原因array_multisort + json_encode
// 的逐字节产物必须与项目原生工具一致,否则每次发版都会产生大面积排序/转义噪声 diff已验证 host php 可字节级复现)。
//
// 子命令:
// language.php diff
// —— 输出 JSONneeds(待翻译key 已转成 (%T1)/(%M1) 形式) / redundants(冗余,提示) / regexErrors(占位符错乱,致命)
// language.php apply <translated.json>
// —— 把新翻译合并进 translate.json追加 + 剔除冗余),不生成 public 文件
// language.php generate
// —— 由 translate.json 重新生成 public/language/{web,api}/*
//
// 项目根相对脚本自身定位(脚本固定在 <root>/.claude/skills/dootask-release/scripts/),与调用时的 cwd 无关。
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
$ROOT = dirname(__DIR__, 4);
$LANG_DIR = $ROOT . '/language';
$LANG_FIELDS = ['key', 'zh', 'zh-CHT', 'en', 'ko', 'ja', 'de', 'fr', 'id', 'ru'];
if (!is_dir($LANG_DIR)) {
fwrite(STDERR, "未找到 language 目录($LANG_DIR。\n");
exit(1);
}
chdir($LANG_DIR);
$cmd = $argv[1] ?? '';
// ---- 公共:读取 original-*.txt ----
function read_generateds(): array
{
$originals = [];
$generateds = [];
foreach (['web', 'api'] as $type) {
$content = file_exists("original-{$type}.txt") ? file_get_contents("original-{$type}.txt") : "";
$array = array_values(array_filter(array_unique(explode("\n", $content))));
$generateds[$type] = $array;
$originals = array_merge($originals, $array);
}
return [$originals, $generateds];
}
// ---- 公共:构建 translations 映射normalizedKey -> obj并收集冗余/占位符错乱 ----
function build_translations(array $originals): array
{
$translations = [];
$redundants = [];
$regrror = [];
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'])) {
continue;
}
$currentKey = $obj['key'];
$originalKey = preg_replace(["/\(%T\d+\)/", "/\(%M\d+\)/"], ["(*)", "(**)"], $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;
}
}
}
}
}
return [$translations, $redundants, $regrror];
}
// ---- 公共:由 translate.json + originals 重新生成 public 文件 ----
function generate(array $generateds, array $translations): void
{
foreach ($generateds as $type => $array) {
$datas = [];
foreach ($array as $text) {
$text = trim($text);
if (isset($translations[$text])) {
$datas[] = $translations[$text];
}
}
$inOrder = [];
foreach ($datas as $index => $item) {
if (preg_match('/\(%[TM]\d+\)/', $item['key'])) {
$inOrder[$index] = strlen($item['key']);
} else {
$inOrder[$index] = strlen($item['key']) + 10000000000;
}
}
array_multisort($inOrder, SORT_DESC, $datas);
$results = [];
foreach ($datas as $items) {
foreach ($items as $kk => $item) {
$results[$kk][] = $item;
}
}
if ($type === 'api') {
if (!is_dir("../public/language/api")) {
mkdir("../public/language/api", 0777, true);
}
foreach ($results as $kk => $item) {
file_put_contents("../public/language/api/$kk.json", json_encode($item, JSON_UNESCAPED_UNICODE));
}
} elseif ($type === 'web') {
if (!is_dir("../public/language/web")) {
mkdir("../public/language/web", 0777, true);
}
foreach ($results as $kk => $item) {
file_put_contents("../public/language/web/$kk.js", "if(typeof window.LANGUAGE_DATA===\"undefined\")window.LANGUAGE_DATA={};window.LANGUAGE_DATA[\"{$kk}\"]=" . json_encode($item, JSON_UNESCAPED_UNICODE));
}
}
echo "[$type] total: " . count($results['key']) . "\n";
}
}
if ($cmd === 'diff') {
[$originals, $generateds] = read_generateds();
[$translations, $redundants, $regrror] = build_translations($originals);
// 需要翻译的数据(对齐 translate.php 150-169占位符按单一计数器编号
$needs = [];
foreach ($originals as $text) {
$key = trim($text);
if ($key === '') {
continue;
}
if (!isset($translations[$key])) {
$needs[$key] = $key;
}
}
$needsOut = [];
foreach ($needs as $key) {
$c = 1;
$converted = preg_replace_callback('/\((\*+)\)/', function ($m) use (&$c) {
$label = strlen($m[1]) > 1 ? "M" : "T";
return "(%" . $label . $c++ . ")";
}, $key);
$needsOut[] = ['key' => $converted];
}
echo json_encode([
'needsCount' => count($needsOut),
'redundantCount' => count($redundants),
'regexErrorCount' => count($regrror),
'needs' => $needsOut,
'redundants' => array_keys($redundants),
'regexErrors' => array_values($regrror),
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
if (count($regrror) > 0) {
exit(2); // 已有数据占位符错乱,需先修复
}
exit(0);
}
if ($cmd === 'apply') {
$file = $argv[2] ?? '';
if ($file === '' || !file_exists($file)) {
fwrite(STDERR, "用法apply <translated.json>(文件不存在)\n");
exit(1);
}
[$originals, $generateds] = read_generateds();
[$translations, $redundants, $regrror] = build_translations($originals);
if (count($regrror) > 0) {
fwrite(STDERR, "translate.json 已有条目占位符错乱,请先修复再发版。\n");
exit(2);
}
$incoming = json_decode(file_get_contents($file), true);
if (!is_array($incoming)) {
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);
}
}
// 占位符完整性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);
}
}
}
}
// 规范化:固定字段顺序 + zh 置空
$item = [];
foreach ($GLOBALS['LANG_FIELDS'] as $f) {
$item[$f] = $f === 'zh' ? '' : $raw[$f];
}
$originalKey = preg_replace(["/\(%T\d+\)/", "/\(%M\d+\)/"], ["(*)", "(**)"], $item['key']);
$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,
'total' => count($translations),
'droppedRedundant' => count($redundants),
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
exit(0);
}
if ($cmd === 'generate') {
[$originals, $generateds] = read_generateds();
[$translations] = build_translations($originals);
generate($generateds, $translations);
exit(0);
}
fwrite(STDERR, "未知子命令:'$cmd'。可用diff | apply <file> | generate\n");
exit(1);

View File

@@ -0,0 +1,47 @@
#!/usr/bin/env node
// 计算并写入新版本号到 package.jsonversion + codeVerson算法对齐 bin/version.js。
// 不生成 CHANGELOG在技能流程内撰写只输出版本号与 changelog 的提交区间。
//
// 项目根相对脚本自身定位(脚本固定在 <root>/.claude/skills/dootask-release/scripts/),与调用时的 cwd 无关。
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const ROOT = path.resolve(__dirname, '../../../..');
const pkgFile = path.join(ROOT, 'package.json');
const verOffset = 6394; // 版本号偏移量(与 bin/version.js 一致)
const codeOffset = 35; // 代码版本号偏移量
function git(cmd) {
return execSync(cmd, { cwd: ROOT, maxBuffer: 1024 * 1024 * 10 }).toString().trim();
}
const verCount = parseInt(git('git rev-list --count HEAD'), 10);
const codeCount = parseInt(git("git tag --merged pro -l 'v*' | wc -l"), 10);
const num = verOffset + verCount;
if (Number.isNaN(num)) {
console.error(`版本计算失败rev-list count=${verCount}`);
process.exit(1);
}
const version = `${Math.floor(num / 10000)}.${Math.floor((num % 10000) / 100)}.${Math.floor(num % 100)}`;
const codeVersion = codeOffset + codeCount;
let pkg = fs.readFileSync(pkgFile, 'utf8');
const prevVersion = (pkg.match(/"version":\s*"(.*?)"/) || [])[1] || '';
pkg = pkg.replace(/"version":\s*"(.*?)"/, `"version": "${version}"`);
pkg = pkg.replace(/"codeVerson":(.*?)(,|$)/, `"codeVerson": ${codeVersion}$2`);
fs.writeFileSync(pkgFile, pkg, 'utf8');
// 上一个 release 提交作为 changelog 区间下界
let prevReleaseCommit = '';
try {
prevReleaseCommit = git("git log --grep='^release: v' -n 1 --pretty=format:%H");
} catch (e) { /* ignore */ }
console.log(JSON.stringify({
version,
codeVersion,
prevVersion,
prevReleaseCommit,
changelogRange: prevReleaseCommit ? `${prevReleaseCommit}..HEAD` : '(未找到上一个 release 提交,需人工确定区间)',
}, null, 2));

View File

@@ -0,0 +1,83 @@
---
name: dootask-update
description: 更新已部署的 DooTask前置检查后执行 `sudo ./cmd update`(拉代码 + composer + 迁移 + 重启),本地有改动时停下交用户决定,不自动强制、失败即停。
---
# DooTask 更新流程
**刚性技能**——前置检查 → 向用户确认一次 → 执行 → 报告结果。任何一步失败立即停止。
## 核心原则
**违反字面规则 = 违反流程精神。** 不要擅自加步骤、绕过 sudo/确认,**尤其不要替用户决定强制更新**(会丢本地改动)。
`./cmd update` 已封装整套更新(检测本地改动→`git fetch`→必要时备份库→`git pull/reset``composer install``migrate`→重启 php+nginx→写 `UPDATE_TIME`)。本技能职责是**更新前把关、选对参数、处理本地改动这一关键岔路、执行前确认**。
## 前置检查(全部通过才能继续)
1. **已安装**:必须存在 `vendor/autoload.php`(脚本会查,没装则报"请先执行安装命令"——此时引导用户走 dootask-install
2. **工作目录**:在项目根
3. **当前分支 / 目标分支**:默认更新当前分支;用户要切分支用 `--branch <分支>`。若用户没说,确认是否就更新当前分支
4. **本地改动**(关键):`git status` 看是否有未提交改动
5. **sudo**`sudo ./cmd update` 需 root
检查通过后汇报结果,**向用户确认一次**再执行。
## 关键岔路:本地有改动
脚本检测到本地改动时会询问是否强制更新。**强制更新 = `git reset --hard origin/<分支>`,会丢弃所有本地改动。**
- 发现本地有改动 → **停下**,把改动清单报告用户,让**用户决定**:先提交/暂存改动,还是确认强制更新
- **不要**替用户选 `--force`
- 只有用户明确说"丢掉改动强制更新"时,才带 `--force`
## 参数选择
| 参数 | 作用 | 何时用 |
|------|------|--------|
| `--branch <分支>` | 切到指定分支再更新 | 用户要换分支(如切 `dev`/`pro` |
| `--force` | 强制更新:`git checkout -f` + `git reset --hard` | **危险**:仅用户明确接受"丢弃本地改动"后 |
| `--local` | 本地更新模式:只备份库 + `migrate` + 重启,不拉远程代码 | 代码已就位(如手动改过/CI 拉过),只需迁移+重启 |
## 数据库
- 远程模式下,脚本检测到 `database/` 目录有迁移变动会**自动备份数据库**再继续——这是脚本内置的,无需手动。
- 但若是大版本升级或用户在意数据,执行前提醒用户:本次可能含库迁移,已有自动备份兜底;如需可先 `./cmd mysql backup` 额外备份。
## 执行
确认(含本地改动决策)后执行:
```shell
sudo ./cmd update
# 切分支: sudo ./cmd update --branch pro
# 强制(丢改动,用户确认后): sudo ./cmd update --force
# 本地模式: sudo ./cmd update --local
```
成功后报告:更新到的分支、是否做了库备份/迁移、服务是否重启完成。
## 失败处理
- 任何步骤失败立即停止,原样报告错误
- **不要**自动重试、不要自动跳过、不要因为 `git pull` 失败就自己改成 `--force`
- 常见失败:
- `请先执行安装命令` → 走 dootask-install
- `代码拉取失败,可能存在冲突` → 报告,让用户决定是否 `--force`(丢改动)或先处理冲突
- 重启服务失败 → 脚本会尝试 `down` 后重起;若仍失败,报告交用户
## 禁止项
| 错误做法 | 正确做法 |
|---------|---------|
| 检测到本地改动就自动 `--force` | 停下,报告改动,交用户决定 |
| `git pull` 失败就自动改用 `--force` | 报告冲突,交用户 |
| 不加 sudo | `sudo ./cmd update` |
| 未装就更新 | 先走 dootask-install |
| 失败后自动重试/跳过 | 立即停止 |
## Red Flags —— 出现这些念头立即停下
- "有点本地改动,强制更新一下就好了" → 不,`--force` 会丢改动,必须用户拍板
- "拉取冲突了,我 reset 一下" → 不,交用户决定
- "已经装过了吧,直接更新" → 先确认 `vendor/autoload.php`

432
.github/workflows/ios-publish.yml vendored Normal file
View File

@@ -0,0 +1,432 @@
name: "iOS Publish"
# Required GitHub Secrets:
#
# IOS_CERTIFICATE_BASE64 - Apple distribution certificate (.p12) encoded in base64
# IOS_CERTIFICATE_PASSWORD - Password for the .p12 certificate
# IOS_PROVISION_PROFILE_BASE64 - App Store provisioning profile (.mobileprovision) encoded in base64
# IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64 - Share extension App Store provisioning profile (.mobileprovision) encoded in base64
# ASC_API_KEY_P8_BASE64 - App Store Connect API key (.p8) encoded in base64
# ASC_API_KEY_ID - App Store Connect API Key ID
# ASC_ISSUER_ID - App Store Connect Issuer ID
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ios-publish-${{ github.ref }}
cancel-in-progress: false
jobs:
prepare-assets:
name: Prepare iOS Assets
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
version: ${{ steps.get-version.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Get version from package.json
id: get-version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm install
- name: Install electron dependencies
run: |
pushd electron
npm install
popd
- name: Init mobile submodule
run: |
git submodule init
git submodule update --remote "resources/mobile"
- name: Build app assets
run: ./cmd appbuild publish
- name: Upload iOS platform artifacts
uses: actions/upload-artifact@v4
with:
name: ios-platform
path: resources/mobile/platforms/ios/
retention-days: 1
build-ios:
name: Build & Submit iOS
needs: prepare-assets
runs-on: macos-26
timeout-minutes: 60
environment: build
steps:
- uses: actions/checkout@v4
- name: Init mobile submodule
run: |
git submodule init
git submodule update --remote "resources/mobile"
- name: Download prepared assets
uses: actions/download-artifact@v4
with:
name: ios-platform
path: resources/mobile/platforms/ios/
- name: Select Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
- name: Install CocoaPods
run: |
if [ -f "resources/mobile/platforms/ios/eeuiApp/Podfile" ]; then
cd resources/mobile/platforms/ios/eeuiApp
pod install
fi
- name: Import signing certificate
env:
IOS_CERTIFICATE_BASE64: ${{ secrets.IOS_CERTIFICATE_BASE64 }}
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
run: |
# Create temporary keychain
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -hex 20)
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
# Import certificate
CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12
echo "$IOS_CERTIFICATE_BASE64" | base64 --decode > "$CERTIFICATE_PATH"
security import "$CERTIFICATE_PATH" \
-P "$IOS_CERTIFICATE_PASSWORD" \
-A \
-t cert \
-f pkcs12 \
-k "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security list-keychain -d user -s "$KEYCHAIN_PATH"
- name: Import provisioning profile
env:
IOS_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_PROVISION_PROFILE_BASE64 }}
IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64 }}
run: |
set -euo pipefail
APP_PROFILE_PATH=$RUNNER_TEMP/app.mobileprovision
SHARE_PROFILE_PATH=$RUNNER_TEMP/share-extension.mobileprovision
APP_PROFILE_PLIST=$RUNNER_TEMP/app-profile.plist
SHARE_PROFILE_PLIST=$RUNNER_TEMP/share-extension-profile.plist
echo "$IOS_PROVISION_PROFILE_BASE64" | base64 --decode > "$APP_PROFILE_PATH"
echo "$IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64" | base64 --decode > "$SHARE_PROFILE_PATH"
security cms -D -i "$APP_PROFILE_PATH" > "$APP_PROFILE_PLIST"
security cms -D -i "$SHARE_PROFILE_PATH" > "$SHARE_PROFILE_PLIST"
APP_PROFILE_NAME=$(/usr/libexec/PlistBuddy -c "Print :Name" "$APP_PROFILE_PLIST")
SHARE_PROFILE_NAME=$(/usr/libexec/PlistBuddy -c "Print :Name" "$SHARE_PROFILE_PLIST")
IOS_TEAM_ID=$(/usr/libexec/PlistBuddy -c "Print :TeamIdentifier:0" "$APP_PROFILE_PLIST")
APP_PROFILE_APP_ID=$(/usr/libexec/PlistBuddy -c "Print :Entitlements:application-identifier" "$APP_PROFILE_PLIST")
SHARE_PROFILE_APP_ID=$(/usr/libexec/PlistBuddy -c "Print :Entitlements:application-identifier" "$SHARE_PROFILE_PLIST")
if [ "$APP_PROFILE_APP_ID" != "$IOS_TEAM_ID.com.dootask.task" ]; then
echo "Expected app profile for $IOS_TEAM_ID.com.dootask.task, got $APP_PROFILE_APP_ID"
exit 1
fi
if [ "$SHARE_PROFILE_APP_ID" != "$IOS_TEAM_ID.com.dootask.task.shareExtension" ]; then
echo "Expected share extension profile for $IOS_TEAM_ID.com.dootask.task.shareExtension, got $SHARE_PROFILE_APP_ID"
exit 1
fi
if ! /usr/libexec/PlistBuddy -c "Print :Entitlements:aps-environment" "$APP_PROFILE_PLIST" >/dev/null; then
echo "The DooTask app profile must include Push Notifications."
exit 1
fi
if ! /usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.security.application-groups" "$APP_PROFILE_PLIST" | grep -q "group.im.dootask"; then
echo "The DooTask app profile must include App Group group.im.dootask."
exit 1
fi
if ! /usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.security.application-groups" "$SHARE_PROFILE_PLIST" | grep -q "group.im.dootask"; then
echo "The share extension profile must include App Group group.im.dootask."
exit 1
fi
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp "$APP_PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/
cp "$SHARE_PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/
echo "APP_PROFILE_NAME=$APP_PROFILE_NAME" >> $GITHUB_ENV
echo "SHARE_PROFILE_NAME=$SHARE_PROFILE_NAME" >> $GITHUB_ENV
echo "IOS_TEAM_ID=$IOS_TEAM_ID" >> $GITHUB_ENV
- name: Configure manual signing
run: |
set -euo pipefail
ruby <<'RUBY'
require 'xcodeproj'
project_path = 'resources/mobile/platforms/ios/eeuiApp/eeuiApp.xcodeproj'
project = Xcodeproj::Project.open(project_path)
{
'DooTask' => ENV.fetch('APP_PROFILE_NAME'),
'ShareExtension' => ENV.fetch('SHARE_PROFILE_NAME')
}.each do |target_name, profile_name|
target = project.targets.find { |item| item.name == target_name }
abort "Target #{target_name} not found in #{project_path}" unless target
target.build_configurations.each do |config|
next unless config.name == 'Release'
config.build_settings['CODE_SIGN_STYLE'] = 'Manual'
config.build_settings['DEVELOPMENT_TEAM'] = ENV.fetch('IOS_TEAM_ID')
config.build_settings['CODE_SIGN_IDENTITY'] = 'Apple Distribution'
config.build_settings['PROVISIONING_PROFILE_SPECIFIER'] = profile_name
end
end
project.save
RUBY
- name: Resolve iOS build number
env:
ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }}
run: |
set -euo pipefail
ruby <<'RUBY'
require 'base64'
require 'json'
require 'net/http'
require 'openssl'
require 'uri'
BUNDLE_ID = 'com.dootask.task'
VERSION_CONFIG_PATH = 'resources/mobile/platforms/ios/eeuiApp/Config/Version.xcconfig'
def base64url(value)
Base64.urlsafe_encode64(value).delete('=')
end
def jwt_es256_signature(private_key, unsigned)
der_signature = private_key.sign('SHA256', unsigned)
sequence = OpenSSL::ASN1.decode(der_signature)
sequence.value.map { |integer|
integer.value.to_s(2).rjust(32, "\0")[-32, 32]
}.join
end
def asc_token
key_id = ENV.fetch('ASC_API_KEY_ID')
issuer_id = ENV.fetch('ASC_ISSUER_ID')
private_key = OpenSSL::PKey.read(Base64.decode64(ENV.fetch('ASC_API_KEY_P8_BASE64')))
now = Time.now.to_i
header = { alg: 'ES256', kid: key_id, typ: 'JWT' }
payload = {
iss: issuer_id,
iat: now,
exp: now + 20 * 60,
aud: 'appstoreconnect-v1'
}
unsigned = "#{base64url(header.to_json)}.#{base64url(payload.to_json)}"
signature = jwt_es256_signature(private_key, unsigned)
"#{unsigned}.#{base64url(signature)}"
end
def asc_get(path, params, token)
uri = URI::HTTPS.build(
host: 'api.appstoreconnect.apple.com',
path: path,
query: URI.encode_www_form(params)
)
request_uri = uri
loop do
response = Net::HTTP.start(request_uri.host, request_uri.port, use_ssl: true) do |http|
request = Net::HTTP::Get.new(request_uri)
request['Authorization'] = "Bearer #{token}"
http.request(request)
end
unless response.is_a?(Net::HTTPSuccess)
abort "App Store Connect API request failed: #{response.code} #{response.body}"
end
parsed = JSON.parse(response.body)
yield parsed
next_link = parsed.dig('links', 'next')
break unless next_link
request_uri = URI(next_link)
end
end
token = asc_token
app_id = nil
asc_get('/v1/apps', { 'filter[bundleId]' => BUNDLE_ID, 'limit' => 1 }, token) do |page|
app_id = page.fetch('data').first&.fetch('id')
end
abort "App Store Connect app not found for bundle id #{BUNDLE_ID}" unless app_id
existing_versions = []
asc_get('/v1/builds', {
'filter[app]' => app_id,
'fields[builds]' => 'version',
'limit' => 200
}, token) do |page|
existing_versions.concat(
page.fetch('data').map { |build| build.dig('attributes', 'version').to_s }
)
end
max_build_number = existing_versions
.select { |version| version.match?(/\A\d+\z/) }
.map(&:to_i)
.max || 0
next_build_number = max_build_number + 1
config_content = File.exist?(VERSION_CONFIG_PATH) ? File.read(VERSION_CONFIG_PATH) : ''
if config_content.match?(/^VERSION_CODE\s*=/)
config_content = config_content.gsub(/^VERSION_CODE\s*=.*$/, "VERSION_CODE = #{next_build_number}")
else
config_content = "#{config_content.rstrip}\nVERSION_CODE = #{next_build_number}\n"
end
File.write(VERSION_CONFIG_PATH, config_content)
File.open(ENV.fetch('GITHUB_ENV'), 'a') { |file| file.puts "IOS_BUILD_NUMBER=#{next_build_number}" }
puts "Latest App Store Connect build number: #{max_build_number}"
puts "Resolved iOS build number: #{next_build_number}"
RUBY
- name: Build archive
run: |
set -euo pipefail
cd resources/mobile/platforms/ios/eeuiApp
xcodebuild archive \
-workspace eeuiApp.xcworkspace \
-scheme eeuiApp \
-configuration Release \
-destination "generic/platform=iOS" \
-archivePath $RUNNER_TEMP/eeuiApp.xcarchive \
-allowProvisioningUpdates \
DEVELOPMENT_TEAM=$IOS_TEAM_ID \
CODE_SIGN_IDENTITY="Apple Distribution" \
CODE_SIGN_STYLE=Manual \
| xcpretty
if [ ! -d "$RUNNER_TEMP/eeuiApp.xcarchive" ]; then
echo "Archive was not created at $RUNNER_TEMP/eeuiApp.xcarchive"
exit 1
fi
- name: Export IPA
run: |
set -euo pipefail
cd resources/mobile/platforms/ios/eeuiApp
# Generate ExportOptions.plist
cat > $RUNNER_TEMP/ExportOptions.plist << PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store</string>
<key>signingStyle</key>
<string>manual</string>
<key>teamID</key>
<string>${IOS_TEAM_ID}</string>
<key>provisioningProfiles</key>
<dict>
<key>com.dootask.task</key>
<string>${APP_PROFILE_NAME}</string>
<key>com.dootask.task.shareExtension</key>
<string>${SHARE_PROFILE_NAME}</string>
</dict>
<key>uploadBitcode</key>
<false/>
<key>uploadSymbols</key>
<true/>
</dict>
</plist>
PLIST
xcodebuild -exportArchive \
-archivePath $RUNNER_TEMP/eeuiApp.xcarchive \
-exportOptionsPlist $RUNNER_TEMP/ExportOptions.plist \
-exportPath $RUNNER_TEMP/ipa-output \
-allowProvisioningUpdates \
| xcpretty
- name: Submit to App Store Connect
env:
ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }}
run: |
set -euo pipefail
# Prepare API key
mkdir -p ~/private_keys
echo "$ASC_API_KEY_P8_BASE64" | base64 --decode > ~/private_keys/AuthKey_${ASC_API_KEY_ID}.p8
# Find and upload IPA
IPA_PATH=$(find $RUNNER_TEMP/ipa-output -name "*.ipa" | head -1)
if [ -z "$IPA_PATH" ]; then
echo "No IPA file found in $RUNNER_TEMP/ipa-output"
exit 1
fi
echo "Uploading: $IPA_PATH"
xcrun altool --upload-app \
-f "$IPA_PATH" \
--type ios \
--apiKey "$ASC_API_KEY_ID" \
--apiIssuer "$ASC_ISSUER_ID"
- name: Clean up
if: always()
run: |
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db 2>/dev/null || true
rm -f $RUNNER_TEMP/certificate.p12
rm -f $RUNNER_TEMP/app.mobileprovision
rm -f $RUNNER_TEMP/share-extension.mobileprovision
rm -f $RUNNER_TEMP/app-profile.plist
rm -f $RUNNER_TEMP/share-extension-profile.plist
rm -rf ~/private_keys

View File

@@ -52,53 +52,18 @@ jobs:
uses: actions/github-script@v7
with:
script: |
// 获取最新的 tag
const { data: tags } = await github.rest.repos.listTags({
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 1
});
const fs = require('fs');
const version = '${{ needs.check-version.outputs.version }}';
// 获取提交日志
// 从 CHANGELOG.md 提取当前版本段落
let changelog = '';
if (tags.length > 0) {
const { data: commits } = await github.rest.repos.compareCommits({
owner: context.repo.owner,
repo: context.repo.repo,
base: tags[0].name,
head: 'HEAD'
});
// 按类型分组提交
const groups = {
'feat:': { title: '## Features', commits: new Set() },
'fix:': { title: '## Bug Fixes', commits: new Set() },
'perf:': { title: '## Performance Improvements', commits: new Set() }
};
// 分类收集提交,使用 Set 去重
commits.commits.forEach(commit => {
const message = commit.commit.message.split('\n')[0].trim();
for (const [prefix, group] of Object.entries(groups)) {
if (message.startsWith(prefix)) {
// 移除前缀后添加到对应分组
const cleanMessage = message.slice(prefix.length).trim();
group.commits.add(cleanMessage); // 使用 Set.add 自动去重
break;
}
}
});
// 生成更新日志
const sections = [];
for (const group of Object.values(groups)) {
if (group.commits.size > 0) {
sections.push(`${group.title}\n\n${Array.from(group.commits).map(msg => `- ${msg}`).join('\n')}`);
}
}
if (sections.length > 0) {
changelog = '# Changelog\n\n' + sections.join('\n\n');
const changelogPath = 'CHANGELOG.md';
if (fs.existsSync(changelogPath)) {
const content = fs.readFileSync(changelogPath, 'utf-8');
const regex = new RegExp(`## \\[${version.replace(/\./g, '\\.')}\\][\\s\\S]*?(?=\\n## \\[|$)`);
const match = content.match(regex);
if (match) {
changelog = match[0].trim();
}
}
@@ -106,8 +71,8 @@ jobs:
const { data } = await github.rest.repos.createRelease({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: `v${{ needs.check-version.outputs.version }}`,
name: `${{ needs.check-version.outputs.version }}`,
tag_name: `v${version}`,
name: version,
body: changelog || 'No significant changes in this release.',
draft: true,
prerelease: false
@@ -215,7 +180,11 @@ jobs:
- name: (Android) Upload File
if: matrix.build_type == 'android'
env:
PUBLISH_KEY: ${{ secrets.PUBLISH_KEY }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
run: |
node ./electron/build.js android-upload
@@ -253,7 +222,11 @@ jobs:
APPLEIDPASS: ${{ secrets.APPLEIDPASS }}
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
PUBLISH_KEY: ${{ secrets.PUBLISH_KEY }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
@@ -263,7 +236,11 @@ jobs:
- name: (Windows) Build Client
if: matrix.build_type == 'windows'
env:
PUBLISH_KEY: ${{ secrets.PUBLISH_KEY }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
shell: bash
@@ -294,11 +271,16 @@ jobs:
prerelease: false
})
- name: Publish Official
- name: Upload Changelog & Publish to Website
env:
PUBLISH_KEY: ${{ secrets.PUBLISH_KEY }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
run: |
pushd electron || exit
npm install
popd || exit
node ./electron/build.js published
node ./electron/build.js upload-changelog
node ./electron/build.js release

45
.github/workflows/sync-gitee.yml vendored Normal file
View File

@@ -0,0 +1,45 @@
name: "Sync to Gitee"
# Required GitHub Secrets:
#
# GITEE_SSH_PRIVATE_KEY - SSH private key with push access to gitee.com/aipaw/dootask
on:
workflow_run:
workflows: ["Publish"]
types:
- completed
jobs:
sync:
name: Push to Gitee
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup SSH key
env:
GITEE_SSH_PRIVATE_KEY: ${{ secrets.GITEE_SSH_PRIVATE_KEY }}
run: |
mkdir -p ~/.ssh
echo "$GITEE_SSH_PRIVATE_KEY" > ~/.ssh/gitee_key
chmod 600 ~/.ssh/gitee_key
cat >> ~/.ssh/config << EOF
Host gitee.com
HostName gitee.com
IdentityFile ~/.ssh/gitee_key
StrictHostKeyChecking no
EOF
- name: Push to Gitee
run: |
git remote add gitee git@gitee.com:aipaw/dootask.git
git push gitee pro
- name: Clean up
if: always()
run: rm -rf ~/.ssh/gitee_key

4
.gitignore vendored
View File

@@ -7,6 +7,7 @@
/public/hot
/public/tmp
/tmp
/backup
# Uploads and user-generated content
/public/summary
@@ -61,3 +62,6 @@ laravels.pid
# Documentation
README_LOCAL.md
# playwright
.playwright-mcp/

1
AGENTS.md Symbolic link
View File

@@ -0,0 +1 @@
CLAUDE.md

View File

@@ -2,6 +2,168 @@
All notable changes to this project will be documented in this file.
## [1.7.90]
### Features
- 系统设置新增「创建项目」权限开关,可指定由所有人、部门负责人或特定人员创建项目,未授权时自动隐藏新建入口,管理更清晰。
- 会员卡片新增「项目与任务」入口,可直接查看该成员参与的项目、待办与已完成任务,团队协作一目了然。
- 审批详情支持删除已结束的审批,由发起人或管理员清理无用记录更方便。
- 管理员现在可以设置全员群的群名称,便于统一团队群组的展示。
## [1.7.81]
### Features
- 团队管理中可标记成员邮箱认证状态,成员信息更易管理。
- 系统管理员可在任意群组中设置或取消他人的待办,协作管理更灵活。
### Bug Fixes
- 修复 AI 助手消息推送中发送者身份不完整的问题。
### Performance
- 优化大文件下载方式,下载更稳定、更高效。
## [1.7.67]
### Features
- 聊天待办现在可以设置提醒时间,到点会引用原消息并提醒相关人员,避免遗漏重要事项。
- 团队管理支持管理员创建或批量导入员工账号,并可填写部门、职位等信息,添加成员更方便。
- 系统设置新增聊天待办权限控制,可限制其他人员设置或取消聊天待办。
### Bug Fixes
- 设置内容没有变化时不再重复保存,减少无效操作,让使用更稳定。
### Documentation
- 补充路由使用限制说明,帮助使用者更清楚地了解规则。
- 统一回复语言偏好说明,确保整段回复使用简体中文。
## [1.7.55]
### Features
- 新增部门负责人只读视角,可查看部门成员的项目和任务,并按可见性设置控制展示范围。
- 群组、项目和部门支持主负责人 + 副负责人,协作管理更灵活。
- 新增共享任务模板,支持跨项目使用、搜索和使用统计,复用常用任务更方便。
- 管理页侧边栏支持拖拽调整宽度,使用不同屏幕时更顺手。
- 优化任务添加界面,模板浏览和加载提示更清晰。
- 项目归档设置选择系统默认规则时,会显示对应提示,减少误操作。
- 聊天消息中的表格显示更稳定,单元格内容不再随意换行。
- 支持按需调整翻译使用的模型,便于适配不同使用场景。
### Bug Fixes
- 修复权限变更过程中可能出现的可见性或访问异常。
- 修复 AI 自动分析开关状态判断不准确的问题。
- 修复用户详情页在部分情况下出现横向滚动的问题。
- 优化应用发布流程,提升发布稳定性。
## [1.7.29]
### Features
- AI 助手聊天记录现在可自动保存,换设备或重新打开后也能继续查看历史对话。
### Bug Fixes
- 改善 AI 助手中长图的显示清晰度,减少图片被压缩后变模糊的问题。
- 修复部分企业账号环境下用户搜索失败、密码规则异常的问题。
## [1.7.23]
### Features
- 支持使用非邮箱形式的用户名登录,登录方式更灵活,也更适合接入常见的企业账号环境。
- 进一步优化与 Active Directory 的兼容性,企业用户接入和登录更顺畅。
### Bug Fixes
- 修复部分企业账号环境下的登录问题,提升账号验证的稳定性和成功率。
- 修复上传或发布失败时提示不明确的问题,方便更快发现并处理失败情况。
## [1.7.20]
### Bug Fixes
- 优化了 LDAP 登录方式,更好兼容 Active Directory企业账号登录更稳定。
## [1.7.14]
### Features
- 新增消息合并转发,支持批量选择后一次转发,分享聊天内容更方便。
- 现在可以按项目负责人筛选任务,查找和整理任务更省时。
- 支持解除任务关联,调整任务关系更灵活。
- 新增 AI 自动分析开关,可按需开启或关闭,使用起来更可控。
- 安装和修改设置时会自动检查应用编号与端口是否冲突,减少配置出错和无法启动的情况。
- 支持自定义 AI 服务地址,连接和接入方式更灵活。
### Bug Fixes
- 修复了 AI 助手在部分页面显示异常的问题,查看和使用时更稳定。
## [1.6.89]
### Features
- AI 助手支持拖放、粘贴上传图片,并可直接发送图片参与对话,交流更直观
- AI 任务建议支持多语言输出,跨语言使用更顺畅
- 工作流配置新增规则摘要展示,规则一眼看懂,减少来回查看
### Bug Fixes
- 修复工作流在切换、完成/取消完成任务时状态不同步的问题,避免状态错乱
- 修复 AI 建议触发条件与头像显示异常,展示更稳定、体验更一致
- 修复部分提示文案显示不正确的问题,信息更清晰
- 修复描述格式与负责人重复显示的问题,页面更整洁
- 修复点击 AI 相关链接时解析失败的问题,打开更可靠
- 修复因日期格式导致文件名被误处理而创建失败的问题,上传更稳定
- 修复网络连接异常时状态未正确更新的问题,避免卡住
- 修复延迟推送的已读检查偶发失效的问题,提醒更准确
## [1.6.51]
### AI 助手更新
- 新增全屏模式,支持拖动边缘调整聊天窗口大小
- 新增可拖拽浮动按钮,支持自动贴边收起
- 支持 ↑ / ↓ 快速切换历史输入,并可编辑后重新发送
- 按使用场景保存并恢复会话,切换不串内容
- 连续操作过程展示更清晰
- 新增更多提示词与随机推荐,获取灵感更方便
- 文件能力增强:支持读取文本内容与大文件分段读取
- 搜索能力提升:支持更灵活的匹配与关键词查找
- 新增 AI 协助部分前端操作
- 新建菜单优化,新增 AI 助手快捷入口
- 优化内容逐步输出时的加载提示显示
### 其他更新
- 修复弹窗、下拉菜单可能被遮挡的问题
- 优化快捷键响应与事件处理,操作更流畅稳定
- 优化其他已知问题
## [1.6.27]
### Features
- 新增全局悬浮AI入口随时更快打开常用功能
- 新增AI助手窗口模式并能根据当前页面自动更贴合你的使用场景
- 支持为不同场景设置AI自定义标题界面展示更清晰
- 同步失败时会自动重试,减少手动操作和中断
### Bug Fixes
- 修复下载功能偶尔无法启动的问题
- 修复深色模式下部分显示异常,并优化相关操作体验
- 修复「@ 提及」下拉列表偶尔被遮挡的问题
- 修复部分情况下连接判断不准确导致的问题
## [1.6.10]
### Features

131
CLAUDE.md
View File

@@ -1,116 +1,57 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
DooTask 是一套开源任务/项目管理系统,支持看板、任务、子任务、评论、对话、文件、报表等协作能力
- **后端**Laravel 8运行在 LaravelS/Swoole 常驻进程上
- **前端**Vue 2 + Vite
- **桌面端**Electron 壳,核心逻辑复用 Web 前端
Laravel 8 (LaravelS/Swoole) + Vue 2 (Vite) + Electron。开源任务/项目管理系统。
## 开发命令
所有命令通过 `./cmd` 脚本执行,确保与 Docker/容器环境一致
所有命令通过 `./cmd` 脚本执行(不要直接运行 `php artisan` 等)
```bash
# 服务管理
./cmd up # 启动容器
./cmd down # 停止容器
./cmd restart # 重启容器
./cmd reup # 重新构建并启动
- `./cmd artisan ...` / `./cmd composer ...` / `./cmd php ...` — PHP 相关命令
# 开发构建
./cmd dev # 启动前端开发服务器(需要 Node.js 20+
./cmd serve # dev 别名
./cmd prod # 构建前端生产版本
./cmd build # prod 别名
### AI 不要主动执行的命令
# Laravel/PHP
./cmd artisan ... # 运行 Laravel Artisan 命令
./cmd composer ... # 运行 Composer 命令
./cmd php ... # 运行 PHP 命令
以下命令仅由用户人工触发AI 不要主动跑——包括"任务完成后 sanity check"、"看下能不能编译"等场景:
# Electron
./cmd electron # 构建桌面应用
- `./cmd dev` — 用户已自行运行 dev server改完会自己 reloadAI 再跑会争抢进程
- `./cmd prod` / `./cmd build` — 发版才用,走 `/release` 流程
# 配置管理
./cmd port <端口> # 修改服务端口
./cmd url <地址> # 修改访问地址
./cmd env <键> <值> # 设置环境变量
./cmd debug [true|false] # 切换调试模式
前端代码改动只做 Edit/Write不要为了"验证"启动 dev server。用户明确说"跑一下 / 出包"时除外。
# 数据库
./cmd mysql backup # 备份数据库
./cmd mysql recovery # 恢复数据库
## Gotchas
# 其他
./cmd install # 一键安装
./cmd update # 升级项目
./cmd repassword # 重置管理员密码
./cmd doc # 生成 API 文档
./cmd https # 配置 HTTPS
```
### LaravelS/Swoole
## 代码架构
### 后端 (`app/`)
**Controller (`app/Http/Controllers/Api/`)**API 控制器,负责路由入口、参数校验、编排调用模型/模块、组装响应。保持控制器「薄」,业务异常通过 `App\Exceptions\ApiException` 抛出。
**Model (`app/Models/`)**Eloquent 模型,负责表结构映射、关系、访问器/修改器、查询 Scope。避免在模型中堆积复杂业务逻辑。
**Module (`app/Module/`)**:跨控制器/跨模型的业务逻辑与独立功能子域:
- 外部集成:`AgoraIO/``Manticore/`
- 通用工具:`Lock.php``TextExtractor.php``Image.php``AI.php`
- 复杂业务逻辑:`Base.php`(核心业务)、`Doo.php``Timer.php`
**Tasks (`app/Tasks/`)**Swoole 异步任务,用于后台处理:
- WebSocket 消息推送:`WebSocketDialogMsgTask.php``PushTask.php`
- 定时任务:`LoopTask.php``AutoArchivedTask.php`
- 搜索同步:`ManticoreSyncTask.php`
**Observers (`app/Observers/`)**Eloquent 观察者监听模型事件created/updated/deleted自动触发相关逻辑。
**Services (`app/Services/`)**:服务类,如 `WebSocketService.php``RequestContext.php`
### 前端 (`resources/assets/js/`)
```
├── app.js, App.vue # 应用入口与根组件
├── components/ # 通用与业务组件(看板、文件预览、聊天)
├── pages/ # 页面级组件(登录、项目、任务、消息、报表)
├── store/ # Vuex 状态管理
│ ├── state.js # 状态定义
│ ├── mutations.js # 同步修改
│ ├── actions.js # 异步操作(含 API 调用封装)
│ └── getters.js # 计算属性
├── routes.js # 前端路由
├── functions/ # 业务函数
├── utils/ # 工具函数
├── directives/ # Vue 自定义指令
├── mixins/ # Vue 混入
└── language/ # 国际化翻译
```
API 调用应使用 `store/actions.js` 中已有的封装,避免在组件中散落 axios/fetch。
### LaravelS/Swoole 注意事项
- **避免在静态属性、单例、全局变量中存储请求级状态**——防止请求间数据串联和内存泄漏
- **避免在静态属性、单例、全局变量中存储请求级状态**——请求间共享进程,会导致数据串联和内存泄漏
- 构造函数、服务提供者、`boot()` 方法不会在每个请求重新执行
- 配置/路由变更需要 `./cmd php restart` 或容器重启才能生效
- 长生命周期逻辑WebSocket、定时器应复用现有模式避免阻塞协程/事件循环
## 数据库
### 后端
- **非 REST 路由**API 控制器(继承 `InvokeController`)在 `routes/web.php` 按资源注册路由URL 段映射为控制器方法(如 `api/project/lists``lists()`,带 action 则用双下划线:`api/project/invite/join``invite__join()`
- 路由最多两段:方法名最多一个双下划线(`method__action`),不支持 `method__action__xxx`(无对应路由,访问 404
- **响应格式**:统一使用 `Base::retSuccess($msg, $data)` / `Base::retError($msg)`,返回 `{"ret": 1, "msg": "...", "data": {...}}`——不要用 `response()->json()`
- 业务异常通过 `App\Exceptions\ApiException` 抛出,不要用通用 Exception
- 模型继承 `AbstractModel`,使用 `Model::createInstance($params)` 创建——不要用 `new Model()``Model::create()`
- 认证使用 `Doo::userId()`——不要用 `auth()->user()`
- 参数校验在控制器方法中手动进行——不要创建 FormRequest 类
- 异步任务使用 Swoole Task`app/Tasks/`)——不要用 Laravel Queue
- `app/Module/` 存放跨控制器/跨模型的业务逻辑(非标准 Laravel 目录)
- 所有表结构变更必须通过 Laravel migration禁止直接改库
- 使用 Eloquent 模型访问数据库
## 前端弹窗文案
### 前端
调用 `$A.modalXXX``$A.messageXXX``$A.noticeXXX` 时,内部会自动处理 `$L` 翻译,调用方不要额外包 `$L`。仅当显式传入 `language: false` 时,才由调用方自行处理翻译。
- API 调用使用 `store.dispatch("call", params)`,不要在组件中直接 axios/fetch
- `$A.modalXXX``$A.messageXXX``$A.noticeXXX` 内部自动处理 `$L` 翻译,调用方不要额外包 `$L`。仅当传入 `language: false` 时由调用方自行处理翻译
### 国际化
- 新增用户可见文本须追加原文(简体中文)到:前端 `language/original-web.txt`,后端 `language/original-api.txt`(去重)
- 前端翻译用 `$L("文本")`,动态值用 `(*)` 占位:`$L('共(*)条', n)`——禁止拼接翻译
## Playwright 测试
- Playwright 测试结果放在 `tests/playwright-results/`,包含测试环境、测试用例、结果截图等信息
## 交互规范
@@ -118,8 +59,4 @@ API 调用应使用 `store/actions.js` 中已有的封装,避免在组件中
## 语言偏好
- 技术总结和关键结论优先使用简体中文,除非用户明确要求其他语言
## 扩展规则
详见 @.claude/rules/graphiti.md 了解 Graphiti 长期记忆集成。
- 回复一律使用简体中文,除非用户明确要求其他语言

View File

@@ -22,6 +22,7 @@ English | **[中文文档](./README_CN.md)**
- Required: `Docker v20.10+` and `Docker Compose v2.0+`
- Supported Systems: `CentOS/Debian/Ubuntu/macOS` and other Linux/Unix systems
- Hardware Recommendation: 2+ cores, 4GB+ memory
- Database: MariaDB (provided by the default Docker Compose `mariadb` service)
- Special Note: Windows users can install Linux environment using WSL2 before installing DooTask.
### Deploy Project
@@ -115,13 +116,15 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
After installing the new project, follow these steps to complete migration:
1、Backup original database
1、Backup the MariaDB database
```bash
# Run command in the old project
./cmd mysql backup
```
> `./cmd mysql` is the CLI subcommand name; backups run against the MariaDB container.
2、Copy the following files and directories from old project to the same paths in new project
- `Database backup file`

View File

@@ -22,6 +22,7 @@
- 必须安装:`Docker v20.10+``Docker Compose v2.0+`
- 支持环境:`Centos/Debian/Ubuntu/macOS` 等 linux/unix 系统
- 硬件建议2核4G以上
- 数据库MariaDB默认 Docker Compose 中的 `mariadb` 服务)
- 特别说明Windows 可以使用 WSL2 安装 Linux 环境后再安装 DooTask。
### 部署项目
@@ -115,13 +116,15 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
在新项目安装好之后按照以下步骤完成项目迁移:
1、备份数据库
1、备份 MariaDB 数据库
```bash
# 在旧的项目下执行指令
./cmd mysql backup
```
> `./cmd mysql` 为 CLI 子命令名称,实际操作的是 MariaDB 容器。
2、将旧项目以下文件和目录拷贝至新项目同路径位置
- `数据库备份文件`

View File

@@ -9,9 +9,9 @@
## 发布版本
> 翻译、版本号、更新日志改由 `dootask-release` 技能完成(见 `.claude/skills/dootask-release/`)。
```shell
npm run translate # 翻译(可选)
npm run version # 生成版本
npm run build # 编译前端
```

View File

@@ -0,0 +1,188 @@
<?php
namespace App\Console\Commands;
use App\Console\Commands\Traits\ManticoreSyncLock;
use App\Models\File;
use App\Models\ManticoreSyncFailure;
use App\Models\Project;
use App\Models\ProjectTask;
use App\Models\User;
use App\Models\WebSocketDialogMsg;
use App\Module\Apps;
use App\Module\Manticore\ManticoreBase;
use App\Module\Manticore\ManticoreFile;
use App\Module\Manticore\ManticoreMsg;
use App\Module\Manticore\ManticoreProject;
use App\Module\Manticore\ManticoreTask;
use App\Module\Manticore\ManticoreUser;
use Illuminate\Console\Command;
class RetryManticoreSync extends Command
{
use ManticoreSyncLock;
protected $signature = 'manticore:retry-failures {--limit=100 : 每次处理的最大数量} {--stats : 显示统计信息}';
protected $description = '重试 Manticore 同步失败的记录';
public function handle(): int
{
if (!Apps::isInstalled("search")) {
$this->error("应用「Manticore Search」未安装");
return 1;
}
// 显示统计信息
if ($this->option('stats')) {
$this->showStats();
return 0;
}
$this->registerSignalHandlers();
if (!$this->acquireLock()) {
return 1;
}
$this->info('开始重试失败的同步任务...');
$limit = intval($this->option('limit'));
$failures = ManticoreSyncFailure::getPendingRetries($limit);
if ($failures->isEmpty()) {
$this->info('无待重试的记录');
$this->releaseLock();
return 0;
}
$this->info("找到 {$failures->count()} 条待重试记录");
$successCount = 0;
$failCount = 0;
foreach ($failures as $failure) {
if ($this->shouldStop) {
$this->info('收到停止信号,退出处理');
break;
}
$this->setLock();
$result = $this->retryOne($failure);
if ($result) {
$successCount++;
$this->info(" [成功] {$failure->data_type}:{$failure->data_id} ({$failure->action})");
} else {
$failCount++;
$this->warn(" [失败] {$failure->data_type}:{$failure->data_id} ({$failure->action}) - 第 {$failure->retry_count}");
}
}
$this->info("\n重试完成: 成功 {$successCount}, 失败 {$failCount}");
$this->releaseLock();
return 0;
}
/**
* 重试单条失败记录
*/
private function retryOne(ManticoreSyncFailure $failure): bool
{
$type = $failure->data_type;
$id = $failure->data_id;
$action = $failure->action;
try {
if ($action === 'delete') {
// 删除操作直接调用通用删除方法
return ManticoreBase::deleteVector($type, $id);
}
// sync 操作需要根据类型获取模型并同步
return $this->retrySyncByType($type, $id);
} catch (\Throwable $e) {
// 记录失败(会自动更新重试次数和时间)
ManticoreSyncFailure::recordFailure($type, $id, $action, $e->getMessage());
return false;
}
}
/**
* 根据类型重试同步
*/
private function retrySyncByType(string $type, int $id): bool
{
switch ($type) {
case 'msg':
$model = WebSocketDialogMsg::find($id);
if (!$model) {
// 数据已删除,移除失败记录
ManticoreSyncFailure::removeSuccess($type, $id, 'sync');
return true;
}
return ManticoreMsg::sync($model);
case 'file':
$model = File::find($id);
if (!$model) {
ManticoreSyncFailure::removeSuccess($type, $id, 'sync');
return true;
}
return ManticoreFile::sync($model);
case 'task':
$model = ProjectTask::find($id);
if (!$model) {
ManticoreSyncFailure::removeSuccess($type, $id, 'sync');
return true;
}
return ManticoreTask::sync($model);
case 'project':
$model = Project::find($id);
if (!$model) {
ManticoreSyncFailure::removeSuccess($type, $id, 'sync');
return true;
}
return ManticoreProject::sync($model);
case 'user':
$model = User::find($id);
if (!$model) {
ManticoreSyncFailure::removeSuccess($type, $id, 'sync');
return true;
}
return ManticoreUser::sync($model);
default:
return false;
}
}
/**
* 显示统计信息
*/
private function showStats(): void
{
$stats = ManticoreSyncFailure::getStats();
$this->info('Manticore 同步失败统计:');
$this->info(" 总数: {$stats['total']}");
if (!empty($stats['by_type'])) {
$this->info(' 按类型:');
foreach ($stats['by_type'] as $type => $count) {
$this->info(" - {$type}: {$count}");
}
}
if (!empty($stats['by_action'])) {
$this->info(' 按操作:');
foreach ($stats['by_action'] as $action => $count) {
$this->info(" - {$action}: {$count}");
}
}
}
}

View File

@@ -348,6 +348,37 @@ class ApproveController extends AbstractController
return Base::retSuccess('已撤回', Base::arrayKeyToUnderline($task['data']));
}
/**
* @api {post} api/approve/process/delById 删除审批(流程实例)
*
* @apiDescription 需要token身份仅可删除已结束的审批且仅发起人或管理员可删
* @apiVersion 1.0.0
* @apiGroup approve
* @apiName process__delById
*
* @apiQuery {Number} proc_inst_id 流程实例ID
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function process__delById()
{
$user = User::auth();
$data['userid'] = (string)$user->userid;
$data['proc_inst_id'] = intval(Request::input('proc_inst_id'));
$data['is_admin'] = $user->isAdmin();
if ($data['proc_inst_id'] <= 0) {
return Base::retError('参数错误');
}
$ret = Ihttp::ihttp_post($this->flow_url . '/api/v1/workflow/process/delById', json_encode(Base::arrayKeyToCamel($data)));
$task = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true);
if (!$task || $task['status'] != 200) {
return Base::retError($task['message'] ?? '删除失败');
}
return Base::retSuccess('已删除');
}
/**
* @api {post} api/approve/process/findTask 查询需要我审批的流程(审批中)
*

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api;
use App\Models\AiAssistantSession;
use App\Models\User;
use App\Module\AI;
use App\Module\Apps;
@@ -70,4 +71,237 @@ class AssistantController extends AbstractController
return Base::retSuccess('success', $setting ?: json_decode('{}'));
}
/**
* @api {post} api/assistant/match-elements 元素向量匹配
*
* @apiDescription 通过向量相似度匹配页面元素,用于智能查找与查询语义相关的元素
* @apiVersion 1.0.0
* @apiGroup assistant
* @apiName match_elements
*
* @apiParam {String} query 搜索关键词
* @apiParam {Array} elements 元素列表,每个元素包含 ref 和 name 字段
* @apiParam {Number} [top_k=10] 返回的匹配数量最大50
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* @apiSuccess {Array} data.matches 匹配结果数组,按相似度降序排列
*/
public function match_elements()
{
User::auth();
$query = trim(Request::input('query', ''));
$elements = Request::input('elements', []);
$topK = min(intval(Request::input('top_k', 10)), 50);
if (empty($query) || empty($elements)) {
return Base::retError('参数不能为空');
}
// 获取查询向量
$queryResult = AI::getEmbedding($query);
if (Base::isError($queryResult)) {
return $queryResult;
}
$queryVector = $queryResult['data'];
// 计算相似度并排序
$scored = [];
foreach ($elements as $el) {
$name = $el['name'] ?? '';
if (empty($name)) {
continue;
}
$elResult = AI::getEmbedding($name);
if (Base::isError($elResult)) {
continue;
}
$similarity = $this->cosineSimilarity($queryVector, $elResult['data']);
$scored[] = [
'element' => $el,
'similarity' => $similarity,
];
}
// 按相似度降序排序
usort($scored, fn($a, $b) => $b['similarity'] <=> $a['similarity']);
return Base::retSuccess('success', [
'matches' => array_slice($scored, 0, $topK),
]);
}
/**
* 计算两个向量的余弦相似度
*/
private function cosineSimilarity(array $a, array $b): float
{
$dotProduct = 0;
$normA = 0;
$normB = 0;
$count = count($a);
for ($i = 0; $i < $count; $i++) {
$dotProduct += $a[$i] * $b[$i];
$normA += $a[$i] * $a[$i];
$normB += $b[$i] * $b[$i];
}
$denominator = sqrt($normA) * sqrt($normB);
if ($denominator == 0) {
return 0;
}
return $dotProduct / $denominator;
}
/**
* 获取会话列表
*/
public function session__list()
{
$user = User::auth();
$sessionKey = trim(Request::input('session_key', 'default'));
$sessions = AiAssistantSession::where('userid', $user->userid)
->where('session_key', $sessionKey)
->orderByDesc('updated_at')
->get();
$list = [];
foreach ($sessions as $session) {
$data = Base::json2array($session->data);
$images = Base::json2array($session->images);
foreach ($images as $imageId => $path) {
$images[$imageId] = Base::fillUrl($path);
}
$list[] = [
'id' => $session->session_id,
'title' => $session->title,
'responses' => $data,
'images' => $images,
'sceneKey' => $session->scene_key,
'createdAt' => $session->created_at ? $session->created_at->getTimestampMs() : 0,
'updatedAt' => $session->updated_at ? $session->updated_at->getTimestampMs() : 0,
];
}
return Base::retSuccess('success', $list);
}
/**
* 保存会话
*/
public function session__save()
{
$user = User::auth();
$sessionKey = trim(Request::input('session_key', 'default'));
$sessionId = trim(Request::input('session_id', ''));
$sceneKey = trim(Request::input('scene_key', ''));
$title = trim(Request::input('title', ''));
$data = Request::input('data', []);
$newImages = Request::input('new_images', []);
if (empty($sessionId)) {
return Base::retError('session_id 不能为空');
}
$newImageUrls = [];
if (is_array($newImages)) {
$path = 'uploads/assistant/' . date('Ym') . '/' . $user->userid . '/';
foreach ($newImages as $img) {
$imageId = $img['imageId'] ?? '';
$dataUrl = $img['dataUrl'] ?? '';
if (empty($imageId) || empty($dataUrl)) {
continue;
}
$result = Base::image64save([
'image64' => $dataUrl,
'path' => $path,
'autoThumb' => false,
]);
if (Base::isSuccess($result)) {
$newImageUrls[$imageId] = $result['data']['path'];
}
}
}
$session = AiAssistantSession::where('userid', $user->userid)
->where('session_key', $sessionKey)
->where('session_id', $sessionId)
->first();
$imageMap = $newImageUrls;
if ($session) {
$existingImages = Base::json2array($session->images);
$imageMap = array_merge($existingImages, $newImageUrls);
}
$session = AiAssistantSession::createInstance([
'userid' => $user->userid,
'session_key' => $sessionKey,
'session_id' => $sessionId,
'scene_key' => $sceneKey,
'title' => mb_substr($title, 0, 255),
'data' => Base::array2json(is_array($data) ? $data : []),
'images' => Base::array2json($imageMap),
], $session?->id);
$session->save();
// 仅返回本次新增的图片URL
$urls = [];
foreach ($newImageUrls as $imageId => $path) {
$urls[$imageId] = Base::fillUrl($path);
}
return Base::retSuccess('success', [
'image_urls' => $urls,
]);
}
/**
* 删除会话
*/
public function session__delete()
{
$user = User::auth();
$sessionKey = trim(Request::input('session_key', 'default'));
$sessionId = trim(Request::input('session_id', ''));
$clearAll = Request::input('clear_all', false);
$query = AiAssistantSession::where('userid', $user->userid)
->where('session_key', $sessionKey);
if ($clearAll) {
$sessions = $query->get();
foreach ($sessions as $session) {
$this->deleteSessionImages($session);
}
$query->delete();
} else {
if (empty($sessionId)) {
return Base::retError('session_id 不能为空');
}
$session = $query->where('session_id', $sessionId)->first();
if ($session) {
$this->deleteSessionImages($session);
$session->delete();
}
}
return Base::retSuccess('success');
}
private function deleteSessionImages(AiAssistantSession $session)
{
$images = Base::json2array($session->images);
foreach ($images as $path) {
$fullPath = public_path($path);
if (file_exists($fullPath)) {
@unlink($fullPath);
}
}
}
}

View File

@@ -21,6 +21,9 @@ use App\Module\TimeRange;
use App\Module\MsgTool;
use App\Models\FileContent;
use App\Models\ProjectTask;
use App\Models\ProjectTaskUser;
use App\Models\ProjectTaskVisibilityUser;
use App\Models\ProjectUser;
use App\Models\AbstractModel;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogMsg;
@@ -1667,6 +1670,7 @@ class DialogController extends AbstractController
if (!in_array($botType, [
'system-msg',
'task-alert',
'todo-alert',
'check-in',
'approval-alert',
'meeting-alert',
@@ -1696,6 +1700,116 @@ class DialogController extends AbstractController
return WebSocketDialogMsg::sendMsg(null, $dialog->id, 'text', $msgData, $botUser->userid, false, false, $silence);
}
/**
* @api {post} api/dialog/msg/send_ai_assistant 以AI助手身份发送消息到对话
*
* @apiDescription 需要token身份以AI助手身份(userid=-1)发送消息到对话。支持两种方式:
* 1. 通过 dialog_id 直接发送到指定对话
* 2. 通过 task_id 发送到任务对话(自动创建对话如不存在)
* 两个参数至少提供一个,同时提供时优先使用 dialog_id
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName msg__send_ai_assistant
*
* @apiParam {Number} [dialog_id] 对话ID与task_id二选一
* @apiParam {Number} [task_id] 任务ID与dialog_id二选一自动创建对话
* @apiParam {String} text 消息内容
* @apiParam {String} [text_type=md] 消息格式md 或 html
* @apiParam {String} [silence=no] 是否静默发送yes/no
* @apiParam {String} [nickname] 自定义发送者昵称最多20字留空则显示"AI 助手"
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function msg__send_ai_assistant()
{
$user = User::auth();
//
$dialog_id = intval(Request::input('dialog_id'));
$task_id = intval(Request::input('task_id'));
$text = trim(Request::input('text'));
$text_type = strtolower(trim(Request::input('text_type'))) ?: 'md';
$silence = in_array(strtolower(trim(Request::input('silence'))), ['yes', 'true', '1']);
$nickname = trim(Request::input('nickname'));
$markdown = in_array($text_type, ['md', 'markdown']);
//
if (empty($dialog_id) && empty($task_id)) {
return Base::retError('dialog_id 或 task_id 至少提供一个');
}
if (empty($text)) {
return Base::retError('消息内容不能为空');
}
if (mb_strlen($text) > 200000) {
return Base::retError('消息内容最大不能超过200000字');
}
if (mb_strlen($nickname) > 20) {
return Base::retError('发送者昵称最多不能超过20字');
}
//
if ($dialog_id) {
// Direct dialog mode: verify user is a member
WebSocketDialog::checkDialog($dialog_id);
} else {
// Task mode: resolve task -> dialog_id (auto-create if needed)
$task = ProjectTask::find($task_id);
if (!$task) {
return Base::retError('任务不存在');
}
if (!ProjectUser::whereProjectId($task->project_id)->whereUserid($user->userid)->exists()) {
return Base::retError('没有权限操作此任务');
}
// 任务可见性校验(与 task__one 一致)
if ($task->visibility != 1) {
$projectOwnerids = ProjectUser::whereProjectId($task->project_id)
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
->pluck('userid')->map(fn($v) => (int)$v)->toArray();
if (!in_array($user->userid, $projectOwnerids)) {
$visibleUserids = array_merge(
ProjectTaskUser::whereTaskId($task_id)->pluck('userid')->toArray(),
ProjectTaskUser::whereTaskPid($task_id)->pluck('userid')->toArray(),
ProjectTaskVisibilityUser::whereTaskId($task_id)->pluck('userid')->toArray()
);
if (!in_array($user->userid, $visibleUserids)) {
return Base::retError('没有权限操作此任务');
}
}
}
if (!$task->dialog_id) {
$dialog = WebSocketDialog::createGroup($task->name, $task->relationUserids(), 'task');
if ($dialog) {
$task->dialog_id = $dialog->id;
$task->save();
$task->pushMsg('dialog');
} else {
return Base::retError('无法创建任务对话');
}
}
$dialog_id = $task->dialog_id;
}
//
$msgData = ['text' => $text];
if ($markdown) {
$msgData['type'] = 'md';
}
if ($nickname !== '') {
$msgData['nickname'] = $nickname;
}
//
$result = WebSocketDialogMsg::sendMsg(
null,
$dialog_id,
'text',
$msgData,
\App\Module\AiTaskSuggestion::AI_ASSISTANT_USERID,
true, // push_self
false, // push_retry
$silence
);
//
return $result;
}
/**
* @api {post} api/dialog/msg/sendlocation 发送位置消息
*
@@ -2208,6 +2322,7 @@ class DialogController extends AbstractController
{
$user = User::auth();
//
$msg_ids = Request::input('msg_ids');
$msg_id = intval(Request::input("msg_id"));
$dialogids = Request::input('dialogids');
$userids = Request::input('userids');
@@ -2218,6 +2333,33 @@ class DialogController extends AbstractController
return Base::retError("请选择对话或成员");
}
//
// 支持批量逐条转发
if (!empty($msg_ids) && is_array($msg_ids)) {
if (count($msg_ids) > 100) {
return Base::retError("最多转发100条消息");
}
$allMsgs = [];
$msgs = WebSocketDialogMsg::whereIn('id', $msg_ids)->orderBy('created_at')->get();
if ($msgs->isEmpty()) {
return Base::retError("消息不存在或已被删除");
}
WebSocketDialog::checkDialog($msgs->first()->dialog_id);
foreach ($msgs as $msg) {
if (in_array($msg->type, WebSocketDialogMsg::$unforwardableTypes)) {
continue;
}
$res = $msg->forwardMsg($dialogids, $userids, $user, $show_source, $leave_message);
if (Base::isSuccess($res)) {
$allMsgs = array_merge($allMsgs, $res['data']['msgs']);
}
// 留言只在第一条时发送,后续不再重复
$leave_message = '';
}
return Base::retSuccess('转发成功', [
'msgs' => $allMsgs
]);
}
//
$msg = WebSocketDialogMsg::whereId($msg_id)->first();
if (empty($msg)) {
return Base::retError("消息不存在或已被删除");
@@ -2227,6 +2369,98 @@ class DialogController extends AbstractController
return $msg->forwardMsg($dialogids, $userids, $user, $show_source, $leave_message);
}
/**
* @api {get} api/dialog/msg/mergeforward 合并转发消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName msg__mergeforward
*
* @apiParam {Array} msg_ids 消息ID数组最多100条
* @apiParam {Array} dialogids 转发给的对话ID
* @apiParam {Array} userids 转发给的成员ID
* @apiParam {Number} show_source 是否显示原发送者信息
* @apiParam {String} leave_message 转发留言
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function msg__mergeforward()
{
$user = User::auth();
//
$msg_ids = Request::input('msg_ids');
$dialogids = Request::input('dialogids');
$userids = Request::input('userids');
$show_source = intval(Request::input("show_source"));
$leave_message = Request::input('leave_message');
//
if (empty($dialogids) && empty($userids)) {
return Base::retError("请选择对话或成员");
}
if (empty($msg_ids) || !is_array($msg_ids)) {
return Base::retError("请选择要转发的消息");
}
if (count($msg_ids) > 100) {
return Base::retError("最多转发100条消息");
}
//
return WebSocketDialogMsg::mergeForwardMsg($msg_ids, $dialogids, $userids, $user, $show_source, $leave_message);
}
/**
* @api {get} api/dialog/msg/mergedetail 合并转发消息详情
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName msg__mergedetail
*
* @apiParam {Number} msg_id 合并转发消息ID
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function msg__mergedetail()
{
User::auth();
//
$msg_id = intval(Request::input('msg_id'));
if ($msg_id <= 0) {
return Base::retError('参数错误');
}
$dialogMsg = WebSocketDialogMsg::find($msg_id);
if (!$dialogMsg || $dialogMsg->type !== 'merge-forward') {
return Base::retError('消息不存在或已被删除');
}
WebSocketDialog::checkDialog($dialogMsg->dialog_id);
//
$msgData = Base::json2array($dialogMsg->getRawOriginal('msg'));
$msgIds = $msgData['msg_ids'] ?? [];
if (empty($msgIds)) {
return Base::retError('消息不存在或已被删除');
}
$msgs = WebSocketDialogMsg::withTrashed()
->whereIn('id', $msgIds)
->orderBy('created_at')
->get()
->map(function ($msg) {
return [
'id' => $msg->id,
'userid' => $msg->userid,
'type' => $msg->type,
'msg' => $msg->msg,
'created_at' => $msg->created_at->toDateTimeString(),
];
});
return Base::retSuccess('success', [
'msgs' => $msgs,
]);
}
/**
* @api {get} api/dialog/msg/emoji emoji回复
*
@@ -2346,7 +2580,8 @@ class DialogController extends AbstractController
} else {
$userids = is_array($userids) ? $userids : [];
}
return $msg->toggleTodoMsg($user->userid, $userids);
$remindAt = Request::exists('remind_at') ? (trim(Request::input('remind_at', '')) ?: null) : false;
return $msg->toggleTodoMsg($user->userid, $userids, $remindAt);
}
/**
@@ -2379,6 +2614,64 @@ class DialogController extends AbstractController
return Base::retSuccess('success', $todo ?: []);
}
/**
* @api {post} api/dialog/msg/todoremind 设置/修改/取消待办提醒时间
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName msg__todoremind
*
* @apiParam {Number} msg_id 消息ID
* @apiParam {Array} userids 目标成员ID组
* @apiParam {String} remind_at 提醒时间(空表示取消提醒)
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function msg__todoremind()
{
$user = User::auth();
//
$msg_id = intval(Request::input("msg_id"));
$userids = Request::input('userids');
$userids = is_array($userids) ? array_values(array_filter(array_map('intval', $userids))) : [];
$remindAt = trim(Request::input('remind_at', '')) ?: null;
//
$msg = WebSocketDialogMsg::whereId($msg_id)->first();
if (empty($msg)) {
return Base::retError("消息不存在或已被删除");
}
if (in_array($msg->type, ['tag', 'todo', 'notice'])) {
return Base::retError('此消息不支持设待办');
}
$dialog = WebSocketDialog::checkDialog($msg->dialog_id);
//
if (empty($userids)) {
return Base::retError("请选择成员");
}
// 权限管控(与设/取消待办同一开关与放行规则)
if (Base::settingFind('system', 'todo_set_permission') === 'close') {
$others = array_diff($userids, [$user->userid]);
if ($others && !$dialog->checkTodoOwnerPermission($user->userid)) {
return Base::retError('仅群主、项目/任务负责人或系统管理员可设置或取消他人待办');
}
}
//
$msg->setTodoRemind($userids, $remindAt);
//
$upData = [
'id' => $msg->id,
'todo' => $msg->todo,
'todo_done' => $msg->isTodoDone(true),
'dialog_id' => $msg->dialog_id,
];
$dialog->pushMsg('update', $upData);
//
return Base::retSuccess($remindAt ? '设置成功' : '取消成功', $upData);
}
/**
* @api {get} api/dialog/msg/done 完成待办
*
@@ -2609,7 +2902,10 @@ class DialogController extends AbstractController
return Base::retError('对话不存在或已被删除', ['dialog_id' => $dialog_id], -4003);
}
} else {
$dialog = WebSocketDialog::checkDialog($dialog_id, true);
$dialog = WebSocketDialog::checkDialog($dialog_id);
if (!$dialog->isOwner(User::userid())) {
throw new \App\Exceptions\ApiException('仅群主或群管理员可操作');
}
}
//
$data = ['id' => $dialog->id];
@@ -2620,7 +2916,9 @@ class DialogController extends AbstractController
$data['avatar'] = Base::fillUrl($array['avatar'] = $avatar);
}
$existName = Request::exists('chat_name') || Request::exists('name');
if ($existName && $dialog->group_type === 'user') {
// 个人群组群主可改名;全员群仅系统管理员可改名
$canEditName = $dialog->group_type === 'user' || ($dialog->group_type === 'all' && $admin === 1);
if ($existName && $canEditName) {
$chatName = trim(Request::input('chat_name') ?: Request::input('name'));
if (mb_strlen($chatName) < 2) {
return Base::retError('群名称至少2个字');
@@ -2668,7 +2966,11 @@ class DialogController extends AbstractController
return Base::retError('请选择群成员');
}
//
$dialog = WebSocketDialog::checkDialog($dialog_id, "auto");
$dialog = WebSocketDialog::checkDialog($dialog_id);
// 有群主时,仅群主/群管理员可邀请;无群主时,任意成员可邀请
if ($dialog->owner_id > 0 && !$dialog->isOwner($user->userid)) {
throw new \App\Exceptions\ApiException('仅限群主或群管理员操作');
}
//
$dialog->checkGroup();
$dialog->joinGroup($userids, $user->userid);
@@ -2758,17 +3060,107 @@ class DialogController extends AbstractController
$dialog = WebSocketDialog::checkDialog($dialog_id, $check_owner);
//
$dialog->checkGroup($check_owner ? 'user' : null);
$oldOwnerId = (int)$dialog->owner_id;
$dialog->owner_id = $userid;
if ($dialog->save()) {
$dialog->joinGroup($userid, 0);
// 同步 role原主 role=0、新主 role=1覆盖即可
if ($oldOwnerId > 0 && $oldOwnerId !== (int)$userid) {
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $oldOwnerId)
->update(['role' => 0]);
}
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $userid)
->update(['role' => 1]);
$dialog->pushMsg("groupUpdate", [
'id' => $dialog->id,
'owner_id' => $dialog->owner_id,
'deputy_ids' => $dialog->deputy_ids,
]);
}
return Base::retSuccess('转让成功');
}
/**
* 任命群管理员(仅群主可操作)
*
* @apiParam {Number} dialog_id 群对话ID
* @apiParam {Number} userid 要任命的群成员 userid
*/
public function group__adddeputy()
{
$user = User::auth();
$dialog_id = intval(Request::input('dialog_id'));
$userid = intval(Request::input('userid'));
if ($userid <= 0) {
return Base::retError('请选择有效的成员');
}
$dialog = WebSocketDialog::checkDialog($dialog_id, true); // checkOwner=true仅群主
$dialog->checkGroup('user'); // 仅普通群
$member = WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $userid)
->first();
if (empty($member)) {
return Base::retError('该用户不是群成员');
}
if ((int)$member->role === 1) {
return Base::retError('不能将群主任命为群管理员');
}
if ((int)$member->role !== 2) {
$member->role = 2;
$member->save();
$dialog->pushMsg('groupUpdate', [
'id' => $dialog->id,
'deputy_ids' => $dialog->fresh()->deputy_ids,
]);
}
return Base::retSuccess('任命成功');
}
/**
* 罢免群管理员(仅群主可操作)
*
* @apiParam {Number} dialog_id 群对话ID
* @apiParam {Number} userid 要罢免的群管理员 userid
*/
public function group__deldeputy()
{
$user = User::auth();
$dialog_id = intval(Request::input('dialog_id'));
$userid = intval(Request::input('userid'));
if ($userid <= 0) {
return Base::retError('请选择有效的成员');
}
$dialog = WebSocketDialog::checkDialog($dialog_id, true);
$dialog->checkGroup('user');
$member = WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $userid)
->first();
if (empty($member)) {
return Base::retSuccess('罢免成功'); // 幂等:本来就不是成员
}
if ((int)$member->role === 2) {
$member->role = 0;
$member->save();
$dialog->pushMsg('groupUpdate', [
'id' => $dialog->id,
'deputy_ids' => $dialog->fresh()->deputy_ids,
]);
}
return Base::retSuccess('罢免成功');
}
/**
* @api {get} api/dialog/group/disband 解散群组
*

View File

@@ -17,6 +17,7 @@ use App\Module\Down;
use App\Module\Lock;
use App\Module\Timer;
use App\Module\Ihttp;
use App\Module\Manticore\ManticoreFile;
use Response;
use Swoole\Coroutine;
use Carbon\Carbon;
@@ -68,6 +69,11 @@ class FileController extends AbstractController
* @apiParam {String} [with_url] 是否返回文件访问URL
* - no: 不返回(默认)
* - yes: 返回content_url字段
* @apiParam {String} [with_text] 是否提取文件文本内容用于AI阅读支持分页
* - no: 不提取(默认)
* - yes: 提取文本内容,支持 docx/xlsx/pptx/pdf/txt 等格式
* @apiParam {Number} [text_offset] with_text=yes时有效文本起始位置字符数默认0
* @apiParam {Number} [text_limit] with_text=yes时有效文本获取长度字符数默认50000最大200000
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@@ -77,6 +83,9 @@ class FileController extends AbstractController
{
$id = Request::input('id');
$with_url = Request::input('with_url', 'no');
$with_text = Request::input('with_text', 'no');
$text_offset = intval(Request::input('text_offset', 0));
$text_limit = intval(Request::input('text_limit', 50000));
//
$permission = 0;
if (Base::isNumber($id)) {
@@ -112,9 +121,57 @@ class FileController extends AbstractController
$array['content_url'] = FileContent::getFileUrl($file->id);
}
// 如果请求提取文本内容
if ($with_text === 'yes') {
$array['text_content'] = ManticoreFile::extractFileContentPaginated($file, $text_offset, $text_limit);
}
return Base::retSuccess('success', $array);
}
/**
* @api {get} api/file/fetch 通过路径获取文件文本内容
*
* @apiDescription 用于 MCP/AI 工具通过文件路径获取内容,支持分页获取大文件
* @apiVersion 1.0.0
* @apiGroup file
* @apiName fetch
*
* @apiParam {String} path 文件路径(相对于系统根目录,如 uploads/file/...
* @apiParam {Number} [offset] 起始位置字符数默认0
* @apiParam {Number} [limit] 获取长度字符数默认50000最大200000
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* - content: 文本内容
* - total_length: 完整内容总长度
* - offset: 当前起始位置
* - limit: 本次获取长度
* - has_more: 是否还有更多内容
*/
public function fetch()
{
User::auth();
//
$path = trim(Request::input('path'));
$offset = intval(Request::input('offset', 0));
$limit = intval(Request::input('limit', 50000));
if (empty($path)) {
return Base::retError('参数错误path 不能为空');
}
// 直接传入路径ManticoreFile 内部处理 URL 解析
$result = ManticoreFile::extractFileContentPaginated($path, $offset, $limit);
if (isset($result['error'])) {
return Base::retError($result['error']);
}
return Base::retSuccess('success', $result);
}
/**
* @api {get} api/file/search 搜索文件列表
*

File diff suppressed because it is too large Load Diff

View File

@@ -69,6 +69,8 @@ class SystemController extends AbstractController
'login_code',
'password_policy',
'project_invite',
'project_add_permission',
'project_add_userids',
'chat_information',
'anon_message',
'convert_video',
@@ -93,6 +95,9 @@ class SystemController extends AbstractController
'file_upload_limit',
'unclaimed_task_reminder',
'unclaimed_task_reminder_time',
'task_ai_auto_analyze',
'department_owner_project_view',
'todo_set_permission',
])) {
unset($all[$key]);
}
@@ -140,14 +145,21 @@ class SystemController extends AbstractController
$setting['archived_day'] = floatval($setting['archived_day']) ?: 7;
$setting['task_visible'] = $setting['task_visible'] ?: 'close';
$setting['all_group_mute'] = $setting['all_group_mute'] ?: 'open';
$setting['todo_set_permission'] = $setting['todo_set_permission'] ?: 'open';
$setting['all_group_autoin'] = $setting['all_group_autoin'] ?: 'yes';
$setting['user_private_chat_mute'] = $setting['user_private_chat_mute'] ?: 'open';
$setting['user_group_chat_mute'] = $setting['user_group_chat_mute'] ?: 'open';
$setting['file_upload_limit'] = $setting['file_upload_limit'] ?: '';
$setting['unclaimed_task_reminder'] = $setting['unclaimed_task_reminder'] ?: 'close';
$setting['unclaimed_task_reminder_time'] = $setting['unclaimed_task_reminder_time'] ?: '';
$setting['task_ai_auto_analyze'] = $setting['task_ai_auto_analyze'] ?: 'open';
$setting['department_owner_project_view'] = $setting['department_owner_project_view'] ?: 'close';
$setting['server_timezone'] = config('app.timezone');
$setting['server_version'] = Base::getVersion();
// 指定人员名单仅管理员可见
if ($type != 'all' && $type != 'save') {
unset($setting['project_add_userids']);
}
//
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
}
@@ -610,6 +622,7 @@ class SystemController extends AbstractController
'ldap_password',
'ldap_user_dn',
'ldap_base_dn',
'ldap_login_attr',
'ldap_sync_local'
])) {
unset($all[$key]);
@@ -623,6 +636,7 @@ class SystemController extends AbstractController
//
$setting['ldap_open'] = $setting['ldap_open'] ?: 'close';
$setting['ldap_port'] = intval($setting['ldap_port']) ?: 389;
$setting['ldap_login_attr'] = $setting['ldap_login_attr'] ?: 'cn';
$setting['ldap_sync_local'] = $setting['ldap_sync_local'] ?: 'close';
//
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));

View File

@@ -41,6 +41,9 @@ use Illuminate\Support\Facades\DB;
use App\Models\UserEmailVerification;
use App\Module\AgoraIO\AgoraTokenGenerator;
use Swoole\Coroutine;
use App\Module\UserImport;
use App\Module\UserImportTemplate;
use Maatwebsite\Excel\Facades\Excel;
/**
* @apiDefine users
@@ -300,6 +303,8 @@ class UsersController extends AbstractController
* @apiGroup users
* @apiName token__expire
*
* @apiParam {Number} [refresh] 是否刷新 token1=是token 剩余有效期不足总有效期的 1/3 时才会刷新
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
@@ -307,10 +312,11 @@ class UsersController extends AbstractController
* @apiSuccess {Number|null} data.remaining_seconds 距离过期剩余秒数(负值表示已过期)
* @apiSuccess {Boolean} data.expired token 是否已过期
* @apiSuccess {String} data.server_time 当前服务器时间
* @apiSuccess {String} [data.token] 刷新后的新 token仅当 refresh=1 且 token 即将过期时返回)
*/
public function token__expire()
{
User::auth();
$user = User::auth();
$expiredAt = Doo::userExpiredAt();
$expired = Doo::userExpired();
$expiredAtCarbon = $expiredAt ? Carbon::parse($expiredAt) : null;
@@ -320,6 +326,14 @@ class UsersController extends AbstractController
'expired' => $expired,
'server_time' => Carbon::now()->toDateTimeString(),
];
// 请求刷新 token剩余有效期不足总有效期的 1/3 时才刷新
if (Request::input('refresh') && $expiredAtCarbon) {
$tokenValidDays = max(1, intval(Base::settingFind('system', 'token_valid_days', 30)));
$refreshThresholdDays = ceil($tokenValidDays / 3);
if ($expiredAtCarbon->isBefore(Carbon::now()->addDays($refreshThresholdDays))) {
$data['token'] = User::generateToken($user, true);
}
}
return Base::retSuccess('success', $data);
}
@@ -377,10 +391,14 @@ class UsersController extends AbstractController
//
$refreshToken = false;
if (in_array(Base::platform(), ['ios', 'android'])) {
// 移动端token还剩7天到期时获取新的token
// 移动端token剩余有效期不足总有效期的1/3时获取新的token
$expiredAt = Doo::userExpiredAt();
if ($expiredAt && Carbon::parse($expiredAt)->isBefore(Carbon::now()->addDays(7))) {
$refreshToken = true;
if ($expiredAt) {
$tokenValidDays = max(1, intval(Base::settingFind('system', 'token_valid_days', 30)));
$refreshThresholdDays = ceil($tokenValidDays / 3);
if (Carbon::parse($expiredAt)->isBefore(Carbon::now()->addDays($refreshThresholdDays))) {
$refreshToken = true;
}
}
}
User::generateToken($user, $refreshToken);
@@ -389,9 +407,22 @@ class UsersController extends AbstractController
$data['nickname_original'] = $user->getRawOriginal('nickname');
$data['department_name'] = $user->getDepartmentName();
$data['department_owner'] = UserDepartment::where('parent_id',0)->where('owner_userid', $user->userid)->exists(); // 适用默认部门下第1级负责人才能添加部门OKR
$data['managed_departments'] = UserDepartment::getManagedDepartments($user->userid)->toArray();
return Base::retSuccess('success', $data);
}
/**
* @api {get} api/users/info/managed_departments 获取我可切换负责人视角的部门列表
*/
public function info__managed_departments()
{
$user = User::auth();
if (Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') {
return Base::retSuccess('success', []);
}
return Base::retSuccess('success', UserDepartment::getManagedDepartments($user->userid));
}
/**
* @api {get} api/users/info/departments 获取我的部门列表
*
@@ -853,7 +884,8 @@ class UsersController extends AbstractController
*/
public function extra()
{
$user = User::auth();
$viewer = User::auth();
$user = $viewer;
//
$userid = intval(Request::input('userid'));
if ($userid <= 0) {
@@ -888,6 +920,8 @@ class UsersController extends AbstractController
$tagMeta = UserTag::listWithMeta($userid, $user);
$worksContext = UserDepartment::userWorksContext($viewer, $userid);
$data = [
'userid' => $userid,
'birthday' => $birthday,
@@ -895,6 +929,7 @@ class UsersController extends AbstractController
'introduction' => $introduction,
'personal_tags' => $tagMeta['top'],
'personal_tags_total' => $tagMeta['total'],
'works_visible' => $worksContext['allowed'],
];
return Base::retSuccess('success', $data);
@@ -1063,6 +1098,8 @@ class UsersController extends AbstractController
* - clearadmin 取消管理员
* - settemp 设为临时帐号
* - cleartemp 取消临时身份(取消临时帐号)
* - setverity 标记邮箱为已认证
* - clearverity 标记邮箱为未认证
* - checkin_macs 修改自动签到mac地址需要参数 checkin_macs
* - checkin_face 修改签到人脸图片(需要参数 checkin_face
* - department 修改部门(需要参数 department
@@ -1126,6 +1163,16 @@ class UsersController extends AbstractController
$upArray['identity'] = array_diff($userInfo->identity, ['temp']);
break;
case 'setverity':
$msg = '设置成功';
$upArray['email_verity'] = 1;
break;
case 'clearverity':
$msg = '取消成功';
$upArray['email_verity'] = 0;
break;
case 'checkin_macs':
$list = is_array($data['checkin_macs']) ? $data['checkin_macs'] : [];
$array = [];
@@ -1235,7 +1282,7 @@ class UsersController extends AbstractController
User::passwordPolicy($password);
$upArray['encrypt'] = Base::generatePassword(6);
$upArray['password'] = Doo::md5s($password, $upArray['encrypt']);
$upArray['changepass'] = 1;
$upArray['changepass'] = intval($data['changepass'] ?? 1) === 1 ? 1 : 0;
$upLdap['userPassword'] = $password;
}
// 昵称
@@ -1312,6 +1359,101 @@ class UsersController extends AbstractController
return Base::retSuccess($msg, $userInfo);
}
/**
* @api {post} api/users/createuser 创建用户(管理员)
*
* @apiDescription 需要token身份管理员
* @apiVersion 1.0.0
* @apiGroup users
* @apiName createuser
*
* @apiParam {String} email 邮箱
* @apiParam {String} password 初始密码
* @apiParam {String} nickname 昵称
* @apiParam {Number} [email_verity] 是否标记邮箱为已认证1是、0否默认1
* @apiParam {String} [profession] 职位/职称可选2-20字
* @apiParam {Array} [department] 部门ID列表可选最多10个
*/
public function createuser()
{
User::auth('admin');
$email = trim(Request::input('email'));
$password = trim(Request::input('password'));
$nickname = trim(Request::input('nickname'));
$changePass = intval(Request::input('changepass', 1)) === 1;
$emailVerity = intval(Request::input('email_verity', 1)) === 1;
$profession = trim((string)Request::input('profession', ''));
$department = Request::input('department', []);
$user = User::createByAdmin($email, $password, $nickname, [
'changePass' => $changePass,
'emailVerity' => $emailVerity,
'profession' => $profession,
'department' => is_array($department) ? $department : [],
]);
return Base::retSuccess('创建成功', $user);
}
/**
* @api {post} api/users/import/preview 批量导入预览(管理员)
*
* @apiDescription 需要token身份管理员。上传 Excel/CSV列顺序邮箱、昵称、初始密码、职位(选填)),仅解析+校验、不创建账号
* @apiVersion 1.0.0
* @apiGroup users
* @apiName import__preview
*/
public function import__preview()
{
User::auth('admin');
$file = Request::file('file');
if (empty($file)) {
return Base::retError('请选择文件');
}
$ext = strtolower($file->getClientOriginalExtension());
if (!in_array($ext, ['xls', 'xlsx', 'csv'])) {
return Base::retError('仅支持 xls/xlsx/csv 文件');
}
$sheets = Excel::toArray(new UserImport, $file);
$sheet = $sheets[0] ?? [];
$rows = User::parseImportRows($sheet);
if (empty($rows)) {
return Base::retError('文件中没有可导入的数据');
}
return Base::retSuccess('解析完成', User::importPreview($rows));
}
/**
* @api {post} api/users/import 批量导入用户(管理员)
*
* @apiDescription 需要token身份管理员。提交预览确认后的行数据 rows每行 {email,nickname,password,profession},可选 department[]、email_verity(1已认证/0未认证默认0))进行创建
* @apiVersion 1.0.0
* @apiGroup users
* @apiName import
*/
public function import()
{
User::auth('admin');
$rows = Request::input('rows');
if (!is_array($rows) || empty($rows)) {
return Base::retError('没有可导入的数据');
}
$changePass = intval(Request::input('changepass', 1)) === 1;
$result = User::importUsers($rows, $changePass);
return Base::retSuccess('导入完成', $result);
}
/**
* @api {get} api/users/import/template 下载批量导入模板(管理员)
*
* @apiVersion 1.0.0
* @apiGroup users
* @apiName import__template
*/
public function import__template()
{
User::auth('admin');
return Excel::download(new UserImportTemplate, 'user_import_template.xlsx');
}
/**
* @api {get} api/users/email/verification 邮箱验证
*
@@ -2130,6 +2272,65 @@ class UsersController extends AbstractController
return Base::retSuccess($id > 0 ? '保存成功' : '新建成功');
}
/**
* @api {post} api/users/department/adddeputy 任命部门管理员(限管理员)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup users
* @apiName department__adddeputy
*
* @apiParam {Number} id 部门 id
* @apiParam {Number} userid 部门管理员 userid
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
*/
public function department__adddeputy()
{
User::auth('admin');
$id = intval(Request::input('id'));
$userid = intval(Request::input('userid'));
$dept = UserDepartment::find($id);
if (empty($dept)) {
return Base::retError('部门不存在或已被删除');
}
// ApiException 由框架统一捕获并 retError 转换
$dept->addDeputy($userid);
Cache::forever("UserDepartment::rand", Base::generatePassword());
return Base::retSuccess('任命成功');
}
/**
* @api {post} api/users/department/deldeputy 罢免部门管理员(限管理员)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup users
* @apiName department__deldeputy
*
* @apiParam {Number} id 部门 id
* @apiParam {Number} userid 要罢免的部门管理员 userid
*/
public function department__deldeputy()
{
User::auth('admin');
$id = intval(Request::input('id'));
$userid = intval(Request::input('userid'));
$dept = UserDepartment::find($id);
if (empty($dept)) {
return Base::retError('部门不存在或已被删除');
}
$dept->delDeputy($userid);
Cache::forever("UserDepartment::rand", Base::generatePassword());
return Base::retSuccess('罢免成功');
}
/**
* @api {get} api/users/department/del 删除部门(限管理员)
*
@@ -3198,7 +3399,7 @@ class UsersController extends AbstractController
return Base::retError('参数错误');
}
//
ProjectTask::userTask($task_id, null, null);
ProjectTask::findForDepartmentView($task_id, null, null);
//
UserTaskBrowse::recordBrowse($user->userid, $task_id);
//

View File

@@ -23,6 +23,8 @@ use App\Tasks\CheckinRemindTask;
use App\Tasks\CloseMeetingRoomTask;
use App\Tasks\ManticoreSyncTask;
use App\Tasks\UnclaimedTaskRemindTask;
use App\Tasks\TodoRemindTask;
use App\Tasks\AiTaskLoopTask;
use Hhxsv5\LaravelS\Swoole\Task\Task;
use Laravolt\Avatar\Avatar;
@@ -269,10 +271,14 @@ class IndexController extends InvokeController
Task::deliver(new JokeSoupTask());
// 未领取任务通知
Task::deliver(new UnclaimedTaskRemindTask());
// 待办提醒
Task::deliver(new TodoRemindTask());
// 关闭会议室
Task::deliver(new CloseMeetingRoomTask());
// Manticore Search 同步
Task::deliver(new ManticoreSyncTask());
// AI 任务建议
Task::deliver(new AiTaskLoopTask());
return "success";
}

View File

@@ -10,14 +10,19 @@ class TrustProxies extends Middleware
/**
* The trusted proxies for this application.
*
* PHPSwoole只在内网被 nginx 访问,外部无法直连,故信任内网代理。
*
* @var array|string|null
*/
protected $proxies;
protected $proxies = '*';
/**
* The headers that should be used to detect proxies.
*
* 只采信 X-Forwarded-Protonginx 已用 $the_scheme 覆盖该头(值由 nginx 控制),
* 据此让 url() 实时跟随 httpshost/for 一律不信,避免 Host 注入与 IP 伪造。
*
* @var int
*/
protected $headers = Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_AWS_ELB;
protected $headers = Request::HEADER_X_FORWARDED_PROTO;
}

View File

@@ -56,12 +56,6 @@ class WebApi
}
}
// 强制 https
$APP_SCHEME = env('APP_SCHEME', 'auto');
if (in_array(strtolower($APP_SCHEME), ['https', 'on', 'ssl', '1', 'true', 'yes'], true)) {
$request->setTrustedProxies([$request->getClientIp()], $request::HEADER_X_FORWARDED_PROTO);
}
// 执行下一个中间件
$response = $next($request);

View File

@@ -2,8 +2,10 @@
namespace App\Ldap;
use App\Exceptions\ApiException;
use App\Models\User;
use App\Module\Base;
use App\Services\RequestContext;
use LdapRecord\Configuration\ConfigurationException;
use LdapRecord\Container;
use LdapRecord\LdapRecordException;
@@ -11,20 +13,18 @@ use LdapRecord\Models\Model;
class LdapUser extends Model
{
protected static $init = null;
/**
* The object classes of the LDAP model.
*
* @var array
*/
public static $objectClasses = [
'inetOrgPerson',
'organizationalPerson',
'person',
'top',
'posixAccount',
];
private static $emailAttrs = ['mail', 'cn', 'uid', 'userPrincipalName'];
/**
* @return mixed|null
*/
@@ -68,19 +68,29 @@ class LdapUser extends Model
return Base::settingFind('thirdAccessSetting', 'ldap_sync_local') === 'open';
}
/**
* 获取登录属性名
* @return string
*/
public static function getLoginAttr(): string
{
$attr = Base::settingFind('thirdAccessSetting', 'ldap_login_attr');
return in_array($attr, ['cn', 'uid', 'mail', 'sAMAccountName', 'userPrincipalName']) ? $attr : 'cn';
}
/**
* 初始化配置
* @return bool
*/
public static function initConfig()
{
if (is_bool(self::$init)) {
return self::$init;
if (RequestContext::has('ldap_init')) {
return RequestContext::get('ldap_init');
}
//
$setting = Base::setting('thirdAccessSetting');
if ($setting['ldap_open'] !== 'open') {
return self::$init = false;
return RequestContext::save('ldap_init', false);
}
//
$connection = Container::getDefaultConnection();
@@ -92,15 +102,15 @@ class LdapUser extends Model
"username" => $setting['ldap_user_dn'],
"password" => $setting['ldap_password'],
]);
return self::$init = true;
return RequestContext::save('ldap_init', true);
} catch (ConfigurationException $e) {
info($e->getMessage());
return self::$init = false;
return RequestContext::save('ldap_init', false);
}
}
/**
* 获取
* 通过管理员绑定搜索用户,然后用用户 DN 做 Bind 认证
* @param $username
* @param $password
* @return Model|null
@@ -111,16 +121,68 @@ class LdapUser extends Model
return null;
}
try {
return self::static()
->where([
'cn' => $username,
'userPassword' => $password
])->first();
$loginAttr = self::getLoginAttr();
$row = self::static()
->whereRaw($loginAttr, '=', $username)
->first();
if (!$row) {
return null;
}
$connection = Container::getDefaultConnection();
if (!$connection->auth()->attempt($row->getDn(), $password)) {
return null;
}
// Swoole 下连接共享,必须恢复管理员绑定
$connection->auth()->attempt(
$connection->getConfiguration()->get('username'),
$connection->getConfiguration()->get('password')
);
return $row;
} catch (\Exception $e) {
info("[LDAP] auth fail: " . $e->getMessage());
return null;
}
}
/**
* 通过邮箱查找 LDAP 用户
* @param $email
* @return Model|null
*/
public static function findByEmail($email): ?Model
{
if (!self::initConfig()) {
return null;
}
try {
foreach (self::$emailAttrs as $attr) {
$row = self::static()->whereRaw($attr, '=', $email)->first();
if ($row) {
return $row;
}
}
return null;
} catch (\Exception) {
return null;
}
}
/**
* 获取用户的邮箱(从 LDAP 记录中提取)
* @param Model $row
* @return string|null
*/
public static function getUserEmail(Model $row): ?string
{
foreach (self::$emailAttrs as $attr) {
$val = $row->getFirstAttribute($attr);
if ($val && Base::isEmail($val)) {
return $val;
}
}
return null;
}
/**
* 登录
* @param $username
@@ -138,7 +200,18 @@ class LdapUser extends Model
return null;
}
if (empty($user)) {
$user = User::reg($username, $password);
$email = self::getUserEmail($row);
if (empty($email)) {
throw new ApiException('LDAP 用户缺少邮箱属性,请联系管理员配置');
}
$user = User::whereEmail($email)->first();
if (empty($user)) {
// LDAP 用户通过 LDAP 认证,本地密码用随机值以满足密码策略
$localPassword = Base::generatePassword(16) . 'Aa1!';
$user = User::reg($email, $localPassword);
} elseif (!$user->isLdap()) {
info("[LDAP] merged with existing local account: userid={$user->userid}, email={$email}");
}
}
if ($user) {
$userimg = $row->getPhoto();
@@ -173,7 +246,7 @@ class LdapUser extends Model
}
//
if (self::isSyncLocal()) {
$row = self::userFirst($user->email, $password);
$row = self::findByEmail($user->email);
if ($row) {
return;
}
@@ -184,17 +257,18 @@ class LdapUser extends Model
} else {
$userimg = '';
}
self::static()->create([
$attrs = [
'cn' => $user->email,
'gidNumber' => 0,
'homeDirectory' => '/home/ldap/dootask/' . env("APP_NAME"),
'sn' => $user->email,
'uid' => $user->email,
'uidNumber' => $user->userid,
'userPassword' => $password,
'displayName' => $user->nickname,
'jpegPhoto' => $userimg,
]);
'mail' => $user->email,
];
if ($userimg) {
$attrs['jpegPhoto'] = $userimg;
}
self::static()->create($attrs);
$user->identity = Base::arrayImplode(array_merge(array_diff($user->identity, ['ldap']), ['ldap']));
$user->save();
} catch (LdapRecordException $e) {
@@ -205,11 +279,11 @@ class LdapUser extends Model
/**
* 更新
* @param $username
* @param $email
* @param $array
* @return void
*/
public static function userUpdate($username, $array)
public static function userUpdate($email, $array)
{
if (empty($array)) {
return;
@@ -218,10 +292,7 @@ class LdapUser extends Model
return;
}
try {
$row = self::static()
->where([
'cn' => $username,
])->first();
$row = self::findByEmail($email);
$row?->update($array);
} catch (\Exception $e) {
info("[LDAP] update fail: " . $e->getMessage());
@@ -230,19 +301,16 @@ class LdapUser extends Model
/**
* 删除
* @param $username
* @param $email
* @return void
*/
public static function userDelete($username)
public static function userDelete($email)
{
if (!self::initConfig()) {
return;
}
try {
$row = self::static()
->where([
'cn' => $username,
])->first();
$row = self::findByEmail($email);
$row?->delete();
} catch (\Exception $e) {
info("[LDAP] delete fail: " . $e->getMessage());

View File

@@ -20,9 +20,7 @@ use Illuminate\Support\Facades\DB;
* @method static \Illuminate\Database\Eloquent\Model|object|static|null cancelAppend()
* @method static \Illuminate\Database\Eloquent\Model|object|static|null cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|static with($relations)
* @method static \Illuminate\Database\Query\Builder|static select($columns = [])
* @method static \Illuminate\Database\Query\Builder|static whereIn($column, $values, $boolean = 'and', $not = false)
* @method static \Illuminate\Database\Query\Builder|static whereNotIn($column, $values, $boolean = 'and')
* @method static \Illuminate\Pagination\LengthAwarePaginator paginate(callable $callback)
* @method int change(array $array)
* @method int remove()
* @mixin \Eloquent
@@ -53,6 +51,8 @@ class AbstractModel extends Model
'read_at',
'done_at',
'remind_at',
'reminded_at',
'created_at',
'updated_at',

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Models;
/**
* AI 助手会话
*
* @property int $id
* @property int $userid
* @property string $session_key
* @property string $session_id
* @property string $scene_key
* @property string $title
* @property string|null $data
* @property string|null $images
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
*/
class AiAssistantSession extends AbstractModel
{
protected $table = 'ai_assistant_sessions';
}

View File

@@ -0,0 +1,132 @@
<?php
namespace App\Models;
/**
* Manticore 同步失败记录
*
* @property int $id
* @property string $data_type 数据类型: msg/file/task/project/user
* @property int $data_id 数据ID
* @property string $action 操作类型: sync/delete
* @property string|null $error_message 错误信息
* @property int $retry_count 重试次数
* @property \Carbon\Carbon|null $last_retry_at 最后重试时间
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
*/
class ManticoreSyncFailure extends AbstractModel
{
protected $table = 'manticore_sync_failures';
protected $fillable = [
'data_type',
'data_id',
'action',
'error_message',
'retry_count',
'last_retry_at',
];
protected $dates = [
'last_retry_at',
'created_at',
'updated_at',
];
/**
* 记录同步失败
*
* @param string $dataType 数据类型
* @param int $dataId 数据ID
* @param string $action 操作类型 sync/delete
* @param string $errorMessage 错误信息
*/
public static function recordFailure(string $dataType, int $dataId, string $action, string $errorMessage = ''): void
{
self::updateOrCreate(
[
'data_type' => $dataType,
'data_id' => $dataId,
'action' => $action,
],
[
'error_message' => mb_substr($errorMessage, 0, 500),
'retry_count' => \DB::raw('retry_count + 1'),
'last_retry_at' => now(),
]
);
}
/**
* 删除成功记录
*
* @param string $dataType 数据类型
* @param int $dataId 数据ID
* @param string $action 操作类型
*/
public static function removeSuccess(string $dataType, int $dataId, string $action): void
{
self::where('data_type', $dataType)
->where('data_id', $dataId)
->where('action', $action)
->delete();
}
/**
* 获取待重试的记录
* 根据重试次数决定间隔1次=1分钟2次=5分钟3次=15分钟4次+=30分钟
*
* @param int $limit 数量限制
* @return \Illuminate\Database\Eloquent\Collection
*/
public static function getPendingRetries(int $limit = 100)
{
return self::where(function ($query) {
$query->whereNull('last_retry_at')
->orWhere(function ($q) {
// 根据重试次数决定间隔
$q->where(function ($sub) {
// 重试1次等待1分钟
$sub->where('retry_count', 1)
->where('last_retry_at', '<', now()->subMinutes(1));
})->orWhere(function ($sub) {
// 重试2次等待5分钟
$sub->where('retry_count', 2)
->where('last_retry_at', '<', now()->subMinutes(5));
})->orWhere(function ($sub) {
// 重试3次等待15分钟
$sub->where('retry_count', 3)
->where('last_retry_at', '<', now()->subMinutes(15));
})->orWhere(function ($sub) {
// 重试4次以上等待30分钟
$sub->where('retry_count', '>=', 4)
->where('last_retry_at', '<', now()->subMinutes(30));
});
});
})
->orderBy('last_retry_at')
->limit($limit)
->get();
}
/**
* 获取统计信息
*
* @return array
*/
public static function getStats(): array
{
return [
'total' => self::count(),
'by_type' => self::selectRaw('data_type, COUNT(*) as count')
->groupBy('data_type')
->pluck('count', 'data_type')
->toArray(),
'by_action' => self::selectRaw('action, COUNT(*) as count')
->groupBy('action')
->pluck('count', 'action')
->toArray(),
];
}
}

View File

@@ -22,6 +22,9 @@ use Request;
* @property int|null $personal 是否个人项目
* @property string|null $archive_method 自动归档方式
* @property int|null $archive_days 自动归档天数
* @property string|null $ai_auto_analyze AI自动分析
* @property string|null $task_template_share 共享模板开关
* @property string|null $department_owner_view 部门负责人视角可见开关
* @property string|null $user_simple 成员总数|1,2,3
* @property int|null $dialog_id 聊天会话ID
* @property \Illuminate\Support\Carbon|null $archived_at 归档时间
@@ -77,6 +80,7 @@ class Project extends AbstractModel
protected $appends = [
'owner_userid',
'deputy_userids',
];
/**
@@ -92,6 +96,58 @@ class Project extends AbstractModel
return $this->appendattrs['owner_userid'];
}
/**
* 项目管理员 userid 列表
* @return array
*/
public function getDeputyUseridsAttribute(): array
{
if (empty($this->id)) {
return [];
}
return ProjectUser::whereProjectId($this->id)
->whereOwner(ProjectUser::OWNER_DEPUTY)
->pluck('userid')
->map(fn($v) => (int)$v)
->toArray();
}
/**
* 是否项目负责人(与 project_users.owner=1 一致)
*/
public function isPrimaryOwner($userid): bool
{
if (empty($this->id) || $userid <= 0) {
return false;
}
return ProjectUser::whereProjectId($this->id)
->whereUserid($userid)
->whereOwner(ProjectUser::OWNER_PRIMARY)
->exists();
}
/**
* 是否项目管理员(与 project_users.owner=2 一致)
*/
public function isDeputyOwner($userid): bool
{
if (empty($this->id) || $userid <= 0) {
return false;
}
return ProjectUser::whereProjectId($this->id)
->whereUserid($userid)
->whereOwner(ProjectUser::OWNER_DEPUTY)
->exists();
}
/**
* 是否负责人(含项目管理员)
*/
public function isOwner($userid): bool
{
return $this->isPrimaryOwner($userid) || $this->isDeputyOwner($userid);
}
/**
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
@@ -227,21 +283,40 @@ class Project extends AbstractModel
return;
}
AbstractModel::transaction(function() {
$userids = $this->relationUserids();
// 拉所有项目成员 + 各自 owner 值
$userOwnerMap = ProjectUser::whereProjectId($this->id)
->pluck('owner', 'userid');
$userids = $userOwnerMap->keys()->map(fn($v) => (int)$v)->toArray();
foreach ($userids as $userid) {
$owner = (int)$userOwnerMap[$userid];
// 巧合:编码完全一致 owner 0/1/2 → role 0/1/2
$role = $owner;
WebSocketDialogUser::updateInsert([
'dialog_id' => $this->dialog_id,
'userid' => $userid,
], [
'important' => 1
], function () use ($userid) {
'important' => 1,
'role' => $role,
], function () use ($userid, $role) {
return [
'important' => 1,
'role' => $role,
'bot' => User::isBot($userid) ? 1 : 0,
];
});
}
WebSocketDialogUser::whereDialogId($this->dialog_id)->whereNotIn('userid', $userids)->whereImportant(1)->remove();
WebSocketDialogUser::whereDialogId($this->dialog_id)
->whereNotIn('userid', $userids)
->whereImportant(1)
->remove();
// 同步 dialog.owner_id 到主负责人owner=1前端「群主」标签依赖此字段
// 必须随项目主负责人变更(含用户离职转移)一起刷新,否则会显示已离职用户
$primaryUserid = $userOwnerMap->search(ProjectUser::OWNER_PRIMARY);
if ($primaryUserid !== false && (int)$primaryUserid > 0) {
WebSocketDialog::whereId($this->dialog_id)
->where('owner_id', '!=', (int)$primaryUserid)
->update(['owner_id' => (int)$primaryUserid]);
}
});
}
@@ -378,7 +453,7 @@ class Project extends AbstractModel
// 处理所有者权限
if (isset($data['owner'])) {
$owners = ProjectUser::whereProjectId($data['id'])
->whereOwner(1)
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
->pluck('userid')
->toArray();
$recipients = [
@@ -530,6 +605,38 @@ class Project extends AbstractModel
});
}
/**
* 判断用户是否有权限创建项目(依据系统设置「项目创建权限」)
* @param int $userid
* @return bool
*/
public static function userCanCreate($userid)
{
// 范围已在 Setting::getSettingAttribute() 归一化(默认 ['all']
$modes = Base::settingFind('system', 'project_add_permission', ['all']);
// 「所有人」:放行(与具体用户无关,避免未携带身份时被误判为无权)
if (in_array('all', $modes)) {
return true;
}
$user = User::find(intval($userid));
if (empty($user)) {
return false;
}
// 系统管理员始终可创建项目(不受开关限制)
if ($user->isAdmin()) {
return true;
}
// 部门负责人/部门管理员
if (in_array('departmentOwner', $modes) && UserDepartment::getManagedDepartments($user->userid)->isNotEmpty()) {
return true;
}
// 指定人员
if (in_array('appoint', $modes)) {
return in_array($user->userid, Base::settingFind('system', 'project_add_userids', []));
}
return false;
}
/**
* 创建项目
* @param $params
@@ -546,6 +653,10 @@ class Project extends AbstractModel
$desc = trim(Arr::get($params, 'desc', ''));
$flow = trim(Arr::get($params, 'flow', 'close'));
$isPersonal = intval(Arr::get($params, 'personal'));
// 个人项目为系统自动创建,不受创建权限限制
if (!$isPersonal && !self::userCanCreate($userid)) {
return Base::retError('当前仅指定人员可以创建项目');
}
if (mb_strlen($name) < 2) {
return Base::retError('项目名称不可以少于2个字');
} elseif (mb_strlen($name) > 32) {
@@ -599,7 +710,7 @@ class Project extends AbstractModel
$column['project_id'] = $project->id;
ProjectColumn::createInstance($column)->save();
}
$dialog = WebSocketDialog::createGroup($project->name, $project->userid, 'project');
$dialog = WebSocketDialog::createGroup($project->name, $project->userid, 'project', $project->userid);
if (empty($dialog)) {
throw new ApiException('创建项目聊天室失败');
}
@@ -621,7 +732,9 @@ class Project extends AbstractModel
* 获取项目信息(用于判断会员是否存在项目内)
* @param int $project_id
* @param null|bool $archived true:仅限未归档, false:仅限已归档, null:不限制
* @param null|bool $mustOwner true:仅限项目负责人, false:仅限非项目负责人, null:不限制
* @param null|bool|string $mustOwner true:负责人或项目管理员都可(共享操作);
* 'primary':仅负责人(转让/删除/任命项目管理员等独占操作);
* false:仅限非负责人null:不限制
* @return self
*/
public static function userProject($project_id, $archived = true, $mustOwner = null)
@@ -639,9 +752,39 @@ class Project extends AbstractModel
if ($mustOwner === true && !$project->owner) {
throw new ApiException('仅限项目负责人操作', [ 'project_id' => $project_id ]);
}
if ($mustOwner === 'primary' && (int)$project->owner !== 1) {
throw new ApiException('仅限项目负责人操作', [ 'project_id' => $project_id ]);
}
if ($mustOwner === false && $project->owner) {
throw new ApiException('禁止项目负责人操作', [ 'project_id' => $project_id ]);
}
return $project;
}
/**
* 获取项目(含部门负责人只读视角兜底)
* @param int $project_id
* @param null|bool $archived true:仅限未归档, false:仅限已归档, null:不限制
* @param null|bool|string $mustOwner 仅限 null 时尝试部门只读视角
* @return self
*/
public static function findForDepartmentView($project_id, $archived = true, $mustOwner = null)
{
$user = User::auth();
$departmentView = UserDepartment::ownerViewContext($user, true);
if (UserDepartment::isDepartmentReadonlyProject($departmentView, intval($project_id)) && $mustOwner === null) {
$project = self::allData()->where('projects.id', intval($project_id))->first();
if (empty($project)) {
throw new ApiException('项目不存在或已被删除', [ 'project_id' => $project_id ], -4001);
}
if ($archived === true && $project->archived_at != null) {
throw new ApiException('项目已归档', [ 'project_id' => $project_id ], -4001);
}
if ($archived === false && $project->archived_at == null) {
throw new ApiException('项目未归档', [ 'project_id' => $project_id ]);
}
return $project;
}
return self::userProject($project_id, $archived, $mustOwner);
}
}

View File

@@ -399,6 +399,38 @@ class ProjectTask extends AbstractModel
return Base::cutStr(strip_tags($content), 100);
}
/**
* 标准化时间参数,兼容 start_at/end_at 转换为 times
* @param array $data 请求数据
* @param self|null $task 任务实例(更新时传入)
* @return array 处理后的data
*/
public static function normalizeTimes(array $data, ?self $task = null): array
{
if (isset($data['times']) || (!isset($data['start_at']) && !isset($data['end_at']))) {
return $data;
}
$startAt = $data['start_at'] ?? null;
$endAt = $data['end_at'] ?? null;
if ($endAt && !$startAt) {
// 只传 end_at保留已有 start_at否则取当前时间
$startAt = $task?->start_at
? Carbon::parse($task->start_at)->toDateTimeString()
: date('Y-m-d H:i:s');
} elseif ($startAt && !$endAt) {
// 只传 start_at必须已有 end_at
if (!$task?->end_at) {
throw new ApiException('请设置结束时间');
}
$endAt = Carbon::parse($task->end_at)->toDateTimeString();
}
$data['times'] = [$startAt, $endAt];
return $data;
}
/**
* 添加任务
* @param $data
@@ -718,12 +750,22 @@ class ProjectTask extends AbstractModel
if ($this->complete_at) {
throw new ApiException('任务已完成');
}
$this->completeTask(Carbon::now(), isset($newFlowItem) ? $newFlowItem->name : null);
// 只有用户单独提交 complete_at 时才自动设置工作流状态
if (!Arr::exists($data, 'flow_item_id')) {
$flowItemName = $this->checkAndAutoSetFlowItem('end', -4005);
} else {
$flowItemName = isset($newFlowItem) ? $newFlowItem->name : null;
}
$this->completeTask(Carbon::now(), $flowItemName);
} else {
// 标记未完成
if (!$this->complete_at) {
throw new ApiException('未完成任务');
}
// 只有用户单独提交 complete_at 时才自动设置工作流状态
if (!Arr::exists($data, 'flow_item_id')) {
$this->checkAndAutoSetFlowItem('start', -4006);
}
$this->completeTask(null);
}
$updateMarking['is_update_project'] = true;
@@ -1507,6 +1549,49 @@ class ProjectTask extends AbstractModel
return $this->appendattrs['has_owner'];
}
/**
* 检查并自动设置工作流状态
* @param string $status 目标状态类型 ('start' 或 'end')
* @param int $errorCode 多状态时的错误码 (-4005 或 -4006)
* @return string|null 自动设置的状态名称,无状态时返回 null
*/
private function checkAndAutoSetFlowItem(string $status, int $errorCode): ?string
{
$flowItems = ProjectFlowItem::whereProjectId($this->project_id)
->whereStatus($status)
->get(['id', 'name', 'status', 'color']);
if ($flowItems->count() > 1) {
$msg = $status === 'end' ? '存在多个结束状态,请选择要使用的状态' : '存在多个开始状态,请选择要使用的状态';
throw new ApiException($msg, [
'task_id' => $this->id,
'flow_items' => $flowItems->toArray(),
], $errorCode);
}
if ($flowItems->count() == 1) {
$autoFlowItem = $flowItems->first();
$oldFlowItemId = $this->flow_item_id;
$oldFlowItemName = $this->flow_item_name;
$this->flow_item_id = $autoFlowItem->id;
$this->flow_item_name = $autoFlowItem->status . "|" . $autoFlowItem->name . "|" . $autoFlowItem->color;
if ($oldFlowItemId != $this->flow_item_id) {
ProjectTaskFlowChange::createInstance([
'task_id' => $this->id,
'userid' => User::userid(),
'before_flow_item_id' => $oldFlowItemId,
'before_flow_item_name' => $oldFlowItemName,
'after_flow_item_id' => $this->flow_item_id,
'after_flow_item_name' => $this->flow_item_name,
])->save();
}
return $autoFlowItem->name;
}
return null;
}
/**
* 标记已完成、未完成
* @param Carbon|null $complete_at 完成时间
@@ -1906,7 +1991,9 @@ class ProjectTask extends AbstractModel
'dialog_id' => $this->dialog_id,
];
//
$projectOwnerids = ProjectUser::whereProjectId($this->project_id)->whereOwner(1)->pluck('userid')->toArray(); // 项目负责人
$projectOwnerids = ProjectUser::whereProjectId($this->project_id)
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
->pluck('userid')->toArray(); // 项目负责人(含项目管理员)
//
$array = [];
if (empty($userids)) {
@@ -2171,6 +2258,40 @@ class ProjectTask extends AbstractModel
return $task;
}
/**
* 获取任务(含部门负责人只读视角兜底)
* @param int $task_id
* @param null|bool $archived true:仅限未归档, false:仅限已归档, null:不限制
* @param null|bool $trashed true:仅限未删除, false:仅限已删除, null:不限制
* @param array $with
* @return self
*/
public static function findForDepartmentView($task_id, $archived = true, $trashed = true, $with = [])
{
$user = User::auth();
$departmentView = UserDepartment::ownerViewContext($user, true);
if ($departmentView['enabled']) {
$builder = self::with($with)->allData()->where('project_tasks.id', intval($task_id));
if ($trashed === false) {
$builder->onlyTrashed();
} elseif ($trashed === null) {
$builder->withTrashed();
}
$task = $builder->first();
// 仅"全员可见"(visibility=1)的任务走负责人只读视角;指定成员可见的任务交由 userTask 按可见性校验
if (!empty($task) && intval($task->visibility) === 1 && UserDepartment::isDepartmentReadonlyProject($departmentView, intval($task->project_id))) {
if ($archived === true && $task->archived_at != null) {
throw new ApiException('任务已归档', ['task_id' => $task_id]);
}
if ($archived === false && $task->archived_at == null) {
throw new ApiException('任务未归档', ['task_id' => $task_id]);
}
return $task;
}
}
return self::userTask($task_id, $archived, $trashed, $with);
}
/**
* 构建指定周期内的未完成任务查询(用于周报/日报等)
* @param int $userid

View File

@@ -0,0 +1,154 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\ProjectTaskAiEvent
*
* @property int $id
* @property int $task_id 任务ID
* @property string $event_type 事件类型
* @property string $status 状态
* @property int $retry_count 重试次数
* @property array|null $result 执行结果
* @property string|null $error 错误信息
* @property int $msg_id 消息ID
* @property \Illuminate\Support\Carbon|null $executed_at
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
*/
class ProjectTaskAiEvent extends AbstractModel
{
const EVENT_DESCRIPTION = 'description';
const EVENT_SUBTASKS = 'subtasks';
const EVENT_ASSIGNEE = 'assignee';
const EVENT_SIMILAR = 'similar';
const STATUS_PENDING = 'pending';
const STATUS_PROCESSING = 'processing';
const STATUS_COMPLETED = 'completed';
const STATUS_FAILED = 'failed';
const STATUS_SKIPPED = 'skipped';
const STATUS_APPLIED = 'applied';
const STATUS_DISMISSED = 'dismissed';
const MAX_RETRY = 3;
protected $table = 'project_task_ai_events';
protected $fillable = [
'task_id',
'event_type',
'status',
'retry_count',
'result',
'error',
'msg_id',
'executed_at',
];
protected $casts = [
'result' => 'array',
'executed_at' => 'datetime',
];
/**
* 关联任务
*/
public function task(): BelongsTo
{
return $this->belongsTo(ProjectTask::class, 'task_id', 'id');
}
/**
* 获取所有事件类型
*/
public static function getEventTypes(): array
{
return [
self::EVENT_DESCRIPTION,
self::EVENT_SUBTASKS,
self::EVENT_ASSIGNEE,
self::EVENT_SIMILAR,
];
}
/**
* 标记为处理中
*/
public function markProcessing(): bool
{
return $this->update([
'status' => self::STATUS_PROCESSING,
]);
}
/**
* 标记为完成
*/
public function markCompleted(array $result, int $msgId = 0): bool
{
return $this->update([
'status' => self::STATUS_COMPLETED,
'result' => $result,
'msg_id' => $msgId,
'executed_at' => now(),
]);
}
/**
* 标记为失败
*/
public function markFailed(string $error): bool
{
return $this->update([
'status' => self::STATUS_FAILED,
'retry_count' => $this->retry_count + 1,
'error' => $error,
'executed_at' => now(),
]);
}
/**
* 标记为跳过
*/
public function markSkipped(string $reason = ''): bool
{
return $this->update([
'status' => self::STATUS_SKIPPED,
'error' => $reason,
'executed_at' => now(),
]);
}
/**
* 是否可以重试
*/
public function canRetry(): bool
{
return $this->status === self::STATUS_FAILED
&& $this->retry_count < self::MAX_RETRY;
}
/**
* 标记为已采纳
*/
public function markApplied(): bool
{
return $this->update([
'status' => self::STATUS_APPLIED,
]);
}
/**
* 标记为已忽略
*/
public function markDismissed(): bool
{
return $this->update([
'status' => self::STATUS_DISMISSED,
]);
}
}

View File

@@ -63,6 +63,121 @@ class ProjectTaskRelation extends AbstractModel
return $this->belongsTo(ProjectTask::class, 'related_task_id');
}
/**
* 创建双向任务关联
*
* @param int $sourceTaskId 源任务ID
* @param int $targetTaskId 目标任务ID
* @param int|null $dialogId 来源对话ID
* @param int|null $msgId 来源消息ID
* @param int|null $userid 操作人
* @param bool $push 是否推送更新
* @return bool 是否创建成功
*/
public static function createRelation(
int $sourceTaskId,
int $targetTaskId,
?int $dialogId = null,
?int $msgId = null,
?int $userid = null,
bool $push = true
): bool {
if ($sourceTaskId === $targetTaskId) {
return false;
}
$sourceTask = ProjectTask::with('project')->find($sourceTaskId);
$targetTask = ProjectTask::with('project')->find($targetTaskId);
if (!$sourceTask || !$targetTask) {
return false;
}
if ($sourceTask->deleted_at || $targetTask->deleted_at) {
return false;
}
// 创建正向关联:源任务提及目标任务
$mentionRelation = static::updateOrCreate(
[
'task_id' => $sourceTaskId,
'related_task_id' => $targetTaskId,
'direction' => self::DIRECTION_MENTION,
],
[
'dialog_id' => $dialogId,
'msg_id' => $msgId,
'userid' => $userid,
]
);
// 创建反向关联:目标任务被源任务提及
$reverseRelation = static::updateOrCreate(
[
'task_id' => $targetTaskId,
'related_task_id' => $sourceTaskId,
'direction' => self::DIRECTION_MENTIONED_BY,
],
[
'dialog_id' => $dialogId,
'msg_id' => $msgId,
'userid' => $userid,
]
);
// 推送关联更新
if ($push) {
$needPush = $mentionRelation->wasRecentlyCreated || $mentionRelation->wasChanged()
|| $reverseRelation->wasRecentlyCreated || $reverseRelation->wasChanged();
if ($needPush) {
if ($sourceTask->project) {
$sourceTask->pushMsg('relation', null, null, false);
}
if ($targetTask->project) {
$targetTask->pushMsg('relation', null, null, false);
}
}
}
return true;
}
/**
* 删除双向任务关联
*
* @param int $taskId 任务ID
* @param int $relatedTaskId 关联任务ID
* @return bool 是否删除成功
*/
public static function deleteRelation(int $taskId, int $relatedTaskId): bool
{
// 删除正向关联
$deleted1 = static::whereTaskId($taskId)
->whereRelatedTaskId($relatedTaskId)
->delete();
// 删除反向关联
$deleted2 = static::whereTaskId($relatedTaskId)
->whereRelatedTaskId($taskId)
->delete();
if ($deleted1 || $deleted2) {
// 推送关联更新
$sourceTask = ProjectTask::with('project')->find($taskId);
$targetTask = ProjectTask::with('project')->find($relatedTaskId);
if ($sourceTask?->project) {
$sourceTask->pushMsg('relation', null, null, false);
}
if ($targetTask?->project) {
$targetTask->pushMsg('relation', null, null, false);
}
return true;
}
return false;
}
public static function recordMentionsFromMessage(WebSocketDialogMsg $msg): void
{
if ($msg->type !== 'text') {
@@ -84,71 +199,25 @@ class ProjectTaskRelation extends AbstractModel
return;
}
$sourceTasks = ProjectTask::with('project')->whereDialogId($msg->dialog_id)->get();
if ($sourceTasks->isEmpty()) {
$sourceTaskIds = ProjectTask::whereDialogId($msg->dialog_id)
->whereNull('deleted_at')
->pluck('id')
->toArray();
if (empty($sourceTaskIds)) {
return;
}
$targetTasks = ProjectTask::with('project')->whereIn('id', $targetIds)->get()->keyBy('id');
if ($targetTasks->isEmpty()) {
return;
}
$pushTasks = [];
foreach ($sourceTasks as $sourceTask) {
foreach ($sourceTaskIds as $sourceTaskId) {
foreach ($targetIds as $targetId) {
if ($targetId === $sourceTask->id) {
continue;
}
$targetTask = $targetTasks->get($targetId);
if (!$targetTask) {
continue;
}
$mentionRelation = static::updateOrCreate(
[
'task_id' => $sourceTask->id,
'related_task_id' => $targetTask->id,
'direction' => self::DIRECTION_MENTION,
],
[
'dialog_id' => $msg->dialog_id,
'msg_id' => $msg->id,
'userid' => $msg->userid,
]
self::createRelation(
$sourceTaskId,
$targetId,
$msg->dialog_id,
$msg->id,
$msg->userid
);
if ($mentionRelation->wasRecentlyCreated || $mentionRelation->wasChanged()) {
$pushTasks[$sourceTask->id] = $sourceTask;
}
$reverseRelation = static::updateOrCreate(
[
'task_id' => $targetTask->id,
'related_task_id' => $sourceTask->id,
'direction' => self::DIRECTION_MENTIONED_BY,
],
[
'dialog_id' => $msg->dialog_id,
'msg_id' => $msg->id,
'userid' => $msg->userid,
]
);
if ($reverseRelation->wasRecentlyCreated || $reverseRelation->wasChanged()) {
$pushTasks[$targetTask->id] = $targetTask;
}
}
}
foreach ($pushTasks as $task) {
$task->loadMissing('project');
if (!$task->project) {
continue;
}
$task->pushMsg('relation', null, null, false);
}
}
}

View File

@@ -13,6 +13,8 @@ namespace App\Models;
* @property int $sort 排序
* @property int $is_default 是否默认模板
* @property int $userid 创建人
* @property int $use_count 累计使用次数
* @property \Illuminate\Support\Carbon|null $last_used_at 最近一次使用时间
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\Project $project
@@ -52,7 +54,18 @@ class ProjectTaskTemplate extends AbstractModel
'content',
'sort',
'is_default',
'userid'
'userid',
'use_count',
'last_used_at'
];
/**
* The attributes that should be cast.
*
* @var array
*/
protected $casts = [
'last_used_at' => 'datetime',
];
/**
@@ -74,4 +87,17 @@ class ProjectTaskTemplate extends AbstractModel
{
return $this->belongsTo(User::class, 'userid');
}
/**
* 原子递增使用次数并刷新最近使用时间。
*/
public function incrementUsage(): void
{
$this->newQuery()
->where('id', $this->id)
->update([
'use_count' => \DB::raw('use_count + 1'),
'last_used_at' => now(),
]);
}
}

View File

@@ -37,6 +37,36 @@ use App\Module\Base;
*/
class ProjectUser extends AbstractModel
{
/** @var int 普通成员编码 */
const OWNER_MEMBER = 0;
/** @var int 项目负责人编码 */
const OWNER_PRIMARY = 1;
/** @var int 项目管理员编码 */
const OWNER_DEPUTY = 2;
/**
* 是否项目负责人owner=1
*/
public function isPrimaryOwner(): bool
{
return (int)$this->owner === self::OWNER_PRIMARY;
}
/**
* 是否项目管理员owner=2
*/
public function isDeputyOwner(): bool
{
return (int)$this->owner === self::OWNER_DEPUTY;
}
/**
* 是否负责人(含项目管理员)
*/
public function isOwner(): bool
{
return $this->isPrimaryOwner() || $this->isDeputyOwner();
}
/**
* @return \Illuminate\Database\Eloquent\Relations\HasOne
@@ -61,12 +91,19 @@ class ProjectUser extends AbstractModel
foreach ($list as $item) {
$row = self::whereProjectId($item->project_id)->whereUserid($newUserid)->first();
if ($row) {
// 已存在则删除原数据,判断改变已存在的数据
$row->owner = max($row->owner, $item->owner);
// 已存在仅当离职用户是项目负责人owner=1时把接收人升为项目负责人
// 离职用户是项目管理员owner=2时不传项目管理员身份给接收人spec项目管理员不替补
if ((int)$item->owner === self::OWNER_PRIMARY) {
$row->owner = self::OWNER_PRIMARY;
}
// owner=2/0保留接收人原有 owner 值不变
$row->save();
$item->delete();
} else {
// 不存在则改变原数据
// 不存在:转移时如果离职用户是项目管理员,降级为普通成员(不带项目管理员身份过户给接收人)
if ((int)$item->owner === self::OWNER_DEPUTY) {
$item->owner = self::OWNER_MEMBER;
}
$item->userid = $newUserid;
$item->save();
}

View File

@@ -59,6 +59,14 @@ class Setting extends AbstractModel
if (!is_array($value['task_default_time']) || count($value['task_default_time']) != 2 || !Timer::isTime($value['task_default_time'][0]) || !Timer::isTime($value['task_default_time'][1])) {
$value['task_default_time'] = ['09:00', '18:00'];
}
// 项目创建权限范围all/departmentOwner/appoint默认 all+ 指定人员
$value['project_add_permission'] = array_values(array_intersect(
is_array($value['project_add_permission'] ?? null) ? $value['project_add_permission'] : [],
['all', 'departmentOwner', 'appoint']
)) ?: ['all'];
$value['project_add_userids'] = is_array($value['project_add_userids'] ?? null)
? array_values(array_unique(array_filter(array_map('intval', $value['project_add_userids']))))
: [];
break;
// 文件设置
@@ -199,7 +207,6 @@ class Setting extends AbstractModel
$key = trim((string)($setting[$vendor . '_key'] ?? ''));
return match ($vendor) {
'ollama' => $key !== '' || !empty($setting['ollama_base_url']),
'wenxin' => $key !== '' && !empty($setting['wenxin_secret']),
default => $key !== '',
};
}
@@ -501,7 +508,7 @@ class Setting extends AbstractModel
}
$limitTime = Carbon::parse($dialogMsg->created_at)->addMinutes($limitNum);
if ($limitTime->lt(Carbon::now())) {
throw new ApiException('已超过' . Doo::translate(Base::forumMinuteDay($limitNum)) . '' . $error);
throw new ApiException('已超过' . Base::forumMinuteDay($limitNum) . '' . $error);
}
}
}

View File

@@ -89,6 +89,8 @@ use Carbon\Carbon;
*/
class User extends AbstractModel
{
const IMPORT_MAX = 500;
protected $primaryKey = 'userid';
protected $hidden = [
@@ -425,6 +427,287 @@ class User extends AbstractModel
return $createdUser;
}
/**
* 管理员创建员工账号(复用注册逻辑,强制正式身份,可选首登改密 / 部门 / 职位)
* @param string $email
* @param string $password
* @param string $nickname
* @param array $options changePass(bool,默认true) / emailVerity(bool,默认false,标记邮箱已认证) / department(int[]) / profession(string)
* @return self
* @throws ApiException
*/
public static function createByAdmin(string $email, $password, string $nickname, array $options = []): self
{
$nickname = trim($nickname);
if (mb_strlen($nickname) < 2 || mb_strlen($nickname) > 20) {
throw new ApiException('昵称需为2-20个字');
}
$changePass = ($options['changePass'] ?? true) ? 1 : 0;
$emailVerity = ($options['emailVerity'] ?? false) ? 1 : 0;
$profession = trim((string)($options['profession'] ?? ''));
// 校验前置reg 之前快速失败,且可在无 Swoole 环境单测)
self::assertValidProfession($profession);
$departmentIds = self::assertValidDepartments($options['department'] ?? []);
// 复用 reg邮箱校验/查重、passwordPolicy、Doo::userCreate、az/pinyin、全员群、索引同步、user_onboard hook
$user = self::reg($email, $password, ['nickname' => $nickname]);
// 管理员显式创建的账号视为正式员工,去除系统 reg_identity 可能带上的 temp
if (in_array('temp', $user->identity)) {
$user->identity = Base::arrayImplode(array_diff($user->identity, ['temp']));
}
$user->changepass = $changePass; // 复用现有首登强制改密机制
$user->email_verity = $emailVerity; // 管理员可在创建时直接标记邮箱认证状态
if ($profession !== '') {
$user->profession = $profession;
}
if ($departmentIds) {
$user->department = Base::arrayImplode($departmentIds);
}
$user->save();
// 设置了部门 → 加入对应部门群(复刻 operation 的 type=department 入群逻辑)
if ($departmentIds) {
$departments = UserDepartment::whereIn('id', $departmentIds)->get();
foreach ($departments as $department) {
try {
if ($department->dialog_id > 0 && $dialog = WebSocketDialog::find($department->dialog_id)) {
$dialog->joinGroup([$user->userid], 0, true);
$dialog->pushMsg("groupJoin", null, [$user->userid]);
}
} catch (\Throwable $e) {
// 部门入群为尽力投递:单个部门失败不影响账号创建与其他部门
\Log::warning('createByAdmin: 部门入群失败', [
'userid' => $user->userid,
'department_id' => $department->id,
'error' => $e->getMessage(),
]);
}
}
}
return $user;
}
/**
* 将上传表格Excel::toArray 的二维数组)归一化为导入行
* @param array $sheet
* @return array [{line, email, nickname, password}]
*/
public static function parseImportRows(array $sheet): array
{
$rows = [];
foreach ($sheet as $index => $cells) {
if ($index === 0) {
continue; // 表头
}
$email = trim((string)($cells[0] ?? ''));
$nickname = trim((string)($cells[1] ?? ''));
$password = trim((string)($cells[2] ?? ''));
$profession = trim((string)($cells[3] ?? ''));
if ($email === '' && $nickname === '' && $password === '') {
continue; // 空行(仅职位有值也视为空行跳过)
}
$rows[] = [
'line' => $index + 1, // 电子表格行号(从 1 开始)
'email' => $email,
'nickname' => $nickname,
'password' => $password,
'profession' => $profession,
];
}
return $rows;
}
/**
* 校验单条导入行
* @param array $row ['email'=>,'nickname'=>,'password'=>,'profession'=>(选填)]
* @return string|null 错误文案null 表示通过
*/
public static function validateImportRow(array $row): ?string
{
$email = trim((string)($row['email'] ?? ''));
$nickname = trim((string)($row['nickname'] ?? ''));
$password = trim((string)($row['password'] ?? ''));
if ($email === '' || $nickname === '' || $password === '') {
return '邮箱、昵称、初始密码均为必填';
}
if (!Base::isEmail($email)) {
return '邮箱格式不正确';
}
if (mb_strlen($nickname) < 2 || mb_strlen($nickname) > 20) {
return '昵称需为2-20个字';
}
try {
self::passwordPolicy($password);
} catch (ApiException $e) {
return $e->getMessage();
}
// 职位/职称选填,填写则校验 2-20 字
try {
self::assertValidProfession((string)($row['profession'] ?? ''));
} catch (ApiException $e) {
return $e->getMessage();
}
return null;
}
/**
* 校验职位/职称:非空时必须 2-20 字(复用 operation 的现有文案)
* @param string $profession
* @return void
* @throws ApiException
*/
public static function assertValidProfession(string $profession): void
{
$profession = trim($profession);
if ($profession === '') {
return;
}
if (mb_strlen($profession) < 2) {
throw new ApiException('职位/职称不可以少于2个字');
}
if (mb_strlen($profession) > 20) {
throw new ApiException('职位/职称最多只能设置20个字');
}
}
/**
* 规整并校验部门 ID 列表:转正整数去重、最多 10 个、且每个必须存在
* @param mixed $ids
* @return int[]
* @throws ApiException
*/
public static function assertValidDepartments($ids): array
{
if (!is_array($ids)) {
$ids = [];
}
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), fn($v) => $v > 0)));
if (count($ids) > 10) {
throw new ApiException('最多只可加入10个部门');
}
if ($ids) {
$existing = UserDepartment::whereIn('id', $ids)->pluck('id')->map(fn($v) => (int)$v)->all();
if (count($existing) < count($ids)) {
throw new ApiException('修改部门不存在');
}
}
return $ids;
}
/**
* 批量导入用户(部门/职位逐行department 来自前端逐行设置profession 来自 Excel 行)
* @param array $rows 每行含 email/nickname/password/profession可选 department(int[])
* @param bool $changePass 是否要求首登改密(对本批所有账号生效)
* @return array ['total'=>int, 'success'=>int, 'failed'=>[['line','email','reason']]]
* @throws ApiException 行数超限
*/
public static function importUsers(array $rows, bool $changePass = true): array
{
if (count($rows) > self::IMPORT_MAX) {
throw new ApiException('单次最多导入' . self::IMPORT_MAX . '条');
}
$success = 0;
$failed = [];
$seen = [];
foreach ($rows as $row) {
$error = self::validateImportRow($row);
if ($error === null) {
$emailLower = strtolower(trim((string)$row['email']));
if (isset($seen[$emailLower])) {
$error = '文件内邮箱重复';
} else {
$seen[$emailLower] = true;
}
}
if ($error === null) {
try {
self::createByAdmin($row['email'], $row['password'], $row['nickname'], [
'changePass' => $changePass,
'emailVerity' => !empty($row['email_verity']),
'department' => $row['department'] ?? [],
'profession' => $row['profession'] ?? '',
]);
$success++;
continue;
} catch (ApiException $e) {
$error = $e->getMessage();
}
}
$failed[] = [
'line' => $row['line'] ?? 0,
'email' => $row['email'] ?? '',
'reason' => $error,
];
}
return [
'total' => count($rows),
'success' => $success,
'failed' => $failed,
];
}
/**
* 批量导入预览(只解析+校验,不创建任何账号)
* 逐行判定 ok/error必填/邮箱格式/昵称长度/密码策略、文件内邮箱重复、系统中邮箱已存在
* @param array $rows parseImportRows 的输出
* @return array ['total'=>int,'valid'=>int,'invalid'=>int,'rows'=>[['line','email','nickname','password','status','reason']]]
*/
public static function importPreview(array $rows): array
{
if (count($rows) > self::IMPORT_MAX) {
throw new ApiException('单次最多导入' . self::IMPORT_MAX . '条');
}
// 预查系统中已存在的邮箱(小写比较)
$emails = [];
foreach ($rows as $row) {
$e = strtolower(trim((string)($row['email'] ?? '')));
if ($e !== '') {
$emails[$e] = true;
}
}
$existing = [];
if ($emails) {
foreach (self::whereIn('email', array_keys($emails))->pluck('email') as $em) {
$existing[strtolower($em)] = true;
}
}
$seen = [];
$valid = 0;
$list = [];
foreach ($rows as $row) {
$reason = self::validateImportRow($row);
$emailLower = strtolower(trim((string)($row['email'] ?? '')));
if ($reason === null) {
if (isset($seen[$emailLower])) {
$reason = '文件内邮箱重复';
} else {
$seen[$emailLower] = true;
if (isset($existing[$emailLower])) {
$reason = '邮箱地址已存在';
}
}
}
$ok = $reason === null;
if ($ok) {
$valid++;
}
$list[] = [
'line' => $row['line'] ?? 0,
'email' => $row['email'] ?? '',
'nickname' => $row['nickname'] ?? '',
'password' => $row['password'] ?? '',
'profession' => $row['profession'] ?? '',
'email_verity' => 1, // 默认标记为已认证,前端可在预览中按行调整
'status' => $ok ? 'ok' : 'error',
'reason' => $reason ?? '',
];
}
return [
'total' => count($rows),
'valid' => $valid,
'invalid' => count($rows) - $valid,
'rows' => $list,
];
}
/**
* 获取我的ID
* @return int

View File

@@ -151,6 +151,7 @@ class UserBot extends AbstractModel
$name = match ($name) {
'system-msg' => '系统消息',
'task-alert' => '任务提醒',
'todo-alert' => '待办提醒',
'check-in' => '签到打卡',
'anon-msg' => '匿名消息',
'approval-alert' => '审批',

View File

@@ -3,7 +3,9 @@
namespace App\Models;
use App\Exceptions\ApiException;
use App\Module\Base;
use Cache;
use Request;
/**
* App\Models\UserDepartment
@@ -35,6 +37,10 @@ use Cache;
*/
class UserDepartment extends AbstractModel
{
protected $appends = [
'deputy_userids',
];
/**
* 获取所有父级部门
* @return array
@@ -50,6 +56,55 @@ class UserDepartment extends AbstractModel
return $parents;
}
/**
* 部门管理员 userid 列表
* @return array
*/
public function getDeputyUseridsAttribute(): array
{
if (empty($this->id)) {
return [];
}
return \DB::table('user_department_owners')
->where('department_id', $this->id)
->pluck('userid')
->map(fn($v) => (int)$v)
->toArray();
}
/**
* 是否部门负责人(与 owner_userid 一致)
*/
public function isPrimaryOwner($userid): bool
{
if (empty($this->id) || $userid <= 0) {
return false;
}
return (int)$this->owner_userid === (int)$userid;
}
/**
* 是否部门管理员(在 user_department_owners 表里)
*/
public function isDeputyOwner($userid): bool
{
if (empty($this->id) || $userid <= 0) {
return false;
}
return \DB::table('user_department_owners')
->where('department_id', $this->id)
->where('userid', $userid)
->exists();
}
/**
* 是否负责人(含部门管理员)
*/
public function isOwner($userid): bool
{
return $this->isPrimaryOwner($userid) || $this->isDeputyOwner($userid);
}
/**
* 保存部门
* @param $data
@@ -65,18 +120,38 @@ class UserDepartment extends AbstractModel
}
$this->updateInstance($data);
//
// 防御:新负责人若残留在 user_department_owners 中(如曾是该部门管理员),清理掉
// 否则后续 delDeputy / 罢免接口会把当前部门负责人误移出部门
if ($this->id && (int)$this->owner_userid > 0) {
\DB::table('user_department_owners')
->where('department_id', $this->id)
->where('userid', (int)$this->owner_userid)
->delete();
}
//
if ($this->dialog_id > 0) {
// 已有群
$dialog = WebSocketDialog::find($this->dialog_id);
if ($dialog) {
$oldOwnerId = (int)$dialog->owner_id;
$dialog->name = $this->name;
$dialog->owner_id = $this->owner_userid;
if ($dialog->save()) {
$dialog->joinGroup($this->owner_userid, 0, true);
// 同步 role原负责人 role=0、新负责人 role=1部门管理员 role=2 保留不动)
if ($oldOwnerId > 0 && $oldOwnerId !== (int)$this->owner_userid) {
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $oldOwnerId)
->update(['role' => 0]);
}
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $this->owner_userid)
->update(['role' => 1]);
$dialog->pushMsg("groupUpdate", [
'id' => $dialog->id,
'name' => $dialog->name,
'owner_id' => $dialog->owner_id,
'deputy_ids' => $dialog->fresh()->deputy_ids,
]);
}
}
@@ -86,16 +161,33 @@ class UserDepartment extends AbstractModel
if (empty($dialog)) {
throw new ApiException("选择现有聊天群不存在");
}
$oldOwnerId = (int)$dialog->owner_id;
$dialog->name = $this->name;
$dialog->owner_id = $this->owner_userid;
$dialog->group_type = 'department';
if ($dialog->save()) {
$dialog->joinGroup($this->owner_userid, 0, true);
// 同步 role原负责人 role=0、新负责人 role=1、原部门管理员 role=0
// 原部门管理员清零:避免 dialog_users.role=2 与 user_department_owners 不一致
// (部门管理员关系不带过来,须通过 addDeputy 显式重新任命)
if ($oldOwnerId > 0 && $oldOwnerId !== (int)$this->owner_userid) {
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $oldOwnerId)
->update(['role' => 0]);
}
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', '!=', $this->owner_userid)
->where('role', 2)
->update(['role' => 0]);
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $this->owner_userid)
->update(['role' => 1]);
$dialog->pushMsg("groupUpdate", [
'id' => $dialog->id,
'name' => $dialog->name,
'owner_id' => $dialog->owner_id,
'group_type' => $dialog->group_type,
'deputy_ids' => $dialog->fresh()->deputy_ids,
]);
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'notice', [
'notice' => User::nickname() . " 将此群改为部门群"
@@ -116,6 +208,12 @@ class UserDepartment extends AbstractModel
$oldUser->department = array_diff($oldUser->department, [$this->id]);
$oldUser->department = "," . implode(",", $oldUser->department) . ",";
$oldUser->save();
// 原主从 users.department 移除后也要退出部门群(保持成员关系=群关系一致)
// checkDelete=false业务流程跳过 owner_id/important 校验
if ($this->dialog_id > 0) {
$dialog = WebSocketDialog::find($this->dialog_id);
$dialog?->exitGroup($oldUser->userid, 'remove', false, true);
}
}
if ($newUser) {
$newUser->department = array_diff($newUser->department, [$this->id]);
@@ -126,6 +224,123 @@ class UserDepartment extends AbstractModel
});
}
/**
* 任命部门管理员
* - 部门管理员自动加入 users.department成为部门成员与负责人对齐
* - 部门管理员自动加入部门群 + 设 role=2
* - 幂等(已是部门管理员不报错)
*
* @param int $userid
* @return void
* @throws ApiException
*/
public function addDeputy($userid)
{
if ($userid <= 0) {
throw new ApiException('请选择有效的成员');
}
$user = User::whereUserid($userid)->first();
if (!$user) {
throw new ApiException('该用户不存在');
}
if ((int)$this->owner_userid === (int)$userid) {
throw new ApiException('不能将部门负责人任命为部门管理员');
}
AbstractModel::transaction(function () use ($userid, $user) {
// 写部门管理员表unique key 自动幂等)
\DB::table('user_department_owners')->insertOrIgnore([
'department_id' => $this->id,
'userid' => $userid,
]);
// 加入 users.department成为部门成员与负责人对齐
$userDeptIds = $user->department; // accessor 返回数组
if (!in_array($this->id, $userDeptIds)) {
$userDeptIds = array_merge($userDeptIds, [$this->id]);
$user->department = "," . implode(",", $userDeptIds) . ",";
$user->save();
}
// 加部门管理员入部门群 + 设 role=2 + important=true
if ($this->dialog_id > 0) {
$dialog = WebSocketDialog::find($this->dialog_id);
if ($dialog) {
// joinGroup($userid, $inviter, $important=null, $pushMsg=true)
// important=true部门管理员成员关系不可被普通群操作打散
$dialog->joinGroup($userid, 0, true, true);
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $userid)
->update(['role' => 2]);
$dialog->pushMsg('groupUpdate', [
'id' => $dialog->id,
'deputy_ids' => $dialog->fresh()->deputy_ids,
]);
}
}
});
}
/**
* 罢免部门管理员
* - 删部门管理员表记录
* - 从 users.department 移除该部门 ID与负责人"离开部门"对齐)
* - 退出部门群(成员关系=群关系一致)
* - 幂等
*
* @param int $userid
* @return void
*/
public function delDeputy($userid)
{
if ($userid <= 0) {
return;
}
// 防御当前部门负责人不能被罢免saveDepartment 应已清理残留,此处兜底)
// 仅清理 user_department_owners 中的悬挂记录,绝不联动移除其部门成员关系/部门群成员
if ((int)$this->owner_userid === (int)$userid) {
\DB::table('user_department_owners')
->where('department_id', $this->id)
->where('userid', $userid)
->delete();
return;
}
AbstractModel::transaction(function () use ($userid) {
$deleted = \DB::table('user_department_owners')
->where('department_id', $this->id)
->where('userid', $userid)
->delete();
if ($deleted > 0) {
// 从 users.department 移除该部门 ID
$user = User::whereUserid($userid)->first();
if ($user) {
$userDeptIds = $user->department;
if (in_array($this->id, $userDeptIds)) {
$userDeptIds = array_diff($userDeptIds, [$this->id]);
$user->department = "," . implode(",", $userDeptIds) . ",";
$user->save();
}
}
// 退出部门群exitGroup 会清除 dialog_users 记录role 随之消失)
if ($this->dialog_id > 0) {
$dialog = WebSocketDialog::find($this->dialog_id);
if ($dialog) {
// checkDelete=false业务流程跳过 owner_id/important 校验
$dialog->exitGroup($userid, 'remove', false, true);
$dialog->pushMsg('groupUpdate', [
'id' => $dialog->id,
'deputy_ids' => $dialog->fresh()->deputy_ids,
]);
}
}
}
});
}
/**
* 删除部门
* @return void
@@ -148,6 +363,8 @@ class UserDepartment extends AbstractModel
// 解散群组
$dialog = WebSocketDialog::find($this->dialog_id);
$dialog?->deleteDialog();
// 清理部门管理员记录(防悬挂)
\DB::table('user_department_owners')->where('department_id', $this->id)->delete();
//
$this->delete();
}
@@ -160,6 +377,7 @@ class UserDepartment extends AbstractModel
*/
public static function transfer($originalUserid, $newUserid)
{
// 部门负责人转让(保持现有逻辑)
self::whereOwnerUserid($originalUserid)->chunkById(100, function ($list) use ($originalUserid, $newUserid) {
/** @var self $item */
foreach ($list as $item) {
@@ -168,6 +386,11 @@ class UserDepartment extends AbstractModel
]);
}
});
// 部门管理员离职清理(新增):直接删除离职用户的所有部门管理员记录
// 不需要清群 role —— UserTransfer::exitDialog 会把人踢出所有群role 随成员关系一起消失
\DB::table('user_department_owners')
->where('userid', $originalUserid)
->delete();
}
/**
@@ -190,6 +413,93 @@ class UserDepartment extends AbstractModel
return array_unique($subIds);
}
/**
* 获取用户可切换负责人视角的部门(正负责人 + 部门管理员)
* @param int $userid
* @return \Illuminate\Support\Collection
*/
public static function getManagedDepartments($userid)
{
$userid = intval($userid);
if ($userid <= 0) {
return collect();
}
$deputyDepartmentIds = \DB::table('user_department_owners')
->where('userid', $userid)
->pluck('department_id')
->map(fn($v) => intval($v))
->toArray();
return self::select(['id', 'name', 'parent_id', 'owner_userid'])
->where(function ($query) use ($userid, $deputyDepartmentIds) {
$query->where('owner_userid', $userid);
if ($deputyDepartmentIds) {
$query->orWhereIn('id', $deputyDepartmentIds);
}
})
->orderBy('id')
->get();
}
/**
* 获取用户选择的负责人视角部门范围(含所有下级部门)
* @param int $userid
* @param array|string|null $selectedIds all/空表示全部可管理部门
* @return array
*/
public static function getManagedDepartmentScopeIds($userid, $selectedIds = null): array
{
$managedIds = self::getManagedDepartments($userid)->pluck('id')->map(fn($v) => intval($v))->toArray();
if (empty($managedIds)) {
return [];
}
if ($selectedIds === 'all' || $selectedIds === null || $selectedIds === '' || $selectedIds === []) {
$selected = $managedIds;
} else {
if (!is_array($selectedIds)) {
$selectedIds = explode(',', (string)$selectedIds);
}
$selected = array_values(array_intersect(
array_map('intval', $selectedIds),
$managedIds
));
}
if (empty($selected)) {
return [];
}
$scopeIds = [];
foreach ($selected as $departmentId) {
$scopeIds[] = $departmentId;
$scopeIds = array_merge($scopeIds, self::getAllSubDepartmentIds($departmentId));
}
return array_values(array_unique(array_map('intval', $scopeIds)));
}
/**
* 获取负责人视角可管理的成员 userid
* @param int $userid
* @param array|string|null $selectedIds
* @return array
*/
public static function getManagedMemberUserids($userid, $selectedIds = null): array
{
$departmentIds = self::getManagedDepartmentScopeIds($userid, $selectedIds);
if (empty($departmentIds)) {
return [];
}
return User::select(['userid'])
->where(function ($query) use ($departmentIds) {
foreach ($departmentIds as $departmentId) {
$query->orWhere('department', 'like', "%,{$departmentId},%");
}
})
->pluck('userid')
->map(fn($v) => intval($v))
->unique()
->values()
->toArray();
}
/**
* 获取部门基本信息缓存时间1小时
* @param int|array $ids
@@ -232,4 +542,142 @@ class UserDepartment extends AbstractModel
return is_array($ids) ? $result : $result->first();
}
/**
* 部门负责人视角上下文(只读)。
* $defaultAll=true 用于项目内只读辅助接口兜底:前端漏传部门选择时按全部可管理部门判断。
*/
public static function ownerViewContext(User $user, bool $defaultAll = false): array
{
$ids = Request::input('department_owner_ids', Request::input('department_ids'));
if (($ids === null || $ids === '') && $defaultAll) {
$ids = 'all';
}
$empty = [
'enabled' => false,
'member_userids' => [],
'project_ids' => [],
'project_id_map' => [],
'own_project_ids' => [],
'own_project_id_map' => [],
];
if ($ids === null || $ids === '' || Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') {
return $empty;
}
$memberUserids = self::getManagedMemberUserids($user->userid, $ids);
if (empty($memberUserids)) {
return $empty;
}
// 项目可单独关闭"部门负责人视角可见",关闭后对负责人隐藏(含项目和任务群聊)
$projectIds = ProjectUser::whereIn('project_users.userid', $memberUserids)
->join('projects', 'projects.id', '=', 'project_users.project_id')
->whereNull('projects.deleted_at')
->where(function ($query) {
$query->where('projects.department_owner_view', '<>', 'close')
->orWhereNull('projects.department_owner_view');
})
->distinct()
->pluck('projects.id')
->map(fn($v) => intval($v))
->values()
->toArray();
$ownProjectIds = ProjectUser::whereUserid($user->userid)
->pluck('project_id')
->map(fn($v) => intval($v))
->unique()
->values()
->toArray();
return [
'enabled' => !empty($projectIds),
'member_userids' => $memberUserids,
'project_ids' => $projectIds,
'project_id_map' => array_fill_keys($projectIds, true),
'own_project_ids' => $ownProjectIds,
'own_project_id_map' => array_fill_keys($ownProjectIds, true),
];
}
/**
* 判断项目是否属于部门只读范围(非本人项目)
*/
public static function isDepartmentReadonlyProject(array $context, int $projectId): bool
{
return !empty($context['enabled'])
&& isset($context['project_id_map'][$projectId])
&& !isset($context['own_project_id_map'][$projectId]);
}
/**
* 为项目数据附加部门只读标记
*/
public static function appendDepartmentReadonlyProject(array $project, array $context): array
{
$project['department_readonly'] = self::isDepartmentReadonlyProject($context, intval($project['id']));
return $project;
}
/**
* 会员卡片「查看该会员项目/任务」的权限上下文。
* 允许条件:本人 / 系统管理员 / 对该会员具有部门负责人只读视角。
* @param User $viewer 当前登录用户
* @param int $targetUserid 目标会员
* @return array ['allowed'=>bool, 'is_self'=>bool, 'is_admin'=>bool, 'project_ids'=>int[]]
* project_ids 仅在部门负责人视角下有意义(限定可见项目集合);本人/管理员为空数组表示不限制
*/
public static function userWorksContext(User $viewer, int $targetUserid): array
{
$result = [
'allowed' => false,
'is_self' => false,
'is_admin' => false,
'project_ids' => [],
];
if ($targetUserid <= 0) {
return $result;
}
// 机器人/系统账号(或不存在)不展示项目与任务
$target = User::select(['userid', 'bot'])->whereUserid($targetUserid)->first();
if (empty($target) || $target->bot) {
return $result;
}
// 本人
if ($viewer->userid === $targetUserid) {
$result['allowed'] = true;
$result['is_self'] = true;
return $result;
}
// 系统管理员
if ($viewer->isAdmin()) {
$result['allowed'] = true;
$result['is_admin'] = true;
return $result;
}
// 部门负责人只读视角
if (Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') {
return $result;
}
$memberUserids = self::getManagedMemberUserids($viewer->userid, 'all');
if (!in_array($targetUserid, $memberUserids, true)) {
return $result;
}
// 目标会员参与、且未关闭「部门负责人视角可见」的项目
$projectIds = ProjectUser::where('project_users.userid', $targetUserid)
->join('projects', 'projects.id', '=', 'project_users.project_id')
->whereNull('projects.deleted_at')
->where(function ($query) {
$query->where('projects.department_owner_view', '<>', 'close')
->orWhereNull('projects.department_owner_view');
})
->distinct()
->pluck('projects.id')
->map(fn($v) => intval($v))
->values()
->toArray();
if (empty($projectIds)) {
return $result;
}
$result['allowed'] = true;
$result['project_ids'] = $projectIds;
return $result;
}
}

View File

@@ -90,9 +90,15 @@ class UserTransfer extends AbstractModel
$dialog->owner_id = $this->new_userid;
if ($dialog->save()) {
$dialog->joinGroup($this->new_userid, 0);
// 同步 role=1保证 deputy_ids 与 owner_id 一致
// 若 new_userid 之前是群管理员role=2升为群主后必须从 deputy 列表移出
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $this->new_userid)
->update(['role' => 1]);
$dialog->pushMsg("groupUpdate", [
'id' => $dialog->id,
'owner_id' => $dialog->owner_id,
'deputy_ids' => $dialog->fresh()->deputy_ids,
]);
}
}

View File

@@ -62,6 +62,11 @@ class WebSocketDialog extends AbstractModel
{
use SoftDeletes;
// 全员群初始化默认名称(双语字面量),用于识别"管理员尚未自定义"的状态
const ALL_GROUP_DEFAULT_NAME = '全体成员 All members';
protected $appends = ['deputy_ids'];
/**
* 头像地址
* @param $value
@@ -260,6 +265,15 @@ class WebSocketDialog extends AbstractModel
$data[$field] = $data[$field] ?? null;
}
}
// DB::table 列表/search/beyond 渠道进入的是 stdClass不会触发 Eloquent $appends。
// 这里统一补齐 deputy_ids保证群管理员入口和标识在所有会话来源中一致。
if (($data['type'] ?? null) === 'group' && !array_key_exists('deputy_ids', $data)) {
$data['deputy_ids'] = WebSocketDialogUser::whereDialogId($data['id'])
->where('role', 2)
->pluck('userid')
->map(fn($v) => (int)$v)
->toArray();
}
$data['avatar'] = Base::fillUrl($data['avatar']);
// 会员必要字段
@@ -355,7 +369,9 @@ class WebSocketDialog extends AbstractModel
}
break;
case 'all':
$data['name'] = Doo::translate('全体成员');
$data['name'] = ($data['name'] && $data['name'] !== self::ALL_GROUP_DEFAULT_NAME)
? $data['name']
: Doo::translate('全体成员');
$data['dialog_mute'] = Base::settingFind('system', 'all_group_mute');
break;
}
@@ -457,11 +473,12 @@ class WebSocketDialog extends AbstractModel
* @param int|array $userid 加入的会员ID或会员ID组
* @param int $inviter 邀请人
* @param bool|null $important 重要人员(null不修改、bool修改)
* @param bool $pushMsg 是否推送消息
* @return bool
*/
public function joinGroup($userid, $inviter, $important = null)
public function joinGroup($userid, $inviter, $important = null, $pushMsg = true)
{
AbstractModel::transaction(function () use ($important, $inviter, $userid) {
AbstractModel::transaction(function () use ($important, $inviter, $userid, $pushMsg) {
foreach (is_array($userid) ? $userid : [$userid] as $value) {
if ($value > 0) {
$updateData = [
@@ -479,7 +496,7 @@ class WebSocketDialog extends AbstractModel
'bot' => User::isBot($value) ? 1 : 0
]);
}, $isInsert);
if ($isInsert) {
if ($isInsert && $pushMsg) {
WebSocketDialogMsg::sendMsg(null, $this->id, 'notice', [
'notice' => User::userid2nickname($value) . " 已加入群组"
], $inviter, true, true);
@@ -487,9 +504,11 @@ class WebSocketDialog extends AbstractModel
}
}
});
$data = WebSocketDialog::generatePeople($this->id);
$data['id'] = $this->id;
$this->pushMsg("groupUpdate", $data);
if ($pushMsg) {
$data = WebSocketDialog::generatePeople($this->id);
$data['id'] = $this->id;
$this->pushMsg("groupUpdate", $data);
}
return true;
}
@@ -515,11 +534,40 @@ class WebSocketDialog extends AbstractModel
foreach ($list as $item) {
if ($checkDelete) {
if ($type === 'remove') {
// 移出时:如果是全员群仅允许管理员操作,其他群仅群主或邀请人可以操作
// 移出时:如果是全员群仅允许管理员操作,其他群主/群管理员/邀请人可以操作
if ($this->group_type === 'all') {
User::auth("admin");
} elseif (!in_array(User::userid(), [$this->owner_id, $item->inviter])) {
throw new ApiException('只有群主或邀请人可以移出成员');
} else {
$actor = User::userid();
// 未认证时拒绝
if ($actor <= 0) {
throw new ApiException('只有群主或邀请人可以移出成员');
}
// 目标是群主或群管理员时的保护
$targetIsPrimaryOwner = $this->isPrimaryOwner($item->userid);
$targetIsDeputyOwner = $this->isDeputyOwner($item->userid);
if ($targetIsPrimaryOwner || $targetIsDeputyOwner) {
// 普通邀请人不能移出群主或群管理员
$actorIsPrimaryOwner = $this->isPrimaryOwner($actor);
$actorIsDeputyOwner = $this->isDeputyOwner($actor);
if (!$actorIsPrimaryOwner && !$actorIsDeputyOwner) {
throw new ApiException('普通成员不能移出群主或群管理员');
}
// 群管理员不能移出群主或其他群管理员
if ($actorIsDeputyOwner && !$actorIsPrimaryOwner) {
throw new ApiException('群管理员不能移出群主或其他群管理员');
}
}
// 普通成员:群主、群管理员、邀请人可移出
$allowedActor = $this->isOwner($actor) || $actor === (int)$item->inviter;
if (!$allowedActor) {
throw new ApiException('只有群主、群管理员或邀请人可以移出成员');
}
}
}
if ($item->userid == $this->owner_id) {
@@ -547,9 +595,11 @@ class WebSocketDialog extends AbstractModel
});
});
//
$data = WebSocketDialog::generatePeople($this->id);
$data['id'] = $this->id;
$this->pushMsg("groupUpdate", $data);
if ($pushMsg) {
$data = WebSocketDialog::generatePeople($this->id);
$data['id'] = $this->id;
$this->pushMsg("groupUpdate", $data);
}
}
/**
@@ -635,6 +685,93 @@ class WebSocketDialog extends AbstractModel
}
}
/**
* 是否群主(与 owner_id 一致)
*/
public function isPrimaryOwner($userid): bool
{
return $userid > 0 && (int)$this->owner_id === (int)$userid;
}
/**
* 是否群管理员(仅 web_socket_dialog_users.role=2
*/
public function isDeputyOwner($userid): bool
{
if ($userid <= 0) {
return false;
}
return WebSocketDialogUser::where('dialog_id', $this->id)
->where('userid', $userid)
->where('role', 2)
->exists();
}
/**
* 是否群主(含群管理员)
*/
public function isOwner($userid): bool
{
return $this->isPrimaryOwner($userid) || $this->isDeputyOwner($userid);
}
/**
* 是否有权限设置/取消本会话内「他人」的待办
* 放行:群主/群管理员、关联项目负责人/项目管理员、关联任务负责人(及任务所属项目负责人/管理员)
*
* @param int $userid
* @return bool
*/
public function checkTodoOwnerPermission($userid): bool
{
$userid = intval($userid);
if ($userid <= 0) {
return false;
}
// 系统管理员:可管理任意会话的他人待办(与管理员全局管理能力一致,覆盖无群主的全员群等)
if (User::find($userid)?->isAdmin()) {
return true;
}
// 群主 / 群管理员
if ($this->isOwner($userid)) {
return true;
}
// 关联项目(项目群)负责人 / 项目管理员
$project = Project::whereDialogId($this->id)->first();
if ($project && $project->isOwner($userid)) {
return true;
}
// 关联任务(任务群)负责人,及任务所属项目负责人 / 管理员
$task = ProjectTask::whereDialogId($this->id)->first();
if ($task) {
if (ProjectTaskUser::whereTaskId($task->id)->whereUserid($userid)->whereOwner(1)->exists()) {
return true;
}
$taskProject = Project::find($task->project_id);
if ($taskProject && $taskProject->isOwner($userid)) {
return true;
}
}
return false;
}
/**
* 群管理员 userid 列表
*
* @return array
*/
public function getDeputyIdsAttribute(): array
{
if (!$this->id) {
return [];
}
return WebSocketDialogUser::where('dialog_id', $this->id)
->where('role', 2)
->pluck('userid')
->map(fn($v) => (int)$v)
->toArray();
}
/**
* 检查禁言
* @param $userid
@@ -692,7 +829,9 @@ class WebSocketDialog extends AbstractModel
$name = \DB::table('project_tasks')->where('dialog_id', $this->id)->value('name');
break;
case 'all':
$name = Doo::translate('全体成员');
$name = ($name && $name !== self::ALL_GROUP_DEFAULT_NAME)
? $name
: Doo::translate('全体成员');
break;
}
}
@@ -820,6 +959,13 @@ class WebSocketDialog extends AbstractModel
if ($projectId > 0 && ProjectUser::whereProjectId($projectId)->whereUserid($userid)->exists()) {
return $dialog;
}
// 部门负责人只读视角:项目/任务群按项目级共享放行(任务数据另按可见性校验,与普通成员一致)
if ($projectId > 0 && $checkOwner === false) {
$departmentView = UserDepartment::ownerViewContext(User::auth(), true);
if (UserDepartment::isDepartmentReadonlyProject($departmentView, $projectId)) {
return $dialog;
}
}
break;
case 'okr':
@@ -857,6 +1003,7 @@ class WebSocketDialog extends AbstractModel
WebSocketDialogUser::createInstance([
'dialog_id' => $dialog->id,
'userid' => $value,
'role' => ($owner_id > 0 && (int)$value === (int)$owner_id) ? 1 : 0,
'bot' => User::isBot($value) ? 1 : 0,
'important' => !in_array($group_type, ['user', 'all']),
'last_at' => in_array($group_type, ['user', 'department', 'all']) ? Carbon::now() : null,

View File

@@ -414,7 +414,7 @@ class WebSocketDialogMsg extends AbstractModel
* @param array $userids 设置给指定会员
* @return mixed
*/
public function toggleTodoMsg($sender, $userids = [])
public function toggleTodoMsg($sender, $userids = [], $remindAt = false)
{
if (in_array($this->type, ['tag', 'todo', 'notice'])) {
return Base::retError('此消息不支持设待办');
@@ -423,6 +423,14 @@ class WebSocketDialogMsg extends AbstractModel
$current = WebSocketDialogMsgTodo::whereMsgId($this->id)->pluck('userid')->toArray();
$cancel = array_diff($current, $userids);
$setup = array_diff($userids, $current);
// 待办操作权限管控(系统开关:禁止其他人员设置/取消待办)
if (Base::settingFind('system', 'todo_set_permission') === 'close') {
$affected = array_unique(array_merge($cancel, $setup)); // 本次真正影响到的用户
$others = array_diff($affected, [$sender]); // 排除"自己"
if ($others && !$dialog->checkTodoOwnerPermission($sender)) {
return Base::retError('仅群主、项目/任务负责人或系统管理员可设置或取消他人待办');
}
}
//
$this->todo = $setup || count($current) > count($cancel) ? $sender : 0;
$this->save();
@@ -477,12 +485,39 @@ class WebSocketDialogMsg extends AbstractModel
];
$dialog->pushMsg('update', $upData);
//
// 提醒时间仅当调用方显式传入时处理false=不传则不动既有提醒)
if ($remindAt !== false) {
$this->setTodoRemind($userids, $remindAt ?: null);
}
//
return Base::retSuccess($this->todo ? '设置成功' : '取消成功', [
'add' => $addData,
'update' => $upData,
]);
}
/**
* 设置/取消本消息指定成员待办的提醒时间(纯数据,无推送)。
* 改动会把 reminded_at 重置为 null使其可再次到点提醒。
*
* @param array $userids 目标成员
* @param string|null $remindAt 提醒时间字符串null/空 表示取消提醒
* @return int 受影响行数
*/
public function setTodoRemind(array $userids, $remindAt = null)
{
$userids = array_values(array_filter(array_map('intval', $userids)));
if (empty($userids)) {
return 0;
}
return WebSocketDialogMsgTodo::whereMsgId($this->id)
->whereIn('userid', $userids)
->update([
'remind_at' => $remindAt ?: null,
'reminded_at' => null,
]);
}
/**
* 转发消息
* @param array|int $dialogids
@@ -492,9 +527,58 @@ class WebSocketDialogMsg extends AbstractModel
* @param string $leaveMessage 转发留言
* @return mixed
*/
/**
* 收集目标对话
* @param array|int $userids 转发给的成员ID
* @param array|int $dialogids 转发给的对话ID
* @param User $user 当前用户
* @return array
*/
private static function collectTargetDialogs($userids, $dialogids, $user)
{
$dialogs = [];
if ($userids) {
if (!is_array($userids)) {
$userids = [$userids];
}
foreach ($userids as $userid) {
if (!User::whereUserid($userid)->exists()) {
continue;
}
$dialog = WebSocketDialog::checkUserDialog($user, $userid);
if ($dialog) {
$dialogs[$dialog->id] = $dialog;
}
}
}
if ($dialogids) {
if (!is_array($dialogids)) {
$dialogids = [$dialogids];
}
foreach ($dialogids as $dialogid) {
if (isset($dialogs[$dialogid])) {
continue;
}
$dialog = WebSocketDialog::find($dialogid);
if ($dialog) {
$dialogs[$dialog->id] = $dialog;
}
}
}
return $dialogs;
}
/**
* 不支持转发的消息类型
*/
public static $unforwardableTypes = ['tag', 'top', 'todo', 'notice', 'word-chain', 'vote', 'template'];
public function forwardMsg($dialogids, $userids, $user, $showSource = 1, $leaveMessage = '')
{
return AbstractModel::transaction(function () use ($dialogids, $user, $userids, $showSource, $leaveMessage) {
if (in_array($this->type, self::$unforwardableTypes)) {
throw new ApiException('此类型消息不支持转发');
}
$msgData = Base::json2array($this->getRawOriginal('msg'));
$forwardData = is_array($msgData['forward_data']) ? $msgData['forward_data'] : [];
$forwardId = $forwardData['id'] ?: $this->id;
@@ -513,35 +597,7 @@ class WebSocketDialogMsg extends AbstractModel
'leave' => $leaveMessage ? 1 : 0, // 是否留言用于判断是否发给AI
];
$msgs = [];
$dialogs = [];
if ($userids) {
if (!is_array($userids)) {
$userids = [$userids];
}
foreach ($userids as $userid) {
if (!User::whereUserid($userid)->exists()) {
continue;
}
$dialog = WebSocketDialog::checkUserDialog($user, $userid);
if ($dialog) {
$dialogs[$dialog->id] = $dialog;
}
}
}
if ($dialogids) {
if (!is_array($dialogids)) {
$dialogids = [$dialogids];
}
foreach ($dialogids as $dialogid) {
if (isset($dialogs[$dialogid])) {
continue;
}
$dialog = WebSocketDialog::find($dialogid);
if ($dialog) {
$dialogs[$dialog->id] = $dialog;
}
}
}
$dialogs = self::collectTargetDialogs($userids, $dialogids, $user);
foreach ($dialogs as $dialog) {
$res = self::sendMsg('forward-' . $forwardId, $dialog->id, $this->type, $msgData, $user->userid);
if (Base::isSuccess($res)) {
@@ -564,6 +620,105 @@ class WebSocketDialogMsg extends AbstractModel
});
}
/**
* 合并转发消息
* @param array $msgIds 消息ID数组
* @param array|int $dialogids 转发给的对话ID
* @param array|int $userids 转发给的成员ID
* @param User $user 当前用户
* @param int $showSource 是否显示原发送者信息
* @param string $leaveMessage 转发留言
* @return array
*/
public static function mergeForwardMsg($msgIds, $dialogids, $userids, $user, $showSource = 1, $leaveMessage = '')
{
return AbstractModel::transaction(function () use ($msgIds, $dialogids, $userids, $user, $showSource, $leaveMessage) {
// 查询并验证所有消息
$msgs = self::whereIn('id', $msgIds)->orderBy('created_at')->get();
if ($msgs->isEmpty()) {
throw new ApiException('消息不存在或已被删除');
}
// 验证所有消息属于同一对话
$dialogId = $msgs->first()->dialog_id;
if ($msgs->pluck('dialog_id')->unique()->count() > 1) {
throw new ApiException('只能合并转发同一对话的消息');
}
WebSocketDialog::checkDialog($dialogId);
// 过滤不支持转发的消息类型
$msgs = $msgs->filter(function ($msg) {
return !in_array($msg->type, self::$unforwardableTypes);
});
if ($msgs->isEmpty()) {
throw new ApiException('所选消息均不支持转发');
}
// 收集发送者信息
$senderIds = $msgs->pluck('userid')->unique()->values()->toArray();
$senderNames = User::whereIn('userid', array_slice($senderIds, 0, 2))
->pluck('nickname')
->toArray();
// 组装预览列表前4条精简字段
$msgIds = $msgs->pluck('id')->toArray();
$preview = [];
foreach ($msgs->take(4) as $msg) {
$preview[] = [
'userid' => $msg->userid,
'type' => $msg->type,
'msg' => self::buildPreviewMsg($msg->type, Base::json2array($msg->getRawOriginal('msg'))),
];
}
// 构建合并转发消息体
$msgData = [
'sender_names' => $senderNames,
'sender_total' => count($senderIds),
'msg_ids' => $msgIds,
'preview' => $preview,
'count' => count($msgIds),
'forward_data' => [
'show' => $showSource,
'leave' => $leaveMessage ? 1 : 0,
],
];
$dialogs = self::collectTargetDialogs($userids, $dialogids, $user);
// 发送到每个目标对话
$result = [];
foreach ($dialogs as $dialog) {
$res = self::sendMsg(null, $dialog->id, 'merge-forward', $msgData, $user->userid);
if (Base::isSuccess($res)) {
$result[] = $res['data'];
}
if ($leaveMessage) {
$res = self::sendMsg(null, $dialog->id, 'text', ['text' => $leaveMessage], $user->userid);
if (Base::isSuccess($res)) {
$result[] = $res['data'];
}
}
}
return Base::retSuccess('转发成功', [
'msgs' => $result
]);
});
}
/**
* 构建预览消息(精简字段)
* @param string $type
* @param array $msg
* @return array
*/
private static function buildPreviewMsg($type, $msg)
{
switch ($type) {
case 'text':
return ['text' => $msg['text'] ?? ''];
case 'file':
return ['name' => $msg['name'] ?? '', 'ext' => $msg['ext'] ?? ''];
case 'location':
return ['title' => $msg['title'] ?? ''];
default:
return [];
}
}
/**
* 删除消息
* @param array|int $ids
@@ -695,6 +850,9 @@ class WebSocketDialogMsg extends AbstractModel
case 'template':
return self::previewTemplateMsg($data['msg']);
case 'merge-forward':
return "[" . Doo::translate("聊天记录") . "]";
case 'preview':
return $data['msg']['preview'];
@@ -1262,6 +1420,9 @@ class WebSocketDialogMsg extends AbstractModel
$msg['height'] = $imageSize[1];
}
}
if ($type === 'merge-forward') {
$mtype = 'merge-forward';
}
if ($push_silence === null) {
$push_silence = !in_array($type, ["text", "file", "record", "meeting"]);
}

View File

@@ -2,6 +2,8 @@
namespace App\Models;
use Carbon\Carbon;
/**
* App\Models\WebSocketDialogMsgTodo
*
@@ -50,4 +52,21 @@ class WebSocketDialogMsgTodo extends AbstractModel
}
return $this->appendattrs['msgData'];
}
/**
* 取到点待提醒的待办行:有提醒时间、未提醒、未完成、提醒时间已到。
* 纯查询,无副作用,供 TodoRemindTask 使用。
* @return \Illuminate\Database\Eloquent\Collection
*/
public static function dueReminders()
{
return self::whereNotNull('remind_at')
->whereNull('reminded_at')
->whereNull('done_at')
->where('remind_at', '<=', Carbon::now())
->orderBy('msg_id')
->orderBy('id')
->limit(500)
->get();
}
}

View File

@@ -166,14 +166,29 @@ class AI
continue;
}
$role = trim((string)($item[0] ?? ''));
$message = trim((string)($item[1] ?? ''));
if ($role === '' || $message === '') {
$message = $item[1] ?? '';
// 跳过空消息
if (empty($message)) {
continue;
}
// 替换系统条件性提示块占位符
if (str_contains($message, '{{SYSTEM_OPTIONAL_PROMPTS}}')) {
$optionalPrompts = PromptPlaceholder::buildOptionalPrompts(User::userid());
$message = str_replace('{{SYSTEM_OPTIONAL_PROMPTS}}', $optionalPrompts, $message);
// 处理纯文本(字符串)
if (!is_array($message)) {
// 纯文本
$message = trim((string)$message);
if ($role === '' || $message === '') {
continue;
}
// 替换系统条件性提示块占位符
if (str_contains($message, '{{SYSTEM_OPTIONAL_PROMPTS}}')) {
$optionalPrompts = PromptPlaceholder::buildOptionalPrompts(User::userid());
$message = str_replace('{{SYSTEM_OPTIONAL_PROMPTS}}', $optionalPrompts, $message);
}
}
if ($role === '') {
continue;
}
$context[] = [$role, $message];
}
@@ -189,12 +204,6 @@ class AI
}
$apiKey = Base::val($setting, $modelType . '_key');
if ($modelType === 'wenxin') {
$wenxinSecret = Base::val($setting, 'wenxin_secret');
if ($wenxinSecret) {
$apiKey = trim(($apiKey ?: '') . ':' . $wenxinSecret);
}
}
if ($modelType === 'ollama' && empty($apiKey)) {
$apiKey = Base::strRandom(6);
}
@@ -224,6 +233,10 @@ class AI
$authParams['agency'] = $agency;
}
// 从模型名末尾剥离思考标记,支持以下写法:
// 模型名 think / 模型名-thinking / 模型名_reasoning (空格、- 、_ 作分隔)
// 模型名(think) / 模型名 ( reasoning ) (括号包裹)
// 关键词三选一think | thinking | reasoning
$thinkPatterns = [
"/^(.+?)(\s+|\s*[_-]\s*)(think|thinking|reasoning)\s*$/",
"/^(.+?)\s*\(\s*(think|thinking|reasoning)\s*\)\s*$/"
@@ -234,6 +247,7 @@ class AI
break;
}
}
// 命中后把关键词剥掉,只保留前面的真实模型名
if ($thinkMatch && !empty($thinkMatch[1])) {
$authParams['model_name'] = $thinkMatch[1];
}
@@ -261,6 +275,101 @@ class AI
]);
}
/**
* 通用 AI 调用接口
* 适用于自定义对话场景
*
* @param array $messages 消息数组,格式:[['role', 'content'], ...]
* role: system | user | assistant
* @param int $timeout 超时时间(秒)
* @param bool $noCache 是否禁用缓存
* @return array 返回结果,成功时 data 包含 content 字段
*/
public static function invoke(array $messages, int $timeout = 60, bool $noCache = true): array
{
if (!Apps::isInstalled('ai')) {
return Base::retError('应用「AI Assistant」未安装');
}
if (empty($messages)) {
return Base::retError('消息内容不能为空');
}
$provider = self::resolveTextProvider();
if (!$provider) {
return Base::retError("请先配置 AI 助手");
}
// 转换消息格式
$formattedMessages = [];
foreach ($messages as $msg) {
if (!is_array($msg) || count($msg) < 2) {
continue;
}
$role = trim((string)($msg[0] ?? ''));
$content = trim((string)($msg[1] ?? ''));
if ($role === '' || $content === '') {
continue;
}
// 标准化 role
$role = match ($role) {
'system' => 'system',
'assistant' => 'assistant',
default => 'user',
};
$formattedMessages[] = [
'role' => $role,
'content' => $content,
];
}
if (empty($formattedMessages)) {
return Base::retError('消息内容格式错误');
}
// 构建缓存 key
$cacheKey = "AIInvoke::" . md5(json_encode($formattedMessages));
if ($noCache) {
Cache::forget($cacheKey);
}
$result = Cache::remember($cacheKey, Carbon::now()->addHours(1), function () use ($formattedMessages, $provider, $timeout) {
$payload = [
"model" => $provider['model'],
"messages" => $formattedMessages,
];
$reasoningEffort = self::getReasoningEffort($provider);
if ($reasoningEffort !== null) {
$payload['reasoning_effort'] = $reasoningEffort;
}
$post = json_encode($payload);
$ai = new self($post);
$ai->setProvider($provider);
$ai->setTimeout($timeout);
$res = $ai->request();
if (Base::isError($res)) {
return Base::retError("AI 调用失败", $res);
}
$content = $res['data'];
if (empty($content)) {
return Base::retError("AI 返回内容为空");
}
return Base::retSuccess("success", [
'content' => $content,
]);
});
if (Base::isError($result)) {
Cache::forget($cacheKey);
}
return $result;
}
/** ******************************************************************************************** */
/** ******************************************************************************************** */
/** ******************************************************************************************** */
@@ -658,14 +767,6 @@ class AI
}
$model = trim((string)($setting[$vendor . '_model'] ?? ''));
break;
case 'wenxin':
$secret = trim((string)($setting['wenxin_secret'] ?? ''));
if ($key === '' || $secret === '' || $baseUrl === '') {
return null;
}
$key = $key . ':' . $secret;
$model = trim((string)($setting[$vendor . '_model'] ?? ''));
break;
default:
if ($key === '' || $baseUrl === '') {
return null;

View File

@@ -0,0 +1,858 @@
<?php
namespace App\Module;
use App\Models\ProjectTask;
use App\Models\ProjectTaskAiEvent;
use App\Models\ProjectTaskUser;
use App\Models\ProjectUser;
use App\Models\User;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogMsg;
use App\Module\Apps;
use App\Module\Manticore\ManticoreBase;
use Cache;
use Carbon\Carbon;
class AiTaskSuggestion
{
/**
* AI 助手的 userid
*/
const AI_ASSISTANT_USERID = -1;
/**
* 相似度阈值
*/
const SIMILAR_THRESHOLD = 0.5;
/**
* 检查是否满足执行条件
*/
public static function shouldExecute(ProjectTask $task, string $eventType): bool
{
switch ($eventType) {
case ProjectTaskAiEvent::EVENT_DESCRIPTION:
// 描述为空或长度 < 20
$content = trim($task->content ?? '');
return empty($content) || mb_strlen($content) < 20;
case ProjectTaskAiEvent::EVENT_SUBTASKS:
// 无子任务且标题长度 > 5
$hasSubtasks = ProjectTask::where('parent_id', $task->id)->exists();
return !$hasSubtasks && mb_strlen($task->name) > 5;
case ProjectTaskAiEvent::EVENT_ASSIGNEE:
// 未指定负责人
$hasOwner = ProjectTaskUser::where('task_id', $task->id)->where('owner', 1)->exists();
return !$hasOwner;
case ProjectTaskAiEvent::EVENT_SIMILAR:
// 需要安装 search 插件才能使用向量搜索
return Apps::isInstalled('search');
default:
return false;
}
}
/**
* 生成任务描述建议
* @param ProjectTask $task 任务对象
*/
public static function generateDescription(ProjectTask $task): ?array
{
$language = self::getUserLanguageInfo($task->userid)['name'];
$prompt = self::buildDescriptionPrompt($task, $language);
$result = self::callAi($prompt);
if (empty($result)) {
return null;
}
return [
'type' => 'description',
'content' => $result,
];
}
/**
* 生成子任务拆分建议
* @param ProjectTask $task 任务对象
*/
public static function generateSubtasks(ProjectTask $task): ?array
{
$language = self::getUserLanguageInfo($task->userid)['name'];
$prompt = self::buildSubtasksPrompt($task, $language);
$result = self::callAi($prompt);
if (empty($result)) {
return null;
}
// 解析返回的子任务列表
$subtasks = self::parseSubtasksList($result);
if (empty($subtasks)) {
return null;
}
return [
'type' => 'subtasks',
'content' => $subtasks,
];
}
/**
* 生成负责人推荐
* @param ProjectTask $task 任务对象
*/
public static function generateAssignee(ProjectTask $task): ?array
{
// 获取当前任务已有的成员(负责人和协助人)
$existingUserIds = ProjectTaskUser::where('task_id', $task->id)
->pluck('userid')
->toArray();
// 获取项目成员,排除已有任务成员
$members = self::getProjectMembersInfo($task->project_id);
$members = array_filter($members, function ($member) use ($existingUserIds) {
return !in_array($member['userid'], $existingUserIds);
});
$members = array_values($members); // 重新索引
if (empty($members)) {
return null;
}
$language = self::getUserLanguageInfo($task->userid)['name'];
$prompt = self::buildAssigneePrompt($task, $members, $language);
$result = self::callAi($prompt);
if (empty($result)) {
return null;
}
// 解析推荐结果
$recommendations = self::parseAssigneeRecommendations($result, $members);
if (empty($recommendations)) {
return null;
}
return [
'type' => 'assignee',
'content' => $recommendations,
];
}
/**
* 搜索相似任务
* @param ProjectTask $task 任务对象
*/
public static function findSimilarTasks(ProjectTask $task): ?array
{
// 使用 AI 模块的 Embedding 搜索
$searchText = $task->name;
if (empty($searchText)) {
return null;
}
try {
$result = AI::getEmbedding($searchText);
if (Base::isError($result) || empty($result['data'])) {
return null;
}
$embedding = $result['data'];
// 搜索相似任务(排除自己和子任务)
$similarTasks = self::searchSimilarByEmbedding(
$embedding,
$task->project_id,
$task->id
);
if (empty($similarTasks)) {
return null;
}
// 获取用户语言对应的文案
$lang = self::getUserLanguageInfo($task->userid)['code'];
return [
'type' => 'similar',
'lang' => $lang,
'content' => $similarTasks,
];
} catch (\Exception $e) {
\Log::error('AiTaskSuggestion::findSimilarTasks error: ' . $e->getMessage());
return null;
}
}
/**
* 获取用户语言信息
* @param int $userid 用户ID
* @return array ['code' => 语言代码, 'name' => 语言名称]
*/
private static function getUserLanguageInfo(int $userid): array
{
$user = User::find($userid);
$code = $user->lang ?? 'zh';
$name = Doo::getLanguages($code) ?: '简体中文';
return ['code' => $code, 'name' => $name];
}
/**
* 获取多语言标题和提示文案
* @param string $lang 语言代码
* @return array
*/
private static function getLocalizedTitles(string $lang): array
{
$titles = [
'zh' => [
'description' => '建议补充任务描述',
'subtasks' => '建议拆分子任务',
'assignee' => '推荐负责人',
'similar' => '发现相似任务',
'similar_hint' => '以下任务与当前任务内容相似,可能是重复任务或可作为参考:',
],
'zh-CHT' => [
'description' => '建議補充任務描述',
'subtasks' => '建議拆分子任務',
'assignee' => '推薦負責人',
'similar' => '發現相似任務',
'similar_hint' => '以下任務與當前任務內容相似,可能是重複任務或可作為參考:',
],
'en' => [
'description' => 'Suggested Task Description',
'subtasks' => 'Suggested Subtasks',
'assignee' => 'Recommended Assignee',
'similar' => 'Similar Tasks Found',
'similar_hint' => 'The following tasks are similar and may be duplicates or references:',
],
'ko' => [
'description' => '작업 설명 추가 제안',
'subtasks' => '하위 작업 분할 제안',
'assignee' => '추천 담당자',
'similar' => '유사한 작업 발견',
'similar_hint' => '다음 작업은 현재 작업과 유사하며 중복되거나 참고할 수 있습니다:',
],
'ja' => [
'description' => 'タスク説明の追加を提案',
'subtasks' => 'サブタスクの分割を提案',
'assignee' => '推奨担当者',
'similar' => '類似タスクを発見',
'similar_hint' => '以下のタスクは現在のタスクと類似しており、重複している可能性があります:',
],
'de' => [
'description' => 'Vorgeschlagene Aufgabenbeschreibung',
'subtasks' => 'Vorgeschlagene Unteraufgaben',
'assignee' => 'Empfohlener Verantwortlicher',
'similar' => 'Ähnliche Aufgaben gefunden',
'similar_hint' => 'Die folgenden Aufgaben sind ähnlich und könnten Duplikate oder Referenzen sein:',
],
'fr' => [
'description' => 'Description de tâche suggérée',
'subtasks' => 'Sous-tâches suggérées',
'assignee' => 'Responsable recommandé',
'similar' => 'Tâches similaires trouvées',
'similar_hint' => 'Les tâches suivantes sont similaires et peuvent être des doublons ou des références:',
],
'id' => [
'description' => 'Saran Deskripsi Tugas',
'subtasks' => 'Saran Pembagian Subtugas',
'assignee' => 'Penanggung Jawab yang Direkomendasikan',
'similar' => 'Tugas Serupa Ditemukan',
'similar_hint' => 'Tugas berikut mirip dengan tugas saat ini dan mungkin duplikat atau referensi:',
],
'ru' => [
'description' => 'Предлагаемое описание задачи',
'subtasks' => 'Предлагаемые подзадачи',
'assignee' => 'Рекомендуемый ответственный',
'similar' => 'Найдены похожие задачи',
'similar_hint' => 'Следующие задачи похожи на текущую и могут быть дубликатами или справочными:',
],
];
return $titles[$lang] ?? $titles['zh'];
}
/**
* 转义用户输入以防止 Prompt 注入
*/
private static function escapeUserInput(string $input, int $length = 500): string
{
// 移除可能影响 AI Prompt 解析的特殊字符
$input = str_replace(['```', '---', '==='], '', $input);
// 截断过长的输入
return mb_substr(trim($input), 0, $length);
}
/**
* 构建描述生成 Prompt
* @param ProjectTask $task 任务对象
* @param string $language 输出语言名称
*/
private static function buildDescriptionPrompt(ProjectTask $task, string $language): string
{
$taskName = self::escapeUserInput($task->name, 100);
$projectName = self::escapeUserInput($task->project->name ?? '未知项目', 100);
$columnName = self::escapeUserInput($task->projectColumn->name ?? '未知栏目', 50);
return <<<PROMPT
你是一名任务规划助手,擅长根据任务标题推断并补充任务描述。
所属项目:{$projectName}
所属栏目:{$columnName}
任务标题:{$taskName}
你的任务:
根据标题、项目和栏目信息,推断任务意图并生成实用的任务描述。
生成原则:
1. 基于标题关键词和上下文进行合理推断,内容要具体、可执行
2. 使用 Markdown 格式,根据任务性质灵活组织结构(可包含目标、要求、验收标准等)
3. 简单任务保持简洁,复杂任务可适当展开,避免空泛的套话
输出语言:与任务标题的语言保持一致,如无法确定则使用{$language}
输出要求:
- 仅返回 Markdown 格式的描述内容
- 禁止输出额外说明、引导语或与任务无关的内容
PROMPT;
}
/**
* 构建子任务拆分 Prompt
* @param ProjectTask $task 任务对象
* @param string $language 输出语言名称
*/
private static function buildSubtasksPrompt(ProjectTask $task, string $language): string
{
$taskName = self::escapeUserInput($task->name, 100);
$projectName = self::escapeUserInput($task->project->name ?? '未知项目', 100);
$columnName = self::escapeUserInput($task->projectColumn->name ?? '未知栏目', 50);
$content = self::escapeUserInput($task->content ?? '');
return <<<PROMPT
你是一名任务拆解助手,擅长将复杂任务分解为可执行的子任务。
所属项目:{$projectName}
所属栏目:{$columnName}
任务标题:{$taskName}
任务描述:{$content}
你的任务:
分析任务内容,拆解出关键的执行步骤作为子任务。
拆解原则:
1. 每个子任务聚焦单一可执行动作,避免含糊或重复
2. 根据任务复杂度灵活决定数量(通常 2-5 个),简单任务少拆,复杂任务多拆
3. 子任务之间保持合理的执行顺序或逻辑关系
4. 子任务名称简洁明了,控制在 8-30 个字符内
输出语言:与任务标题的语言保持一致,如无法确定则使用{$language}
输出格式:
1. [子任务名称]
2. [子任务名称]
...
输出要求:
- 仅返回子任务列表,禁止输出额外说明或引导语
PROMPT;
}
/**
* 构建负责人推荐 Prompt
* @param ProjectTask $task 任务对象
* @param array $members 成员列表
* @param string $language 输出语言名称
*/
private static function buildAssigneePrompt(ProjectTask $task, array $members, string $language): string
{
$taskName = self::escapeUserInput($task->name, 100);
$projectName = self::escapeUserInput($task->project->name ?? '未知项目', 100);
$columnName = self::escapeUserInput($task->projectColumn->name ?? '未知栏目', 50);
$taskContent = self::escapeUserInput($task->content ?? '');
$membersText = '';
foreach ($members as $member) {
$nickname = self::escapeUserInput($member['nickname'], 20);
$membersText .= "- {$nickname}ID:{$member['userid']}";
if (!empty($member['profession'])) {
$profession = self::escapeUserInput($member['profession'], 50);
$membersText .= ",职位:{$profession}";
}
$membersText .= ",进行中:{$member['in_progress_count']}";
$membersText .= ",近期完成:{$member['completed_count']}";
$membersText .= "\n";
}
return <<<PROMPT
你是一名任务分配助手,根据任务内容和成员情况推荐合适的负责人。
所属项目:{$projectName}
所属栏目:{$columnName}
任务标题:{$taskName}
任务描述:{$taskContent}
可选成员:
{$membersText}
推荐原则:
1. 分析任务内容,匹配成员职位或专业方向
2. 优先推荐进行中任务较少的成员,平衡工作负载
3. 近期完成任务多说明执行力强,可作为参考
输出语言:推荐理由的语言与任务标题保持一致,如无法确定则使用{$language}
输出格式:
1. [userid]|[推荐理由]
2. [userid]|[推荐理由]
输出要求:
- 推荐 1-2 名最合适的负责人,按优先级排序
- 推荐理由需具体说明为何此人适合该任务,不超过 20 字
- 仅返回推荐列表,禁止输出额外说明
PROMPT;
}
/**
* 调用 AI 接口
*/
private static function callAi(string $prompt): ?string
{
try {
// 使用 AI 模块调用
$result = AI::invoke([
['system', '你是 DooTask 任务管理系统的 AI 助手,帮助用户管理任务。'],
['user', $prompt],
]);
if (Base::isError($result)) {
\Log::error('AiTaskSuggestion::callAi error: ' . ($result['msg'] ?? 'Unknown error'));
return null;
}
return $result['data']['content'] ?? null;
} catch (\Exception $e) {
\Log::error('AiTaskSuggestion::callAi error: ' . $e->getMessage());
return null;
}
}
/**
* 获取项目成员信息
*/
private static function getProjectMembersInfo(int $projectId): array
{
$projectUsers = ProjectUser::where('project_id', $projectId)->get();
$members = [];
foreach ($projectUsers as $pu) {
$user = User::find($pu->userid);
if (!$user || $user->bot || $user->disable_at) {
continue;
}
// 获取进行中任务数量
$inProgressCount = ProjectTask::join('project_task_users', 'project_tasks.id', '=', 'project_task_users.task_id')
->where('project_task_users.userid', $user->userid)
->whereNull('project_tasks.complete_at')
->whereNull('project_tasks.archived_at')
->whereNull('project_tasks.deleted_at')
->count();
// 获取近期完成任务数量
$completedCount = ProjectTask::join('project_task_users', 'project_tasks.id', '=', 'project_task_users.task_id')
->where('project_task_users.userid', $user->userid)
->whereNotNull('project_tasks.complete_at')
->where('project_tasks.complete_at', '>=', Carbon::now()->subDays(30))
->whereNull('project_tasks.deleted_at')
->count();
$members[] = [
'userid' => $user->userid,
'nickname' => $user->nickname,
'profession' => $user->profession ?? '',
'in_progress_count' => $inProgressCount,
'completed_count' => $completedCount,
];
}
return $members;
}
/**
* 解析子任务列表
*/
private static function parseSubtasksList(string $text): array
{
$lines = explode("\n", trim($text));
$subtasks = [];
foreach ($lines as $line) {
$line = trim($line);
// 移除序号前缀
$line = preg_replace('/^\d+[\.\)、]\s*/', '', $line);
if (!empty($line) && mb_strlen($line) <= 100) {
$subtasks[] = $line;
}
}
return array_slice($subtasks, 0, 5); // 最多5个
}
/**
* 解析负责人推荐结果
*/
private static function parseAssigneeRecommendations(string $text, array $members): array
{
$memberMap = [];
foreach ($members as $m) {
$memberMap[$m['userid']] = $m;
}
$lines = explode("\n", trim($text));
$recommendations = [];
$addedUserIds = []; // 记录已添加的用户ID防止重复
foreach ($lines as $line) {
$line = trim($line);
$line = preg_replace('/^\d+[\.\)、]\s*/', '', $line);
if (preg_match('/^(\d+)\|(.+)$/', $line, $matches)) {
$userid = intval($matches[1]);
$reason = trim($matches[2]);
// 跳过已添加的用户
if (in_array($userid, $addedUserIds)) {
continue;
}
if (isset($memberMap[$userid])) {
$recommendations[] = [
'userid' => $userid,
'nickname' => $memberMap[$userid]['nickname'],
'reason' => $reason,
];
$addedUserIds[] = $userid;
}
}
}
return array_slice($recommendations, 0, 2); // 最多2个
}
/**
* 通过 Embedding 搜索相似任务
*
* @param array $embedding 任务内容的向量表示
* @param int $projectId 项目ID用于过滤同项目任务
* @param int $excludeTaskId 排除的任务ID当前任务
* @return array 相似任务列表
*/
private static function searchSimilarByEmbedding(array $embedding, int $projectId, int $excludeTaskId): array
{
if (empty($embedding)) {
return [];
}
try {
// 使用 ManticoreBase 进行向量搜索
// userid=0 跳过权限过滤,我们通过 project_id 过滤
$results = ManticoreBase::taskVectorSearch($embedding, 0, 200);
if (empty($results)) {
return [];
}
// 获取当前任务的子任务ID列表
$childTaskIds = ProjectTask::where('parent_id', $excludeTaskId)
->whereNull('deleted_at')
->pluck('id')
->toArray();
// 过滤:同项目、排除当前任务及其子任务、相似度阈值
$similarTasks = [];
foreach ($results as $item) {
// 过滤不同项目的任务
if ($item['project_id'] != $projectId) {
continue;
}
// 排除当前任务
if ($item['task_id'] == $excludeTaskId) {
continue;
}
// 排除子任务
if (in_array($item['task_id'], $childTaskIds)) {
continue;
}
// 相似度阈值
$similarity = $item['similarity'] ?? 0;
if ($similarity < self::SIMILAR_THRESHOLD) {
continue;
}
$similarTasks[] = [
'task_id' => $item['task_id'],
'name' => $item['task_name'] ?? '',
'similarity' => round($similarity, 2),
];
// 最多返回 5 个相似任务
if (count($similarTasks) >= 5) {
break;
}
}
return $similarTasks;
} catch (\Exception $e) {
\Log::error('searchSimilarByEmbedding error: ' . $e->getMessage());
return [];
}
}
/**
* 构建 Markdown 消息
* @param int $taskId 任务ID
* @param array $suggestions 建议列表
* @param int $msgId 消息ID
* @param string $lang 语言代码
*/
public static function buildMarkdownMessage(int $taskId, array $suggestions, int $msgId = 0, string $lang = 'zh'): string
{
$parts = [];
$titles = self::getLocalizedTitles($lang);
foreach ($suggestions as $suggestion) {
// 如果 suggestion 中有 lang使用它similar 类型)
$suggestionLang = $suggestion['lang'] ?? $lang;
$suggestionTitles = ($suggestionLang !== $lang) ? self::getLocalizedTitles($suggestionLang) : $titles;
switch ($suggestion['type']) {
case 'description':
$parts[] = self::buildDescriptionMarkdown($taskId, $msgId, $suggestion['content'], $suggestionTitles);
break;
case 'subtasks':
$parts[] = self::buildSubtasksMarkdown($taskId, $msgId, $suggestion['content'], $suggestionTitles);
break;
case 'assignee':
$parts[] = self::buildAssigneeMarkdown($taskId, $msgId, $suggestion['content'], $suggestionTitles);
break;
case 'similar':
$parts[] = self::buildSimilarMarkdown($taskId, $msgId, $suggestion['content'], $suggestionTitles);
break;
}
}
return implode("\n\n---\n\n", $parts);
}
/**
* 构建描述建议 Markdown
* @param int $taskId 任务ID
* @param int $msgId 消息ID
* @param string $content 描述内容
* @param array $titles 本地化标题
*/
private static function buildDescriptionMarkdown(int $taskId, int $msgId, string $content, array $titles): string
{
$title = $titles['description'];
return <<<MD
### {$title}
{$content}
:::ai-action{type="description" task="{$taskId}" msg="{$msgId}"}:::
MD;
}
/**
* 构建子任务建议 Markdown
* @param int $taskId 任务ID
* @param int $msgId 消息ID
* @param array $subtasks 子任务列表
* @param array $titles 本地化标题
*/
private static function buildSubtasksMarkdown(int $taskId, int $msgId, array $subtasks, array $titles): string
{
$title = $titles['subtasks'];
$list = '';
foreach ($subtasks as $i => $name) {
$num = $i + 1;
$list .= "{$num}. {$name}\n";
}
return <<<MD
### {$title}
{$list}
:::ai-action{type="subtasks" task="{$taskId}" msg="{$msgId}"}:::
MD;
}
/**
* 构建负责人建议 Markdown
* @param int $taskId 任务ID
* @param int $msgId 消息ID
* @param array $recommendations 推荐列表
* @param array $titles 本地化标题
*/
private static function buildAssigneeMarkdown(int $taskId, int $msgId, array $recommendations, array $titles): string
{
$title = $titles['assignee'];
$list = '';
foreach ($recommendations as $rec) {
$stUserId = $rec['userid'];
$viewUrl = "dootask://contact/{$stUserId}";
$list .= "- **[{$rec['nickname']}]({$viewUrl})** - {$rec['reason']} :::ai-action{type=\"assignee\" task=\"{$taskId}\" msg=\"{$msgId}\" userid=\"{$stUserId}\"}:::\n";
}
return <<<MD
### {$title}
{$list}
MD;
}
/**
* 构建相似任务 Markdown
* @param int $taskId 任务ID
* @param int $msgId 消息ID
* @param array $similarTasks 相似任务列表
* @param array $titles 本地化标题
*/
private static function buildSimilarMarkdown(int $taskId, int $msgId, array $similarTasks, array $titles): string
{
$title = $titles['similar'];
$hint = $titles['similar_hint'];
$list = '';
foreach ($similarTasks as $i => $st) {
$num = $i + 1;
$stTaskId = $st['task_id'];
$viewUrl = "dootask://task/{$stTaskId}";
$list .= "{$num}. **[#{$stTaskId}]({$viewUrl})** {$st['name']} :::ai-action{type=\"similar\" task=\"{$taskId}\" msg=\"{$msgId}\" related=\"{$stTaskId}\"}:::\n";
}
return <<<MD
### {$title}
{$hint}
{$list}
MD;
}
/**
* 发送建议消息
* @param ProjectTask $task 任务对象
* @param array $suggestions 建议列表
*/
public static function sendSuggestionMessage(ProjectTask $task, array $suggestions): ?int
{
if (empty($suggestions)) {
return null;
}
// 如果任务没有对话,自动创建
if (!$task->dialog_id) {
$dialog = WebSocketDialog::createGroup($task->name, $task->relationUserids(), 'task');
if ($dialog) {
$task->dialog_id = $dialog->id;
$task->save();
$task->pushMsg('dialog');
} else {
return null;
}
}
// 获取用户语言
$lang = self::getUserLanguageInfo($task->userid)['code'];
// 先发送消息获取 msg_id然后更新消息内容带上 msg_id
$tempMarkdown = self::buildMarkdownMessage($task->id, $suggestions, 0, $lang);
$result = WebSocketDialogMsg::sendMsg(
null,
$task->dialog_id,
'text',
['text' => $tempMarkdown, 'type' => 'md'],
self::AI_ASSISTANT_USERID,
true, // push_self
false, // push_retry
true // push_silence
);
if (Base::isError($result)) {
return null;
}
$msgId = $result['data']->id ?? 0;
if (empty($msgId)) {
return null;
}
// 更新消息,带上真实的 msg_id
$finalMarkdown = self::buildMarkdownMessage($task->id, $suggestions, $msgId, $lang);
WebSocketDialogMsg::sendMsg(
'change-' . $msgId,
$task->dialog_id,
'text',
['text' => $finalMarkdown, 'type' => 'md'],
self::AI_ASSISTANT_USERID,
true, // push_self
);
return $msgId;
}
/**
* 更新消息状态(采纳/忽略后)
*
* @param int $msgId 消息ID
* @param int $dialogId 对话ID
* @param string $type 建议类型
* @param string $status 状态applied/dismissed
* @param int $userid 用户IDassignee类型单独处理时使用
* @param int $related 关联任务IDsimilar类型单独处理时使用
* @return array 更新后的消息数据
*/
public static function updateMessageStatus(int $msgId, int $dialogId, string $type, string $status, int $userid = 0, int $related = 0): array
{
// 验证消息存在且属于指定对话
$msg = WebSocketDialogMsg::where('id', $msgId)
->where('dialog_id', $dialogId)
->first();
if (!$msg) {
return Base::retError('消息不存在');
}
$content = $msg->msg['text'] ?? '';
if (empty($content)) {
return Base::retError('消息内容为空');
}
// 根据类型和参数构建匹配模式,添加 status 属性
if ($type === 'assignee' && $userid > 0) {
$pattern = '/(:::ai-action\{type="assignee"[^}]*userid="' . $userid . '"[^}]*)\}:::/';
} elseif ($type === 'similar' && $related > 0) {
$pattern = '/(:::ai-action\{type="similar"[^}]*related="' . $related . '"[^}]*)\}:::/';
} else {
$pattern = '/(:::ai-action\{type="' . preg_quote($type, '/') . '"[^}]*)\}:::/';
}
$newContent = preg_replace($pattern, '$1 status="' . $status . '"}:::', $content);
// 更新消息并返回结果
return WebSocketDialogMsg::sendMsg(
'change-' . $msgId,
$dialogId,
'text',
['text' => $newContent, 'type' => 'md'],
self::AI_ASSISTANT_USERID
);
}
}

View File

@@ -14,7 +14,7 @@ use Overtrue\Pinyin\Pinyin;
use Redirect;
use Request;
use Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Symfony\Component\HttpFoundation\File\File;
use Validator;
@@ -848,6 +848,13 @@ class Base
*/
public static function getSchemeAndHost()
{
// 优先用当前请求的协议+主机getScheme() 会经 TrustProxies 采信 X-Forwarded-Proto
// 从而正确识别 httpshost 取自 Host 头(不信 X-Forwarded-Host避免 Host 注入)
$request = request();
if ($request && $request->getHttpHost()) {
return $request->getSchemeAndHttpHost();
}
// 非请求上下文Task/命令行等)的兜底
$scheme = isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == '443' ? 'https://' : 'http://';
return $scheme.($_SERVER['HTTP_HOST'] ?? '');
}
@@ -2818,14 +2825,17 @@ class Base
/**
* 字节转格式
* @param $bytes
* @param int|float $bytes
* @return string
*/
public static function readableBytes($bytes)
public static function readableBytes(int|float $bytes): string
{
$i = floor(log($bytes) / log(1024));
if ($bytes <= 0) {
return '0 B';
}
$i = (int) floor(log($bytes) / log(1024));
$sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
return sprintf('%.02F', $bytes / pow(1024, $i)) * 1 . ' ' . $sizes[$i];
return (string) ((float) sprintf('%.02F', $bytes / pow(1024, $i))) . ' ' . $sizes[$i];
}
/**
@@ -2868,9 +2878,15 @@ class Base
/**
* DownloadFileResponse 下载文件
*
* 返回 Symfony BinaryFileResponse在 LaravelS/Swoole 环境下由 StaticResponse 走原生
* sendfile() 发送——OS 级零拷贝、不占用 PHP 内存,可支持任意大小文件(如几百 MB 的大文件)。
* 切勿改回 StreamedResponse它会被 LaravelS 用 ob_start()/ob_get_clean() 把整个响应体
* 缓冲进 PHP 内存,大文件会撞 memory_limit 导致下载失败。
*
* @param File|\SplFileInfo|string $file 文件对象或路径
* @param string|null $name 下载文件名
* @return StreamedResponse
* @return BinaryFileResponse
*/
public static function DownloadFileResponse($file, $name = null)
{
@@ -2889,12 +2905,6 @@ class Base
throw new FileException('File must be readable and exist.');
}
// 获取文件信息
$size = $file->getSize();
if ($size === false || $size < 0) {
throw new FileException('Unable to determine file size.');
}
// 处理文件名
if (empty($name)) {
$name = basename($file->getPathname());
@@ -2912,83 +2922,27 @@ class Base
$mimeType = 'application/octet-stream';
}
// 处理 Range 请求
$start = 0;
$end = $size - 1;
$length = $size;
$isRangeRequest = false;
// BinaryFileResponseautoEtag=false 避免对大文件做 md5/sha1 全文件哈希autoLastModified=true 取 mtime开销极小
$response = new BinaryFileResponse($file, 200, [], true, null, false, true);
$response->headers->set('Content-Type', $mimeType);
$response->headers->set('Cache-Control', 'private, no-transform, no-store, must-revalidate, max-age=0');
// filename 兜底为纯 ASCIIfilename* 用 UTF-8 编码,兼容含中文/特殊字符的文件名
$asciiName = preg_replace('/[^\x20-\x7e]/', '_', $name);
$response->headers->set('Content-Disposition', sprintf(
'attachment; filename="%s"; filename*=UTF-8\'\'%s',
$asciiName,
rawurlencode($name)
));
if (isset($_SERVER['HTTP_RANGE'])) {
$range = str_replace('bytes=', '', $_SERVER['HTTP_RANGE']);
if (preg_match('/^(\d+)-(\d*)$/', $range, $matches)) {
$start = intval($matches[1]);
$end = !empty($matches[2]) ? intval($matches[2]) : $size - 1;
// 验证范围的有效性
if ($start >= 0 && $end < $size && $start <= $end) {
$length = $end - $start + 1;
$isRangeRequest = true;
} else {
$start = 0;
$end = $size - 1;
}
}
// LaravelS/Swoole 下 StaticResponse 用 sendfile() 整文件发送,不支持分段;
// 若放任 Symfony 处理 Range 会返回 206 头却仍发送完整文件,导致内容错位/损坏。
// 故在 Swoole 环境下移除 Range 请求头,始终以 200 返回完整文件。
if (app()->bound('swoole')) {
Request::instance()->headers->remove('Range');
$response->headers->set('Accept-Ranges', 'none');
}
// 设置基本响应头
$headers = [
'Content-Type' => $mimeType,
'Content-Disposition' => sprintf(
'attachment; filename="%s"; filename*=UTF-8\'\'%s',
$name,
rawurlencode($name)
),
'Accept-Ranges' => 'bytes',
'Cache-Control' => 'private, no-transform, no-store, must-revalidate, max-age=0',
'Content-Length' => $length,
'Last-Modified' => gmdate('D, d M Y H:i:s', $file->getMTime()) . ' GMT',
'ETag' => sprintf('"%s"', md5_file($file->getPathname()))
];
if ($isRangeRequest) {
$headers['Content-Range'] = "bytes {$start}-{$end}/{$size}";
$statusCode = 206;
} else {
$statusCode = 200;
}
// 创建流式响应
return new StreamedResponse(
function () use ($file, $start, $length) {
$handle = fopen($file->getPathname(), 'rb');
if ($handle === false) {
throw new FileException('Cannot open file for reading');
}
if (fseek($handle, $start) === -1) {
fclose($handle);
throw new FileException('Cannot seek to position ' . $start);
}
$remaining = $length;
$bufferSize = 8192; // 8KB chunks
while ($remaining > 0 && !feof($handle)) {
$readSize = min($bufferSize, $remaining);
$buffer = fread($handle, $readSize);
if ($buffer === false) {
break;
}
echo $buffer;
flush();
$remaining -= strlen($buffer);
}
fclose($handle);
},
$statusCode,
$headers
);
return $response;
} catch (\Exception $e) {
\Log::error('File download failed', [
'error' => $e->getMessage(),

View File

@@ -2,6 +2,7 @@
namespace App\Module\Manticore;
use App\Models\ManticoreSyncFailure;
use App\Module\Apps;
use App\Module\Base;
use App\Module\AI;
@@ -173,6 +174,71 @@ class ManticoreBase
self::$initialized = false;
}
/**
* 判断是否为连接断开错误
* 参考 Laravel Illuminate\Database\DetectsLostConnections
*/
private function isConnectionLostError(PDOException $e): bool
{
$message = $e->getMessage();
return stripos($message, 'server has gone away') !== false
|| stripos($message, 'no connection to the server') !== false
|| stripos($message, 'Lost connection') !== false
|| stripos($message, 'is dead or not enabled') !== false
|| stripos($message, 'Error while sending') !== false
|| stripos($message, 'decryption failed or bad record mac') !== false
|| stripos($message, 'server closed the connection unexpectedly') !== false
|| stripos($message, 'SSL connection has been closed unexpectedly') !== false
|| stripos($message, 'Error writing data to the connection') !== false
|| stripos($message, 'Resource deadlock avoided') !== false
|| stripos($message, 'Transaction() on null') !== false
|| stripos($message, 'child connection forced to terminate') !== false
|| stripos($message, 'query_wait_timeout') !== false
|| stripos($message, 'reset by peer') !== false
|| stripos($message, 'Physical connection is not usable') !== false
|| stripos($message, 'Packets out of order') !== false
|| stripos($message, 'Adaptive Server connection failed') !== false
|| stripos($message, 'Connection was killed') !== false
|| stripos($message, 'Broken pipe') !== false;
}
/**
* 带重试的执行包装器
* 正常情况零开销,仅在连接断开时重试一次
*
* @param callable $callback 执行回调,接收 PDO 参数
* @param mixed $failureReturn 失败时的返回值
* @param array $logContext 日志上下文
* @return mixed
*/
private function runWithRetry(callable $callback, $failureReturn = false, array $logContext = [])
{
$pdo = $this->getConnection();
if (!$pdo) {
return $failureReturn;
}
try {
return $callback($pdo);
} catch (PDOException $e) {
// 如果是连接断开错误,重置连接并重试一次
if ($this->isConnectionLostError($e)) {
self::resetConnection();
$pdo = $this->getConnection();
if ($pdo) {
try {
return $callback($pdo);
} catch (PDOException $retryException) {
Log::error('Manticore retry failed: ' . $retryException->getMessage(), $logContext);
return $failureReturn;
}
}
}
Log::error('Manticore error: ' . $e->getMessage(), $logContext);
return $failureReturn;
}
}
/**
* 检查是否已安装
*/
@@ -181,6 +247,57 @@ class ManticoreBase
return Apps::isInstalled("search");
}
/**
* 直接执行 SQL不使用参数绑定
* 用于包含 MVA 或向量字段的 INSERT 语句,因为 Manticore 的 prepared statement 不支持括号表达式
*
* @param string $sql 完整的 SQL 语句(所有值已内联)
* @return bool 是否成功
*/
public function executeRaw(string $sql): bool
{
return $this->runWithRetry(
function (PDO $pdo) use ($sql) {
$pdo->exec($sql);
return true;
},
false,
['sql' => $sql]
);
}
/**
* 转义 SQL 字符串值(用于不使用参数绑定的场景)
*
* @param mixed $value 要转义的值
* @return string 转义后的值(包含引号)
*/
public function quoteValue($value): string
{
$pdo = $this->getConnection();
if (!$pdo) {
// Fallback: 手动转义
if (is_null($value)) {
return 'NULL';
}
if (is_int($value) || is_float($value)) {
return (string)$value;
}
return "'" . addslashes((string)$value) . "'";
}
if (is_null($value)) {
return 'NULL';
}
if (is_int($value)) {
return (string)$value;
}
if (is_float($value)) {
return (string)$value;
}
return $pdo->quote((string)$value);
}
/**
* 执行 SQL不返回结果
*
@@ -190,22 +307,15 @@ class ManticoreBase
*/
public function execute(string $sql, array $params = []): bool
{
$pdo = $this->getConnection();
if (!$pdo) {
return false;
}
try {
$stmt = $pdo->prepare($sql);
$this->bindParams($stmt, $params);
return $stmt->execute();
} catch (PDOException $e) {
Log::error('Manticore execute error: ' . $e->getMessage(), [
'sql' => $sql,
'params' => $params
]);
return false;
}
return $this->runWithRetry(
function (PDO $pdo) use ($sql, $params) {
$stmt = $pdo->prepare($sql);
$this->bindParams($stmt, $params);
return $stmt->execute();
},
false,
['sql' => $sql, 'params' => $params]
);
}
/**
@@ -217,23 +327,16 @@ class ManticoreBase
*/
public function executeWithRowCount(string $sql, array $params = []): int
{
$pdo = $this->getConnection();
if (!$pdo) {
return -1;
}
try {
$stmt = $pdo->prepare($sql);
$this->bindParams($stmt, $params);
$stmt->execute();
return $stmt->rowCount();
} catch (PDOException $e) {
Log::error('Manticore execute error: ' . $e->getMessage(), [
'sql' => $sql,
'params' => $params
]);
return -1;
}
return $this->runWithRetry(
function (PDO $pdo) use ($sql, $params) {
$stmt = $pdo->prepare($sql);
$this->bindParams($stmt, $params);
$stmt->execute();
return $stmt->rowCount();
},
-1,
['sql' => $sql, 'params' => $params]
);
}
/**
@@ -245,23 +348,16 @@ class ManticoreBase
*/
public function query(string $sql, array $params = []): array
{
$pdo = $this->getConnection();
if (!$pdo) {
return [];
}
try {
$stmt = $pdo->prepare($sql);
$this->bindParams($stmt, $params);
$stmt->execute();
return $this->convertNumericTypes($stmt->fetchAll());
} catch (PDOException $e) {
Log::error('Manticore query error: ' . $e->getMessage(), [
'sql' => $sql,
'params' => $params
]);
return [];
}
return $this->runWithRetry(
function (PDO $pdo) use ($sql, $params) {
$stmt = $pdo->prepare($sql);
$this->bindParams($stmt, $params);
$stmt->execute();
return $this->convertNumericTypes($stmt->fetchAll());
},
[],
['sql' => $sql, 'params' => $params]
);
}
/**
@@ -273,24 +369,17 @@ class ManticoreBase
*/
public function queryOne(string $sql, array $params = []): ?array
{
$pdo = $this->getConnection();
if (!$pdo) {
return null;
}
try {
$stmt = $pdo->prepare($sql);
$this->bindParams($stmt, $params);
$stmt->execute();
$result = $stmt->fetch();
return $result ? $this->convertNumericTypesRow($result) : null;
} catch (PDOException $e) {
Log::error('Manticore queryOne error: ' . $e->getMessage(), [
'sql' => $sql,
'params' => $params
]);
return null;
}
return $this->runWithRetry(
function (PDO $pdo) use ($sql, $params) {
$stmt = $pdo->prepare($sql);
$this->bindParams($stmt, $params);
$stmt->execute();
$result = $stmt->fetch();
return $result ? $this->convertNumericTypesRow($result) : null;
},
null,
['sql' => $sql, 'params' => $params]
);
}
/**
@@ -578,45 +667,9 @@ class ManticoreBase
*/
public static function upsertFileVector(array $data): bool
{
$instance = new self();
$fileId = $data['file_id'] ?? 0;
if ($fileId <= 0) {
return false;
}
// 先尝试删除已存在的记录
$instance->execute("DELETE FROM file_vectors WHERE file_id = ?", [$fileId]);
// 构建 allowed_users MVA 值
$allowedUsers = $data['allowed_users'] ?? [];
$allowedUsersStr = !empty($allowedUsers) ? '(' . implode(',', array_map('intval', $allowedUsers)) . ')' : '()';
// 插入新记录
$vectorValue = $data['content_vector'] ?? null;
if ($vectorValue) {
$vectorValue = str_replace(['[', ']'], ['(', ')'], $vectorValue);
$sql = "INSERT INTO file_vectors
(id, file_id, userid, pshare, file_name, file_type, file_ext, content, allowed_users, content_vector)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, {$allowedUsersStr}, {$vectorValue})";
} else {
$sql = "INSERT INTO file_vectors
(id, file_id, userid, pshare, file_name, file_type, file_ext, content, allowed_users)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, {$allowedUsersStr})";
}
$params = [
$fileId,
$fileId,
$data['userid'] ?? 0,
$data['pshare'] ?? 0,
$data['file_name'] ?? '',
$data['file_type'] ?? '',
$data['file_ext'] ?? '',
$data['content'] ?? ''
];
return $instance->execute($sql, $params);
// 确保 id 字段与 file_id 一致
$data['id'] = $data['file_id'] ?? 0;
return self::upsertVector('file', $data);
}
/**
@@ -649,12 +702,7 @@ class ManticoreBase
*/
public static function deleteFileVector(int $fileId): bool
{
if ($fileId <= 0) {
return false;
}
$instance = new self();
return $instance->execute("DELETE FROM file_vectors WHERE file_id = ?", [$fileId]);
return self::deleteVector('file', $fileId);
}
/**
@@ -875,50 +923,9 @@ class ManticoreBase
*/
public static function upsertUserVector(array $data): bool
{
$instance = new self();
$userid = $data['userid'] ?? 0;
if ($userid <= 0) {
return false;
}
// 先删除已存在的记录
$instance->execute("DELETE FROM user_vectors WHERE userid = ?", [$userid]);
// 插入新记录
$vectorValue = $data['content_vector'] ?? null;
if ($vectorValue) {
$vectorValue = str_replace(['[', ']'], ['(', ')'], $vectorValue);
$sql = "INSERT INTO user_vectors
(id, userid, nickname, email, profession, tags, introduction, content_vector)
VALUES (?, ?, ?, ?, ?, ?, ?, {$vectorValue})";
$params = [
$userid,
$userid,
$data['nickname'] ?? '',
$data['email'] ?? '',
$data['profession'] ?? '',
$data['tags'] ?? '',
$data['introduction'] ?? ''
];
} else {
$sql = "INSERT INTO user_vectors
(id, userid, nickname, email, profession, tags, introduction)
VALUES (?, ?, ?, ?, ?, ?, ?)";
$params = [
$userid,
$userid,
$data['nickname'] ?? '',
$data['email'] ?? '',
$data['profession'] ?? '',
$data['tags'] ?? '',
$data['introduction'] ?? ''
];
}
return $instance->execute($sql, $params);
// 确保 id 字段与 userid 一致
$data['id'] = $data['userid'] ?? 0;
return self::upsertVector('user', $data);
}
/**
@@ -929,12 +936,7 @@ class ManticoreBase
*/
public static function deleteUserVector(int $userid): bool
{
if ($userid <= 0) {
return false;
}
$instance = new self();
return $instance->execute("DELETE FROM user_vectors WHERE userid = ?", [$userid]);
return self::deleteVector('user', $userid);
}
/**
@@ -1136,43 +1138,9 @@ class ManticoreBase
*/
public static function upsertProjectVector(array $data): bool
{
$instance = new self();
$projectId = $data['project_id'] ?? 0;
if ($projectId <= 0) {
return false;
}
// 先删除已存在的记录
$instance->execute("DELETE FROM project_vectors WHERE project_id = ?", [$projectId]);
// 构建 allowed_users MVA 值
$allowedUsers = $data['allowed_users'] ?? [];
$allowedUsersStr = !empty($allowedUsers) ? '(' . implode(',', array_map('intval', $allowedUsers)) . ')' : '()';
// 插入新记录
$vectorValue = $data['content_vector'] ?? null;
if ($vectorValue) {
$vectorValue = str_replace(['[', ']'], ['(', ')'], $vectorValue);
$sql = "INSERT INTO project_vectors
(id, project_id, userid, personal, project_name, project_desc, allowed_users, content_vector)
VALUES (?, ?, ?, ?, ?, ?, {$allowedUsersStr}, {$vectorValue})";
} else {
$sql = "INSERT INTO project_vectors
(id, project_id, userid, personal, project_name, project_desc, allowed_users)
VALUES (?, ?, ?, ?, ?, ?, {$allowedUsersStr})";
}
$params = [
$projectId,
$projectId,
$data['userid'] ?? 0,
$data['personal'] ?? 0,
$data['project_name'] ?? '',
$data['project_desc'] ?? ''
];
return $instance->execute($sql, $params);
// 确保 id 字段与 project_id 一致
$data['id'] = $data['project_id'] ?? 0;
return self::upsertVector('project', $data);
}
/**
@@ -1205,12 +1173,7 @@ class ManticoreBase
*/
public static function deleteProjectVector(int $projectId): bool
{
if ($projectId <= 0) {
return false;
}
$instance = new self();
return $instance->execute("DELETE FROM project_vectors WHERE project_id = ?", [$projectId]);
return self::deleteVector('project', $projectId);
}
/**
@@ -1420,45 +1383,9 @@ class ManticoreBase
*/
public static function upsertTaskVector(array $data): bool
{
$instance = new self();
$taskId = $data['task_id'] ?? 0;
if ($taskId <= 0) {
return false;
}
// 先删除已存在的记录
$instance->execute("DELETE FROM task_vectors WHERE task_id = ?", [$taskId]);
// 构建 allowed_users MVA 值
$allowedUsers = $data['allowed_users'] ?? [];
$allowedUsersStr = !empty($allowedUsers) ? '(' . implode(',', array_map('intval', $allowedUsers)) . ')' : '()';
// 插入新记录
$vectorValue = $data['content_vector'] ?? null;
if ($vectorValue) {
$vectorValue = str_replace(['[', ']'], ['(', ')'], $vectorValue);
$sql = "INSERT INTO task_vectors
(id, task_id, project_id, userid, visibility, task_name, task_desc, task_content, allowed_users, content_vector)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, {$allowedUsersStr}, {$vectorValue})";
} else {
$sql = "INSERT INTO task_vectors
(id, task_id, project_id, userid, visibility, task_name, task_desc, task_content, allowed_users)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, {$allowedUsersStr})";
}
$params = [
$taskId,
$taskId,
$data['project_id'] ?? 0,
$data['userid'] ?? 0,
$data['visibility'] ?? 1,
$data['task_name'] ?? '',
$data['task_desc'] ?? '',
$data['task_content'] ?? ''
];
return $instance->execute($sql, $params);
// 确保 id 字段与 task_id 一致
$data['id'] = $data['task_id'] ?? 0;
return self::upsertVector('task', $data);
}
/**
@@ -1511,12 +1438,7 @@ class ManticoreBase
*/
public static function deleteTaskVector(int $taskId): bool
{
if ($taskId <= 0) {
return false;
}
$instance = new self();
return $instance->execute("DELETE FROM task_vectors WHERE task_id = ?", [$taskId]);
return self::deleteVector('task', $taskId);
}
/**
@@ -1725,44 +1647,9 @@ class ManticoreBase
*/
public static function upsertMsgVector(array $data): bool
{
$instance = new self();
$msgId = $data['msg_id'] ?? 0;
if ($msgId <= 0) {
return false;
}
// 先删除已存在的记录
$instance->execute("DELETE FROM msg_vectors WHERE msg_id = ?", [$msgId]);
// 构建 allowed_users MVA 值
$allowedUsers = $data['allowed_users'] ?? [];
$allowedUsersStr = !empty($allowedUsers) ? '(' . implode(',', array_map('intval', $allowedUsers)) . ')' : '()';
// 插入新记录
$vectorValue = $data['content_vector'] ?? null;
if ($vectorValue) {
$vectorValue = str_replace(['[', ']'], ['(', ')'], $vectorValue);
$sql = "INSERT INTO msg_vectors
(id, msg_id, dialog_id, userid, msg_type, content, allowed_users, created_at, content_vector)
VALUES (?, ?, ?, ?, ?, ?, {$allowedUsersStr}, ?, {$vectorValue})";
} else {
$sql = "INSERT INTO msg_vectors
(id, msg_id, dialog_id, userid, msg_type, content, allowed_users, created_at)
VALUES (?, ?, ?, ?, ?, ?, {$allowedUsersStr}, ?)";
}
$params = [
$msgId,
$msgId,
$data['dialog_id'] ?? 0,
$data['userid'] ?? 0,
$data['msg_type'] ?? 'text',
$data['content'] ?? '',
$data['created_at'] ?? time()
];
return $instance->execute($sql, $params);
// 确保 id 字段与 msg_id 一致
$data['id'] = $data['msg_id'] ?? 0;
return self::upsertVector('msg', $data);
}
/**
@@ -1796,12 +1683,7 @@ class ManticoreBase
*/
public static function deleteMsgVector(int $msgId): bool
{
if ($msgId <= 0) {
return false;
}
$instance = new self();
return $instance->execute("DELETE FROM msg_vectors WHERE msg_id = ?", [$msgId]);
return self::deleteVector('msg', $msgId);
}
/**
@@ -1882,6 +1764,14 @@ class ManticoreBase
// 向量更新方法
// ==============================
/**
* 数值类型字段列表(用于 SQL 值构建时判断是否需要引号)
*/
private const NUMERIC_FIELDS = [
'id', 'userid', 'pshare', 'visibility', 'personal',
'msg_id', 'file_id', 'task_id', 'project_id', 'dialog_id', 'created_at'
];
/**
* 向量表配置
* 定义各类型的表名、主键字段、普通字段、MVA字段
@@ -1919,6 +1809,120 @@ class ManticoreBase
],
];
/**
* 通用向量插入方法
*
* 使用 executeRaw 直接执行 SQL避免 Manticore prepared statement
* 无法解析 MVA 和向量字段括号语法的问题。
*
* @param string $type 类型: msg/file/task/project/user
* @param array $data 数据,键名对应字段名
* @return bool 是否成功
*/
public static function upsertVector(string $type, array $data): bool
{
if (!isset(self::VECTOR_TABLE_CONFIG[$type])) {
return false;
}
$config = self::VECTOR_TABLE_CONFIG[$type];
$table = $config['table'];
$pk = $config['pk'];
$fields = $config['fields'];
$mvaFields = $config['mva_fields'];
// 检查主键
$pkValue = $data[$pk] ?? 0;
if ($pkValue <= 0) {
return false;
}
$instance = new self();
// 先删除已存在的记录
$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) . ")";
$result = $instance->executeRaw($sql);
// 记录同步结果
if ($result) {
// 成功则删除失败记录(如果有)
ManticoreSyncFailure::removeSuccess($type, $pkValue, 'sync');
} else {
// 失败则记录
ManticoreSyncFailure::recordFailure($type, $pkValue, 'sync', "INSERT failed for {$table}");
}
return $result;
}
/**
* 通用向量删除方法
*
* @param string $type 类型: msg/file/task/project/user
* @param int $id 数据ID
* @return bool 是否成功
*/
public static function deleteVector(string $type, int $id): bool
{
if (!isset(self::VECTOR_TABLE_CONFIG[$type]) || $id <= 0) {
return false;
}
$config = self::VECTOR_TABLE_CONFIG[$type];
$table = $config['table'];
$pk = $config['pk'];
$instance = new self();
$result = $instance->execute("DELETE FROM {$table} WHERE {$pk} = ?", [$id]);
// 记录删除结果
if ($result) {
// 成功则删除失败记录(如果有)
ManticoreSyncFailure::removeSuccess($type, $id, 'delete');
} else {
// 失败则记录
ManticoreSyncFailure::recordFailure($type, $id, 'delete', "DELETE failed for {$table}");
}
return $result;
}
/**
* 通用批量更新向量方法(高性能版本)
*
@@ -1982,29 +1986,34 @@ class ManticoreBase
// Manticore 向量使用 () 格式
$vectorStr = str_replace(['[', ']'], ['(', ')'], $vectorStr);
// 构建字段列表和值
// 构建字段列表和值(直接内联值,不使用参数绑定)
$fieldList = $fields;
$values = [];
$quotedValues = [];
foreach ($fields as $field) {
$value = $existing[$field] ?? null;
// 处理默认值:数值字段用 0时间戳字段用当前时间其他用空字符串
if ($value === null) {
if ($field === 'created_at') {
$value = time();
} elseif (in_array($field, ['id', 'userid', 'pshare', 'visibility', 'personal', 'msg_id', 'file_id', 'task_id', 'project_id', 'dialog_id'])) {
} elseif (in_array($field, self::NUMERIC_FIELDS)) {
$value = 0;
} else {
$value = '';
}
}
$values[] = $value;
// 根据字段类型处理值
if (in_array($field, self::NUMERIC_FIELDS)) {
$quotedValues[] = (int)$value;
} else {
$quotedValues[] = $instance->quoteValue((string)$value);
}
}
// 构建 MVA 字段
$mvaValues = [];
$mvaValuesStr = [];
foreach ($mvaFields as $mvaField) {
$fieldList[] = $mvaField;
$mvaValues[] = !empty($existing[$mvaField])
$mvaValuesStr[] = !empty($existing[$mvaField])
? '(' . $existing[$mvaField] . ')'
: '()';
}
@@ -2012,12 +2021,11 @@ class ManticoreBase
// 添加向量字段
$fieldList[] = 'content_vector';
// 构建 SQL
$valuePlaceholders = array_fill(0, count($fields), '?');
$allValues = implode(', ', array_merge($valuePlaceholders, $mvaValues, [$vectorStr]));
// 构建 SQL(所有值直接内联,使用 executeRaw 避免 prepared statement 解析问题)
$allValues = implode(', ', array_merge($quotedValues, $mvaValuesStr, [$vectorStr]));
$sql = "INSERT INTO {$table} (" . implode(', ', $fieldList) . ") VALUES ({$allValues})";
$insertStatements[] = ['sql' => $sql, 'values' => $values, 'pk' => $pkValue];
$insertStatements[] = ['sql' => $sql, 'pk' => $pkValue];
}
// 如果没有有效的插入语句,直接返回
@@ -2033,13 +2041,16 @@ class ManticoreBase
$validPks
);
// 4. 逐条插入新记录
// 4. 逐条插入新记录(使用 executeRaw 避免 prepared statement 解析问题)
$successCount = 0;
foreach ($insertStatements as $stmt) {
if ($instance->execute($stmt['sql'], $stmt['values'])) {
if ($instance->executeRaw($stmt['sql'])) {
$successCount++;
// 成功则删除失败记录(如果有)
ManticoreSyncFailure::removeSuccess($type, $stmt['pk'], 'sync');
} else {
// 插入失败,数据已被删除,需要重新同步
// 失败则记录
ManticoreSyncFailure::recordFailure($type, $stmt['pk'], 'sync', "Batch INSERT failed for {$table}");
}
}

View File

@@ -10,7 +10,6 @@ use App\Module\Base;
use App\Module\TextExtractor;
use App\Module\AI;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\DB;
/**
* Manticore Search 文件搜索类
@@ -355,7 +354,85 @@ class ManticoreFile
}
/**
* 提取文件内容
* 提取文件内容(支持分页)
*
* @param File|string $fileOrPath 文件模型 或 文件路径/URL
* @param int $offset 起始位置(字符数),默认 0
* @param int $limit 获取长度(字符数),默认 50000最大 200000
* @return array 包含 content, total_length, offset, limit, has_more, 或 error
*/
public static function extractFileContentPaginated(File|string $fileOrPath, int $offset = 0, int $limit = 50000): array
{
$offset = max(0, $offset);
$limit = min(max(1, $limit), 200000);
// 根据参数类型获取完整内容
if ($fileOrPath instanceof File) {
if ($fileOrPath->type === 'folder') {
return ['error' => '文件夹无法提取内容'];
}
$fullContent = self::extractFileContent($fileOrPath);
} else {
$fullContent = self::extractFileContentFromPath($fileOrPath);
if (is_array($fullContent)) {
return $fullContent; // 返回错误信息
}
}
if (empty($fullContent)) {
return ['error' => '无法提取文件内容'];
}
// 分页处理
$totalLength = mb_strlen($fullContent);
if ($offset >= $totalLength) {
return [
'content' => '',
'total_length' => $totalLength,
'offset' => $offset,
'limit' => $limit,
'has_more' => false,
];
}
$content = mb_substr($fullContent, $offset, $limit);
$hasMore = ($offset + mb_strlen($content)) < $totalLength;
return [
'content' => $content,
'total_length' => $totalLength,
'offset' => $offset,
'limit' => $limit,
'has_more' => $hasMore,
];
}
/**
* 通过路径/URL 提取完整内容
* @return string|array 内容字符串,或错误数组
*/
private static function extractFileContentFromPath(string $pathOrUrl): string|array
{
// 从 URL 中提取相对路径
if (str_starts_with($pathOrUrl, 'http://') || str_starts_with($pathOrUrl, 'https://')) {
$parsed = parse_url($pathOrUrl);
$pathOrUrl = ltrim($parsed['path'] ?? '', '/');
}
if (preg_match('/^.*?(uploads\/.*)$/', $pathOrUrl, $matches)) {
$pathOrUrl = $matches[1];
}
// 安全检查:只允许 uploads 目录
if (!str_starts_with($pathOrUrl, 'uploads/')) {
return ['error' => '不支持的文件路径'];
}
return self::extractFromPath($pathOrUrl);
}
/**
* 提取文件内容(内部使用,返回完整内容)
*
* @param File $file 文件模型
* @return string 文件内容文本
@@ -364,37 +441,28 @@ class ManticoreFile
{
// 1. 先尝试从 FileContent 的 text 字段获取(已提取的文本内容)
$fileContent = FileContent::where('fid', $file->id)->orderByDesc('id')->first();
if ($fileContent && !empty($fileContent->text)) {
if (!$fileContent) {
return '';
}
if (!empty($fileContent->text)) {
return $fileContent->text;
}
// 2. 尝试从 FileContent 的 content 字段获取
if ($fileContent && !empty($fileContent->content)) {
if (!empty($fileContent->content)) {
$contentData = Base::json2array($fileContent->content);
// 2.1 某些文件类型直接存储内容
if (!empty($contentData['content'])) {
return is_string($contentData['content']) ? $contentData['content'] : '';
if (!empty($contentData['content']) && is_string($contentData['content'])) {
return $contentData['content'];
}
// 2.2 尝试使用 TextExtractor 提取文件内容
// 2.2 通过路径提取
$filePath = $contentData['url'] ?? null;
if ($filePath && str_starts_with($filePath, 'uploads/')) {
$fullPath = public_path($filePath);
if (file_exists($fullPath)) {
// 根据文件类型设置不同的大小限制
$ext = strtolower(pathinfo($fullPath, PATHINFO_EXTENSION));
$maxFileSize = self::getMaxFileSizeByExt($ext);
$maxContentSize = self::MAX_CONTENT_LENGTH;
$result = TextExtractor::extractFile(
$fullPath,
(int) ($maxFileSize / 1024), // 转换为 KB
(int) ($maxContentSize / 1024) // 转换为 KB
);
if (Base::isSuccess($result)) {
return $result['data'] ?? '';
}
$result = self::extractFromPath($filePath);
if (is_string($result)) {
return $result;
}
}
}
@@ -402,6 +470,33 @@ class ManticoreFile
return '';
}
/**
* 从文件路径提取内容(核心方法)
* @return string|array 内容字符串,或错误数组
*/
private static function extractFromPath(string $relativePath): string|array
{
$fullPath = public_path($relativePath);
if (!file_exists($fullPath)) {
return ['error' => '文件不存在'];
}
$ext = strtolower(pathinfo($fullPath, PATHINFO_EXTENSION));
$maxFileSize = self::getMaxFileSizeByExt($ext);
$result = TextExtractor::extractFile(
$fullPath,
(int) ($maxFileSize / 1024),
(int) (self::MAX_CONTENT_LENGTH / 1024)
);
if (!Base::isSuccess($result)) {
return ['error' => $result['msg'] ?? '无法提取文件内容'];
}
return $result['data'] ?? '';
}
/**
* 构建用于生成向量的内容
* 包含文件名和文件内容,确保语义搜索能匹配文件名

View File

@@ -233,11 +233,12 @@ class TextExtractor
/**
* 获取文件内容
* @param $filePath
* @param int $fileMaxSize 最大文件大小,单位字节默认1024KB
* @param int $contentMaxSize 最大内容大小,单位字节默认300KB
* @param int $fileMaxSize 最大文件大小,单位KB默认1024KB
* @param int $contentMaxSize 最大内容大小,单位KB默认300KB
* @param bool $truncate 超过contentMaxSize时是否截取默认true截取false返回错误
* @return array
*/
public static function extractFile($filePath, int $fileMaxSize = 1024, int $contentMaxSize = 300): array
public static function extractFile($filePath, int $fileMaxSize = 1024, int $contentMaxSize = 300, bool $truncate = true): array
{
if (!file_exists($filePath) || !is_file($filePath)) {
return Base::retError("Failed to read contents of {$filePath}");
@@ -248,8 +249,13 @@ class TextExtractor
try {
$extractor = new self($filePath);
$content = $extractor->extractContent();
if (strlen($content) > $contentMaxSize * 1024) {
return Base::retError("Content size exceeds " . Base::readableBytes($contentMaxSize * 1024) . ", unable to display content");
$maxBytes = $contentMaxSize * 1024;
if (strlen($content) > $maxBytes) {
if ($truncate) {
$content = mb_substr($content, 0, $maxBytes);
} else {
return Base::retError("Content size exceeds " . Base::readableBytes($maxBytes) . ", unable to display content");
}
}
return Base::retSuccess("success", $content);
} catch (Exception $e) {

13
app/Module/UserImport.php Normal file
View File

@@ -0,0 +1,13 @@
<?php
namespace App\Module;
use Maatwebsite\Excel\Concerns\ToArray;
class UserImport implements ToArray
{
public function array(array $array)
{
return $array;
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Module;
use Maatwebsite\Excel\Concerns\FromArray;
use Maatwebsite\Excel\Concerns\WithHeadings;
class UserImportTemplate implements FromArray, WithHeadings
{
public function array(): array
{
return [
['employee@example.com', '张三', 'Abc123456', '工程师'],
];
}
public function headings(): array
{
return ['邮箱(必填)', '昵称(必填,2-20字)', '初始密码(必填,6-32位)', '职位(选填,2-20字)'];
}
}

View File

@@ -113,7 +113,9 @@ class ProjectTaskObserver extends AbstractObserver
return ProjectUser::whereProjectId($projectTask->project_id)->pluck('userid')->toArray();
}
if (in_array('projectOwnerUser', $dataType)) {
return ProjectUser::whereProjectId($projectTask->project_id)->where('owner', 1)->pluck('userid')->toArray();
return ProjectUser::whereProjectId($projectTask->project_id)
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
->pluck('userid')->toArray();
}
$array = [];
if (in_array('task', $dataType)) {

View File

@@ -99,8 +99,8 @@ class UserObserver extends AbstractObserver
if (!empty($changedFields)) {
// 判断是用户自己修改还是管理员修改
$currentUser = User::authInfo();
$eventType = ($currentUser && $currentUser->userid === $user->userid)
$currentUserid = User::userid();
$eventType = ($currentUserid > 0 && $currentUserid === $user->userid)
? 'profile_update'
: 'admin_update';
Apps::dispatchUserHook($user, 'user_update', $eventType, $changedFields);

View File

@@ -0,0 +1,134 @@
<?php
namespace App\Tasks;
use App\Models\ProjectTask;
use App\Models\ProjectTaskAiEvent;
use App\Module\AiTaskSuggestion;
/**
* AI 任务分析异步任务
* 处理单个任务的所有 AI 事件
*/
class AiTaskAnalyzeTask extends AbstractTask
{
protected int $taskId;
public function __construct(int $taskId)
{
parent::__construct();
$this->taskId = $taskId;
}
public function start()
{
$task = ProjectTask::with('project')->find($this->taskId);
if (!$task || $task->deleted_at) {
return;
}
// 获取该任务的所有待处理事件
$events = ProjectTaskAiEvent::where('task_id', $this->taskId)
->whereIn('status', [
ProjectTaskAiEvent::STATUS_PENDING,
ProjectTaskAiEvent::STATUS_FAILED,
])
->get()
->keyBy('event_type');
$suggestions = [];
// 遍历所有事件类型
foreach (ProjectTaskAiEvent::getEventTypes() as $eventType) {
$event = $events->get($eventType);
// 如果没有记录,跳过
if (!$event) {
continue;
}
// 如果是失败状态但不能重试,跳过
if ($event->status === ProjectTaskAiEvent::STATUS_FAILED && !$event->canRetry()) {
continue;
}
// 使用原子操作标记为处理中(防止并发重复处理)
$updated = ProjectTaskAiEvent::where('id', $event->id)
->whereIn('status', [ProjectTaskAiEvent::STATUS_PENDING, ProjectTaskAiEvent::STATUS_FAILED])
->update(['status' => ProjectTaskAiEvent::STATUS_PROCESSING]);
if (!$updated) {
// 已被其他进程处理
continue;
}
$event->status = ProjectTaskAiEvent::STATUS_PROCESSING;
try {
// 检查是否满足执行条件
$shouldExecute = AiTaskSuggestion::shouldExecute($task, $eventType);
if (!$shouldExecute) {
$event->markSkipped('不满足执行条件');
continue;
}
// 执行对应的分析
$result = $this->executeAnalysis($task, $eventType);
if ($result === null) {
$event->markSkipped('未生成有效建议');
continue;
}
// 收集建议
$suggestions[] = $result;
$event->markCompleted($result);
} catch (\Exception $e) {
$event->markFailed($e->getMessage());
\Log::error("AiTaskAnalyzeTask error: task={$this->taskId}, type={$eventType}, error={$e->getMessage()}");
}
}
// 如果有建议,发送消息
if (!empty($suggestions)) {
$msgId = AiTaskSuggestion::sendSuggestionMessage($task, $suggestions);
// 更新所有事件的 msg_id
if ($msgId) {
ProjectTaskAiEvent::where('task_id', $this->taskId)
->where('status', ProjectTaskAiEvent::STATUS_COMPLETED)
->update(['msg_id' => $msgId]);
}
}
}
/**
* 执行具体的分析
* @param ProjectTask $task 任务对象
* @param string $eventType 事件类型
*/
private function executeAnalysis(ProjectTask $task, string $eventType): ?array
{
switch ($eventType) {
case ProjectTaskAiEvent::EVENT_DESCRIPTION:
return AiTaskSuggestion::generateDescription($task);
case ProjectTaskAiEvent::EVENT_SUBTASKS:
return AiTaskSuggestion::generateSubtasks($task);
case ProjectTaskAiEvent::EVENT_ASSIGNEE:
return AiTaskSuggestion::generateAssignee($task);
case ProjectTaskAiEvent::EVENT_SIMILAR:
return AiTaskSuggestion::findSimilarTasks($task);
default:
return null;
}
}
public function end()
{
}
}

View File

@@ -0,0 +1,125 @@
<?php
namespace App\Tasks;
use App\Module\Apps;
use App\Models\ProjectTask;
use App\Models\ProjectTaskAiEvent;
use App\Module\Base;
use Carbon\Carbon;
use Hhxsv5\LaravelS\Swoole\Task\Task;
/**
* AI 任务建议定时任务
* 扫描新建任务并投递分析任务
*/
class AiTaskLoopTask extends AbstractTask
{
/**
* 单次处理任务数量上限
*/
const BATCH_SIZE = 5;
/**
* 任务创建后多久开始分析(秒)
*/
const DELAY_SECONDS = 10;
public function __construct()
{
parent::__construct();
}
public function start()
{
// 检查 AI 插件是否安装
if (!Apps::isInstalled('ai')) {
return;
}
// 检查系统级 AI 自动分析开关
if (Base::settingFind('system', 'task_ai_auto_analyze', 'open') === 'close') {
return;
}
// 查询待处理的任务
$tasks = $this->findPendingTasks();
foreach ($tasks as $task) {
// 检查项目级 AI 自动分析开关
if ($task->project && $task->project->ai_auto_analyze === 'close') {
continue;
}
// 为任务创建事件记录
$this->createEventRecords($task);
// 投递异步分析任务
Task::deliver(new AiTaskAnalyzeTask($task->id));
}
}
/**
* 查找待处理的任务
*/
private function findPendingTasks(): \Illuminate\Support\Collection
{
$delayTime = Carbon::now()->subSeconds(self::DELAY_SECONDS);
// 子查询:已经有 AI 事件记录的任务
$processedTaskIds = ProjectTaskAiEvent::select('task_id')
->distinct()
->pluck('task_id');
// 查询新建任务(未处理过的)
$newTasks = ProjectTask::with('project')
->where('parent_id', 0) // 只处理主任务
->whereNull('deleted_at')
->whereNull('archived_at')
->where('created_at', '<=', $delayTime) // 创建超过延迟时间
->where('created_at', '>=', Carbon::now()->subDays(1)) // 只处理1天内的
->whereNotIn('id', $processedTaskIds)
->orderBy('created_at', 'asc')
->take(self::BATCH_SIZE)
->get();
// 查询需要重试的任务(优先处理较早失败的)
$retryTaskIds = ProjectTaskAiEvent::where('status', ProjectTaskAiEvent::STATUS_FAILED)
->where('retry_count', '<', ProjectTaskAiEvent::MAX_RETRY)
->select('task_id')
->distinct()
->orderBy('updated_at', 'asc')
->take(self::BATCH_SIZE - $newTasks->count())
->pluck('task_id');
$retryTasks = ProjectTask::with('project')
->whereIn('id', $retryTaskIds)
->whereNull('deleted_at')
->get();
return $newTasks->merge($retryTasks)->take(self::BATCH_SIZE);
}
/**
* 为任务创建事件记录
*/
private function createEventRecords(ProjectTask $task): void
{
foreach (ProjectTaskAiEvent::getEventTypes() as $eventType) {
ProjectTaskAiEvent::firstOrCreate(
[
'task_id' => $task->id,
'event_type' => $eventType,
],
[
'status' => ProjectTaskAiEvent::STATUS_PENDING,
'retry_count' => 0,
]
);
}
}
public function end()
{
}
}

View File

@@ -490,10 +490,6 @@ class BotReceiveMsgTask extends AbstractTask
if ($dialog->session_id) {
$extras['context_key'] = 'session_' . $dialog->session_id;
}
// 设置文心一言的API密钥
if ($type === 'wenxin') {
$extras['api_key'] .= ':' . $setting['wenxin_secret'];
}
// 群聊清理上下文(群聊不使用上下文)
if ($dialog->type === 'group') {
$extras['before_clear'] = 1;

View File

@@ -256,6 +256,9 @@ class ManticoreSyncTask extends AbstractTask
@shell_exec("php /var/www/artisan manticore:sync-projects --i 2>&1 &");
@shell_exec("php /var/www/artisan manticore:sync-tasks --i 2>&1 &");
@shell_exec("php /var/www/artisan manticore:sync-msgs --i 2>&1 &");
// 启动失败重试命令
@shell_exec("php /var/www/artisan manticore:retry-failures 2>&1 &");
}
/**

View File

@@ -38,7 +38,7 @@ class PushUmengMsg extends AbstractTask
}
// 消息ID
$msgId = isset($this->array['id']) ? intval($this->array['id']) : 0;
$msgId = isset($this->array['extra']['msg_id']) ? intval($this->array['extra']['msg_id']) : 0;
// 处理用户列表
$userids = is_array($this->userid) ? $this->userid : [$this->userid];

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Tasks;
use App\Models\User;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogMsg;
use App\Models\WebSocketDialogMsgTodo;
use App\Module\Doo;
use Carbon\Carbon;
/**
* 待办提醒:到点由 todo-alert 机器人在原会话发一条「引用原消息 + @被指派成员」的普通文本
* (同一消息同批到点的成员合并一条)。
*/
class TodoRemindTask extends AbstractTask
{
public function __construct()
{
parent::__construct();
}
/**
* 构造提醒文本:每个被提醒成员一个 @ span + 提示语。
* 直接拼 <span class="mention user" data-id> 是因为 sendMsg 不会调用 formatMsg
* 文本会原样入库msgJoinGroup 据此 span 正则提取 @。
*/
public static function buildRemindText(array $mentionUserids): string
{
$nicknames = User::whereIn('userid', $mentionUserids)->pluck('nickname', 'userid');
$mentionText = '';
foreach ($mentionUserids as $uid) {
$name = $nicknames[$uid] ?? $uid;
$mentionText .= "<span class=\"mention user\" data-id=\"{$uid}\">@{$name}</span> ";
}
return $mentionText . Doo::translate('你有一条待办到提醒时间啦');
}
public function start()
{
$rows = WebSocketDialogMsgTodo::dueReminders();
if ($rows->isEmpty()) {
return;
}
$botUser = User::botGetOrCreate('todo-alert');
if (empty($botUser)) {
return;
}
foreach ($rows->groupBy('msg_id') as $msgId => $group) {
$rowIds = $group->pluck('id')->toArray();
$userids = $group->pluck('userid')->map('intval')->values()->toArray();
//
$msg = WebSocketDialogMsg::find($msgId);
$dialog = $msg ? WebSocketDialog::find($msg->dialog_id) : null;
if (empty($msg) || empty($dialog)) {
// 原消息/会话已不存在:标记已提醒,避免空转重复扫描
WebSocketDialogMsgTodo::whereIn('id', $rowIds)->update(['reminded_at' => Carbon::now()]);
continue;
}
//
$memberIds = $dialog->dialogUser->pluck('userid')->map('intval')->values()->toArray();
$mentionUserids = array_values(array_intersect($userids, $memberIds));
if (empty($mentionUserids)) {
// 被指派人都已退群:没人可 @,标记已提醒避免空转重复扫描
WebSocketDialogMsgTodo::whereIn('id', $rowIds)->update(['reminded_at' => Carbon::now()]);
continue;
}
$res = WebSocketDialogMsg::sendMsg(
"reply-{$msg->id}", // 引用原消息 → reply_data 自动填充
$dialog->id,
'text', // 普通文本
['text' => self::buildRemindText($mentionUserids)],
$botUser->userid,
false, false, false // push_self / push_retry / push_silence
);
//
if (\App\Module\Base::isSuccess($res)) {
WebSocketDialogMsgTodo::whereIn('id', $rowIds)->update(['reminded_at' => Carbon::now()]);
}
}
}
public function end()
{
}
}

View File

@@ -189,7 +189,12 @@ class WebSocketDialogMsgTask extends AbstractTask
if ($umengUserid) {
$setting = Base::setting('appPushSetting');
if ($setting['push'] === 'open') {
$umengTitle = User::userid2nickname($msg->userid);
if ($msg->userid == -1) {
// AI 助手虚拟用户没有会员记录,取自定义昵称或默认名称
$umengTitle = ($msg->msg['nickname'] ?? '') ?: Doo::translate('AI 助手');
} else {
$umengTitle = User::userid2nickname($msg->userid);
}
$umengBody = WebSocketDialogMsg::previewMsg($msg);
if ($dialog->type == 'group') {
$umengBody = $umengTitle . ': ' . $umengBody;

347
bin/version.js vendored
View File

@@ -1,347 +0,0 @@
const fs = require('fs');
const path = require("path");
const exec = require('child_process').exec;
let ProxyAgent = null;
try {
ProxyAgent = require("undici").ProxyAgent;
} catch (error) {
ProxyAgent = null;
}
const packageFile = path.resolve(process.cwd(), "package.json");
const changeFile = path.resolve(process.cwd(), "CHANGELOG.md");
const verOffset = 6394; // 版本号偏移量
const codeOffset = 34; // 代码版本号偏移量
const envFilePath = path.resolve(process.cwd(), ".env");
const defaultAiSystemPrompt = "你是一位软件发布日志编辑专家。请产出 Markdown 更新日志,面向普通用户,以通俗友好的简体中文描述更新带来的直接好处,避免技术术语。所有章节标题必须以 `### ` 开头并保持英文 Title Case例如 `### Features`、`### Bug Fixes`、`### Performance`、`### Documentation` 等)。每个章节内的条目按用户价值和影响范围排序,将更重要、影响更广的更新放在前面。";
const defaultOpenAiEndpoint = "https://api.openai.com/v1/chat/completions";
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) {
return;
}
const content = fs.readFileSync(filePath, "utf8");
content.split(/\r?\n/).forEach(rawLine => {
const line = rawLine.trim();
if (!line || line.startsWith("#")) {
return;
}
const equalsIndex = line.indexOf("=");
if (equalsIndex === -1) {
return;
}
let key = line.slice(0, equalsIndex).trim();
if (key.startsWith("export ")) {
key = key.slice(7).trim();
}
let value = line.slice(equalsIndex + 1).trim();
if (!value) {
value = "";
}
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
} else {
const commentIndex = value.indexOf(" #");
if (commentIndex !== -1) {
value = value.slice(0, commentIndex).trim();
}
}
if (process.env[key] === undefined) {
process.env[key] = value;
}
});
}
loadEnvFile(envFilePath);
function resolveApiEndpoint(candidate) {
const source = (candidate || "").trim();
if (!source) {
return defaultOpenAiEndpoint;
}
if (/\/chat\/completions(\?|$)/.test(source)) {
return source;
}
const normalized = source.replace(/\/+$/, "");
if (/\/v\d+$/i.test(normalized)) {
return `${normalized}/chat/completions`;
}
return `${normalized}/v1/chat/completions`;
}
function loadSocksProxyAgent(proxyUrl) {
try {
const { SocksProxyAgent } = require('socks-proxy-agent');
return new SocksProxyAgent(proxyUrl);
} catch (error) {
if (error && error.code === 'MODULE_NOT_FOUND') {
console.warn("检测到 SOCKS 代理,但未安装 socks-proxy-agent请运行 `npm install --save-dev socks-proxy-agent` 后重试。");
} else {
console.warn(`无法初始化 SOCKS 代理: ${error?.message || error}`);
}
return null;
}
}
function createProxyDispatcher(proxyUrl) {
if (!proxyUrl) {
return null;
}
let parsedProtocol = '';
try {
parsedProtocol = new URL(proxyUrl).protocol.replace(':', '').toLowerCase();
} catch (error) {
console.warn(`代理地址无效 (${proxyUrl}): ${error.message}`);
return null;
}
if (parsedProtocol.startsWith('socks')) {
return loadSocksProxyAgent(proxyUrl);
}
if (!ProxyAgent) {
console.warn('未找到 undici.ProxyAgent无法启用 HTTP 代理。');
return null;
}
try {
return new ProxyAgent(proxyUrl);
} catch (error) {
console.warn(`无法初始化代理 (${proxyUrl}): ${error.message}`);
return null;
}
}
function buildDefaultUserPrompt(version, changelogSection) {
return [
"你是一位软件发布日志编辑专家。",
"下面是一段通过 git 提交记录自动生成的更新日志文本。",
"",
"请将其整理为一份「面向普通用户、简洁概览风格」的 changelog保持 Markdown 格式,包含以下结构:",
"",
`## [${version}]`,
"",
"### Features",
"",
"- ...",
"",
"### Bug Fixes",
"",
"- ...",
"",
"### Performance",
"",
"- ...",
"",
"**要求:**",
"1. 删除技术性或重复的细节,合并相似项。",
"2. 语句自然简洁,用简体中文描述。",
"3. 使用贴近日常的词汇,突出更新对普通用户的直接价值,避免开发或管理术语(如\"refactor\"、\"merge branch\"、\"commit lint\")。",
"4. 小节标题必须以 `### ` 开头并保持英文 Title Case例如 `### Features`、`### Bug Fixes`、`### Performance`、`### Documentation`、`### Security`、`### Miscellaneous` 等),不得翻译成中文。",
"5. 每个小节内的条目按用户价值和影响范围排序,将更重要、影响更广的更新放在前面。",
"6. 若某个小节没有内容,请省略整段小节(包括标题)。",
"7. 输出仅为 Markdown changelog 内容,不加其他解释。",
"",
"以下是原始日志:",
"```markdown",
changelogSection,
"```"
].join("\n");
}
function runExec(command) {
return new Promise((resolve, reject) => {
exec(command, { maxBuffer: 1024 * 1024 * 10 }, (err, stdout, stderr) => {
if (err) {
reject(err);
return;
}
resolve(stdout.toString());
});
});
}
function removeDuplicateLines(log) {
const logs = log.split(/(\n## \[.*?\])/);
return logs.map(str => {
const array = [];
const items = str.split("\n");
items.forEach(item => {
if (/^-/.test(item)) {
if (array.indexOf(item) === -1) {
array.push(item);
}
} else {
array.push(item);
}
});
return array.join("\n");
}).join('');
}
function findSectionBounds(content, version) {
const heading = `## [${version}]`;
const start = content.indexOf(heading);
if (start === -1) {
return null;
}
const nextHeadingIndex = content.indexOf("\n## [", start + heading.length);
const end = nextHeadingIndex === -1 ? content.length : nextHeadingIndex;
return { start, end };
}
function trimCliffOutput(rawOutput, version) {
const markerIndex = rawOutput.indexOf("## [");
if (markerIndex === -1) {
return "";
}
return rawOutput
.slice(markerIndex)
.replace("## [Unreleased]", `## [${version}]`)
.trim();
}
function buildAiHeaders(apiUrl, apiKey) {
const headers = { "Content-Type": "application/json" };
const customHeader = process.env.CHANGELOG_AI_AUTH_HEADER;
if (customHeader) {
const separatorIndex = customHeader.indexOf(":");
if (separatorIndex !== -1) {
const headerName = customHeader.slice(0, separatorIndex).trim();
const headerValue = customHeader.slice(separatorIndex + 1).trim();
if (headerName && headerValue) {
headers[headerName] = headerValue;
}
}
return headers;
}
if (apiUrl.includes("openai.azure.com")) {
headers["api-key"] = apiKey;
} else {
headers.Authorization = `Bearer ${apiKey}`;
}
return headers;
}
async function enhanceWithAI(version, changelogSection) {
const apiKey = (process.env.OPENAI_API_KEY || "").trim();
if (!apiKey) {
console.warn("未设置 OPENAI_API_KEY跳过 AI 发布日志整理。");
return changelogSection;
}
const proxyUrl = (process.env.OPENAI_PROXY_URL || "").trim();
const explicitApiUrl = process.env.CHANGELOG_AI_URL || process.env.OPENAI_API_URL;
const apiUrl = resolveApiEndpoint(explicitApiUrl);
const dispatcher = createProxyDispatcher(proxyUrl);
const model = process.env.CHANGELOG_AI_MODEL || process.env.OPENAI_API_MODEL || "gpt-4o-mini";
const systemPrompt = process.env.CHANGELOG_AI_SYSTEM_PROMPT || defaultAiSystemPrompt;
const userPrompt = process.env.CHANGELOG_AI_PROMPT || buildDefaultUserPrompt(version, changelogSection);
try {
const requestInit = {
method: "POST",
headers: buildAiHeaders(apiUrl, apiKey),
body: JSON.stringify({
model,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt }
],
})
};
if (dispatcher) {
requestInit.dispatcher = dispatcher;
}
const response = await fetch(apiUrl, requestInit);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`AI request failed: ${errorText}`);
}
const data = await response.json();
const aiText = data?.choices?.[0]?.message?.content?.trim();
if (!aiText) {
throw new Error("AI response did not contain content.");
}
return aiText
.replace(/^\s*```markdown\s*/i, "")
.replace(/\s*```\s*$/i, "")
.trim();
} catch (error) {
console.warn("AI summarization failed, falling back to original section:", error.message);
return changelogSection;
}
}
async function generateLatestSection(version) {
const rawOutput = await runExec('docker run -t --rm -v "$(pwd)":/app/ orhunp/git-cliff:1.3.0 --unreleased');
const section = trimCliffOutput(rawOutput, version);
if (!section.trim() || section.trim() === `## [${version}]`) {
return "";
}
return section;
}
function insertChangelogSection(existing, section, version) {
const trimmedSection = section.trim();
if (!trimmedSection) {
return existing;
}
const bounds = findSectionBounds(existing, version);
if (bounds) {
return `${existing.slice(0, bounds.start)}${trimmedSection}\n\n${existing.slice(bounds.end).replace(/^(\n)+/, "")}`;
}
const insertIndex = existing.indexOf("\n## [");
if (insertIndex === -1) {
return `${existing.trimEnd()}\n\n${trimmedSection}\n`;
}
const head = existing.slice(0, insertIndex).trimEnd();
const tail = existing.slice(insertIndex).replace(/^(\n)+/, "");
return `${head}\n\n${trimmedSection}\n\n${tail}`;
}
async function main() {
try {
const verCountRaw = await runExec("git rev-list --count HEAD");
const codeCountRaw = await runExec("git tag --merged pro -l 'v*' | wc -l");
const verCount = verCountRaw.trim();
const codeCount = codeCountRaw.trim();
const num = verOffset + parseInt(verCount, 10);
if (Number.isNaN(num) || Math.floor(num % 100) < 0) {
throw new Error(`get version error ${verCount}`);
}
const version = `${Math.floor(num / 10000)}.${Math.floor((num % 10000) / 100)}.${Math.floor(num % 100)}`;
const codeVersion = codeOffset + parseInt(codeCount, 10);
let packageContent = fs.readFileSync(packageFile, "utf8");
packageContent = packageContent.replace(/"version":\s*"(.*?)"/, `"version": "${version}"`);
packageContent = packageContent.replace(/"codeVerson":(.*?)(,|$)/, `"codeVerson": ${codeVersion}$2`);
fs.writeFileSync(packageFile, packageContent, "utf8");
console.log("New version: " + version);
console.log("New code verson: " + codeVersion);
if (!fs.existsSync(changeFile)) {
throw new Error("Change file does not exist");
}
const latestSection = await generateLatestSection(version);
if (!latestSection) {
console.log("No new changelog entries detected.");
return;
}
const aiSection = await enhanceWithAI(version, latestSection);
const changelogContent = fs.readFileSync(changeFile, "utf8");
const mergedContent = insertChangelogSection(changelogContent, aiSection, version);
const dedupedContent = removeDuplicateLines(mergedContent);
fs.writeFileSync(changeFile, dedupedContent.trimEnd() + "\n", "utf8");
console.log("Log file updated: CHANGELOG.md");
} catch (error) {
console.error(error);
process.exitCode = 1;
}
}
main();

View File

@@ -1,60 +0,0 @@
# configuration file for git-cliff (0.1.0)
[changelog]
# changelog header
header = """
# Changelog\n
All notable changes to this project will be documented in this file.\n
"""
# template for the changelog body
# https://tera.netlify.app/docs/#introduction
body = """
{% if version %}\
## [{{ version | trim_start_matches(pat="v") }}]
{% else %}\
## [Unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
{% for commit in commits %}
- {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}\
{% endfor %}
{% endfor %}\n
"""
# remove the leading and trailing whitespace from the template
trim = true
# changelog footer
footer = """
"""
[git]
# parse the commits based on https://www.conventionalcommits.org
conventional_commits = true
# filter out the commits that are not conventional
filter_unconventional = true
# regex for parsing and grouping commits
commit_parsers = [
{ message = "^feat", group = "Features"},
{ message = "^fix", group = "Bug Fixes"},
{ message = "^doc", group = "Documentation"},
{ message = "^perf", group = "Performance"},
{ message = "^pref", group = "Performance"},
{ message = "^refactor", group = "Refactor"},
{ message = "^style", group = "Styling"},
{ message = "^test", group = "Testing"},
{ message = "^chore\\(release\\): prepare for", skip = true},
{ message = "^chore", group = "Miscellaneous Tasks"},
{ body = ".*security", group = "Security"},
]
# filter out the commits that are not matched by commit parsers
filter_commits = true
# glob pattern for matching git tags
tag_pattern = "v[0-9]*"
# regex for skipping tags
skip_tags = "v0.1.0-beta.1"
# regex for ignoring tags
ignore_tags = ""
# sort the tags chronologically
date_order = false
# sort the commits inside sections by oldest/newest order
sort_commits = "newest"

49
cmd
View File

@@ -391,8 +391,13 @@ env_set() {
local val=$2
local exist=`cat ${WORK_DIR}/.env | grep "^$key="`
if [ -z "$exist" ]; then
echo "" >> $WORK_DIR/.env
echo "$key=$val" >> $WORK_DIR/.env
else
# 值未变化则直接返回,避免无谓重写 .env重写会改 mtime触发 vite 全量重启/前端刷新)
if [[ "$(env_get "$key")" == "$val" ]]; then
return 0
fi
if [[ `uname` == 'Linux' ]]; then
sed -i "/^${key}=/c\\${key}=${val}" ${WORK_DIR}/.env
else
@@ -501,10 +506,36 @@ DooTask 管理脚本
EOF
}
# 检测APP_ID是否与其他实例冲突
check_instance() {
local app_id=$(env_get APP_ID)
local container_name="dootask-php-${app_id}"
local mount_path=$(docker inspect "$container_name" --format '{{range .Mounts}}{{if eq .Destination "/var/www"}}{{.Source}}{{end}}{{end}}' 2>/dev/null)
if [[ -n "$mount_path" ]] && [[ "$mount_path" != "$WORK_DIR" ]]; then
error "APP_ID${app_id})已被其他实例使用:${mount_path}"
error "请先清空 .env 中的 APP_ID 和 APP_IPPR 再重新安装"
exit 1
fi
}
# 检测端口是否被占用
# 参数1: 端口号, 参数2: 当前端口号(可选,相同则跳过检测)
check_port() {
local port=$1
local current_port=$2
if [[ "$port" -gt 0 ]] && [[ "$port" != "$current_port" ]]; then
if ! docker run --rm -p "${port}:80" --entrypoint true nginx:alpine 2>/dev/null; then
error "端口 ${port} 已被占用,请指定其他端口"
exit 1
fi
fi
}
# 安装函数
handle_install() {
check_sudo
check_instance
local relock=$(arg_get relock)
local port=$(arg_get port)
@@ -561,8 +592,17 @@ handle_install() {
done
# 设置端口
local old_port=$(env_get APP_PORT)
[[ "$port" -gt 0 ]] && env_set APP_PORT "$port"
# 检测端口占用(首次安装或端口变更时)
local new_port=$(env_get APP_PORT)
if [ -z "$(docker_name nginx)" ] || [[ "$new_port" != "$old_port" ]]; then
check_port "$new_port"
local ssl_port=$(env_get APP_SSL_PORT)
check_port "$ssl_port"
fi
# 启动PHP容器
$COMPOSE up php -d
@@ -763,6 +803,7 @@ case "$1" in
;;
"port")
shift 1
check_port "$1" "$(env_get APP_PORT)"
env_set APP_PORT "$1"
$COMPOSE up -d
success "修改成功"
@@ -841,7 +882,7 @@ case "$1" in
else
https_auto
fi
restart_php
$COMPOSE up -d
;;
"artisan")
shift 1
@@ -890,10 +931,6 @@ case "$1" in
container_exec php "php app/Models/clearHelper.php"
container_exec php "php artisan ide-helper:models -W"
;;
"translate")
shift 1
container_exec php "cd /var/www/language && php translate.php"
;;
"restart")
shift 1
$COMPOSE stop "$@"

View File

@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateManticoreSyncFailuresTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
if (Schema::hasTable('manticore_sync_failures')) {
return;
}
Schema::create('manticore_sync_failures', function (Blueprint $table) {
$table->id();
$table->string('data_type', 20)->comment('数据类型: msg/file/task/project/user');
$table->bigInteger('data_id')->comment('数据ID');
$table->string('action', 20)->comment('操作类型: sync/delete');
$table->string('error_message', 500)->nullable()->comment('错误信息');
$table->integer('retry_count')->default(0)->comment('重试次数');
$table->timestamp('last_retry_at')->nullable()->comment('最后重试时间');
$table->timestamps();
$table->unique(['data_type', 'data_id', 'action'], 'uk_type_id_action');
$table->index(['last_retry_at', 'retry_count'], 'idx_retry');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('manticore_sync_failures');
}
}

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateProjectTaskAiEventsTable extends Migration
{
public function up()
{
if (Schema::hasTable('project_task_ai_events')) {
return;
}
Schema::create('project_task_ai_events', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('task_id')->comment('任务ID');
$table->string('event_type', 50)->comment('事件类型: description/subtasks/assignee/similar');
$table->string('status', 20)->default('pending')->comment('状态: pending/processing/completed/failed/skipped');
$table->tinyInteger('retry_count')->unsigned()->default(0)->comment('重试次数');
$table->json('result')->nullable()->comment('执行结果');
$table->text('error')->nullable()->comment('错误信息');
$table->bigInteger('msg_id')->nullable()->default(0)->comment('消息ID');
$table->timestamp('executed_at')->nullable()->comment('执行时间');
$table->timestamps();
$table->unique(['task_id', 'event_type'], 'uk_task_event');
$table->index('status', 'idx_status');
$table->index('created_at', 'idx_created');
});
}
public function down()
{
Schema::dropIfExists('project_task_ai_events');
}
}

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddAiAutoAnalyzeToProjectsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('projects', function (Blueprint $table) {
$table->string('ai_auto_analyze', 20)->default('open')->after('archive_days');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('projects', function (Blueprint $table) {
$table->dropColumn('ai_auto_analyze');
});
}
}

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateAiAssistantSessionsTable extends Migration
{
public function up()
{
if (Schema::hasTable('ai_assistant_sessions')) {
return;
}
Schema::create('ai_assistant_sessions', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('userid')->default(0)->comment('用户ID');
$table->string('session_key', 100)->default('')->comment('场景分类key');
$table->string('session_id', 100)->default('')->comment('前端生成的会话ID');
$table->string('scene_key', 200)->default('')->comment('具体场景标识');
$table->string('title', 255)->default('')->comment('会话标题');
$table->longText('data')->nullable()->comment('responses JSON');
$table->longText('images')->nullable()->comment('图片映射 {imageId: relativePath}');
$table->timestamps();
$table->index('userid', 'idx_userid');
$table->unique(['userid', 'session_key', 'session_id'], 'uk_user_session');
});
}
public function down()
{
Schema::dropIfExists('ai_assistant_sessions');
}
}

View File

@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddRoleToWebSocketDialogUsers extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('web_socket_dialog_users', function (Blueprint $table) {
if (!Schema::hasColumn('web_socket_dialog_users', 'role')) {
$table->tinyInteger('role')->default(0)->after('userid')
->comment('0=普通成员 1=群主 2=群管理员');
$table->index(['dialog_id', 'role'], 'idx_dialog_role');
}
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('web_socket_dialog_users', function (Blueprint $table) {
if (Schema::hasColumn('web_socket_dialog_users', 'role')) {
$table->dropIndex('idx_dialog_role');
$table->dropColumn('role');
}
});
}
}

View File

@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
class BackfillDialogOwnerRole extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
$prefix = DB::getTablePrefix();
// 把每个群里 userid = web_socket_dialogs.owner_id 的成员记录设为 role=1主群主
// 幂等:仅当 role=0 时才更新
DB::statement("
UPDATE {$prefix}web_socket_dialog_users du
INNER JOIN {$prefix}web_socket_dialogs d ON d.id = du.dialog_id
SET du.role = 1
WHERE d.owner_id > 0
AND du.userid = d.owner_id
AND du.role = 0
");
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
$prefix = DB::getTablePrefix();
// 回滚:把 role=1 的记录全部回到 role=0
DB::statement("UPDATE {$prefix}web_socket_dialog_users SET role = 0 WHERE role = 1");
}
}

View File

@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateUserDepartmentOwnersTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
if (!Schema::hasTable('user_department_owners')) {
Schema::create('user_department_owners', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('department_id')->comment('部门ID');
$table->unsignedBigInteger('userid')->comment('部门管理员 userid');
$table->timestamp('created_at')->useCurrent();
$table->unique(['department_id', 'userid'], 'uniq_dept_user');
$table->index('userid', 'idx_userid');
});
}
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
if (Schema::hasTable('user_department_owners')) {
Schema::dropIfExists('user_department_owners');
}
}
}

View File

@@ -0,0 +1,67 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
class BackfillProjectDialogPrimaryOwner extends Migration
{
/**
* Run the migrations.
*
* 修复历史项目群聊未登记群主的问题:
* - 早期 Project::addProject 调 createGroup 时未传 owner_id 第 4 参
* (在 commit 3a9001e09 才补上),导致老项目群 dialogs.owner_id = 0
* - 这些群也因此被 2026_04_30_000002_backfill_dialog_owner_role 跳过
* (那条迁移要求 owner_id > 0主负责人那条 dialog_users.role 仍为 0
*
* 本迁移仅处理 group_type = 'project' 且未软删的项目:
* (a) dialogs.owner_id = 0 → 按 project_users.owner=1 回填
* (b) 同一批群里主负责人那条 dialog_users.role = 0 → 设为 1
*
* 全部带幂等条件,可重跑。
*
* @return void
*/
public function up()
{
$prefix = DB::getTablePrefix();
// (a) 回填 dialogs.owner_id
DB::statement("
UPDATE {$prefix}web_socket_dialogs d
INNER JOIN {$prefix}projects p ON p.dialog_id = d.id
INNER JOIN {$prefix}project_users pu ON pu.project_id = p.id AND pu.owner = 1
SET d.owner_id = pu.userid
WHERE d.owner_id = 0
AND d.group_type = 'project'
AND p.deleted_at IS NULL
");
// (b) 把这些项目群里主负责人那条 dialog_users.role 设为 1
// 不依赖 (a) 的结果,直接按 project_users.owner=1 反查,幂等条件 du.role=0
DB::statement("
UPDATE {$prefix}web_socket_dialog_users du
INNER JOIN {$prefix}projects p ON p.dialog_id = du.dialog_id
INNER JOIN {$prefix}project_users pu
ON pu.project_id = p.id
AND pu.userid = du.userid
AND pu.owner = 1
SET du.role = 1
WHERE du.role = 0
AND p.deleted_at IS NULL
");
}
/**
* Reverse the migrations.
*
* 数据回填类迁移不提供精确回滚——回滚会丢失原本就正确的数据。
* 如需重置,请手动操作。
*
* @return void
*/
public function down()
{
// no-op
}
}

View File

@@ -0,0 +1,56 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
class BackfillDialogRoleConsistency extends Migration
{
/**
* Run the migrations.
*
* 兜底修复 role/owner_id 一致性:
* - 部门群 owner_id 与 user_departments.owner_userid 对齐
* - owner_id > 0 的群确保 owner 成员存在且 role=1
* - 同一群中非 owner 的 role=1 降为普通成员(不影响 role=2 管理员)
* - 历史 owner_id=0 的普通用户群按最早的非机器人成员回填群主
* - 清理部门负责人残留的 user_department_owners 记录
*
* 全部语句带幂等条件,可重复运行。
*
* @return void
*/
public function up()
{
$prefix = DB::getTablePrefix();
// 1) 部门群 owner_id 以 user_departments.owner_userid 为准
DB::statement("\n UPDATE {$prefix}web_socket_dialogs d\n INNER JOIN {$prefix}user_departments ud ON ud.dialog_id = d.id\n SET d.owner_id = ud.owner_userid\n WHERE d.type = 'group'\n AND d.group_type = 'department'\n AND d.deleted_at IS NULL\n AND ud.owner_userid > 0\n AND d.owner_id != ud.owner_userid\n ");
// 2) 历史普通用户群 owner_id=0按最早加入的非机器人成员回填群主
DB::statement("\n UPDATE {$prefix}web_socket_dialogs d\n INNER JOIN (\n SELECT du.dialog_id, MIN(du.id) AS min_id\n FROM {$prefix}web_socket_dialog_users du\n WHERE du.userid > 0 AND du.bot = 0\n GROUP BY du.dialog_id\n ) first_du ON first_du.dialog_id = d.id\n INNER JOIN {$prefix}web_socket_dialog_users owner_du ON owner_du.id = first_du.min_id\n SET d.owner_id = owner_du.userid\n WHERE d.type = 'group'\n AND d.group_type = 'user'\n AND d.deleted_at IS NULL\n AND d.owner_id = 0\n ");
// 3) owner_id > 0 但 owner 不在群成员表时,补一条成员记录(仅补真实存在的用户)
DB::statement("\n INSERT INTO {$prefix}web_socket_dialog_users\n (dialog_id, userid, role, bot, important, last_at, created_at, updated_at)\n SELECT\n d.id,\n d.owner_id,\n 1,\n COALESCE(u.bot, 0),\n CASE WHEN d.group_type IN ('user', 'all') THEN 0 ELSE 1 END,\n CASE WHEN d.group_type IN ('user', 'department', 'all') THEN NOW(3) ELSE NULL END,\n NOW(3),\n NOW(3)\n FROM {$prefix}web_socket_dialogs d\n INNER JOIN {$prefix}users u ON u.userid = d.owner_id\n LEFT JOIN {$prefix}web_socket_dialog_users du\n ON du.dialog_id = d.id AND du.userid = d.owner_id\n WHERE d.type = 'group'\n AND d.deleted_at IS NULL\n AND d.owner_id > 0\n AND du.id IS NULL\n ");
// 4) owner 成员设为 role=1业务群 owner 同时保持 important=1
DB::statement("\n UPDATE {$prefix}web_socket_dialog_users du\n INNER JOIN {$prefix}web_socket_dialogs d ON d.id = du.dialog_id\n SET du.role = 1,\n du.important = CASE WHEN d.group_type IN ('user', 'all') THEN du.important ELSE 1 END\n WHERE d.type = 'group'\n AND d.deleted_at IS NULL\n AND d.owner_id > 0\n AND du.userid = d.owner_id\n AND (du.role != 1 OR (d.group_type NOT IN ('user', 'all') AND du.important != 1))\n ");
// 5) 同一群里非 owner 的 role=1 降为普通成员,避免多个主群主
DB::statement("\n UPDATE {$prefix}web_socket_dialog_users du\n INNER JOIN {$prefix}web_socket_dialogs d ON d.id = du.dialog_id\n SET du.role = 0\n WHERE d.type = 'group'\n AND d.deleted_at IS NULL\n AND d.owner_id > 0\n AND du.role = 1\n AND du.userid != d.owner_id\n ");
// 6) 部门负责人不能同时残留在部门管理员表
DB::statement("\n DELETE udo FROM {$prefix}user_department_owners udo\n INNER JOIN {$prefix}user_departments ud ON ud.id = udo.department_id\n WHERE ud.owner_userid = udo.userid\n ");
}
/**
* Reverse the migrations.
*
* 数据修复类迁移不提供精确回滚,避免破坏已校准的数据。
*
* @return void
*/
public function down()
{
// no-op
}
}

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddUseCountToProjectTaskTemplates extends Migration
{
public function up()
{
Schema::table('project_task_templates', function (Blueprint $table) {
$table->unsignedInteger('use_count')->default(0)->after('is_default')->comment('累计使用次数');
$table->timestamp('last_used_at')->nullable()->after('use_count')->comment('最近一次使用时间');
$table->index(['use_count', 'last_used_at'], 'idx_template_usage');
});
}
public function down()
{
Schema::table('project_task_templates', function (Blueprint $table) {
$table->dropIndex('idx_template_usage');
$table->dropColumn(['use_count', 'last_used_at']);
});
}
}

View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddTaskTemplateShareToProjectsTable extends Migration
{
public function up()
{
Schema::table('projects', function (Blueprint $table) {
if (!Schema::hasColumn('projects', 'task_template_share')) {
$table->string('task_template_share', 20)->default('open')->after('ai_auto_analyze')->comment('共享模板开关');
}
});
}
public function down()
{
Schema::table('projects', function (Blueprint $table) {
if (Schema::hasColumn('projects', 'task_template_share')) {
$table->dropColumn('task_template_share');
}
});
}
}

View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddDepartmentOwnerViewToProjectsTable extends Migration
{
public function up()
{
Schema::table('projects', function (Blueprint $table) {
if (!Schema::hasColumn('projects', 'department_owner_view')) {
$table->string('department_owner_view', 20)->default('open')->after('task_template_share')->comment('部门负责人视角可见开关');
}
});
}
public function down()
{
Schema::table('projects', function (Blueprint $table) {
if (Schema::hasColumn('projects', 'department_owner_view')) {
$table->dropColumn('department_owner_view');
}
});
}
}

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddRemindToWebSocketDialogMsgTodos extends Migration
{
public function up()
{
Schema::table('web_socket_dialog_msg_todos', function (Blueprint $table) {
$table->timestamp('remind_at')->nullable()->comment('提醒时间')->after('done_at');
$table->timestamp('reminded_at')->nullable()->comment('已提醒时间')->after('remind_at');
$table->index(['remind_at', 'reminded_at', 'done_at'], 'idx_todo_remind');
});
}
public function down()
{
Schema::table('web_socket_dialog_msg_todos', function (Blueprint $table) {
$table->dropIndex('idx_todo_remind');
$table->dropColumn(['remind_at', 'reminded_at']);
});
}
}

View File

@@ -218,6 +218,6 @@ class WebSocketDialogsTableSeeder extends Seeder
User::botGetOrCreate('ai-claude');
$userids = User::whereBot(0)->whereNull('disable_at')->pluck('userid')->toArray();
WebSocketDialog::createGroup("全体成员 All members", $userids, 'all');
WebSocketDialog::createGroup(WebSocketDialog::ALL_GROUP_DEFAULT_NAME, $userids, 'all');
}
}

View File

@@ -42,8 +42,10 @@ services:
ports:
- "${APP_PORT}:80"
- "${APP_SSL_PORT:-0}:443"
environment:
APP_SCHEME: "${APP_SCHEME:-auto}"
volumes:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
- ./docker/nginx/default.conf:/etc/nginx/templates/default.conf.template
- ./:/var/www
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/health"]
@@ -96,7 +98,7 @@ services:
appstore:
container_name: "dootask-appstore-${APP_ID}"
privileged: true
image: "dootask/appstore:0.3.9"
image: "dootask/appstore:0.4.3"
volumes:
- shared_data:/usr/share/dootask
- ${HOST_DOCKER_SOCK:-/var/run/docker.sock}:/var/run/docker.sock

View File

@@ -1,23 +1,47 @@
map $host $app_scheme_raw {
default "${APP_SCHEME}";
}
map $app_scheme_raw $force_https {
"https" 1;
"on" 1;
"ssl" 1;
"1" 1;
"true" 1;
"yes" 1;
default 0;
}
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
"" close;
}
map $http_host $this_host {
"" $host;
"" $host;
default $http_host;
}
map $http_x_forwarded_proto $the_scheme {
default $http_x_forwarded_proto;
"" $scheme;
}
map $http_x_forwarded_host $the_host {
"" $this_host;
default $http_x_forwarded_host;
"" $this_host;
}
map $http_x_forwarded_proto $auto_scheme {
"" $scheme;
default $http_x_forwarded_proto;
}
map $force_https $the_scheme {
1 https;
default $auto_scheme;
}
upstream service {
server php:20000 weight=5 max_fails=3 fail_timeout=30s;
keepalive 16;
}
server {
listen 80;

395
electron/build.js vendored
View File

@@ -6,13 +6,14 @@ const child_process = require('child_process');
const ora = require('ora');
const yauzl = require('yauzl');
const axios = require('axios');
const FormData =require('form-data');
const tar = require('tar');
const utils = require('./lib/utils');
const r2 = require('./lib/r2');
const { buildReleaseIndex } = require('./lib/release-index');
const config = require('../package.json')
const env = require('dotenv').config({ path: './.env' })
const argv = process.argv;
const {BUILD_FRONTEND, APPLEID, APPLEIDPASS, GITHUB_TOKEN, GITHUB_REPOSITORY, PUBLISH_KEY} = process.env;
const {BUILD_FRONTEND, APPLEID, APPLEIDPASS, GITHUB_TOKEN, GITHUB_REPOSITORY} = process.env;
const electronDir = path.resolve(__dirname, "public");
const nativeCachePath = path.resolve(__dirname, ".native");
@@ -25,6 +26,9 @@ const architectures = ["arm64", "x64"];
let buildChecked = false,
updaterChecked = false;
const shellQuote = (value) => `'${String(value).replace(/'/g, `'\\''`)}'`;
const elapsedSeconds = (startTime) => `${((Date.now() - startTime) / 1000).toFixed(1)}s`;
/**
* 检测并下载更新器
*/
@@ -308,234 +312,23 @@ function changeLog() {
}
/**
* 封装 axios 自动重试
* @param data // {axios: object{}, onRetry: function, retryNumber: number}
* @returns {Promise<unknown>}
* 上传单个文件到 R2 的 draft/<version>/ 目录(带进度/spinner
*/
function axiosAutoTry(data) {
return new Promise((resolve, reject) => {
axios(data.axios).then(result => {
resolve(result)
}).catch(error => {
if (typeof data.retryNumber == 'number' && data.retryNumber > 0) {
data.retryNumber--;
if (typeof data.onRetry === "function") {
data.onRetry()
}
if (error.code == 'ECONNABORTED' || error.code == 'ECONNRESET') {
// 中止,超时
return resolve(axiosAutoTry(data))
} else {
if (error.response && error.response.status == 407) {
// 代理407
return setTimeout(v => {
resolve(axiosAutoTry(data))
}, 500 + Math.random() * 500)
} else if (error.response && error.response.status == 503) {
// 服务器异常
return setTimeout(v => {
resolve(axiosAutoTry(data))
}, 1000 + Math.random() * 500)
} else if (error.response && error.response.status == 429) {
// 并发超过限制
return setTimeout(v => {
resolve(axiosAutoTry(data))
}, 1000 + Math.random() * 1000)
}
}
}
reject(error)
})
})
}
/**
* 上传app应用
* @param url
*/
function androidUpload(url) {
if (!PUBLISH_KEY) {
console.error("缺少 PUBLISH_KEY 环境变量");
process.exit()
async function uploadDraftFile(client, localFile, version) {
const filename = path.basename(localFile);
const key = `draft/${version}/${filename}`;
const startTime = Date.now();
const spinner = ora(`Upload [0%] ${filename}`).start();
try {
await r2.uploadFile(client, localFile, key, (loaded, total) => {
const pct = Math.min(99, Math.round((loaded / total) * 100)) + '%';
spinner.text = `Upload [${pct}] ${filename}`;
});
} catch (error) {
spinner.fail(`Upload [fail] ${filename} (${elapsedSeconds(startTime)}): ${error.message || error}`);
throw error;
}
const releaseDir = path.resolve(__dirname, "../resources/mobile/platforms/android/eeuiApp/app/build/outputs/apk/release");
if (!fs.existsSync(releaseDir)) {
console.error("发布文件未找到");
process.exit()
}
fs.readdir(releaseDir, async (err, files) => {
if (err) {
console.warn(err)
} else {
const uploadOras = {}
for (const filename of files) {
const localFile = path.join(releaseDir, filename)
if (/\.apk$/.test(filename) && fs.existsSync(localFile)) {
const fileStat = fs.statSync(localFile)
if (fileStat.isFile()) {
uploadOras[filename] = ora(`Upload [0%] ${filename}`).start()
const formData = new FormData()
formData.append("file", fs.createReadStream(localFile));
formData.append("action", "draft");
await axiosAutoTry({
axios: {
method: 'post',
url: url,
data: formData,
headers: {
'Publish-Version': config.version,
'Publish-Key': PUBLISH_KEY,
'Content-Type': 'multipart/form-data;boundary=' + formData.getBoundary(),
},
onUploadProgress: progress => {
const complete = Math.min(99, Math.round(progress.loaded / progress.total * 100 | 0)) + '%'
uploadOras[filename].text = `Upload [${complete}] ${filename}`
},
},
onRetry: _ => {
uploadOras[filename].warn(`Upload [retry] ${filename}`)
uploadOras[filename] = ora(`Upload [0%] ${filename}`).start()
},
retryNumber: 3
}).then(({status, data}) => {
if (status !== 200) {
uploadOras[filename].fail(`Upload [fail:${status}] ${filename}`)
return
}
if (!utils.isJson(data)) {
uploadOras[filename].fail(`Upload [fail:not json] ${filename}`)
return
}
if (data.ret !== 1) {
uploadOras[filename].fail(`Upload [fail:ret ${data.ret}] ${filename}`)
return
}
uploadOras[filename].succeed(`Upload [100%] ${filename}`)
}).catch(_ => {
uploadOras[filename].fail(`Upload [fail] ${filename}`)
})
}
}
}
}
});
}
/**
* 通知发布完成
* @param url
*/
async function published(url) {
if (!PUBLISH_KEY) {
console.error("缺少 PUBLISH_KEY 环境变量");
process.exit()
}
const spinner = ora('完成发布...').start();
const formData = new FormData()
formData.append("action", "release");
await axiosAutoTry({
axios: {
method: 'post',
url: url,
data: formData,
headers: {
'Publish-Version': config.version,
'Publish-Key': PUBLISH_KEY,
},
},
retryNumber: 3
}).then(({status, data}) => {
if (status !== 200) {
spinner.fail('发布失败, status: ' + status)
return
}
if (!utils.isJson(data)) {
spinner.fail('发布失败, not json')
return
}
if (data.ret !== 1) {
spinner.fail(`发布失败, ${JSON.stringify(data)}`)
return
}
spinner.succeed('发布完成')
}).catch(_ => {
spinner.fail('发布失败')
})
}
/**
* 通用发布
* @param url
* @param key
* @param version
* @param output
*/
function genericPublish({url, key, version, output}) {
if (!/https?:\/\//i.test(url)) {
console.warn("发布地址无效: " + url)
return
}
const filePath = path.resolve(__dirname, output)
if (!fs.existsSync(filePath)) {
console.warn("发布文件未找到: " + filePath)
return
}
fs.readdir(filePath, async (err, files) => {
if (err) {
console.warn(err)
} else {
const uploadOras = {}
for (const filename of files) {
const localFile = path.join(filePath, filename)
if (fs.existsSync(localFile)) {
const fileStat = fs.statSync(localFile)
if (fileStat.isFile()) {
uploadOras[filename] = ora(`Upload [0%] ${filename}`).start()
const formData = new FormData()
formData.append("file", fs.createReadStream(localFile));
formData.append("action", "draft");
await axiosAutoTry({
axios: {
method: 'post',
url: url,
data: formData,
headers: {
'Publish-Version': version,
'Publish-Key': key,
'Content-Type': 'multipart/form-data;boundary=' + formData.getBoundary(),
},
onUploadProgress: progress => {
const complete = Math.min(99, Math.round(progress.loaded / progress.total * 100 | 0)) + '%'
uploadOras[filename].text = `Upload [${complete}] ${filename}`
},
},
onRetry: _ => {
uploadOras[filename].warn(`Upload [retry] ${filename}`)
uploadOras[filename] = ora(`Upload [0%] ${filename}`).start()
},
retryNumber: 3
}).then(({status, data}) => {
if (status !== 200) {
uploadOras[filename].fail(`Upload [fail:${status}] ${filename}`)
return
}
if (!utils.isJson(data)) {
uploadOras[filename].fail(`Upload [fail:not json] ${filename}`)
return
}
if (data.ret !== 1) {
uploadOras[filename].fail(`Upload [fail:ret ${data.ret}] ${filename}`)
return
}
uploadOras[filename].succeed(`Upload [100%] ${filename}`)
}).catch(_ => {
uploadOras[filename].fail(`Upload [fail] ${filename}`)
})
}
}
}
}
});
spinner.succeed(`Upload [100%] ${filename} (${elapsedSeconds(startTime)})`);
}
/**
@@ -609,8 +402,8 @@ async function startBuild(data) {
//
if (data.id === 'app') {
const eeuiDir = path.resolve(__dirname, "../resources/mobile");
const eeuiRun = `docker run --rm -v ${eeuiDir}:/work -w /work kuaifan/eeui-cli:0.0.1`
const publicDir = path.resolve(__dirname, "../resources/mobile/src/public");
const containerName = `dootask-eeui-${Date.now()}-${process.pid}`;
fse.removeSync(publicDir)
fse.copySync(electronDir, publicDir)
if (argv[3] === "publish") {
@@ -628,10 +421,19 @@ async function startBuild(data) {
fs.writeFileSync(xcconfigFile, xcconfigResult, 'utf8')
}
if (['build', 'publish'].includes(argv[3])) {
if (!fs.existsSync(path.resolve(eeuiDir, "node_modules"))) {
child_process.execSync(`${eeuiRun} npm install`, {stdio: "inherit", cwd: "resources/mobile"});
child_process.execSync(
`docker run -d --name ${containerName} -v ${shellQuote(eeuiDir)}:/work -w /work kuaifan/eeui-cli:0.0.1 sleep infinity`,
{stdio: "ignore", cwd: "resources/mobile"}
);
try {
if (!fs.existsSync(path.resolve(eeuiDir, "node_modules"))) {
child_process.execSync(`docker exec ${containerName} npm install`, {stdio: "inherit", cwd: "resources/mobile"});
}
child_process.execSync(`docker exec ${containerName} node /work/scripts/patch-eeui-build.js`, {stdio: "inherit", cwd: "resources/mobile"});
child_process.execSync(`docker exec ${containerName} eeui build --simple`, {stdio: "inherit", cwd: "resources/mobile"});
} finally {
child_process.execSync(`docker rm -f ${containerName}`, {stdio: "ignore", cwd: "resources/mobile"});
}
child_process.execSync(`${eeuiRun} eeui build --simple`, {stdio: "inherit", cwd: "resources/mobile"});
} else {
[
path.resolve(publicDir, "../../platforms/ios/eeuiApp/bundlejs/eeui/public"),
@@ -698,18 +500,24 @@ async function startBuild(data) {
fs.writeFileSync(packageFile, JSON.stringify(appConfig, null, 4), 'utf8');
child_process.execSync(`npm run ${platform}-publish`, {stdio: "inherit", cwd: "electron"});
}
// generic (build or publish)
appConfig.build.publish = data.publish
// generic (build or publish) —— 有 R2_PUBLIC_URL 时自动更新源指向 R2 release/
appConfig.build.publish = r2.R2_PUBLIC_URL
? { provider: 'generic', url: `${r2.R2_PUBLIC_URL.replace(/\/+$/, '')}/release` }
: data.publish
appConfig.build.directories.output = `${output}-generic`;
fs.writeFileSync(packageFile, JSON.stringify(appConfig, null, 4), 'utf8');
child_process.execSync(`npm run ${platform}`, {stdio: "inherit", cwd: "electron"});
if (publish === true && PUBLISH_KEY) {
genericPublish({
url: appConfig.build.publish.url,
key: PUBLISH_KEY,
version: config.version,
output: appConfig.build.directories.output
})
if (publish === true && r2.r2Configured()) {
const client = r2.createR2Client()
const outputDir = path.resolve(__dirname, appConfig.build.directories.output)
if (fs.existsSync(outputDir)) {
const files = fs.readdirSync(outputDir)
for (const filename of files) {
const localFile = path.join(outputDir, filename)
if (!fs.statSync(localFile).isFile()) continue
await uploadDraftFile(client, localFile, config.version)
}
}
}
// package.json Recovery
recoveryPackage(true)
@@ -745,18 +553,101 @@ if (["dev"].includes(argv[2])) {
}
})
} else if (["android-upload"].includes(argv[2])) {
// 上传安卓文件GitHub Actions
config.app.forEach(({publish}) => {
if (publish.provider === 'generic') {
androidUpload(publish.url)
// 上传安卓文件到 R2 draftGitHub Actions
(async () => {
if (!r2.r2Configured()) {
console.error("缺少 R2_* 环境变量R2_ACCESS_KEY_ID/R2_SECRET_ACCESS_KEY/R2_ENDPOINT/R2_BUCKET")
process.exit(1)
}
const client = r2.createR2Client()
const releaseDir = path.resolve(__dirname, "../resources/mobile/platforms/android/eeuiApp/app/build/outputs/apk/release");
if (!fs.existsSync(releaseDir)) {
console.error("发布文件未找到")
process.exit(1)
}
const files = fs.readdirSync(releaseDir)
for (const filename of files) {
const localFile = path.join(releaseDir, filename)
if (/\.apk$/.test(filename) && fs.existsSync(localFile) && fs.statSync(localFile).isFile()) {
await uploadDraftFile(client, localFile, config.version)
}
}
})().catch(err => {
console.error(err.message || err)
process.exit(1)
})
} else if (["published"].includes(argv[2])) {
// 发布完成GitHub Actions
config.app.forEach(async ({publish}) => {
if (publish.provider === 'generic') {
await published(publish.url)
} else if (["release"].includes(argv[2])) {
// R2 内提升draft/<version> → release/(当前版扁平,旧版归档 release/<prev>/
(async () => {
if (!r2.r2Configured()) {
console.error("缺少 R2_* 环境变量")
process.exit(1)
}
const client = r2.createR2Client()
const version = config.version
const draftPrefix = `draft/${version}/`
const draftKeys = await r2.listKeys(client, draftPrefix)
if (!draftKeys.length) {
console.error(`draft/${version}/ 为空,无法发布`)
process.exit(1)
}
const names = draftKeys.map(k => k.slice(draftPrefix.length))
// 读 manifest 取上一发布版
const manifest = JSON.parse(await r2.getText(client, 'manifest.json') || '{"draft":null,"release":null}')
const prev = manifest.release
// 1. 归档上一版扁平文件 → release/<prev>/
if (prev && prev !== version) {
const prevRootKeys = await r2.listKeys(client, 'release/', '/')
for (const key of prevRootKeys) {
const name = key.slice('release/'.length)
await r2.copyObject(client, key, `release/${prev}/${name}`)
}
}
// 2. 清空扁平根层(仅根层对象,版本归档子目录不动)
const rootKeys = await r2.listKeys(client, 'release/', '/')
await r2.deleteKeys(client, rootKeys)
// 3. 铺新扁平:安装包/blockmap/zip 先latest*.yml 最后
const ymls = names.filter(n => /\.ya?ml$/i.test(n))
const others = names.filter(n => !/\.ya?ml$/i.test(n))
for (const name of others) await r2.copyObject(client, `${draftPrefix}${name}`, `release/${name}`)
for (const name of ymls) await r2.copyObject(client, `${draftPrefix}${name}`, `release/${name}`)
// 4. 下载索引
const index = buildReleaseIndex(names)
await r2.putText(client, 'release/index.json', JSON.stringify({ version, files: index }, null, 2))
// 5. 更新 manifest清理 draft
await r2.putText(client, 'manifest.json', JSON.stringify({ draft: null, release: version }, null, 2))
await r2.deleteKeys(client, draftKeys)
console.log(`Release published: v${version}`)
})().catch(err => {
console.error(err.message || err)
process.exit(1)
})
} else if (["upload-changelog"].includes(argv[2])) {
// 上传 changelog 到 R2GitHub Actions
(async () => {
if (!r2.r2Configured()) {
console.error("缺少 R2_* 环境变量")
process.exit(1)
}
const changelogPath = path.resolve(__dirname, "../CHANGELOG.md")
if (!fs.existsSync(changelogPath)) {
console.error("CHANGELOG.md 未找到")
process.exit(1)
}
const client = r2.createR2Client()
const content = fs.readFileSync(changelogPath, 'utf8')
await r2.putText(client, 'changelog.md', content)
console.log('Changelog uploaded')
})().catch(err => {
console.error(err.message || err)
process.exit(1)
})
} else if (["all", "win", "mac"].includes(argv[2])) {
// 自动编译GitHub Actions
@@ -907,8 +798,8 @@ if (["dev"].includes(argv[2])) {
// 发布判断环境变量
if (answers.publish) {
if (!PUBLISH_KEY && (!GITHUB_TOKEN || !utils.strExists(GITHUB_REPOSITORY, "/"))) {
console.error("发布需要 PUBLISH_KEY 或 GitHub Token 和 Repository, 请检查环境变量!");
if (!r2.r2Configured() && !(GITHUB_TOKEN && utils.strExists(GITHUB_REPOSITORY, "/"))) {
console.error("发布需要 R2_* 或 GITHUB_TOKEN + GITHUB_REPOSITORY, 请检查环境变量!");
process.exit()
}
}

View File

@@ -2,7 +2,7 @@ const {BrowserWindow, screen, shell, ipcMain} = require('electron')
const fs = require('fs');
const path = require('path');
const loger = require("electron-log");
const {default: electronDl, download, CancelError} = require("@dootask/electron-dl");
const {default: electronDl, download, CancelError, InterruptedError} = require("@dootask/electron-dl");
const utils = require("./lib/utils");
const {DownloadManager, DownloadStore} = require("./lib/download-manager");
@@ -116,10 +116,12 @@ async function createDownload(window_, url, options = {}) {
try {
return await download(window_, url, options);
} catch (error) {
// electron-dl rejects with CancelError when a download is cancelled; treat it as expected.
// electron-dl rejects with CancelError/InterruptedError; treat them as expected.
const isCancelError = (typeof CancelError === 'function' && error instanceof CancelError)
|| error?.name === 'CancelError';
if (!isCancelError) {
const isInterruptedError = (typeof InterruptedError === 'function' && error instanceof InterruptedError)
|| error?.name === 'InterruptedError';
if (!isCancelError && !isInterruptedError) {
throw error;
}
return null;

View File

@@ -1060,4 +1060,4 @@ ipcMain.on('updateQuitAndInstall', (event, args) => {
//================================================================
onExport()
onRenderer(mainWindow)
onRenderer(() => mainWindow)

219
electron/lib/mcp.js vendored
View File

@@ -4,7 +4,7 @@
* DooTask 的 Electron 客户端集成了 Model Context Protocol (MCP) 服务,
* 允许 AI 助手(如 Claude)直接与 DooTask 任务进行交互。
*
* 提供的工具(共 27 个):
* 提供的工具(共 29 个):
*
* === 用户管理 ===
* - get_users_basic - 批量获取用户基础信息1-50个便于匹配负责人/协助人
@@ -27,9 +27,10 @@
* - update_project - 修改项目信息(名称、描述等)
*
* === 文件管理(个人文件系统) ===
* - list_files - 浏览个人文件系统,获取指定文件夹下的文件和子文件夹列表
* - search_files - 搜索用户文件系统中的文件(自己创建的和共享给自己的)
* - get_file_detail - 获取文件详情,支持通过文件ID或分享码访问返回 content_url 可配合 WebFetch 读取
* - list_files - 浏览个人文件系统,获取指定文件夹下的文件和子文件夹列表
* - search_files - 搜索用户文件系统中的文件(自己创建的和共享给自己的)
* - get_file_detail - 获取文件详情,可选提取文本内容
* - fetch_file_content - 通过文件路径获取文本内容
*
* === 工作报告 ===
* - list_received_reports - 获取我接收的汇报列表,支持按类型/状态/部门/时间筛选
@@ -42,6 +43,7 @@
* === 消息通知 ===
* - search_dialogs - 按名称搜索群聊或联系人,返回 dialog_id/userid
* - send_message - 发送消息到对话(支持 dialog_id 或 userid
* - send_task_ai_message - 以AI助手身份发送消息到任务对话支持自定义发送者昵称
* - get_message_list - 获取对话消息记录(支持 dialog_id 或 userid
*
* === 智能搜索 ===
@@ -74,7 +76,8 @@
* - "查看我的文件列表"
* - "搜索包含'设计稿'的文件"
* - "显示文件123的详细信息"
* - "帮我分析这个文档的内容"
* - "帮我阅读这个Word文档的内容"
* - "分析这份PDF报告的要点"
*
* 工作报告:
* - "查看未读的工作汇报"
@@ -226,7 +229,7 @@ class DooTaskMCP {
return { error: 'Result contains non-serializable data' };
}
} catch (error) {
return { error: error.msg || error.message || String(error) || 'API request failed' };
return { error: error.msg || error.message || String(error) || 'API request failed', ret: error.ret, data: error.data };
}
})()
`);
@@ -240,6 +243,10 @@ class DooTaskMCP {
const result = await Promise.race([executePromise, timeoutPromise]);
if (result && result.error) {
// 多结束/开始状态(-4005/-4006):保留 ret 与 flow_items 交给工具处理,不直接抛错
if (result.ret === -4005 || result.ret === -4006) {
return result;
}
throw new Error(result.error);
}
@@ -589,14 +596,38 @@ class DooTaskMCP {
task_id: z.number()
.min(1)
.describe('要标记完成的任务ID'),
flow_item_id: z.number()
.optional()
.describe('工作流状态ID'),
}),
execute: async (params) => {
const now = new Date().toISOString().slice(0, 19).replace('T', ' ');
const result = await this.request('POST', 'project/task/update', {
const requestData = {
task_id: params.task_id,
complete_at: now,
});
};
if (params.flow_item_id) {
requestData.flow_item_id = params.flow_item_id;
}
const result = await this.request('POST', 'project/task/update', requestData);
// 处理多结束状态的情况
if (result.ret === -4005) {
const flowItems = result.data?.flow_items || [];
return {
content: [{
type: 'text',
text: JSON.stringify({
success: false,
message: '存在多个结束状态请选择要使用的状态后重新调用此工具并指定flow_item_id参数',
task_id: params.task_id,
flow_items: flowItems,
}, null, 2)
}]
};
}
if (result.error) {
throw new Error(result.error);
@@ -718,6 +749,9 @@ class DooTaskMCP {
complete_at: z.union([z.string(), z.boolean()])
.optional()
.describe('完成时间。传时间字符串标记完成传false标记未完成'),
flow_item_id: z.number()
.optional()
.describe('工作流状态ID'),
}),
execute: async (params) => {
const requestData = {
@@ -732,9 +766,42 @@ class DooTaskMCP {
if (params.start_at !== undefined) requestData.start_at = params.start_at;
if (params.end_at !== undefined) requestData.end_at = params.end_at;
if (params.complete_at !== undefined) requestData.complete_at = params.complete_at;
if (params.flow_item_id !== undefined) requestData.flow_item_id = params.flow_item_id;
const result = await this.request('POST', 'project/task/update', requestData);
// 处理多结束状态的情况(标记完成时)
if (result.ret === -4005) {
const flowItems = result.data?.flow_items || [];
return {
content: [{
type: 'text',
text: JSON.stringify({
success: false,
message: '存在多个结束状态请选择要使用的状态后重新调用此工具并指定flow_item_id参数',
task_id: params.task_id,
flow_items: flowItems,
}, null, 2)
}]
};
}
// 处理多开始状态的情况(取消完成时)
if (result.ret === -4006) {
const flowItems = result.data?.flow_items || [];
return {
content: [{
type: 'text',
text: JSON.stringify({
success: false,
message: '存在多个开始状态请选择要使用的状态后重新调用此工具并指定flow_item_id参数',
task_id: params.task_id,
flow_items: flowItems,
}, null, 2)
}]
};
}
if (result.error) {
throw new Error(result.error);
}
@@ -1289,6 +1356,58 @@ class DooTaskMCP {
}
});
// 以AI助手身份发送消息到任务对话
this.mcp.addTool({
name: 'send_task_ai_message',
description: '以AI助手身份发送消息到任务对话。应在每个重要里程碑、遇到阻塞、以及全部完成时主动调用。',
parameters: z.object({
task_id: z.number()
.describe('目标任务ID'),
text: z.string()
.min(1)
.describe('消息内容,支持 Markdown'),
nickname: z.string()
.max(20)
.optional()
.describe('自定义发送者昵称最多20字不传或留空时默认显示“AI 助手”'),
silence: z.boolean()
.optional()
.describe('静默发送,不触发推送提醒'),
}),
execute: async (params) => {
const payload = {
task_id: params.task_id,
text: params.text,
text_type: 'md',
};
if (params.nickname !== undefined) {
payload.nickname = params.nickname;
}
if (params.silence !== undefined) {
payload.silence = params.silence ? 'yes' : 'no';
}
const result = await this.request('POST', 'dialog/msg/send_ai_assistant', payload);
if (result.error) {
throw new Error(result.error);
}
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
task_id: params.task_id,
message: result.data,
}, null, 2)
}]
};
}
});
// 获取对话消息列表
this.mcp.addTool({
name: 'get_message_list',
@@ -1902,16 +2021,38 @@ class DooTaskMCP {
// 文件管理:获取文件详情
this.mcp.addTool({
name: 'get_file_detail',
description: '获取文件详情,包括类型、大小、共享状态等。支持文件ID或分享码。',
description: '获取文件详情,包括类型、大小、正文内容、共享状态等。',
parameters: z.object({
file_id: z.union([z.number(), z.string()])
.describe('文件ID(数字)或分享码(字符串)'),
.describe('文件ID 或分享码'),
with_content: z.boolean()
.optional()
.describe('是否提取文本内容'),
text_offset: z.number()
.optional()
.describe('文本起始位置'),
text_limit: z.number()
.optional()
.describe('获取长度默认50000最大200000'),
}),
execute: async (params) => {
const result = await this.request('GET', 'file/one', {
const requestData = {
id: params.file_id,
with_url: 'yes',
});
};
// 如果需要提取文本内容
if (params.with_content) {
requestData.with_text = 'yes';
if (params.text_offset !== undefined) {
requestData.text_offset = params.text_offset;
}
if (params.text_limit !== undefined) {
requestData.text_limit = Math.min(params.text_limit, 200000);
}
}
const result = await this.request('GET', 'file/one', requestData);
if (result.error) {
throw new Error(result.error);
@@ -1934,6 +2075,19 @@ class DooTaskMCP {
updated_at: file.updated_at,
};
// 如果有文本内容
if (file.text_content) {
if (file.text_content.error) {
fileDetail.text_error = file.text_content.error;
} else {
fileDetail.text_content = file.text_content.content;
fileDetail.text_total_length = file.text_content.total_length;
fileDetail.text_offset = file.text_content.offset;
fileDetail.text_limit = file.text_content.limit;
fileDetail.text_has_more = file.text_content.has_more;
}
}
return {
content: [{
type: 'text',
@@ -1943,6 +2097,47 @@ class DooTaskMCP {
}
});
// 文件管理:通过路径获取文件内容
this.mcp.addTool({
name: 'fetch_file_content',
description: '通过文件路径获取文本内容。',
parameters: z.object({
path: z.string()
.describe('系统内文件路径或URL'),
offset: z.number()
.optional()
.describe('起始位置'),
limit: z.number()
.optional()
.describe('获取长度默认50000最大200000'),
}),
execute: async (params) => {
const requestData = {
path: params.path,
};
if (params.offset !== undefined) {
requestData.offset = params.offset;
}
if (params.limit !== undefined) {
requestData.limit = Math.min(params.limit, 200000);
}
const result = await this.request('GET', 'file/fetch', requestData);
if (result.error) {
throw new Error(result.error);
}
return {
content: [{
type: 'text',
text: JSON.stringify(result.data, null, 2)
}]
};
}
});
// 智能搜索:统一搜索工具
this.mcp.addTool({
name: 'intelligent_search',

133
electron/lib/r2.js vendored Normal file
View File

@@ -0,0 +1,133 @@
const fs = require('fs');
const {
S3Client,
PutObjectCommand,
GetObjectCommand,
CopyObjectCommand,
DeleteObjectsCommand,
ListObjectsV2Command,
} = require('@aws-sdk/client-s3');
const { Upload } = require('@aws-sdk/lib-storage');
const {
R2_ACCESS_KEY_ID,
R2_SECRET_ACCESS_KEY,
R2_ENDPOINT,
R2_BUCKET,
R2_PUBLIC_URL,
} = process.env;
function r2Configured() {
return !!(R2_ACCESS_KEY_ID && R2_SECRET_ACCESS_KEY && R2_ENDPOINT && R2_BUCKET);
}
function createR2Client() {
return new S3Client({
region: 'auto',
endpoint: R2_ENDPOINT,
credentials: {
accessKeyId: R2_ACCESS_KEY_ID,
secretAccessKey: R2_SECRET_ACCESS_KEY,
},
});
}
function contentTypeFor(name) {
if (/\.ya?ml$/i.test(name)) return 'text/yaml';
if (/\.json$/i.test(name)) return 'application/json';
if (/\.md$/i.test(name)) return 'text/markdown; charset=utf-8';
return 'application/octet-stream';
}
/** 流式上传本地文件onProgress(loaded, total) */
async function uploadFile(client, localFile, key, onProgress) {
const total = fs.statSync(localFile).size;
const upload = new Upload({
client,
params: {
Bucket: R2_BUCKET,
Key: key,
Body: fs.createReadStream(localFile),
ContentType: contentTypeFor(key),
},
});
if (onProgress) {
upload.on('httpUploadProgress', (p) => onProgress(p.loaded || 0, total));
}
await upload.done();
}
/** 写入文本对象 */
async function putText(client, key, text) {
await client.send(new PutObjectCommand({
Bucket: R2_BUCKET,
Key: key,
Body: text,
ContentType: contentTypeFor(key),
}));
}
/** 读取文本对象,不存在返回 null */
async function getText(client, key) {
try {
const res = await client.send(new GetObjectCommand({ Bucket: R2_BUCKET, Key: key }));
return await res.Body.transformToString();
} catch (err) {
if (err.name === 'NoSuchKey' || err.$metadata?.httpStatusCode === 404) return null;
throw err;
}
}
/** 桶内服务端复制(文件名为安全 ASCII无需额外编码 */
async function copyObject(client, srcKey, destKey) {
await client.send(new CopyObjectCommand({
Bucket: R2_BUCKET,
CopySource: `${R2_BUCKET}/${srcKey}`,
Key: destKey,
ContentType: contentTypeFor(destKey),
MetadataDirective: 'REPLACE',
}));
}
/** 列举 keydelimiter='/' 时仅返回该前缀下的根层对象(子目录归 CommonPrefixes不返回 */
async function listKeys(client, prefix, delimiter) {
const keys = [];
let token;
do {
const res = await client.send(new ListObjectsV2Command({
Bucket: R2_BUCKET,
Prefix: prefix,
Delimiter: delimiter,
ContinuationToken: token,
}));
for (const o of res.Contents || []) keys.push(o.Key);
token = res.IsTruncated ? res.NextContinuationToken : undefined;
} while (token);
return keys;
}
/** 批量删除(每批 1000 */
async function deleteKeys(client, keys) {
for (let i = 0; i < keys.length; i += 1000) {
const batch = keys.slice(i, i + 1000);
if (!batch.length) continue;
await client.send(new DeleteObjectsCommand({
Bucket: R2_BUCKET,
Delete: { Objects: batch.map((Key) => ({ Key })) },
}));
}
}
module.exports = {
r2Configured,
createR2Client,
contentTypeFor,
uploadFile,
putText,
getText,
copyObject,
listKeys,
deleteKeys,
R2_BUCKET,
R2_PUBLIC_URL,
};

46
electron/lib/release-index.js vendored Normal file
View File

@@ -0,0 +1,46 @@
// 仅这些扩展名进入下载索引(排除 .zipmac 自动更新增量包,非下载按钮目标)
const DOWNLOAD_EXTS = ['.dmg', '.exe', '.msi', '.appimage', '.deb', '.rpm', '.apk', '.pkg'];
/**
* 从文件名解析 platform/arch与官网 storage.ts 规则保持一致)
* @returns {{platform: string, arch: string|null}|null}
*/
function parseFilename(filename) {
const lower = filename.toLowerCase();
if (lower.endsWith('.apk')) {
return { platform: 'android', arch: null };
}
if (!DOWNLOAD_EXTS.some((ext) => lower.endsWith(ext))) {
return null;
}
let platform = null;
if (/-mac-/i.test(filename) || lower.endsWith('.dmg') || lower.endsWith('.pkg')) {
platform = 'mac';
} else if (/-win-/i.test(filename) || /-win\./i.test(filename) || lower.endsWith('.msi')) {
platform = 'win';
} else if (/-linux-/i.test(filename) || lower.endsWith('.appimage') || lower.endsWith('.deb') || lower.endsWith('.rpm')) {
platform = 'linux';
}
if (!platform) return null;
let arch = null;
if (/-arm64[.-]/i.test(filename)) arch = 'arm64';
else if (/-x64[.-]/i.test(filename)) arch = 'x64';
return { platform, arch };
}
/**
* 生成下载索引:{ "<platform>": { "<arch|default>": filename } }
*/
function buildReleaseIndex(filenames) {
const index = {};
for (const filename of filenames) {
const parsed = parseFilename(filename);
if (!parsed) continue;
const archKey = parsed.arch || 'default';
index[parsed.platform] = index[parsed.platform] || {};
index[parsed.platform][archKey] = filename;
}
return index;
}
module.exports = { parseFilename, buildReleaseIndex, DOWNLOAD_EXTS };

36
electron/lib/release-index.test.js vendored Normal file
View File

@@ -0,0 +1,36 @@
const test = require('node:test');
const assert = require('node:assert');
const { parseFilename, buildReleaseIndex } = require('./release-index');
test('parseFilename: win exe x64', () => {
assert.deepStrictEqual(parseFilename('DooTask-v1.7.56-win-x64.exe'), { platform: 'win', arch: 'x64' });
});
test('parseFilename: mac dmg arm64', () => {
assert.deepStrictEqual(parseFilename('DooTask-v1.7.56-mac-arm64.dmg'), { platform: 'mac', arch: 'arm64' });
});
test('parseFilename: android apk has null arch', () => {
assert.deepStrictEqual(parseFilename('app-release.apk'), { platform: 'android', arch: null });
});
test('parseFilename: ignores yml/blockmap/zip', () => {
assert.strictEqual(parseFilename('latest.yml'), null);
assert.strictEqual(parseFilename('DooTask-v1.7.56-win-x64.exe.blockmap'), null);
assert.strictEqual(parseFilename('DooTask-v1.7.56-mac-arm64.zip'), null);
});
test('buildReleaseIndex: groups by platform/arch, .zip never overwrites .dmg', () => {
const index = buildReleaseIndex([
'DooTask-v1.7.56-mac-arm64.dmg',
'DooTask-v1.7.56-mac-arm64.zip',
'DooTask-v1.7.56-win-x64.exe',
'latest.yml',
'app-release.apk',
]);
assert.deepStrictEqual(index, {
mac: { arm64: 'DooTask-v1.7.56-mac-arm64.dmg' },
win: { x64: 'DooTask-v1.7.56-win-x64.exe' },
android: { default: 'app-release.apk' },
});
});

View File

@@ -572,7 +572,7 @@ const renderer = {
},
}
const onRenderer = (mainWindow) => {
const onRenderer = (getMainWindow) => {
ipcMain.on("rendererReq", async (event, args) => {
try {
let ret = null;
@@ -651,7 +651,7 @@ const onRenderer = (mainWindow) => {
ret = await electronDown.updateWindow(args.language, args.theme);
break;
case 'createDownload':
ret = await electronDown.createDownload(mainWindow, args.url, args.options || {});
ret = await electronDown.createDownload(getMainWindow(), args.url, args.options || {});
break;
case 'watchFile':
ret = await renderer.watchFile(args.path);

View File

@@ -42,6 +42,8 @@
"ora": "^4.1.1"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1052.0",
"@aws-sdk/lib-storage": "^3.1052.0",
"@dootask/electron-dl": "^4.0.0-rc.2",
"axios": "^1.11.0",
"crc": "^3.8.0",
@@ -60,8 +62,8 @@
"request": "^2.88.2",
"tar": "^7.4.3",
"turndown": "^7.2.2",
"zod": "^3.23.8",
"yauzl": "^3.2.0"
"yauzl": "^3.2.0",
"zod": "^3.23.8"
},
"trayIcon": {
"dev": {

View File

@@ -1,29 +0,0 @@
# 语言翻译工具说明
`language/translate.php` 脚本用于根据 `original-web.txt``original-api.txt` 中的内容,自动生成/更新 `translate.json` 以及前端使用的多语言文件。
## 使用步骤
1. 在项目根目录 `.env` 文件中配置:
```dotenv
OPENAI_API_KEY=你的OpenAI密钥
OPENAI_PROXY_URL=可选的代理地址
```
2. 在 `language` 目录下执行:
```bash
php translate.php
```
3. 查看生成的翻译结果:
- 翻译详情:`language/translate.json`
- API 文件:`public/language/api/*.json`
- Web 文件:`public/language/web/*.js`
## 注意事项
- 若 `.env` 未设置 `OPENAI_API_KEY`,脚本会直接退出。
- `OPENAI_PROXY_URL` 可选,留空时不会设置代理。

View File

@@ -1,9 +0,0 @@
{
"name": "dootask/language",
"require": {
"php": ">=7.4",
"ext-curl": "*",
"ext-json": "*",
"orhanerday/open-ai": "^5.2"
}
}

82
language/composer.lock generated
View File

@@ -1,82 +0,0 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "ec9d23d3c9171a27ef10589ff18aaf1d",
"packages": [
{
"name": "orhanerday/open-ai",
"version": "5.2",
"source": {
"type": "git",
"url": "https://github.com/orhanerday/open-ai.git",
"reference": "d8c78fe2f5fed59e0ba458f90b5589ed9f13a367"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/orhanerday/open-ai/zipball/d8c78fe2f5fed59e0ba458f90b5589ed9f13a367",
"reference": "d8c78fe2f5fed59e0ba458f90b5589ed9f13a367",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"php": ">=7.4"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.0",
"pestphp/pest": "^1.20",
"spatie/ray": "^1.28"
},
"type": "library",
"autoload": {
"psr-4": {
"Orhanerday\\OpenAi\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Orhan Erday",
"email": "orhanerday@gmail.com",
"role": "Developer"
}
],
"description": "OpenAI GPT-3 Api Client in PHP",
"homepage": "https://github.com/orhanerday/open-ai",
"keywords": [
"open-ai",
"orhanerday"
],
"support": {
"issues": "https://github.com/orhanerday/open-ai/issues",
"source": "https://github.com/orhanerday/open-ai/tree/5.2"
},
"funding": [
{
"url": "https://github.com/orhanerday",
"type": "github"
}
],
"time": "2024-05-29T12:31:54+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": ">=7.4",
"ext-curl": "*",
"ext-json": "*"
},
"platform-dev": [],
"plugin-api-version": "2.6.0"
}

View File

@@ -560,8 +560,6 @@ webhook地址最长仅支持255个字符。
(*)将(*)移出群组
(*)退出群组
(*)已加入群组
(*)当前正在共享,无法移动到另一个共享文件夹内
(*)内含有共享文件,无法移动到另一个共享文件夹内
处理错误
仅限所有者或创建者操作
没有修改写入权限
@@ -575,13 +573,11 @@ webhook地址最长仅支持255个字符。
子任务负责人填写错误
任务负责人填写错误
(*)负责或参与的未完成任务最多不能超过(*)个
(*)已被其他成员设置
邮件发送超时,请检查邮箱配置是否正确
群主不可移出
部门成员、项目人员或任务人员不可移出
群主不可退出
部门成员、项目人员或任务人员不可退出
当前客户端版本(*)过低,最低版本要求(*)。
验证码不能为空
别名不能为空
别名的长度在(*)个字符
@@ -607,7 +603,6 @@ webhook地址最长仅支持255个字符。
(*)的周报[(*)][(*)月第(*)周]
(*)的日报[(*)]
考勤机
手动签到
(*)评论了(*)的「(**)」审批
抄送(*)提交的「(**)」记录
@@ -615,7 +610,6 @@ webhook地址最长仅支持255个字符。
您发起的「(**)」已通过
您发起的「(**)」被(*)拒绝
消息不存在或已被删除
此消息不支持翻译
消息内容为空
翻译失败
@@ -629,26 +623,15 @@ webhook地址最长仅支持255个字符。
任务结束时间
任务计划用时
超时时间
负责人
创建人
(*)等(*)位成员的任务统计
(*)的任务统计
任务ID
父级任务ID
所属项目
任务标题
任务开始时间
任务结束时间
完成时间
归档时间
任务计划用时
实际完成用时
超时时间
开发用时
验收/测试用时
负责人
创建人
状态
审批记录
@@ -656,7 +639,6 @@ webhook地址最长仅支持255个字符。
标题
申请状态
发起时间
完成时间
发起人工号
发起人User ID
发起人姓名
@@ -665,7 +647,6 @@ webhook地址最长仅支持255个字符。
部门负责人
历史审批人
历史办理人
审批记录
当前处理人
审批节点
审批人数
@@ -819,7 +800,6 @@ AI机器人不存在
选择模型
当前对话不支持
会话不存在或已被删除
开启新会话
历史会话
@@ -839,13 +819,11 @@ AI机器人不存在
(*)天(*)小时(*)分钟
(*)天(*)小时
(*)天(*)分钟
(*)天
(*)小时(*)分钟
(*)小时
(*)分钟
任务不存在或已被删除
文件不存在或已被删除
报告不存在或已被删除
文件读取失败:(*)
@@ -919,7 +897,6 @@ URL格式不正确
报告内容为空,无法进行分析
工作汇报分析失败
工作汇报分析结果为空
缺少ID参数
无权访问该工作汇报
生成AI分析失败
工作汇报内容不能为空
@@ -937,15 +914,85 @@ URL格式不正确
会员不存在
请输入个性标签
标签名称最多只能设置(*)个字
标签已存在
每位会员最多添加(*)个标签
参数错误
标签不存在
无权操作该标签
已取消认可
认可成功
选择模型
请先配置 AI 助手
请先在「AI 助手」设置中配置 OpenAI
请先在「AI 助手」设置中配置 (*)
今日未完成的工作
本周未完成的工作
本周未完成的工作
无效的建议类型
任务不存在或无权限
建议不存在
建议内容为空
AI建议指派给 (*)
AI建议关联任务 (*)
AI建议采纳(*)建议
已采纳
已忽略
消息内容格式错误
AI 调用失败
AI 返回内容为空
修改AI自动分析
关联不存在
只能合并转发同一对话的消息
所选消息均不支持转发
无法创建任务对话
最多转发(*)条消息
此类型消息不支持转发
没有权限操作此任务
请选择要转发的消息
LDAP 用户缺少邮箱属性,请联系管理员配置
群管理员
任命群管理员
罢免群管理员
该用户不是群成员
不能将群主任命为群管理员
仅群主或群管理员可操作
仅限群主或群管理员操作
群管理员不能移出群主或其他群管理员
请选择有效的成员
任命成功
罢免成功
项目管理员
任命项目管理员
罢免项目管理员
该用户不是项目成员
不能将负责人任命为项目管理员
不能将部门负责人任命为部门管理员
该用户不存在
无权操作此模板
修改共享模板
修改负责人视角可见
项目负责人数据异常,请先修复项目负责人
项目成员列表必须包含项目负责人
项目管理员不能移除项目负责人或项目管理员
项目管理员必须是项目成员
负责人不能任命为项目管理员
普通成员不能移出群主或群管理员
只有群主、群管理员或邀请人可以移出成员
仅群主、项目/任务负责人或系统管理员可设置或取消他人待办
请选择文件
仅支持 xls/xlsx/csv 文件
文件中没有可导入的数据
导入完成
昵称需为2-20个字
邮箱、昵称、初始密码均为必填
邮箱格式不正确
文件内邮箱重复
单次最多导入500条
没有可导入的数据
解析完成
请选择成员
待办提醒
你有一条待办到提醒时间啦
发送者昵称最多不能超过20字
AI 助手
没有查看权限
当前仅指定人员可以创建项目

View File

@@ -1668,6 +1668,12 @@ WiFi签到延迟时长为±1分钟。
你确定将【(*)】设为管理员吗?
你确定取消【(*)】管理员身份吗?
你确定将【(*)】的邮箱标记为已认证吗?
你确定将【(*)】的邮箱标记为未认证吗?
标记邮箱为已认证
标记邮箱为未认证
标记选中(*)项为已认证
标记选中(*)项为未认证
你确定要取消任务时间吗?
更新子任务
@@ -1689,7 +1695,6 @@ WiFi签到延迟时长为±1分钟。
该任务尚未被领取,点击这里
考勤机
手动签到
签到备注
重复打卡提醒
@@ -1728,7 +1733,6 @@ WiFi签到延迟时长为±1分钟。
插入链接
请输入完整的链接地址
自动通过,审批人与发起人为同一人
自动通过,审批人已审核
你确定要删除项目吗?
@@ -1757,9 +1761,6 @@ WiFi签到延迟时长为±1分钟。
定位失败
位置
你选择的位置「(*)」不在签到范围内
定位签到
通过在签到打卡机器人发送位置签到
签到备注
百度地图AK
腾讯地图Key
高德地图Key
@@ -1813,7 +1814,6 @@ WiFi签到延迟时长为±1分钟。
系统别名
用于网页默认标题、邮件发送等
权限设置
打包权限
允许所有人
仅限管理员
@@ -1882,7 +1882,6 @@ WiFi签到延迟时长为±1分钟。
只有在项目中才能创建任务
项目不存在
只有在任务中才能创建子任务
任务不存在
未知类型
未找到内容
再见
@@ -1901,7 +1900,6 @@ WiFi签到延迟时长为±1分钟。
请输入标签描述
标签颜色
使用示例标签
取消默认
编辑标签
确定要删除该标签吗?
标签
@@ -1909,7 +1907,6 @@ WiFi签到延迟时长为±1分钟。
暂无标签
添加标签
请选择示例标签
全部保存成功
消息详情
长文本
@@ -2022,7 +2019,6 @@ API请求的URL路径
附言
任务不存在或已被删除
文件不存在或已被删除
报告不存在或已被删除
文件读取失败:(*)
独立窗口显示
@@ -2037,7 +2033,6 @@ API请求的URL路径
删除机器人:(*)
默认90天
机器人名称
后退
前进
@@ -2089,12 +2084,8 @@ OKR群组
会话名称
结果
名称
命令
必填
接口地址
清理时间
类型
该机器人不支持
说明
属性
@@ -2214,7 +2205,6 @@ Webhook事件
打开会话
成员加入
成员退出
是否拨打电话给(*)
是否发送邮件给(*)
个人信息
@@ -2237,7 +2227,6 @@ Webhook事件
用户
应用此内容
生成中...
等待 AI 回复...
请输入你的问题...
选择模型
@@ -2281,4 +2270,187 @@ URL不能为空
前日
次日
AI 搜索
AI 搜索
AI 项目助手
AI 汇报分析
AI 任务助手
AI 消息助手
欢迎使用 AI 助手
新建会话
历史会话
清空历史记录
编辑问题
重启应用
关闭应用
请描述你想搜索的内容...
搜索中...
工作流规则
流转到
时改变任务负责人为
,原负责人移至协助人员
仅限任务负责人和项目管理员修改状态
时自动将任务移动至列表
(并保留操作人),原负责人移至协助人员
时添加
至任务负责人
连接失败,请重试
最多上传(*)张图片
松开以上传图片
清空历史会话
确定要清空当前场景的所有历史会话吗?
文件读取失败
无效的图片数据
图片加载失败
应用成功
请选择负责人
未知的建议类型
没有有效的子任务
已忽略
已采纳
已创建
已指派
已关联
采纳描述
指派
关联
采纳
逐条转发
合并转发
复制原文
所属部门
留空则不修改密码
职位
请输入电话号码
正在编辑帐号【ID:(*)】的信息。
编辑用户信息
AI任务分析
关闭后所有项目将不再自动分析任务。
关闭后本项目将不再自动分析任务。
新建任务后AI自动分析并给出建议。
(最多(*)条)
最多选择(*)条消息
系统已关闭AI任务分析功能。
聊天记录
确定要解除与任务 #(*) 的关联吗?
共(*)条消息
已选(*)条
(*)的聊天记录
(*)和(*)的聊天记录
(*)和(*)等人的聊天记录
生日
请选择生日
登录属性
用于匹配登录用户名的 LDAP 属性Active Directory 请选择 sAMAccountName
请输入帐号
群管理员
任命群管理员
罢免群管理员
确定要罢免该群管理员吗?
还没有群管理员
添加群管理员
确定将 (*) 任命为群管理员吗?
项目管理员
任命项目管理员
罢免项目管理员
确定要罢免该项目管理员吗?
还没有项目管理员
添加项目管理员
确定将 (*) 任命为项目管理员吗?
部门管理员
任命部门管理员
罢免部门管理员
请选择部门管理员
确定将 (*) 任命为部门管理员吗?
部门管理员享有部门群的群管理员权限
即将罢免项目管理员
请确认以下操作,注意此操作不可逆!
移除成员负责的任务将变成无负责人。
搜索模板
来自(*)
暂无可用模板
加载中
共享模板
开启后,添加任务时可使用其他项目共享的任务模板。
关闭后,添加任务时仅加载本项目模板,不显示其他项目共享模板。
根据系统设置的自动归档规则执行
负责人视角
开启后,部门负责人可只读查看本项目及其全员可见任务。
关闭后,本项目及其群聊对部门负责人视角隐藏。
个人项目,只读查看
负责人视角,只读查看
我的项目
没有任何与"(*)"相关的结果
标记未选
标记已选
可查看所选部门及所有下级部门成员参与的项目和任务,仅支持只读查看。
反选
切换失败
当前为负责人视角:你可查看项目和任务,并参与讨论,但不能编辑项目或任务。
选择项目管理员
即将移除
当前为负责人视角,并参与讨论,但不能编辑任务。
部门负责人视角
开启后,部门负责人/部门管理员可只读查看本部门及下级部门成员参与的项目和项目内全部任务。
部门管理员同步失败
待办设置权限
允许:所有成员可设置/取消他人待办。
禁止:仅本人、系统管理员、群主(含群管理员)、项目负责人(含项目管理员)、任务负责人可设置/取消待办。
批量导入用户
请按模板填写后上传,列顺序:邮箱、昵称、初始密码、职位(选填)单次最多导入500条。
下载模板
导入结果:共(*)条,成功(*)条,失败(*)条
行号
失败原因
导入失败
仅支持 xls/xlsx/csv 文件
创建用户
批量导入
初始密码
请输入邮箱
请输入初始密码
员工首次登录需修改密码
邮箱、昵称、初始密码均为必填
员工下次登录需修改密码
重新选择文件
共(*)条 · 可导入(*)条 · 错误(*)条
点击查看明文
原因
可导入
错误
确定导入(*)条
解析失败
设置部门到选中(*)项
提醒时间
不提醒
1 小时后
今晚 20:00
明早 9:00
成功导入(*)条
标记完成
暂无待办
暂无完成
取消提醒
确定取消该成员的提醒时间吗?
项目与任务
暂无项目
暂无任务
负责
协作
成员
(*)分钟前
(*)小时前
(*)天前
所有人:所有成员均可创建项目。
可创建项目的人员
系统管理员(始终可创建,不受开关限制)。
部门负责人与部门管理员。
下方指定的人员。

File diff suppressed because it is too large Load Diff

View File

@@ -1,347 +0,0 @@
<?php
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
require __DIR__ . '/vendor/autoload.php';
use Orhanerday\OpenAi\OpenAi;
// 读取 .env 文件的简单工具函数
function language_parse_env_file(string $path): array
{
$env = [];
$lines = @file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines === false) {
return $env;
}
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') {
continue;
}
$delimiterPosition = strpos($line, '=');
if ($delimiterPosition === false) {
continue;
}
$name = trim(substr($line, 0, $delimiterPosition));
if (strpos($name, 'export ') === 0) {
$name = trim(substr($name, 7));
}
if ($name === '') {
continue;
}
$value = trim(substr($line, $delimiterPosition + 1));
$length = strlen($value);
if ($length >= 2) {
$first = $value[0];
$last = $value[$length - 1];
if (($first === '"' && $last === '"') || ($first === "'" && $last === "'")) {
$value = substr($value, 1, $length - 2);
}
}
$env[$name] = $value;
}
return $env;
}
// 获取环境变量值的简单工具函数
function language_env_value(string $key, array $env): ?string
{
if (array_key_exists($key, $env)) {
return $env[$key];
}
$value = getenv($key);
if ($value !== false) {
return $value;
}
return null;
}
// 读取语言环境配置
$languageEnvFile = dirname(__DIR__) . '/.env';
$languageEnv = is_readable($languageEnvFile) ? language_parse_env_file($languageEnvFile) : [];
// 优先从 .env 读取 OPENAI 配置,未找到时再次尝试 getenv 覆盖
$openAiKey = trim(language_env_value('OPENAI_API_KEY', $languageEnv) ?? '');
if ($openAiKey === '') {
fwrite(STDERR, "OPENAI_API_KEY 未设置,请在项目根目录的 .env 中配置。\n");
exit(1);
}
$openAiProxy = trim(language_env_value('OPENAI_PROXY_URL', $languageEnv) ?? '');
// 读取所有要翻译的内容
$originals = [];
$generateds = [];
foreach (['web', 'api'] as $type) {
$content = file_exists("original-{$type}.txt") ? file_get_contents("original-{$type}.txt") : "";
$array = array_values(array_filter(array_unique(explode("\n", $content))));
$generateds[$type] = $array;
$originals = array_merge($originals, $array);
}
// 判定是否存在translate.json文件
if (!file_exists("translate.json")) {
print_r("translate.json not exists");
exit;
}
$translations = []; // 翻译数据
$regrror = []; // 正则匹配错误的数据
$redundants = []; // 多余的数据
$needs = []; // 需要翻译的数据
// 读取翻译数据
$tmps = json_decode(file_get_contents("translate.json"), true);
foreach ($tmps as $obj) {
if (!isset($obj['key'])) {
continue;
}
$currentKey = $obj['key'];
$originalKey = preg_replace(["/\(%T\d+\)/", "/\(%M\d+\)/"], ["(*)", "(**)"], $currentKey);
$translations[$originalKey] = $obj;
if (!in_array($originalKey, $originals)) {
unset($translations[$originalKey]);
$redundants[$originalKey] = $obj;
continue;
}
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] = [
$k => $v,
'match' => $match,
'key' => $currentKey,
];
continue 2;
}
}
}
}
}
if (count($regrror) > 0) {
print_r("正则匹配错误的数据:\n");
print_r($regrror);
exit();
}
if (count($redundants) > 0) {
print_r("多余的数据:\n");
print_r(implode(", ", array_keys($redundants)) . "\n\n");
}
// 需要翻译的数据
foreach ($originals as $text) {
$key = trim($text);
if (!isset($translations[$key])) {
$needs[$key] = $key;
}
}
if (count($needs) > 0) {
$array = array_chunk($needs, 10, true);
$success = [];
$error = [];
$done = 0;
foreach ($array as $index => $keys) {
// 生成翻译内容
foreach ($keys as &$key) {
$c = 1;
$key = preg_replace_callback('/\((\*+)\)/', function ($m) use (&$c) {
$label = strlen($m[1]) > 1 ? "M" : "T";
return "(%" . $label . $c++ . ")";
}, $key);
}
$content = implode("\n", $keys);
// 开始翻译
print_r("正在翻译:" . (count($keys) + $done) . "/" . count($needs) . "...\n");
$openAi = new OpenAi($openAiKey);
if ($openAiProxy !== '') {
$openAi->setProxy($openAiProxy);
}
$result = $openAi->chat([
"model" => "gpt-5.2",
"reasoning_effort" => "low",
'messages' => [
[
"role" => "system",
"content" => <<<EOF
你是一个专业的翻译器,翻译的结果尽量符合 “项目任务管理系统” 的使用,请将提供的内容按每行一个翻译成:
```json
[
{
"key": "", // 原文本
"zh": "", // 留空(不用翻译)
"zh-CHT": "", // 繁体中文
"en": "", // 英语
"ko": "", // 韩语
"ja": "", // 日语
"de": "", // 德语
"fr": "", // 法语
"id": "", // 印度尼西亚语
"ru": "" // 俄语
}
]
```
请注意:(%T1)(%T2)(%T3)(%M1)(%M2) ...... 这类以 `小括号(%+内容)` 的字符组合是一个变量,翻译时请保留。
例子1
原文:此(%T1)已经处于【(%T2)】共享文件夹中,无法重复共享。
翻译成英语This (%T1) is already in the (%T2) shared folder and cannot be shared again。
例子2
原文:(%T1)的周报[(%T2)][(%T3)月第(%T4)]
翻译成英语Weekly report of (%T1) [(%T2)] [(Week (%T4) of (%T3) month)]
例子3
原文:(%T1)提交的「(%M2)」待你审批
翻译成英语:'(%M2)' submitted by (%T1) is waiting for your approval
例子4
原文:您发起的「(%M1)」已通过
翻译成英语The '(%M1)' you initiated has been approved
EOF,
],
[
"role" => "user",
"content" => $content,
],
]
]);
// 处理结果
$obj = json_decode($result);
$txt = preg_replace('/(^\s*```json\s*|\s*```\s*$)/', "", $obj->choices[0]->message->content);
$txt = preg_replace('/\(([TM]\d+)\)/', '(%$1)', $txt);
$arr = json_decode($txt, true);
if (!$arr || !is_array($arr)) {
$error = array_merge($error, array_flip($keys));
print_r("翻译失败:\n" . $content . "\n\n");
file_put_contents("translate-gpt.log", json_encode($obj, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n\n", FILE_APPEND);
continue;
}
// 验证结果
foreach ($arr as $item) {
if (empty($item['key'])) {
print_r("翻译结果不符合规范key为空。\n");
print_r($item);
continue;
}
foreach (['key', 'zh', 'zh-CHT', 'en', 'ko', 'ja', 'de', 'fr', 'id', 'ru'] as $lang) {
if (!isset($item[$lang])) {
print_r("翻译结果不符合规范:{$item['key']},缺少:{$lang} 的值。\n");
continue 2;
}
}
$currentKey = $item['key'];
$originalKey = preg_replace(["/\(%T\d+\)/", "/\(%M\d+\)/"], ["(*)", "(**)"], $currentKey);
if (preg_match_all('/\(%[TM]\d+\)/', $currentKey, $matches)) {
foreach ($matches[0] as $match) {
foreach ($item as $k => $v) {
if (empty($v)) {
continue;
}
if (!str_contains($v, $match)) {
// 正则匹配错误
$error[$originalKey] = [
'key' => $currentKey,
$k => $v,
'match' => $match,
];
continue 3;
}
}
}
}
$item['zh'] = "";
$translations[$originalKey] = $item;
$success[$originalKey] = $item;
}
print_r("翻译完成:" . (count($keys) + $done) . "/" . count($needs) . "\n\n");
$done += count($keys);
}
if (count($error) > 0) {
print_r("正则匹配错误的数据:\n");
print_r(json_encode(array_values($error), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n\n");
}
// 保存翻译结果
file_put_contents("translate.json", json_encode(array_values($translations), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
print_r("----------------\n\n");
print_r("总翻译:" . count($needs) . "\n");
print_r("成功:" . count($success) . "\n");
print_r("错误:" . count($error) . "\n\n");
print_r("----------------\n\n");
}
// 生成前端使用的文件
foreach ($generateds as $type => $array) {
$datas = [];
foreach ($array as $text) {
$text = trim($text);
if (isset($translations[$text])) {
$datas[] = $translations[$text];
}
}
// 按长度排序
$inOrder = [];
foreach ($datas as $index => $item) {
if (preg_match('/\(%[TM]\d+\)/', $item['key'])) {
$inOrder[$index] = strlen($item['key']);
} else {
$inOrder[$index] = strlen($item['key']) + 10000000000;
}
}
array_multisort($inOrder, SORT_DESC, $datas);
// 合成数组
$results = [];
$index = 0;
foreach ($datas as $items) {
foreach ($items as $kk => $item) {
if (!isset($results)) {
$results[$kk] = [];
}
$results[$kk][] = $item;
}
}
// 生成文件
if ($type === 'api') {
if (!is_dir("../public/language/api")) {
mkdir("../public/language/api", 0777, true);
}
foreach ($results as $kk => $item) {
$file = "../public/language/api/$kk.json";
file_put_contents($file, json_encode($item, JSON_UNESCAPED_UNICODE));
}
} elseif ($type === 'web') {
if (!is_dir("../public/language/web")) {
mkdir("../public/language/web", 0777, true);
}
foreach ($results as $kk => $item) {
$file = "../public/language/web/$kk.js";
file_put_contents($file, "if(typeof window.LANGUAGE_DATA===\"undefined\")window.LANGUAGE_DATA={};window.LANGUAGE_DATA[\"{$kk}\"]=" . json_encode($item, JSON_UNESCAPED_UNICODE));
}
}
print_r("[$type] total: " . count($results['key']) . "\n");
}
print_r("\n任务结束\n");

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