Compare commits

..

313 Commits

Author SHA1 Message Date
kuaifan
a336fd4a1a feat: omit content from report list APIs 2025-12-30 01:58:03 +00:00
kuaifan
8759e6fd7e build 2025-12-30 09:20:59 +08:00
kuaifan
92d23014a7 fix: avoid opening blank dialog window when dialogId is 0 2025-12-29 16:22:06 +00:00
kuaifan
7c3f33ea0d fix: avoid mutating task getter arrays in mention list 2025-12-29 16:01:37 +00:00
kuaifan
16a55de6f1 feat: 增强搜索功能,支持通过 ID、名称和其他字段搜索任务、文件和报告 2025-12-29 15:43:50 +00:00
kuaifan
869ac7d316 feat: 更新 appstore 镜像版本至 0.3.8 2025-12-27 10:29:51 +00:00
kuaifan
55303689ea feat: support configurable default priority 2025-12-26 02:42:47 +00:00
kuaifan
c69123ac92 no message 2025-12-24 09:49:21 +00:00
kuaifan
7bce5f1c1f feat: 添加迁移脚本以为相关表添加索引 2025-12-24 09:18:48 +00:00
kuaifan
989660969c feat: 添加迁移脚本以反转待办消息中的用户ID顺序 2025-12-24 07:11:01 +00:00
kuaifan
862acd0776 fix: 修复行前缀检测逻辑,确保正确判断空行 2025-12-24 06:30:43 +00:00
kuaifan
3b3ffd494f feat: 规范以斜杠开头的命令 2025-12-24 06:10:39 +00:00
kuaifan
6cf8290565 feat: 增强斜杠命令支持,添加机器人命令和行首检测功能 2025-12-24 05:58:48 +00:00
kuaifan
230ebbcfb9 feat: support slash trigger for mention/task/file/report 2025-12-24 00:59:31 +00:00
kuaifan
dc77f1cda1 build 2025-12-23 09:51:18 +08:00
kuaifan
1f791b528a fix: 更新对话ID和场景信息的描述,增加字段标识 2025-12-23 01:40:53 +00:00
kuaifan
1459d953ed feat: 更新获取消息列表MCP工具的描述,增强功能说明 2025-12-22 03:44:33 +00:00
kuaifan
719a36b275 chore: update mobile subproject commit reference 2025-12-19 22:35:57 +08:00
kuaifan
0b7a3046fe fix: align parent task subtask progress with task detail (include archived, exclude deleted) 2025-12-19 21:36:00 +08:00
kuaifan
203d107d68 fix: skip loading related tasks for subtasks to prevent request spam 2025-12-19 19:37:07 +08:00
kuaifan
17fd7f02a6 build 2025-12-19 09:13:49 +08:00
kuaifan
57ea4f2b6f feat: 自定义应用菜单新增 immersive 沉浸式开关 2025-12-19 01:07:02 +00:00
kuaifan
df431eea46 no message 2025-12-18 23:12:53 +00:00
kuaifan
ad9dd6330f feat: merge todo done notices and render done_userids 2025-12-18 23:03:11 +00:00
kuaifan
df9d291f98 feat: 优化群组资料修改逻辑,增加权限判断和名称修改提示 2025-12-18 21:53:04 +00:00
kuaifan
0cf7fc2ed2 feat: replace group name quick edit with modify trigger 2025-12-18 21:42:15 +00:00
kuaifan
e8f82baa99 feat: 添加 urlType 字段以兼容旧版本微应用配置 2025-12-18 21:06:49 +00:00
kuaifan
353a05f344 feat: 优化 openMicroApp 方法,增强参数校验和微应用 ID 解析逻辑 2025-12-18 20:59:44 +00:00
kuaifan
d94ebfe04c feat: 添加解析类型的方法,优化微应用配置逻辑 2025-12-18 08:26:42 +00:00
kuaifan
52913abb4f feat: 更新 appstore 镜像版本至 0.3.7 2025-12-18 02:47:39 +00:00
kuaifan
d77406951d feat: 更新微应用菜单配置,统一使用类型字段替代URL类型字段 2025-12-18 02:44:37 +00:00
kuaifan
8c23192eeb build 2025-12-17 09:30:53 +08:00
kuaifan
078c9c198d feat: 更新 appstore 镜像版本至 0.3.6 2025-12-16 11:32:33 +00:00
kuaifan
6cfe2d226a feat: 增加获取胶囊可见性的方法,优化胶囊显示逻辑 2025-12-16 11:31:50 +00:00
kuaifan
fee1c12357 feat: 添加导航功能,支持快捷键和鼠标手势操作 2025-12-16 18:36:11 +08:00
kuaifan
a6385b699e fix: 修复在某些情况下无法打开微应用的问题 2025-12-14 22:36:14 +00:00
kuaifan
718ed8953f no message 2025-12-14 00:23:04 +00:00
kuaifan
a1eea77b9e feat: 更新 appstore 镜像版本至 0.3.5 2025-12-12 07:12:07 +00:00
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
484 changed files with 31078 additions and 10121 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()
{

File diff suppressed because it is too large Load Diff

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
@@ -135,7 +152,9 @@ class FileController extends AbstractController
}
if ($key) {
if (!$id && Base::isNumber($key)) {
$builder->where("id", $key);
$builder->where(function ($query) use ($key) {
$query->where("id", $key)->orWhere("name", "like", "%{$key}%");
});
} else {
$builder->where("name", "like", "%{$key}%");
}
@@ -157,7 +176,13 @@ class FileController extends AbstractController
$builder->where("id", $id);
}
if ($key) {
$builder->where("name", "like", "%{$key}%");
if (Base::isNumber($key)) {
$builder->where(function ($query) use ($key) {
$query->where("id", $key)->orWhere("name", "like", "%{$key}%");
});
} else {
$builder->where("name", "like", "%{$key}%");
}
}
$list = $builder->take($take)->get();
if ($list->isNotEmpty()) {
@@ -175,7 +200,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/add 04. 添加、修改文件(夹)
* @api {get} api/file/add 添加、修改文件(夹)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -284,7 +309,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/copy 05. 复制文件(夹)
* @api {get} api/file/copy 复制文件(夹)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -345,7 +370,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/move 06. 移动文件(夹)
* @api {get} api/file/move 移动文件(夹)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -420,7 +445,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/remove 07. 删除文件(夹)
* @api {get} api/file/remove 删除文件(夹)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -459,7 +484,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/content 08. 获取文件内容
* @api {get} api/file/content 获取文件内容
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -523,6 +548,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 +565,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 +660,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 +675,6 @@ class FileController extends AbstractController
*/
public function office__token()
{
User::auth();
//
File::isNeedInstallApp('office');
//
$config = Request::input('config');
@@ -652,7 +685,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 +741,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 +769,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 +801,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 +843,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/share 15. 获取共享信息
* @api {get} api/file/share 获取共享信息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -846,7 +879,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 +969,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 +1003,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/link 18. 获取链接
* @api {get} api/file/link 获取链接
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -981,6 +1014,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 +1028,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
@@ -50,7 +51,9 @@ class ReportController extends AbstractController
{
$user = User::auth();
//
$builder = Report::with(['receivesUser'])->whereUserid($user->userid);
$builder = Report::with(['receivesUser'])
->select(Report::LIST_FIELDS)
->whereUserid($user->userid);
$keys = Request::input('keys');
if (is_array($keys)) {
if ($keys['key']) {
@@ -58,6 +61,11 @@ class ReportController extends AbstractController
$builder->whereHas('sendUser', function ($q2) use ($keys) {
$q2->where("users.email", "LIKE", "%{$keys['key']}%");
});
} elseif (Base::isNumber($keys['key'])) {
$builder->where(function ($query) use ($keys) {
$query->where("id", intval($keys['key']))
->orWhere("title", "LIKE", "%{$keys['key']}%");
});
} else {
$builder->where("title", "LIKE", "%{$keys['key']}%");
}
@@ -75,7 +83,7 @@ class ReportController extends AbstractController
}
/**
* @api {get} api/report/receive 02. 我接收的汇报
* @api {get} api/report/receive 我接收的汇报
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -98,7 +106,8 @@ class ReportController extends AbstractController
public function receive(): array
{
$user = User::auth();
$builder = Report::with(['receivesUser']);
$builder = Report::with(['receivesUser'])
->select(Report::LIST_FIELDS);
$builder->whereHas("receivesUser", function ($query) use ($user) {
$query->where("report_receives.userid", $user->userid);
});
@@ -110,7 +119,11 @@ class ReportController extends AbstractController
$q2->where("users.email", "LIKE", "%{$keys['key']}%");
});
} elseif (Base::isNumber($keys['key'])) {
$builder->where("userid", intval($keys['key']));
$builder->where(function ($query) use ($keys) {
$query->where("userid", intval($keys['key']))
->orWhere("id", intval($keys['key']))
->orWhere("title", "LIKE", "%{$keys['key']}%");
});
} else {
$builder->where("title", "LIKE", "%{$keys['key']}%");
}
@@ -143,7 +156,7 @@ class ReportController extends AbstractController
}
/**
* @api {get} api/report/store 03. 保存并发送工作汇报
* @api {get} api/report/store 保存并发送工作汇报
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -282,7 +295,7 @@ class ReportController extends AbstractController
}
/**
* @api {get} api/report/template 04. 生成汇报模板
* @api {get} api/report/template 生成汇报模板
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -326,6 +339,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 +381,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 +400,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 +420,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 +436,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 +491,7 @@ class ReportController extends AbstractController
}
/**
* @api {get} api/report/detail 05. 报告详情
* @api {get} api/report/detail 报告详情
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -501,11 +538,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 +687,7 @@ class ReportController extends AbstractController
}
/**
* @api {get} api/report/share 07. 分享报告到消息
* @api {get} api/report/share 分享报告到消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -610,7 +749,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 +767,7 @@ class ReportController extends AbstractController
}
/**
* @api {get} api/report/unread 09. 获取未读
* @api {get} api/report/unread 获取未读
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -653,7 +792,7 @@ class ReportController extends AbstractController
}
/**
* @api {get} api/report/read 10. 标记汇报已读,可批量
* @api {get} api/report/read 标记汇报已读,可批量
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -691,4 +830,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
@@ -777,34 +697,64 @@ class SystemController extends AbstractController
if ($type == 'save') {
User::auth('admin');
$list = Request::input('list');
$array = [];
if (empty($list) || !is_array($list)) {
return Base::retError('参数错误');
}
foreach ($list AS $item) {
if (empty($item['name']) || empty($item['color']) || empty($item['priority'])) {
continue;
}
$array[] = [
'name' => $item['name'],
'color' => $item['color'],
'days' => intval($item['days']),
'priority' => intval($item['priority']),
];
}
$array = Setting::normalizeTaskPriorityList($list);
if (empty($array)) {
return Base::retError('参数为空');
}
$setting = Base::setting('priority', $array);
} else {
$setting = Base::setting('priority');
$setting = Setting::normalizeTaskPriorityList(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 +797,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 +867,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 +898,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 +913,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 +930,7 @@ class SystemController extends AbstractController
}
/**
* @api {post} api/system/imgupload 19. 上传图片
* @api {post} api/system/imgupload 上传图片
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1046,7 +996,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 +1093,7 @@ class SystemController extends AbstractController
}
/**
* @api {post} api/system/fileupload 21. 上传文件
* @api {post} api/system/fileupload 上传文件
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1187,7 +1137,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 +1180,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 +1226,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 +1448,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 +1469,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/version 26. 获取版本号
* @api {get} api/system/version 获取版本号
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -1565,7 +1515,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

@@ -156,7 +156,7 @@ class ProjectTask extends AbstractModel
return;
}
if (!isset($this->appendattrs['sub_num'])) {
$builder = self::whereParentId($this->id)->whereNull('archived_at');
$builder = self::whereParentId($this->id);
$this->appendattrs['sub_num'] = $builder->count();
$this->appendattrs['sub_complete'] = $builder->whereNotNull('complete_at')->count();
//
@@ -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')
@@ -417,6 +418,22 @@ class ProjectTask extends AbstractModel
}
//
$retPre = $parent_id ? '子任务' : '任务';
// 优先级:主任务在缺省时按系统默认补齐,并尽量补全 name/color
if ($parent_id == 0) {
$priorityList = Setting::normalizeTaskPriorityList(Base::setting('priority'));
if ($p_level > 0) {
$matched = reset(array_filter($priorityList, fn($item) => intval($item['priority']) === $p_level)) ?: null;
} else {
$matched = Setting::getDefaultTaskPriorityItem($priorityList);
}
if ($matched) {
$p_level = $p_level > 0 ? $p_level : intval($matched['priority']);
$p_name = $p_name ?: $matched['name'];
$p_color = $p_color ?: $matched['color'];
}
}
$task = self::createInstance([
'parent_id' => $parent_id,
'project_id' => $project_id,
@@ -455,8 +472,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 +1160,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 +1176,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 +1577,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 +1591,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 +1965,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 +2026,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;
/**
@@ -55,6 +56,15 @@ class Report extends AbstractModel
const WEEKLY = "weekly";
const DAILY = "daily";
public const LIST_FIELDS = [
'id',
'title',
'type',
'userid',
'sign',
'created_at',
'updated_at',
];
protected $fillable = [
"title",
@@ -78,6 +88,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,100 @@ class Setting extends AbstractModel
}
/**
* 是否开启 AI 助理
* 规范任务优先级设置(确保字段完整且仅有一个默认项)
* @param mixed $list
* @return array<int, array{name:string,color:string,days:int,priority:int,is_default:int}>
*/
public static function normalizeTaskPriorityList($list)
{
if (!is_array($list)) {
return [];
}
$normalized = [];
$defaultIndex = null;
foreach ($list as $item) {
if (!is_array($item)) {
continue;
}
$name = trim((string)($item['name'] ?? ''));
$color = trim((string)($item['color'] ?? ''));
$priority = intval($item['priority'] ?? 0);
if ($name === '' || $color === '' || $priority <= 0) {
continue;
}
$days = intval($item['days'] ?? 0);
$isDefault = !empty($item['is_default']) || !empty($item['default']);
if ($defaultIndex === null && $isDefault) {
$defaultIndex = count($normalized);
}
$normalized[] = [
'name' => $name,
'color' => $color,
'days' => $days,
'priority' => $priority,
'is_default' => $isDefault ? 1 : 0,
];
}
if (!empty($normalized)) {
$defaultIndex = $defaultIndex ?? 0;
foreach ($normalized as $i => $row) {
$normalized[$i]['is_default'] = $i === $defaultIndex ? 1 : 0;
}
}
return array_values($normalized);
}
/**
* 获取默认任务优先级(来自 settings.priority
* @param array|null $list
* @return array|null
*/
public static function getDefaultTaskPriorityItem($list = null)
{
$list = $list ?? Base::setting('priority');
$list = self::normalizeTaskPriorityList($list);
if (empty($list)) {
return null;
}
foreach ($list as $item) {
if (!empty($item['is_default'])) {
return $item;
}
}
return $list[0];
}
/**
* 是否开启 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 +229,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);
$type = strtolower(trim($menu['type'] ?? 'iframe'));
$payload = [
'location' => $location,
'label' => $label,
'icon' => Base::newTrim($menu['icon'] ?? ''),
'url' => $url,
'type' => $type,
'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,92 @@ 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);
}
// 规范以斜杠开头的命令
$result = preg_replace('/^\s*\\//', '/', $result);
return $result;
}
/**
* 处理文本消息内容,用于发送前
* @param $text
@@ -1227,6 +1283,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 +1350,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,44 @@ 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),
'Version' => Base::getVersion(),
];
$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){$dialog->id}",
"当前系统时间(now){$currentTime}",
];
if ($dialog->type === 'group') {
switch ($dialog->group_type) {
case 'project':
$projectInfo = Project::whereDialogId($dialog->id)->first();
if ($projectInfo) {
$contextLines[] = "场景:项目群聊「{$projectInfo->name}project_id: {$projectInfo->id}";
}
break;
case 'task':
$taskInfo = ProjectTask::with(['content'])->whereDialogId($dialog->id)->first();
if ($taskInfo) {
$contextLines[] = "场景:任务群聊「{$taskInfo->name}task_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

@@ -0,0 +1,79 @@
<?php
use App\Module\Base;
use App\Models\Setting;
use Illuminate\Database\Migrations\Migration;
class UpdateSettingMicroappMenuType extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
$row = Setting::whereName('microapp_menu')->first();
if (!$row) {
return;
}
$data = Base::string2array($row->setting);
if (empty($data) || !is_array($data)) {
return;
}
$changed = false;
foreach ($data as $appIndex => $app) {
if (!is_array($app)) {
continue;
}
$menuItems = [];
if (isset($app['menu_items']) && is_array($app['menu_items'])) {
$menuItems = $app['menu_items'];
} elseif (isset($app['menu']) && is_array($app['menu'])) {
$menuItems = [$app['menu']];
}
if (empty($menuItems)) {
continue;
}
$newMenuItems = [];
foreach ($menuItems as $menu) {
if (!is_array($menu)) {
$newMenuItems[] = $menu;
continue;
}
if (!isset($menu['type']) && isset($menu['url_type'])) {
$menu['type'] = $menu['url_type'];
unset($menu['url_type']);
$changed = true;
} elseif (isset($menu['url_type'])) {
unset($menu['url_type']);
$changed = true;
}
$newMenuItems[] = $menu;
}
if (isset($app['menu_items']) && is_array($app['menu_items'])) {
$data[$appIndex]['menu_items'] = $newMenuItems;
} elseif (isset($app['menu']) && is_array($app['menu'])) {
$data[$appIndex]['menu'] = $newMenuItems[0] ?? $app['menu'];
}
}
if ($changed) {
$row->updateInstance(['setting' => $data]);
$row->save();
}
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
// No-op: do not revert settings payload.
}
}

View File

@@ -0,0 +1,61 @@
<?php
use App\Module\Base;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
class ReverseDoneUseridsInTodoDoneMsgs extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
$this->reverseDoneUserids('2025-12-19 00:00:00');
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
$this->reverseDoneUserids('2025-12-19 00:00:00');
}
private function reverseDoneUserids(string $after)
{
DB::table('web_socket_dialog_msgs')
->select(['id', 'msg'])
->where('type', 'todo')
->where('created_at', '>', $after)
->orderBy('id')
->chunkById(200, function ($rows) {
foreach ($rows as $row) {
$msg = Base::json2array($row->msg);
if (empty($msg) || !is_array($msg)) {
continue;
}
if (($msg['action'] ?? '') !== 'done') {
continue;
}
$data = $msg['data'] ?? null;
if (!is_array($data)) {
continue;
}
$doneUserids = $data['done_userids'] ?? null;
if (!is_array($doneUserids) || count($doneUserids) < 2) {
continue;
}
$data['done_userids'] = array_reverse($doneUserids);
$msg['data'] = $data;
DB::table('web_socket_dialog_msgs')
->where('id', $row->id)
->update(['msg' => Base::array2json($msg)]);
}
});
}
}

View File

@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddIndexesToWebSocketDialogMsgs extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('web_socket_dialog_msgs', function (Blueprint $table) {
$table->index(['dialog_id', 'deleted_at', 'id']);
$table->index(['reply_id', 'deleted_at']);
});
Schema::table('web_socket_dialog_msg_reads', function (Blueprint $table) {
$table->index(['userid', 'silence', 'read_at'], 'idx_ws_msg_reads_userid_silence_read_at');
$table->index(['dialog_id', 'userid', 'mention', 'read_at'], 'idx_ws_msg_reads_dialog_user_mention_read_at');
});
Schema::table('user_checkin_records', function (Blueprint $table) {
$table->index(['userid', 'mac', 'date'], 'idx_user_checkin_records_userid_mac_date');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
// No-op: do not drop indexes automatically.
}
}

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('project_users', function (Blueprint $table) {
$table->index(['userid', 'project_id']);
});
Schema::table('project_tasks', function (Blueprint $table) {
$table->index(['project_id', 'archived_at', 'deleted_at', 'id']);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
// No-op: do not drop indexes automatically.
}
};

View File

@@ -37,7 +37,7 @@ class SettingsTableSeeder extends Seeder
array (
'name' => 'priority',
'desc' => '',
'setting' => '[{"name":"\\u91cd\\u8981\\u4e14\\u7d27\\u6025","color":"#ED4014","days":1,"priority":1},{"name":"\\u91cd\\u8981\\u4e0d\\u7d27\\u6025","color":"#F16B62","days":3,"priority":2},{"name":"\\u7d27\\u6025\\u4e0d\\u91cd\\u8981","color":"#19C919","days":5,"priority":3},{"name":"\\u4e0d\\u91cd\\u8981\\u4e0d\\u7d27\\u6025","color":"#2D8CF0","days":0,"priority":4}]',
'setting' => '[{"name":"\\u91cd\\u8981\\u4e14\\u7d27\\u6025","color":"#ED4014","days":1,"priority":1,"is_default":1},{"name":"\\u91cd\\u8981\\u4e0d\\u7d27\\u6025","color":"#F16B62","days":3,"priority":2,"is_default":0},{"name":"\\u7d27\\u6025\\u4e0d\\u91cd\\u8981","color":"#19C919","days":5,"priority":3,"is_default":0},{"name":"\\u4e0d\\u91cd\\u8981\\u4e0d\\u7d27\\u6025","color":"#2D8CF0","days":0,"priority":4,"is_default":0}]',
'created_at' => seeders_at('2021-07-01 08:04:30'),
'updated_at' => seeders_at('2021-07-01 09:20:26'),
),

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.8"
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;

387
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,87 @@ 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 navigation = require('./lib/navigation');
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 +116,7 @@ let showState = {},
}
}
// 开发模式加载
if (fs.existsSync(devloadPath)) {
let devloadContent = fs.readFileSync(devloadPath, 'utf8')
if (devloadContent.startsWith('http')) {
@@ -86,10 +125,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 +150,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 +181,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 +249,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 +312,7 @@ function createMainWindow() {
webSecurity: true,
nodeIntegration: true,
contextIsolation: true,
nativeWindowOpen: true
backgroundThrottling: false,
}
})
@@ -309,10 +356,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'}
@@ -321,6 +368,9 @@ function createMainWindow() {
// 设置右键菜单
electronMenu.webContentsMenu(mainWindow.webContents)
// 设置导航快捷键(返回/前进)
navigation.setup(mainWindow)
// 加载地址
utils.loadUrl(mainWindow, serverUrl)
}
@@ -416,7 +466,6 @@ function preCreateChildWindow() {
webSecurity: true,
nodeIntegration: true,
contextIsolation: true,
nativeWindowOpen: true
}
});
@@ -452,6 +501,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 +531,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 +591,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 +615,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'}
@@ -563,6 +627,9 @@ function createChildWindow(args) {
// 设置右键菜单
electronMenu.webContentsMenu(browser.webContents)
// 设置导航快捷键(返回/前进)
navigation.setup(browser)
// 加载地址
const hash = `${args.hash || args.path}`;
if (/^https?:/i.test(hash)) {
@@ -723,9 +790,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() {
@@ -789,9 +855,20 @@ function createWebTabWindow(args) {
webTabClosedByShortcut = true
} else if (utils.isMetaOrControl(input) && input.shift && input.key.toLowerCase() === 'i') {
devToolsWebTab(0)
} else {
const item = currentWebTab()
if (item) {
navigation.handleInput(event, input, item.view.webContents)
}
}
})
// 设置鼠标侧键和触控板手势导航
navigation.setupWindowEvents(webTabWindow, () => {
const item = currentWebTab()
return item ? item.view.webContents : null
})
webTabWindow.loadFile('./render/tabs/index.html', {}).then(_ => { }).catch(_ => { })
}
if (webTabWindow.isMinimized()) {
@@ -801,17 +878,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 +906,7 @@ function createWebTabWindow(args) {
})
browserView.webContents.setWindowOpenHandler(({url}) => {
if (allowedCalls.test(url)) {
openExternal(url)
openExternal(url).catch(() => {})
} else {
createWebTabWindow({url})
}
@@ -848,6 +924,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 +953,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 +966,7 @@ function createWebTabWindow(args) {
event: 'stop-loading',
id: browserView.webContents.id,
}).then(_ => { })
// 加载完成暗黑模式下把窗口背景色改成白色,避免透明网站背景色穿透
if (nativeTheme.shouldUseDarkColors) {
browserView.setBackgroundColor('#FFFFFF')
@@ -886,6 +980,8 @@ function createWebTabWindow(args) {
webTabClosedByShortcut = true
} else if (utils.isMetaOrControl(input) && input.shift && input.key.toLowerCase() === 'i') {
browserView.webContents.toggleDevTools()
} else {
navigation.handleInput(event, input, browserView.webContents)
}
})
@@ -895,8 +991,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 +1009,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 +1091,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 +1114,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 +1176,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 +1252,7 @@ app.on('before-quit', () => {
willQuitApp = true
})
app.on("will-quit",function(){
app.on("will-quit", () => {
globalShortcut.unregisterAll();
})
@@ -1257,7 +1377,7 @@ ipcMain.on('webTabExternal', (event) => {
if (!item) {
return
}
openExternal(item.view.webContents.getURL())
openExternal(item.view.webContents.getURL()).catch(() => {})
event.returnValue = "ok"
})
@@ -1283,6 +1403,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 +1535,7 @@ ipcMain.on('childWindowCloseAll', (event) => {
})
preloadWindow?.close()
mediaWindow?.close()
electronDown.close()
event.returnValue = "ok"
})
@@ -1334,6 +1548,7 @@ ipcMain.on('childWindowDestroyAll', (event) => {
})
preloadWindow?.destroy()
mediaWindow?.destroy()
electronDown.destroy()
event.returnValue = "ok"
})
@@ -1480,6 +1695,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 +1906,7 @@ ipcMain.on('updateQuitAndInstall', (event, args) => {
})
preloadWindow?.destroy()
mediaWindow?.destroy()
electronDown.destroy()
// 启动更新子窗口
createUpdaterWindow(args.updateTitle)
@@ -1826,7 +2055,6 @@ function exportVsdx(event, args, directFinalize) {
webSecurity: true,
nodeIntegration: true,
contextIsolation: true,
nativeWindowOpen: true
},
})
@@ -2439,7 +2667,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 +2803,7 @@ function getPluginFile(plugin) {
return null;
}
function uninstallPlugin(plugin) {
async function uninstallPlugin(plugin) {
const pluginFile = getPluginFile(plugin);
if (pluginFile != null) {
@@ -2636,7 +2864,7 @@ async function deleteFile(file) {
}
}
function windowAction(method) {
async function windowAction(method) {
let win = BrowserWindow.getFocusedWindow();
if (win) {
@@ -2656,16 +2884,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 +2903,13 @@ function watchFile(path) {
prev: prev
});
} catch (e) {
} // Ignore
// Ignore
}
});
}
}
function unwatchFile(path) {
async function unwatchFile(path) {
fs.unwatchFile(path);
}
@@ -2711,7 +2938,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 +2959,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 +2971,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 +2988,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 +3004,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

235
electron/lib/navigation.js vendored Normal file
View File

@@ -0,0 +1,235 @@
/**
* 窗口导航相关工具函数
*
* 规则:
* - 顶层页面是 localhost 时:禁用顶层 goBack/goForward
* 避免影响应用主路由历史(例如 Vue hash 路由)。
* - 若此时焦点在 iframe 内:允许 iframe 自己执行 history.back()/history.forward()(不影响顶层路由)。
*/
const utils = require('./utils')
/**
* @typedef {'back'|'forward'} NavDirection
*/
function getWebContentsUrl(webContents) {
try {
return webContents?.getURL?.() || ''
} catch (e) {
return ''
}
}
function isBackKey(input) {
return (input.alt && input.key === 'ArrowLeft') || (input.meta && input.key === '[')
}
function isForwardKey(input) {
return (input.alt && input.key === 'ArrowRight') || (input.meta && input.key === ']')
}
/**
* 尝试从 Electron 提供的 focusedFrame 获取当前聚焦 frame兼容属性/方法两种形态)
* @param webContents
* @returns {Electron.WebFrameMain|null}
*/
function getFocusedFrameDirect(webContents) {
const focused = webContents?.focusedFrame
if (!focused) {
return null
}
try {
return typeof focused === 'function' ? focused.call(webContents) : focused
} catch (e) {
return null
}
}
/**
* 获取当前聚焦的 Frame优先返回更深层的 iframe
* @param webContents
* @returns {Promise<Electron.WebFrameMain|null>}
*/
async function getFocusedFrame(webContents) {
const mainFrame = webContents?.mainFrame
if (!mainFrame) {
return null
}
const direct = getFocusedFrameDirect(webContents)
if (direct) {
return direct
}
const frames = Array.isArray(mainFrame.framesInSubtree) && mainFrame.framesInSubtree.length > 0
? mainFrame.framesInSubtree
: [mainFrame]
// document.hasFocus() 可能在主文档与子 frame 同时为 true因此取“最深”的那个
const focusedList = await Promise.all(frames.map((frame) => {
if (!frame?.executeJavaScript) {
return Promise.resolve(false)
}
return frame
.executeJavaScript('document.hasFocus && document.hasFocus()')
.then(Boolean)
.catch(() => false)
}))
for (let i = focusedList.length - 1; i >= 0; i--) {
if (focusedList[i]) {
return frames[i]
}
}
return mainFrame
}
/**
* 在“聚焦 iframe”内执行前进/后退(不影响顶层路由历史)
* @param webContents
* @param {NavDirection} direction
* @returns {Promise<boolean>}
*/
async function navigateFocusedSubframe(webContents, direction) {
const mainFrame = webContents?.mainFrame
if (!mainFrame) {
return false
}
const frame = await getFocusedFrame(webContents)
if (!frame || frame === mainFrame) {
return false
}
const js = direction === 'forward' ? 'history.forward()' : 'history.back()'
try {
await frame.executeJavaScript(js)
return true
} catch (e) {
return false
}
}
/**
* 尝试在顶层 webContents 上执行前进/后退
* @param webContents
* @param {NavDirection} direction
* @returns {boolean}
*/
function navigateTopLevel(webContents, direction) {
if (!webContents) {
return false
}
if (direction === 'back') {
if (!webContents.canGoBack()) {
return false
}
webContents.goBack()
return true
}
if (direction === 'forward') {
if (!webContents.canGoForward()) {
return false
}
webContents.goForward()
return true
}
return false
}
/**
* 统一导航入口:
* - 顶层 intranet阻止顶层导航尝试让聚焦 iframe 自己 history.back/forward
* - 外网:正常顶层导航
*
* @param event
* @param webContents
* @param {NavDirection} direction
* @returns {boolean} 是否“接管”了这次操作intranet 总是接管)
*/
function handleDirection(event, webContents, direction) {
const url = getWebContentsUrl(webContents)
const intranet = utils.isLocalHost(url)
if (intranet) {
event?.preventDefault?.()
navigateFocusedSubframe(webContents, direction)
.catch(() => {})
return true
}
const ok = navigateTopLevel(webContents, direction)
if (ok) {
event?.preventDefault?.()
}
return ok
}
function resolveDirectionFromInput(input) {
if (isBackKey(input)) return 'back'
if (isForwardKey(input)) return 'forward'
return null
}
function resolveDirectionFromAppCommand(cmd) {
if (cmd === 'browser-backward') return 'back'
if (cmd === 'browser-forward') return 'forward'
return null
}
function resolveDirectionFromSwipe(direction) {
// macOS swipe: left = back, right = forward与当前用户验证一致
if (direction === 'left') return 'back'
if (direction === 'right') return 'forward'
return null
}
function handleInput(event, input, webContents) {
const direction = resolveDirectionFromInput(input)
if (!direction) return false
return handleDirection(event, webContents, direction)
}
function setupWindowEvents(win, getWebContents) {
if (!win) return
win.on('app-command', (event, cmd) => {
const direction = resolveDirectionFromAppCommand(cmd)
if (!direction) return
const webContents = getWebContents?.()
if (!webContents) return
handleDirection(event, webContents, direction)
})
win.on('swipe', (event, direction) => {
const navDirection = resolveDirectionFromSwipe(direction)
if (!navDirection) return
const webContents = getWebContents?.()
if (!webContents) return
handleDirection(event, webContents, navDirection)
})
}
function setup(win) {
if (!win || !win.webContents) return
win.webContents.on('before-input-event', (event, input) => {
handleInput(event, input, win.webContents)
})
setupWindowEvents(win, () => win.webContents)
}
module.exports = {
isBackKey,
isForwardKey,
getWebContentsUrl,
getFocusedFrame,
navigateFocusedSubframe,
handleInput,
setupWindowEvents,
setup,
}

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>

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