Compare commits

..

275 Commits

Author SHA1 Message Date
kuaifan
6eb08ac09b build 2025-12-11 10:28:18 +08:00
kuaifan
20fc2b073b no message 2025-12-11 02:09:59 +00:00
kuaifan
8c4b9e8d12 feat: 优化项目/报告控制器及任务模型 2025-12-11 02:06:13 +00:00
kuaifan
8d187f5cfc feat: 优化周报/日报模板的已完成与未完成任务规则 2025-12-11 01:35:10 +00:00
kuaifan
db07a96e97 fix: 修复任务导出状态判断及状态高亮列错位问题 2025-12-11 01:13:03 +00:00
kuaifan
7acc9227ff fix: 修复任务统计导出漏掉无计划时间已完成任务的问题 2025-12-11 00:43:54 +00:00
kuaifan
c3a71e5b07 feat: 更新 appstore 镜像版本至 0.3.4 2025-12-10 02:01:43 +00:00
kuaifan
ac9e1e5e67 feat: call appstore user lifecycle hooks from main app 2025-12-09 10:30:23 +00:00
kuaifan
c668340661 feat: 优化消息推送逻辑 2025-12-05 02:10:37 +00:00
kuaifan
ee9b6248bb fix(electron): cleanup child windows by instance instead of name 2025-12-04 11:18:47 +00:00
kuaifan
01c7f7250b fix: 修复关闭应用时加载状态未正确更新的问题 2025-12-03 12:48:33 +00:00
kuaifan
2abc5976f9 fix: 更新 iframe 的 sandbox 属性以增强安全性 2025-12-02 12:03:54 +00:00
kuaifan
3e468c74e4 fix: 修改微模态框的最小高度设置 2025-12-02 11:46:46 +00:00
kuaifan
4ef78d2c81 feat: 添加点击消息打开微应用功能 2025-12-02 06:29:45 +00:00
kuaifan
4621222fa3 build 2025-11-30 12:18:18 +08:00
kuaifan
be860f9968 fix: load fastmcp via dynamic import in electron MCP 2025-11-30 12:13:31 +08:00
kuaifan
fe0b8aed20 no message 2025-11-28 22:09:55 +00:00
kuaifan
f0e844c308 feat: 添加个人任务上限设置,限制负责人或协助人的未完成任务数量 2025-11-28 11:05:08 +00:00
kuaifan
6a7cc95b23 feat: 添加颜色工具函数,支持颜色反转和解析 2025-11-28 09:35:01 +00:00
kuaifan
7fd90b9ceb feat: 添加对话框顶部消息样式 2025-11-28 08:58:14 +00:00
kuaifan
43577073e6 fix: 调整各组件最大高度计算,考虑状态栏和导航栏高度 2025-11-28 02:27:03 +00:00
kuaifan
faeeb09a4a fix: 修复微模态组件的样式,调整为固定定位以适应全屏显示 2025-11-28 01:33:49 +00:00
kuaifan
d88349b6f7 feat: 使用 CSS 变量动态调整窗口高度,优化各组件的最大高度设置 2025-11-28 01:33:35 +00:00
kuaifan
ff53e1fac3 fix: enforce positive rounded size in normalizeSize 2025-11-27 10:40:45 +08:00
kuaifan
cf4894b7c3 no message 2025-11-27 02:24:40 +00:00
kuaifan
678dfd2d5c feat: 更新 appstore 镜像版本 2025-11-27 02:24:34 +00:00
kuaifan
bf4a62ae04 feat: 更新文档,添加前端弹窗文案处理说明 2025-11-24 01:23:39 +00:00
kuaifan
7e6f3f92cf feat: 添加 URL 输入提示,优化 iframe 测试功能的用户体验 2025-11-24 01:23:22 +00:00
kuaifan
df382dafb4 no message 2025-11-24 00:38:16 +00:00
kuaifan
10925d3a47 no message 2025-11-20 06:19:29 +00:00
kuaifan
66252072c7 feat: 添加 iframe 测试功能,支持通过 URL 加载外部内容 2025-11-20 06:18:56 +00:00
kuaifan
29918882bd no message 2025-11-19 07:54:56 +00:00
kuaifan
4983fe8feb feat: 添加自定义微应用菜单功能,支持管理员配置和保存菜单项 2025-11-19 07:54:47 +00:00
kuaifan
f65da118d7 feat: 更新 appstore 镜像版本至 0.3.2 2025-11-15 09:17:38 +00:00
kuaifan
a86bd9a05e fix: 修复桌面端部分机器新窗口任务报错的情况 2025-11-14 09:48:10 +00:00
kuaifan
f2719eb742 feat: 更新助手默认模型为 gpt-5.1-mini 2025-11-14 01:20:41 +00:00
kuaifan
4f9ee1dfa9 no message 2025-11-14 01:17:48 +00:00
kuaifan
e6ad1218bc feat: 添加一键归档列表中已完成任务 2025-11-14 01:15:19 +00:00
kuaifan
dd2cd1df9a feat: 更新 OnlyOffice 组件的主题名称;优化文件管理页面的列表渲染;调整抽屉和文件内容的圆角样式 2025-11-13 06:20:21 +08:00
kuaifan
6dcbe8ba38 build 2025-11-12 16:46:33 +08:00
kuaifan
360d4dbbe2 no message 2025-11-12 07:18:54 +00:00
kuaifan
2f32b53d19 feat: 修改 getDomain 函数以支持可选的小写转换参数;更新 getObject 函数的默认值 2025-11-12 07:07:00 +00:00
kuaifan
6a3e3c3753 feat: AI 助手增加最大响应数至50,并添加上下文窗口大小设置 2025-11-12 01:23:34 +00:00
kuaifan
5ad08d8d36 no message 2025-11-12 01:06:36 +00:00
kuaifan
b892d92614 build 2025-11-12 07:11:38 +08:00
kuaifan
b259f083d4 no message 2025-11-12 07:05:46 +08:00
kuaifan
38aa9fe2fb build 2025-11-12 00:30:39 +08:00
kuaifan
863dd3a53e no message 2025-11-11 22:42:45 +08:00
kuaifan
bea5058df8 feat: 优化错误处理逻辑,简化错误消息输出 2025-11-11 21:49:09 +08:00
kuaifan
31c157f58f no message 2025-11-11 21:40:34 +08:00
kuaifan
8af6887daa feat: 优化WebSocketDialogMsg和BotReceiveMsgTask中的消息格式,统一中文标点,增强可读性 2025-11-11 13:05:04 +00:00
kuaifan
eb9b7b4f86 feat: 更新MCP工具描述 2025-11-11 07:16:04 +00:00
kuaifan
cf78766a37 feat: 移除未使用的消息处理函数和Markdown插件任务创建功能,优化代码结构 2025-11-11 05:42:02 +00:00
kuaifan
944824b552 feat: 移除未使用的函数和代码,优化BotReceiveMsgTask和WebSocketDialogMsg的消息处理逻辑 2025-11-11 05:31:59 +00:00
kuaifan
477bb1ac8f feat: MCP增加文件管理功能,支持获取文件访问URL、文件列表和文件搜索 2025-11-11 05:23:00 +00:00
kuaifan
29df864ecb feat: MCP增加工作报告相关功能,包括获取汇报列表、获取汇报详情、生成汇报模板、创建汇报及标记已读/未读状态 2025-11-11 02:24:35 +00:00
kuaifan
bcf897b7e0 no message 2025-11-10 23:03:42 +00:00
kuaifan
e63890c755 feat: 重构隐私政策页面,优化结构和样式,增强可读性 2025-11-10 23:01:39 +00:00
kuaifan
f3725215bd feat: 简化长按指令的参数配置 2025-11-10 22:43:25 +00:00
kuaifan
c43e305ea7 feat: 优化AI输出语言策略提示词 2025-11-10 22:36:37 +00:00
kuaifan
b9215e2410 feat: 添加语言偏好提示功能到AI系统提示 2025-11-10 16:46:29 +00:00
kuaifan
19d79ab055 feat: 优化触摸设备交互
- 触摸设备取消拖动选中文件
2025-11-10 16:14:01 +00:00
kuaifan
64d4492806 feat: 优化AI助手响应构建
- 增加剔除推理块功能
2025-11-10 16:13:05 +00:00
kuaifan
0790eae8c6 no message 2025-11-10 15:20:31 +00:00
kuaifan
e10e2c27c1 feat: 优化导出菜单交互 2025-11-10 07:59:52 +00:00
kuaifan
d30b38d4b9 feat: 添加应用排序功能 2025-11-10 07:47:00 +00:00
kuaifan
f6e4ed7c60 no message
- 添加AI助手流式会话凭证生成方法
- 优化AI助手模型获取逻辑
- 更新相关接口调用
2025-11-09 22:20:38 +00:00
kuaifan
7a6bbfac75 feat: 更新AI模块的transcriptions方法,增加扩展请求头参数,优化语音识别功能 2025-11-09 04:43:17 +00:00
kuaifan
425d6f9a06 feat: 移除冗余的AI助手设置方法,优化AI模块的模型配置逻辑 2025-11-09 04:28:51 +00:00
kuaifan
58c760bb77 no message 2025-11-09 02:14:27 +00:00
kuaifan
3ffdce5e7a no message 2025-11-08 23:54:18 +00:00
kuaifan
8e518a044a feat: 优化AI助手输出界面,简化状态显示逻辑,增强用户交互体验 2025-11-08 23:43:06 +00:00
kuaifan
a5adbf80a9 feat: 重构报告分析功能,更新API接口,移除冗余代码,优化分析逻辑 2025-11-08 22:18:59 +00:00
kuaifan
0b6c478b4f feat: 优化报告AI整理功能,优化报告编辑逻辑,移除冗余代码 2025-11-08 21:53:02 +00:00
kuaifan
0434bde16f feat: 移除冗余的AI任务和项目生成逻辑,优化代码结构 2025-11-08 21:52:26 +00:00
kuaifan
0deb3113b5 feat: 引入文本提取功能,优化AI内容解析逻辑,移除冗余代码 2025-11-08 20:42:21 +00:00
kuaifan
ecb52c76b9 feat: 完善AI助手功能 2025-11-08 08:57:22 +00:00
kuaifan
69c66053b7 feat: 完善AI助手功能,新增消息提示词整理接口,优化流式消息处理逻辑,移除冗余数据表和相关代码 2025-11-07 22:25:45 +00:00
kuaifan
892ad395a7 feat: 添加额外数据处理,优化AI助手消息生成与发送逻辑 2025-11-07 20:38:06 +00:00
kuaifan
e801c09c0f feat: 增强AI助手响应处理,支持流式输出和模型缓存 2025-11-07 08:13:51 +00:00
kuaifan
ad560a8555 feat: 增强流消息处理,支持回应和会话ID 2025-11-07 08:13:41 +00:00
kuaifan
e75aa5c2b9 feat: 创建新 AI 会话时将旧会话消息批量标记已读 2025-11-07 07:54:04 +00:00
kuaifan
e83fd7af1b feat: 优化 AI 助手,支持自定义模型 2025-11-07 07:01:15 +00:00
kuaifan
eaec8ef994 no message 2025-11-07 01:00:30 +00:00
kuaifan
3339e6b442 feat: 添加文件列表滚动事件处理,优化右键菜单显示逻辑 2025-11-07 01:00:22 +00:00
kuaifan
4c2425c758 feat: 优化链接获取逻辑 2025-11-06 14:53:16 +00:00
kuaifan
80d1e6469e no message 2025-11-06 14:23:39 +00:00
kuaifan
2fad6394ee no message 2025-11-06 14:03:58 +00:00
kuaifan
4bfe33a37f feat: 优化打开会话事件接口,优化机器人webhook逻辑
- 新增 `open__event` 方法用于处理打开会话事件
- 移除旧的 `open__webhook` 方法
- 更新前端调用逻辑,使用新的事件接口
- 优化 webhook 事件推送逻辑,简化参数传递
2025-11-06 13:59:10 +00:00
kuaifan
130c8bf3b1 Merge pull request #289 from nightcp/dev
feat: 调整机器人webhook事件
2025-11-06 15:24:06 +08:00
kuaifan
b9df277104 no message 2025-11-06 07:16:29 +00:00
kuaifan
97e1f321ca feat: 优化长文本预览组件 2025-11-06 07:00:11 +00:00
王昱
4933930afd feat: 调整机器人webhook事件
- 可取消接收消息事件
- 打开机器人会话窗口时推送webhook消息,相同机器人消息缓存1分钟
2025-11-06 04:08:39 +00:00
kuaifan
ab4640382d feat: 添加会员扩展信息接口,优化用户详情和个人设置页面 2025-11-06 02:01:15 +00:00
kuaifan
e4cfa4b405 feat: 优化个性标签 2025-11-05 22:19:45 +00:00
kuaifan
789062e85e Merge pull request #288 from xxyijixx/dev-profile
Dev profile
2025-11-05 17:11:46 +08:00
kuaifan
5370bee369 Merge branch 'dev' into pro
# Conflicts:
#	CHANGELOG.md
#	cmd
#	package.json
#	public/js/build/404.5645cb91.js
#	public/js/build/404.9598cd97.js
#	public/js/build/404.a5736629.js
#	public/js/build/AceEditor.8747edb1.js
#	public/js/build/AceEditor.af35593f.js
#	public/js/build/AceEditor.e7f5b602.js
#	public/js/build/DialogWrapper.0c7cd033.js
#	public/js/build/DialogWrapper.64072671.js
#	public/js/build/DialogWrapper.7fcb5b27.js
#	public/js/build/Drawio.2ca59c31.js
#	public/js/build/Drawio.6691a6ef.js
#	public/js/build/Drawio.e3576e4e.js
#	public/js/build/FileContent.3a899bcc.js
#	public/js/build/FileContent.c311c89c.js
#	public/js/build/FileContent.d8e600e1.js
#	public/js/build/FilePreview.87ca99d9.js
#	public/js/build/FilePreview.f8134ee5.js
#	public/js/build/FilePreview.f9f90ff4.js
#	public/js/build/IFrame.02598edc.js
#	public/js/build/IFrame.2a7489ee.js
#	public/js/build/IFrame.be9780e1.js
#	public/js/build/ImgUpload.29e2d88d.js
#	public/js/build/ImgUpload.a4eff264.js
#	public/js/build/ImgUpload.e96999cf.js
#	public/js/build/Minder.2bce6c16.js
#	public/js/build/Minder.b1d1145f.js
#	public/js/build/Minder.f5bc5aca.js
#	public/js/build/OnlyOffice.31e7af4f.js
#	public/js/build/OnlyOffice.574ad560.js
#	public/js/build/OnlyOffice.9ce921ed.js
#	public/js/build/ReportEdit.5eb3a319.js
#	public/js/build/ReportEdit.9141bb93.js
#	public/js/build/ReportEdit.e3369e09.js
#	public/js/build/SearchButton.906cea81.js
#	public/js/build/SearchButton.cf201525.js
#	public/js/build/SearchButton.d41addb6.js
#	public/js/build/TEditor.7b9a9d91.js
#	public/js/build/TEditor.971af80f.js
#	public/js/build/TEditor.cc94d929.js
#	public/js/build/TaskDetail.38815236.js
#	public/js/build/TaskDetail.d1a9952e.js
#	public/js/build/TaskDetail.dfd78b4a.js
#	public/js/build/add.0cfbdd9e.js
#	public/js/build/add.3673f91c.js
#	public/js/build/add.423bc480.js
#	public/js/build/application.005cc174.js
#	public/js/build/application.5587ac3b.js
#	public/js/build/application.5b8f123b.js
#	public/js/build/apps.4e0bf65b.js
#	public/js/build/apps.b0a3d4f5.js
#	public/js/build/apps.f77a8c4e.js
#	public/js/build/calendar.31470aa0.js
#	public/js/build/calendar.ad5d85d5.js
#	public/js/build/calendar.e08e7575.js
#	public/js/build/checkin.5d4c364e.js
#	public/js/build/checkin.ab08f01e.js
#	public/js/build/checkin.c05284a9.js
#	public/js/build/dashboard.7cced7be.js
#	public/js/build/dashboard.c82415db.js
#	public/js/build/dashboard.f6ed8299.js
#	public/js/build/dayjs.495f600d.js
#	public/js/build/dayjs.71653272.js
#	public/js/build/dayjs.cf033d87.js
#	public/js/build/delete.4072c68f.js
#	public/js/build/delete.5f06c51d.js
#	public/js/build/delete.b26aa3fd.js
#	public/js/build/device.4cff22ad.js
#	public/js/build/device.66a7e05a.js
#	public/js/build/device.a13f3ef0.js
#	public/js/build/dialog.97b951ce.js
#	public/js/build/dialog.e9f6d55f.js
#	public/js/build/dialog.eb7b795a.js
#	public/js/build/editor.18a511b5.js
#	public/js/build/editor.2cca497c.js
#	public/js/build/editor.e034df4e.js
#	public/js/build/email.0643f86b.js
#	public/js/build/email.1d00cb0c.js
#	public/js/build/email.d95a35c0.js
#	public/js/build/file.4fe82c29.js
#	public/js/build/file.684a63df.js
#	public/js/build/file.9dceb82f.js
#	public/js/build/fileMsg.0a0029c2.js
#	public/js/build/fileMsg.1f4ecb0f.js
#	public/js/build/fileMsg.f99b6f61.js
#	public/js/build/fileTask.72914205.js
#	public/js/build/fileTask.bf35fb6b.js
#	public/js/build/fileTask.f4356f14.js
#	public/js/build/index.236af26f.js
#	public/js/build/index.299c9f99.js
#	public/js/build/index.2ffa8f9e.js
#	public/js/build/index.7d6e1bbe.js
#	public/js/build/index.94a5d2da.css
#	public/js/build/index.af34aeb9.js
#	public/js/build/index.b0ae9460.js
#	public/js/build/index.b69b5f25.js
#	public/js/build/index.b71c2859.js
#	public/js/build/index.c3968cad.js
#	public/js/build/index.d1ae44be.js
#	public/js/build/index.e07db7f9.css
#	public/js/build/index.edee4b6e.css
#	public/js/build/index.ef9e1e57.js
#	public/js/build/index.fe32159a.js
#	public/js/build/jquery.0909250e.js
#	public/js/build/jquery.16b446fd.js
#	public/js/build/jquery.27f590f5.js
#	public/js/build/keyboard.3f5b3ac6.js
#	public/js/build/keyboard.5de3dd2c.js
#	public/js/build/keyboard.c3ef7d49.js
#	public/js/build/language.1fadd54c.js
#	public/js/build/language.8bb72294.js
#	public/js/build/language.f3d03ece.js
#	public/js/build/license.21482fde.js
#	public/js/build/license.60871496.js
#	public/js/build/license.add318a7.js
#	public/js/build/localforage.65ac7a2a.js
#	public/js/build/localforage.be4775a0.js
#	public/js/build/localforage.dd58f5ac.js
#	public/js/build/login.7560afa5.js
#	public/js/build/login.75b3978c.js
#	public/js/build/login.aa163163.js
#	public/js/build/meeting.a60d7e8d.js
#	public/js/build/meeting.aa5510c7.js
#	public/js/build/meeting.fdb9793b.js
#	public/js/build/password.267357fd.js
#	public/js/build/password.749ce44d.js
#	public/js/build/password.e6d81eb1.js
#	public/js/build/personal.69279937.js
#	public/js/build/personal.a27cef8e.js
#	public/js/build/personal.c613af3c.js
#	public/js/build/preload.5827bd38.js
#	public/js/build/preload.8ec61a5b.js
#	public/js/build/preload.c6189d87.js
#	public/js/build/preview.29e49902.js
#	public/js/build/preview.7329f0f4.js
#	public/js/build/preview.b452b0ee.js
#	public/js/build/preview.c64402ed.js
#	public/js/build/preview.ec796a92.js
#	public/js/build/preview.ec85a43c.js
#	public/js/build/pro.2128a514.js
#	public/js/build/pro.213d8da6.js
#	public/js/build/pro.9fb60d27.js
#	public/js/build/projectInvite.0b3bf524.js
#	public/js/build/projectInvite.393920f8.js
#	public/js/build/projectInvite.e9cee390.js
#	public/js/build/reportDetail.2db50632.js
#	public/js/build/reportDetail.90aaf973.js
#	public/js/build/reportDetail.d93cc650.js
#	public/js/build/reportEdit.84a81076.js
#	public/js/build/reportEdit.8baf23d4.js
#	public/js/build/reportEdit.d008dd34.js
#	public/js/build/swipe.0c72cce1.js
#	public/js/build/swipe.4567bb5d.js
#	public/js/build/swipe.92aebd0c.js
#	public/js/build/system.67c1b700.js
#	public/js/build/system.c45c70de.js
#	public/js/build/system.f3384133.js
#	public/js/build/task.1b9e0e77.js
#	public/js/build/task.a445c89e.js
#	public/js/build/task.d43091db.js
#	public/js/build/taskContent.20b80714.js
#	public/js/build/taskContent.3ebbd2f9.js
#	public/js/build/taskContent.9dc7a121.js
#	public/js/build/theme.72d103d1.js
#	public/js/build/theme.7f1b2ffd.js
#	public/js/build/theme.df79fe8f.js
#	public/js/build/token.0ecffef5.js
#	public/js/build/token.a7f5ccf5.js
#	public/js/build/token.ece75257.js
#	public/js/build/validEmail.1462dd30.js
#	public/js/build/validEmail.17a3e0d2.js
#	public/js/build/validEmail.ee19c1f3.js
#	public/js/build/version.137935c7.js
#	public/js/build/version.1441c1fd.js
#	public/js/build/version.b0154505.js
#	public/js/build/video.03b62c93.js
#	public/js/build/video.2dc7f3c6.js
#	public/js/build/video.531c68e2.js
#	public/js/build/view.18713f1b.js
#	public/js/build/view.7770155e.js
#	public/js/build/view.8c6a0cc1.js
#	public/manifest.json
2025-11-05 16:55:17 +08:00
kuaifan
2f972488a1 Merge pull request #287 from nightcp/dev
feat: 优化用户机器人 webhook 逻辑
2025-11-05 16:30:37 +08:00
kuaifan
6f7656802f no message 2025-11-05 06:20:04 +00:00
kuaifan
7d98c5493e feat: 添加AI整理工作汇报功能 2025-11-05 04:02:29 +00:00
kuaifan
e0443aa336 feat: 添加AI分析工作汇报功能 2025-11-05 04:02:06 +00:00
kuaifan
39ff0d1516 feat: 将AI助手从gpt-5-nano更改为gpt-5-mini 2025-11-05 01:58:24 +00:00
kuaifan
1b9c0ee4b8 feat: 优化AI助手入口 2025-11-05 01:55:59 +00:00
kuaifan
d48287f93a feat: 添加判断是否为iPad的功能,并在预加载时处理安全区域 2025-11-04 13:08:23 +08:00
kuaifan
717e87cfa9 feat: 更新抽屉样式以支持横屏模式下的最大宽度设置 2025-11-04 13:06:19 +08:00
kuaifan
708b488af8 fix: 修复android分享页面元素重叠的情况 2025-11-03 16:56:20 +08:00
kuaifan
d60d3f374b feat: 调整对话框尺寸计算,避免发送消息失败的情况 2025-11-03 14:46:46 +08:00
kuaifan
8b87a2bc40 feat: 添加聊天输入历史记录功能 2025-11-03 02:12:05 +00:00
kuaifan
d0da517503 no message 2025-11-03 00:43:28 +00:00
kuaifan
754036c472 build 2025-11-03 08:05:35 +08:00
kuaifan
720438fd91 Merge commit '96106498d8c480c3ea7ec493bfb063450e11b7b5' into pro 2025-11-03 08:00:22 +08:00
kuaifan
ba76df1b00 no message 2025-11-03 08:00:15 +08:00
kuaifan
44d85c2864 feat: 增加对应用平台的 overscroll-behavior 设置
- 优化iOS15滚动超限的情况
2025-11-03 07:51:44 +08:00
kuaifan
1c8b73a381 feat: 重构胶囊缓存逻辑,增加设置和移除缓存的方法 2025-11-03 01:29:34 +08:00
kuaifan
b445af932c feat: 更新消息推送逻辑 2025-11-03 00:45:34 +08:00
kuaifan
5121739fe4 feat: 优化应用激活逻辑,增加 IndexedDB 测试失败时的提前返回处理 2025-11-03 00:34:32 +08:00
kuaifan
96106498d8 feat: 添加Umeng日志模型及数据库迁移 2025-11-01 16:15:32 +00:00
kuaifan
0116d92021 feat: 给支持角标的Android设备推送添加角标 2025-11-01 16:15:25 +00:00
kuaifan
43746634a5 no message 2025-10-31 08:27:44 +00:00
kuaifan
5183786fb0 no message 2025-10-30 20:04:41 +00:00
kuaifan
5ba0eed721 no message 2025-10-29 00:15:45 +00:00
kuaifan
7d08c735ef no message 2025-10-28 11:35:36 +00:00
kuaifan
e3067b685c no message 2025-10-28 09:23:41 +00:00
kuaifan
b219ca4c1c no message 2025-10-27 20:57:42 +00:00
kuaifan
9e5d16ff16 feat: 添加 MCP 服务器类型为 streamable-http 2025-10-27 02:49:53 +00:00
kuaifan
da630458e1 fix: 修复任务操作无法点击确定 2025-10-27 02:45:29 +00:00
kuaifan
ee2eceffb0 build 2025-10-27 06:39:25 +08:00
kuaifan
c8d22e7b5f no message 2025-10-27 06:35:27 +08:00
kuaifan
342e8725bd feat: 更新 MCP 服务器配置和工具 2025-10-27 06:34:47 +08:00
kuaifan
3ced00de1f no message 2025-10-27 06:34:47 +08:00
kuaifan
7fa075fa75 no message 2025-10-26 09:59:37 +08:00
kuaifan
95ca496691 feat: 优化获取任务子任务数据相关逻辑 2025-10-26 09:30:24 +08:00
kuaifan
50b1d93f08 no message 2025-10-26 09:21:58 +08:00
kuaifan
8958f2f234 feat: 添加MCP服务器状态切换功能 2025-10-25 16:39:50 +08:00
kuaifan
00b4d6a748 no message 2025-10-25 10:46:01 +08:00
kuaifan
f4de0d8276 feat: 更新MCP工具,添加项目管理功能及任务创建、更新接口 2025-10-25 10:45:46 +08:00
kuaifan
cfa749f4f3 feat: 优化时间范围参数 2025-10-24 23:48:35 +08:00
kuaifan
eeaff08673 feat: 桌面端添加MCP服务 2025-10-24 23:48:18 +08:00
kuaifan
0475e88dc2 feat: 添加任务移动权限检查以增强项目任务管理 2025-10-24 06:35:22 +00:00
kuaifan
e1f73a4639 feat: 为列表项添加最小高度以改善可读性 2025-10-24 05:42:57 +00:00
kuaifan
e2296a6f64 feat: 添加子任务升级为主任务功能 2025-10-24 05:38:54 +00:00
kuaifan
1a6abf4e1b feat: 在安装和更新函数中添加sudo检查 2025-10-24 03:34:22 +00:00
kuaifan
315851eb5f feat: 优化数据库还原功能
- 支持通过编号选择备份文件
2025-10-23 22:55:29 +00:00
kuaifan
0b99b4a9a0 fix: 修复用户选择在输入法预输入时误删已选项 2025-10-23 06:07:24 +00:00
王昱
66002ff401 Merge branch 'kuaifan:dev' into dev 2025-10-22 17:30:34 +08:00
nightcp
bdfc8bdd0c feat: 添加机器人消息推送参数文档,增强 webhook 事件说明 2025-10-22 17:29:32 +08:00
nightcp
98e4668969 feat: 优化用户机器人 webhook 逻辑 2025-10-21 13:53:16 +08:00
kuaifan
e8235dd0a2 feat: 优化已读消息标记逻辑,提升性能和可读性 2025-10-17 00:41:38 +00:00
kuaifan
123c74de46 feat: 优化开发环境配置 2025-10-16 23:56:48 +00:00
yatgei
c92b9bf0fb feat: 在用户详情组件中添加创建群组按钮功能 2025-10-14 18:29:21 +08:00
yatgei
b4cbfd2ae9 feat: 更新用户详情组件样式,调整布局和颜色 2025-10-14 14:01:03 +08:00
yatgei
dd7eee277e feat: 添加共同群组对话框组件并在用户详情中集成 2025-10-13 18:22:25 +08:00
kuaifan
ab76185434 feat: 优化个人资料卡片 2025-10-13 06:56:44 +00:00
kuaifan
6d97bf1e88 feat: 添加个性标签管理功能 2025-10-12 23:02:34 +00:00
kuaifan
49701fcd09 feat: 会员资料窗口添加创建群组按钮 2025-10-12 15:15:34 +00:00
kuaifan
40f04d9860 feat: 添加用户生日、地址和个人简介 2025-10-12 15:07:10 +00:00
kuaifan
d58dd25dbb feat: 添加用户生日、地址和个人简介 2025-10-12 15:05:05 +00:00
kuaifan
9b2731607b feat: 优化开发环境配置 2025-10-11 10:42:49 +00:00
kuaifan
a8d2d6f13f feat: 优化开发环境配置 2025-10-11 02:53:17 +00:00
kuaifan
7c21782ab5 no message 2025-10-08 04:34:31 +00:00
kuaifan
f59bdaf5e0 feat: 添加用户机器人 webhook 事件配置,优化相关逻辑 2025-09-30 04:25:50 +00:00
kuaifan
9419ddd174 no message 2025-09-29 09:19:28 +08:00
kuaifan
0666a8f5c2 feat: 优化任务可见性推送逻辑 2025-09-29 09:04:31 +08:00
kuaifan
81c019105c no message 2025-09-28 10:40:48 +08:00
kuaifan
6584259454 build 2025-09-28 08:38:48 +08:00
kuaifan
03d0f56095 no message 2025-09-28 08:16:53 +08:00
kuaifan
6ffd169784 build 2025-09-28 06:54:05 +08:00
kuaifan
406f64a7c5 no message 2025-09-28 06:46:19 +08:00
kuaifan
1353a2c4c9 no message 2025-09-28 06:34:35 +08:00
kuaifan
fb88f3bd96 no message 2025-09-28 06:33:38 +08:00
kuaifan
22b3598704 feat: 优化共同群聊计数缓存 2025-09-28 06:28:24 +08:00
kuaifan
b62c580d5e no message 2025-09-28 05:55:02 +08:00
kuaifan
6a63ceaecc fix: 编辑器快捷键保存重复 2025-09-28 05:19:27 +08:00
kuaifan
591f9e61fb no message 2025-09-27 17:48:43 +08:00
kuaifan
7011c81bcd feat: 优化自动归档逻辑
- 子任务不自动归档
2025-09-27 16:38:44 +08:00
kuaifan
3cf7055122 feat: 添加任务关联功能 2025-09-27 15:53:58 +08:00
kuaifan
aba31eda83 no message 2025-09-27 07:09:08 +08:00
kuaifan
1b30582dd9 feat: 添加emoji表情删除按钮 2025-09-26 20:18:12 +08:00
kuaifan
0fb66358cc feat: 优化对话搜索时的选择状态管理 2025-09-26 19:29:13 +08:00
kuaifan
e226f444f7 feat: 优化部门选择逻辑
- 支持自动添加父级部门
2025-09-26 19:21:26 +08:00
kuaifan
95bf70f568 no message 2025-09-26 19:02:09 +08:00
kuaifan
a6597b44c3 feat: 优化Ai提示词 2025-09-26 19:00:10 +08:00
kuaifan
51c01c5445 feat: 添加文件缩略图显示 2025-09-26 14:00:53 +08:00
kuaifan
161bf75a1d feat: 添加文件拖拽选择功能 2025-09-26 13:32:11 +08:00
kuaifan
2f16e2c608 feat: 添加文件预览功能和优化文件打开逻辑 2025-09-26 12:13:38 +08:00
kuaifan
aea2e79b37 no message 2025-09-25 16:36:11 +08:00
kuaifan
f433d13a2f feat: 优化透明模式样式 2025-09-25 09:04:35 +08:00
kuaifan
e9abf6ed05 no message 2025-09-25 06:05:09 +08:00
kuaifan
0c32b25ddf no message 2025-09-25 00:14:14 +08:00
kuaifan
a03dec91c5 feat: 添加任务复制功能 2025-09-24 23:49:22 +08:00
kuaifan
7c5a966944 no message 2025-09-24 21:00:31 +08:00
kuaifan
652dc0953b feat: 添加任务模板排序功能
- 在 ProjectController 中新增 task__template_sort 方法,支持项目任务模板的排序
- 更新前端组件以支持拖拽调整任务模板顺序
- 新增数据库迁移以填充任务模板的排序字段
- 优化样式以提升用户体验
2025-09-24 20:49:09 +08:00
kuaifan
03860a6dce feat: 添加标签排序功能
- 在 ProjectController 中新增 tag__sort 方法,支持项目标签的排序
- 更新 ProjectTag 模型,添加排序字段
- 新增数据库迁移以添加标签排序字段
- 更新前端组件,支持拖拽调整标签顺序
- 优化样式以提升用户体验
2025-09-24 20:31:54 +08:00
kuaifan
c6bee25264 fix: 优化用户交接人选择逻辑
- 更新 UsersController 中的交接人选择逻辑,确保在选择交接人时进行有效性检查
- 修改前端 TeamManagement 组件,添加交接人选择提示信息
- 确保在提交数据时正确处理交接人 ID 的格式
2025-09-24 19:06:50 +08:00
kuaifan
068de0fa9f fix: 优化文件访问权限检查逻辑
- 移除冗余的游客访问权限检查代码
- 简化用户认证逻辑,确保在文件不允许游客访问时强制用户登录
- 更新返回数据结构,移除不再使用的 is_guest_access 字段
2025-09-24 19:00:07 +08:00
kuaifan
4b45d5ca26 feat: 添加会话重命名功能
- 在 DialogController 中新增 session__rename 方法,支持用户重命名会话
- 更新前端组件 DialogSessionHistory.vue,添加重命名按钮及相关逻辑
- 修改样式以支持重命名功能的交互效果
- 优化用户体验,确保重命名操作的流畅性
2025-09-24 18:39:25 +08:00
kuaifan
a268391e68 feat: 添加收藏备注功能
- 在 UsersController 中新增 favorite__remark 方法,支持用户修改收藏的备注
- 在 UserFavorite 模型中添加更新备注的逻辑
- 新增数据库迁移以添加备注字段
- 更新前端组件以支持备注的显示和编辑
- 优化收藏操作的用户体验
2025-09-24 18:15:03 +08:00
kuaifan
89bdd86f14 fix: 更新消息预览文本获取方法
- 将获取消息预览文本的方法从 previewTextMsg 更新为 previewMsg,以适应新的消息结构
- 确保在处理消息时使用最新的预览文本获取逻辑
2025-09-24 16:45:08 +08:00
kuaifan
e533bd7e35 no message 2025-09-24 15:48:48 +08:00
kuaifan
09ed978e80 no message 2025-09-24 11:54:01 +08:00
kuaifan
4b106e1f41 feat: 添加最近访问记录功能
- 在 UsersController 中新增获取和删除最近访问记录的接口
- 在相关控制器中记录用户最近访问的任务、文件和消息文件
- 新增 RecentManagement 组件,展示用户最近访问的记录
- 更新样式和图标以提升用户体验
2025-09-24 09:51:13 +08:00
kuaifan
feeeb26d94 no message 2025-09-23 19:39:13 +08:00
kuaifan
bef0d2d992 feat: 增强用户部门成员管理功能
- 在 UsersController 中新增逻辑,自动将缺失的部门成员加入 WebSocket 对话组
- 优化部门成员同步流程,提升用户体验
2025-09-23 18:49:02 +08:00
kuaifan
6e6bd8a6be build 2025-09-23 17:44:09 +08:00
kuaifan
631fa0db4e feat: 添加数据导出功能及相关样式
- 在管理页面中新增数据导出功能,支持导出任务、超期任务、审批数据和签到数据
- 更新应用页面,添加导出管理的弹出菜单
- 新增导出相关的 SVG 图标
- 优化样式以提升用户体验
2025-09-23 16:41:07 +08:00
kuaifan
65d30b7a30 no message 2025-09-23 15:32:01 +08:00
kuaifan
5ba5f27ca7 no message 2025-09-23 15:03:29 +08:00
kuaifan
acc437bf2d fix: 重置成功登录流程后的认证异常标志
- 在 actions.js 中添加逻辑,确保在成功登录后重置 ajaxAuthException 状态
- 优化用户认证体验,避免异常状态影响后续操作
2025-09-23 14:41:46 +08:00
kuaifan
5fd2505a33 feat: 优化 AI 生成交互体验
- 移除不必要的 loading 状态,简化用户交互
- 在项目和任务生成中添加取消功能,提升用户体验
- 更新相关组件以支持取消操作,确保生成过程的灵活性
2025-09-23 14:41:34 +08:00
kuaifan
7f6abc331b feat: 添加 AI 助手生成消息功能
- 在 DialogController 中新增 msg__ai_generate 接口,支持根据用户需求自动生成聊天消息
- 在 AI 模块中实现 generateMessage 方法,处理消息生成逻辑
- 更新前端 ChatInput 组件,添加 AI 生成按钮,集成消息生成请求
- 增强用户交互体验,支持输入消息主题和要点
2025-09-23 14:05:01 +08:00
kuaifan
c190aab8b9 feat: 添加 AI 助手生成项目功能
- 在 ProjectController 中新增 ai__generate 接口,支持根据用户需求自动生成项目名称及任务列表
- 在 AI 模块中实现 generateProject 方法,处理项目生成逻辑
- 更新前端管理页面,添加 AI 生成按钮,集成项目生成请求
- 增强样式以提升用户体验
2025-09-23 13:43:46 +08:00
kuaifan
0f71abdac3 no message 2025-09-23 13:13:52 +08:00
kuaifan
8ddc507bd5 feat: 添加 AI 助手生成任务功能
- 在 ProjectController 中新增 ai_generate 接口,支持根据用户输入生成任务标题和详细描述
- 在 AI 模块中实现 generateTask 方法,处理任务生成逻辑
- 更新前端 TaskAdd 组件,添加 AI 生成按钮,集成任务生成请求
- 优化 TEditor 和 TEditorTask 组件,支持设置内容格式
- 增强样式以提升用户体验
2025-09-23 13:11:33 +08:00
kuaifan
1c4bae2d91 no message 2025-09-23 10:16:57 +08:00
kuaifan
73ca4b1ea5 feat: 扩展收藏功能,支持消息类型的收藏
- 在 UserFavorite 模型中添加消息类型常量
- 更新 UsersController,支持消息的收藏、切换和状态检查
- 修改前端 Vue 组件以实现消息的收藏操作和状态显示
- 优化收藏管理界面,支持消息类型的展示与处理
2025-09-23 09:48:06 +08:00
kuaifan
18a922b5cd feat: 重构收藏功能,优化状态检查与切换逻辑
- 将文件、项目和任务的收藏状态切换逻辑统一为 toggleFavorite 方法
- 添加 checkFavoriteStatus 方法以简化收藏状态检查
- 更新相关 Vue 组件以使用新的状态管理方法,提升代码可读性和维护性
- 优化上下文菜单和操作逻辑,确保收藏状态的实时更新
2025-09-23 08:59:15 +08:00
kuaifan
11b98978c1 feat: 增强文件和项目的收藏功能
- 在 UserFavorite 模型中添加文件的 pid 字段以支持层级结构
- 更新前端 Vue 组件以实现文件和项目的收藏状态切换
- 添加检查文件和项目收藏状态的功能
- 优化上下文菜单以支持收藏操作
2025-09-22 16:35:57 +08:00
kuaifan
379d3811a8 feat: 添加用户收藏功能
- 在 UsersController 中新增获取、切换、清理用户收藏的 API 接口
- 创建 UserFavorite 模型以管理用户的收藏记录
- 更新前端 Vue 组件以支持收藏管理界面和交互
- 添加相关样式以美化收藏管理界面
2025-09-22 16:09:33 +08:00
kuaifan
0401b8a6e6 feat: 添加任务浏览历史功能
- 在 UsersController 中新增获取、记录和清理任务浏览历史的 API 接口
- 创建 UserTaskBrowse 模型以管理用户的任务浏览记录
- 更新前端 Vue 组件以支持任务浏览历史的加载和显示
- 移除不再使用的本地缓存逻辑,直接通过 API 进行数据交互
2025-09-22 07:10:12 +08:00
kuaifan
6148b996d8 no message 2025-09-22 06:27:57 +08:00
kuaifan
39781c9cd7 feat: 优化消息传递处理逻辑
- 在 DialogWrapper 组件中添加 handlerMsgTransfer 方法以简化消息传递逻辑
- 更新 TaskDetail 组件以直接使用状态管理中的 dialogMsgTransfer 数据
2025-09-22 06:01:56 +08:00
kuaifan
18758a1614 no message 2025-09-22 06:01:36 +08:00
kuaifan
b044d8d90e feat: 添加部门成员同步功能
- 在 UsersController 中新增同步部门成员的 API 接口
- 在 UserDepartment 模型中添加递归获取子部门 ID 的方法
- 在前端 TeamManagement 组件中添加同步部门成员的操作选项
2025-09-22 05:07:45 +08:00
kuaifan
02e56f87bc perf: 优化群聊消息AI处理逻辑
- 添加获取最近聊天记录功能
2025-09-21 20:00:23 +08:00
kuaifan
d9b9ee221b no message 2025-09-21 15:43:00 +08:00
kuaifan
21ec9188ca fix: 添加异常处理以确保提及格式转换的稳定性 2025-09-20 17:04:38 +08:00
kuaifan
4d768becf5 fix: 更新应用商店镜像版本至0.2.9 2025-09-20 17:04:29 +08:00
kuaifan
a27049386b no message 2025-09-20 15:11:42 +08:00
kuaifan
b23e3d7359 feat: 添加下载功能的等待状态支持 2025-09-20 14:04:44 +08:00
kuaifan
7660164583 fix: 修复在列表中未找到当前图像时的处理逻辑 2025-09-20 07:30:45 +08:00
kuaifan
5e1f3c5564 feat: 添加文件游客访问权限功能 2025-09-19 19:10:58 +08:00
kuaifan
197fa9c01c build 2025-09-02 07:27:42 +08:00
kuaifan
554e3d0c2f no message 2025-08-27 17:10:17 +08:00
kuaifan
b800cde34d no message 2025-08-26 20:21:19 +08:00
kuaifan
775fdd2be0 fix: 无法修改群组名称的问题 2025-08-26 20:19:41 +08:00
kuaifan
7908ae4258 no message 2025-08-20 18:25:51 +08:00
kuaifan
bfbd8229a1 no message 2025-08-20 16:53:29 +08:00
kuaifan
afbf8dedbf no message 2025-08-20 16:21:26 +08:00
kuaifan
569912abef no message 2025-08-20 13:39:58 +08:00
kuaifan
7c94f6bc9a perf: 支持项目调整排序 2025-08-20 08:36:19 +08:00
kuaifan
b825b5b063 perf: 支持项目调整排序 2025-08-19 23:12:10 +08:00
kuaifan
50098b5e70 no message 2025-08-19 22:19:46 +08:00
kuaifan
e237b4db1c perf: 支持项目调整排序 2025-08-19 22:18:18 +08:00
kuaifan
2a25cf3bbd no message 2025-08-19 21:43:17 +08:00
kuaifan
02275bb417 perf: 支持项目调整排序 2025-08-19 21:19:45 +08:00
kuaifan
788cae3efe no message 2025-08-19 20:06:46 +08:00
kuaifan
0dec70c53a no message 2025-08-19 20:06:38 +08:00
kuaifan
f534f012d2 perf: 优化错误页 2025-08-19 18:01:21 +08:00
kuaifan
bb83875c99 feat: 添加内置浏览器导航功能 2025-08-19 18:01:21 +08:00
kuaifan
d048aa33f7 no message 2025-08-19 18:01:21 +08:00
kuaifan
8f3e250073 perf: 优化输入框工具栏 2025-08-19 18:01:21 +08:00
kuaifan
63a792d169 no message 2025-08-19 18:01:21 +08:00
kuaifan
eb3524a22d Merge pull request #280 from nightcp/fix-ganntt-timeline-error
fix(gantt): 修复甘特图时间轴计算错误
2025-08-19 18:00:43 +08:00
nightcp
f657a24a1a fix(gantt): 修复甘特图时间轴计算错误
Closes #272
2025-08-18 21:05:26 +08:00
kuaifan
a5228448d7 Merge pull request #278 from puzzle9/pro
fix: 修复 supervisor crontab 运行状态错误
2025-08-18 17:07:50 +08:00
kuaifan
1ec4796f72 feat: 添加查看共同的群 2025-08-18 09:45:39 +08:00
kuaifan
6964158cf6 perf: 优化任务模板、任务标签 2025-08-18 08:31:22 +08:00
kuaifan
4fc4dd1b16 no message 2025-08-18 05:28:37 +08:00
kuaifan
3e851f0c3c build 2025-08-15 07:54:34 +08:00
kuaifan
b8befaa973 no message 2025-08-15 07:43:45 +08:00
kuaifan
b05046af29 perf: 优化下载工具 2025-08-15 07:22:20 +08:00
kuaifan
eecc6c9e53 perf: 优化下载工具 2025-08-15 01:02:40 +08:00
kuaifan
d4e754d601 perf: 优化下载工具 2025-08-15 00:27:34 +08:00
kuaifan
a8a54593e2 perf: 优化下载工具 2025-08-14 23:11:37 +08:00
kuaifan
5bbffc4f5c perf: 优化下载工具 2025-08-14 20:31:55 +08:00
kuaifan
0833018399 perf: 优化下载工具 2025-08-14 16:50:48 +08:00
kuaifan
f6850fc795 perf: 优化下载工具 2025-08-14 16:50:42 +08:00
kuaifan
c0b4674568 no message 2025-08-14 11:56:51 +08:00
puzzle
5a8996d90a fix: 修复 supervisor crontab 运行状态错误 2025-08-14 03:08:06 +08:00
kuaifan
548b30e5b3 build 2025-08-12 08:05:11 +08:00
kuaifan
80f9329004 no message 2025-08-12 07:38:40 +08:00
kuaifan
f672280236 no message 2025-08-12 07:37:52 +08:00
kuaifan
90a4a01de7 no message 2025-08-11 22:35:58 +08:00
kuaifan
09cebb90fe fix: 修复应用加载中无法点击胶囊 2025-08-11 20:23:55 +08:00
kuaifan
70389aab3d no message 2025-08-11 16:47:53 +08:00
473 changed files with 29651 additions and 9914 deletions

View File

@@ -4,7 +4,6 @@ on:
push:
branches:
- "pro"
- "dev"
jobs:
check-version:

53
.gitignore vendored
View File

@@ -1,32 +1,63 @@
# Dependencies
/node_modules
/vendor
# Build and temporary files
/build
/public/hot
/public/tmp
/tmp
# Uploads and user-generated content
/public/summary
/public/uploads/*
/public/.well-known
/public/.user.ini
/storage/*.key
# Storage and configuration
/config/LICENSE
/vendor
/build
/tmp
._*
/storage/*.key
# Environment and configuration
.env
vars.yaml
# IDE and editor files
.cursor/*
!.cursor/rules/
!.cursor/rules/**
.idea
.vscode
.vagrant
.windsurfrules
.phpunit.result.cache
# Development tools
.vagrant
Homestead.json
Homestead.yaml
# Development file
/index.html
# Testing
.phpunit.result.cache
test.*
# Logs and debug files
npm-debug.log
yarn-error.log
test.*
# Lock files
dootask.lock
package-lock.json
# Laravel/Swoole specific
laravels-timer-process.pid
.DS_Store
vars.yaml
laravels.conf
laravels.pid
# System files
._*
.DS_Store
# Documentation
README_LOCAL.md
dootask.lock

View File

@@ -1,13 +0,0 @@
# This configuration file was automatically generated by Gitpod.
# Please adjust to your needs (see https://www.gitpod.io/docs/config-gitpod-file)
# and commit this file to your remote git repository to share the goodness with others.
tasks:
- init: sudo ./cmd install
command: ./cmd dev
ports:
- port: 2222
visibility: public
- port: 22222
visibility: public

127
AGENTS.md Normal file
View File

@@ -0,0 +1,127 @@
# DooTask 项目说明
## 一、项目总览
- **项目定位**DooTask 是一套开源的任务 / 项目管理系统,支持看板、任务、子任务、评论、对话、文件、报表等协作能力。
- **后端技术栈**
- 基于 Laravel运行在 LaravelS / Swoole 常驻进程上),代码集中在 `app/``routes/``config/` 等目录。
- 数据库通过 Laravel Eloquent 模型访问,所有表结构变更必须通过 migration 完成,禁止直接手工改库。
- **前端技术栈**
- 主 Web 前端基于 Vue2 + Vite代码集中在 `resources/assets/js`
- 打包与开发通过根目录的 `./cmd` 脚本间接调用 Vite。
- **桌面端**
- 使用 Electron 作为桌面壳,核心业务逻辑仍在 Web 前端与 Laravel 后端中。
更多安装、升级、迁移说明见根目录 `README.md`
## 二、开发与运行(命令约定)
- 开发 / 构建命令统一通过根目录的 `./cmd` 脚本执行,以保证与 Docker / 容器环境一致:
- 启动服务:`./cmd up`
- 停止服务:`./cmd down`
- 重启服务:`./cmd reup``./cmd restart`
- Laravel 工具:`./cmd artisan ...`
- 前端开发:`./cmd dev`
- 前端构建:`./cmd prod``./cmd build`
- 其他工具:`./cmd composer ...``./cmd php ...``./cmd doc`
- 在示例、脚本与回答中,优先使用 `./cmd ...` 形式,而不是直接调用 `php``composer``npm` 等命令。
## 三、代码结构(后端 + 前端)
- **Controller`app/Http/Controllers`**
- 负责路由入口、参数接收与基础校验,编排调用模型 / 模块,并组装 API 响应。
- 原则:控制器尽量保持「薄」,复杂业务逻辑不要堆积在控制器中。
- 业务异常优先使用 `App\Exceptions\ApiException` 抛出,由全局 Handler 统一转换为标准 JSON 响应。
- **Model`app/Models`**
- 负责数据表结构映射、关系relations、访问器 / 修改器、自定义查询 Scope 等「数据层」逻辑。
- 避免在模型方法中塞入大量跨业务的流程控制逻辑,复杂业务应下沉到模块中。
- **Module`app/Module`**
- 承载跨控制器 / 跨模型的业务逻辑与独立功能子域,例如:
- 外部服务集成:`AgoraIO/*``ZincSearch/*` 等;
- 通用工具:`Lock.php``TextExtractor.php``Image.php` 等;
- 项目 / 任务 / 对话等领域里的复杂协作逻辑。
- 原则:
- 新增较复杂的业务功能时,优先考虑创建 / 扩展 Module而不是在 Controller 或 Model 中堆砌流程。
- Module 尽量保持单一职责与可复用,命名能直接反映其业务或能力作用。
- **运行环境注意事项LaravelS / Swoole**
- 避免在静态属性、单例、全局变量中存储请求级状态或可变数据,防止请求间数据串联和内存泄漏。
- 不要假设构造函数、服务提供者或 `boot()` 方法会在每个请求重新执行;涉及配置、路由等改动时,通常需要通过 `./cmd php restart` 或容器重启后才能生效。
- 编写长连接、定时任务、WebSocket 等长生命周期逻辑时,优先复用现有模式,并避免长时间阻塞协程 / 事件循环的操作。
- **前端(`resources/assets/js`Vue2 + Vite**
- 结构大致包括:
- `app.js``App.vue`:应用入口与根组件;
- `components/`:通用与业务组件(任务看板、文件预览、聊天等);
- `pages/`:页面级组件(登录、项目、任务视图、消息、报表等);
- `store/`Vuex 全局状态管理;
- `routes.js`:前端路由配置。
- 构建与开发:
- 开发模式:使用 `./cmd dev` 或类似子命令,内部通过 Vite 启动开发服务器。
- 生产构建:使用 `./cmd prod``./cmd build`,内部通过 Vite 产出前端静态资源。
- 与后端接口协作:
- 接口调用默认通过已有的 Vuex 封装发起请求,新增接口时优先扩展集中封装,而不是在组件中直接散落 `axios/fetch`
- **Electron**
- Electron 主要作为桌面入口壳,核心业务逻辑仍在 Web/Vue2 前端与 PHP/Laravel 后端。
- 日常开发与调试优先使用 `./cmd electron ...`;需要构建 App 端资源时使用 `./cmd appbuild`
- 原则:优先保证 Web 端行为正确,再通过 Electron 壳复用 Web 逻辑;桌面专有能力(本地文件、托盘等)需在代码中明确边界。
## 四、在本项目中使用 Graphiti 作为长期记忆
- **角色与 group_id**
- Graphiti 作为本项目的「长期记忆层」,用于持久化:
- 用户偏好Preferences、工作流程 / 习惯Procedures、重要约束Requirements、关键事实 / 关系Facts
- 目标是:跨对话、跨任务保持一致的行为和决策,而不是简单堆积信息。
- 本项目统一使用的 `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、ZincSearch集成方式等。
- 写入建议:
- 默认使用 `source: "text"`,在 `episode_body` 中用简洁结构化自然语言描述背景、类型、范围、具体内容;
- 需要结构化数据时可用 `source: "json"`,保证 `episode_body` 是合法 JSON 字符串;
- 所有写入默认使用 `group_id: "dootask-main"`
- **更新与更正**
- 偏好 / 流程发生变化时,新增一条 episode 说明新约定,并标明这是对旧习惯的更新,后续以最新、最明确的为准;
- 用户要求「忘记」某些记忆时,可通过删除或更正相关 episode / 关系的方式处理;
- 尽量通过新增 episode 记录「更正 / 废弃说明」,而不是直接改写历史事实。
- **在工作中的使用方式**
- 尊重已存偏好:编码风格、回答结构、工具选择等应对齐已知偏好;
- 遵循已有流程:若图谱中已有与当前任务匹配的 Procedure应尽量按步骤执行
- 利用事实:理解系统行为、模块边界、历史决策时优先查已存 Facts减少重新摸索
- 如 Graphiti 与当前代码实际冲突,应以代码实际为准,并视情况新增 episode 更新事实。
- **不要写入 Graphiti 的内容**
- 含敏感信息(密钥、密码、隐私数据等);
- 只与当前一次任务相关、未来不会复用的临时信息(调试日志、一次性命令输出等);
- 体量巨大的原始数据(完整日志、长脚本全文等),应只存摘要和关键结论。
- **最佳实践小结**
- 先查再做:在提出方案或改动架构前,优先查阅 Graphiti 中已有的设计、偏好和约束;
- 能复用就沉淀:只要发现某个偏好 / 流程 / 约束未来会反复用到,就尽快写入 Graphiti而不是只放在当前对话里
- 保持项目内外一致:确保 Graphiti 中的记忆与实际代码长期保持一致,避免「记忆漂移」。
## 五、前端弹窗文案
- 在前端 Vue 代码中调用 `$A.modalXXX``$A.messageXXX``$A.noticeXXX` 时,这些方法内部会统一处理 `$L` 翻译,调用方默认不要再额外包一层 `$L`
- 仅当 `modalXXX` 特殊场景显式传入 `language: false`(关闭内部自动翻译)时,才由调用方在传入前自行决定是否使用 `$L` 处理文案。
## 六、AI 回复风格与语言偏好
- 总体说明与重要总结(尤其是最终回答的 recap 部分),在不影响技术表达准确性的前提下,应优先使用简体中文进行回复。
- 如用户在对话中明确要求使用其他语言(例如英文),则以用户的显式指令为最高优先级。
- 当本次协作的改动已经较为完整且自然形成一个提交单元时,应在最终回答中附带一条或数条推荐的 Git 提交 message方便用户直接复制使用。

File diff suppressed because it is too large Load Diff

View File

@@ -41,7 +41,7 @@ class ApproveController extends AbstractController
}
/**
* @api {get} api/approve/verifyToken 01. 验证APi登录
* @api {get} api/approve/verifyToken 验证APi登录
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -63,7 +63,7 @@ class ApproveController extends AbstractController
}
/**
* @api {post} api/approve/procdef/all 02. 查询流程定义
* @api {post} api/approve/procdef/all 查询流程定义
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -90,7 +90,7 @@ class ApproveController extends AbstractController
}
/**
* @api {get} api/approve/procdef/del 03. 删除流程定义
* @api {get} api/approve/procdef/del 删除流程定义
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -116,7 +116,7 @@ class ApproveController extends AbstractController
}
/**
* @api {post} api/approve/process/start 04. 启动流程(审批中)
* @api {post} api/approve/process/start 启动流程(审批中)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -179,7 +179,7 @@ class ApproveController extends AbstractController
}
/**
* @api {post} api/approve/process/addGlobalComment 05. 添加全局评论
* @api {post} api/approve/process/addGlobalComment 添加全局评论
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -224,7 +224,7 @@ class ApproveController extends AbstractController
}
/**
* @api {post} api/approve/task/complete 06. 审批
* @api {post} api/approve/task/complete 审批
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -304,7 +304,7 @@ class ApproveController extends AbstractController
}
/**
* @api {post} api/approve/task/withdraw 07. 撤回
* @api {post} api/approve/task/withdraw 撤回
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -349,7 +349,7 @@ class ApproveController extends AbstractController
}
/**
* @api {post} api/approve/process/findTask 08. 查询需要我审批的流程(审批中)
* @api {post} api/approve/process/findTask 查询需要我审批的流程(审批中)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -392,7 +392,7 @@ class ApproveController extends AbstractController
}
/**
* @api {post} api/approve/process/startByMyselfAll 09. 查询我启动的流程(全部)
* @api {post} api/approve/process/startByMyselfAll 查询我启动的流程(全部)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -435,7 +435,7 @@ class ApproveController extends AbstractController
}
/**
* @api {post} api/approve/process/startByMyself 10. 查询我启动的流程(审批中)
* @api {post} api/approve/process/startByMyself 查询我启动的流程(审批中)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -473,7 +473,7 @@ class ApproveController extends AbstractController
}
/**
* @api {post} api/approve/process/findProcNotify 11. 查询抄送我的流程(审批中)
* @api {post} api/approve/process/findProcNotify 查询抄送我的流程(审批中)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -517,7 +517,7 @@ class ApproveController extends AbstractController
}
/**
* @api {get} api/approve/identitylink/findParticipant 12. 查询流程实例的参与者(审批中)
* @api {get} api/approve/identitylink/findParticipant 查询流程实例的参与者(审批中)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -552,7 +552,7 @@ class ApproveController extends AbstractController
}
/**
* @api {post} api/approve/procHistory/findTask 13. 查询需要我审批的流程(已结束)
* @api {post} api/approve/procHistory/findTask 查询需要我审批的流程(已结束)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -595,7 +595,7 @@ class ApproveController extends AbstractController
}
/**
* @api {post} api/approve/procHistory/startByMyself 14. 查询我启动的流程(已结束)
* @api {post} api/approve/procHistory/startByMyself 查询我启动的流程(已结束)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -633,7 +633,7 @@ class ApproveController extends AbstractController
}
/**
* @api {post} api/approve/procHistory/findProcNotify 15. 查询抄送我的流程(已结束)
* @api {post} api/approve/procHistory/findProcNotify 查询抄送我的流程(已结束)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -677,7 +677,7 @@ class ApproveController extends AbstractController
}
/**
* @api {get} api/approve/identitylinkHistory/findParticipant 16. 查询流程实例的参与者(已结束)
* @api {get} api/approve/identitylinkHistory/findParticipant 查询流程实例的参与者(已结束)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -712,7 +712,7 @@ class ApproveController extends AbstractController
}
/**
* @api {get} api/approve/process/detail 17. 根据流程ID查询流程详情
* @api {get} api/approve/process/detail 根据流程ID查询流程详情
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -734,7 +734,7 @@ class ApproveController extends AbstractController
}
/**
* @api {post} api/approve/export 18. 导出数据
* @api {post} api/approve/export 导出数据
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -970,7 +970,7 @@ class ApproveController extends AbstractController
}
/**
* @api {get} api/approve/down 19. 下载导出的审批数据
* @api {get} api/approve/down 下载导出的审批数据
*
* @apiVersion 1.0.0
* @apiGroup approve
@@ -1192,7 +1192,7 @@ class ApproveController extends AbstractController
/**
* @api {get} api/approve/user/status 20. 获取用户审批状态
* @api {get} api/approve/user/status 获取用户审批状态
*
* @apiVersion 1.0.0
* @apiGroup approve
@@ -1212,7 +1212,7 @@ class ApproveController extends AbstractController
}
/**
* @api {get} api/approve/process/doto 21. 查询需要我审批的流程数量
* @api {get} api/approve/process/doto 查询需要我审批的流程数量
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\User;
use App\Module\AI;
use App\Module\Apps;
use App\Module\Base;
use Request;
/**
* @apiDefine assistant
*
* 助手
*/
class AssistantController extends AbstractController
{
public function __construct()
{
Apps::isInstalledThrow('ai');
}
/**
* @api {post} api/assistant/auth 生成授权码
*
* @apiDescription 需要token身份生成 AI 流式会话的 stream_key
* @apiVersion 1.0.0
* @apiGroup assistant
* @apiName auth
*
* @apiParam {String} model_type 模型类型
* @apiParam {String} model_name 模型名称
* @apiParam {JSON} context 上下文数组
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* @apiSuccess {String} data.stream_key 流式会话凭证
*/
public function auth()
{
$user = User::auth();
$user->checkChatInformation();
$modelType = trim(Request::input('model_type', ''));
$modelName = trim(Request::input('model_name', ''));
$contextInput = Request::input('context', []);
return AI::createStreamKey($modelType, $modelName, $contextInput);
}
/**
* @api {get} api/assistant/models 获取AI模型
*
* @apiDescription 获取所有AI机器人模型设置
* @apiVersion 1.0.0
* @apiGroup assistant
* @apiName models
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function models()
{
$setting = Base::setting('aibotSetting');
$setting = array_filter($setting, function ($value, $key) {
return str_ends_with($key, '_models') || str_ends_with($key, '_model');
}, ARRAY_FILTER_USE_BOTH);
return Base::retSuccess('success', $setting ?: json_decode('{}'));
}
}

View File

@@ -10,18 +10,18 @@ use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogMsg;
/**
* @apiDefine dialog
* @apiDefine complaint
*
* 投诉
*/
class ComplaintController extends AbstractController
{
/**
* @api {get} api/complaint/lists 01. 获取举报投诉列表
* @api {get} api/complaint/lists 获取举报投诉列表
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiGroup complaint
* @apiName lists
*
* @apiParam {Number} [type] 类型
@@ -33,6 +33,34 @@ class ComplaintController extends AbstractController
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*
* @apiSuccessExample {json} Success-Response-Data:
* {
* "current_page": 1,
* "data": [
* {
* "id": 1,
* "dialog_id": 100,
* "userid": 1,
* "type": 1,
* "reason": "举报原因",
* "imgs": [],
* "status": 0,
* "created_at": "2025-01-01 00:00:00",
* "updated_at": "2025-01-01 00:00:00"
* }
* ],
* "first_page_url": "http://example.com/api/complaint/lists?page=1",
* "from": 1,
* "last_page": 1,
* "last_page_url": "http://example.com/api/complaint/lists?page=1",
* "next_page_url": null,
* "path": "http://example.com/api/complaint/lists",
* "per_page": 50,
* "prev_page_url": null,
* "to": 1,
* "total": 1
* }
*/
public function lists()
{
@@ -56,21 +84,25 @@ class ComplaintController extends AbstractController
}
/**
* @api {get} api/complaint/submit 02. 举报投诉
* @api {post} api/complaint/submit 举报投诉
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiGroup complaint
* @apiName submit
*
* @apiParam {Number} dialog_id 对话ID
* @apiParam {Number} type 类型
* @apiParam {String} reason 原因
* @apiParam {String} imgs 图片
* @apiBody {Number} dialog_id 对话ID
* @apiBody {Number} type 类型
* @apiBody {String} reason 原因
* @apiBody {Object[]} [imgs] 图片数组(可选)
* @apiBody {String} imgs.path 图片路径
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*
* @apiSuccessExample {json} Success-Response-Data:
* []
*/
public function submit()
{
@@ -125,19 +157,22 @@ class ComplaintController extends AbstractController
}
/**
* @api {get} api/complaint/action 03. 举报投诉 - 操作
* @api {post} api/complaint/action 举报投诉 - 操作
*
* @apiDescription 需要token身份
* @apiDescription 需要token身份(管理员权限)
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiGroup complaint
* @apiName action
*
* @apiParam {Number} id ID
* @apiParam {Number} type 类型
* @apiBody {Number} id 投诉ID
* @apiBody {String} type 操作类型handle=已处理delete=删除
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*
* @apiSuccessExample {json} Success-Response-Data:
* []
*/
public function action()
{

View File

@@ -5,12 +5,14 @@ namespace App\Http\Controllers\Api;
use DB;
use Request;
use Redirect;
use Cache;
use Carbon\Carbon;
use App\Tasks\PushTask;
use App\Module\AI;
use App\Module\Doo;
use App\Models\File;
use App\Models\User;
use App\Models\UserBot;
use App\Module\Base;
use App\Module\Timer;
use App\Models\Setting;
@@ -28,9 +30,9 @@ use App\Models\WebSocketDialogMsgRead;
use App\Models\WebSocketDialogMsgTodo;
use App\Models\WebSocketDialogMsgTranslate;
use App\Models\WebSocketDialogSession;
use App\Models\UserRecentItem;
use App\Module\Table\OnlineData;
use App\Module\ZincSearch\ZincSearchDialogMsg;
use App\Tasks\BotReceiveMsgTask;
use Hhxsv5\LaravelS\Swoole\Task\Task;
/**
@@ -41,7 +43,7 @@ use Hhxsv5\LaravelS\Swoole\Task\Task;
class DialogController extends AbstractController
{
/**
* @api {get} api/dialog/lists 01. 对话列表
* @api {get} api/dialog/lists 对话列表
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -71,7 +73,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/beyond 02. 列表外对话
* @api {get} api/dialog/beyond 列表外对话
*
* @apiDescription 需要token身份列表外的未读对话 和 列表外的待办对话
* @apiVersion 1.0.0
@@ -100,7 +102,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/search 03. 搜索会话
* @api {get} api/dialog/search 搜索会话
*
* @apiDescription 根据消息关键词搜索相关会话需要token身份
* @apiVersion 1.0.0
@@ -168,7 +170,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/search/tag 04. 搜索标注会话
* @api {get} api/dialog/search/tag 搜索标注会话
*
* @apiDescription 根据消息关键词搜索相关会话需要token身份
* @apiVersion 1.0.0
@@ -202,7 +204,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/one 05. 获取单个会话信息
* @api {get} api/dialog/one 获取单个会话信息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -228,7 +230,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/user 06. 获取会话成员
* @api {get} api/dialog/user 获取会话成员
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -272,7 +274,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/todo 07. 获取会话待办
* @api {get} api/dialog/todo 获取会话待办
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -302,7 +304,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/top 08. 会话置顶
* @api {get} api/dialog/top 会话置顶
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -332,7 +334,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/hide 09. 会话隐藏
* @api {get} api/dialog/hide 会话隐藏
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -365,7 +367,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/tel 10. 获取对方联系电话
* @api {get} api/dialog/tel 获取对方联系电话
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -414,7 +416,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/open/user 11. 打开会话
* @api {get} api/dialog/open/user 打开会话
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -441,11 +443,45 @@ class DialogController extends AbstractController
return Base::retError('打开会话失败');
}
$data = WebSocketDialog::synthesizeData($dialog->id, $user->userid);
return Base::retSuccess('success', $data);
}
/**
* @api {get} api/dialog/msg/list 12. 获取消息列表
* @api {get} api/dialog/open/event 打开会话事件
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName open__event
*
* @apiParam {Number} dialog_id 对话ID
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function open__event()
{
$user = User::auth();
//
$dialog_id = intval(Request::input('dialog_id'));
//
$dialog = WebSocketDialog::checkDialog($dialog_id);
if (empty($dialog)) {
return Base::retError('打开会话失败');
}
//
Cache::remember("webhook_dialog_open_{$dialog->id}_{$user->userid}", Carbon::now()->addMinute(), function () use ($dialog, $user) {
$dialog->dispatchMemberWebhook(UserBot::WEBHOOK_EVENT_DIALOG_OPEN, $user->userid, $user->userid);
return true;
});
//
return Base::retSuccess('success');
}
/**
* @api {get} api/dialog/msg/list 获取消息列表
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -584,7 +620,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/latest 13. 获取最新消息列表
* @api {get} api/dialog/msg/latest 获取最新消息列表
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -650,7 +686,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/search 14. 搜索消息
* @api {get} api/dialog/msg/search 搜索消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -704,7 +740,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/one 15. 获取单条消息
* @api {get} api/dialog/msg/one 获取单条消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -733,7 +769,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/dot 16. 聊天消息去除点
* @api {get} api/dialog/msg/dot 聊天消息去除点
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -766,7 +802,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/read 17. 已读聊天消息
* @api {get} api/dialog/msg/read 已读聊天消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -837,7 +873,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/unread 18. 获取未读消息数据
* @api {get} api/dialog/msg/unread 获取未读消息数据
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -880,7 +916,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/checked 19. 设置消息checked
* @api {get} api/dialog/msg/checked 设置消息checked
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -946,7 +982,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/stream 20. 通知成员监听消息
* @api {post} api/dialog/msg/stream 通知成员监听消息
*
* @apiDescription 通知指定会员EventSource监听流动消息
* @apiVersion 1.0.0
@@ -990,7 +1026,17 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/sendtext 21. 发送消息
* 使用 AI 助手生成消息
*
* @deprecated 已废弃方法,仅保留路由占位,后续版本中移除
*/
public function msg__ai_generate()
{
Base::checkClientVersion('1.4.35');
}
/**
* @api {post} api/dialog/msg/sendtext 发送消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1070,14 +1116,14 @@ class DialogController extends AbstractController
$text = WebSocketDialogMsg::formatMsg($text, $dialog_id);
}
$strlen = mb_strlen($text);
$noimglen = mb_strlen(preg_replace("/<img[^>]*?>/i", "", $text));
$reallen = mb_strlen(preg_replace("/<img[^>]*?>/i", "", $text));
if ($strlen < 1) {
return Base::retError('消息内容不能为空');
}
if ($noimglen > 200000) {
if ($reallen > 200000) {
return Base::retError('消息内容最大不能超过200000字');
}
if ($noimglen > 5000) {
if ($reallen > 5000) {
// 内容过长转成文件发送
$path = "uploads/chat/" . date("Ym") . "/" . $dialog_id . "/";
Base::makeDir(public_path($path));
@@ -1135,7 +1181,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/sendnotice 22. 发送通知
* @api {post} api/dialog/msg/sendnotice 发送通知
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1188,7 +1234,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/sendtemplate 23. 发送模板消息
* @api {post} api/dialog/msg/sendtemplate 发送模板消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1257,7 +1303,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/sendrecord 24. 发送语音
* @api {post} api/dialog/msg/sendrecord 发送语音
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1305,7 +1351,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/convertrecord 25. 录音转文字
* @api {post} api/dialog/msg/convertrecord 录音转文字
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1358,7 +1404,9 @@ class DialogController extends AbstractController
'prompt' => "将此语音识别为“" . Doo::getLanguages($language) . "”。",
];
}
$result = AI::transcriptions($recordData['file'], $extParams);
$result = AI::transcriptions($recordData['file'], $extParams, [
'accept-language' => Request::header('Accept-Language', 'zh')
]);
if (Base::isError($result)) {
return $result;
}
@@ -1378,7 +1426,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/sendfile 26. 文件上传
* @api {post} api/dialog/msg/sendfile 文件上传
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1410,7 +1458,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/sendfiles 27. 群发文件上传
* @api {post} api/dialog/msg/sendfiles 群发文件上传
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1466,7 +1514,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/sendfileid 28. 通过文件ID发送文件
* @api {get} api/dialog/msg/sendfileid 通过文件ID发送文件
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1506,7 +1554,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/sendtaskid 29. 通过任务ID发送任务
* @api {get} api/dialog/msg/sendtaskid 通过任务ID发送任务
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1545,7 +1593,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/sendanon 30. 发送匿名消息
* @api {post} api/dialog/msg/sendanon 发送匿名消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1601,7 +1649,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/sendbot 31. 发送机器人消息
* @api {post} api/dialog/msg/sendbot 发送机器人消息
*
* @apiDescription 需要token身份通过机器人发送消息给指定用户
* @apiVersion 1.0.0
@@ -1683,7 +1731,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/sendlocation 32. 发送位置消息
* @api {post} api/dialog/msg/sendlocation 发送位置消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1745,7 +1793,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/readlist 33. 获取消息阅读情况
* @api {get} api/dialog/msg/readlist 获取消息阅读情况
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1774,7 +1822,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/detail 34. 消息详情
* @api {get} api/dialog/msg/detail 消息详情
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1792,7 +1840,7 @@ class DialogController extends AbstractController
*/
public function msg__detail()
{
User::auth();
$user =User::auth();
//
$msg_id = intval(Request::input('msg_id'));
$only_update_at = Request::input('only_update_at', 'no');
@@ -1830,11 +1878,21 @@ class DialogController extends AbstractController
}
}
//
if ($dialogMsg->type === 'file') {
UserRecentItem::record(
$user->userid,
UserRecentItem::TYPE_MESSAGE_FILE,
$dialogMsg->id,
UserRecentItem::SOURCE_DIALOG,
$dialogMsg->dialog_id
);
}
return Base::retSuccess('success', $data);
}
/**
* @api {get} api/dialog/msg/download 35. 文件下载
* @api {get} api/dialog/msg/download 文件下载
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1871,7 +1929,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/withdraw 36. 聊天消息撤回
* @api {get} api/dialog/msg/withdraw 聊天消息撤回
*
* @apiDescription 消息撤回限制24小时内需要token身份
* @apiVersion 1.0.0
@@ -1902,7 +1960,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/voice2text 37. 语音消息转文字
* @api {get} api/dialog/msg/voice2text 语音消息转文字
*
* @apiDescription 将语音消息转文字需要token身份
* @apiVersion 1.0.0
@@ -1941,7 +1999,9 @@ class DialogController extends AbstractController
}
WebSocketDialog::checkDialog($msg->dialog_id);
//
$result = AI::transcriptions(public_path($msgData['path']));
$result = AI::transcriptions(public_path($msgData['path']), [], [
'accept-language' => Request::header('Accept-Language', 'zh')
]);
if (Base::isError($result)) {
return $result;
}
@@ -1957,7 +2017,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/translation 38. 翻译消息
* @api {get} api/dialog/msg/translation 翻译消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2026,7 +2086,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/mark 39. 消息标记操作
* @api {get} api/dialog/msg/mark 消息标记操作
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2058,7 +2118,10 @@ class DialogController extends AbstractController
switch ($type) {
case 'read':
// 标记已读
$builder = WebSocketDialogMsgRead::whereDialogId($dialog_id)->whereUserid($user->userid)->whereReadAt(null);
$builder = WebSocketDialogMsgRead::whereDialogId($dialog_id)
->whereUserid($user->userid)
->whereReadAt(null)
->select(['id', 'msg_id']);
if ($after_msg_id > 0) {
$builder->where('msg_id', '>=', $after_msg_id);
}
@@ -2090,7 +2153,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/silence 40. 消息免打扰
* @api {get} api/dialog/msg/silence 消息免打扰
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2153,7 +2216,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/forward 41. 转发消息给
* @api {get} api/dialog/msg/forward 转发消息给
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2194,7 +2257,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/emoji 42. emoji回复
* @api {get} api/dialog/msg/emoji emoji回复
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2244,7 +2307,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/tag 43. 标注/取消标注
* @api {get} api/dialog/msg/tag 标注/取消标注
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2273,7 +2336,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/todo 44. 设待办/取消待办
* @api {get} api/dialog/msg/todo 设待办/取消待办
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2316,7 +2379,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/todolist 45. 获取消息待办情况
* @api {get} api/dialog/msg/todolist 获取消息待办情况
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2346,7 +2409,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/done 46. 完成待办
* @api {get} api/dialog/msg/done 完成待办
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2400,7 +2463,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/color 47. 设置颜色
* @api {get} api/dialog/msg/color 设置颜色
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2441,34 +2504,17 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/webhookmsg2ai 48. 转换为AI对话
* 转换为AI对话
*
* @apiDescription 需要token身份将webhook消息转换为适合AI对话的格式消息用于AI对话
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName msg__webhookmsg2ai
*
* @apiParam {String} msg 消息内容
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* @deprecated 已废弃方法,仅保留路由占位,后续版本中移除
*/
public function msg__webhookmsg2ai()
{
User::auth();
//
$msg = Request::input('msg');
try {
$res = BotReceiveMsgTask::convertMentionForAI($msg);
return Base::retSuccess("success", ['msg' => $res]);
} catch (\Exception $e) {
return Base::retError($e->getMessage());
}
Base::checkClientVersion('1.4.35');
}
/**
* @api {get} api/dialog/group/add 49. 新增群组
* @api {get} api/dialog/group/add 新增群组
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2530,7 +2576,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/group/edit 50. 修改群组
* @api {get} api/dialog/group/edit 修改群组
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2571,8 +2617,9 @@ class DialogController extends AbstractController
$avatar = $avatar ? Base::unFillUrl(is_array($avatar) ? $avatar[0]['path'] : $avatar) : '';
$data['avatar'] = Base::fillUrl($array['avatar'] = $avatar);
}
if (Request::exists('chat_name') && $dialog->group_type === 'user') {
$chatName = trim(Request::input('chat_name'));
$existName = Request::exists('chat_name') || Request::exists('name');
if ($existName && $dialog->group_type === 'user') {
$chatName = trim(Request::input('chat_name') ?: Request::input('name'));
if (mb_strlen($chatName) < 2) {
return Base::retError('群名称至少2个字');
}
@@ -2592,7 +2639,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/group/adduser 51. 添加群成员
* @api {get} api/dialog/group/adduser 添加群成员
*
* @apiDescription 需要token身份
* - 有群主时:只有群主可以邀请
@@ -2628,7 +2675,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/group/deluser 52. 移出(退出)群成员
* @api {get} api/dialog/group/deluser 移出(退出)群成员
*
* @apiDescription 需要token身份
* - 只有群主、邀请人可以踢人
@@ -2672,7 +2719,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/group/transfer 53. 转让群组
* @api {get} api/dialog/group/transfer 转让群组
*
* @apiDescription 需要token身份
* - 只有群主且是个人类型群可以解散
@@ -2721,7 +2768,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/group/disband 54. 解散群组
* @api {get} api/dialog/group/disband 解散群组
*
* @apiDescription 需要token身份
* - 只有群主且是个人类型群可以解散
@@ -2749,7 +2796,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/group/searchuser 55. 搜索个人群(仅限管理员)
* @api {get} api/dialog/group/searchuser 搜索个人群(仅限管理员)
*
* @apiDescription 需要token身份用于创建部门搜索个人群组
* @apiVersion 1.0.0
@@ -2778,7 +2825,84 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/okr/add 56. 创建OKR评论会话
* @api {get} api/dialog/common/list 共同群组群聊
*
* @apiDescription 需要token身份按置顶时间、用户在群组中的最后活跃时间倒序排列
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName common__list
*
* @apiParam {Number} [target_userid] 目标用户ID和谁的共同群组不传则获取自己所有群组
* @apiParam {Number} [page] 当前页数默认为1
* @apiParam {Number} [pagesize] 每页显示条数默认为20最大100
* @apiParam {String} [only_count] 是否只返回数量,传入 'yes' 则只返回数量不返回列表
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*
* - 当 only_count=yes 时:
* @apiSuccess {Number} data.total 群组数量
*
* - 当获取列表时,返回 Laravel 标准分页格式:
* @apiSuccess {Array} data.data 群组列表数据
* @apiSuccess {Number} data.current_page 当前页数
* @apiSuccess {Number} data.per_page 每页显示条数
* @apiSuccess {Number} data.total 总数量
* @apiSuccess {String} data.first_page_url 第一页链接
* @apiSuccess {String} data.last_page_url 最后页链接
* @apiSuccess {String} data.next_page_url 下一页链接
* @apiSuccess {String} data.prev_page_url 上一页链接
*/
public function common__list()
{
$user = User::auth();
//
$target_userid = intval(Request::input('target_userid'));
$only_count = trim(Request::input('only_count')) === 'yes';
// 参考getDialogList的查询模式
$builder = DB::table('web_socket_dialog_users as u')
->select(['d.*', 'u.top_at', 'u.last_at', 'u.mark_unread', 'u.silence', 'u.hide', 'u.color', 'u.updated_at as user_at'])
->join('web_socket_dialogs as d', 'u.dialog_id', '=', 'd.id')
->where('u.userid', $user->userid)
->where('d.type', 'group')
->where('d.group_type', 'user')
->whereNull('d.deleted_at');
if ($target_userid) {
// 获取与目标用户的共同群组
$builder->whereExists(function($query) use ($target_userid) {
$query->select(DB::raw(1))
->from('web_socket_dialog_users as du2')
->whereColumn('du2.dialog_id', 'd.id')
->where('du2.userid', $target_userid);
});
}
if ($only_count) {
// 只返回数量
return Base::retSuccess('success', [
'total' => $builder->count()
]);
}
// 返回分页列表参考getDialogList的排序逻辑
$list = $builder
->orderByDesc('u.top_at')
->orderByDesc('u.last_at')
->paginate(Base::getPaginate(100, 20));
// 处理分页数据与getDialogList保持一致的处理方式
$list->transform(function ($item) use ($user) {
return WebSocketDialog::synthesizeData($item, $user->userid);
});
return Base::retSuccess('success', $list);
}
/**
* @api {post} api/dialog/okr/add 创建OKR评论会话
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2817,7 +2941,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/okr/push 57. 推送OKR相关信息
* @api {post} api/dialog/okr/push 推送OKR相关信息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2853,7 +2977,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/wordchain 58. 发送接龙消息
* @api {post} api/dialog/msg/wordchain 发送接龙消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2880,11 +3004,11 @@ class DialogController extends AbstractController
//
WebSocketDialog::checkDialog($dialog_id);
$strlen = mb_strlen($text);
$noimglen = mb_strlen(preg_replace("/<img[^>]*?>/i", "", $text));
$reallen = mb_strlen(preg_replace("/<img[^>]*?>/i", "", $text));
if ($strlen < 1 || empty($list)) {
return Base::retError('内容不能为空');
}
if ($noimglen > 200000) {
if ($reallen > 200000) {
return Base::retError('内容最大不能超过200000字');
}
//
@@ -2939,7 +3063,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/vote 59. 发起投票
* @api {post} api/dialog/msg/vote 发起投票
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -3029,11 +3153,11 @@ class DialogController extends AbstractController
});
} else {
$strlen = mb_strlen($text);
$noimglen = mb_strlen(preg_replace("/<img[^>]*?>/i", "", $text));
$reallen = mb_strlen(preg_replace("/<img[^>]*?>/i", "", $text));
if ($strlen < 1) {
return Base::retError('内容不能为空');
}
if ($noimglen > 200000) {
if ($reallen > 200000) {
return Base::retError('内容最大不能超过200000字');
}
$msgData = [
@@ -3055,7 +3179,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/top 60. 置顶/取消置顶
* @api {get} api/dialog/msg/top 置顶/取消置顶
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -3115,7 +3239,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/topinfo 61. 获取置顶消息
* @api {get} api/dialog/msg/topinfo 获取置顶消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -3142,56 +3266,17 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/applied 62. 标记消息已应用
* 标记消息已应用
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName msg__applied
*
* @apiParam {Number} index 索引
* @apiParam {Number} msg_id 消息ID
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* @deprecated 已废弃方法,仅保留路由占位,后续版本中移除
*/
public function msg__applied()
{
User::auth();
//
$msg_id = intval(Request::input('msg_id'));
$index = intval(Request::input('index'));
//
$msg = WebSocketDialogMsg::whereId($msg_id)->first();
if (empty($msg)) {
return Base::retError("消息不存在或已被删除");
}
WebSocketDialog::checkDialog($msg->dialog_id);
//
$originalMsg = $msg->getRawOriginal('msg');
$pattern = '/:::\s*(create-task-list|create-subtask-list)(?:\s+(\S+))?/';
$count = -1;
$updatedMsg = preg_replace_callback($pattern, function($matches) use (&$count, $index) {
$count++;
if ($count === $index || ($index === 0 && $count === 1)) {
return "::: {$matches[1]} applied";
}
return $matches[0];
}, $originalMsg);
if ($count === -1) {
return Base::retError("未找到可应用的规则");
}
$msg->msg = $updatedMsg;
$msg->save();
//
return Base::retSuccess("success");
Base::checkClientVersion('1.4.35');
}
/**
* @api {get} api/dialog/sticker/search 63. 搜索在线表情
* @api {get} api/dialog/sticker/search 搜索在线表情
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -3215,7 +3300,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/config 64. 获取会话配置
* @api {get} api/dialog/config 获取会话配置
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -3251,7 +3336,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/config/save 65. 保存会话配置
* @api {post} api/dialog/config/save 保存会话配置
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -3297,7 +3382,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/session/create 66. AI-开启新会话
* @api {get} api/dialog/session/create AI-开启新会话
*
* @apiDescription 需要token身份仅限与AI用户会话
* @apiVersion 1.0.0
@@ -3328,6 +3413,8 @@ class DialogController extends AbstractController
return Base::retError('当前对话不支持');
}
//
$previousSessionId = intval($dialog->session_id);
//
$session = WebSocketDialogSession::whereDialogId($dialog->id)->whereTitle('')->first();
if ($session) {
$dialog->session_id = $session->id;
@@ -3342,11 +3429,13 @@ class DialogController extends AbstractController
$dialog->session_id = $session->id;
$dialog->save();
//
WebSocketDialogMsgRead::markSessionMessagesAsRead($dialog->id, $previousSessionId);
//
return Base::retSuccess('success', $session);
}
/**
* @api {get} api/dialog/session/list 67. AI-获取会话列表
* @api {get} api/dialog/session/list AI-获取会话列表
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -3386,7 +3475,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/session/open 68. AI-打开会话
* @api {get} api/dialog/session/open AI-打开会话
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -3417,4 +3506,51 @@ class DialogController extends AbstractController
//
return Base::retSuccess('success', $session);
}
/**
* @api {post} api/dialog/session/rename AI-重命名会话
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName session_rename
*
* @apiParam {Number} session_id 会话ID
* @apiParam {String} title 会话名称
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function session__rename()
{
User::auth();
//
$session_id = intval(Request::input('session_id'));
$title = trim((string)Request::input('title'));
//
if ($session_id <= 0) {
return Base::retError('参数错误');
}
if ($title === '') {
return Base::retError('请输入会话名称');
}
//
$session = WebSocketDialogSession::whereId($session_id)->first();
if (empty($session)) {
return Base::retError('会话不存在或已被删除');
}
//
$dialog = WebSocketDialog::checkDialog($session->dialog_id);
if (!$dialog->isSessionDialog()) {
return Base::retError('当前对话不支持');
}
//
$session->title = Base::cutStr($title, 100);
$session->save();
$session->refresh();
Cache::forever('dialog_session_title_' . $session->id, true);
//
return Base::retSuccess('重命名成功', $session);
}
}

View File

@@ -11,6 +11,7 @@ use App\Models\FileContent;
use App\Models\FileLink;
use App\Models\FileUser;
use App\Models\User;
use App\Models\UserRecentItem;
use App\Module\Base;
use App\Module\Down;
use App\Module\Timer;
@@ -30,7 +31,7 @@ use ZipArchive;
class FileController extends AbstractController
{
/**
* @api {get} api/file/lists 01. 获取文件列表
* @api {get} api/file/lists 获取文件列表
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -53,7 +54,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/one 02. 获取单条数据
* @api {get} api/file/one 获取单条数据
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -63,6 +64,9 @@ class FileController extends AbstractController
* @apiParam {Number|String} id
* - Number 文件ID需要登录
* - String 链接码(不需要登录,用于预览)
* @apiParam {String} [with_url] 是否返回文件访问URL
* - no: 不返回(默认)
* - yes: 返回content_url字段
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@@ -71,11 +75,12 @@ class FileController extends AbstractController
public function one()
{
$id = Request::input('id');
$with_url = Request::input('with_url', 'no');
//
$permission = 0;
if (Base::isNumber($id)) {
$user = User::auth();
$file = File::permissionFind(intval($id), $user, 0, $permission);
$file = File::permissionFind(intval($id), $user, $with_url === 'yes' ? 1 : 0, $permission);
} elseif ($id) {
$fileLink = FileLink::whereCode($id)->first();
$file = $fileLink?->file;
@@ -87,6 +92,12 @@ class FileController extends AbstractController
}
return Base::retError($msg, $data);
}
// 如果文件不允许游客访问,则需要登录
if (!$file->guest_access) {
User::auth();
}
$fileLink->increment("num");
} else {
return Base::retError('参数错误');
@@ -94,11 +105,17 @@ class FileController extends AbstractController
//
$array = $file->toArray();
$array['permission'] = $permission;
// 如果请求返回文件URL
if ($with_url === 'yes') {
$array['content_url'] = FileContent::getFileUrl($file->id);
}
return Base::retSuccess('success', $array);
}
/**
* @api {get} api/file/search 03. 搜索文件列表
* @api {get} api/file/search 搜索文件列表
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -175,7 +192,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/add 04. 添加、修改文件(夹)
* @api {get} api/file/add 添加、修改文件(夹)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -284,7 +301,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/copy 05. 复制文件(夹)
* @api {get} api/file/copy 复制文件(夹)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -345,7 +362,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/move 06. 移动文件(夹)
* @api {get} api/file/move 移动文件(夹)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -420,7 +437,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/remove 07. 删除文件(夹)
* @api {get} api/file/remove 删除文件(夹)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -459,7 +476,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/content 08. 获取文件内容
* @api {get} api/file/content 获取文件内容
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -523,6 +540,16 @@ class FileController extends AbstractController
$builder->whereId($history_id);
}
$content = $builder->orderByDesc('id')->first();
if (isset($user)) {
UserRecentItem::record(
$user->userid,
UserRecentItem::TYPE_FILE,
$file->id,
UserRecentItem::SOURCE_FILESYSTEM,
intval($file->pid)
);
}
if ($down === 'preview') {
return Redirect::to(FileContent::formatPreview($file, $content?->content));
}
@@ -530,7 +557,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/content/save 09. 保存文件内容
* @api {get} api/file/content/save 保存文件内容
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -625,9 +652,9 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/office/token 10. 获取token
* @api {get} api/file/office/token 获取token
*
* @apiDescription 需要token身份
* @apiDescription 用于生成office在线编辑的token
* @apiVersion 1.0.0
* @apiGroup file
* @apiName office__token
@@ -640,8 +667,6 @@ class FileController extends AbstractController
*/
public function office__token()
{
User::auth();
//
File::isNeedInstallApp('office');
//
$config = Request::input('config');
@@ -652,7 +677,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/content/office 11. 保存文件内容office
* @api {get} api/file/content/office 保存文件内容office
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -708,7 +733,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/content/upload 12. 保存文件内容(上传文件)
* @api {get} api/file/content/upload 保存文件内容(上传文件)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -736,7 +761,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/content/history 13. 获取内容历史
* @api {get} api/file/content/history 获取内容历史
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -768,7 +793,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/content/restore 14. 恢复文件历史
* @api {get} api/file/content/restore 恢复文件历史
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -810,7 +835,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/share 15. 获取共享信息
* @api {get} api/file/share 获取共享信息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -846,7 +871,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/share/update 16. 设置共享
* @api {get} api/file/share/update 设置共享
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -936,7 +961,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/share/out 17. 退出共享
* @api {get} api/file/share/out 退出共享
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -970,7 +995,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/link 18. 获取链接
* @api {get} api/file/link 获取链接
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -981,6 +1006,9 @@ class FileController extends AbstractController
* @apiParam {String} refresh 刷新链接
* - no: 只获取(默认)
* - yes: 刷新链接,之前的将失效
* @apiParam {String} guest_access 是否允许游客访问
* - no: 不允许(默认)
* - yes: 允许游客访问
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@@ -992,15 +1020,22 @@ class FileController extends AbstractController
//
$id = intval(Request::input('id'));
$refresh = Request::input('refresh', 'no');
$guestAccess = Request::input('guest_access', 'no');
//
$file = File::permissionFind($id, $user);
// 更新文件的游客访问权限
$file->guest_access = $guestAccess === 'yes' ? 1 : 0;
$file->save();
$fileLink = $file->getShareLink($user->userid, $refresh == 'yes');
$fileLink['guest_access'] = $file->guest_access;
//
return Base::retSuccess('success', $fileLink);
}
/**
* @api {get} api/file/download/pack 19. 打包文件
* @api {get} api/file/download/pack 打包文件
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@ use App\Exceptions\ApiException;
use App\Models\AbstractModel;
use App\Models\ProjectTask;
use App\Models\Report;
use App\Models\ReportAnalysis;
use App\Models\ReportLink;
use App\Models\ReportReceive;
use App\Models\User;
@@ -28,7 +29,7 @@ use Illuminate\Support\Facades\Validator;
class ReportController extends AbstractController
{
/**
* @api {get} api/report/my 01. 我发送的汇报
* @api {get} api/report/my 我发送的汇报
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -75,7 +76,7 @@ class ReportController extends AbstractController
}
/**
* @api {get} api/report/receive 02. 我接收的汇报
* @api {get} api/report/receive 我接收的汇报
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -143,7 +144,7 @@ class ReportController extends AbstractController
}
/**
* @api {get} api/report/store 03. 保存并发送工作汇报
* @api {get} api/report/store 保存并发送工作汇报
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -282,7 +283,7 @@ class ReportController extends AbstractController
}
/**
* @api {get} api/report/template 04. 生成汇报模板
* @api {get} api/report/template 生成汇报模板
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -326,6 +327,13 @@ class ReportController extends AbstractController
$start_time->startOfWeek();
$end_time = Carbon::instance($start_time)->endOfWeek();
}
// 周报时预计算下一周期时间范围(下周)
$next_start_time = null;
$next_end_time = null;
if ($type === Report::WEEKLY) {
$next_start_time = Carbon::instance($start_time)->copy()->addWeek();
$next_end_time = Carbon::instance($end_time)->copy()->addWeek();
}
// 生成唯一标识
$sign = Report::generateSign($type, 0, Carbon::instance($start_time));
@@ -361,6 +369,10 @@ class ReportController extends AbstractController
->get();
if ($complete_task->isNotEmpty()) {
foreach ($complete_task as $task) {
// 排除取消态任务:不将已取消任务计入“已完成工作”
if (ProjectTask::isCanceledFlowName($task->flow_item_name)) {
continue;
}
$complete_at = Carbon::parse($task->complete_at);
$remark = $type == Report::WEEKLY ? ('<div style="text-align:center">[' . Doo::translate('周' . ['日', '一', '二', '三', '四', '五', '六'][$complete_at->dayOfWeek]) . ']</div>') : '&nbsp;';
$completeDatas[] = [
@@ -376,18 +388,7 @@ class ReportController extends AbstractController
// 未完成的任务
$unfinishedDatas = [];
$unfinished_task = ProjectTask::query()
->join("projects", "projects.id", "=", "project_tasks.project_id")
->whereNull("projects.archived_at")
->whereNull("project_tasks.complete_at")
->whereNotNull("project_tasks.start_at")
->where("project_tasks.end_at", "<", $end_time->toDateTimeString())
->whereHas("taskUser", function ($query) use ($user) {
$query->where("userid", $user->userid);
})
->select("project_tasks.*")
->orderByDesc("project_tasks.id")
->get();
$unfinished_task = ProjectTask::buildUnfinishedTaskQuery($user->userid, $start_time, $end_time, true)->get();
if ($unfinished_task->isNotEmpty()) {
foreach ($unfinished_task as $task) {
empty($task->end_at) || $end_at = Carbon::parse($task->end_at);
@@ -407,8 +408,10 @@ class ReportController extends AbstractController
if ($type === Report::WEEKLY) {
$title = $user->nickname . "的周报[" . $start_time->format("m/d") . "-" . $end_time->format("m/d") . "]";
$title .= "[" . $start_time->month . "月第" . $start_time->weekOfMonth . "周]";
$unfinishedTitle = '本周未完成的工作';
} else {
$title = $user->nickname . "的日报[" . $start_time->format("Y/m/d") . "]";
$unfinishedTitle = '今日未完成的工作';
}
$title = Doo::translate($title);
@@ -421,22 +424,44 @@ class ReportController extends AbstractController
])->render();
$contents[] = '<p>&nbsp;</p>';
$contents[] = '<h2>' . Doo::translate('未完成的工作') . '</h2>';
$contents[] = '<h2>' . Doo::translate($unfinishedTitle) . '</h2>';
$contents[] = view('report', [
'labels' => $labels,
'datas' => $unfinishedDatas,
])->render();
if ($type === Report::WEEKLY) {
// 下周拟定计划:基于下周时间范围预生成候选任务
$nextPlanDatas = [];
if ($next_start_time && $next_end_time) {
$next_tasks = ProjectTask::buildUnfinishedTaskQuery($user->userid, $next_start_time, $next_end_time, false)->get();
if ($next_tasks->isNotEmpty()) {
foreach ($next_tasks as $task) {
$planTime = '-';
if ($task->start_at || $task->end_at) {
$startText = $task->start_at ? Carbon::parse($task->start_at)->format('Y-m-d H:i') : '';
$endText = $task->end_at ? Carbon::parse($task->end_at)->format('Y-m-d H:i') : '';
$planTime = trim($startText . ($endText ? (' ~ ' . $endText) : ''));
}
$nextPlanDatas[] = [
'[' . $task->project->name . '] ' . $task->name,
$planTime,
$task->taskUser->where("owner", 1)->map(function ($item) {
return User::userid2nickname($item->userid);
})->implode(", "),
];
}
}
}
$contents[] = '<p>&nbsp;</p>';
$contents[] = "<h2>" . Doo::translate("下周拟定计划") . "[" . $start_time->addWeek()->format("m/d") . "-" . $end_time->addWeek()->format("m/d") . "]</h2>";
$contents[] = "<h2>" . Doo::translate("下周拟定计划") . "[" . $next_start_time->format("m/d") . "-" . $next_end_time->format("m/d") . "]</h2>";
$contents[] = view('report', [
'labels' => [
Doo::translate('计划描述'),
Doo::translate('计划时间'),
Doo::translate('负责人'),
],
'datas' => [],
'datas' => $nextPlanDatas,
])->render();
}
@@ -454,7 +479,7 @@ class ReportController extends AbstractController
}
/**
* @api {get} api/report/detail 05. 报告详情
* @api {get} api/report/detail 报告详情
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -501,11 +526,113 @@ class ReportController extends AbstractController
$one->report_link = $link;
$link->increment("num");
}
$analysis = ReportAnalysis::query()
->whereRid($one->id)
->whereUserid($user->userid)
->first();
if ($analysis) {
$updatedAt = $analysis->updated_at ? $analysis->updated_at->toDateTimeString() : null;
$one->setAttribute('ai_analysis', [
'id' => $analysis->id,
'text' => $analysis->analysis_text,
'model' => $analysis->model,
'updated_at' => $updatedAt,
]);
} else {
$one->setAttribute('ai_analysis', null);
}
return Base::retSuccess("success", $one);
}
/**
* @api {get} api/report/mark 06. 标记已读/未读
* @api {post} api/report/analysave 保存工作汇报 AI 分析
*
* @apiDescription 需要token身份仅支持报告提交人或接收人保存分析
* @apiVersion 1.0.0
* @apiGroup report
* @apiName analysave
*
* @apiParam {Number} id 报告ID
* @apiParam {String} text 分析内容Markdown
* @apiParam {String} [model] 分析使用的模型标识(可选)
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* @apiSuccess {Number} data.id 分析记录ID
* @apiSuccess {String} data.text 分析内容Markdown
* @apiSuccess {String} data.updated_at 最近更新时间
*/
public function analysave(): array
{
$user = User::auth();
$id = intval(Request::input("id"));
if ($id <= 0) {
return Base::retError("缺少ID参数");
}
$text = trim((string)Request::input('text', ''));
if ($text === '') {
return Base::retError("分析内容不能为空");
}
$model = trim((string)Request::input('model', ''));
$report = Report::getOne($id);
if (!$this->userCanAccessReport($report, $user)) {
return Base::retError("无权访问该工作汇报");
}
$analysis = ReportAnalysis::query()
->whereRid($report->id)
->whereUserid($user->userid)
->first();
if (!$analysis) {
$analysis = ReportAnalysis::fillInstance([
'rid' => $report->id,
'userid' => $user->userid,
]);
}
$viewerRole = $user->profession ?: (is_array($user->identity) && !empty($user->identity) ? implode('/', $user->identity) : null);
$focusMeta = null;
$focus = Request::input('focus');
if (is_array($focus)) {
$focusMeta = array_filter(array_map('trim', $focus));
} elseif (is_string($focus) && trim($focus) !== '') {
$focusMeta = [trim($focus)];
}
$meta = array_filter([
'viewer_role' => $viewerRole,
'viewer_name' => $user->nickname ?? null,
'focus' => $focusMeta,
], function ($value) {
if (is_array($value)) {
return !empty($value);
}
return $value !== null && $value !== '';
});
$analysis->updateInstance([
'model' => $model,
'analysis_text' => $text,
'meta' => $meta,
]);
$analysis->save();
$analysis->refresh();
return Base::retSuccess("success", [
'id' => $analysis->id,
'text' => $analysis->analysis_text,
'model' => $analysis->model,
'updated_at' => $analysis->updated_at ? $analysis->updated_at->toDateTimeString() : null,
]);
}
/**
* @api {get} api/report/mark 标记已读/未读
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -548,7 +675,7 @@ class ReportController extends AbstractController
}
/**
* @api {get} api/report/share 07. 分享报告到消息
* @api {get} api/report/share 分享报告到消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -610,7 +737,7 @@ class ReportController extends AbstractController
}
/**
* @api {get} api/report/last_submitter 08. 获取最后一次提交的接收人
* @api {get} api/report/last_submitter 获取最后一次提交的接收人
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -628,7 +755,7 @@ class ReportController extends AbstractController
}
/**
* @api {get} api/report/unread 09. 获取未读
* @api {get} api/report/unread 获取未读
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -653,7 +780,7 @@ class ReportController extends AbstractController
}
/**
* @api {get} api/report/read 10. 标记汇报已读,可批量
* @api {get} api/report/read 标记汇报已读,可批量
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -691,4 +818,22 @@ class ReportController extends AbstractController
}
return Base::retSuccess("success", $data);
}
/**
* 判断当前用户是否有权限查看/分析指定工作汇报
* @param Report $report
* @param User $user
* @return bool
*/
protected function userCanAccessReport(Report $report, User $user): bool
{
if ($report->userid === $user->userid) {
return true;
}
return ReportReceive::query()
->whereRid($report->id)
->whereUserid($user->userid)
->exists();
}
}

View File

@@ -35,7 +35,7 @@ class SystemController extends AbstractController
{
/**
* @api {get} api/system/setting 01. 获取设置、保存设置
* @api {get} api/system/setting 获取设置、保存设置
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -44,7 +44,7 @@ class SystemController extends AbstractController
* @apiParam {String} type
* - get: 获取(默认)
* - all: 获取所有(需要管理员权限)
* - save: 保存设置(参数:['reg', 'reg_identity', 'reg_invite', 'temp_account_alias', 'login_code', 'password_policy', 'project_invite', 'chat_information', 'anon_message', 'voice2text', 'translation', 'convert_video', 'compress_video', 'e2e_message', 'auto_archived', 'archived_day', 'task_visible', 'task_default_time', 'all_group_mute', 'all_group_autoin', 'user_private_chat_mute', 'user_group_chat_mute', 'system_alias', 'system_welcome', 'image_compress', 'image_quality', 'image_save_local']
* - save: 保存设置(参数:['reg', 'reg_identity', 'reg_invite', 'temp_account_alias', 'login_code', 'password_policy', 'project_invite', 'chat_information', 'anon_message', 'convert_video', 'compress_video', 'e2e_message', 'auto_archived', 'archived_day', 'task_visible', 'task_default_time', 'task_user_limit', 'all_group_mute', 'all_group_autoin', 'user_private_chat_mute', 'user_group_chat_mute', 'system_alias', 'system_welcome', 'image_compress', 'image_quality', 'image_save_local']
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@@ -71,8 +71,6 @@ class SystemController extends AbstractController
'project_invite',
'chat_information',
'anon_message',
'voice2text',
'translation',
'convert_video',
'compress_video',
'e2e_message',
@@ -82,6 +80,7 @@ class SystemController extends AbstractController
'archived_day',
'task_visible',
'task_default_time',
'task_user_limit',
'all_group_mute',
'all_group_autoin',
'user_private_chat_mute',
@@ -106,12 +105,6 @@ class SystemController extends AbstractController
return Base::retError('自动归档时间不可大于100天');
}
}
if ($all['voice2text'] == 'open' && !Setting::AIOpen()) {
return Base::retError('开启语音转文字功能需要先设置 AI 助理。');
}
if ($all['translation'] == 'open' && !Setting::AIOpen()) {
return Base::retError('开启翻译功能需要先设置 AI 助理。');
}
if ($all['system_alias'] == env('APP_NAME')) {
$all['system_alias'] = '';
}
@@ -138,8 +131,6 @@ class SystemController extends AbstractController
$setting['project_invite'] = $setting['project_invite'] ?: 'open';
$setting['chat_information'] = $setting['chat_information'] ?: 'optional';
$setting['anon_message'] = $setting['anon_message'] ?: 'open';
$setting['voice2text'] = $setting['voice2text'] ?: 'close';
$setting['translation'] = $setting['translation'] ?: 'close';
$setting['convert_video'] = $setting['convert_video'] ?: 'close';
$setting['compress_video'] = $setting['compress_video'] ?: 'close';
$setting['e2e_message'] = $setting['e2e_message'] ?: 'close';
@@ -158,11 +149,11 @@ class SystemController extends AbstractController
$setting['server_timezone'] = config('app.timezone');
$setting['server_version'] = Base::getVersion();
//
return Base::retSuccess('success', $setting ?: json_decode('{}'));
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
}
/**
* @api {get} api/system/setting/email 02. 获取邮箱设置、保存邮箱设置(限管理员)
* @api {get} api/system/setting/email 获取邮箱设置、保存邮箱设置(限管理员)
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -228,11 +219,11 @@ class SystemController extends AbstractController
$setting = array_intersect_key($setting, array_flip(['reg_verify']));
}
//
return Base::retSuccess('success', $setting ?: json_decode('{}'));
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
}
/**
* @api {get} api/system/setting/meeting 03. 获取会议设置、保存会议设置(限管理员)
* @api {get} api/system/setting/meeting 获取会议设置、保存会议设置(限管理员)
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -282,53 +273,21 @@ class SystemController extends AbstractController
$setting['api_secret'] = substr($setting['api_secret'], 0, 4) . str_repeat('*', strlen($setting['api_secret']) - 8) . substr($setting['api_secret'], -4);
}
//
return Base::retSuccess('success', $setting ?: json_decode('{}'));
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
}
/**
* @api {get} api/system/setting/ai 04. AI助手设置限管理员
* AI助手设置限管理员
*
* @apiVersion 1.0.0
* @apiGroup system
* @apiName setting__ai
*
* @apiParam {String} type
* - get: 获取(默认)
* - save: 保存设置(参数:['ai_provider', 'ai_api_key', 'ai_api_url', 'ai_proxy']
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* @deprecated 已废弃方法,仅保留路由占位,后续版本中移除
*/
public function setting__ai()
{
User::auth('admin');
//
$type = trim(Request::input('type'));
if ($type == 'save') {
if (env("SYSTEM_SETTING") == 'disabled') {
return Base::retError('当前环境禁止修改');
}
$all = Base::newTrim(Request::input());
foreach ($all as $key => $value) {
if (!in_array($key, [
'ai_provider',
'ai_api_key',
'ai_api_url',
'ai_proxy',
])) {
unset($all[$key]);
}
}
$setting = Base::setting('aiSetting', Base::newTrim($all));
} else {
$setting = Base::setting('aiSetting');
}
//
return Base::retSuccess('success', $setting ?: json_decode('{}'));
Base::checkClientVersion('1.4.35');
}
/**
* @api {get} api/system/setting/aibot 05. 获取会议设置、保存AI机器人设置限管理员
* @api {get} api/system/setting/aibot 获取AI设置、保存AI机器人设置限管理员
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -382,70 +341,31 @@ class SystemController extends AbstractController
}
}
//
return Base::retSuccess('success', $setting ?: json_decode('{}'));
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
}
/**
* @api {get} api/system/setting/aibot_models 06. 获取AI模型
* 获取AI模型
*
* @apiDescription 获取所有AI机器人模型设置
* @apiVersion 1.0.0
* @apiGroup system
* @apiName aibot_models
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* @deprecated 已废弃方法,仅保留路由占位,后续版本中移除
*/
public function setting__aibot_models()
{
$setting = Base::setting('aibotSetting');
$setting = array_filter($setting, function($value, $key) {
return str_ends_with($key, '_models') || str_ends_with($key, '_model');
}, ARRAY_FILTER_USE_BOTH);
return Base::retSuccess('success', $setting ?: json_decode('{}'));
Base::checkClientVersion('1.4.35');
}
/**
* @api {get} api/system/setting/aibot_defmodels 07. 获取AI默认模型
* 获取AI默认模型
*
* @apiDescription 获取AI机器人默认模型
* @apiVersion 1.0.0
* @apiGroup system
* @apiName setting__aibot_defmodels
*
* @apiParam {String} type AI类型
* @apiParam {String} [base_url] 基础URL仅 type=ollama 时有效)
* @apiParam {String} [key] Key仅 type=ollama 时有效)
* @apiParam {String} [agency] 使用代理(仅 type=ollama 时有效)
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* @deprecated 已废弃方法,仅保留路由占位,后续版本中移除
*/
public function setting__aibot_defmodels()
{
$type = trim(Request::input('type'));
if ($type == 'ollama') {
$baseUrl = trim(Request::input('base_url'));
$key = trim(Request::input('key'));
$agency = trim(Request::input('agency'));
if (empty($baseUrl)) {
return Base::retError('请先填写 Base URL');
}
return AI::ollamaModels($baseUrl, $key, $agency);
}
$models = Setting::AIBotDefaultModels($type);
if (empty($models)) {
return Base::retError('未找到默认模型');
}
return Base::retSuccess('success', [
'models' => $models
]);
Base::checkClientVersion('1.4.35');
}
/**
* @api {get} api/system/setting/checkin 08. 获取签到设置、保存签到设置(限管理员)
* @api {get} api/system/setting/checkin 获取签到设置、保存签到设置(限管理员)
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -572,11 +492,11 @@ class SystemController extends AbstractController
$setting['cmd'] = base64_encode($setting['cmd']);
}
//
return Base::retSuccess('success', $setting ?: json_decode('{}'));
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
}
/**
* @api {get} api/system/setting/apppush 09. 获取APP推送设置、保存APP推送设置限管理员
* @api {get} api/system/setting/apppush 获取APP推送设置、保存APP推送设置限管理员
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -617,11 +537,11 @@ class SystemController extends AbstractController
//
$setting['push'] = $setting['push'] ?: 'close';
//
return Base::retSuccess('success', $setting ?: json_decode('{}'));
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
}
/**
* @api {get} api/system/setting/thirdaccess 10. 第三方帐号(限管理员)
* @api {get} api/system/setting/thirdaccess 第三方帐号(限管理员)
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -687,11 +607,11 @@ class SystemController extends AbstractController
$setting['ldap_port'] = intval($setting['ldap_port']) ?: 389;
$setting['ldap_sync_local'] = $setting['ldap_sync_local'] ?: 'close';
//
return Base::retSuccess('success', $setting ?: json_decode('{}'));
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
}
/**
* @api {get} api/system/setting/file 11. 文件设置(限管理员)
* @api {get} api/system/setting/file 文件设置(限管理员)
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -727,11 +647,11 @@ class SystemController extends AbstractController
$setting = Base::setting('fileSetting');
}
//
return Base::retSuccess('success', $setting ?: json_decode('{}'));
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
}
/**
* @api {get} api/system/demo 12. 获取演示帐号
* @api {get} api/system/demo 获取演示帐号
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -755,7 +675,7 @@ class SystemController extends AbstractController
}
/**
* @api {post} api/system/priority 13. 任务优先级
* @api {post} api/system/priority 任务优先级
*
* @apiDescription 获取任务优先级、保存任务优先级
* @apiVersion 1.0.0
@@ -800,11 +720,52 @@ class SystemController extends AbstractController
$setting = Base::setting('priority');
}
//
return Base::retSuccess('success', $setting);
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting);
}
/**
* @api {post} api/system/column/template 14. 创建项目模板
* @api {post} api/system/microapp_menu 自定义应用菜单
*
* @apiDescription 获取或保存自定义微应用菜单,仅管理员可配置
* @apiVersion 1.0.0
* @apiGroup system
* @apiName microapp_menu
*
* @apiParam {String} type
* - get: 获取(默认)
* - save: 保存(限管理员)
* @apiParam {Array} list 菜单列表,格式:[{id,name,version,menu_items}]
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function microapp_menu()
{
$type = trim(Request::input('type'));
$user = User::auth();
if ($type == 'save') {
User::auth('admin');
$list = Request::input('list');
if (empty($list) || !is_array($list)) {
$list = [];
}
$apps = Setting::normalizeCustomMicroApps($list);
$setting = Base::setting('microapp_menu', $apps);
$setting = Setting::formatCustomMicroAppsForResponse($setting);
} else {
$setting = Base::setting('microapp_menu');
if (!is_array($setting)) {
$setting = [];
}
$setting = Setting::filterCustomMicroAppsForUser($setting, $user);
$setting = Setting::formatCustomMicroAppsForResponse($setting);
}
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting);
}
/**
* @api {post} api/system/column/template 创建项目模板
*
* @apiDescription 获取创建项目模板、保存创建项目模板
* @apiVersion 1.0.0
@@ -847,11 +808,11 @@ class SystemController extends AbstractController
$setting = Base::setting('columnTemplate');
}
//
return Base::retSuccess('success', $setting);
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting);
}
/**
* @api {post} api/system/license 15. License
* @api {post} api/system/license License
*
* @apiDescription 获取License信息、保存License限管理员
* @apiVersion 1.0.0
@@ -917,11 +878,11 @@ class SystemController extends AbstractController
];
}
//
return Base::retSuccess('success', $data);
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $data ?: json_decode('{}'));
}
/**
* @api {get} api/system/get/info 16. 获取终端详细信息
* @api {get} api/system/get/info 获取终端详细信息
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -948,7 +909,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/get/ip 17. 获取IP地址
* @api {get} api/system/get/ip 获取IP地址
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -963,7 +924,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/get/cnip 18. 是否中国IP地址
* @api {get} api/system/get/cnip 是否中国IP地址
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -980,7 +941,7 @@ class SystemController extends AbstractController
}
/**
* @api {post} api/system/imgupload 19. 上传图片
* @api {post} api/system/imgupload 上传图片
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1046,7 +1007,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/get/imgview 20. 浏览图片空间
* @api {get} api/system/get/imgview 浏览图片空间
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1143,7 +1104,7 @@ class SystemController extends AbstractController
}
/**
* @api {post} api/system/fileupload 21. 上传文件
* @api {post} api/system/fileupload 上传文件
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1187,7 +1148,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/get/updatelog 22. 获取更新日志
* @api {get} api/system/get/updatelog 获取更新日志
*
* @apiDescription 获取更新日志
* @apiVersion 1.0.0
@@ -1230,7 +1191,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/email/check 23. 邮件发送测试(限管理员)
* @api {get} api/system/email/check 邮件发送测试(限管理员)
*
* @apiDescription 测试配置邮箱是否能发送邮件
* @apiVersion 1.0.0
@@ -1276,7 +1237,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/checkin/export 24. 导出签到数据(限管理员)
* @api {get} api/system/checkin/export 导出签到数据(限管理员)
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -1498,7 +1459,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/checkin/down 25. 下载导出的签到数据
* @api {get} api/system/checkin/down 下载导出的签到数据
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -1519,7 +1480,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/version 26. 获取版本号
* @api {get} api/system/version 获取版本号
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -1565,7 +1526,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/prefetch 27. 预加载的资源
* @api {get} api/system/prefetch 预加载的资源
*
* @apiVersion 1.0.0
* @apiGroup system

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,378 @@
# apiDoc 参数标签说明(完整速查)
apiDoc 使用内联注释为 RESTful API 自动生成文档。
以下为所有官方支持的参数与其说明。
---
## @api
**定义 API 方法的基本信息**
```js
@api {method} path title
```
- **method**:请求方法,如 `GET``POST``PUT``DELETE`
- **path**:请求路径,例如 `/user/:id`
- **title**:简短标题(显示在文档中)
📘 示例:
```js
@api {get} /user/:id Get user info
```
---
## @apiBody
**定义请求体参数**
```js
@apiBody [{type}] [field=defaultValue] [description]
```
- `{type}` 参数类型(如 String, Number, Object, String[]
- `[field]` 可选字段(方括号表示可选)
- `=defaultValue` 默认值
- `description` 参数说明
📘 示例:
```js
@apiBody {String} lastname Mandatory Lastname.
@apiBody {Object} [address] Optional address object.
@apiBody {String} [address[city]] Optional city.
```
---
## @apiDefine
**定义可复用的文档块**
```js
@apiDefine name [title] [description]
```
- `name`:唯一标识
- `title`:简短标题
- `description`:多行描述
📘 示例:
```js
@apiDefine MyError
@apiError UserNotFound The <code>id</code> of the User was not found.
```
---
## @apiDeprecated
**标记接口为弃用状态**
```js
@apiDeprecated [text]
```
- `text`:提示文本,可带链接到新方法
📘 示例:
```js
@apiDeprecated use now (#User:GetDetails)
```
---
## @apiDescription
**描述接口详细说明**
```js
@apiDescription text
```
📘 示例:
```js
@apiDescription This is the Description.
It is multiline capable.
```
---
## @apiError
**定义错误返回参数**
```js
@apiError [(group)] [{type}] field [description]
```
📘 示例:
```js
@apiError UserNotFound The id of the User was not found.
```
---
## @apiErrorExample
**定义错误返回示例**
```js
@apiErrorExample [{type}] [title]
example
```
📘 示例:
```js
@apiErrorExample {json} Error-Response:
HTTP/1.1 404 Not Found
{ "error": "UserNotFound" }
```
---
## @apiExample
**定义接口使用示例**
```js
@apiExample [{type}] title
example
```
📘 示例:
```js
@apiExample {curl} Example usage:
curl -i http://localhost/user/4711
```
---
## @apiGroup
**定义所属分组**
```js
@apiGroup name
```
📘 示例:
```js
@apiGroup User
```
---
## @apiHeader
**定义请求头参数**
```js
@apiHeader [(group)] [{type}] [field=defaultValue] [description]
```
📘 示例:
```js
@apiHeader {String} access-key Users unique access-key.
```
---
## @apiHeaderExample
**定义请求头示例**
```js
@apiHeaderExample [{type}] [title]
example
```
📘 示例:
```js
@apiHeaderExample {json} Header-Example:
{
"Accept-Encoding": "gzip, deflate"
}
```
---
## @apiIgnore
**忽略当前文档块**
```js
@apiIgnore [hint]
```
📘 示例:
```js
@apiIgnore Not finished method
```
---
## @apiName
**定义接口唯一名称**
```js
@apiName name
```
📘 示例:
```js
@apiName GetUser
```
---
## @apiParam
**定义请求参数**
```js
@apiParam [(group)] [{type}] [field=defaultValue] [description]
```
📘 示例:
```js
@apiParam {Number} id Users unique ID.
@apiParam {String} [firstname] Optional firstname.
@apiParam {String} country="DE" Mandatory with default.
```
---
## @apiParamExample
**定义参数请求示例**
```js
@apiParamExample [{type}] [title]
example
```
📘 示例:
```js
@apiParamExample {json} Request-Example:
{ "id": 4711 }
```
---
## @apiPermission
**定义权限要求**
```js
@apiPermission name
```
📘 示例:
```js
@apiPermission admin
```
---
## @apiPrivate
**标记接口为私有(可过滤)**
```js
@apiPrivate
```
---
## @apiQuery
**定义查询参数(?query**
```js
@apiQuery [{type}] [field=defaultValue] [description]
```
📘 示例:
```js
@apiQuery {Number} id Users unique ID.
@apiQuery {String} [sort="asc"] Sort order.
```
---
## @apiSampleRequest
**定义接口测试请求 URL**
```js
@apiSampleRequest url
```
📘 示例:
```js
@apiSampleRequest http://test.github.com/some_path/
```
---
## @apiSuccess
**定义成功返回参数**
```js
@apiSuccess [(group)] [{type}] field [description]
```
📘 示例:
```js
@apiSuccess {String} firstname Firstname of the User.
@apiSuccess {String} lastname Lastname of the User.
```
---
## @apiSuccessExample
**定义成功返回示例**
```js
@apiSuccessExample [{type}] [title]
example
```
📘 示例:
```js
@apiSuccessExample {json} Success-Response:
HTTP/1.1 200 OK
{ "firstname": "John", "lastname": "Doe" }
```
---
## @apiUse
**引用定义块(@apiDefine**
```js
@apiUse name
```
📘 示例:
```js
@apiDefine MySuccess
@apiSuccess {String} firstname User firstname.
@apiUse MySuccess
```
---
## @apiVersion
**定义接口版本**
```js
@apiVersion version
```
📘 示例:
```js
@apiVersion 1.6.2
```
---
# 附录:常用标签速查表
| 标签 | 作用 | 示例 |
|------|------|------|
| `@api` | 定义接口 | `@api {get} /user/:id` |
| `@apiName` | 唯一名称 | `@apiName GetUser` |
| `@apiGroup` | 所属分组 | `@apiGroup User` |
| `@apiParam` | 请求参数 | `@apiParam {Number} id Users unique ID.` |
| `@apiBody` | 请求体参数 | `@apiBody {String} name Username.` |
| `@apiQuery` | 查询参数 | `@apiQuery {String} keyword Search term.` |
| `@apiHeader` | Header 参数 | `@apiHeader {String} token Auth token.` |
| `@apiSuccess` | 成功返回字段 | `@apiSuccess {String} name Username.` |
| `@apiError` | 错误返回字段 | `@apiError NotFound User not found.` |
| `@apiVersion` | 版本号 | `@apiVersion 1.0.0` |

View File

@@ -1,89 +1,137 @@
<?php
/**
* 给apidoc项目增加顺序编号
* 给apidoc项目增加顺序编号 / 支持恢复
*/
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
$path = dirname(__FILE__). '/';
$lists = scandir($path);
//
foreach ($lists AS $item) {
$fillPath = $path . $item;
if (str_ends_with($fillPath, 'Controller.php')) {
$content = file_get_contents($fillPath);
preg_match_all("/\* @api \{(.+?)\} (.*?)\n/i", $content, $matchs);
$i = 1;
foreach ($matchs[2] AS $key=>$text) {
if (in_array(strtolower($matchs[1][$key]), array('get', 'post'))) {
$expl = explode(" ", __sRemove($text));
$end = $expl[1];
if ($expl[2]) {
$end = '';
foreach ($expl AS $k=>$v) { if ($k >= 2) { $end.= " ".$v; } }
}
$newtext = "* @api {".$matchs[1][$key]."} ".$expl[0]." ".__zeroFill($i, 2).". ".trim($end);
$content = str_replace("* @api {".$matchs[1][$key]."} ".$text, $newtext, $content);
$i++;
//
echo $newtext;
echo "\r\n";
}
}
if ($i > 1) {
file_put_contents($fillPath, $content);
}
}
}
echo "Success \n";
const NUMBER_WIDTH = 2;
/** ************************************************************** */
/** ************************************************************** */
/** ************************************************************** */
$isRestore = isset($argv[1]) && strtolower($argv[1]) === 'restore';
/**
* 替换所有空格
* @param $str
* @return mixed
*/
function __sRemove($str) {
$str = str_replace(" ", " ", $str);
if (__strExists($str, " ")) {
return __sRemove($str);
}
return $str;
$basePath = dirname(__FILE__) . '/';
$controllerFiles = glob($basePath . '*Controller.php');
if (!$controllerFiles) {
echo "No Controller.php files found\n";
exit(0);
}
foreach ($controllerFiles as $filePath) {
$original = file_get_contents($filePath);
[$updated, $linesChanged] = processFile($original, $isRestore);
if (count($linesChanged) === 0) {
continue;
}
file_put_contents($filePath, $updated);
foreach ($linesChanged as $line) {
echo $line . "\n";
}
}
echo $isRestore ? "Restore Success \n" : "Success \n";
/**
* 是否包含字符
* @param $string
* @param $find
* @return bool
* 处理单个文件内容
*
* @param string $content
* @param bool $restore
* @return array{string, array<int, string>}
*/
function __strExists($string, $find)
function processFile(string $content, bool $restore): array
{
return str_contains($string, $find);
$lineChanges = [];
$counter = 1;
$pattern = '/\* @api \{([^\}]+)\}\s+([^\s]+)([^\r\n]*)(\r?\n)/';
$updated = preg_replace_callback(
$pattern,
function (array $matches) use ($restore, &$counter, &$lineChanges) {
$method = trim($matches[1]);
if (!in_array(strtolower($method), ['get', 'post'], true)) {
return $matches[0];
}
$endpoint = trim($matches[2]);
$suffix = normalizeDescription(stripExistingNumbering($matches[3]));
if (!$restore) {
$numberedSuffix = formatNumber($counter) . '.';
if ($suffix !== '') {
$numberedSuffix .= ' ' . $suffix;
}
$counter++;
} else {
$numberedSuffix = $suffix;
}
$newLine = renderAnnotation($method, $endpoint, $numberedSuffix);
if ($newLine !== rtrim($matches[0], "\r\n")) {
$lineChanges[] = $newLine;
}
return $newLine . $matches[4];
},
$content
);
if ($updated === null) {
return [$content, []];
}
return [$updated, $lineChanges];
}
/**
* @param string $str 补零
* @param int $length
* @param int $after
* @return bool|string
* 生成格式化后的注释行
*/
function __zeroFill($str, $length = 0, $after = 1) {
if (strlen($str) >= $length) {
return $str;
function renderAnnotation(string $method, string $endpoint, string $suffix = ''): string
{
$line = "* @api {" . $method . "} " . $endpoint;
if ($suffix !== '') {
if ($suffix[0] !== ' ') {
$line .= ' ';
}
$line .= $suffix;
}
$_str = '';
for ($i = 0; $i < $length; $i++) {
$_str .= '0';
}
if ($after) {
$_ret = substr($_str . $str, $length * -1);
} else {
$_ret = substr($str . $_str, 0, $length);
}
return $_ret;
return $line;
}
/**
* 移除已有编号部分
*/
function stripExistingNumbering(string $text): string
{
$trimmed = ltrim($text);
$pattern = '/^\d+\.\s*/';
return preg_replace($pattern, '', $trimmed) ?? $trimmed;
}
/**
* 压缩多余空格
*/
function normalizeDescription(string $text): string
{
$text = trim($text);
if ($text === '') {
return '';
}
return preg_replace('/\s+/', ' ', $text) ?? $text;
}
/**
* 生成固定宽度的数字
*/
function formatNumber(int $number): string
{
return str_pad((string) $number, NUMBER_WIDTH, '0', STR_PAD_LEFT);
}

View File

@@ -61,6 +61,10 @@ class IndexController extends InvokeController
$array = Base::json2array(file_get_contents($hotFile));
$style = null;
$script = preg_replace("/^(\/\/(.*?))(:\d+)?\//i", "$1:" . $array['APP_DEV_PORT'] . "/", asset_main("resources/assets/js/app.js"));
$proxyUri = Base::liveEnv('VSCODE_PROXY_URI');
if (is_string($proxyUri) && preg_match('/^https?:\/\//i', $proxyUri)) {
$script = preg_replace('/^(https?:\/\/|\/\/)[^\/]+/', rtrim($proxyUri, '/'), $script, 1);
}
} else {
$array = Base::json2array(file_get_contents($manifestFile));
$style = asset_main($array['resources/assets/js/app.js']['css'][0]);
@@ -254,6 +258,7 @@ class IndexController extends InvokeController
Task::deliver(new DeleteTmpTask('file'));
Task::deliver(new DeleteTmpTask('tmp_file', 24));
Task::deliver(new DeleteTmpTask('user_device', 24));
Task::deliver(new DeleteTmpTask('umeng_log', 24 * 3));
// 删除机器人消息
Task::deliver(new DeleteBotMsgTask());
// 周期任务

View File

@@ -4,8 +4,10 @@ namespace App\Http\Middleware;
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
use App\Module\Base;
use App\Module\Doo;
use App\Services\RequestContext;
use Cache;
use Closure;
class WebApi
@@ -29,6 +31,12 @@ class WebApi
// 加载Doo类
Doo::load();
// 记录 PC 端活跃时间
$userid = Doo::userId();
if ($userid > 0 && Base::isPc()) {
Cache::put("user_pc_active:{$userid}", time(), 60);
}
// 解密请求内容
$encrypt = Doo::pgpParseStr($request->header('encrypt'));
if ($request->isMethod('post')) {

View File

@@ -24,6 +24,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property int|null $size 大小(B)
* @property int|null $userid 拥有者ID
* @property int|null $share 是否共享
* @property int|null $guest_access 是否允许游客访问
* @property int|null $pshare 所属分享ID
* @property int|null $created_id 创建者
* @property \Illuminate\Support\Carbon|null $created_at
@@ -44,6 +45,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @method static \Illuminate\Database\Eloquent\Builder|File whereCreatedId($value)
* @method static \Illuminate\Database\Eloquent\Builder|File whereDeletedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|File whereExt($value)
* @method static \Illuminate\Database\Eloquent\Builder|File whereGuestAccess($value)
* @method static \Illuminate\Database\Eloquent\Builder|File whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|File whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder|File wherePid($value)

View File

@@ -152,6 +152,23 @@ class FileContent extends AbstractModel
return Base::retSuccess('success', [ 'content' => $content ]);
}
/**
* 获取文件访问URL
* @param int $fileId 文件ID
* @return string|null 返回完整的文件URL如果文件无内容则返回null
*/
public static function getFileUrl($fileId)
{
$content = self::whereFid($fileId)->orderByDesc('id')->first();
if ($content) {
$contentData = Base::json2array($content->content ?: []);
if (!empty($contentData['url'])) {
return Base::fillUrl($contentData['url']);
}
}
return null;
}
/**
* 获取文件内容
* @param $id

View File

@@ -129,6 +129,7 @@ class Project extends AbstractModel
'projects.*',
'project_users.owner',
'project_users.top_at',
'project_users.sort',
])
->leftJoin('project_users', function ($leftJoin) use ($userid) {
$leftJoin
@@ -153,6 +154,7 @@ class Project extends AbstractModel
'projects.*',
'project_users.owner',
'project_users.top_at',
'project_users.sort',
])
->join('project_users', 'projects.id', '=', 'project_users.project_id')
->where('project_users.userid', $userid);

View File

@@ -31,6 +31,7 @@ use App\Module\Base;
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereColor($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereColumnid($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereFlowId($value)
@@ -39,7 +40,6 @@ use App\Module\Base;
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereProjectId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereSort($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereStatus($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereColor($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereTurns($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereUserids($value)

View File

@@ -10,6 +10,7 @@ namespace App\Models;
* @property string $name 标签名称
* @property string|null $desc 标签描述
* @property string|null $color 颜色
* @property int $sort 排序
* @property int $userid 创建人
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
@@ -29,6 +30,7 @@ namespace App\Models;
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag whereProjectId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag whereSort($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag whereUserid($value)
* @mixin \Eloquent
@@ -49,6 +51,7 @@ class ProjectTag extends AbstractModel
'name',
'desc',
'color',
'sort',
'userid'
];

View File

@@ -396,6 +396,7 @@ class ProjectTask extends AbstractModel
$userid = User::userid();
$visibility = $data['visibility_appoint'] ?? $data['visibility'];
$visibility_userids = $data['visibility_appointor'] ?: [];
$taskUserLimit = intval(Base::settingFind('system', 'task_user_limit'));
//
if (ProjectTask::whereProjectId($project_id)
->whereNull('project_tasks.complete_at')
@@ -455,8 +456,8 @@ class ProjectTask extends AbstractModel
if (ProjectTask::authData($uid)
->whereNull('project_tasks.complete_at')
->whereNull('project_tasks.archived_at')
->count() > 500) {
throw new ApiException(User::userid2nickname($uid) . '负责或参与的未完成任务最多不能超过500个');
->count() > $taskUserLimit) {
throw new ApiException(User::userid2nickname($uid) . '负责或参与的未完成任务最多不能超过' . $taskUserLimit . '个');
}
$tmpArray[] = $uid;
}
@@ -1143,9 +1144,14 @@ class ProjectTask extends AbstractModel
*/
public function copyTask()
{
return AbstractModel::transaction(function() {
// 复制任务
$task = $this->replicate();
$source = $this->fresh(['content', 'taskFile', 'taskUser']);
if (!$source) {
throw new ApiException('任务不存在');
}
return AbstractModel::transaction(function () use ($source) {
// 复制任务(使用最新数据,避免复制临时字段)
$task = $source->replicate();
$task->dialog_id = 0;
$task->archived_at = null;
$task->archived_userid = 0;
@@ -1154,21 +1160,21 @@ class ProjectTask extends AbstractModel
$task->created_at = Carbon::now();
$task->save();
// 复制任务内容
if ($this->content) {
$tmp = $this->content->replicate();
if ($source->content) {
$tmp = $source->content->replicate();
$tmp->task_id = $task->id;
$tmp->created_at = Carbon::now();
$tmp->save();
}
// 复制任务附件
foreach ($this->taskFile as $taskFile) {
foreach ($source->taskFile as $taskFile) {
$tmp = $taskFile->replicate();
$tmp->task_id = $task->id;
$tmp->created_at = Carbon::now();
$tmp->save();
}
// 复制任务成员
foreach ($this->taskUser as $taskUser) {
foreach ($source->taskUser as $taskUser) {
$tmp = $taskUser->replicate();
$tmp->task_id = $task->id;
$tmp->task_pid = $task->id;
@@ -1555,8 +1561,9 @@ class ProjectTask extends AbstractModel
* @param string $action
* @param array|self $data 发送内容,默认为[id, parent_id, project_id, column_id, dialog_id]
* @param array $userid 指定会员,默认为项目所有成员
* @param bool $ignoreSelf 是否忽略当前连接
*/
public function pushMsg($action, $data = null, $userid = null)
public function pushMsg($action, $data = null, $userid = null, $ignoreSelf = true)
{
if (!$this->project) {
return;
@@ -1568,77 +1575,91 @@ class ProjectTask extends AbstractModel
'project_id' => $this->project_id,
'column_id' => $this->column_id,
'dialog_id' => $this->dialog_id,
'visibility' => $this->visibility,
];
} elseif ($data instanceof self) {
$data = $data->toArray();
}
//
// 获取接收会员
if ($userid === null) {
$userids = $this->project->relationUserids();
} else {
$userids = is_array($userid) ? $userid : [$userid];
}
//
$array = [];
if (Arr::exists($data, 'owner') || Arr::exists($data, 'assist')) {
$taskUser = ProjectTaskUser::select(['userid', 'owner'])->whereTaskId($data['id'])->get();
// 负责人
$owners = $taskUser->where('owner', 1)->pluck('userid')->toArray();
$owners = array_intersect($userids, $owners);
if ($owners) {
$array[] = [
'userid' => array_values($owners),
'data' => array_merge($data, [
'owner' => 1,
'assist' => 1,
])
];
}
// 协助人
$assists = $taskUser->where('owner', 0)->pluck('userid')->toArray();
$assists = array_intersect($userids, $assists);
if ($assists) {
$array[] = [
'userid' => array_values($assists),
'data' => array_merge($data, [
'owner' => 0,
'assist' => 1,
])
];
}
// 其他人
switch ($data['visibility']) {
case 1:
// 项目人员,除了负责人、协助人项目其他人
$userids = array_diff($userids, $owners, $assists);
break;
case 2:
// 任务人员,除了负责人、协助人
$userids = [];
break;
case 3:
// 指定成员
$specifys = ProjectTaskVisibilityUser::select(['userid'])->whereTaskId($data['id'])->pluck('userid')->toArray();
$userids = array_diff($specifys, $owners, $assists);
break;
default:
$userids = [];
break;
}
if ($userids) {
$array[] = [
'userid' => array_values($userids),
'data' => array_merge($data, [
'owner' => 0,
'assist' => 0,
])
];
}
$userids = array_values(array_unique(array_map('intval', $userids)));
if (empty($userids)) {
return;
}
//
// 按可见性分组推送
$taskUser = ProjectTaskUser::select(['userid', 'owner'])->whereTaskId($data['id'])->get();
$ownerList = $taskUser->where('owner', 1)->pluck('userid')->toArray();
$assistList = $taskUser->where('owner', 0)->pluck('userid')->toArray();
$ownerUsers = array_values(array_intersect($userids, $ownerList));
$assistUsers = array_values(array_diff(array_intersect($userids, $assistList), $ownerUsers));
$array = [];
// 负责人
if ($ownerUsers) {
$array[] = [
'userid' => $ownerUsers,
'data' => array_merge($data, [
'owner' => 1,
'assist' => 0,
])
];
}
// 协助人
if ($assistUsers) {
$array[] = [
'userid' => $assistUsers,
'data' => array_merge($data, [
'owner' => 0,
'assist' => 1,
])
];
}
// 其他人
$otherUsers = [];
switch (intval($data['visibility'])) {
case 1:
// 项目人员:除了负责人、协助人项目其他人
$otherUsers = array_diff($userids, $ownerUsers, $assistUsers);
break;
case 2:
// 任务人员:除了负责人、协助人
// $otherUsers = [];
break;
case 3:
// 指定成员
$specifys = ProjectTaskVisibilityUser::select(['userid'])->whereTaskId($data['id'])->pluck('userid')->toArray();
$otherUsers = array_diff(array_intersect($userids, $specifys), $ownerUsers, $assistUsers);
break;
}
if ($otherUsers) {
$array[] = [
'userid' => array_values($otherUsers),
'data' => array_merge($data, [
'owner' => 0,
'assist' => 0,
])
];
}
if (empty($array)) {
return;
}
// 推送
foreach ($array as $item) {
$params = [
'ignoreFd' => Request::header('fd'),
'ignoreFd' => $ignoreSelf ? Request::header('fd') : null,
'userid' => $item['userid'],
'msg' => [
'type' => 'projectTask',
@@ -1928,66 +1949,6 @@ class ProjectTask extends AbstractModel
return true;
}
/**
* 生成AI上下文
* @return array
*/
public function AIContext()
{
$contexts = [];
if ($this->archived_at) {
$contexts[] = "任务状态:已归档";
$contexts[] = "归档时间:" . $this->archived_at;
} elseif ($this->complete_at) {
$contexts[] = "任务状态:已完成";
$contexts[] = "完成时间:" . $this->complete_at;
} elseif ($this->end_at && Carbon::parse($this->end_at)->lt(Carbon::now())) {
$contexts[] = "任务状态:已过期";
$contexts[] = "任务截止时间:" . $this->end_at;
} else {
$contexts[] = "任务状态:进行中";
if ($this->start_at) {
$contexts[] = "任务开始时间:" . $this->start_at;
}
if ($this->end_at) {
$contexts[] = "任务截止时间:" . $this->end_at;
}
}
$contexts[] = "当前系统时间:" . Carbon::now()->toDateTimeString();
if ($this->content) {
$taskDesc = $this->content?->getContentInfo();
if ($taskDesc) {
$descContent = Base::cutStr(Base::html2markdown($taskDesc['content'], ['strip_tags' => true]), 2000);
$contexts[] = <<<EOF
任务描述:
```md
{$descContent}
```
EOF;
}
}
$subTask = ProjectTask::select(['id', 'name', 'complete_at', 'end_at'])->whereParentId($this->id)->get();
if ($subTask->isNotEmpty()) {
$subTaskContent = $subTask->map(function($item) {
if ($item->complete_at) {
$status = " (已完成)";
} elseif ($item->end_at && Carbon::parse($item->end_at)->lt(Carbon::now())) {
$status = " (已过期)";
} else {
$status = " (进行中)";
}
return " - {$item->name} {$status}";
})->join("\n");
if ($subTaskContent) {
$contexts[] = <<<EOF
子任务列表:
{$subTaskContent}
EOF;
}
}
return $contexts;
}
/**
* 获取任务
* @param $task_id
@@ -2049,4 +2010,64 @@ class ProjectTask extends AbstractModel
//
return $task;
}
/**
* 构建指定周期内的未完成任务查询(用于周报/日报等)
* @param int $userid
* @param Carbon $start_time
* @param Carbon $end_time
* @param bool $includeUpdatedForNoPlan 无计划时间任务是否按周期内更新时间一并纳入
* @return \Illuminate\Database\Eloquent\Builder
*/
public static function buildUnfinishedTaskQuery(int $userid, Carbon $start_time, Carbon $end_time, bool $includeUpdatedForNoPlan = true)
{
return self::query()
->join("projects", "projects.id", "=", "project_tasks.project_id")
->whereNull("projects.archived_at")
->whereNull("project_tasks.complete_at")
->whereHas("taskUser", function ($query) use ($userid) {
$query->where("userid", $userid);
})
->where(function ($query) use ($start_time, $end_time, $includeUpdatedForNoPlan) {
// 1) 有计划时间:计划时间与给定周期 [start_time, end_time] 有交集
$query->where(function ($q1) use ($start_time, $end_time) {
$q1->whereNotNull('project_tasks.start_at')
->whereNotNull('project_tasks.end_at')
->where(function ($q2) use ($start_time, $end_time) {
$q2->whereBetween('project_tasks.start_at', [$start_time->toDateTimeString(), $end_time->toDateTimeString()])
->orWhereBetween('project_tasks.end_at', [$start_time->toDateTimeString(), $end_time->toDateTimeString()])
->orWhere(function ($q3) use ($start_time, $end_time) {
$q3->where('project_tasks.start_at', '<=', $start_time->toDateTimeString())
->where('project_tasks.end_at', '>=', $end_time->toDateTimeString());
});
});
});
// 2) 无计划时间
$query->orWhere(function ($q1) use ($start_time, $end_time, $includeUpdatedForNoPlan) {
$q1->whereNull('project_tasks.start_at')
->whereNull('project_tasks.end_at')
->where(function ($q2) use ($start_time, $end_time, $includeUpdatedForNoPlan) {
$q2->whereBetween('project_tasks.created_at', [$start_time->toDateTimeString(), $end_time->toDateTimeString()]);
if ($includeUpdatedForNoPlan) {
$q2->orWhereBetween('project_tasks.updated_at', [$start_time->toDateTimeString(), $end_time->toDateTimeString()]);
}
});
});
})
->select("project_tasks.*")
->orderByDesc("project_tasks.id");
}
/**
* 判断工作流名称是否为取消态(多语言)
* @param string|null $flowItemName
* @return bool
*/
public static function isCanceledFlowName(?string $flowItemName): bool
{
if (empty($flowItemName)) {
return false;
}
return preg_match('/已取消|Cancelled|취소됨|キャンセル済み|Abgebrochen|Annulé|Dibatalkan|Отменено/', $flowItemName) === 1;
}
}

View File

@@ -0,0 +1,154 @@
<?php
namespace App\Models;
use App\Module\Base;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\ProjectTaskRelation
*
* @property int $id
* @property int $task_id 任务ID
* @property int $related_task_id 关联任务ID
* @property string $direction 关系方向: mention/mentioned_by
* @property int|null $dialog_id 来源会话ID
* @property int|null $msg_id 来源消息ID
* @property int|null $userid 提及人
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\ProjectTask|null $relatedTask
* @property-read \App\Models\ProjectTask|null $task
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereDialogId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereDirection($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereMsgId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereRelatedTaskId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereTaskId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereUserid($value)
* @mixin \Eloquent
*/
class ProjectTaskRelation extends AbstractModel
{
public const DIRECTION_MENTION = 'mention';
public const DIRECTION_MENTIONED_BY = 'mentioned_by';
protected $fillable = [
'task_id',
'related_task_id',
'direction',
'dialog_id',
'msg_id',
'userid',
];
public function task(): BelongsTo
{
return $this->belongsTo(ProjectTask::class, 'task_id');
}
public function relatedTask(): BelongsTo
{
return $this->belongsTo(ProjectTask::class, 'related_task_id');
}
public static function recordMentionsFromMessage(WebSocketDialogMsg $msg): void
{
if ($msg->type !== 'text') {
return;
}
$payload = $msg->msg;
if (!is_array($payload)) {
$payload = Base::json2array($msg->getRawOriginal('msg'));
}
$text = $payload['text'] ?? '';
if (!$text || !preg_match_all('/<span class="mention task" data-id="(\d+)">#?(.*?)<\/span>/i', $text, $matches)) {
return;
}
$targetIds = array_values(array_unique(array_filter(array_map('intval', $matches[1] ?? []))));
if (empty($targetIds)) {
return;
}
$sourceTasks = ProjectTask::with('project')->whereDialogId($msg->dialog_id)->get();
if ($sourceTasks->isEmpty()) {
return;
}
$targetTasks = ProjectTask::with('project')->whereIn('id', $targetIds)->get()->keyBy('id');
if ($targetTasks->isEmpty()) {
return;
}
$pushTasks = [];
foreach ($sourceTasks as $sourceTask) {
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,
]
);
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

@@ -6,10 +6,8 @@ namespace App\Models;
* App\Models\ProjectTaskTag
*
* @property int $id
* @property int|null $tag_id
* @property int|null $project_id 项目ID
* @property int|null $task_id 任务ID
* @property int|null $userid 用户ID创建者
* @property string|null $name 标题
* @property string|null $color 颜色
* @property \Illuminate\Support\Carbon|null $created_at
@@ -28,10 +26,8 @@ namespace App\Models;
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTag whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTag whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTag whereProjectId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTag whereTagId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTag whereTaskId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTag whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTag whereUserid($value)
* @mixin \Eloquent
*/
class ProjectTaskTag extends AbstractModel

View File

@@ -12,6 +12,7 @@ use App\Module\Base;
* @property int|null $userid 成员ID
* @property int|null $owner 是否负责人
* @property \Illuminate\Support\Carbon|null $top_at 置顶时间
* @property int|null $sort 排序(ASC)
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\Project|null $project
@@ -28,6 +29,7 @@ use App\Module\Base;
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereOwner($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereProjectId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereSort($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereTopAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereUserid($value)

View File

@@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use JetBrains\PhpStorm\Pure;
/**
@@ -78,6 +79,16 @@ class Report extends AbstractModel
->withPivot("receive_at", "read");
}
public function aiAnalyses(): HasMany
{
return $this->hasMany(ReportAnalysis::class, 'rid');
}
public function aiAnalysis(): HasOne
{
return $this->hasOne(ReportAnalysis::class, 'rid');
}
public function sendUser()
{
return $this->hasOne(User::class, "userid", "userid");

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ReportAnalysis extends AbstractModel
{
protected $table = 'report_ai_analyses';
protected $fillable = [
'rid',
'userid',
'model',
'analysis_text',
'meta',
];
protected $casts = [
'meta' => 'array',
];
public function report(): BelongsTo
{
return $this->belongsTo(Report::class, 'rid');
}
}

View File

@@ -6,6 +6,7 @@ use App\Exceptions\ApiException;
use App\Module\Base;
use App\Module\Doo;
use App\Module\Timer;
use App\Module\AI;
use Carbon\Carbon;
/**
@@ -54,6 +55,7 @@ class Setting extends AbstractModel
$value['image_compress'] = $value['image_compress'] ?: 'open';
$value['image_quality'] = min(100, max(0, intval($value['image_quality']) ?: 90));
$value['image_save_local'] = $value['image_save_local'] ?: 'open';
$value['task_user_limit'] = min(2000, max(1, intval($value['task_user_limit']) ?: 500));
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'];
}
@@ -65,14 +67,6 @@ class Setting extends AbstractModel
$value['permission_pack_userids'] = is_array($value['permission_pack_userids']) ? $value['permission_pack_userids'] : [];
break;
// AI 助手设置
case 'aiSetting':
$value['ai_provider'] = $value['ai_provider'] ?: 'openai';
$value['ai_api_key'] = $value['ai_api_key'] ?: '';
$value['ai_api_url'] = $value['ai_api_url'] ?: '';
$value['ai_proxy'] = $value['ai_proxy'] ?: '';
break;
// AI 机器人设置
case 'aibotSetting':
if ($value['claude_token'] && empty($value['claude_key'])) {
@@ -91,10 +85,7 @@ class Setting extends AbstractModel
$content = explode("\n", $content);
$content = array_filter($content);
}
if (empty($content)) {
$content = self::AIBotDefaultModels($aiName);
}
$content = implode("\n", $content);
$content = is_array($content) ? implode("\n", $content) : '';
break;
case 'model':
$models = Setting::AIBotModels2Array($array[$key . 's'], true);
@@ -116,100 +107,36 @@ class Setting extends AbstractModel
}
/**
* 是否开启 AI 助
* 是否开启 AI 助
* @return bool
*/
public static function AIOpen()
{
return !!Base::settingFind('aiSetting', 'ai_api_key');
$setting = Base::setting('aibotSetting');
if (!is_array($setting) || empty($setting)) {
return false;
}
foreach (AI::TEXT_MODEL_PRIORITY as $vendor) {
if (self::isAIBotVendorEnabled($setting, $vendor)) {
return true;
}
}
return false;
}
/**
* AI 机器人默认模型
* @param string $ai
* @return array
* 判断 AI 机器人厂商是否启用
* @param array $setting
* @param string $vendor
* @return bool
*/
public static function AIBotDefaultModels($ai = 'openai')
protected static function isAIBotVendorEnabled(array $setting, string $vendor): bool
{
return match ($ai) {
'openai' => [
'gpt-4.1 | GPT-4.1',
'gpt-4o | GPT-4o',
'gpt-4 | GPT-4',
'gpt-4o-mini | GPT-4o Mini',
'gpt-4-turbo | GPT-4 Turbo',
'o3 (thinking) | GPT-o3',
'o1 | GPT-o1',
'o4-mini | GPT-o4 Mini',
'o3-mini | GPT-o3 Mini',
'o1-mini | GPT-o1 Mini',
'gpt-3.5-turbo | GPT-3.5 Turbo',
'gpt-3.5-turbo-16k | GPT-3.5 Turbo 16K',
'gpt-3.5-turbo-0125 | GPT-3.5 Turbo 0125',
'gpt-3.5-turbo-1106 | GPT-3.5 Turbo 1106'
],
'claude' => [
'claude-opus-4-0 (thinking) | Claude Opus 4',
'claude-sonnet-4-0 (thinking) | Claude Sonnet 4',
'claude-3-7-sonnet-latest (thinking) | Claude Sonnet 3.7',
'claude-3-5-sonnet-latest | Claude Sonnet 3.5',
'claude-3-5-haiku-latest | Claude Haiku 3.5',
'claude-3-opus-latest | Claude Opus 3'
],
'deepseek' => [
'deepseek-chat | DeepSeek V3',
'deepseek-reasoner | DeepSeek R1'
],
'gemini' => [
'gemini-2.5-pro-preview-05-06 (thinking) | Gemini 2.5 Pro Preview',
'gemini-2.0-flash | Gemini 2.0 Flash',
'gemini-2.0-flash-lite | Gemini 2.0 Flash-Lite',
'gemini-1.5-flash | Gemini 1.5 Flash',
'gemini-1.5-flash-8b | Gemini 1.5 Flash 8B',
'gemini-1.5-pro | Gemini 1.5 Pro',
'gemini-1.0-pro | Gemini 1.0 Pro'
],
'grok' => [
'grok-3-latest | Grok 3',
'grok-3-fast-latest | Grok 3 Fast',
'grok-3-mini-latest | Grok 3 Mini',
'grok-3-mini-fast-latest | Grok 3 Mini Fast',
'grok-2-vision-latest | Grok 2 Vision',
'grok-2-latest | Grok 2',
],
'zhipu' => [
'glm-4 | GLM-4',
'glm-4-plus | GLM-4 Plus',
'glm-4-air | GLM-4 Air',
'glm-4-airx | GLM-4 AirX',
'glm-4-long | GLM-4 Long',
'glm-4-flash | GLM-4 Flash',
'glm-4v | GLM-4V',
'glm-4v-plus | GLM-4V Plus',
'glm-3-turbo | GLM-3 Turbo'
],
'qianwen' => [
'qwen-max | QWEN Max',
'qwen-max-latest | QWEN Max Latest',
'qwen-turbo | QWEN Turbo',
'qwen-turbo-latest | QWEN Turbo Latest',
'qwen-plus | QWEN Plus',
'qwen-plus-latest | QWEN Plus Latest',
'qwen-long | QWEN Long'
],
'wenxin' => [
'ernie-4.0-8k | Ernie 4.0 8K',
'ernie-4.0-8k-latest | Ernie 4.0 8K Latest',
'ernie-4.0-turbo-128k | Ernie 4.0 Turbo 128K',
'ernie-4.0-turbo-8k | Ernie 4.0 Turbo 8K',
'ernie-3.5-128k | Ernie 3.5 128K',
'ernie-3.5-8k | Ernie 3.5 8K',
'ernie-speed-128k | Ernie Speed 128K',
'ernie-speed-8k | Ernie Speed 8K',
'ernie-lite-8k | Ernie Lite 8K',
'ernie-tiny-8k | Ernie Tiny 8K'
],
default => [],
$key = trim((string)($setting[$vendor . '_key'] ?? ''));
return match ($vendor) {
'ollama' => $key !== '' || !empty($setting['ollama_base_url']),
'wenxin' => $key !== '' && !empty($setting['wenxin_secret']),
default => $key !== '',
};
}
@@ -238,6 +165,213 @@ class Setting extends AbstractModel
return $array;
}
/**
* 规范自定义微应用配置
* @param array $list
* @return array
*/
public static function normalizeCustomMicroApps($list)
{
if (!is_array($list)) {
return [];
}
$apps = [];
foreach ($list as $item) {
$app = self::normalizeCustomMicroAppItem($item);
if ($app) {
$apps[] = $app;
}
}
return $apps;
}
/**
* 根据用户身份过滤可见的自定义微应用
* @param array $apps
* @param \App\Models\User|null $user
* @return array
*/
public static function filterCustomMicroAppsForUser(array $apps, $user)
{
if (empty($apps)) {
return [];
}
$isAdmin = $user ? $user->isAdmin() : false;
$userId = $user ? intval($user->userid) : 0;
$filtered = [];
foreach ($apps as $app) {
$visible = self::normalizeCustomMicroVisible($app['visible_to'] ?? ['admin']);
if (!self::isCustomMicroVisibleTo($visible, $isAdmin, $userId)) {
continue;
}
if (empty($app['menu_items']) || !is_array($app['menu_items'])) {
continue;
}
$menus = array_values(array_filter($app['menu_items'], function ($menu) use ($isAdmin, $userId) {
if (!isset($menu['visible_to'])) {
return true;
}
$visible = self::normalizeCustomMicroVisible($menu['visible_to']);
return self::isCustomMicroVisibleTo($visible, $isAdmin, $userId);
}));
if (empty($menus)) {
continue;
}
$app['menu_items'] = $menus;
$filtered[] = $app;
}
return $filtered;
}
/**
* 将存储结构转换成 appstore 接口同款格式
* @param array $apps
* @return array
*/
public static function formatCustomMicroAppsForResponse(array $apps)
{
return array_values(array_map(function ($app) {
unset($app['visible_to']);
if (!empty($app['menu_items']) && is_array($app['menu_items'])) {
$app['menu_items'] = array_values(array_map(function ($menu) {
$menu['keep_alive'] = isset($menu['keep_alive']) ? (bool)$menu['keep_alive'] : true;
$menu['disable_scope_css'] = (bool)($menu['disable_scope_css'] ?? false);
$menu['auto_dark_theme'] = isset($menu['auto_dark_theme']) ? (bool)$menu['auto_dark_theme'] : true;
$menu['transparent'] = (bool)($menu['transparent'] ?? false);
if (isset($menu['visible_to'])) {
unset($menu['visible_to']);
}
return $menu;
}, $app['menu_items']));
}
return $app;
}, $apps));
}
/**
* 规范自定义微应用
* @param array $item
* @return array|null
*/
protected static function normalizeCustomMicroAppItem($item)
{
if (!is_array($item)) {
return null;
}
$id = trim($item['id'] ?? '');
if ($id === '') {
return null;
}
$name = Base::newTrim($item['name'] ?? '');
$version = Base::newTrim($item['version'] ?? '') ?: 'custom';
$menuItems = [];
if (isset($item['menu_items']) && is_array($item['menu_items'])) {
$menuItems = $item['menu_items'];
} elseif (isset($item['menu']) && is_array($item['menu'])) {
$menuItems = [$item['menu']];
}
if (empty($menuItems)) {
return null;
}
$normalizedMenus = [];
foreach ($menuItems as $menu) {
$formattedMenu = self::normalizeCustomMicroMenuItem($menu, $name ?: $id);
if ($formattedMenu) {
$normalizedMenus[] = $formattedMenu;
}
}
if (empty($normalizedMenus)) {
return null;
}
return Base::newTrim([
'id' => $id,
'name' => $name,
'version' => $version,
'menu_items' => $normalizedMenus,
'visible_to' => self::normalizeCustomMicroVisible($item['visible_to'] ?? 'admin'),
]);
}
/**
* 规范自定义微应用菜单项
* @param array $menu
* @param string $fallbackLabel
* @return array|null
*/
protected static function normalizeCustomMicroMenuItem($menu, $fallbackLabel = '')
{
if (!is_array($menu)) {
return null;
}
$url = trim($menu['url'] ?? '');
if ($url === '') {
return null;
}
$location = trim($menu['location'] ?? 'application');
$label = trim($menu['label'] ?? $fallbackLabel);
$urlType = strtolower(trim($menu['url_type'] ?? 'iframe'));
$payload = [
'location' => $location,
'label' => $label,
'icon' => Base::newTrim($menu['icon'] ?? ''),
'url' => $url,
'url_type' => $urlType,
'keep_alive' => isset($menu['keep_alive']) ? (bool)$menu['keep_alive'] : true,
'disable_scope_css' => (bool)($menu['disable_scope_css'] ?? false),
'auto_dark_theme' => isset($menu['auto_dark_theme']) ? (bool)$menu['auto_dark_theme'] : true,
'transparent' => (bool)($menu['transparent'] ?? false),
];
if (!empty($menu['background'])) {
$payload['background'] = Base::newTrim($menu['background']);
}
if (!empty($menu['capsule']) && is_array($menu['capsule'])) {
$payload['capsule'] = Base::newTrim($menu['capsule']);
}
return $payload;
}
/**
* 规范自定义微应用可见范围
* @param mixed $value
* @return array
*/
protected static function normalizeCustomMicroVisible($value)
{
if (is_array($value)) {
$list = array_filter(array_map('trim', $value));
} else {
$list = array_filter(array_map('trim', explode(',', (string)$value)));
}
if (empty($list)) {
return ['admin'];
}
if (in_array('all', $list)) {
return ['all'];
}
return array_values($list);
}
/**
* 判断自定义微应用是否可见
* @param array $visible
* @param bool $isAdmin
* @param int $userId
* @return bool
*/
protected static function isCustomMicroVisibleTo(array $visible, bool $isAdmin, int $userId)
{
if (in_array('all', $visible)) {
return true;
}
if ($isAdmin && in_array('admin', $visible)) {
return true;
}
if ($userId > 0 && in_array((string)$userId, $visible, true)) {
return true;
}
return false;
}
/**
* 验证邮箱地址(过滤忽略地址)
* @param $array

View File

@@ -70,6 +70,9 @@ class UmengAlias extends AbstractModel
return;
}
$instance = null;
$responsePayload = null;
try {
switch ($first['platform']) {
case 'ios':
@@ -81,8 +84,11 @@ class UmengAlias extends AbstractModel
default:
return;
}
$instance->send($first['data']);
$responsePayload = $instance->send($first['data']);
} catch (\Exception $e) {
$responsePayload = [
'error' => $e->getMessage(),
];
$first['retry'] = intval($first['retry'] ?? 0) + 1;
if ($first['retry'] > 3) {
info("[PushMsg] fail: " . $e->getMessage());
@@ -91,6 +97,12 @@ class UmengAlias extends AbstractModel
self::$waitSend[] = $first;
}
} finally {
if ($instance !== null) {
UmengLog::create([
'request' => Base::array2json($first['data']),
'response' => Base::array2json($responsePayload),
]);
}
self::sendTask();
}
}
@@ -153,7 +165,7 @@ class UmengAlias extends AbstractModel
$description = $array['description'] ?: 'no description'; // 描述
$extra = is_array($array['extra']) ? $array['extra'] : []; // 额外参数
$seconds = intval($array['seconds']) ?: 86400; // 有效时间(单位:秒)
$badge = intval($array['badge']) ?: 0; // 角标数iOS
$badge = intval($array['badge']) ?: 0; // 角标数
//
switch ($platform) {
case 'ios':
@@ -203,6 +215,7 @@ class UmengAlias extends AbstractModel
'title' => $title,
'after_open' => 'go_app',
'play_sound' => true,
'set_badge' => min(99, $badge),
],
], $extra),
'type' => 'customizedcast',
@@ -213,13 +226,19 @@ class UmengAlias extends AbstractModel
'policy' => [
'expire_time' => Carbon::now()->addSeconds($seconds)->toDateTimeString(),
],
'category' => 1,
'channel_properties' => [
'main_activity' => 'com.dootask.task.WelcomeActivity',
'oppo_channel_id' => 'dootask',
'vivo_category' => 'IM',
'huawei_channel_importance' => 'NORMAL',
'huawei_channel_category' => 'IM',
'channel_fcm' => 0,
],
'local_properties' => [
'importance' => 'IMPORTANCE_DEFAULT',
'category' => 'CATEGORY_MESSAGE',
]
]
]);
break;

32
app/Models/UmengLog.php Normal file
View File

@@ -0,0 +1,32 @@
<?php
namespace App\Models;
/**
* App\Models\UmengLog
*
* @property int $id
* @property string|null $request 请求参数
* @property string|null $response 推送返回
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog whereRequest($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog whereResponse($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog whereUpdatedAt($value)
* @mixin \Eloquent
*/
class UmengLog extends AbstractModel
{
protected $guarded = [];
}

View File

@@ -5,6 +5,7 @@ namespace App\Models;
use App\Exceptions\ApiException;
use App\Module\Base;
use App\Module\Doo;
use App\Module\Apps;
use App\Module\Table\OnlineData;
use App\Services\RequestContext;
use Cache;
@@ -14,15 +15,18 @@ use Carbon\Carbon;
* App\Models\User
*
* @property int $userid
* @property array $identity
* @property array $department
* @property array $identity 身份
* @property array $department 所属部门
* @property string|null $az A-Z
* @property string|null $pinyin 拼音(主要用于搜索)
* @property string|null $email
* @property string|null $email 邮箱
* @property string|null $tel 联系电话
* @property string $nickname
* @property string|null $profession
* @property string $userimg
* @property string $nickname 昵称
* @property string|null $profession 职位/职称
* @property \Illuminate\Support\Carbon|null $birthday 生日
* @property string|null $address 地址
* @property string|null $introduction 个人简介
* @property string $userimg 头像
* @property string|null $encrypt
* @property string|null $password 登录密码
* @property int|null $changepass 登录需要修改密码
@@ -33,7 +37,7 @@ use Carbon\Carbon;
* @property \Illuminate\Support\Carbon|null $line_at 最后在线时间(接口)
* @property int|null $task_dialog_id 最后打开的任务会话ID
* @property string|null $created_ip 注册IP
* @property \Illuminate\Support\Carbon|null $disable_at
* @property \Illuminate\Support\Carbon|null $disable_at 禁用时间(离职时间)
* @property int|null $email_verity 邮箱是否已验证
* @property int|null $bot 是否机器人
* @property string|null $lang 语言首选项
@@ -310,7 +314,7 @@ class User extends AbstractModel
*/
public function deleteUser($reason)
{
return AbstractModel::transaction(function () use ($reason) {
$ret = AbstractModel::transaction(function () use ($reason) {
// 删除原因
$userDelete = UserDelete::createInstance([
'operator' => User::userid(),
@@ -331,6 +335,10 @@ class User extends AbstractModel
//
return $this->delete();
});
if ($ret) {
Apps::dispatchUserHook($this, 'user_offboard', 'delete');
}
return $ret;
}
/**
@@ -404,7 +412,9 @@ class User extends AbstractModel
$dialog?->joinGroup($user->userid, 0);
}
}
return $user->find($user->userid);
$createdUser = $user->find($user->userid);
Apps::dispatchUserHook($createdUser, 'user_onboard', 'onboard');
return $createdUser;
}
/**

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Models;
/**
* App\Models\UserAppSort
*
* @property int $id
* @property int $userid 用户ID
* @property array|null $sorts 排序配置
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort query()
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort whereSorts($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort whereUserid($value)
* @mixin \Eloquent
*/
class UserAppSort extends AbstractModel
{
protected $fillable = [
'userid',
'sorts',
];
protected $casts = [
'sorts' => 'array',
];
/**
* 获取用户排序配置
* @param int $userid
* @return array
*/
public static function getSorts(int $userid): array
{
$record = static::whereUserid($userid)->first();
if (!$record) {
return self::normalizeSorts([]);
}
return self::normalizeSorts($record->sorts);
}
/**
* 保存排序配置
* @param int $userid
* @param array $sorts
* @return static
*/
public static function saveSorts(int $userid, array $sorts): self
{
return static::updateOrCreate(
['userid' => $userid],
['sorts' => self::normalizeSorts($sorts)]
);
}
/**
* 规范化排序数据
* @param mixed $sorts
* @return array
*/
public static function normalizeSorts($sorts): array
{
$result = [
'base' => [],
'admin' => [],
];
if (!is_array($sorts)) {
return $result;
}
foreach (['base', 'admin'] as $group) {
$list = $sorts[$group] ?? [];
if (!is_array($list)) {
$list = [];
}
$normalized = [];
foreach ($list as $value) {
if (!is_string($value)) {
continue;
}
$value = trim($value);
if ($value === '') {
continue;
}
$normalized[] = $value;
}
$result[$group] = array_values(array_unique($normalized));
}
return $result;
}
}

View File

@@ -4,11 +4,12 @@ namespace App\Models;
use App\Module\Base;
use App\Module\Doo;
use App\Module\Extranet;
use App\Module\Ihttp;
use App\Module\Timer;
use App\Tasks\JokeSoupTask;
use Cache;
use Carbon\Carbon;
use Throwable;
/**
* App\Models\UserBot
@@ -16,11 +17,11 @@ use Carbon\Carbon;
* @property int $id
* @property int|null $userid 所属人ID
* @property int|null $bot_id 机器人ID
* @property int|null $session 开启新会话功能
* @property int|null $clear_day 消息自动清理天数
* @property \Illuminate\Support\Carbon|null $clear_at 下一次清理时间
* @property string|null $webhook_url 消息webhook地址
* @property int|null $webhook_num 消息webhook请求次数
* @property array|null $webhook_events Webhook事件配置
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
@@ -37,7 +38,6 @@ use Carbon\Carbon;
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereClearDay($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereSession($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereUserid($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereWebhookNum($value)
@@ -46,6 +46,86 @@ use Carbon\Carbon;
*/
class UserBot extends AbstractModel
{
public const WEBHOOK_EVENT_MESSAGE = 'message';
public const WEBHOOK_EVENT_DIALOG_OPEN = 'dialog_open';
public const WEBHOOK_EVENT_MEMBER_JOIN = 'member_join';
public const WEBHOOK_EVENT_MEMBER_LEAVE = 'member_leave';
protected $casts = [
'webhook_events' => 'array',
];
/**
* 获取 webhook 事件配置
*
* @param mixed $value
* @return array
*/
public function getWebhookEventsAttribute(mixed $value): array
{
if ($value === null || $value === '') {
return self::normalizeWebhookEvents(null, true);
}
return self::normalizeWebhookEvents($value, false);
}
/**
* 设置 webhook 事件配置
*
* @param mixed $value
* @return void
*/
public function setWebhookEventsAttribute(mixed $value): void
{
$useFallback = $value === null;
$this->attributes['webhook_events'] = Base::array2json(self::normalizeWebhookEvents($value, $useFallback));
}
/**
* 判断是否需要触发指定 webhook 事件
*
* @param string $event
* @return bool
*/
public function shouldDispatchWebhook(string $event): bool
{
if (!$this->webhook_url) {
return false;
}
if (!preg_match('/^https?:\/\//', $this->webhook_url)) {
return false;
}
return in_array($event, $this->webhook_events ?? [], true);
}
/**
* 发送 webhook
*
* @param string $event
* @param array $data
* @param int $timeout
* @return array|null
*/
public function dispatchWebhook(string $event, array $data, int $timeout = 30): ?array
{
if (!$this->shouldDispatchWebhook($event)) {
return null;
}
try {
$data['event'] = $event;
$result = Ihttp::ihttp_post($this->webhook_url, $data, $timeout);
$this->increment('webhook_num');
return $result;
} catch (Throwable $th) {
info(Base::array2json([
'webhook_url' => $this->webhook_url,
'data' => $data,
'error' => $th->getMessage(),
]));
return null;
}
}
/**
* 判断是否系统机器人
@@ -481,4 +561,42 @@ class UserBot extends AbstractModel
}
return Base::retSuccess("创建成功。", $data);
}
/**
* 获取可选的 webhook 事件
*
* @return string[]
*/
public static function webhookEventOptions(): array
{
return [
self::WEBHOOK_EVENT_MESSAGE,
self::WEBHOOK_EVENT_DIALOG_OPEN,
self::WEBHOOK_EVENT_MEMBER_JOIN,
self::WEBHOOK_EVENT_MEMBER_LEAVE,
];
}
/**
* 标准化 webhook 事件配置
*
* @param mixed $events
* @param bool $useFallback
* @return array
*/
public static function normalizeWebhookEvents(mixed $events, bool $useFallback = true): array
{
if (is_string($events)) {
$events = Base::json2array($events);
}
if ($events === null) {
$events = [];
}
if (!is_array($events)) {
$events = [$events];
}
$events = array_filter(array_map('strval', $events));
$events = array_values(array_intersect($events, self::webhookEventOptions()));
return $events ?: ($useFallback ? [self::WEBHOOK_EVENT_MESSAGE] : []);
}
}

View File

@@ -170,6 +170,26 @@ class UserDepartment extends AbstractModel
});
}
/**
* 递归获取所有子部门ID
* @param int $departmentId
* @return array
*/
public static function getAllSubDepartmentIds($departmentId)
{
$subIds = [];
$directSubs = self::whereParentId($departmentId)->pluck('id')->toArray();
foreach ($directSubs as $subId) {
$subIds[] = $subId;
// 递归获取子部门的子部门
$subSubIds = self::getAllSubDepartmentIds($subId);
$subIds = array_merge($subIds, $subSubIds);
}
return array_unique($subIds);
}
/**
* 获取部门基本信息缓存时间1小时
* @param int|array $ids

340
app/Models/UserFavorite.php Normal file
View File

@@ -0,0 +1,340 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use App\Models\File;
/**
* App\Models\UserFavorite
*
* @property int $id
* @property int|null $userid 用户ID
* @property string|null $favoritable_type 收藏类型(比如task/project/file/message)
* @property int|null $favoritable_id 收藏对象ID
* @property string $remark 收藏备注
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \Illuminate\Database\Eloquent\Model|\Eloquent $favoritable
* @property-read \App\Models\User|null $user
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereFavoritableId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereFavoritableType($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereRemark($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereUserid($value)
* @mixin \Eloquent
*/
class UserFavorite extends AbstractModel
{
const TYPE_TASK = 'task';
const TYPE_PROJECT = 'project';
const TYPE_FILE = 'file';
const TYPE_MESSAGE = 'message';
protected $fillable = [
'userid',
'favoritable_type',
'favoritable_id',
'remark',
];
/**
* 关联用户
*/
public function user()
{
return $this->belongsTo(User::class, 'userid', 'userid');
}
/**
* 多态关联
*/
public function favoritable()
{
return $this->morphTo();
}
/**
* 切换收藏状态
* @param int $userid 用户ID
* @param string $type 收藏类型
* @param int $id 收藏对象ID
* @return array ['favorited' => bool, 'action' => 'added'|'removed']
*/
public static function toggleFavorite($userid, $type, $id)
{
$favorite = self::whereUserid($userid)
->whereFavoritableType($type)
->whereFavoritableId($id)
->first();
if ($favorite) {
// 取消收藏
$favorite->delete();
return ['favorited' => false, 'action' => 'removed', 'remark' => ''];
}
// 添加收藏
$favorite = self::create([
'userid' => $userid,
'favoritable_type' => $type,
'favoritable_id' => $id,
]);
return ['favorited' => true, 'action' => 'added', 'remark' => $favorite->remark ?? ''];
}
/**
* 更新收藏备注
* @param int $userid
* @param string $type
* @param int $id
* @param string $remark
* @return static|null
*/
public static function updateRemark($userid, $type, $id, $remark)
{
$favorite = self::whereUserid($userid)
->whereFavoritableType($type)
->whereFavoritableId($id)
->first();
if (!$favorite) {
return null;
}
$favorite->remark = $remark;
$favorite->save();
return $favorite;
}
/**
* 检查是否已收藏
* @param int $userid 用户ID
* @param string $type 收藏类型
* @param int $id 收藏对象ID
* @return bool
*/
public static function isFavorited($userid, $type, $id)
{
return self::whereUserid($userid)
->whereFavoritableType($type)
->whereFavoritableId($id)
->exists();
}
/**
* 获取用户收藏列表
* @param int $userid 用户ID
* @param string|null $type 收藏类型过滤
* @param int $page 页码
* @param int $pageSize 每页数量
* @return array
*/
public static function getUserFavorites($userid, $type = null, $page = 1, $pageSize = 20)
{
$query = self::whereUserid($userid)->orderByDesc('created_at');
if ($type) {
$query->whereFavoritableType($type);
}
$favorites = $query->paginate($pageSize, ['*'], 'page', $page);
$data = [
'tasks' => [],
'projects' => [],
'files' => [],
'messages' => []
];
// 分组收集ID
$taskIds = [];
$projectIds = [];
$fileIds = [];
$messageIds = [];
foreach ($favorites->items() as $favorite) {
switch ($favorite->favoritable_type) {
case self::TYPE_TASK:
$taskIds[] = $favorite->favoritable_id;
break;
case self::TYPE_PROJECT:
$projectIds[] = $favorite->favoritable_id;
break;
case self::TYPE_FILE:
$fileIds[] = $favorite->favoritable_id;
break;
case self::TYPE_MESSAGE:
$messageIds[] = $favorite->favoritable_id;
break;
}
}
// 批量查询具体数据
if (!empty($taskIds)) {
$tasks = ProjectTask::select([
'project_tasks.id',
'project_tasks.name',
'project_tasks.project_id',
'project_tasks.complete_at',
'project_tasks.created_at',
'project_tasks.flow_item_id',
'project_tasks.flow_item_name',
'projects.name as project_name'
])
->leftJoin('projects', 'project_tasks.project_id', '=', 'projects.id')
->whereIn('project_tasks.id', $taskIds)
->get()
->keyBy('id');
foreach ($favorites->items() as $favorite) {
if ($favorite->favoritable_type === self::TYPE_TASK && isset($tasks[$favorite->favoritable_id])) {
$task = $tasks[$favorite->favoritable_id];
// 解析 flow_item_name 字段格式status|name|color
$flowItemParts = explode('|', $task->flow_item_name ?: '');
$flowItemStatus = $flowItemParts[0] ?? '';
$flowItemName = $flowItemParts[1] ?? $task->flow_item_name;
$flowItemColor = $flowItemParts[2] ?? '';
$data['tasks'][] = [
'id' => $task->id,
'name' => $task->name,
'project_id' => $task->project_id,
'project_name' => $task->project_name,
'complete_at' => $task->complete_at,
'flow_item_id' => $task->flow_item_id,
'flow_item_name' => $flowItemName,
'flow_item_status' => $flowItemStatus,
'flow_item_color' => $flowItemColor,
'favorited_at' => Carbon::parse($favorite->created_at)->format('Y-m-d H:i:s'),
'remark' => $favorite->remark,
];
}
}
}
if (!empty($projectIds)) {
$projects = Project::select([
'id', 'name', 'desc', 'archived_at', 'created_at'
])->whereIn('id', $projectIds)->get()->keyBy('id');
foreach ($favorites->items() as $favorite) {
if ($favorite->favoritable_type === self::TYPE_PROJECT && isset($projects[$favorite->favoritable_id])) {
$project = $projects[$favorite->favoritable_id];
$data['projects'][] = [
'id' => $project->id,
'name' => $project->name,
'desc' => $project->desc,
'archived_at' => $project->archived_at,
'favorited_at' => Carbon::parse($favorite->created_at)->format('Y-m-d H:i:s'),
'remark' => $favorite->remark,
];
}
}
}
if (!empty($fileIds)) {
$files = File::select([
'id', 'name', 'ext', 'size', 'pid', 'created_at'
])->whereIn('id', $fileIds)->get()->keyBy('id');
foreach ($favorites->items() as $favorite) {
if ($favorite->favoritable_type === self::TYPE_FILE && isset($files[$favorite->favoritable_id])) {
$file = $files[$favorite->favoritable_id];
$fileData = File::handleImageUrl(array_merge(
$file->only(['id', 'ext']),
[
'name' => $file->name,
'size' => $file->size,
'pid' => $file->pid,
]
));
$data['files'][] = [
'id' => $file->id,
'name' => $file->name,
'ext' => $file->ext,
'size' => $file->size,
'pid' => $file->pid,
'image_url' => $fileData['image_url'] ?? null,
'image_width' => $fileData['image_width'] ?? null,
'image_height' => $fileData['image_height'] ?? null,
'favorited_at' => Carbon::parse($favorite->created_at)->format('Y-m-d H:i:s'),
'remark' => $favorite->remark,
];
}
}
}
if (!empty($messageIds)) {
$messages = WebSocketDialogMsg::select([
'id', 'dialog_id', 'userid', 'type', 'msg', 'created_at'
])->whereIn('id', $messageIds)->get()->keyBy('id');
foreach ($favorites->items() as $favorite) {
if ($favorite->favoritable_type === self::TYPE_MESSAGE && isset($messages[$favorite->favoritable_id])) {
$message = $messages[$favorite->favoritable_id];
// 使用 previewMsg 获取消息预览文本
$previewText = '';
if ($message->msg && is_array($message->msg)) {
$previewText = WebSocketDialogMsg::previewMsg($message);
}
// 如果没有预览文本,使用消息类型作为标题
if (empty($previewText)) {
$previewText = '[' . ucfirst($message->type) . ']';
}
$data['messages'][] = [
'id' => $message->id,
'name' => $previewText,
'dialog_id' => $message->dialog_id,
'userid' => $message->userid,
'type' => $message->type,
'favorited_at' => Carbon::parse($favorite->created_at)->format('Y-m-d H:i:s'),
'remark' => $favorite->remark,
];
}
}
}
return [
'data' => $data,
'total' => $favorites->total(),
'current_page' => $favorites->currentPage(),
'per_page' => $favorites->perPage(),
'last_page' => $favorites->lastPage(),
];
}
/**
* 清理用户收藏
* @param int $userid 用户ID
* @param string|null $type 收藏类型null表示全部类型
* @return int 删除的记录数
*/
public static function cleanUserFavorites($userid, $type = null)
{
$query = self::whereUserid($userid);
if ($type) {
$query->whereFavoritableType($type);
}
return $query->delete();
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Models;
use Carbon\Carbon;
/**
* App\Models\UserRecentItem
*
* @property int $id
* @property int $userid 用户ID
* @property string $target_type 目标类型(task/file/task_file/message_file 等)
* @property int $target_id 目标ID
* @property string $source_type 来源类型(project/filesystem/project_task/dialog 等)
* @property int $source_id 来源ID
* @property \Illuminate\Support\Carbon|null $browsed_at 浏览时间
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereBrowsedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereSourceId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereSourceType($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereTargetId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereTargetType($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereUserid($value)
* @mixin \Eloquent
*/
class UserRecentItem extends AbstractModel
{
public const TYPE_TASK = 'task';
public const TYPE_FILE = 'file';
public const TYPE_TASK_FILE = 'task_file';
public const TYPE_MESSAGE_FILE = 'message_file';
public const SOURCE_PROJECT = 'project';
public const SOURCE_FILESYSTEM = 'filesystem';
public const SOURCE_PROJECT_TASK = 'project_task';
public const SOURCE_DIALOG = 'dialog';
protected $fillable = [
'userid',
'target_type',
'target_id',
'source_type',
'source_id',
'browsed_at',
];
protected $dates = [
'browsed_at',
];
public static function record(int $userid, string $targetType, int $targetId, string $sourceType = '', int $sourceId = 0): self
{
return self::updateOrCreate(
[
'userid' => $userid,
'target_type' => $targetType,
'target_id' => $targetId,
'source_type' => $sourceType,
'source_id' => $sourceId,
],
[
'browsed_at' => Carbon::now(),
]
);
}
}

84
app/Models/UserTag.php Normal file
View File

@@ -0,0 +1,84 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class UserTag extends AbstractModel
{
protected $table = 'user_tags';
protected $fillable = [
'user_id',
'name',
'created_by',
'updated_by',
];
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by', 'userid')
->select(['userid', 'nickname']);
}
public function recognitions(): HasMany
{
return $this->hasMany(UserTagRecognition::class, 'tag_id');
}
public function canManage(User $viewer): bool
{
return $viewer->isAdmin()
|| $viewer->userid === $this->user_id
|| $viewer->userid === $this->created_by;
}
public static function listWithMeta(int $targetUserId, ?User $viewer): array
{
$query = static::query()
->where('user_id', $targetUserId)
->with(['creator'])
->withCount(['recognitions as recognition_total'])
->orderByDesc('recognition_total')
->orderBy('id');
$tags = $query->get();
$viewerId = $viewer?->userid ?? 0;
$viewerIsAdmin = $viewer?->isAdmin() ?? false;
$viewerIsOwner = $viewerId > 0 && $viewerId === $targetUserId;
$recognizedIds = [];
if ($viewerId > 0 && $tags->isNotEmpty()) {
$recognizedIds = UserTagRecognition::query()
->where('user_id', $viewerId)
->whereIn('tag_id', $tags->pluck('id'))
->pluck('tag_id')
->all();
}
$recognizedLookup = array_flip($recognizedIds);
$list = $tags->map(function (self $tag) use ($viewerId, $viewerIsAdmin, $viewerIsOwner, $recognizedLookup) {
$canManage = $viewerIsAdmin || $viewerIsOwner || $viewerId === $tag->created_by;
return [
'id' => $tag->id,
'user_id' => $tag->user_id,
'name' => $tag->name,
'created_by' => $tag->created_by,
'created_by_name' => $tag->creator?->nickname ?: '',
'recognition_total' => (int) $tag->recognition_total,
'recognized' => isset($recognizedLookup[$tag->id]),
'can_edit' => $canManage,
'can_delete' => $canManage,
];
})->values()->toArray();
return [
'list' => $list,
'top' => array_slice($list, 0, 10),
'total' => count($list),
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserTagRecognition extends AbstractModel
{
protected $table = 'user_tag_recognitions';
protected $fillable = [
'tag_id',
'user_id',
];
public function tag(): BelongsTo
{
return $this->belongsTo(UserTag::class, 'tag_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id', 'userid')
->select(['userid', 'nickname']);
}
}

View File

@@ -0,0 +1,138 @@
<?php
namespace App\Models;
use Carbon\Carbon;
/**
* App\Models\UserTaskBrowse
*
* @property int $id
* @property int|null $userid 用户ID
* @property int|null $task_id 任务ID
* @property \Illuminate\Support\Carbon|null $browsed_at 浏览时间
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\ProjectTask|null $task
* @property-read \App\Models\User|null $user
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse whereBrowsedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse whereTaskId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse whereUserid($value)
* @mixin \Eloquent
*/
class UserTaskBrowse extends AbstractModel
{
protected $fillable = [
'userid',
'task_id',
'browsed_at',
];
protected $dates = [
'browsed_at',
];
/**
* 关联用户
*/
public function user()
{
return $this->belongsTo(User::class, 'userid', 'userid');
}
/**
* 关联任务
*/
public function task()
{
return $this->belongsTo(ProjectTask::class, 'task_id', 'id');
}
/**
* 记录用户浏览任务
* @param int $userid 用户ID
* @param int $task_id 任务ID
* @return UserTaskBrowse
*/
public static function recordBrowse($userid, $task_id)
{
$record = self::updateOrCreate(
[
'userid' => $userid,
'task_id' => $task_id,
],
[
'browsed_at' => Carbon::now(),
]
);
UserRecentItem::record(
$userid,
UserRecentItem::TYPE_TASK,
$task_id,
UserRecentItem::SOURCE_PROJECT,
0
);
return $record;
}
/**
* 获取用户浏览历史
* @param int $userid 用户ID
* @param int $limit 获取数量
* @return \Illuminate\Database\Eloquent\Collection
*/
public static function getUserBrowseHistory($userid, $limit = 20)
{
return self::with(['task' => function ($query) {
$query->select([
'id', 'name', 'project_id', 'column_id', 'parent_id',
'flow_item_id', 'flow_item_name',
'complete_at', 'archived_at'
]);
}])
->whereUserid($userid)
->whereHas('task', function ($query) {
// 只获取存在且未被删除的任务
$query->whereNull('archived_at');
})
->orderByDesc('browsed_at')
->limit($limit)
->get();
}
/**
* 清理用户浏览历史
* @param int $userid 用户ID
* @param int $keepCount 保留数量0表示全部删除
* @return int 删除的记录数
*/
public static function cleanUserBrowseHistory($userid, $keepCount = 100)
{
if ($keepCount === 0) {
return self::whereUserid($userid)->delete();
}
$keepIds = self::whereUserid($userid)
->orderByDesc('browsed_at')
->limit($keepCount)
->pluck('id');
return self::whereUserid($userid)
->whereNotIn('id', $keepIds)
->delete();
}
}

View File

@@ -10,6 +10,7 @@ namespace App\Models;
* @property string $key
* @property string|null $fd
* @property string|null $path
* @property string|null $platform 平台类型android, ios, win, mac, web
* @property int|null $userid
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
@@ -27,6 +28,7 @@ namespace App\Models;
* @method static \Illuminate\Database\Eloquent\Builder|WebSocket whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocket whereKey($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocket wherePath($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocket wherePlatform($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocket whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocket whereUserid($value)
* @mixin \Eloquent

View File

@@ -530,6 +530,7 @@ class WebSocketDialog extends AbstractModel
}
}
//
$item->operator_id = User::userid();
$item->delete();
//
if ($pushMsg) {
@@ -551,6 +552,42 @@ class WebSocketDialog extends AbstractModel
$this->pushMsg("groupUpdate", $data);
}
/**
* 推送成员事件到机器人 webhook
* @param string $event
* @param int $memberId
* @param int $operatorId
* @return void
*/
public function dispatchMemberWebhook(string $event, int $memberId, int $operatorId): void
{
$botIds = $this->dialogUser()->where('bot', 1)->pluck('userid')->toArray();
if (empty($botIds)) {
return;
}
$userBots = UserBot::whereIn('bot_id', $botIds)->get();
if ($userBots->isEmpty()) {
return;
}
$member = User::find($memberId, ['userid', 'nickname', 'email', 'bot'])?->toArray();
$operator = $operatorId === $memberId ? $member : User::find($operatorId, ['userid', 'nickname', 'email', 'bot'])?->toArray();
$payload = [
'dialog_id' => $this->id,
'dialog_type' => $this->type,
'group_type' => $this->group_type,
'dialog_name' => $this->getGroupName(),
'member' => $member,
'operator' => $operator,
];
foreach ($userBots as $userBot) {
$userBot->dispatchWebhook($event, $payload, 10);
}
}
/**
* 删除会话
* @return bool

View File

@@ -8,6 +8,7 @@ use App\Module\Base;
use App\Module\Doo;
use App\Module\Image;
use App\Tasks\PushTask;
use App\Models\ProjectTaskRelation;
use App\Exceptions\ApiException;
use App\Tasks\WebSocketDialogMsgTask;
use Hhxsv5\LaravelS\Swoole\Task\Task;
@@ -694,7 +695,6 @@ class WebSocketDialogMsg extends AbstractModel
$text = $title;
} else {
$text = Base::markdown2html($text);
$text = self::previewConvertTaskList($text);
}
}
$text = preg_replace("/<img\s+class=\"emoticon\"[^>]*?alt=\"(\S+)\"[^>]*?>/", "[$1]", $text);
@@ -710,36 +710,6 @@ class WebSocketDialogMsg extends AbstractModel
return $text;
}
/**
* 转换任务列表
* @param $text
* @return array|string|string[]|null
*/
private static function previewConvertTaskList($text) {
$pattern = '/:::\s*(create-task-list|create-subtask-list)(.*?):::/s';
$replacement = function($matches) {
$content = $matches[2];
$lines = explode("\n", trim($content));
$result = [];
$currentTitle = '';
foreach ($lines as $line) {
$line = trim($line);
if (empty($line)) continue;
if (preg_match('/^title:\s*(.+)$/', $line, $titleMatch)) {
$currentTitle = $titleMatch[1];
$result[] = $currentTitle;
} elseif (preg_match('/^desc:\s*(.+)$/', $line, $descMatch)) {
if (!empty($currentTitle)) {
$result[] = $descMatch[1];
}
}
}
return implode("\n", $result);
};
return preg_replace_callback($pattern, $replacement, $text);
}
/**
* 预览文件消息
* @param $msg
@@ -851,6 +821,89 @@ class WebSocketDialogMsg extends AbstractModel
return $msg;
}
/**
* 提取消息内容
* 根据消息类型(文件、文本等)提取相应的内容文本
*
* @param int $maxLength 最大长度超过则截取0表示不限制
* @return string 提取出的消息文本内容
*/
public function extractMessageContent(int $maxLength = 0): string
{
$reserves = [];
switch ($this->type) {
case "file":
// 提取文件消息
$result = " 文件:{$this->msg['name']}(大小:{$this->msg['size']}BURL{$this->msg['path']} ";
break;
case "text":
// 提取文本消息
$result = $this->msg['text'] ?: '';
if (empty($result)) {
return '';
}
// 提取快捷键
if (preg_match("/<span[^>]*?data-quick-key=([\"'])([^\"']+?)\\1[^>]*?>(.*?)<\/span>/is", $result, $match)) {
$command = $match[2] ?? '';
$command = preg_replace("/^%3A\.?/", ":", $command);
$command = trim($command);
if ($command) {
return $command;
}
}
// 提及任务、文件、报告
$result = preg_replace_callback_array([
// 用户
"/<span class=\"mention user\" data-id=\"(\d+)\">(.*?)<\/span>/" => function ($match) {
return "";
},
// 任务
"/<span class=\"mention task\" data-id=\"(\d+)\">#?(.*?)<\/span>/" => function ($match) {
return " 任务:{$match[2]} (任务ID{$match[1]}) ";
},
// 文件
"/<a class=\"mention file\" href=\"([^\"']+?)\"[^>]*?>~?(.*?)<\/a>/" => function ($match) use (&$reserves) {
$idOrCode = "";
if (preg_match("/single\/file\/(.*?)$/", $match[1], $subMatch)) {
$idOrCode = " (" . (Base::isNumber($subMatch[1]) ? "文件ID{$subMatch[1]}" : "文件分享码:{$subMatch[1]}") . ")";
}
return " 文件:{$match[2]}{$idOrCode} ";
},
// 报告
"/<a class=\"mention report\" href=\"([^\"']+?)\"[^>]*?>%?(.*?)<\/a>/" => function ($match) use (&$reserves) {
$idOrCode = "";
if (preg_match("/single\/report\/detail\/(.*?)$/", $match[1], $subMatch)) {
$idOrCode = " (" . (Base::isNumber($subMatch[1]) ? "报告ID{$subMatch[1]}" : "报告分享码:{$subMatch[1]}") . ")";
}
return " 工作汇报:{$match[2]}{$idOrCode} ";
},
], $result);
// 转成 markdown
if ($this->msg['type'] !== 'md') {
$result = Base::html2markdown($result);
}
break;
default:
// 其他类型消息不处理
return '';
}
// 截取最大长度
if ($maxLength > 0 && mb_strlen($result) > $maxLength) {
$result = mb_substr($result, 0, $maxLength);
}
return $result;
}
/**
* 处理文本消息内容,用于发送前
* @param $text
@@ -1227,6 +1280,7 @@ class WebSocketDialogMsg extends AbstractModel
];
$dialogMsg->updateInstance($updateData);
$dialogMsg->generateKeyAndSave($search_key);
ProjectTaskRelation::recordMentionsFromMessage($dialogMsg);
//
WebSocketDialogUser::whereDialogId($dialog->id)->whereUserid($sender)->whereHide(1)->change([
'hide' => 0, // 修改消息时,显示会话(仅自己)
@@ -1293,6 +1347,7 @@ class WebSocketDialogMsg extends AbstractModel
'updated_at' => Carbon::now()->toDateTimeString('millisecond'),
]);
});
ProjectTaskRelation::recordMentionsFromMessage($dialogMsg);
//
$task = new WebSocketDialogMsgTask($dialogMsg->id);
if ($push_self) {

View File

@@ -3,6 +3,7 @@
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
/**
* App\Models\WebSocketDialogMsgRead
@@ -76,24 +77,74 @@ class WebSocketDialogMsgRead extends AbstractModel
*/
public static function onlyMarkRead($list)
{
$dialogMsg = [];
if (empty($list)) {
return;
}
$collection = collect($list);
if ($collection->isEmpty()) {
return;
}
$now = Carbon::now();
$ids = [];
$msgCounts = [];
/** @var WebSocketDialogMsgRead $item */
foreach ($list as $item) {
$item->read_at = Carbon::now();
$item->save();
if (isset($dialogMsg[$item->msg_id])) {
$dialogMsg[$item->msg_id]['readNum']++;
} else {
$dialogMsg[$item->msg_id] = [
'dialogMsg' => $item->webSocketDialogMsg,
'readNum' => 1
];
foreach ($collection as $item) {
$ids[] = $item->id;
if ($item->msg_id) {
$msgCounts[$item->msg_id] = ($msgCounts[$item->msg_id] ?? 0) + 1;
}
}
foreach ($dialogMsg as $item) {
if ($item['dialogMsg']) {
$item['dialogMsg']->increment('read', $item['readNum']);
if (!empty($ids)) {
DB::table((new self())->getTable())
->whereIn('id', $ids)
->whereNull('read_at')
->update(['read_at' => $now]);
}
if (!empty($msgCounts)) {
$cases = [];
$bindings = [];
foreach ($msgCounts as $msgId => $num) {
$cases[] = 'WHEN ? THEN ?';
$bindings[] = $msgId;
$bindings[] = $num;
}
$msgIds = array_keys($msgCounts);
$bindings = array_merge($bindings, $msgIds);
$placeholders = implode(',', array_fill(0, count($msgIds), '?'));
$table = DB::getTablePrefix() . (new WebSocketDialogMsg())->getTable();
$sql = "UPDATE {$table} SET `read` = `read` + CASE `id` " . implode(' ', $cases) . " END WHERE `deleted_at` IS NULL AND `id` IN ({$placeholders})";
DB::update($sql, $bindings);
}
}
/**
* 标记指定会话的历史消息为已读
* @param int $dialogId
* @param int $sessionId
* @param int $chunkSize
* @return void
*/
public static function markSessionMessagesAsRead(int $dialogId, int $sessionId, int $chunkSize = 100): void
{
if ($dialogId <= 0 || $sessionId <= 0) {
return;
}
self::whereDialogId($dialogId)
->whereNull('read_at')
->whereIn('msg_id', function ($query) use ($dialogId, $sessionId) {
$query->select('id')
->from((new WebSocketDialogMsg())->getTable())
->where('dialog_id', $dialogId)
->where('session_id', $sessionId);
})
->chunkById($chunkSize, function ($list) {
self::onlyMarkRead($list);
});
}
}

View File

@@ -11,10 +11,24 @@ use Carbon\Carbon;
*/
class AI
{
public const TEXT_MODEL_PRIORITY = [
'openai',
'claude',
'deepseek',
'gemini',
'grok',
'ollama',
'zhipu',
'qianwen',
'wenxin'
];
protected const OPENAI_DEFAULT_MODEL = 'gpt-5.1-mini';
protected $post = [];
protected $headers = [];
protected $urlPath = '';
protected $timeout = 30;
protected $providerConfig = null;
/**
* 构造函数
@@ -63,6 +77,15 @@ class AI
$this->timeout = $timeout;
}
/**
* 指定请求所使用的模型配置
* @param array $provider
*/
public function setProvider(array $provider)
{
$this->providerConfig = $provider;
}
/**
* 请求 AI 接口
* @param bool $resRaw 是否返回原始数据
@@ -70,23 +93,23 @@ class AI
*/
public function request($resRaw = false)
{
$aiSetting = Base::setting('aiSetting');
if (!Setting::AIOpen()) {
return Base::retError("AI 助手未开启");
$provider = $this->providerConfig ?: self::resolveTextProvider();
if (!$provider) {
return Base::retError("请先配置 AI 助手");
}
$headers = [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $aiSetting['ai_api_key'],
'Authorization' => 'Bearer ' . $provider['api_key'],
];
if ($aiSetting['ai_proxy']) {
$headers['CURLOPT_PROXY'] = $aiSetting['ai_proxy'];
$headers['CURLOPT_PROXYTYPE'] = str_contains($aiSetting['ai_proxy'], 'socks') ? CURLPROXY_SOCKS5 : CURLPROXY_HTTP;
if (!empty($provider['agency'])) {
$headers['CURLOPT_PROXY'] = $provider['agency'];
$headers['CURLOPT_PROXYTYPE'] = str_contains($provider['agency'], 'socks') ? CURLPROXY_SOCKS5 : CURLPROXY_HTTP;
}
$headers = array_merge($headers, $this->headers);
$url = $aiSetting['ai_api_url'] ?: 'https://api.openai.com/v1';
$url = $url . ($this->urlPath ?: '/chat/completions');
$baseUrl = $provider['base_url'] ?: 'https://api.openai.com/v1';
$url = $baseUrl . ($this->urlPath ?: '/chat/completions');
$result = Ihttp::ihttp_request($url, $this->post, $headers, $this->timeout);
if (Base::isError($result)) {
@@ -109,6 +132,129 @@ class AI
return Base::retSuccess("success", $result);
}
/**
* 生成 AI 流式会话凭证
* @param string $modelType
* @param string $modelName
* @param mixed $contextInput
* @return array
*/
public static function createStreamKey($modelType, $modelName, $contextInput = [])
{
$modelType = trim((string)$modelType);
$modelName = trim((string)$modelName);
if ($modelType === '' || $modelName === '') {
return Base::retError('参数错误');
}
if (is_string($contextInput)) {
$decoded = json_decode($contextInput, true);
if (json_last_error() === JSON_ERROR_NONE) {
$contextInput = $decoded;
}
}
if (!is_array($contextInput)) {
return Base::retError('context 参数格式错误');
}
$context = [];
foreach ($contextInput as $item) {
if (!is_array($item) || count($item) < 2) {
continue;
}
$role = trim((string)($item[0] ?? ''));
$message = trim((string)($item[1] ?? ''));
if ($role === '' || $message === '') {
continue;
}
$context[] = [$role, $message];
}
$contextJson = json_encode($context, JSON_UNESCAPED_UNICODE);
if ($contextJson === false) {
return Base::retError('context 参数格式错误');
}
$setting = Base::setting('aibotSetting');
if (!is_array($setting)) {
$setting = [];
}
$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);
}
if (empty($apiKey)) {
return Base::retError('模型未启用');
}
$remoteModelType = match ($modelType) {
'qianwen' => 'qwen',
default => $modelType,
};
$authParams = [
'api_key' => $apiKey,
'model_type' => $remoteModelType,
'model_name' => $modelName,
'context' => $contextJson,
];
$baseUrl = trim((string)($setting[$modelType . '_base_url'] ?? ''));
if ($baseUrl !== '') {
$authParams['base_url'] = $baseUrl;
}
$agency = trim((string)($setting[$modelType . '_agency'] ?? ''));
if ($agency !== '') {
$authParams['agency'] = $agency;
}
$thinkPatterns = [
"/^(.+?)(\s+|\s*[_-]\s*)(think|thinking|reasoning)\s*$/",
"/^(.+?)\s*\(\s*(think|thinking|reasoning)\s*\)\s*$/"
];
$thinkMatch = [];
foreach ($thinkPatterns as $pattern) {
if (preg_match($pattern, $authParams['model_name'], $thinkMatch)) {
break;
}
}
if ($thinkMatch && !empty($thinkMatch[1])) {
$authParams['model_name'] = $thinkMatch[1];
}
$authResult = Ihttp::ihttp_request('http://nginx/ai/invoke/auth', $authParams, [
'Content-Type' => 'application/x-www-form-urlencoded',
'Authorization' => 'Bearer ' . Base::token(),
], 30);
if (Base::isError($authResult)) {
return Base::retError($authResult['msg']);
}
$body = Base::json2array($authResult['data']);
if (($body['code'] ?? null) !== 200) {
return Base::retError(($body['error'] ?? '') ?: 'AI 接口返回异常', $body);
}
$streamKey = Base::val($body, 'data.stream_key');
if (empty($streamKey)) {
return Base::retError('AI 接口返回数据异常');
}
return Base::retSuccess('success', [
'stream_key' => $streamKey,
]);
}
/** ******************************************************************************************** */
/** ******************************************************************************************** */
/** ******************************************************************************************** */
@@ -117,34 +263,38 @@ class AI
* 通过 openAI 语音转文字
* @param string $filePath 语音文件路径
* @param array $extParams 扩展参数
* @param array $extHeaders 扩展请求头
* @param bool $noCache 是否禁用缓存
* @return array
*/
public static function transcriptions($filePath, $extParams = [], $noCache = false)
public static function transcriptions($filePath, $extParams = [], $extHeaders = [], $noCache = false)
{
Apps::isInstalledThrow('ai');
if (!file_exists($filePath)) {
return Base::retError("语音文件不存在");
}
$systemSetting = Base::setting('system');
if ($systemSetting['voice2text'] !== 'open') {
return Base::retError("语音转文字功能未开启");
}
$cacheKey = "openAItranscriptions::" . md5($filePath . '_' . Base::array2json($extParams));
if ($noCache) {
Cache::forget($cacheKey);
}
$result = Cache::remember($cacheKey, Carbon::now()->addDays(), function () use ($extParams, $filePath) {
$audioProvider = self::resolveOpenAIAudioProvider();
if (!$audioProvider) {
return Base::retError("请先在「AI 助手」设置中配置 OpenAI");
}
$result = Cache::remember($cacheKey, Carbon::now()->addDays(), function () use ($extParams, $extHeaders, $filePath, $audioProvider) {
$post = array_merge($extParams, [
'file' => new \CURLFile($filePath),
'model' => 'whisper-1',
]);
$header = [
$header = array_merge($extHeaders, [
'Content-Type' => 'multipart/form-data',
];
]);
$ai = new self($post, $header);
$ai->setProvider($audioProvider);
$ai->setUrlPath('/audio/transcriptions');
$ai->setTimeout(15);
@@ -177,19 +327,21 @@ class AI
*/
public static function translations($text, $targetLanguage, $noCache = false)
{
$systemSetting = Base::setting('system');
if ($systemSetting['translation'] !== 'open') {
return Base::retError("翻译功能未开启");
}
Apps::isInstalledThrow('ai');
$cacheKey = "openAItranslations::" . md5($text . '_' . $targetLanguage);
if ($noCache) {
Cache::forget($cacheKey);
}
$result = Cache::remember($cacheKey, Carbon::now()->addDays(7), function () use ($text, $targetLanguage) {
$post = json_encode([
"model" => "gpt-4.1-nano",
$provider = self::resolveTextProvider();
if (!$provider) {
return Base::retError("请先配置 AI 助手");
}
$result = Cache::remember($cacheKey, Carbon::now()->addDays(7), function () use ($text, $targetLanguage, $provider) {
$payload = [
"model" => $provider['model'],
"messages" => [
[
"role" => "system",
@@ -220,11 +372,14 @@ class AI
"content" => "请将以下内容翻译为 {$targetLanguage}\n\n{$text}"
]
],
"temperature" => 0.2,
"max_tokens" => max(1000, intval(mb_strlen($text) * 1.5))
]);
];
if (self::shouldSendReasoningEffort($provider)) {
$payload['reasoning_effort'] = 'minimal';
}
$post = json_encode($payload);
$ai = new self($post);
$ai->setProvider($provider);
$ai->setTimeout(60);
$res = $ai->request();
@@ -257,14 +412,23 @@ class AI
*/
public static function generateTitle($text, $noCache = false)
{
if (!Apps::isInstalled('ai')) {
return Base::retError('应用「AI Assistant」未安装');
}
$cacheKey = "openAIGenerateTitle::" . md5($text);
if ($noCache) {
Cache::forget($cacheKey);
}
$result = Cache::remember($cacheKey, Carbon::now()->addHours(24), function () use ($text) {
$post = json_encode([
"model" => "gpt-4.1-nano",
$provider = self::resolveTextProvider();
if (!$provider) {
return Base::retError("请先配置 AI 助手");
}
$result = Cache::remember($cacheKey, Carbon::now()->addHours(24), function () use ($text, $provider) {
$payload = [
"model" => $provider['model'],
"messages" => [
[
"role" => "system",
@@ -289,11 +453,14 @@ class AI
"content" => "请为以下内容生成一个合适的标题:\n\n" . $text
]
],
"temperature" => 0.3,
"max_tokens" => 100
]);
];
if (self::shouldSendReasoningEffort($provider)) {
$payload['reasoning_effort'] = 'minimal';
}
$post = json_encode($payload);
$ai = new self($post);
$ai->setProvider($provider);
$ai->setTimeout(10);
$res = $ai->request();
@@ -326,14 +493,23 @@ class AI
*/
public static function generateJokeAndSoup($noCache = false)
{
if (!Apps::isInstalled('ai')) {
return Base::retError('应用「AI Assistant」未安装');
}
$cacheKey = "openAIJokeAndSoup::" . md5(date('Y-m-d'));
if ($noCache) {
Cache::forget($cacheKey);
}
$result = Cache::remember($cacheKey, Carbon::now()->addHours(6), function () {
$post = json_encode([
"model" => "gpt-4.1-nano",
$provider = self::resolveTextProvider();
if (!$provider) {
return Base::retError("请先配置 AI 助手");
}
$result = Cache::remember($cacheKey, Carbon::now()->addHours(6), function () use ($provider) {
$payload = [
"model" => $provider['model'],
"messages" => [
[
"role" => "system",
@@ -365,10 +541,14 @@ class AI
"content" => "请生成20个职场笑话和20个心灵鸡汤"
]
],
"temperature" => 0.8
]);
];
if (self::shouldSendReasoningEffort($provider)) {
$payload['reasoning_effort'] = 'minimal';
}
$post = json_encode($payload);
$ai = new self($post);
$ai->setProvider($provider);
$ai->setTimeout(120);
$res = $ai->request();
@@ -419,43 +599,143 @@ class AI
}
/**
* 获取 ollama 模型
* @param $baseUrl
* @param $key
* @param $agency
* @return array
* 选择可用的文本模型配置
* @return array|null
*/
public static function ollamaModels($baseUrl, $key = null, $agency = null)
protected static function resolveTextProvider()
{
$extra = [
'Content-Type' => 'application/json',
];
if ($key) {
$extra['Authorization'] = 'Bearer ' . $key;
$setting = Base::setting('aibotSetting');
if (!is_array($setting)) {
$setting = [];
}
if ($agency) {
$extra['CURLOPT_PROXY'] = $agency;
$extra['CURLOPT_PROXYTYPE'] = str_contains($agency, 'socks') ? CURLPROXY_SOCKS5 : CURLPROXY_HTTP;
}
$res = Ihttp::ihttp_request(rtrim($baseUrl, '/') . '/api/tags', [], $extra, 15);
if (Base::isError($res)) {
return Base::retError("获取失败", $res);
}
$resData = Base::json2array($res['data']);
if (empty($resData['models'])) {
return Base::retError("获取失败", $resData);
}
$models = [];
foreach ($resData['models'] as $model) {
if ($model['name'] !== $model['model']) {
$models[] = "{$model['model']} | {$model['name']}";
} else {
$models[] = $model['model'];
foreach (self::TEXT_MODEL_PRIORITY as $vendor) {
$config = self::buildProviderConfig($setting, $vendor);
if ($config) {
return $config;
}
}
return Base::retSuccess("success", [
'models' => $models,
'original' => $resData['models']
]);
return null;
}
/**
* 构建指定厂商的请求参数
* @param array $setting
* @param string $vendor
* @return array|null
*/
protected static function buildProviderConfig(array $setting, string $vendor)
{
$key = trim((string)($setting[$vendor . '_key'] ?? ''));
$baseUrl = trim((string)($setting[$vendor . '_base_url'] ?? ''));
$agency = trim((string)($setting[$vendor . '_agency'] ?? ''));
switch ($vendor) {
case 'openai':
if ($key === '') {
return null;
}
$baseUrl = $baseUrl ?: 'https://api.openai.com/v1';
$model = self::resolveOpenAITextModel($setting);
break;
case 'ollama':
if ($baseUrl === '') {
return null;
}
if ($key === '') {
$key = Base::strRandom(6);
}
$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;
}
$model = trim((string)($setting[$vendor . '_model'] ?? ''));
break;
}
if ($model === '') {
return null;
}
return [
'vendor' => $vendor,
'model' => $model,
'api_key' => $key,
'base_url' => rtrim($baseUrl, '/'),
'agency' => $agency,
];
}
/**
* 解析 OpenAI 文本模型
* @param array $setting
* @return string
*/
protected static function resolveOpenAITextModel(array $setting)
{
$models = Setting::AIBotModels2Array($setting['openai_models'] ?? '', true);
if (in_array(self::OPENAI_DEFAULT_MODEL, $models, true)) {
return self::OPENAI_DEFAULT_MODEL;
}
if (!empty($setting['openai_model'])) {
return $setting['openai_model'];
}
return $models[0] ?? self::OPENAI_DEFAULT_MODEL;
}
/**
* OpenAI 语音模型配置
* @return array|null
*/
protected static function resolveOpenAIAudioProvider()
{
$setting = Base::setting('aibotSetting');
if (!is_array($setting)) {
$setting = [];
}
$key = trim((string)($setting['openai_key'] ?? ''));
if ($key === '') {
return null;
}
$baseUrl = trim((string)($setting['openai_base_url'] ?? ''));
$baseUrl = $baseUrl ?: 'https://api.openai.com/v1';
$agency = trim((string)($setting['openai_agency'] ?? ''));
return [
'vendor' => 'openai',
'model' => 'whisper-1',
'api_key' => $key,
'base_url' => rtrim($baseUrl, '/'),
'agency' => $agency,
];
}
/**
* 是否需要附加 reasoning_effort 参数
* @param array $provider
* @return bool
*/
protected static function shouldSendReasoningEffort(array $provider): bool
{
if (($provider['vendor'] ?? '') !== 'openai') {
return false;
}
$model = $provider['model'] ?? '';
// 匹配 gpt- 开头后跟数字的模型名称
if (preg_match('/^gpt-(\d+)/', $model, $matches)) {
return intval($matches[1]) >= 5;
}
return false;
}
}

View File

@@ -3,8 +3,11 @@
namespace App\Module;
use App\Exceptions\ApiException;
use App\Models\User;
use App\Services\RequestContext;
use Symfony\Component\Yaml\Yaml;
use App\Module\Base;
use App\Module\Ihttp;
class Apps
{
@@ -44,7 +47,7 @@ class Apps
{
if (!self::isInstalled($appId)) {
$name = match ($appId) {
'ai' => 'AI Robot',
'ai' => 'AI Assistant',
'face' => 'Face check-in',
'appstore' => 'AppStore',
'approve' => 'Approval',
@@ -57,4 +60,43 @@ class Apps
throw new ApiException("应用「{$name}」未安装", [], 0, false);
}
}
/**
* Dispatch user lifecycle hook to appstore (onboard/offboard/delete/restore).
*/
public static function dispatchUserHook(User $user, string $action, string $eventType = ''): void
{
$appKey = env('APP_KEY', '');
if (empty($appKey)) {
info('[appstore_hook] APP_KEY is empty, skip dispatchUserHook');
return;
}
$url = sprintf('http://appstore/api/v1/internal/hooks/%s', $action);
$payload = [
'user' => [
'id' => (string) $user->userid,
'email' => (string) $user->email,
'name' => (string) $user->nickname,
'role' => in_array('admin', $user->identity ?? []) ? 'admin' : 'normal',
],
];
if ($eventType !== '') {
$payload['event_type'] = $eventType;
}
$headers = [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . md5($appKey),
];
$resp = Ihttp::ihttp_request($url, json_encode($payload, JSON_UNESCAPED_UNICODE), $headers, 5);
if (Base::isError($resp)) {
info('[appstore_hook] dispatch fail', [
'url' => $url,
'payload' => $payload,
'error' => $resp,
]);
}
}
}

View File

@@ -1301,7 +1301,7 @@ class Base
/**
* 获取或设置
* @param $setname // 配置名称
* @param bool $array // 保存内容
* @param bool|array $array // 保存内容
* @param bool $isUpdate // 保存内容为更新模式,默认否
* @return array
*/
@@ -1404,7 +1404,12 @@ class Base
*/
public static function ajaxError($msg, $data = [], $ret = 0, $abortCode = 404)
{
abort_if(Request::header('Content-Type') !== 'application/json', $abortCode, Doo::translate($msg));
if (Request::header('Content-Type') !== 'application/json') {
$translateMsg = Doo::translate($msg);
abort($abortCode, $translateMsg, [
'X-Error-Message-Base64' => base64_encode($translateMsg),
]);
}
return Base::retError($msg, $data, $ret);
}
@@ -1822,6 +1827,19 @@ class Base
return $platform;
}
/**
* 是否是PC端包括 Electron 桌面端和 Web 浏览器)
* @param string|null $platform 平台类型,不传则自动获取
* @return bool
*/
public static function isPc($platform = null)
{
if ($platform === null) {
$platform = self::platform();
}
return in_array($platform, ['win', 'mac', 'web']);
}
/**
* 是否是App移动端
* @return bool
@@ -3047,7 +3065,7 @@ class Base
{
try {
$converter = new CommonMarkConverter();
return $converter->convert($markdown);
return $converter->convert($markdown)->getContent();
} catch (\League\CommonMark\Exception\CommonMarkException $e) {
return $markdown;
}
@@ -3068,4 +3086,61 @@ class Base
return $html;
}
}
/**
* 实时读取 .env 配置(不受配置缓存影响)
* @param string $key 配置键名
* @param mixed $default 默认值
* @return mixed
*/
public static function liveEnv($key, $default = null)
{
$envFile = base_path('.env');
if (!file_exists($envFile)) {
return $default;
}
$envContent = file_get_contents($envFile);
$lines = explode("\n", $envContent);
foreach ($lines as $line) {
$line = trim($line);
// 跳过注释和空行
if (empty($line) || str_starts_with($line, '#')) {
continue;
}
// 解析 KEY=VALUE
if (str_contains($line, '=')) {
[$envKey, $envValue] = explode('=', $line, 2);
$envKey = trim($envKey);
if ($envKey === $key) {
$envValue = trim($envValue);
// 移除引号
if (preg_match('/^(["\'])(.*)\1$/', $envValue, $matches)) {
$envValue = $matches[2];
}
// 处理布尔值
$lowerValue = strtolower($envValue);
if ($lowerValue === 'true') {
return true;
}
if ($lowerValue === 'false') {
return false;
}
if ($lowerValue === 'null' || $lowerValue === '(null)') {
return null;
}
return $envValue;
}
}
}
return $default;
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Observers;
use App\Models\Deleted;
use App\Models\UserBot;
use App\Models\WebSocketDialogUser;
use App\Tasks\ZincSearchSyncTask;
use Carbon\Carbon;
@@ -31,6 +32,11 @@ class WebSocketDialogUserObserver extends AbstractObserver
}
Deleted::forget('dialog', $webSocketDialogUser->dialog_id, $webSocketDialogUser->userid);
self::taskDeliver(new ZincSearchSyncTask('userSync', $webSocketDialogUser->toArray()));
//
$dialog = $webSocketDialogUser->webSocketDialog;
if ($dialog) {
$dialog->dispatchMemberWebhook(UserBot::WEBHOOK_EVENT_MEMBER_JOIN, $webSocketDialogUser->userid, intval($webSocketDialogUser->inviter));
}
}
/**
@@ -54,6 +60,12 @@ class WebSocketDialogUserObserver extends AbstractObserver
{
Deleted::record('dialog', $webSocketDialogUser->dialog_id, $webSocketDialogUser->userid);
self::taskDeliver(new ZincSearchSyncTask('deleteUser', $webSocketDialogUser->toArray()));
//
$dialog = $webSocketDialogUser->webSocketDialog;
if ($dialog) {
$operatorId = $webSocketDialogUser->operator_id ?? 0;
$dialog->dispatchMemberWebhook(UserBot::WEBHOOK_EVENT_MEMBER_LEAVE, $webSocketDialogUser->userid, intval($operatorId));
}
}
/**

View File

@@ -64,7 +64,7 @@ class WebSocketService implements WebSocketHandlerInterface
'ud' => $userid,
],
]));
$this->userOn($fd, $userid);
$this->userOn($fd, $userid, $get['platform']);
} else {
// 用户不存在
$server->push($fd, Base::array2json([
@@ -105,6 +105,11 @@ class WebSocketService implements WebSocketHandlerInterface
// 握手信息
case 'handshake':
// 更新 PC 端活跃时间
$row = WebSocket::whereFd($frame->fd)->first();
if ($row && Base::isPc($row->platform)) {
Cache::put("user_pc_active:{$row->userid}", time(), 60);
}
break;
// 访问状态
@@ -166,17 +171,27 @@ class WebSocketService implements WebSocketHandlerInterface
* 用户上线
* @param $fd
* @param $userid
* @param $platform
* @return void
*/
private function userOn($fd, $userid)
private function userOn($fd, $userid, $platform = 'web')
{
// 校验平台类型
if (!in_array($platform, ['android', 'ios', 'win', 'mac', 'web'])) {
$platform = 'web';
}
WebSocket::updateInsert([
'key' => md5($fd . '@' . $userid)
], [
'fd' => $fd,
'userid' => $userid,
'platform' => $platform,
]);
OnlineData::online($userid);
// PC 端上线时更新活跃时间
if (Base::isPc($platform)) {
Cache::put("user_pc_active:{$userid}", time(), 60);
}
}
/**

View File

@@ -42,6 +42,7 @@ class AutoArchivedTask extends AbstractTask
->whereNotNull('project_tasks.complete_at')
->where('project_tasks.complete_at', '<=', Carbon::now()->subDays($archivedDay))
->where('project_tasks.archived_userid', 0)
->where('project_tasks.parent_id', 0)
->whereNull('project_tasks.archived_at')
->where('projects.archive_method', '!=', 'custom')
->take(100)
@@ -63,6 +64,7 @@ class AutoArchivedTask extends AbstractTask
->join('projects', 'projects.id', '=', 'project_tasks.project_id')
->whereNotNull('project_tasks.complete_at')
->where('project_tasks.archived_userid', 0)
->where('project_tasks.parent_id', 0)
->whereNull('project_tasks.archived_at')
->where('projects.archive_method', 'custom')
->whereRaw("DATEDIFF(NOW(), {$prefix}project_tasks.complete_at) >= {$prefix}projects.archive_days")

View File

@@ -117,10 +117,10 @@ class BotReceiveMsgTask extends AbstractTask
}
// 提取指令
$sendText = $this->extractMessageContent($msg);
$sendText = $msg->extractMessageContent();
$replyText = null;
if ($msg->reply_id && $replyMsg = WebSocketDialogMsg::find($msg->reply_id)) {
$replyText = $this->extractMessageContent($replyMsg);
$replyText = $replyMsg->extractMessageContent();
}
// 没有提取到指令,则不处理
@@ -134,11 +134,6 @@ class BotReceiveMsgTask extends AbstractTask
return;
}
// 如果是群聊,@别人但是没有@自己,则不处理
if ($dialog->type === 'group' && $this->mentionOther && !$this->mention) {
return;
}
// 推送Webhook
$this->handleWebhookRequest($sendText, $replyText, $msg, $dialog, $botUser);
@@ -432,20 +427,26 @@ class BotReceiveMsgTask extends AbstractTask
private function handleWebhookRequest($sendText, $replyText, WebSocketDialogMsg $msg, WebSocketDialog $dialog, User $botUser)
{
$webhookUrl = null;
$userBot = null;
$extras = ['timestamp' => time()];
try {
if ($botUser->isAiBot($type)) {
// AI机器人
// AI机器人,不处理带有留言的转发消息,因为他要处理那条留言消息
if (Base::val($msg->msg, 'forward_data.leave')) {
// AI机器人不处理带有留言的转发消息因为他要处理那条留言消息
return;
}
// 如果是群聊,没有@自己,则不处理
if ($dialog->type === 'group' && !$this->mention) {
return;
}
// 检查客户端版本
if (in_array($this->client['platform'], ['win', 'mac', 'web']) && !Base::judgeClientVersion("0.41.11", $this->client['version'])) {
throw new Exception('当前客户端版本低所需版本≥v0.41.11)。');
}
// 判断AI应用是否安装
if (!Apps::isInstalled('ai')) {
throw new Exception('应用「AI Robot」未安装');
throw new Exception('应用「AI Assistant」未安装');
}
// 整理机器人参数
$setting = Base::setting('aibotSetting');
@@ -492,6 +493,10 @@ class BotReceiveMsgTask extends AbstractTask
if ($type === 'wenxin') {
$extras['api_key'] .= ':' . $setting['wenxin_secret'];
}
// 群聊清理上下文(群聊不使用上下文)
if ($dialog->type === 'group') {
$extras['before_clear'] = 1;
}
if ($type === 'ollama') {
if (empty($extras['base_url'])) {
throw new Exception('机器人未启用。');
@@ -503,17 +508,15 @@ class BotReceiveMsgTask extends AbstractTask
if (empty($extras['api_key'])) {
throw new Exception('机器人未启用。');
}
$this->generateSystemPromptForAI($msg->userid, $dialog, $extras);
$this->generateSystemPromptForAI($msg->userid, $dialog, $botUser, $extras);
// 转换提及格式
$sendText = self::convertMentionForAI($sendText);
$replyText = self::convertMentionForAI($replyText);
if ($replyText) {
$sendText = <<<EOF
<quoted_content>
{$replyText}
</quoted_content>
The content within the above quoted_content tags is a citation.
上述 quoted_content 标签中的内容为引用。
{$sendText}
EOF;
@@ -526,15 +529,10 @@ class BotReceiveMsgTask extends AbstractTask
return;
}
$userBot = UserBot::whereBotId($botUser->userid)->first();
if ($userBot) {
$userBot->webhook_num++;
$userBot->save();
$webhookUrl = $userBot->webhook_url;
if (!$userBot || !$userBot->shouldDispatchWebhook(UserBot::WEBHOOK_EVENT_MESSAGE)) {
return;
}
}
if (!preg_match("/^https?:\/\//", $webhookUrl)) {
return;
}
} catch (\Exception $e) {
WebSocketDialogMsg::sendMsg(null, $msg->dialog_id, 'template', [
'type' => 'content',
@@ -542,245 +540,60 @@ class BotReceiveMsgTask extends AbstractTask
], $botUser->userid, false, false, true); // todo 未能在任务end事件来发送任务
return;
}
//
try {
$data = [
'text' => $sendText,
'reply_text' => $replyText,
'token' => User::generateToken($botUser),
'session_id' => $dialog->session_id,
'dialog_id' => $dialog->id,
'dialog_type' => $dialog->type,
'msg_id' => $msg->id,
'msg_uid' => $msg->userid,
'mention' => $this->mention ? 1 : 0,
'bot_uid' => $botUser->userid,
'version' => Base::getVersion(),
'extras' => Base::array2json($extras)
// 基本请求数据
$data = [
'event' => UserBot::WEBHOOK_EVENT_MESSAGE,
'text' => $sendText,
'reply_text' => $replyText,
'token' => User::generateToken($botUser),
'session_id' => $dialog->session_id,
'dialog_id' => $dialog->id,
'dialog_type' => $dialog->type,
'msg_id' => $msg->id,
'msg_uid' => $msg->userid,
'mention' => $this->mention ? 1 : 0,
'bot_uid' => $botUser->userid,
'extras' => Base::array2json($extras),
'version' => Base::getVersion(),
'timestamp' => time(),
];
// 添加用户信息
$userInfo = User::find($msg->userid);
if ($userInfo) {
$data['msg_user'] = [
'userid' => $userInfo->userid,
'email' => $userInfo->email,
'nickname' => $userInfo->nickname,
'profession' => $userInfo->profession,
'lang' => $userInfo->lang,
'token' => User::generateTokenNoDevice($userInfo, now()->addHour()),
];
// 添加用户信息
$userInfo = User::find($msg->userid);
if ($userInfo) {
$data['msg_user'] = [
'userid' => $userInfo->userid,
'email' => $userInfo->email,
'nickname' => $userInfo->nickname,
'profession' => $userInfo->profession,
'lang' => $userInfo->lang,
'token' => User::generateTokenNoDevice($userInfo, now()->addHour()),
];
}
// 请求Webhook
$result = Ihttp::ihttp_post($webhookUrl, $data, 30);
if ($result['data'] && $data = Base::json2array($result['data'])) {
if ($data['code'] != 200 && $data['message']) {
WebSocketDialogMsg::sendMsg(null, $msg->dialog_id, 'text', [
'text' => $result['data']['message']
], $botUser->userid, false, false, true);
}
}
} catch (\Throwable $th) {
info(Base::array2json([
'bot_userid' => $botUser->userid,
'dialog' => $dialog->id,
'msg' => $msg->id,
'webhook_url' => $webhookUrl,
'error' => $th->getMessage(),
]));
}
}
/**
* 提取消息内容
* 根据消息类型(文件、文本等)提取相应的内容文本
*
* @param WebSocketDialogMsg $msg 消息对象
* @return string 提取出的消息文本内容
*/
private function extractMessageContent(WebSocketDialogMsg $msg)
{
$reserves = [];
switch ($msg->type) {
case "file":
// 提取文件消息
$msgData = Base::json2array($msg->getRawOriginal('msg'));
$result = $this->convertMentionFormat("path", $msgData['path'], $msgData['name'], $reserves);
break;
case "text":
// 提取文本消息
$result = $msg->msg['text'] ?: '';
if (empty($result)) {
return '';
}
// 提取快捷键
if (preg_match("/<span[^>]*?data-quick-key=([\"'])([^\"']+?)\\1[^>]*?>(.*?)<\/span>/is", $result, $match)) {
$command = $match[2] ?? '';
$command = preg_replace("/^%3A\.?/", ":", $command);
$command = trim($command);
if ($command) {
return $command;
}
}
// 提及任务、文件、报告
$result = preg_replace_callback_array([
// 用户
"/<span class=\"mention user\" data-id=\"(\d+)\">(.*?)<\/span>/" => function () {
return "";
},
// 任务
"/<span class=\"mention task\" data-id=\"(\d+)\">#?(.*?)<\/span>/" => function ($match) use (&$reserves) {
return $this->convertMentionFormat("task", $match[1], $match[2], $reserves);
},
// 文件
"/<a class=\"mention file\" href=\"([^\"']+?)\"[^>]*?>~?(.*?)<\/a>/" => function ($match) use (&$reserves) {
if (preg_match("/single\/file\/(.*?)$/", $match[1], $subMatch)) {
return $this->convertMentionFormat("file", $subMatch[1], $match[2], $reserves);
}
return "";
},
// 报告
"/<a class=\"mention report\" href=\"([^\"']+?)\"[^>]*?>%?(.*?)<\/a>/" => function ($match) use (&$reserves) {
if (preg_match("/single\/report\/detail\/(.*?)$/", $match[1], $subMatch)) {
return $this->convertMentionFormat("report", $subMatch[1], $match[2], $reserves);
}
return "";
},
], $result);
// 转成 markdown
if ($msg->msg['type'] !== 'md') {
$result = Base::html2markdown($result);
}
break;
default:
// 其他类型消息不处理
return '';
}
// 处理 reserves
foreach ($reserves as $rand => $mention) {
$result = str_replace($rand, $mention, $result);
$result = null;
if ($userBot) {
$result = $userBot->dispatchWebhook(UserBot::WEBHOOK_EVENT_MESSAGE, $data);
} else {
try {
$result = Ihttp::ihttp_post($webhookUrl, $data, 30);
} catch (\Throwable $th) {
info(Base::array2json([
'webhook_url' => $webhookUrl,
'data' => $data,
'error' => $th->getMessage(),
]));
}
}
return $result;
}
/**
* 转换提及消息格式
* 将提及的任务、文件、报告等转换为统一的格式 [type#key#name]
*
* @param string $type 提及类型task、file、report、path
* @param string $key 提及对象的唯一标识
* @param string $name 提及对象的显示名称
* @return string 格式化后的提及字符串
*/
private function convertMentionFormat($type, $key, $name, &$reserves)
{
$key = str_replace(['#', '-->'], '', $key);
$name = str_replace(['#', '-->'], '', $name);
$rand = Base::generatePassword(12);
$reserves[$rand] = "<!--{$type}#{$key}#{$name}-->";
return $rand;
}
/**
* 为AI机器人转换提及消息格式
* 将提及的任务、文件、报告转换为AI可理解的格式并提取相关内容
*
* @param string $original 原始消息文本
* @return string 转换后的消息文本,包含相关内容的标签
* @throws Exception 当提及的对象不存在或读取失败时抛出异常
*/
public static function convertMentionForAI($original)
{
$array = [];
$original = preg_replace_callback('/<!--(.*?)#(.*?)#(.*?)-->/', function ($match) use (&$array) {
// 初始化 tag 内容
$pathTag = null;
$pathName = null;
$pathContent = null;
// 根据 type 提取 tag 内容
switch ($match[1]) {
// 任务
case 'task':
$taskInfo = ProjectTask::with(['content'])->whereId(intval($match[2]))->first();
if (!$taskInfo) {
throw new Exception("任务不存在或已被删除");
}
$pathTag = "task_content";
$pathName = addslashes($taskInfo->name) . " (ID:{$taskInfo->id})";
$pathContent = implode("\n", $taskInfo->AIContext());
break;
// 文件
case 'file':
$fileInfo = FileContent::idOrCodeToContent($match[2]);
if (!$fileInfo || !isset($fileInfo->content['url'])) {
throw new Exception("文件不存在或已被删除");
}
$urlPath = public_path($fileInfo->content['url']);
if (!file_exists($urlPath)) {
throw new Exception("文件不存在或已被删除");
}
$fileResult = TextExtractor::extractFile($urlPath);
if (Base::isError($fileResult)) {
throw new Exception("文件读取失败:" . $fileResult['msg']);
}
$pathTag = "file_content";
$pathName = addslashes($match[3]) . " (ID:{$fileInfo->id})";
$pathContent = $fileResult['data'];
break;
// 文件路径
case 'path':
$urlPath = public_path($match[2]);
if (!file_exists($urlPath)) {
throw new Exception("文件不存在或已被删除");
}
$fileResult = TextExtractor::extractFile($urlPath);
if (Base::isError($fileResult)) {
throw new Exception("文件读取失败:" . $fileResult['msg']);
}
$pathTag = "file_content";
$pathName = addslashes($match[3]);
$pathContent = $fileResult['data'];
break;
// 报告
case 'report':
$reportInfo = Report::idOrCodeToContent($match[2]);
if (!$reportInfo) {
throw new Exception("报告不存在或已被删除");
}
$pathTag = "report_content";
$pathName = addslashes($match[3]) . " (ID:{$reportInfo->id})";
$pathContent = Base::html2markdown($reportInfo->content);
break;
if ($result && isset($result['data'])) {
$responseData = Base::json2array($result['data']);
if (($responseData['code'] ?? 0) === 200 && !empty($responseData['message'])) {
WebSocketDialogMsg::sendMsg(null, $msg->dialog_id, 'text', [
'text' => $responseData['message']
], $botUser->userid, false, false, true);
}
// 如果提取到 tag 内容,则添加到 contents 数组中
if ($pathTag) {
$array[] = "<{$pathTag} path=\"{$pathName}\">\n{$pathContent}\n</{$pathTag}>";
return "`{$pathName}` (see below for {$pathTag} tag)";
}
return "";
}, $original);
// 添加 tag 内容
if ($array) {
$original .= "\n\n" . implode("\n\n", $array);
}
return $original;
}
/**
@@ -789,93 +602,124 @@ class BotReceiveMsgTask extends AbstractTask
*
* @param int|null $userid 用户ID
* @param WebSocketDialog $dialog 对话对象
* @param User $botUser 机器人用户对象
* @param array $extras 额外参数数组通过引用传递以修改system_message
* @return void
*/
private function generateSystemPromptForAI($userid, WebSocketDialog $dialog, array &$extras)
private function generateSystemPromptForAI($userid, WebSocketDialog $dialog, User $botUser, array &$extras)
{
$system_messages = [];
switch ($dialog->type) {
// 用户对话
case "user":
$aiPrompt = WebSocketDialogConfig::where([
'dialog_id' => $dialog->id,
'userid' => $userid,
'type' => 'ai_prompt',
])->value('value');
if ($aiPrompt) {
$extras['system_message'] = $aiPrompt;
// 用户自定义提示词(私聊场景优先使用)
$customPrompt = null;
if ($dialog->type === 'user') {
$customPrompt = WebSocketDialogConfig::where([
'dialog_id' => $dialog->id,
'userid' => $userid,
'type' => 'ai_prompt',
])->value('value');
}
$prompt = [];
// 1. 基础角色(自定义提示词优先)
if ($customPrompt) {
$prompt[] = $customPrompt;
} elseif (!empty($extras['system_message'])) {
$prompt[] = $extras['system_message'];
}
// 2. 上下文信息
$currentTime = Carbon::now()->toDateTimeString();
$contextLines = [
"您是:{$botUser->nickname}ID: {$botUser->userid}",
"当前对话ID{$dialog->id}",
"当前系统时间:{$currentTime}"
];
if ($dialog->type === 'group') {
switch ($dialog->group_type) {
case 'project':
$projectInfo = Project::whereDialogId($dialog->id)->first();
if ($projectInfo) {
$contextLines[] = "场景:项目群聊「{$projectInfo->name}ID: {$projectInfo->id}";
}
break;
case 'task':
$taskInfo = ProjectTask::with(['content'])->whereDialogId($dialog->id)->first();
if ($taskInfo) {
$contextLines[] = "场景:任务群聊「{$taskInfo->name}ID: {$taskInfo->id}";
}
break;
case 'department':
$userDepartment = UserDepartment::whereDialogId($dialog->id)->first();
if ($userDepartment) {
$contextLines[] = "场景:部门群聊「{$userDepartment->name}";
}
break;
case 'all':
$contextLines[] = "场景:全体成员群聊";
break;
}
// 3. 聊天历史(仅群聊)
$chatHistory = $this->getRecentChatHistory($dialog, 15);
if ($chatHistory) {
$prompt[] = implode("\n", $contextLines);
$prompt[] = "最近的对话记录:\n{$chatHistory}";
} else {
$prompt[] = implode("\n", $contextLines);
}
} else {
$prompt[] = implode("\n", $contextLines);
}
$extras['system_message'] = implode("\n----\n", array_filter($prompt));
}
/**
* 获取最近的聊天记录
* @param WebSocketDialog $dialog 对话对象
* @param int $limit 获取的聊天记录条数
* @return string|null 格式化后的聊天记录字符串无记录时返回null
*/
private function getRecentChatHistory(WebSocketDialog $dialog, $limit = 10): ?string
{
// 构建查询条件
$conditions = [
['dialog_id', '=', $dialog->id],
['id', '<', $this->msgId],
];
// 如果有会话ID添加会话过滤条件
if ($dialog->session_id > 0) {
$conditions[] = ['session_id', '=', $dialog->session_id];
}
// 查询最近$limit条消息并格式化
$chatMessages = WebSocketDialogMsg::with(['user'])
->where($conditions)
->orderByDesc('id')
->take($limit)
->get()
->map(function (WebSocketDialogMsg $message) {
$userName = $message->user?->nickname ?? '未知用户';
$content = $message->extractMessageContent(500);
if (!$content) {
return null;
}
break;
// 群组对话
case "group":
switch ($dialog->group_type) {
// 用户群
case 'user':
break;
// 项目群
case 'project':
$projectInfo = Project::whereDialogId($dialog->id)->first();
if ($projectInfo) {
$projectDesc = $projectInfo->desc ?: "-";
$projectStatus = $projectInfo->archived_at ? '已归档' : '正在进行中';
$system_messages[] = <<<EOF
当前我在项目【{$projectInfo->name}】中
项目描述:{$projectDesc}
项目状态:{$projectStatus}
// 使用XML标签格式确保AI能清晰识别边界
// 对用户名进行HTML转义防止特殊字符破坏格式
$safeUserName = htmlspecialchars($userName, ENT_QUOTES, 'UTF-8');
return "<message userid=\"{$message->userid}\" nickname=\"{$safeUserName}\">\n{$content}\n</message>";
})
->reverse() // 反转集合,让时间顺序正确(最早的在前)
->filter() // 过滤掉空内容的消息
->values() // 重新索引数组
->toArray();
如果你判断我想要或需要添加任务,请按照以下格式回复:
::: create-task-list
title: 任务标题1
desc: 任务描述1
title: 任务标题2
desc: 任务描述2
:::
EOF;
}
break;
// 任务群
case 'task':
$taskInfo = ProjectTask::with(['content'])->whereDialogId($dialog->id)->first();
if ($taskInfo) {
$taskContext = implode("\n", $taskInfo->AIContext());
$system_messages[] = <<<EOF
当前我在任务【{$taskInfo->name}】中
当前时间:{$taskInfo->updated_at}
任务ID{$taskInfo->id}
{$taskContext}
如果你判断我想要或需要添加子任务,请按照以下格式回复:
::: create-subtask-list
title: 子任务标题1
title: 子任务标题2
:::
EOF;
}
break;
// 部门群
case 'department':
$userDepartment = UserDepartment::whereDialogId($dialog->id)->first();
if ($userDepartment) {
$system_messages[] = "当前我在【{$userDepartment->name}】的部门群聊中";
}
break;
// 全体成员群
case 'all':
$system_messages[] = "当前我在【全体成员】的群聊中";
break;
}
break;
}
if ($extras['system_message']) {
array_unshift($system_messages, $extras['system_message']);
}
if ($system_messages) {
$extras['system_message'] = implode("\n\n----------------\n\n", Base::newTrim($system_messages));
}
return empty($chatMessages) ? null : implode("\n", $chatMessages);
}
/**

View File

@@ -6,6 +6,7 @@ use App\Models\File;
use App\Models\TaskWorker;
use App\Models\Tmp;
use App\Models\UserDevice;
use App\Models\UmengLog;
use App\Models\WebSocketTmpMsg;
use App\Module\Base;
use Carbon\Carbon;
@@ -103,6 +104,17 @@ class DeleteTmpTask extends AbstractTask
}
});
break;
case 'umeng_log':
UmengLog::where('created_at', '<', Carbon::now()->subHours($this->hours))
->orderBy('id')
->chunk(500, function ($logs) {
/** @var UmengLog $log */
foreach ($logs as $log) {
$log->delete();
}
});
break;
}
}

View File

@@ -2,7 +2,10 @@
namespace App\Tasks;
use App\Models\UmengAlias;
use App\Models\WebSocketDialogMsgRead;
use App\Module\Base;
use Cache;
use Hhxsv5\LaravelS\Swoole\Task\Task;
/**
* 推送友盟消息
@@ -11,6 +14,7 @@ class PushUmengMsg extends AbstractTask
{
protected $userid = 0;
protected $array = [];
protected $endPush = []; // 需要在 end() 方法中处理的延迟推送列表
/**
* @param array|int $userid
@@ -32,11 +36,68 @@ class PushUmengMsg extends AbstractTask
if ($setting['push'] !== 'open') {
return;
}
UmengAlias::pushMsgToUserid($this->userid, $this->array);
// 消息ID
$msgId = isset($this->array['id']) ? intval($this->array['id']) : 0;
// 处理用户列表
$userids = is_array($this->userid) ? $this->userid : [$this->userid];
$directPushUsers = []; // 直接推送的用户
$delayedPushUsers = []; // 需要延迟推送的用户
foreach ($userids as $uid) {
if ($this->getDelay() > 0) {
// 已经延迟过,检查消息是否已读
if ($msgId > 0) {
$isRead = WebSocketDialogMsgRead::whereMsgId($msgId)
->whereUserid($uid)
->whereNotNull('read_at')
->exists();
if ($isRead) {
// 已读,跳过推送
continue;
}
}
// 未读或无法判断,执行推送
$directPushUsers[] = $uid;
} else {
// 首次推送,检查 PC 端是否活跃
$lastActive = Cache::get("user_pc_active:{$uid}");
$isPcActive = $lastActive && (time() - $lastActive) < 60;
if ($isPcActive) {
// PC 端活跃,需要延迟推送
$delayedPushUsers[] = $uid;
} else {
// PC 端不活跃,直接推送
$directPushUsers[] = $uid;
}
}
}
// 直接推送
if ($directPushUsers) {
UmengAlias::pushMsgToUserid($directPushUsers, $this->array);
}
// 创建延迟推送任务
if ($delayedPushUsers) {
$this->endPush[] = [
'userid' => $delayedPushUsers,
'array' => $this->array,
];
}
}
public function end()
{
if (empty($this->endPush)) {
return;
}
foreach ($this->endPush as $item) {
$task = new PushUmengMsg($item['userid'], $item['array']);
$task->delay(10);
Task::deliver($task);
}
}
}

View File

@@ -140,7 +140,7 @@ class WebSocketDialogMsgTask extends AbstractTask
];
// 机器人收到消处理
$botUser = User::whereUserid($userid)->whereBot(1)->first();
if ($botUser) {
if ($botUser) { // 避免机器人处理自己发送的消息
$this->endArray[] = new BotReceiveMsgTask($botUser->userid, $msg->id, $mentions, $this->client);
}
}
@@ -211,6 +211,10 @@ class WebSocketDialogMsgTask extends AbstractTask
'description' => "MID:{$msg->id}",
'seconds' => 3600,
'badge' => 1,
'extra' => [
'dialog_id' => $msg->dialog_id,
'msg_id' => $msg->id,
]
];
$this->endArray[] = new PushUmengMsg($uids->toArray(), $umengMsg);
}

395
bin/version.js vendored

File diff suppressed because one or more lines are too long

59
cmd
View File

@@ -119,6 +119,14 @@ switch_debug() {
fi
}
# 检查是否有sudo
check_sudo() {
if [ "$EUID" -ne 0 ]; then
error "请使用 sudo 运行此脚本"
exit 1
fi
}
# 检查docker、docker-compose
check_docker() {
docker --version &> /dev/null
@@ -175,7 +183,15 @@ web_build() {
fi
if [ "$type" = "dev" ]; then
echo "<script>window.location.href=window.location.href.replace(/:\d+/, ':' + $(env_get APP_PORT))</script>" > ./index.html
env_set APP_DEV_PORT $(rand 20001 30000)
if [ -z "$(env_get APP_DEV_PORT)" ]; then
env_set APP_DEV_PORT $(rand 20001 30000)
fi
if [ -n "${VSCODE_PROXY_URI:-}" ]; then
APP_REAL_URI=$(TARGET_PORT="$(env_get APP_PORT)" node -p "process.env.VSCODE_PROXY_URI.replace(/\{\{port\}\}/g, process.env.TARGET_PORT || '')")
VSCODE_PROXY_URI=$(APP_DEV_PORT="$(env_get APP_DEV_PORT)" node -p "process.env.VSCODE_PROXY_URI.replace(/\{\{port\}\}/g, process.env.APP_DEV_PORT || '')")
echo "<script>window.location.href='${APP_REAL_URI}'</script>" > ./index.html
fi
env_set VSCODE_PROXY_URI "${VSCODE_PROXY_URI:-}"
fi
switch_debug "$type"
#
@@ -246,25 +262,33 @@ mysql_snapshot() {
password=$(env_get DB_PASSWORD)
# 还原数据库
mkdir -p ${WORK_DIR}/docker/mysql/backup
list=`ls -1 "${WORK_DIR}/docker/mysql/backup" | grep ".sql.gz"`
if [ -z "$list" ]; then
shopt -s nullglob
backup_files=("${WORK_DIR}/docker/mysql/backup/"*.sql.gz)
shopt -u nullglob
if [ ${#backup_files[@]} -eq 0 ]; then
error "没有备份文件!"
exit 1
fi
echo "$list"
read -rp "请输入备份文件名称还原:" inputname
filename="${WORK_DIR}/docker/mysql/backup/${inputname}"
if [ ! -f "$filename" ]; then
error "备份文件:${inputname} 不存在!"
exit 1
fi
echo "可用备份列表:"
for idx in "${!backup_files[@]}"; do
printf "%2d) %s\n" "$((idx + 1))" "$(basename "${backup_files[$idx]}")"
done
while true; do
read -rp "请输入备份文件编号还原:" selection
if [[ "$selection" =~ ^[0-9]+$ ]] && [ "$selection" -ge 1 ] && [ "$selection" -le ${#backup_files[@]} ]; then
break
fi
warning "编号无效,请重新输入。"
done
filename="${backup_files[$((selection - 1))]}"
inputname="$(basename "$filename")"
container_name=`docker_name mariadb`
if [ -z "$container_name" ]; then
error "没有找到 mariadb 容器!"
exit 1
fi
docker cp $filename ${container_name}:/
container_exec mariadb "gunzip < /${inputname} | mysql -u${username} -p${password} $database"
docker cp "$filename" "${container_name}:/"
container_exec mariadb "gunzip < '/${inputname}' | mysql -u${username} -p${password} $database"
container_exec php "php artisan migrate"
judge "还原数据库"
fi
@@ -459,6 +483,8 @@ EOF
# 安装函数
handle_install() {
check_sudo
local relock=$(arg_get relock)
local port=$(arg_get port)
@@ -479,7 +505,8 @@ handle_install() {
for vol in "${volumes[@]}"; do
tmp_path="${WORK_DIR}/${vol}"
mkdir -p "${tmp_path}"
chmod -R 775 "${tmp_path}"
find "${tmp_path}" -type d -exec chmod 775 {} \;
rm -f "${tmp_path}/dootask.lock"
cmda="${cmda} -v ${tmp_path}:/usr/share/${vol}"
cmdb="${cmdb} touch /usr/share/${vol}/dootask.lock &&"
@@ -547,6 +574,8 @@ handle_install() {
# 更新函数
handle_update() {
check_sudo
local target_branch=$(arg_get branch)
local is_local=$(arg_get local)
local force_update=$(arg_get force)
@@ -617,7 +646,7 @@ handle_update() {
fi
# 更新依赖
exec_judge "container_exec php 'composer update --optimize-autoloader'" "更新PHP依赖失败"
exec_judge "container_exec php 'composer install --optimize-autoloader'" "更新PHP依赖失败"
else
# 本地更新模式
echo "执行数据库备份..."
@@ -644,6 +673,7 @@ handle_update() {
# 卸载函数
handle_uninstall() {
check_sudo
# 确认卸载
echo -e "${RedBG}警告:此操作将永久删除以下内容:${Font}"
echo "- 数据库"
@@ -775,6 +805,7 @@ case "$1" in
shift 1
container_exec php "php app/Http/Controllers/Api/apidoc.php"
docker run -it --rm -v ${WORK_DIR}:/home/node/apidoc kuaifan/apidoc -i app/Http/Controllers/Api -o public/docs
container_exec php "php app/Http/Controllers/Api/apidoc.php restore"
;;
"debug")
shift 1

805
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateReportAiAnalysesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
if (Schema::hasTable('report_ai_analyses')) {
return;
}
Schema::create('report_ai_analyses', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('rid')->comment('报告ID');
$table->unsignedBigInteger('userid')->comment('生成分析的会员ID');
$table->string('model')->default('')->comment('使用的模型名称');
$table->longText('analysis_text')->comment('AI 分析的原始文本Markdown');
$table->json('meta')->nullable()->comment('额外的上下文信息');
$table->timestamps();
$table->unique(['rid', 'userid'], 'uk_report_ai_analysis_rid_userid');
$table->index(['userid', 'updated_at'], 'idx_report_ai_analysis_user_updated');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('report_ai_analyses');
}
}

View File

@@ -1,35 +0,0 @@
<?php
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
use App\Module\Base;
use Illuminate\Database\Migrations\Migration;
class CreateAiSettingsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
$setting = Base::setting('aibotSetting');
Base::setting('aiSetting', [
'ai_provider' => 'openai',
'ai_api_key' => $setting['openai_key'],
'ai_api_url' => $setting['openai_base_url'],
'ai_proxy' => $setting['openai_agency'],
]);
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
// This migration does not need to be reversible
}
}

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddSortFieldToProjectUsersTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('project_users', function (Blueprint $table) {
// 添加一个排序sort字段
if (!Schema::hasColumn('project_users', 'sort')) {
$table->integer('sort')->nullable()->default(0)->after('top_at')->comment('排序(ASC)');
}
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('project_users', function (Blueprint $table) {
// 删除排序sort字段
if (Schema::hasColumn('project_users', 'sort')) {
$table->dropColumn('sort');
}
});
}
}

View File

@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddGuestAccessToFilesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
$isAdd = false;
Schema::table('files', function (Blueprint $table) use (&$isAdd) {
if (!Schema::hasColumn('files', 'guest_access')) {
$table->tinyInteger('guest_access')->nullable()->default(0)->comment('是否允许游客访问')->after('share');
$isAdd = true;
}
});
if ($isAdd) {
// 更新现有记录的guest_access字段为0默认不允许游客访问
\DB::table('files')->whereNull('guest_access')->update(['guest_access' => 0]);
}
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('files', function (Blueprint $table) {
if (Schema::hasColumn('files', 'guest_access')) {
$table->dropColumn('guest_access');
}
});
}
}

View File

@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateUserTaskBrowsesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
if (Schema::hasTable('user_task_browses'))
return;
Schema::create('user_task_browses', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('userid')->index()->nullable()->default(0)->comment('用户ID');
$table->bigInteger('task_id')->index()->nullable()->default(0)->comment('任务ID');
$table->timestamp('browsed_at')->index()->nullable()->comment('浏览时间');
$table->timestamps();
// 复合索引用户ID + 浏览时间(用于按时间排序获取用户浏览历史)
$table->index(['userid', 'browsed_at']);
// 唯一索引用户ID + 任务ID防止重复记录相同任务会更新浏览时间
$table->unique(['userid', 'task_id']);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('user_task_browses');
}
}

View File

@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateUserRecentItemsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
if (Schema::hasTable('user_recent_items')) {
return;
}
Schema::create('user_recent_items', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('userid')->index()->default(0)->comment('用户ID');
$table->string('target_type', 50)->default('')->comment('目标类型(task/file/task_file/message_file 等)');
$table->bigInteger('target_id')->default(0)->comment('目标ID');
$table->string('source_type', 50)->default('')->comment('来源类型(project/filesystem/project_task/dialog 等)');
$table->bigInteger('source_id')->default(0)->comment('来源ID');
$table->timestamp('browsed_at')->nullable()->index()->comment('浏览时间');
$table->timestamps();
$table->index(['userid', 'browsed_at']);
$table->unique(['userid', 'target_type', 'target_id', 'source_type', 'source_id'], 'recent_unique');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('user_recent_items');
}
}

View File

@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateUserFavoritesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
if (Schema::hasTable('user_favorites'))
return;
Schema::create('user_favorites', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('userid')->index()->nullable()->default(0)->comment('用户ID');
$table->string('favoritable_type', 50)->index()->nullable()->default('')->comment('收藏类型(比如task/project/file/message)');
$table->bigInteger('favoritable_id')->index()->nullable()->default(0)->comment('收藏对象ID');
$table->timestamps();
// 复合索引用户ID + 收藏类型(用于按类型获取收藏列表)
$table->index(['userid', 'favoritable_type']);
// 唯一索引用户ID + 收藏类型 + 收藏对象ID防止重复收藏
$table->unique(['userid', 'favoritable_type', 'favoritable_id'], 'user_favorites_unique');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('user_favorites');
}
}

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddRemarkToUserFavoritesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('user_favorites', function (Blueprint $table) {
if (!Schema::hasColumn('user_favorites', 'remark')) {
$table->string('remark', 255)->default('')->after('favoritable_id')->comment('收藏备注');
}
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('user_favorites', function (Blueprint $table) {
if (Schema::hasColumn('user_favorites', 'remark')) {
$table->dropColumn('remark');
}
});
}
}

View File

@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddSortToProjectTagsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
$added = false;
Schema::table('project_tags', function (Blueprint $table) use (&$added) {
if (!Schema::hasColumn('project_tags', 'sort')) {
$table->unsignedInteger('sort')->default(0)->after('color')->comment('排序');
$added = true;
}
});
if ($added) {
\App\Models\ProjectTag::query()
->select('project_id')
->distinct()
->orderBy('project_id')
->chunk(100, function ($projectIds) {
foreach ($projectIds as $project) {
$tags = \App\Models\ProjectTag::query()
->where('project_id', $project->project_id)
->orderByDesc('id')
->get(['id']);
$index = 0;
foreach ($tags as $tag) {
\App\Models\ProjectTag::where('id', $tag->id)->update(['sort' => $index++]);
}
}
});
}
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('project_tags', function (Blueprint $table) {
if (Schema::hasColumn('project_tags', 'sort')) {
$table->dropColumn('sort');
}
});
}
}

View File

@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Schema;
class BackfillSortProjectTaskTemplates extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
if (!Schema::hasTable('project_task_templates') || !Schema::hasColumn('project_task_templates', 'sort')) {
return;
}
\App\Models\ProjectTaskTemplate::query()
->select('project_id')
->distinct()
->orderBy('project_id')
->chunk(100, function ($projects) {
foreach ($projects as $project) {
$templates = \App\Models\ProjectTaskTemplate::query()
->where('project_id', $project->project_id)
->orderByDesc('id')
->get(['id']);
$index = 0;
foreach ($templates as $template) {
\App\Models\ProjectTaskTemplate::where('id', $template->id)->update(['sort' => $index++]);
}
}
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
// no-op
}
}

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('project_task_relations', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('task_id')->comment('任务ID');
$table->unsignedBigInteger('related_task_id')->comment('关联任务ID');
$table->string('direction', 32)->default('mention')->comment('关系方向: mention/mentioned_by');
$table->unsignedBigInteger('dialog_id')->nullable()->comment('来源会话ID');
$table->unsignedBigInteger('msg_id')->nullable()->comment('来源消息ID');
$table->unsignedBigInteger('userid')->nullable()->comment('提及人');
$table->timestamps();
$table->unique(['task_id', 'related_task_id', 'direction'], 'project_task_relations_unique');
$table->index(['task_id', 'direction']);
$table->index('related_task_id');
$table->index('dialog_id');
$table->index('msg_id');
});
}
public function down(): void
{
Schema::dropIfExists('project_task_relations');
}
};

View File

@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('user_bots', function (Blueprint $table) {
if (!Schema::hasColumn('user_bots', 'webhook_events')) {
$table->text('webhook_events')->nullable()->after('webhook_num')->comment('Webhook事件配置');
}
});
DB::table('user_bots')
->where(function ($query) {
$query->whereNull('webhook_events')->orWhere('webhook_events', '');
})
->update(['webhook_events' => json_encode(['message'])]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('user_bots', function (Blueprint $table) {
if (Schema::hasColumn('user_bots', 'webhook_events')) {
$table->dropColumn('webhook_events');
}
});
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->date('birthday')->nullable()->after('profession');
$table->string('address', 255)->nullable()->after('birthday');
$table->text('introduction')->nullable()->after('address');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['birthday', 'address', 'introduction']);
});
}
};

View File

@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateUserTagsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('user_tags', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('user_id')->index()->comment('被标签用户ID');
$table->string('name', 50)->comment('标签名称');
$table->unsignedBigInteger('created_by')->index()->comment('创建人');
$table->unsignedBigInteger('updated_by')->nullable()->comment('最后更新人');
$table->timestamps();
$table->unique(['user_id', 'name'], 'user_tags_unique_name');
$table->foreign('user_id')->references('userid')->on('users')->onDelete('cascade');
$table->foreign('created_by')->references('userid')->on('users')->onDelete('cascade');
$table->foreign('updated_by')->references('userid')->on('users')->onDelete('set null');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('user_tags');
}
}

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateUserTagRecognitionsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('user_tag_recognitions', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('tag_id')->index()->comment('标签ID');
$table->unsignedBigInteger('user_id')->index()->comment('认可人ID');
$table->timestamps();
$table->unique(['tag_id', 'user_id'], 'user_tag_recognitions_unique');
$table->foreign('tag_id')->references('id')->on('user_tags')->onDelete('cascade');
$table->foreign('user_id')->references('userid')->on('users')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('user_tag_recognitions');
}
}

View File

@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('umeng_logs', function (Blueprint $table) {
$table->id();
$table->text('request')->nullable()->comment('请求参数');
$table->text('response')->nullable()->comment('推送返回');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('umeng_logs');
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateUserAppSortsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
if (Schema::hasTable('user_app_sorts')) {
return;
}
Schema::create('user_app_sorts', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('userid')->unique()->comment('用户ID');
$table->json('sorts')->nullable()->comment('排序配置');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('user_app_sorts');
}
}

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddWebSocketsPlatform extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('web_sockets', function (Blueprint $table) {
$table->string('platform', 20)->nullable()->default('')->after('path')->comment('平台类型android, ios, win, mac, web');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('web_sockets', function (Blueprint $table) {
$table->dropColumn('platform');
});
}
}

View File

@@ -96,10 +96,10 @@ services:
appstore:
container_name: "dootask-appstore-${APP_ID}"
privileged: true
image: "dootask/appstore:0.2.5"
image: "dootask/appstore:0.3.4"
volumes:
- shared_data:/usr/share/dootask
- /var/run/docker.sock:/var/run/docker.sock
- ${HOST_DOCKER_SOCK:-/var/run/docker.sock}:/var/run/docker.sock
- ./:/var/www
environment:
HOST_PWD: "${PWD}"

View File

@@ -1,6 +1,6 @@
[program:crontab]
directory=/var/www/docker/crontab
command=/etc/init.d/cron start
command=/usr/sbin/cron -f
numprocs=1
autostart=true
autorestart=false

2
electron/build.js vendored
View File

@@ -8,7 +8,7 @@ const yauzl = require('yauzl');
const axios = require('axios');
const FormData =require('form-data');
const tar = require('tar');
const utils = require('./utils');
const utils = require('./lib/utils');
const config = require('../package.json')
const env = require('dotenv').config({ path: './.env' })
const argv = process.argv;

687
electron/electron-down.js vendored Normal file
View File

@@ -0,0 +1,687 @@
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 utils = require("./lib/utils");
const {DownloadManager, DownloadStore} = require("./lib/download-manager");
const downloadManager = new DownloadManager();
let downloadWindow = null,
downloadLanguageCode = 'zh',
downloadWaiting = false;
function initialize(onStarted = null) {
// 下载配置
electronDl({
showBadge: false,
showProgressBar: false,
onStarted: (item) => {
downloadManager.add(item);
downloadWaiting = false;
syncDownloadItems();
if (typeof onStarted === 'function') {
onStarted(item)
}
},
onCancel: (item) => {
downloadManager.refresh(item.getSavePath())
syncDownloadItems();
},
onInterrupted: (item) => {
downloadManager.refresh(item.getSavePath());
syncDownloadItems();
// 尝试更新下载项的错误信息
downloadManager.updateError(item, {
language: downloadLanguageCode,
}).then(success => {
if (success) {
syncDownloadItems();
}
});
},
onProgress: (item) => {
downloadManager.refresh(item.path);
syncDownloadItems();
},
onCompleted: (item) => {
downloadManager.refresh(item.path);
syncDownloadItems();
}
});
// IPC
ipcMain.handle('downloadManager', async (event, {action, path}) => {
switch (action) {
case "get": {
return {
items: downloadManager.get(),
waiting: downloadWaiting,
};
}
case "pause": {
downloadManager.pause(path);
syncDownloadItems();
return true;
}
case "resume": {
downloadManager.resume(path);
syncDownloadItems();
return true;
}
case "cancel": {
downloadManager.cancel(path);
syncDownloadItems();
return true;
}
case "remove": {
downloadManager.remove(path);
syncDownloadItems();
return true;
}
case "removeAll": {
downloadManager.removeAll();
syncDownloadItems();
return true;
}
case "openFile": {
if (!fs.existsSync(path)) {
throw new Error('file not found');
}
return shell.openPath(path);
}
case "showFolder": {
if (!fs.existsSync(path)) {
throw new Error('file not found');
}
shell.showItemInFolder(path);
return true;
}
}
});
}
async function createDownload(window_, url, options = {}) {
downloadWaiting = true;
syncDownloadItems();
try {
return await download(window_, url, options);
} catch (error) {
// electron-dl rejects with CancelError when a download is cancelled; treat it as expected.
const isCancelError = (typeof CancelError === 'function' && error instanceof CancelError)
|| error?.name === 'CancelError';
if (!isCancelError) {
throw error;
}
return null;
} finally {
downloadWaiting = false;
syncDownloadItems();
}
}
function syncDownloadItems() {
// 同步下载项到渲染进程
if (downloadWindow) {
downloadWindow.webContents.send('download-items', {
items: downloadManager.get(),
waiting: downloadWaiting,
});
}
}
function getLanguageData(code) {
const packs = {
zh: {
// 语言设置
locale: 'zh-CN',
title: '下载管理器',
// 界面文本
searchPlaceholder: '搜索文件名或链接...',
noItems: '暂无任务',
noSearchResult: '未找到匹配的结果',
// 操作按钮
refresh: '刷新',
removeAll: "清空历史",
copyLink: '复制链接',
resume: '继续',
pause: '暂停',
cancel: '取消',
remove: '删除',
showInFolder: '显示在文件夹',
// 状态文本
progressing: '下载中',
completed: '已完成',
cancelled: '已取消',
interrupted: '失败',
paused: '已暂停',
// 成功消息
copied: "已复制",
refreshSuccess: '刷新成功',
// 确认对话框
confirmCancel: '确定要取消此下载任务并删除记录吗?',
confirmRemove: '确定要从历史记录中删除此项吗?',
confirmRemoveAll: '确定要清空下载历史吗?',
// 错误消息
copyFailed: '复制失败: ',
pauseFailed: '暂停失败: ',
resumeFailed: '继续失败: ',
removeFailed: '删除失败: ',
removeAllFailed: '清空失败: ',
openFailed: '打开文件失败: ',
showFailed: '显示文件失败: ',
},
'zh-CHT': {
locale: 'zh-TW',
title: '下載管理器',
// 界面文本
searchPlaceholder: '搜尋檔案名稱或連結...',
noItems: '暫無任務',
noSearchResult: '未找到匹配的結果',
// 操作按钮
refresh: '重新整理',
removeAll: "清空歷史",
copyLink: '複製連結',
resume: '繼續',
pause: '暫停',
cancel: '取消',
remove: '刪除',
showInFolder: '顯示在資料夾',
// 状态文本
progressing: '下載中',
completed: '已完成',
cancelled: '已取消',
interrupted: '失敗',
paused: '已暫停',
// 成功消息
copied: "已複製",
refreshSuccess: '重新整理成功',
// 确认对话框
confirmCancel: '確定要取消此下載任務並刪除記錄嗎?',
confirmRemove: '確定要從歷史記錄中刪除此項嗎?',
confirmRemoveAll: '確定要清空下載歷史嗎?',
// 错误消息
copyFailed: '複製失敗: ',
pauseFailed: '暫停失敗: ',
resumeFailed: '繼續失敗: ',
removeFailed: '刪除失敗: ',
removeAllFailed: '清空失敗: ',
openFailed: '開啟檔案失敗: ',
showFailed: '顯示檔案失敗: ',
},
en: {
locale: 'en-US',
title: 'Download Manager',
// 界面文本
searchPlaceholder: 'Search filename or link...',
noItems: 'No tasks',
noSearchResult: 'No matching results found',
// 操作按钮
refresh: 'Refresh',
removeAll: "Clear History",
copyLink: 'Copy Link',
resume: 'Resume',
pause: 'Pause',
cancel: 'Cancel',
remove: 'Remove',
showInFolder: 'Show in Folder',
// 状态文本
progressing: 'Downloading',
completed: 'Completed',
cancelled: 'Cancelled',
interrupted: 'Failed',
paused: 'Paused',
// 成功消息
copied: "Copied",
refreshSuccess: 'Refresh successful',
// 确认对话框
confirmCancel: 'Are you sure you want to cancel this download task and delete the record?',
confirmRemove: 'Are you sure you want to remove this item from history?',
confirmRemoveAll: 'Are you sure you want to clear download history?',
// 错误消息
copyFailed: 'Copy failed: ',
pauseFailed: 'Pause failed: ',
resumeFailed: 'Resume failed: ',
removeFailed: 'Remove failed: ',
removeAllFailed: 'Clear failed: ',
openFailed: 'Open file failed: ',
showFailed: 'Show file failed: ',
},
ko: {
locale: 'ko-KR',
title: '다운로드 관리자',
// 界面文本
searchPlaceholder: '파일명 또는 링크 검색...',
noItems: '작업 없음',
noSearchResult: '일치하는 결과를 찾을 수 없습니다',
// 操作按钮
refresh: '새로고침',
removeAll: "기록 지우기",
copyLink: '링크 복사',
resume: '계속',
pause: '일시정지',
cancel: '취소',
remove: '삭제',
showInFolder: '폴더에서 보기',
// 状态文本
progressing: '다운로드 중',
completed: '완료됨',
cancelled: '취소됨',
interrupted: '실패',
paused: '일시정지됨',
// 成功消息
copied: "복사됨",
refreshSuccess: '새로고침 성공',
// 确认对话框
confirmCancel: '이 다운로드 작업을 취소하고 기록을 삭제하시겠습니까?',
confirmRemove: '기록에서 이 항목을 삭제하시겠습니까?',
confirmRemoveAll: '다운로드 기록을 지우시겠습니까?',
// 错误消息
copyFailed: '복사 실패: ',
pauseFailed: '일시정지 실패: ',
resumeFailed: '계속 실패: ',
removeFailed: '삭제 실패: ',
removeAllFailed: '지우기 실패: ',
openFailed: '파일 열기 실패: ',
showFailed: '파일 표시 실패: ',
},
ja: {
locale: 'ja-JP',
title: 'ダウンロードマネージャー',
// 界面文本
searchPlaceholder: 'ファイル名またはリンクを検索...',
noItems: 'タスクがありません',
noSearchResult: '一致する結果が見つかりません',
// 操作按钮
refresh: '更新',
removeAll: "履歴をクリア",
copyLink: 'リンクをコピー',
resume: '再開',
pause: '一時停止',
cancel: 'キャンセル',
remove: '削除',
showInFolder: 'フォルダで表示',
// 状态文本
progressing: 'ダウンロード中',
completed: '完了',
cancelled: 'キャンセル済み',
interrupted: '失敗',
paused: '一時停止中',
// 成功消息
copied: "コピーしました",
refreshSuccess: '更新が完了しました',
// 确认对话框
confirmCancel: 'このダウンロードタスクをキャンセルして記録を削除しますか?',
confirmRemove: '履歴からこの項目を削除しますか?',
confirmRemoveAll: 'ダウンロード履歴をクリアしますか?',
// 错误消息
copyFailed: 'コピーに失敗しました: ',
pauseFailed: '一時停止に失敗しました: ',
resumeFailed: '再開に失敗しました: ',
removeFailed: '削除に失敗しました: ',
removeAllFailed: 'クリアに失敗しました: ',
openFailed: 'ファイルを開けませんでした: ',
showFailed: 'ファイルの表示に失敗しました: ',
},
de: {
locale: 'de-DE',
title: 'Download-Manager',
// 界面文本
searchPlaceholder: 'Dateiname oder Link suchen...',
noItems: 'Keine Aufgaben',
noSearchResult: 'Keine übereinstimmenden Ergebnisse gefunden',
// 操作按钮
refresh: 'Aktualisieren',
removeAll: "Verlauf löschen",
copyLink: 'Link kopieren',
resume: 'Fortsetzen',
pause: 'Pause',
cancel: 'Abbrechen',
remove: 'Entfernen',
showInFolder: 'Im Ordner anzeigen',
// 状态文本
progressing: 'Wird heruntergeladen',
completed: 'Abgeschlossen',
cancelled: 'Abgebrochen',
interrupted: 'Fehlgeschlagen',
paused: 'Pausiert',
// 成功消息
copied: "Kopiert",
refreshSuccess: 'Erfolgreich aktualisiert',
// 确认对话框
confirmCancel: 'Sind Sie sicher, dass Sie diese Download-Aufgabe abbrechen und den Eintrag löschen möchten?',
confirmRemove: 'Sind Sie sicher, dass Sie diesen Eintrag aus dem Verlauf entfernen möchten?',
confirmRemoveAll: 'Sind Sie sicher, dass Sie den Download-Verlauf löschen möchten?',
// 错误消息
copyFailed: 'Kopieren fehlgeschlagen: ',
pauseFailed: 'Pause fehlgeschlagen: ',
resumeFailed: 'Fortsetzen fehlgeschlagen: ',
removeFailed: 'Entfernen fehlgeschlagen: ',
removeAllFailed: 'Löschen fehlgeschlagen: ',
openFailed: 'Datei öffnen fehlgeschlagen: ',
showFailed: 'Datei anzeigen fehlgeschlagen: ',
},
fr: {
locale: 'fr-FR',
title: 'Gestionnaire de téléchargements',
// 界面文本
searchPlaceholder: 'Rechercher nom de fichier ou lien...',
noItems: 'Aucune tâche',
noSearchResult: 'Aucun résultat correspondant trouvé',
// 操作按钮
refresh: 'Actualiser',
removeAll: "Effacer l'historique",
copyLink: 'Copier le lien',
resume: 'Reprendre',
pause: 'Pause',
cancel: 'Annuler',
remove: 'Supprimer',
showInFolder: 'Afficher dans le dossier',
// 状态文本
progressing: 'Téléchargement en cours',
completed: 'Terminé',
cancelled: 'Annulé',
interrupted: 'Échoué',
paused: 'En pause',
// 成功消息
copied: "Copié",
refreshSuccess: 'Actualisation réussie',
// 确认对话框
confirmCancel: 'Êtes-vous sûr de vouloir annuler cette tâche de téléchargement et supprimer l\'enregistrement ?',
confirmRemove: 'Êtes-vous sûr de vouloir supprimer cet élément de l\'historique ?',
confirmRemoveAll: 'Êtes-vous sûr de vouloir effacer l\'historique des téléchargements ?',
// 错误消息
copyFailed: 'Échec de la copie : ',
pauseFailed: 'Échec de la pause : ',
resumeFailed: 'Échec de la reprise : ',
removeFailed: 'Échec de la suppression : ',
removeAllFailed: 'Échec de l\'effacement : ',
openFailed: 'Échec de l\'ouverture du fichier : ',
showFailed: 'Échec de l\'affichage du fichier : ',
},
id: {
locale: 'id-ID',
title: 'Manajer Unduhan',
// 界面文本
searchPlaceholder: 'Cari nama file atau tautan...',
noItems: 'Tidak ada tugas',
noSearchResult: 'Tidak ada hasil yang cocok ditemukan',
// 操作按钮
refresh: 'Segarkan',
removeAll: "Hapus Riwayat",
copyLink: 'Salin Tautan',
resume: 'Lanjutkan',
pause: 'Jeda',
cancel: 'Batal',
remove: 'Hapus',
showInFolder: 'Tampilkan di Folder',
// 状态文本
progressing: 'Mengunduh',
completed: 'Selesai',
cancelled: 'Dibatalkan',
interrupted: 'Gagal',
paused: 'Dijeda',
// 成功消息
copied: "Disalin",
refreshSuccess: 'Berhasil disegarkan',
// 确认对话框
confirmCancel: 'Apakah Anda yakin ingin membatalkan tugas unduhan ini dan menghapus catatan?',
confirmRemove: 'Apakah Anda yakin ingin menghapus item ini dari riwayat?',
confirmRemoveAll: 'Apakah Anda yakin ingin menghapus riwayat unduhan?',
// 错误消息
copyFailed: 'Gagal menyalin: ',
pauseFailed: 'Gagal menjeda: ',
resumeFailed: 'Gagal melanjutkan: ',
removeFailed: 'Gagal menghapus: ',
removeAllFailed: 'Gagal menghapus: ',
openFailed: 'Gagal membuka file: ',
showFailed: 'Gagal menampilkan file: ',
},
ru: {
locale: 'ru-RU',
title: 'Менеджер загрузок',
// 界面文本
searchPlaceholder: 'Поиск имени файла или ссылки...',
noItems: 'Нет задач',
noSearchResult: 'Совпадающих результатов не найдено',
// 操作按钮
refresh: 'Обновить',
removeAll: "Очистить историю",
copyLink: 'Копировать ссылку',
resume: 'Возобновить',
pause: 'Пауза',
cancel: 'Отмена',
remove: 'Удалить',
showInFolder: 'Показать в папке',
// 状态文本
progressing: 'Загружается',
completed: 'Завершено',
cancelled: 'Отменено',
interrupted: 'Ошибка',
paused: 'На паузе',
// 成功消息
copied: "Скопировано",
refreshSuccess: 'Успешно обновлено',
// 确认对话框
confirmCancel: 'Вы уверены, что хотите отменить эту задачу загрузки и удалить запись?',
confirmRemove: 'Вы уверены, что хотите удалить этот элемент из истории?',
confirmRemoveAll: 'Вы уверены, что хотите очистить историю загрузок?',
// 错误消息
copyFailed: 'Ошибка копирования: ',
pauseFailed: 'Ошибка паузы: ',
resumeFailed: 'Ошибка возобновления: ',
removeFailed: 'Ошибка удаления: ',
removeAllFailed: 'Ошибка очистки: ',
openFailed: 'Ошибка открытия файла: ',
showFailed: 'Ошибка отображения файла: ',
}
};
downloadLanguageCode = code;
return packs[code] || packs.zh;
}
async function open(language = 'zh', theme = 'light') {
// 获取语言包
const finalLanguage = getLanguageData(language);
// 如果窗口已存在,直接显示
if (downloadWindow) {
// 更新窗口数据
await updateWindow(language, theme)
// 显示窗口并聚焦
downloadWindow.show();
downloadWindow.focus();
return;
}
// 窗口默认参数
const downloadWindowOptions = {
width: 700,
height: 480,
minWidth: 500,
minHeight: 350,
center: true,
show: false,
autoHideMenuBar: true,
title: finalLanguage.title,
backgroundColor: utils.getDefaultBackgroundColor(),
webPreferences: {
preload: path.join(__dirname, 'electron-preload.js'),
webSecurity: true,
nodeIntegration: true,
contextIsolation: true,
}
}
// 恢复窗口位置
const downloadWindowBounds = DownloadStore.get('downloadWindowBounds', {});
if (
downloadWindowBounds.width !== undefined &&
downloadWindowBounds.height !== undefined &&
downloadWindowBounds.x !== undefined &&
downloadWindowBounds.y !== undefined
) {
// 获取所有显示器的可用区域
const displays = screen.getAllDisplays();
// 检查窗口是否在任意一个屏幕内
let isInScreen = false;
for (const display of displays) {
const area = display.workArea;
if (
downloadWindowBounds.x + downloadWindowBounds.width > area.x &&
downloadWindowBounds.x < area.x + area.width &&
downloadWindowBounds.y + downloadWindowBounds.height > area.y &&
downloadWindowBounds.y < area.y + area.height
) {
isInScreen = true;
break;
}
}
// 如果超出所有屏幕,则移动到主屏幕可见区域
if (!isInScreen) {
const primaryArea = screen.getPrimaryDisplay().workArea;
downloadWindowBounds.x = primaryArea.x + 50;
downloadWindowBounds.y = primaryArea.y + 50;
// 防止窗口太大超出屏幕
downloadWindowBounds.width = Math.min(downloadWindowBounds.width, primaryArea.width - 100);
downloadWindowBounds.height = Math.min(downloadWindowBounds.height, primaryArea.height - 100);
}
downloadWindowOptions.center = false;
downloadWindowOptions.width = downloadWindowBounds.width;
downloadWindowOptions.height = downloadWindowBounds.height;
downloadWindowOptions.x = downloadWindowBounds.x;
downloadWindowOptions.y = downloadWindowBounds.y;
}
// 创建窗口
downloadWindow = new BrowserWindow(downloadWindowOptions);
// 禁止修改窗口标题
downloadWindow.on('page-title-updated', (event) => {
event.preventDefault()
})
// 监听窗口关闭保存窗口位置
downloadWindow.on('close', () => {
const bounds = downloadWindow.getBounds();
DownloadStore.set('downloadWindowBounds', bounds);
});
// 监听窗口关闭事件
downloadWindow.on('closed', () => {
downloadWindow = null;
});
// 加载下载管理器页面
const htmlPath = path.join(__dirname, 'render', 'download', 'index.html');
const themeParam = (theme === 'dark' ? 'dark' : 'light');
await downloadWindow.loadFile(htmlPath, {query: {theme: themeParam}});
// 将语言包发送到渲染进程
downloadWindow.webContents.once('dom-ready', () => {
updateWindow(language, theme)
});
// 显示窗口
downloadWindow.show();
}
function close() {
if (downloadWindow) {
downloadWindow.close();
downloadWindow = null;
}
}
function destroy() {
if (downloadWindow) {
downloadWindow.destroy();
downloadWindow = null;
}
}
async function updateWindow(language, theme) {
if (downloadWindow) {
try {
const finalLanguage = getLanguageData(language);
downloadWindow.setTitle(finalLanguage.title);
downloadWindow.webContents.send('download-theme', theme);
downloadWindow.webContents.send('download-language', finalLanguage);
syncDownloadItems()
} catch (error) {
loger.error(error);
}
}
}
module.exports = {
initialize,
createDownload,
open,
close,
destroy,
updateWindow
}

View File

@@ -9,7 +9,7 @@ const {
const fs = require('fs')
const url = require('url')
const request = require("request");
const utils = require('./utils')
const utils = require('./lib/utils')
const MAILTO_PREFIX = "mailto:";

View File

@@ -32,7 +32,13 @@ contextBridge.exposeInMainWorld(
'electron', {
request: (msg, callback, error) => {
msg.reqId = reqId++;
reqInfo[msg.reqId] = {callback: callback, error: error};
if (typeof callback !== "function") {
callback = function () {};
}
if (typeof error !== "function") {
error = function () {};
}
reqInfo[msg.reqId] = {callback, error};
if (msg.action == 'watchFile') {
fileChangedListeners[msg.path] = msg.listener;
delete msg.listener;

367
electron/electron.js vendored
View File

@@ -1,7 +1,17 @@
// Node.js 核心模块
const fs = require('fs')
const os = require("os");
const path = require('path')
const spawn = require("child_process").spawn;
const fsProm = require('fs/promises');
const crc = require('crc');
const zlib = require('zlib');
// Web 服务相关
const express = require('express')
const axios = require('axios');
// Electron 核心模块
const {
app,
ipcMain,
@@ -13,59 +23,86 @@ const {
nativeTheme,
Tray,
Menu,
BrowserView,
WebContentsView,
BrowserWindow
} = require('electron')
// 禁用渲染器后台化
app.commandLine.appendSwitch('disable-renderer-backgrounding');
app.commandLine.appendSwitch('disable-backgrounding-occluded-windows');
// Electron 扩展和工具
const {autoUpdater} = require("electron-updater")
const Store = require("electron-store");
const loger = require("electron-log");
const axios = require('axios');
const electronConf = require('electron-config')
const userConf = new electronConf()
const fsProm = require('fs/promises');
const PDFDocument = require('pdf-lib').PDFDocument;
const Screenshots = require("electron-screenshots-tool").Screenshots;
const crc = require('crc');
const zlib = require('zlib');
const utils = require('./utils');
// PDF 处理
const PDFDocument = require('pdf-lib').PDFDocument;
// 本地模块和配置
const utils = require('./lib/utils');
const config = require('./package.json');
const electronDown = require("./electron-down");
const electronMenu = require("./electron-menu");
const spawn = require("child_process").spawn;
const { startMCPServer, stopMCPServer } = require("./lib/mcp");
// 实例初始化
const userConf = new electronConf()
const store = new Store();
// 平台检测常量
const isMac = process.platform === 'darwin'
const isWin = process.platform === 'win32'
// URL 和调用验证正则
const allowedUrls = /^(?:https?|mailto|tel|callto):/i;
const allowedCalls = /^(?:mailto|tel|callto):/i;
const cacheDir = path.join(os.tmpdir(), 'dootask-cache')
let updaterLockFile = path.join(cacheDir, '.dootask_updater.lock');
let enableStoreBkp = true;
let dialogOpen = false;
let enablePlugins = false;
let mainWindow = null,
mainTray = null,
// 路径和缓存配置
const cacheDir = path.join(os.tmpdir(), 'dootask-cache')
const updaterLockFile = path.join(cacheDir, '.dootask_updater.lock');
// 应用状态标志
let enableStoreBkp = true,
dialogOpen = false,
enablePlugins = false,
isReady = false,
willQuitApp = false,
devloadPath = path.resolve(__dirname, ".devload"),
isDevelopMode = false,
serverPort = 22223,
isDevelopMode = false;
// 服务器配置
let serverPort = 22223,
mcpPort = 22224,
serverPublicDir = path.join(__dirname, 'public'),
serverUrl = "",
serverTimer = null;
// 截图相关变量
let screenshotObj = null,
screenshotKey = null;
let childWindow = [],
// 窗口实例变量
let mainWindow = null,
mainTray = null,
preloadWindow = null,
mediaWindow = null,
mediaType = null,
webTabWindow = null,
webTabView = [],
webTabWindow = null;
// 窗口数组和状态
let childWindow = [],
webTabView = [];
// 窗口配置和状态
let mediaType = null,
webTabHeight = 40,
webTabClosedByShortcut = false;
// 开发模式路径
let devloadPath = path.resolve(__dirname, ".devload");
// 窗口显示状态管理
let showState = {},
onShowWindow = (win) => {
try {
@@ -78,6 +115,7 @@ let showState = {},
}
}
// 开发模式加载
if (fs.existsSync(devloadPath)) {
let devloadContent = fs.readFileSync(devloadPath, 'utf8')
if (devloadContent.startsWith('http')) {
@@ -86,10 +124,18 @@ if (fs.existsSync(devloadPath)) {
}
}
// 缓存目录检查
if (!fs.existsSync(cacheDir)) {
fs.mkdirSync(cacheDir, { recursive: true });
}
// 初始化下载
electronDown.initialize(() => {
if (mainWindow) {
mainWindow.webContents.send("openDownloadWindow", {})
}
})
/**
* 启动web服务
*/
@@ -103,16 +149,16 @@ async function startWebServer(force = false) {
return new Promise((resolve, reject) => {
// 创建Express应用
const app = express();
const expressApp = express();
// 健康检查
app.head('/health', (req, res) => {
expressApp.head('/health', (req, res) => {
res.status(200).send('OK');
});
// 使用express.static中间件提供静态文件服务
// Express内置了全面的MIME类型支持无需手动配置
app.use(express.static(serverPublicDir, {
expressApp.use(express.static(serverPublicDir, {
// 设置默认文件
index: ['index.html', 'index.htm'],
// 启用etag缓存
@@ -134,12 +180,12 @@ async function startWebServer(force = false) {
}));
// 404处理中间件
app.use((req, res) => {
expressApp.use((req, res) => {
res.status(404).send('File not found');
});
// 错误处理中间件
app.use((err, req, res, next) => {
expressApp.use((err, req, res, next) => {
// 不是ENOENT错误记录error级别日志
if (err.code !== 'ENOENT') {
loger.error('Server error:', err);
@@ -202,7 +248,7 @@ async function startWebServer(force = false) {
});
// 启动服务器
const server = app.listen(serverPort, 'localhost', () => {
const server = expressApp.listen(serverPort, 'localhost', () => {
loger.info(`Express static file server running at http://localhost:${serverPort}/`);
loger.info(`Serving files from: ${serverPublicDir}`);
serverUrl = `http://localhost:${serverPort}/`;
@@ -265,7 +311,7 @@ function createMainWindow() {
webSecurity: true,
nodeIntegration: true,
contextIsolation: true,
nativeWindowOpen: true
backgroundThrottling: false,
}
})
@@ -309,10 +355,10 @@ function createMainWindow() {
// 新窗口处理
mainWindow.webContents.setWindowOpenHandler(({url}) => {
if (allowedCalls.test(url)) {
openExternal(url)
openExternal(url).catch(() => {})
} else {
utils.onBeforeOpenWindow(mainWindow.webContents, url).then(() => {
openExternal(url)
openExternal(url).catch(() => {})
})
}
return {action: 'deny'}
@@ -416,7 +462,6 @@ function preCreateChildWindow() {
webSecurity: true,
nodeIntegration: true,
contextIsolation: true,
nativeWindowOpen: true
}
});
@@ -452,6 +497,14 @@ function createChildWindow(args) {
const wind = childWindow.find(item => item.name == name);
let browser = wind ? wind.browser : null;
let isPreload = false;
// 清理已销毁但仍被引用的窗口,避免对失效对象调用方法
if (browser && browser.isDestroyed && browser.isDestroyed()) {
const index = childWindow.findIndex(item => item.name == name);
if (index > -1) {
childWindow.splice(index, 1);
}
browser = null;
}
if (browser) {
browser.focus();
if (args.force === false) {
@@ -474,14 +527,21 @@ function createChildWindow(args) {
webSecurity: true,
nodeIntegration: true,
contextIsolation: true,
nativeWindowOpen: true
}, webPreferences),
}, config)
options.width = utils.normalizeSize(options.width, 1280)
options.height = utils.normalizeSize(options.height, 800)
options.minWidth = utils.normalizeSize(options.minWidth, 360)
options.minHeight = utils.normalizeSize(options.minHeight, 360)
if (!options.webPreferences.contextIsolation) {
delete options.webPreferences.preload;
}
if (options.parent) {
options.parent = mainWindow
}
if (preloadWindow && Object.keys(webPreferences).length === 0) {
if (preloadWindow && !preloadWindow.isDestroyed?.() && Object.keys(webPreferences).length === 0) {
// 使用预加载窗口
browser = preloadWindow;
preloadWindow = null;
@@ -527,7 +587,7 @@ function createChildWindow(args) {
})
browser.on('closed', () => {
const index = childWindow.findIndex(item => item.name == name);
const index = childWindow.findIndex(item => item.browser === browser);
if (index > -1) {
childWindow.splice(index, 1)
}
@@ -551,10 +611,10 @@ function createChildWindow(args) {
// 新窗口处理
browser.webContents.setWindowOpenHandler(({url}) => {
if (allowedCalls.test(url)) {
openExternal(url)
openExternal(url).catch(() => {})
} else {
utils.onBeforeOpenWindow(browser.webContents, url).then(() => {
openExternal(url)
openExternal(url).catch(() => {})
})
}
return {action: 'deny'}
@@ -723,9 +783,8 @@ function createWebTabWindow(args) {
webSecurity: true,
nodeIntegration: true,
contextIsolation: true,
nativeWindowOpen: true
},
}, userConf.get('webTabWindow', {})))
}, userConf.get('webTabWindow') || {}))
const originalClose = webTabWindow.close;
webTabWindow.close = function() {
@@ -801,17 +860,16 @@ function createWebTabWindow(args) {
webTabWindow.show();
// 创建 tab 子窗口
const viewOptions = Object.assign({
useHTMLTitleAndIcon: true,
useLoadingView: true,
useErrorView: true,
}, args.config || {})
const viewOptions = args.config || {}
viewOptions.webPreferences = Object.assign({
preload: path.join(__dirname, 'electron-preload.js'),
nodeIntegration: true,
contextIsolation: true
}, args.webPreferences || {})
const browserView = new BrowserView(viewOptions)
if (!viewOptions.webPreferences.contextIsolation) {
delete viewOptions.webPreferences.preload;
}
const browserView = new WebContentsView(viewOptions)
if (args.backgroundColor) {
browserView.setBackgroundColor(args.backgroundColor)
} else if (nativeTheme.shouldUseDarkColors) {
@@ -830,7 +888,7 @@ function createWebTabWindow(args) {
})
browserView.webContents.setWindowOpenHandler(({url}) => {
if (allowedCalls.test(url)) {
openExternal(url)
openExternal(url).catch(() => {})
} else {
createWebTabWindow({url})
}
@@ -848,6 +906,20 @@ function createWebTabWindow(args) {
if (!errorDescription) {
return
}
// 主框架加载失败时,展示内置的错误页面
if (isMainFrame) {
const originalUrl = validatedURL || args.url || ''
const filePath = path.join(__dirname, 'render', 'tabs', 'error.html')
browserView.webContents.loadFile(filePath, {
query: {
id: String(browserView.webContents.id),
url: originalUrl,
code: String(errorCode),
desc: errorDescription,
}
}).then(_ => { }).catch(_ => { })
return
}
utils.onDispatchEvent(webTabWindow.webContents, {
event: 'title',
id: browserView.webContents.id,
@@ -863,6 +935,9 @@ function createWebTabWindow(args) {
}).then(_ => { })
})
browserView.webContents.on('did-start-loading', _ => {
webTabView.forEach(({id: vid, view}) => {
view.setVisible(vid === browserView.webContents.id)
})
utils.onDispatchEvent(webTabWindow.webContents, {
event: 'start-loading',
id: browserView.webContents.id,
@@ -873,6 +948,7 @@ function createWebTabWindow(args) {
event: 'stop-loading',
id: browserView.webContents.id,
}).then(_ => { })
// 加载完成暗黑模式下把窗口背景色改成白色,避免透明网站背景色穿透
if (nativeTheme.shouldUseDarkColors) {
browserView.setBackgroundColor('#FFFFFF')
@@ -895,8 +971,9 @@ function createWebTabWindow(args) {
electronMenu.webContentsMenu(browserView.webContents, true)
browserView.webContents.loadURL(args.url).then(_ => { }).catch(_ => { })
browserView.setVisible(true)
webTabWindow.addBrowserView(browserView)
webTabWindow.contentView.addChildView(browserView)
webTabView.push({
id: browserView.webContents.id,
view: browserView
@@ -912,15 +989,36 @@ function createWebTabWindow(args) {
/**
* 获取当前内置浏览器标签
* @returns {Electron.BrowserView|undefined}
* @returns {Electron.WebContentsView|undefined}
*/
function currentWebTab() {
const views = webTabWindow.getBrowserViews()
const view = views.length ? views[views.length - 1] : undefined
if (!view) {
return undefined
// 第一:使用当前可见的标签
try {
const item = webTabView.find(({view}) => view?.getVisible && view.getVisible())
if (item) {
return item
}
} catch (e) {}
// 第二:使用当前聚焦的 webContents
try {
const focused = webContents.getFocusedWebContents?.()
if (focused) {
const item = webTabView.find(it => it.id === focused.id)
if (item) {
return item
}
}
} catch (e) {}
// 兜底:根据 children 顺序选择最上层的可用视图
const children = webTabWindow.contentView.children || []
for (let i = children.length - 1; i >= 0; i--) {
const id = children[i]?.webContents?.id
const item = webTabView.find(it => it.id === id)
if (item) {
return item
}
}
return webTabView.find(item => item.id == view.webContents.id)
return undefined
}
/**
@@ -973,8 +1071,10 @@ function activateWebTab(id) {
if (!item) {
return
}
webTabView.forEach(({id: vid, view}) => {
view.setVisible(vid === item.id)
})
resizeWebTab(item.id)
webTabWindow.setTopBrowserView(item.view)
item.view.webContents.focus()
utils.onDispatchEvent(webTabWindow.webContents, {
event: 'switch',
@@ -994,7 +1094,7 @@ function closeWebTab(id) {
if (webTabView.length === 1) {
webTabWindow.hide()
}
webTabWindow.removeBrowserView(item.view)
webTabWindow.contentView.removeChildView(item.view)
try {
item.view.webContents.close()
} catch (e) {
@@ -1056,11 +1156,11 @@ if (!getTheLock) {
app.on('ready', async () => {
isReady = true
isWin && app.setAppUserModelId(config.appId)
// 启动web服务
// 启动 Web 服务
try {
await startWebServer()
} catch (error) {
dialog.showErrorBox('启动失败', `服务器启动失败:${error.message}`);
dialog.showErrorBox('启动失败', `Web 服务器启动失败:${error.message}`);
app.quit();
return;
}
@@ -1132,7 +1232,7 @@ app.on('before-quit', () => {
willQuitApp = true
})
app.on("will-quit",function(){
app.on("will-quit", () => {
globalShortcut.unregisterAll();
})
@@ -1257,7 +1357,7 @@ ipcMain.on('webTabExternal', (event) => {
if (!item) {
return
}
openExternal(item.view.webContents.getURL())
openExternal(item.view.webContents.getURL()).catch(() => {})
event.returnValue = "ok"
})
@@ -1283,6 +1383,99 @@ ipcMain.on('webTabDestroyAll', (event) => {
event.returnValue = "ok"
})
/**
* 内置浏览器 - 后退
*/
ipcMain.on('webTabGoBack', (event) => {
const item = currentWebTab()
if (!item) {
return
}
if (item.view.webContents.canGoBack()) {
item.view.webContents.goBack()
// 导航后更新状态
setTimeout(() => {
utils.onDispatchEvent(webTabWindow.webContents, {
event: 'navigation-state',
id: item.id,
canGoBack: item.view.webContents.canGoBack(),
canGoForward: item.view.webContents.canGoForward()
}).then(_ => { })
}, 100)
}
event.returnValue = "ok"
})
/**
* 内置浏览器 - 前进
*/
ipcMain.on('webTabGoForward', (event) => {
const item = currentWebTab()
if (!item) {
return
}
if (item.view.webContents.canGoForward()) {
item.view.webContents.goForward()
// 导航后更新状态
setTimeout(() => {
utils.onDispatchEvent(webTabWindow.webContents, {
event: 'navigation-state',
id: item.id,
canGoBack: item.view.webContents.canGoBack(),
canGoForward: item.view.webContents.canGoForward()
}).then(_ => { })
}, 100)
}
event.returnValue = "ok"
})
/**
* 内置浏览器 - 刷新
*/
ipcMain.on('webTabReload', (event) => {
const item = currentWebTab()
if (!item) {
return
}
item.view.webContents.reload()
// 刷新完成后会触发 did-stop-loading 事件,在那里会更新导航状态
event.returnValue = "ok"
})
/**
* 内置浏览器 - 停止加载
*/
ipcMain.on('webTabStop', (event) => {
const item = currentWebTab()
if (!item) {
return
}
item.view.webContents.stop()
event.returnValue = "ok"
})
/**
* 内置浏览器 - 获取导航状态
*/
ipcMain.on('webTabGetNavigationState', (event) => {
const item = currentWebTab()
if (!item) {
return
}
const canGoBack = item.view.webContents.canGoBack()
const canGoForward = item.view.webContents.canGoForward()
utils.onDispatchEvent(webTabWindow.webContents, {
event: 'navigation-state',
id: item.id,
canGoBack,
canGoForward
}).then(_ => { })
event.returnValue = "ok"
})
/**
* 隐藏窗口mac、win隐藏其他关闭
*/
@@ -1322,6 +1515,7 @@ ipcMain.on('childWindowCloseAll', (event) => {
})
preloadWindow?.close()
mediaWindow?.close()
electronDown.close()
event.returnValue = "ok"
})
@@ -1334,6 +1528,7 @@ ipcMain.on('childWindowDestroyAll', (event) => {
})
preloadWindow?.destroy()
mediaWindow?.destroy()
electronDown.destroy()
event.returnValue = "ok"
})
@@ -1480,6 +1675,19 @@ ipcMain.on('setDockBadge', (event, args) => {
event.returnValue = "ok"
})
/**
* MCP 服务器状态切换
* @param args
*/
ipcMain.on('mcpServerToggle', (event, args) => {
const { running } = args;
if (running === 'running') {
startMCPServer(mainWindow, mcpPort)
} else {
stopMCPServer()
}
})
/**
* 复制Base64图片
* @param args
@@ -1678,6 +1886,7 @@ ipcMain.on('updateQuitAndInstall', (event, args) => {
})
preloadWindow?.destroy()
mediaWindow?.destroy()
electronDown.destroy()
// 启动更新子窗口
createUpdaterWindow(args.updateTitle)
@@ -1826,7 +2035,6 @@ function exportVsdx(event, args, directFinalize) {
webSecurity: true,
nodeIntegration: true,
contextIsolation: true,
nativeWindowOpen: true
},
})
@@ -2439,7 +2647,7 @@ async function saveFile(fileObject, data, origStat, overwrite, defEnc) {
async function doSaveFile(isNew) {
if (enableStoreBkp && !isNew) {
//Copy file to backup file (after conflict and stat is checked)
//Copy file to back up file (after conflict and stat is checked)
let bkpFh;
try {
@@ -2575,7 +2783,7 @@ function getPluginFile(plugin) {
return null;
}
function uninstallPlugin(plugin) {
async function uninstallPlugin(plugin) {
const pluginFile = getPluginFile(plugin);
if (pluginFile != null) {
@@ -2636,7 +2844,7 @@ async function deleteFile(file) {
}
}
function windowAction(method) {
async function windowAction(method) {
let win = BrowserWindow.getFocusedWindow();
if (win) {
@@ -2656,16 +2864,14 @@ function windowAction(method) {
}
}
function openExternal(url) {
async function openExternal(url) {
//Only open http(s), mailto, tel, and callto links
if (allowedUrls.test(url)) {
shell.openExternal(url).catch(_ => {});
return true;
await shell.openExternal(url)
}
return false;
}
function watchFile(path) {
async function watchFile(path) {
let win = BrowserWindow.getFocusedWindow();
if (win) {
@@ -2677,12 +2883,13 @@ function watchFile(path) {
prev: prev
});
} catch (e) {
} // Ignore
// Ignore
}
});
}
}
function unwatchFile(path) {
async function unwatchFile(path) {
fs.unwatchFile(path);
}
@@ -2711,7 +2918,7 @@ ipcMain.on("rendererReq", async (event, args) => {
ret = await getDocumentsFolder();
break;
case 'checkFileExists':
ret = await checkFileExists(args.pathParts);
ret = checkFileExists(args.pathParts);
break;
case 'showOpenDialog':
dialogOpen = true;
@@ -2732,7 +2939,7 @@ ipcMain.on("rendererReq", async (event, args) => {
ret = await uninstallPlugin(args.plugin);
break;
case 'getPluginFile':
ret = await getPluginFile(args.plugin);
ret = getPluginFile(args.plugin);
break;
case 'isPluginsEnabled':
ret = enablePlugins;
@@ -2744,7 +2951,7 @@ ipcMain.on("rendererReq", async (event, args) => {
ret = await readFile(args.filename, args.encoding);
break;
case 'clipboardAction':
ret = await clipboardAction(args.method, args.data);
ret = clipboardAction(args.method, args.data);
break;
case 'deleteFile':
ret = await deleteFile(args.file);
@@ -2761,6 +2968,15 @@ ipcMain.on("rendererReq", async (event, args) => {
case 'openExternal':
ret = await openExternal(args.url);
break;
case 'openDownloadWindow':
ret = await electronDown.open(args.language || 'zh', args.theme || 'light');
break;
case 'updateDownloadWindow':
ret = await electronDown.updateWindow(args.language, args.theme);
break;
case 'createDownload':
ret = await electronDown.createDownload(mainWindow, args.url, args.options || {});
break;
case 'watchFile':
ret = await watchFile(args.path);
break;
@@ -2768,12 +2984,13 @@ ipcMain.on("rendererReq", async (event, args) => {
ret = await unwatchFile(args.path);
break;
case 'getCurDir':
ret = await getCurDir();
ret = getCurDir();
break;
}
event.reply('mainResp', {success: true, data: ret, reqId: args.reqId});
} catch (e) {
event.reply('mainResp', {error: true, msg: e.message, e: e, reqId: args.reqId});
loger.error('Renderer request error', e.message, e.stack);
}
});

View File

@@ -18,7 +18,7 @@
<body>
<div id="app" data-preload="false">
<div id="app" data-preload="init">
<div class="app-view-loading no-dark-content">
<div>
<div>PAGE LOADING</div>

239
electron/lib/download-manager.js vendored Normal file
View File

@@ -0,0 +1,239 @@
const path = require("path");
const loger = require("electron-log");
const Store = require('electron-store');
const utils = require("./utils");
const store = new Store({
name: 'download-manager',
defaults: {
downloadHistory: [],
}
});
const DownloadStore = {
get(key, defaultValue) {
return store.get(key, defaultValue);
},
set(key, value) {
store.set(key, value);
},
};
class DownloadManager {
static key = 'downloadHistory';
constructor() {
const history = DownloadStore.get(DownloadManager.key, []);
if (utils.isArray(history)) {
this.downloadHistory = history.map(item => ({
...item,
// 历史记录中,将 progressing 状态改为 interrupted
state: item.state === 'progressing' ? 'interrupted' : item.state,
// 移除源对象,避免序列化问题
_source: undefined,
}));
} else {
this.downloadHistory = [];
}
}
/**
* 转换下载项格式
* @param {Electron.DownloadItem} downloadItem
*/
convert(downloadItem) {
return {
filename: path.basename(downloadItem.getSavePath()) || downloadItem.getFilename(),
path: downloadItem.getSavePath(),
url: downloadItem.getURL(),
urls: downloadItem.getURLChain(),
mine: downloadItem.getMimeType(),
received: downloadItem.getReceivedBytes(),
total: downloadItem.getTotalBytes(),
percent: downloadItem.getPercentComplete(),
speed: downloadItem.getCurrentBytesPerSecond(),
state: downloadItem.getState(),
paused: downloadItem.isPaused(),
startTime: downloadItem.getStartTime(),
endTime: downloadItem.getEndTime(),
}
}
/**
* 添加下载项
* @param {Electron.DownloadItem} downloadItem
*/
add(downloadItem) {
// 根据保存路径,如果下载项已存在,则取消下载(避免重复下载)
this.cancel(downloadItem.getSavePath());
// 添加下载项
this.downloadHistory.unshift({
...this.convert(downloadItem),
error: null,
_source: downloadItem,
});
if (this.downloadHistory.length > 1000) {
this.downloadHistory = this.downloadHistory.slice(0, 1000);
}
DownloadStore.set(DownloadManager.key, this.downloadHistory);
}
/**
* 获取下载列表
* @returns {*}
*/
get() {
return this.downloadHistory.map(item => {
return {
...item,
// 移除源对象,避免序列化问题
_source: undefined,
};
});
}
/**
* 更新下载项
* @param {string} path
*/
refresh(path) {
const item = this.downloadHistory.find(d => d.path === path)
if (!item) {
return;
}
const downloadItem = item._source;
if (!downloadItem) {
loger.warn(`Download item not found for path: ${path}`);
return;
}
Object.assign(item, this.convert(downloadItem))
DownloadStore.set(DownloadManager.key, this.downloadHistory);
}
/**
* 尝试更新下载项的错误信息
* @param {Electron.DownloadItem} downloadItem
* @param {Object} headers
*/
async updateError(downloadItem, headers = {}) {
const urls = downloadItem.getURLChain()
const url = urls.length > 0 ? urls[0] : downloadItem.getURL()
const path = downloadItem.getSavePath()
const item = this.downloadHistory.find(d => d.path === path)
if (!item) {
return;
}
try {
const res = await fetch(url, {
method: 'HEAD',
headers,
})
let error = null
if (res.headers.get('X-Error-Message-Base64')) {
error = Buffer.from(res.headers.get('X-Error-Message-Base64'), 'base64').toString('utf-8')
} else if (res.headers.get('X-Error-Message')) {
error = res.headers.get('X-Error-Message')
}
if (error) {
Object.assign(item, {error});
DownloadStore.set(DownloadManager.key, this.downloadHistory);
return true;
}
} catch {
// 忽略错误
}
return false
}
/**
* 暂停下载项
* @param {string} path
*/
pause(path) {
const item = this.downloadHistory.find(d => d.path === path)
if (!item) {
return;
}
const downloadItem = item._source;
if (!downloadItem) {
loger.warn(`Download item not found for path: ${path}`);
return;
}
downloadItem.pause();
this.refresh(path);
}
/**
* 恢复下载项
* @param {string} path
*/
resume(path) {
const item = this.downloadHistory.find(d => d.path === path)
if (!item) {
return;
}
const downloadItem = item._source;
if (!downloadItem) {
loger.warn(`Download item not found for path: ${path}`);
return;
}
downloadItem.resume();
this.refresh(path);
}
/**
* 取消下载项
* @param {string} path
*/
cancel(path) {
const item = this.downloadHistory.find(d => d.path === path)
if (!item) {
return;
}
const downloadItem = item._source;
if (!downloadItem) {
loger.warn(`Download item not found for path: ${path}`);
return;
}
downloadItem.cancel();
this.refresh(path);
}
/**
* 取消所有下载项
*/
cancelAll() {
this.downloadHistory.forEach(item => {
this.cancel(item.path);
});
}
/**
* 删除下载项
* @param {string} path
*/
remove(path) {
const index = this.downloadHistory.findIndex(item => item.path === path);
if (index > -1) {
this.cancel(path);
this.downloadHistory.splice(index, 1);
DownloadStore.set(DownloadManager.key, this.downloadHistory);
}
}
/**
* 清空下载项
*/
removeAll() {
this.cancelAll();
this.downloadHistory = [];
DownloadStore.set(DownloadManager.key, []);
}
}
module.exports = {DownloadStore, DownloadManager};

1955
electron/lib/mcp.js vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -108,6 +108,25 @@ const utils = {
return _s;
},
/**
* 兜底处理尺寸类数值返回四舍五入后的正整数
* @param value
* @param fallback
* @returns {number}
*/
normalizeSize(value, fallback) {
const toPositiveNumber = (candidate) => {
const num = Number(candidate);
return Number.isFinite(num) && num > 0 ? num : null;
};
const primary = toPositiveNumber(value);
const secondary = toPositiveNumber(fallback);
const safeValue = primary ?? secondary ?? 1;
return Math.max(1, Math.round(safeValue));
},
/**
* 随机字符串
* @param len
@@ -274,10 +293,11 @@ const utils = {
* @param weburl
* @returns {string|string}
*/
getDomain(weburl) {
getDomain(weburl, toLowerCase = true) {
const urlReg = /http(s)?:\/\/([^\/]+)/i;
const domain = `${weburl}`.match(urlReg);
return ((domain != null && domain.length > 0) ? domain[2] : "");
const result = ((domain != null && domain.length > 0) ? domain[2] : "");
return toLowerCase ? result.toLowerCase() : result;
},
/**

View File

@@ -26,34 +26,41 @@
"url": "https://github.com/kuaifan/dootask.git"
},
"devDependencies": {
"@electron-forge/cli": "^7.7.0",
"@electron-forge/maker-deb": "^7.7.0",
"@electron-forge/maker-rpm": "^7.7.0",
"@electron-forge/maker-squirrel": "^7.7.0",
"@electron-forge/maker-zip": "^7.7.0",
"@electron-forge/cli": "^7.10.2",
"@electron-forge/maker-deb": "^7.10.2",
"@electron-forge/maker-rpm": "^7.10.2",
"@electron-forge/maker-squirrel": "^7.10.2",
"@electron-forge/maker-zip": "^7.10.2",
"@types/crc": "^3.8.3",
"@types/electron-config": "^0.2.1",
"dotenv": "^16.4.5",
"electron": "^34.3.4",
"electron-builder": "^25.1.8",
"electron": "^38.4.0",
"electron-builder": "^26.0.12",
"electron-notarize": "^1.2.2",
"form-data": "^4.0.1",
"inquirer": "^12.4.2",
"form-data": "^4.0.4",
"inquirer": "^12.9.1",
"ora": "^4.1.1"
},
"dependencies": {
"axios": "^1.7.7",
"@dootask/electron-dl": "^4.0.0-rc.2",
"axios": "^1.11.0",
"crc": "^3.8.0",
"dayjs": "^1.11.13",
"electron-config": "^2.0.0",
"electron-log": "^5.2.2",
"electron-log": "^5.4.2",
"electron-screenshots-tool": "^1.1.2",
"electron-squirrel-startup": "^1.0.1",
"electron-store": "^8.2.0",
"electron-updater": "^6.3.9",
"electron-updater": "^6.6.2",
"express": "^5.1.0",
"fastmcp": "^3.24.0",
"fs-extra": "^11.2.0",
"marked": "^17.0.0",
"pdf-lib": "^1.17.1",
"request": "^2.88.2",
"tar": "^7.4.3",
"turndown": "^7.2.2",
"zod": "^3.23.8",
"yauzl": "^3.2.0"
},
"trayIcon": {
@@ -74,12 +81,13 @@
"output": "dist"
},
"files": [
"lib/**/*",
"render/**/*",
"public/**/*",
"electron-down.js",
"electron-menu.js",
"electron-preload.js",
"electron.js",
"utils.js"
"electron.js"
],
"extraFiles": [
{

View File

@@ -0,0 +1,522 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<title>Download</title>
<link rel="stylesheet" href="./style.css">
<script>
const getQueryParam = (name) => {
const url = window.location.href;
const match = url.match(new RegExp('[?&]' + name + '=([^&#]*)'));
return match ? decodeURIComponent(match[1]) : null;
}
const updateTheme = (theme) => {
const root = document.documentElement;
root.classList.toggle('dark', theme === 'dark');
root.classList.toggle('light', theme === 'light');
};
updateTheme(getQueryParam('theme'))
</script>
<script src="../tabs/assets/js/vue.global.min.js"></script>
</head>
<body>
<div id="app" class="download-manager">
<div class="toolbar">
<label class="search">
<input class="search-input" v-model.trim="query" :placeholder="lang.searchPlaceholder"></input>
</label>
<div class="actions">
<button class="action-btn" @click="onRemoveAll" :disabled="items.length === 0">{{ lang.removeAll }}</button>
<button class="action-btn" @click="onRefresh">{{ lang.refresh }}</button>
</div>
</div>
<div class="content">
<div class="tab-content all-tasks">
<div v-if="list.length === 0 && waiting === false" class="empty-state">
<div class="empty-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="48" height="48" fill="currentColor">
<path d="M5 20h14v-2H5v2zM19 9h-4V3H9v6H5l7 7 7-7z"/>
</svg>
</div>
<div class="empty-text">{{ query ? lang.noSearchResult : lang.noItems }}</div>
</div>
<div v-else class="task-list">
<!-- 骨架条目 -->
<div v-if="waiting" class="task-item skeleton-item">
<div class="task-icon">
<div class="skeleton-file-icon"></div>
</div>
<div class="task-info">
<div class="task-name">
<div class="skeleton-name"></div>
</div>
<div class="task-meta">
<span class="skeleton-size"></span>
<span class="skeleton-time"></span>
<span class="skeleton-status"></span>
</div>
</div>
<div class="task-actions">
<div class="skeleton-btn"></div>
<div class="skeleton-btn"></div>
<div class="skeleton-btn"></div>
</div>
</div>
<!-- 任务列表 -->
<div
v-for="(item, index) in list"
:key="index"
class="task-item"
:class="{'progressing-item': item.state === 'progressing'}"
:style="item.state === 'progressing' ? {'--progress': item.percent + '%'} : {'--progress': '0%'}">
<div class="task-icon">
<div class="file-icon" :class="getFileTypeClass(item)" v-html="getFileIcon(item)"></div>
</div>
<div class="task-info">
<div class="task-name">
<div class="task-name-clickable" :title="item.filename" @click="onOpenFile(item)">{{ item.filename }}</div>
</div>
<div class="task-meta">
<!-- 大小 -->
<span v-if="item.state === 'progressing'" class="file-size">
{{ formatBytes(item.received) }}<template v-if="item.total > 0"> / {{ formatBytes(item.total) }}</template><template v-if="item.percent >= 0"> ({{ item.percent }}%)</template>
</span>
<span v-else class="file-size">
{{ formatBytes(item.total) }}
</span>
<!-- 时间 -->
<span v-if="item.state === 'completed'" class="download-time">{{ formatTime(item.endTime) }}</span>
<span v-else class="download-time">{{ formatTime(item.startTime) }}</span>
<!-- 状态 -->
<span v-if="item.state !== 'progressing' || item.paused" class="state" :class="getStateClass(item)">
{{ getStateText(item) }}{{item.error ? `: ${item.error}` : ''}}
</span>
</div>
</div>
<div class="task-actions">
<!-- 下载速度 -->
<span v-if="item.state === 'progressing' && item.speed" class="speed">{{ formatBytes(item.speed) }}/s</span>
<!-- 复制链接 -->
<button @click="copyUrl(item)" class="icon-btn" :title="lang.copyLink">
<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet" focusable="false" viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/>
</svg>
</button>
<!-- 暂停和继续 -->
<template v-if="item.state === 'progressing'">
<button v-if="item.paused" @click="onResume(item)" class="icon-btn" :title="lang.resume">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
</button>
<button v-else @click="onPause(item)" class="icon-btn" :title="lang.pause">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
</svg>
</button>
</template>
<!-- 显示文件夹 -->
<button v-if="item.state === 'completed'" @click="onShowFolder(item)" class="icon-btn" :title="lang.showInFolder">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 24 24">
<path d="M20 6h-8l-2-2H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 12H4V8h16v10z"/>
</svg>
</button>
<!-- 删除 -->
<button @click="onRemove(item)" class="icon-btn danger" :title="lang.remove">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 24 24">
<path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 信息提示框 -->
<div v-if="toast.show" class="toast" :class="toast.type">
<div class="toast-content">
<svg v-if="toast.type === 'success'" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
<svg v-else-if="toast.type === 'error'" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
</svg>
<span class="toast-message">{{ toast.message }}</span>
</div>
</div>
</div>
<script>
const {createApp} = Vue;
createApp({
data() {
return {
query: '',
items: [],
waiting: false,
lang: {
// 语言设置
locale: 'zh-CN',
title: '下载管理器',
// 界面文本
searchPlaceholder: '搜索文件名或链接...',
noItems: '暂无任务',
noSearchResult: '未找到匹配的结果',
// 操作按钮
refresh: '刷新',
removeAll: "清空历史",
copyLink: '复制链接',
resume: '继续',
pause: '暂停',
cancel: '取消',
remove: '删除',
showInFolder: '显示在文件夹',
// 状态文本
progressing: '下载中',
completed: '已完成',
cancelled: '已取消',
interrupted: '失败',
paused: '已暂停',
// 成功消息
copied: "已复制",
refreshSuccess: '刷新成功',
// 确认对话框
confirmCancel: '确定要取消此下载任务并删除记录吗?',
confirmRemove: '确定要从历史记录中删除此项吗?',
confirmRemoveAll: '确定要清空下载历史吗?',
// 错误消息
copyFailed: '复制失败: ',
pauseFailed: '暂停失败: ',
resumeFailed: '继续失败: ',
removeFailed: '删除失败: ',
removeAllFailed: '清空失败: ',
openFailed: '打开文件失败: ',
showFailed: '显示文件失败: ',
},
toast: {
show: false,
type: 'success', // success, error
message: '',
timer: null
},
}
},
mounted() {
this.getList();
// 监听下载任务列表
electron?.listener('download-items', ({items, waiting}) => {
this.items = items
this.waiting = waiting
});
// 接收主题
electron?.listener('download-theme', (theme) => {
updateTheme(theme)
});
// 接收语言包
electron?.listener('download-language', (lang) => {
if (lang && typeof lang === 'object') {
this.lang = {...this.lang, ...lang};
document.title = this.lang.title || document.title;
}
});
},
beforeUnmount() {
if (this.toast.timer) {
clearTimeout(this.toast.timer);
this.toast.timer = null;
}
},
computed: {
list() {
const q = (this.query || '').toLowerCase();
return q
? this.items.filter(t => (t.filename || '').toLowerCase().includes(q) || (t.url || '').toLowerCase().includes(q))
: this.items;
}
},
methods: {
async sendAsync(action, args = {}) {
try {
return await electron?.sendAsync("downloadManager", {
action,
...args
});
} catch (e) {
e.message = `${e.message}`.replace(/Error invoking remote method 'downloadManager': Error:\s+/, '');
throw e;
}
},
async copyUrl({url, urls}) {
try {
await navigator.clipboard.writeText(urls.length > 0 ? urls[0] : url);
this.showToast(this.lang.copied);
} catch (e) {
this.errorToast(this.lang.copyFailed + e.message);
}
},
async getList() {
try {
const data = await this.sendAsync('get');
this.items = data.items || [];
this.waiting = data.waiting || false;
} catch (e) {
console.error('加载下载任务失败:', e);
}
},
async onRefresh() {
await this.getList();
this.showToast(this.lang.refreshSuccess, 'success');
},
async onPause({path}) {
try {
await this.sendAsync('pause', {path});
} catch (e) {
this.errorToast(this.lang.pauseFailed + e.message);
}
},
async onResume({path}) {
try {
await this.sendAsync('resume', {path});
} catch (e) {
this.errorToast(this.lang.resumeFailed + e.message);
}
},
async onRemove({state, path}) {
if (!confirm(state === 'progressing' ? this.lang.confirmCancel : this.lang.confirmRemove)) {
return;
}
try {
await this.sendAsync('remove', {path});
} catch (e) {
this.errorToast(this.lang.removeFailed + e.message);
}
},
async onRemoveAll() {
if (!confirm(this.lang.confirmRemoveAll)) {
return;
}
try {
await this.sendAsync('removeAll');
} catch (e) {
this.errorToast(this.lang.removeAllFailed + e.message);
}
},
async onOpenFile({path}) {
try {
await this.sendAsync('openFile', {path});
} catch (e) {
this.errorToast(this.lang.openFailed + e.message);
}
},
async onShowFolder({path}) {
try {
await this.sendAsync('showFolder', {path});
} catch (e) {
this.errorToast(this.lang.showFailed + e.message);
}
},
isPaused({state, paused}) {
return state === 'progressing' && paused;
},
getStateText({state, paused}) {
if (this.isPaused({state, paused})) {
return this.lang.paused;
}
const stateMap = {
'progressing': this.lang.progressing,
'completed': this.lang.completed,
'cancelled': this.lang.cancelled,
'interrupted': this.lang.interrupted,
};
return stateMap[state] || state;
},
getStateClass({state, paused}) {
if (this.isPaused({state, paused})) {
return 'paused';
}
return state
},
getFileTypeClass({filename}) {
const typeMap = {
'pdf': 'file-pdf',
'doc': 'file-word',
'docx': 'file-word',
'xls': 'file-excel',
'xlsx': 'file-excel',
'ppt': 'file-powerpoint',
'pptx': 'file-powerpoint',
'jpg': 'file-image',
'jpeg': 'file-image',
'png': 'file-image',
'gif': 'file-image',
'svg': 'file-image',
'webp': 'file-image',
'bmp': 'file-image',
'mp4': 'file-video',
'avi': 'file-video',
'mov': 'file-video',
'mkv': 'file-video',
'webm': 'file-video',
'wmv': 'file-video',
'mp3': 'file-audio',
'wav': 'file-audio',
'flac': 'file-audio',
'aac': 'file-audio',
'm4a': 'file-audio',
'zip': 'file-archive',
'rar': 'file-archive',
'7z': 'file-archive',
'tar': 'file-archive',
'gz': 'file-archive',
'txt': 'file-text',
'md': 'file-text',
'rtf': 'file-text',
'js': 'file-code',
'ts': 'file-code',
'css': 'file-code',
'html': 'file-code',
'php': 'file-code',
'py': 'file-code',
'java': 'file-code',
'cpp': 'file-code',
'c': 'file-code',
'exe': 'file-exe',
'msi': 'file-exe',
'dmg': 'file-exe',
'deb': 'file-exe',
'rpm': 'file-exe',
'_': 'file-unknown'
};
return typeMap[this.getFileExt(filename)] || typeMap['_'];
},
getFileIcon({filename}) {
const iconMap = {
'pdf': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ea4335"><path d="M20 2H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8.5 7.5c0 .83-.67 1.5-1.5 1.5H9v2H7.5V7H10c.83 0 1.5.67 1.5 1.5v1zm5 2c0 .83-.67 1.5-1.5 1.5h-2.5V7H15c.83 0 1.5.67 1.5 1.5v3zm4-3H19v1h1.5V11H19v1h1.5v1.5H17.5V7h4v1.5z"/></svg>',
'doc': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#4285f4"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 2 2h12c1.11 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/></svg>',
'docx': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#4285f4"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 2 2h12c1.11 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/></svg>',
'xls': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#0f9d58"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 2 2h12c1.11 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/></svg>',
'xlsx': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#0f9d58"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 2 2h12c1.11 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/></svg>',
'ppt': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ff6d01"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 2 2h12c1.11 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/></svg>',
'pptx': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ff6d01"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 2 2h12c1.11 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/></svg>',
'jpg': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#9c27b0"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>',
'jpeg': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#9c27b0"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>',
'png': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#9c27b0"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>',
'gif': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#9c27b0"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>',
'mp4': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#607d8b"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>',
'avi': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#607d8b"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>',
'mov': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#607d8b"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>',
'mp3': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ff9800"><path d="M12 3v9.28c-.47-.17-.97-.28-1.5-.28C8.01 12 6 14.01 6 16.5S8.01 21 10.5 21s4.5-2.01 4.5-4.5V7h4V3h-7z"/></svg>',
'wav': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ff9800"><path d="M12 3v9.28c-.47-.17-.97-.28-1.5-.28C8.01 12 6 14.01 6 16.5S8.01 21 10.5 21s4.5-2.01 4.5-4.5V7h4V3h-7z"/></svg>',
'zip': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#795548"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 2 2h12c1.11 0 2-.9 2-2V8l-6-6zm-1 7V3.5L18.5 9H13z"/></svg>',
'rar': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#795548"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 2 2h12c1.11 0 2-.9 2-2V8l-6-6zm-1 7V3.5L18.5 9H13z"/></svg>',
'txt': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#757575"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 2 2h12c1.11 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/></svg>',
'js': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#f7df1e"><rect width="24" height="24" fill="#323330"/><path d="M12 12v8h2c2 0 3-1 3-3s-1-3-3-3h-2zm-2 0h-2v8h2v-3c0-1 1-2 2-2s2 1 2 2v3h2v-3c0-2-1-4-4-4s-4 2-4 4z" fill="#f7df1e"/></svg>',
'exe': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#424242"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 2 2h12c1.11 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/></svg>',
'_': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#9e9e9e"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 2 2h12c1.11 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/></svg>'
};
return iconMap[this.getFileExt(filename)] || iconMap['_'];
},
getFileExt(value) {
return `${value}`.split('.').pop()?.toLowerCase();
},
formatBytes(bytes) {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
},
formatTime(timestamp) {
if (!timestamp) return '';
const date = new Date(timestamp * 1000);
return date.toLocaleString(this.lang?.locale || "zh-CN", {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
},
showToast(message, type = 'success') {
if (this.toast.timer) {
clearTimeout(this.toast.timer);
this.toast.timer = null;
}
if (this.toast.show) {
this.toast.show = false;
setTimeout(() => {
this.displayToast(message, type);
}, 100);
} else {
this.displayToast(message, type);
}
},
errorToast(message) {
this.showToast(message, 'error');
},
displayToast(message, type) {
this.toast.message = message;
this.toast.type = type;
this.toast.show = true;
this.toast.timer = setTimeout(() => {
this.toast.show = false;
this.toast.timer = null;
}, 4000);
}
}
}).mount('#app');
</script>
</body>
</html>

623
electron/render/download/style.css vendored Normal file
View File

@@ -0,0 +1,623 @@
/* 下载管理器样式 - Chrome 风格 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--color-bg: #fff;
--color-text: #202124;
--color-toolbar-bg: #fff;
--color-toolbar-border: #dadce0;
--color-input-bg: #efefef;
--color-input-text: #202124;
--color-input-placeholder: #5f6368;
--color-input-focus-border: #1a73e8;
--color-input-focus-ring: rgba(26, 115, 232, .2);
--color-action-btn-bg: #fff;
--color-action-btn-border: #dadce0;
--color-action-btn-text: #1a73e8;
--color-action-btn-hover-bg: #f8f9fa;
--color-action-btn-hover-border: #c8c9ca;
--color-action-btn-danger-text: #d93025;
--color-action-btn-danger-hover-bg: #fce8e6;
--color-icon-btn: #5f6368;
--color-icon-btn-hover-bg: #f8f9fa;
--color-icon-btn-hover-color: #202124;
--color-icon-btn-danger: #d93025;
--color-icon-btn-danger-hover-bg: #fce8e6;
--color-icon-btn-danger-hover-color: #d93025;
--color-content-bg: #fff;
--color-task-item-bg: #fff;
--color-task-item-border: #e8eaed;
--color-task-item-hover-bg: #f8f9fa;
--color-task-name: #202124;
--color-task-name-clickable: #1a73e8;
--color-progress-bar: #e8eaed;
--color-progress-fill: #1a73e8;
--color-progress-text: #5f6368;
--color-task-meta: #5f6368;
--color-speed: #1a73e8;
--color-empty-state: #5f6368;
--color-empty-text: #5f6368;
--color-state-completed-bg: #e8f5e8;
--color-state-completed-text: #137333;
--color-state-failed-bg: #fce8e6;
--color-state-failed-text: #d93025;
--color-state-cancelled-bg: #e8eaed;
--color-state-cancelled-text: #5f6368;
--color-state-paused-bg: #fff3e0;
--color-state-paused-text: #f57c00;
--scrollbar-thumb: rgba(0, 0, 0, 0.2);
--scrollbar-thumb-hover: rgba(0, 0, 0, 0.3);
}
.dark {
--color-bg: #202124;
--color-text: #e8eaed;
--color-toolbar-bg: #2d2e30;
--color-toolbar-border: #3c4043;
--color-input-bg: #282828;
--color-input-text: #e8eaed;
--color-input-placeholder: #9aa0a6;
--color-input-focus-border: #8ab4f8;
--color-input-focus-ring: rgba(138, 180, 248, .12);
--color-action-btn-bg: #2d2e30;
--color-action-btn-border: #5f6368;
--color-action-btn-text: #8ab4f8;
--color-action-btn-hover-bg: #35363a;
--color-action-btn-hover-border: #70757a;
--color-action-btn-danger-text: #f28b82;
--color-action-btn-danger-hover-bg: #35363a;
--color-icon-btn: #9aa0a6;
--color-icon-btn-hover-bg: #35363a;
--color-icon-btn-hover-color: #e8eaed;
--color-icon-btn-danger: #f28b82;
--color-icon-btn-danger-hover-bg: #3d1a1a;
--color-icon-btn-danger-hover-color: #f28b82;
--color-content-bg: #2d2e30;
--color-task-item-bg: #2d2e30;
--color-task-item-border: #3c4043;
--color-task-item-hover-bg: #35363a;
--color-task-name: #e8eaed;
--color-task-name-clickable: #8ab4f8;
--color-progress-bar: #3c4043;
--color-progress-fill: #8ab4f8;
--color-progress-text: #9aa0a6;
--color-task-meta: #9aa0a6;
--color-speed: #8ab4f8;
--color-empty-state: #9aa0a6;
--color-empty-text: #9aa0a6;
--color-state-completed-bg: #1e3a1e;
--color-state-completed-text: #81c995;
--color-state-failed-bg: #3d1a1a;
--color-state-failed-text: #f28b82;
--color-state-cancelled-bg: #3c4043;
--color-state-cancelled-text: #9aa0a6;
--color-state-paused-bg: #522f2f;
--color-state-paused-text: #f57c00;
--scrollbar-thumb: rgba(255, 255, 255, 0.2);
--scrollbar-thumb-hover: rgba(255, 255, 255, 0.3);
body {
color-scheme: dark;
}
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--color-bg);
color: var(--color-text);
font-size: 13px;
overflow: hidden;
}
.download-manager {
display: flex;
flex-direction: column;
height: 100vh;
}
/* 工具栏 */
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 32px;
padding: 0 24px;
background: var(--color-toolbar-bg);
border-bottom: 1px solid var(--color-toolbar-border);
flex-shrink: 0;
}
.search {
flex: 1;
padding: 8px 0;
}
.search-input {
width: 100%;
max-width: 380px;
height: 32px;
padding: 0 12px 0 32px;
border: 0;
border-radius: 6px;
margin: 1px 0;
font-size: 13px;
outline: none;
color: var(--color-input-text);
background: var(--color-input-bg) url('data:image/svg+xml;utf8,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"%235f6368\"><path d=\"M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z\"/></svg>') no-repeat 8px center;
background-size: 18px 18px;
transition: all 0.3s;
}
.search-input::placeholder {
color: var(--color-input-placeholder);
}
.search-input:focus {
border-color: var(--color-input-focus-border);
box-shadow: 0 0 0 2px var(--color-input-focus-ring);
}
.actions {
display: flex;
gap: 10px;
}
.action-btn {
padding: 6px 12px;
border: 1px solid var(--color-action-btn-border);
background: var(--color-action-btn-bg);
cursor: pointer;
border-radius: 4px;
font-size: 12px;
color: var(--color-action-btn-text);
transition: all 0.15s;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.action-btn:hover:not(:disabled) {
background: var(--color-action-btn-hover-bg);
border-color: var(--color-action-btn-hover-border);
}
.action-btn:disabled {
opacity: 0.38;
cursor: not-allowed;
}
.action-btn.small {
padding: 4px 8px;
font-size: 11px;
}
.action-btn.danger {
color: var(--color-action-btn-danger-text);
}
.action-btn.danger:hover:not(:disabled) {
background: var(--color-action-btn-danger-hover-bg);
}
/* 图标按钮样式 */
.icon-btn {
padding: 6px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 4px;
color: var(--color-icon-btn);
transition: all 0.15s;
display: inline-flex;
align-items: center;
justify-content: center;
}
.icon-btn svg {
width: 20px;
height: 20px;
}
.icon-btn:hover {
background: var(--color-icon-btn-hover-bg);
color: var(--color-icon-btn-hover-color);
}
.icon-btn.danger {
color: var(--color-icon-btn-danger);
}
.icon-btn.danger:hover {
background: var(--color-icon-btn-danger-hover-bg);
color: var(--color-icon-btn-danger-hover-color);
}
/* 内容区 */
.content {
flex: 1;
overflow: hidden;
background: var(--color-content-bg);
}
.tab-content {
height: 100%;
overflow-y: auto;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 48px 24px;
color: var(--color-empty-state);
text-align: center;
}
.empty-icon {
margin-bottom: 20px;
opacity: 0.6;
}
.empty-text {
font-size: 14px;
color: var(--color-empty-text);
}
/* 骨架屏样式 */
.skeleton-item {
opacity: 0.7;
}
.skeleton-item:last-child {
border-bottom: none;
}
/* 骨架元素基础动画 */
@keyframes skeleton-shimmer {
0% {
background-position: -200px 0;
}
100% {
background-position: calc(200px + 100%) 0;
}
}
.skeleton-file-icon,
.skeleton-name,
.skeleton-size,
.skeleton-time,
.skeleton-status,
.skeleton-btn {
background: linear-gradient(90deg, var(--color-task-item-border) 25%, var(--color-task-item-hover-bg) 50%, var(--color-task-item-border) 75%);
background-size: 200px 100%;
animation: skeleton-shimmer 1.5s infinite linear;
border-radius: 4px;
}
/* 骨架文件图标 */
.skeleton-file-icon {
width: 32px;
height: 32px;
margin: 0 4px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
/* 骨架文件名 */
.skeleton-name {
height: 13px;
width: 60%;
margin: 4px 0 6px;
max-width: 200px;
}
/* 骨架元信息 */
.skeleton-size {
height: 12px;
width: 80px;
}
.skeleton-time {
height: 12px;
width: 100px;
}
.skeleton-status {
height: 12px;
width: 60px;
padding: 2px 8px;
border-radius: 12px;
}
/* 骨架操作按钮 */
.skeleton-btn {
width: 22px;
height: 22px;
border-radius: 50%;
margin-right: 8px;
}
/* 任务列表 */
.task-list {
padding: 0;
}
.task-item {
display: flex;
align-items: center;
height: 72px;
padding: 0 24px;
background: var(--color-task-item-bg);
border-bottom: 1px solid var(--color-task-item-border);
transition: background-color 0.15s;
position: relative;
overflow: hidden;
}
.task-item > * {
position: relative;
z-index: 1;
}
.task-item:hover {
background: var(--color-task-item-hover-bg);
}
.task-item:last-child {
border-bottom: none;
}
/* 整块背景进度条(下载中样式) */
.task-item.progressing-item::before {
content: '';
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: var(--progress, 0%);
background: var(--color-progress-fill);
opacity: 0.12;
pointer-events: none;
transition: width 0.3s ease;
z-index: 0;
}
.task-icon {
margin-right: 16px;
flex-shrink: 0;
}
.file-icon {
width: 40px;
height: 40px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.file-icon svg {
width: 28px;
height: 28px;
}
.task-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
min-width: 0;
}
.task-name {
min-width: 0;
width: auto;
font-weight: 400;
font-size: 13px;
color: var(--color-task-name);
}
.task-name-clickable {
display: inline-block;
cursor: pointer;
color: var(--color-task-name-clickable);
text-decoration: none;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-name-clickable:hover {
text-decoration: underline;
}
.task-progress {
margin-bottom: 4px;
}
.progress-bar {
width: 100%;
height: 4px;
background: var(--color-progress-bar);
border-radius: 2px;
margin-bottom: 6px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--color-progress-fill);
border-radius: 2px;
transition: width 0.3s ease;
}
.progress-text {
font-size: 11px;
color: var(--color-progress-text);
display: flex;
justify-content: space-between;
align-items: center;
}
.speed {
display: flex;
align-items: center;
color: var(--color-speed);
font-weight: 400;
}
.task-meta {
display: flex;
align-items: center;
gap: 12px;
height: 20px;
font-size: 12px;
color: var(--color-task-meta);
}
.task-meta > span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.state {
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 400;
}
.state.completed {
background: var(--color-state-completed-bg);
color: var(--color-state-completed-text);
}
.state.cancelled {
background: var(--color-state-cancelled-bg);
color: var(--color-state-cancelled-text);
}
.state.interrupted {
background: var(--color-state-failed-bg);
color: var(--color-state-failed-text);
}
.state.paused {
background: var(--color-state-paused-bg);
color: var(--color-state-paused-text);
}
.task-actions {
display: flex;
gap: 12px;
margin-left: 16px;
flex-shrink: 0;
}
/* 平台特定样式 */
body.darwin {
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
}
body.win32 {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
/* 滚动条样式 */
.tab-content::-webkit-scrollbar {
width: 8px;
}
.tab-content::-webkit-scrollbar-track {
background: transparent;
}
.tab-content::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 4px;
}
.tab-content::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
/* 深色模式通过 .dark 类启用变量,不再依赖系统配色偏好 */
/* Toast 提示框样式 */
.toast {
position: fixed;
left: 0;
right: 0;
bottom: 20px;
z-index: 1000;
animation: toast-slide-up 0.3s ease-out;
display: flex;
justify-content: center;
}
.toast-content {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: #323232;
color: #fff;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
font-size: 13px;
min-width: 200px;
max-width: 90vw;
}
.toast.success .toast-content {
background: #4caf50;
}
.toast.error .toast-content {
background: #f44336;
}
.toast-message {
flex: 1;
max-height: 200px;
overflow-y: auto;
}
@keyframes toast-slide-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -2,7 +2,7 @@
--tab-font-family: -apple-system, 'Segoe UI', roboto, oxygen-sans, ubuntu, cantarell, 'Helvetica Neue', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
--tab-font-size: 12px;
--tab-transition: background-color 200ms ease-out, color 200ms ease-out;
--tab-cursor: pointer; /* 设置鼠标指针为手型 */
--tab-cursor: pointer;
--tab-color: #7f8792;
--tab-background: #EFF0F4;
--tab-active-color: #222529;
@@ -15,7 +15,8 @@
padding: 0;
}
html, body {
html,
body {
margin: 0;
padding: 0;
font-family: 'Roboto', sans-serif;
@@ -39,8 +40,43 @@ html, body {
-webkit-app-region: drag;
}
.nav ul {
/* 导航按钮 */
.nav-controls {
display: flex;
align-items: center;
margin-right: 12px;
-webkit-app-region: none;
}
.nav-controls div {
display: flex;
justify-content: center;
align-items: center;
width: 32px;
height: 32px;
cursor: pointer;
}
.nav-controls svg {
width: 16px;
height: 16px;
color: var(--tab-active-color);
}
.nav-controls .disabled {
cursor: not-allowed !important;
}
.nav-controls .disabled svg {
opacity: 0.3;
}
/* 标签 */
.nav-tabs {
min-width: 0;
flex: 1;
display: flex;
gap: 8px;
height: 35px;
margin-top: 5px;
user-select: none;
@@ -48,18 +84,17 @@ html, body {
overflow-y: hidden;
}
.nav ul::-webkit-scrollbar {
.nav-tabs::-webkit-scrollbar {
display: none;
}
.nav ul li {
.nav-tabs li {
display: inline-flex;
position: relative;
box-sizing: border-box;
align-items: center;
height: calc(100% - 5px);
padding: 7px 8px;
margin: 0 8px 0 0;
min-width: 100px;
max-width: 240px;
scroll-margin: 12px;
@@ -70,24 +105,23 @@ html, body {
-webkit-app-region: none;
}
.nav ul li:first-child {
margin-left: 8px;
.nav-tabs li:first-child {
border-left: none;
}
.nav ul li.active {
.nav-tabs li.active {
color: var(--tab-active-color);
background: var(--tab-active-background);
border-radius: 4px;
}
.nav ul li.active .tab-icon.background {
.nav-tabs li.active .tab-icon.background {
background-image: url(../image/link_normal_selected_icon.png);
}
.nav ul li:not(.active)::after {
.nav-tabs li:not(.active)::after {
position: absolute;
right: 0;
width: 1px;
@@ -96,22 +130,24 @@ html, body {
content: '';
}
.nav ul li:not(.active):last-child::after {
.nav-tabs li:not(.active):last-child::after {
content: none;
}
/* 浏览器打开 */
.browser {
.nav-browser {
flex-shrink: 0;
display: flex;
align-items: center;
height: 40px;
padding: 0 14px;
margin: 0 2px;
cursor: pointer;
background-color: var(--tab-background);
-webkit-app-region: none;
}
.browser span {
.nav-browser span {
display: inline-block;
width: 18px;
height: 18px;
@@ -123,8 +159,8 @@ html, body {
.tab-icon {
display: inline-block;
flex-shrink: 0;
width: 18px;
height: 18px;
width: 16px;
height: 16px;
background-size: cover;
}
@@ -161,6 +197,7 @@ html, body {
0% {
transform: scale(0.8) rotate(0deg);
}
100% {
transform: scale(0.8) rotate(360deg);
}
@@ -170,8 +207,7 @@ html, body {
.tab-title {
display: inline-block;
flex: 1;
margin-right: 8px;
margin-left: 6px;
margin: 0 8px;
overflow: hidden;
line-height: 150%;
text-overflow: ellipsis;
@@ -205,18 +241,17 @@ html, body {
}
/* 不同平台样式 */
body.win32 .nav ul {
margin-left: 8px;
margin-right: 186px;
body.win32 .nav {
padding-left: 8px;
padding-right: 140px;
}
body.win32 .browser {
right: 140px;
body.darwin .nav {
padding-left: 76px;
}
body.darwin .nav ul {
margin-left: 76px;
}
body.darwin.full-screen .nav ul {
margin-left: 8px;
body.darwin.full-screen .nav {
padding-left: 8px;
}
/* 暗黑模式 */
@@ -229,11 +264,11 @@ body.darwin.full-screen .nav ul {
--tab-close-color: #E3E3E3;
}
.nav ul li.active .tab-icon.background {
.nav-tabs li.active .tab-icon.background {
background-image: url(../image/dark/link_normal_selected_icon.png);
}
.browser span {
.nav-browser span {
background-image: url(../image/dark/link_normal_selected_icon.png);
}

View File

@@ -0,0 +1,157 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LOAD FAILED</title>
<style>
:root {
--bg: #ffffff;
--fg: #1f2328;
--muted: #6a737d;
--border: #e1e4e8;
--btn: #84c56a;
--btn-fg: #ffffff;
--btn-outline: #d0e2ff;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0D0D0D;
--fg: #e6edf3;
--muted: #9aa7b2;
--border: #30363d;
--btn: #84c56a;
--btn-fg: #ffffff;
--btn-outline: #84c56a44;
}
}
html,
body {
height: 100%;
}
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font: 14px/1.5 -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol;
display: grid;
place-items: center;
}
.card {
width: min(680px, calc(100% - 32px));
padding: 20px;
box-sizing: border-box;
}
h1 {
margin: 0 0 12px;
font-size: 18px;
}
p {
margin: 0 0 12px;
color: var(--muted);
}
code {
background: var(--btn-outline);
padding: 2px 6px;
border-radius: 6px;
}
.row {
display: flex;
margin-bottom: 8px;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.url {
overflow-wrap: anywhere;
}
.actions {
margin-top: 14px;
display: flex;
gap: 12px;
flex-wrap: wrap;
}
button {
appearance: none;
border: 1px solid var(--btn);
background: var(--btn);
color: var(--btn-fg);
padding: 8px 24px;
border-radius: 8px;
cursor: pointer;
}
button.secondary {
background: transparent;
color: var(--fg);
border-color: var(--border);
}
</style>
<script>
function qs(key) {
return new URLSearchParams(location.search).get(key) || ''
}
function setText(id, text) {
var el = document.getElementById(id);
if (el) {
el.textContent = text
}
}
function retry() {
var u = qs('url');
if (u) {
location.href = u
}
}
function closeTab() {
window.close()
}
document.addEventListener('DOMContentLoaded', function () {
setText('url', qs('url'))
setText('code', qs('code'))
setText('desc', qs('desc'))
})
</script>
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline';">
<meta name="color-scheme" content="light dark">
<meta name="referrer" content="no-referrer">
<meta name="robots" content="noindex">
<meta name="format-detection" content="telephone=no,email=no,address=no">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#00000000">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Load Error">
</head>
<body>
<div class="card">
<h1>LOAD FAILED</h1>
<div class="row">URL: <span id="url" class="url"></span></div>
<div class="row">Error code: <code id="code"></code></div>
<p id="desc"></p>
<div class="actions">
<button onclick="retry()">Retry</button>
<button class="secondary" onclick="closeTab()">Close</button>
</div>
</div>
</body>
</html>

View File

@@ -9,7 +9,19 @@
<body>
<div id="app" class="app">
<div class="nav">
<ul>
<div class="nav-controls">
<div class="nav-back" :class="{disabled: !canGoBack}" @click="goBack">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>
</div>
<div class="nav-forward" :class="{disabled: !canGoForward}" @click="goForward">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>
</div>
<div class="nav-refresh" @click="loadingState ? stop() : refresh()">
<svg v-if="loadingState" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" style="transform:scale(0.99)" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
</div>
</div>
<ul class="nav-tabs">
<li v-for="item in tabs" :data-id="item.id" :class="{active: activeId === item.id}" @click="onSwitch(item)">
<div v-if="item.state === 'loading'" class="tab-icon loading">
<div class="tab-icon-loading"></div>
@@ -19,9 +31,9 @@
<div class="tab-close" @click.stop="onClose(item)"></div>
</li>
</ul>
</div>
<div v-if="canBrowser" class="browser" @click="onBrowser">
<span></span>
<div v-if="canBrowser" class="nav-browser" @click="onBrowser">
<span></span>
</div>
</div>
</div>
@@ -29,10 +41,20 @@
const App = {
data() {
return {
// 当前激活的标签页ID
activeId: 0,
// 标签页列表
tabs: [],
// 停止定时器
stopTimer: null,
// 是否可以后退
canGoBack: false,
// 是否可以前进
canGoForward: false,
}
},
beforeCreate() {
@@ -42,6 +64,7 @@
window.__onDispatchEvent = (detail) => {
const {id, event} = detail
switch (event) {
// 创建标签页
case 'create':
this.tabs.push(Object.assign({
id,
@@ -52,6 +75,7 @@
}, detail))
break
// 关闭标签页
case 'close':
const closeIndex = this.tabs.findIndex(item => item.id === id)
if (closeIndex > -1) {
@@ -59,11 +83,14 @@
}
break
// 切换标签页
case 'switch':
this.activeId = id
this.scrollTabActive()
this.updateNavigationState()
break
// 页面标题
case 'title':
if (["HitoseaTask", "DooTask", "about:blank"].includes(detail.title)) {
return
@@ -75,6 +102,7 @@
}
break
// 页面图标
case 'favicon':
const faviconItem = this.tabs.find(item => item.id === id)
if (faviconItem) {
@@ -88,6 +116,7 @@
}
break
// 开始加载
case 'start-loading':
const startItem = this.tabs.find(item => item.id === id)
if (startItem) {
@@ -96,19 +125,33 @@
}
break
// 停止加载
case 'stop-loading':
this.stopTimer = setTimeout(_ => {
const stopItem = this.tabs.find(item => item.id === id)
if (stopItem) {
stopItem.state = 'loaded'
}
if (id === this.activeId) {
this.updateNavigationState()
}
}, 300)
break
// 导航状态
case 'navigation-state':
if (id === this.activeId) {
this.canGoBack = detail.canGoBack
this.canGoForward = detail.canGoForward
}
break
// 进入全屏
case 'enter-full-screen':
document.body.classList.add('full-screen')
break
// 离开全屏
case 'leave-full-screen':
document.body.classList.remove('full-screen')
break
@@ -119,41 +162,85 @@
}
},
computed: {
/**
* 获取当前激活的标签页
* @returns {object|null}
*/
activeItem() {
if (this.tabs.length === 0) {
return null
}
return this.tabs.find(item => item.id === this.activeId)
},
/**
* 获取页面标题
* @returns {string}
*/
pageTitle() {
return this.activeItem ? this.activeItem.title : 'Untitled'
},
/**
* 是否可以打开浏览器
* @returns {boolean}
*/
canBrowser() {
return !(this.activeItem && this.isLocalHost(this.activeItem.url))
},
/**
* 获取加载状态
* @returns {boolean}
*/
loadingState() {
return this.activeItem ? this.activeItem.state === 'loading' : false
}
},
watch: {
/**
* 监听页面标题
* @param title
*/
pageTitle(title) {
document.title = title;
},
},
methods: {
/**
* 切换标签页
* @param item
*/
onSwitch(item) {
this.sendMessage('webTabActivate', item.id)
},
/**
* 关闭标签页
* @param item
*/
onClose(item) {
this.sendMessage('webTabClose', item.id);
},
/**
* 打开浏览器
*/
onBrowser() {
this.sendMessage('webTabExternal')
},
/**
* 获取标签页图标样式
* @param item
* @returns {string}
*/
iconStyle(item) {
return item.icon ? `background-image: url(${item.icon})` : ''
},
/**
* 获取标签页标题
* @param item
* @returns {string}
*/
tabTitle(item) {
if (item.title) {
return item.title
@@ -162,14 +249,20 @@
return 'Loading...'
}
if (item.url) {
if (/localhost:/.test(item.url)) {
return 'Loading...'
}
return `${item.url}`.replace(/^https?:\/\//, '')
}
},
/**
* 滚动到当前激活的标签页
*/
scrollTabActive() {
setTimeout(() => {
try {
const child = document.querySelector(`.nav ul li[data-id=${this.activeId}]`)
const child = document.querySelector(`.nav-tabs li[data-id="${this.activeId}"]`)
if (child) {
child.scrollIntoView({behavior: 'smooth', block: 'nearest'})
}
@@ -179,10 +272,52 @@
}, 0)
},
/**
* 发送消息
* @param event
* @param args
*/
sendMessage(event, args) {
electron?.sendMessage(event, args)
},
/**
* 后退
*/
goBack() {
if (!this.canGoBack) return
this.sendMessage('webTabGoBack')
},
/**
* 前进
*/
goForward() {
if (!this.canGoForward) return
this.sendMessage('webTabGoForward')
},
/**
* 停止
*/
stop() {
this.sendMessage('webTabStop')
},
/**
* 刷新
*/
refresh() {
this.sendMessage('webTabReload')
},
/**
* 更新导航状态
*/
updateNavigationState() {
this.sendMessage('webTabGetNavigationState')
},
/**
* 判断是否是本地URL
* @param url

View File

@@ -1 +0,0 @@
<script>window.location.href=window.location.href.replace(/:\d+/, ':' + 2222)</script>

37
jsconfig.json Normal file
View File

@@ -0,0 +1,37 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"~element-sea/*": ["node_modules/element-sea/*"],
"~quill-hi/*": ["node_modules/quill-hi/*"],
"~quill-mention-hi/*": ["node_modules/quill-mention-hi/*"]
},
"moduleResolution": "node",
"module": "ESNext",
"target": "ES2019",
"allowJs": true,
"checkJs": false,
"jsx": "preserve",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"types": []
},
"include": [
"resources/assets/js/**/*",
"resources/assets/**/*.vue",
"resources/assets/sass/**/*",
"types/**/*.d.ts"
],
"exclude": [
"node_modules",
"vendor",
"storage",
"public",
"tests",
"docker",
"language",
"database",
"bin"
]
}

29
language/README.md Normal file
View File

@@ -0,0 +1,29 @@
# 语言翻译工具说明
`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

@@ -446,8 +446,6 @@ API接口文档
会议已结束
请选择举报类型
请填写举报原因
开启语音转文字功能需要先设置 AI 助理。
语音转文字功能未开启
语音文件不存在
语音转文字失败
仅支持语音消息
@@ -615,8 +613,6 @@ webhook地址最长仅支持255个字符。
消息不存在或已被删除
此消息不支持翻译
消息内容为空
开启翻译功能需要先设置 AI 助理。
翻译功能未开启
翻译失败
超期任务
@@ -880,10 +876,71 @@ URL格式不正确
更新失败:(*)
应用列表正在更新中,请稍后再试
应用正在下载中,请稍后再试
应用「*」未安装
应用「(*)」未安装
没有权限修改标签
没有权限删除标签
标签已存在
工作流状态创建失败
排序已保存
同步完成,子部门中没有成员需要同步
同步完成,共同步(*)个成员
同步完成,共同步(*)个成员,其中(*)个成员已在当前部门
无效的收藏类型
收藏成功
取消收藏成功
清理(*)收藏成功
清理全部收藏成功
清理完成
重命名成功
请输入会话名称
复制任务
调整模板排序
调整标签排序
收藏记录不存在
修改备注成功
请输入修改备注
备注最多支持(*)个字符
当前任务已是主任务
子任务升级为主任务
升级为主任务
报告内容为空,无法进行分析
工作汇报分析失败
工作汇报分析结果为空
缺少ID参数
无权访问该工作汇报
生成AI分析失败
工作汇报内容不能为空
整理内容为空
汇报整理失败
汇报整理结果为空
汇报内容不能为空
汇报内容解析失败
整理汇报失败
整理后的内容为空
生日格式错误
地址最多只能设置(*)个字
个人简介最多只能设置(*)个字
会员不存在
请输入个性标签
标签名称最多只能设置(*)个字
标签已存在
每位会员最多添加(*)个标签
参数错误
标签不存在
无权操作该标签
已取消认可
认可成功
选择模型
请先配置 AI 助手
请先在「AI 助手」设置中配置 OpenAI
今日未完成的工作
本周未完成的工作

View File

@@ -1221,6 +1221,8 @@ OKR 结果分析
未完成
AI 机器人
任务相关
个人任务上限
负责人或协助人的未完成任务数量上限最大2000。
请填写名称!
使用代理
支持 http 或 socks 代理
@@ -1498,10 +1500,6 @@ License Key
举报图
举报投诉
转文字
语音转文字
长按语音消息可转换成文字。
需要先设置 AI 助理
关闭语音转文字功能。
你确定要删除文件【(*)】吗?
查看附件
下载附件
@@ -1564,17 +1562,10 @@ API接口文档
搜索关键词
查看会话ID
查看接口列表
发送文本消息
对话ID
消息内容
回复指定消息ID
对话类型
消息ID
消息发送人ID
消息发送人信息
是否被@到
机器人ID
系统版本
机器人详情
保留消息时间
最后一次清理时间
@@ -1717,10 +1708,6 @@ WiFi签到延迟时长为±1分钟。
您发起的「(**)」被(*)拒绝
翻译
翻译消息
长按文本消息可翻译成当前设置的语言。
关闭文本消息翻译功能。
从不
每天
工作日
@@ -1964,8 +1951,6 @@ API请求的基础URL路径如果没有请留空
Grok是由xAI开发的生成式人工智能聊天机器人旨在通过实时回答用户问题来提供帮助。
Ollama 是一个轻量级、可扩展的框架,旨在让用户能够在本地机器上构建和运行大型语言模型。
AI 列表
AI 设置
思考中...
请先填写 Base URL
@@ -2095,37 +2080,17 @@ OKR群组
已加入
邀请地址不存在或已被删除!
API 使用说明
Webhook 消息推送
html 或 md
yes 或 no
会话名称
结果
参数名
名称
命令
回复/引用的消息文本
开发者可以通过此接口调用机器人向指定对话发送文本消息。
必填
接口信息
接口地址
推送参数
搜索词
文本类型
机器人收到消息后会自动POST推送到配置的Webhook地址请求超时为10秒。
消息文本内容
清理时间
留空自动生成
示例值
类型
该机器人不支持
说明
请求参数
请求头
请求方式
通过机器人向指定对话发送文本消息
静默模式
属性
会话ID
@@ -2134,7 +2099,7 @@ yes 或 no
部门成员
AI 助手
此功能并非聊天机器人,而是用于辅助工作。比如:语音转文字、聊天翻译等。
此功能并非聊天机器人,而是用于辅助工作。比如:语音转文字、聊天翻译、整理分析工作报告等。
如果需要聊天机器人请在「应用」中使用「AI 机器人」插件。
AI 提供商
支持OpenAI
@@ -2147,6 +2112,165 @@ API URL
代理
请输入代理
选填,支持 http、https、socks5 协议
需要先设置 AI 助理
打开签到机器人
下载内容
退出排序
调整排序
解散
你确定要解散【(*)】群组吗?
允许游客访问此链接
警告:任何人都可通过此链接访问文件
同步部门成员
当前部门没有子部门,无需同步
你确定要同步部门成员吗?
注:此操作会同步子部门成员到当前部门
我的收藏
收藏类型
全部类型
搜索收藏名称
所属项目
收藏时间
确定要取消收藏"(*)"吗?
取消收藏
收藏
没有相关的收藏
取消收藏成功
收藏项目
操作失败
最近打开
最近访问时间
任务文件
聊天文件
文件库
AI 生成
请简要描述项目目标、范围或关键里程碑AI 将生成名称和任务列表
请输入项目需求
请简要描述消息的主题、语气或要点AI 将生成完整消息
请输入消息需求
当前未选择会话
AI 未生成内容
请简要描述任务目标、背景或预期交付AI 将生成标题、详细说明和子任务
请输入任务需求
数据导出
调整模板排序
调整标签排序
请输入会话名称
重命名会话
完成排序
拖拽调整排序
排序保存失败
复制前
复制后
复制任务
可选,留空则不执行迁移
修改备注
共同群组
暂无共同群组
(*)个
查看更多...
子任务升级为主任务
升级为主任务
升主任务
你确定要将子任务【(*)】升级为主任务吗?
桌面 MCP 服务器
启用桌面 MCP 服务器
关闭 MCP 服务器
MCP 服务器已启动成功!
服务地址
接入配置
以接入 Claude 为例,在配置文件中添加以下配置
复制配置
使用示例
配置生效后,即可通过自然语言使用 MCP 服务
查看我未完成的任务
搜索包含'报告'的任务
在项目1中创建任务完成用户手册
把任务789的截止时间改为下周五
我有哪些项目?
查看项目5的详情包括所有列和成员
我知道了
橙色
青色
深蓝
深绿
金色
湖蓝
AI 分析
重新分析
生成分析
最后更新:
暂无 AI 分析,点击右侧按钮生成。
AI 整理汇报
应用到汇报
请先填写汇报内容
已应用整理结果
Webhook事件
接收消息
打开会话
成员加入
成员退出
是否拨打电话给(*)
是否发送邮件给(*)
个人信息
我的群组
个性标签管理
请输入个性标签
还没有个性标签,快来添加吧~
认可
由(*)创建
当前共(*)个标签
删除标签
地址
请输入地址
个人简介
请输入个人简介
个性标签
暂无个性标签
管理
用户
应用此内容
生成中...
等待 AI 回复...
请输入你的问题...
选择模型
暂无可用模型
获取模型列表失败
请选择模型
获取 stream_key 失败
补充你想聚焦的风险、成果或建议,留空直接生成分析
保存分析
当前没有可分析的汇报
汇报内容为空,无法分析
AI 分析已更新
保存 AI 分析失败
补充你想强调的重点或特殊说明AI 将在此基础上整理汇报
拖动卡片调整顺序,保存后仅自己可见
恢复默认
当前列表没有可归档的已完成任务
归档已完成任务
你确定将列表【(*)】中所有已完成的任务归档吗?
已归档列表中所有已完成任务
归档失败,请稍后再试
请输入 URL
URL不能为空
仅管理员可使用此功能

File diff suppressed because it is too large Load Diff

View File

@@ -5,8 +5,76 @@ require __DIR__ . '/vendor/autoload.php';
use Orhanerday\OpenAi\OpenAi;
require_once("config.php");
// 读取 .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 = [];
@@ -41,7 +109,7 @@ foreach ($tmps as $obj) {
$translations[$originalKey] = $obj;
if (!in_array($originalKey, $originals)) {
// 多余的数据
unset($translations[$originalKey]);
$redundants[$originalKey] = $obj;
continue;
}
@@ -73,8 +141,7 @@ if (count($regrror) > 0) {
}
if (count($redundants) > 0) {
print_r("多余的数据:\n");
print_r($redundants);
exit();
print_r(implode(", ", array_keys($redundants)) . "\n\n");
}
// 需要翻译的数据
@@ -102,10 +169,13 @@ if (count($needs) > 0) {
// 开始翻译
print_r("正在翻译:" . (count($keys) + $done) . "/" . count($needs) . "...\n");
$openAi = new OpenAi(OPEN_AI_KEY);
$openAi->setProxy(OPEN_AI_PROXY);
$openAi = new OpenAi($openAiKey);
if ($openAiProxy !== '') {
$openAi->setProxy($openAiProxy);
}
$result = $openAi->chat([
'model' => 'gpt-4.1',
"model" => "gpt-5.1",
"reasoning_effort" => "low",
'messages' => [
[
"role" => "system",
@@ -152,11 +222,7 @@ if (count($needs) > 0) {
"role" => "user",
"content" => $content,
],
],
'temperature' => 1.0,
'max_tokens' => 4000,
'frequency_penalty' => 0,
'presence_penalty' => 0,
]
]);
// 处理结果

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