Compare commits

...

449 Commits

Author SHA1 Message Date
kuaifan
92c4565590 no message 2025-04-21 01:54:50 +08:00
kuaifan
c51870ff79 build 2025-04-21 00:56:08 +08:00
kuaifan
182f061354 no message 2025-04-20 23:10:31 +08:00
kuaifan
80507cab27 no message 2025-04-20 19:37:12 +08:00
kuaifan
f801ae9b63 no message 2025-04-20 17:53:32 +08:00
kuaifan
977173d987 no message 2025-04-20 09:40:46 +08:00
kuaifan
cd0fcb903f no message 2025-04-20 09:19:46 +08:00
kuaifan
7bae1d9537 feat: 新增系统分享搜索功能 2025-04-20 00:24:33 +08:00
kuaifan
b43cbb7afe no message 2025-04-19 22:06:07 +08:00
kuaifan
72982387cc no message 2025-04-19 21:39:02 +08:00
kuaifan
ff0245840a no message 2025-04-19 21:33:10 +08:00
kuaifan
c55f64e209 no message 2025-04-19 21:21:30 +08:00
kuaifan
a4cb5d1b14 no message 2025-04-19 19:48:24 +08:00
kuaifan
13e1415355 no message 2025-04-19 19:11:05 +08:00
kuaifan
7b49d66a8e no message 2025-04-19 19:06:34 +08:00
kuaifan
63c6e12aca no message 2025-04-19 16:57:56 +08:00
kuaifan
b64d4fd96f no message 2025-04-19 07:57:43 +08:00
kuaifan
dda603c7d8 perf: 优化通用菜单 2025-04-19 01:09:29 +08:00
kuaifan
e22de5cba1 no message 2025-04-18 22:46:19 +08:00
kuaifan
bdabfdcb3d perf: 优化视频压缩 2025-04-18 22:28:24 +08:00
kuaifan
00a8514245 no message 2025-04-18 21:44:33 +08:00
kuaifan
94fd3197b3 no message 2025-04-18 20:26:35 +08:00
kuaifan
7957353c3f no message 2025-04-18 19:25:13 +08:00
kuaifan
b3b7589db3 no message 2025-04-18 14:49:05 +08:00
kuaifan
5aed9ce29e perf: 优化全文搜索 2025-04-18 13:56:11 +08:00
kuaifan
924f0a9f7c perf: 优化全文搜索 2025-04-18 12:40:32 +08:00
kuaifan
7a7cd72db9 perf: 优化全文搜索 2025-04-18 11:59:28 +08:00
kuaifan
e9e9bab479 perf: 优化全文搜索 2025-04-18 01:45:03 +08:00
kuaifan
f258dcfca2 perf: 优化全文搜索 2025-04-18 00:46:59 +08:00
kuaifan
fe84f812e7 perf: 优化全文搜索 2025-04-17 22:14:38 +08:00
kuaifan
9eba376976 perf: 优化全文搜索 2025-04-17 21:55:14 +08:00
kuaifan
462705c4ed perf: 优化全文搜索 2025-04-17 16:45:13 +08:00
kuaifan
a2533ce7f9 perf: 优化全文搜索 2025-04-17 13:04:45 +08:00
kuaifan
dbf42c51a4 perf: 优化全文搜索 2025-04-17 12:45:47 +08:00
kuaifan
f61e7caf2b perf: 优化全文搜索 2025-04-17 12:27:21 +08:00
kuaifan
679c2070c1 perf: 优化全文搜索 2025-04-17 11:14:11 +08:00
kuaifan
92d46e1da3 no message 2025-04-17 10:34:32 +08:00
kuaifan
7ab94205e4 no message 2025-04-17 10:09:28 +08:00
kuaifan
ab616c5d32 perf: 优化长按菜单 2025-04-17 09:46:57 +08:00
kuaifan
8f2f68dffc no message 2025-04-17 09:45:48 +08:00
kuaifan
18b7e17e95 no message 2025-04-16 21:34:33 +08:00
kuaifan
cca2298d3a no message 2025-04-16 19:47:39 +08:00
kuaifan
f3683bcc84 no message 2025-04-16 13:00:36 +08:00
kuaifan
fa2959515e no message 2025-04-16 08:49:33 +08:00
kuaifan
7ab5ddc408 no message 2025-04-15 00:24:54 +08:00
kuaifan
f273858248 build 2025-04-15 00:06:25 +08:00
kuaifan
ca8f7374da no message 2025-04-14 23:59:23 +08:00
kuaifan
ff1dce833a no message 2025-04-14 22:08:59 +08:00
kuaifan
d3d5a7bade no message 2025-04-14 19:48:21 +08:00
kuaifan
f5d6702472 no message 2025-04-14 18:23:04 +08:00
kuaifan
3db687ad40 no message 2025-04-14 17:30:59 +08:00
kuaifan
a5cb958398 perf: 优化移动任务 2025-04-14 15:50:20 +08:00
kuaifan
9e522091c6 no message 2025-04-14 15:29:53 +08:00
kuaifan
79f256976e no message 2025-04-14 14:24:00 +08:00
kuaifan
b560c0bafd feat: 新增任务发送功能 2025-04-14 13:40:46 +08:00
kuaifan
bd157d305e fix: 修复调整任务排序后出现空白的情况 2025-04-14 13:22:11 +08:00
kuaifan
923016197a perf: 优化自己的对话不限修改撤回时间 2025-04-14 13:18:38 +08:00
kuaifan
dcf96e2bf5 perf: 优化访问链接 2025-04-14 13:02:04 +08:00
kuaifan
d4697cb203 perf: 优化访问链接 2025-04-14 12:00:54 +08:00
kuaifan
6e6a50b46e no message 2025-04-14 09:39:47 +08:00
kuaifan
b9830bc64a no message 2025-04-14 08:00:02 +08:00
kuaifan
7c501cec45 no message 2025-04-13 14:59:37 +08:00
kuaifan
add23934ca no message 2025-04-13 13:04:03 +08:00
kuaifan
a8b798b00c no message 2025-04-13 11:33:43 +08:00
kuaifan
b522b1de05 no message 2025-04-13 11:19:37 +08:00
kuaifan
3660cbd450 no message 2025-04-13 10:50:47 +08:00
kuaifan
50f8bb8721 feat: 新增会员详情窗口 2025-04-13 10:50:40 +08:00
kuaifan
e1a2d90382 no message 2025-04-13 09:16:19 +08:00
kuaifan
d8872f215b no message 2025-04-13 00:18:39 +08:00
kuaifan
484bc6ea39 no message 2025-04-12 19:45:14 +08:00
kuaifan
7d1979f067 perf: 优化日历 2025-04-12 19:45:06 +08:00
kuaifan
6927c0b30b no message 2025-04-12 18:54:39 +08:00
kuaifan
aa74c5ccaf perf: 优化长按事件 2025-04-12 18:42:27 +08:00
kuaifan
e3d0f571d2 no message 2025-04-12 17:47:43 +08:00
kuaifan
d03dabdfdf perf: 优化日历 2025-04-12 17:40:05 +08:00
kuaifan
fc339ae55f no message 2025-04-12 17:39:23 +08:00
kuaifan
a0aa04fd8c no message 2025-04-12 15:08:33 +08:00
kuaifan
6dc5ae1ae4 perf: 优化移动端任务窗口布局 2025-04-12 13:20:17 +08:00
kuaifan
df02a6b50f no message 2025-04-12 11:55:43 +08:00
kuaifan
9e4f733c28 no message 2025-04-12 09:11:58 +08:00
kuaifan
1175b330f5 no message 2025-04-11 19:17:26 +08:00
kuaifan
3cb9fff07f perf: 优化长按操作 2025-04-11 13:47:53 +08:00
kuaifan
bfdb72dd0a no message 2025-04-11 09:55:18 +08:00
kuaifan
5489462f90 no message 2025-04-11 09:02:16 +08:00
kuaifan
94ac3c3922 no message 2025-04-10 17:24:51 +08:00
kuaifan
bf75946e14 no message 2025-04-10 17:06:12 +08:00
kuaifan
b2a70e0cce perf: 优化转发确认选项保持上一次选择 2025-04-10 17:05:42 +08:00
kuaifan
83780f9bcd feat: 添加从团队管理打开会话窗口 2025-04-10 17:05:03 +08:00
kuaifan
bfb9795913 no message 2025-04-10 16:50:11 +08:00
kuaifan
208598a6df no message 2025-04-10 16:02:01 +08:00
kuaifan
6c79753051 no message 2025-04-10 11:57:56 +08:00
kuaifan
095a238fff perf: 优化移动端布局 2025-04-10 11:29:31 +08:00
kuaifan
ebbde8afd3 perf: 优化移动端布局 2025-04-10 11:13:19 +08:00
kuaifan
bba5bb7411 fix: 修复移动任务时负责人和协助人可以同时选择的情况 2025-04-10 11:06:29 +08:00
kuaifan
9c155c6cf5 perf: 优化禁止选择会员效果 2025-04-10 11:05:50 +08:00
kuaifan
19da7a74df perf: 优化长按菜单位置 2025-04-10 10:47:12 +08:00
kuaifan
f5d76fd5ff perf: 优化移动端打开会话等待效果 2025-04-10 10:40:40 +08:00
kuaifan
77940c9430 perf: 优化长按菜单位置 2025-04-10 07:46:32 +08:00
kuaifan
54a42a14b6 perf: 优化会议弹窗 2025-04-10 07:45:51 +08:00
kuaifan
52faf7884b perf: 任务详情点任务聊天时不要发送消息 2025-04-10 07:27:10 +08:00
kuaifan
841ed4e682 perf: 优化移动端布局 2025-04-09 23:25:48 +08:00
kuaifan
bc417b9eea perf: 优化移动端布局 2025-04-09 19:12:12 +08:00
kuaifan
da7dc477c8 no message 2025-04-09 13:47:26 +08:00
kuaifan
6c519ebd61 no message 2025-04-08 21:43:41 +08:00
kuaifan
88e859817b no message 2025-04-08 15:34:05 +08:00
kuaifan
f5dd36260f perf: 优化国际化 2025-04-08 15:33:08 +08:00
kuaifan
a5325b84ae no message 2025-04-08 15:04:00 +08:00
kuaifan
7095c9e71e no message 2025-04-08 14:43:56 +08:00
kuaifan
fb4373c83a fix: 修复无法从任务页面打开聊天的情况 2025-04-08 14:31:01 +08:00
kuaifan
dd59a1aebb no message 2025-04-08 12:38:30 +08:00
kuaifan
6f7edd0b40 fix: 修复移动端焦点抖动的问题 2025-04-08 08:49:30 +08:00
kuaifan
397421010e build 2025-04-07 23:11:32 +08:00
kuaifan
8872b0519d no message 2025-04-07 23:09:24 +08:00
kuaifan
83d3b3ffbf fix: 修复部分页面出现空白的情况 2025-04-07 20:09:45 +08:00
kuaifan
d1702bd62c no message 2025-04-07 18:26:02 +08:00
kuaifan
928235eac8 fix: 修复输入框无法点击添加链接的情况 2025-04-07 16:22:36 +08:00
kuaifan
3bdfaab158 perf: 优化数据结构 2025-04-07 14:32:24 +08:00
kuaifan
d7902b4d08 perf: 优化数据结构 2025-04-07 14:06:27 +08:00
kuaifan
3334abfb8f no message 2025-04-07 12:16:33 +08:00
kuaifan
3e44e584c0 perf: 优化数据结构 2025-04-07 11:41:01 +08:00
kuaifan
195a305fc3 perf: 优化数据结构 2025-04-07 11:35:19 +08:00
kuaifan
cedffd17b3 perf: 优化图片存储名 2025-04-07 11:10:17 +08:00
kuaifan
59b29014d9 fix: 修复AI机器人不存在的情况 2025-04-07 09:05:17 +08:00
kuaifan
06db036e4a build 2025-04-07 08:49:39 +08:00
kuaifan
617e0837c9 no message 2025-04-07 08:46:09 +08:00
kuaifan
7a275bd802 perf: 优化数据结构 2025-04-07 08:30:06 +08:00
kuaifan
83f58eae68 perf: 优化数据结构 2025-04-07 06:55:35 +08:00
kuaifan
19815fe27d no message 2025-04-07 06:47:59 +08:00
kuaifan
0f75556bed perf: 优化数据结构 2025-04-07 06:15:17 +08:00
kuaifan
dc0f925d24 no message 2025-04-07 01:21:50 +08:00
kuaifan
c5948c4171 feat: 新增转发至AI开启新会话 2025-04-07 01:20:58 +08:00
kuaifan
d144b06c1f perf: 优化数据结构 2025-04-06 23:43:18 +08:00
kuaifan
92dfea677b perf: 优化数据结构 2025-04-06 23:14:20 +08:00
kuaifan
7b5867e2c0 perf: 优化数据结构 2025-04-04 09:18:51 +08:00
kuaifan
b643fe56d5 perf: 优化数据结构 2025-04-04 08:22:18 +08:00
kuaifan
38fa72e9da perf: 优化数据结构 2025-04-03 22:35:46 +08:00
kuaifan
82fddefc94 perf: 优化消息窗口显示 2025-04-03 22:12:29 +08:00
kuaifan
0c9c9cb90a perf: 优化消息窗口显示 2025-04-03 10:01:02 +08:00
kuaifan
38b50a8a84 perf: 优化消息窗口显示 2025-04-02 20:35:03 +08:00
kuaifan
0f250dbafd perf: 优化目录结构 2025-04-02 19:17:03 +08:00
kuaifan
168650649f perf: 优化日历 2025-04-02 18:48:25 +08:00
kuaifan
52babf82ae perf: 优化任务时间范围选择 2025-03-31 23:49:11 +08:00
kuaifan
0c8517667f build 2025-03-30 11:13:39 +08:00
kuaifan
77d105cb9f no message 2025-03-30 10:45:24 +08:00
kuaifan
8af33ea66a perf: 优化消息窗口 2025-03-30 10:13:43 +08:00
kuaifan
a57740e14e perf: 优化消息窗口 2025-03-30 08:56:22 +08:00
kuaifan
82230d70a5 perf: 优化消息窗口 2025-03-30 08:50:50 +08:00
kuaifan
15f3f9c0e5 perf: 优化消息窗口 2025-03-30 07:51:13 +08:00
kuaifan
7fc328492b no message 2025-03-29 22:09:07 +08:00
kuaifan
81cedca590 no message 2025-03-29 14:11:54 +08:00
kuaifan
df4e00e23f perf: 优化消息长按菜单 2025-03-29 14:11:47 +08:00
kuaifan
ad70f23a05 perf: 优化内置浏览器 2025-03-29 13:55:16 +08:00
kuaifan
c93beb27fd no message 2025-03-29 12:44:20 +08:00
kuaifan
41da2231ed no message 2025-03-29 12:33:12 +08:00
kuaifan
9d9213fbdb feat: 添加移动端提示可能要发送的图片 2025-03-29 00:23:49 +08:00
kuaifan
50106d19e8 fix: 修复未读数错误暴增的情况 2025-03-28 19:44:43 +08:00
kuaifan
62d1e676bd perf: 优化App隐私政策提示 2025-03-28 19:35:12 +08:00
kuaifan
0b7d49785c no message 2025-03-28 18:45:09 +08:00
kuaifan
40736c4a05 no message 2025-03-28 18:30:53 +08:00
kuaifan
1f0ab02702 feat: 添加移动端提示可能要发送的图片 2025-03-28 17:01:07 +08:00
kuaifan
21aa4f7b2b fix: 修复地址可能存在localhost的情况 2025-03-28 15:45:13 +08:00
kuaifan
43d0a85061 feat: 添加移动端提示可能要发送的图片 2025-03-28 14:07:33 +08:00
kuaifan
8bdd31ae67 no message 2025-03-28 14:07:26 +08:00
kuaifan
b78f93979d no message 2025-03-27 20:46:12 +08:00
kuaifan
8d6b4a1d2e feat: 添加移动端提示可能要发送的图片 2025-03-27 20:46:03 +08:00
kuaifan
7630c83ae0 perf: 优化对话独立窗口显示 2025-03-27 20:25:51 +08:00
kuaifan
c7c47aff5a fix: 修复消息编辑和发布时序号对不上 2025-03-27 18:09:07 +08:00
kuaifan
72e475cb84 fix: 修复草稿出现上一次内容的情况 2025-03-27 17:03:26 +08:00
kuaifan
f750a6aec2 no message 2025-03-26 23:50:41 +08:00
kuaifan
0dde37e1f1 no message 2025-03-26 18:57:19 +08:00
kuaifan
a2066fc137 no message 2025-03-26 15:26:07 +08:00
kuaifan
f652f35c3a perf: 优化移动端选择交互 2025-03-26 14:17:15 +08:00
kuaifan
562697da27 fix: 修复本地群消息通知没有会员昵称的问题 2025-03-25 18:31:44 +08:00
kuaifan
d23d77ff90 perf: 优化移动端选中消息文本 2025-03-25 18:14:40 +08:00
kuaifan
119443cc88 no message 2025-03-25 17:36:21 +08:00
kuaifan
27ae831799 perf: 优化移动端选中消息文本 2025-03-25 17:35:47 +08:00
kuaifan
b482947207 perf: 优化撤回消息逻辑 2025-03-25 16:39:49 +08:00
kuaifan
6ebc89695a perf: 优化移动端选中消息文本 2025-03-25 16:38:19 +08:00
kuaifan
a65dfec7a8 perf: 优化提及搜索 2025-03-24 21:53:04 +08:00
kuaifan
a0cd79e587 fix: 修复了拉人进群无法踢出去的问题 2025-03-24 21:33:04 +08:00
kuaifan
8fe1e2fee4 no message 2025-03-24 21:13:17 +08:00
kuaifan
3cc9f7bc40 fix: 提及出现白色字的情况 2025-03-24 21:05:25 +08:00
kuaifan
8d3d5025ed no message 2025-03-24 20:39:53 +08:00
kuaifan
a49c0aea47 perf: 优化机器人Webhook消息 2025-03-24 20:34:28 +08:00
kuaifan
d366cf9885 build 2025-03-23 23:24:48 +08:00
kuaifan
be53afe6b4 no message 2025-03-22 19:24:28 +08:00
kuaifan
cdd980112d no message 2025-03-22 19:02:00 +08:00
kuaifan
bca284969d no message 2025-03-22 18:44:09 +08:00
kuaifan
dd899a3e13 feat: 添加我的机器人管理 2025-03-22 18:19:39 +08:00
kuaifan
d6ca66aa2f no message 2025-03-21 22:16:47 +08:00
kuaifan
20ba671cd3 no message 2025-03-21 13:37:27 +08:00
kuaifan
672795ac49 perf: 优化初始化逻辑 2025-03-21 13:37:21 +08:00
kuaifan
9716d7fe43 perf: 优化docker配置 2025-03-21 11:34:09 +08:00
kuaifan
193ad8d902 build 2025-03-21 09:06:41 +08:00
kuaifan
a87f903c50 no message 2025-03-21 09:04:58 +08:00
kuaifan
82f154a229 no message 2025-03-21 00:18:09 +08:00
kuaifan
bee36801ab feat: 新增独立窗口打开会话 2025-03-21 00:09:16 +08:00
kuaifan
37f379c890 no message 2025-03-20 23:18:04 +08:00
kuaifan
88b995ca9c perf: 优化AI支持文件类型 2025-03-20 15:34:31 +08:00
kuaifan
919289c5ca fix: 修复搜索结果显示即将到期 2025-03-20 14:51:13 +08:00
kuaifan
0535b56766 build 2025-03-19 23:37:39 +08:00
kuaifan
6afd413b87 no message 2025-03-19 23:35:02 +08:00
kuaifan
4818409329 perf: 优化AI解析文件 2025-03-19 23:33:17 +08:00
kuaifan
919b652a06 perf: 优化AI解析文件 2025-03-19 22:49:03 +08:00
kuaifan
15d3ec9d81 perf: 优化 WebSocket 消息 2025-03-19 22:01:07 +08:00
kuaifan
e0be6e429e no message 2025-03-19 21:04:05 +08:00
kuaifan
8d24be914d perf: 优化数据 2025-03-19 15:14:23 +08:00
kuaifan
8bbe9c97e9 perf: 优化数据 2025-03-19 12:53:44 +08:00
kuaifan
ccbd904a3f build 2025-03-19 08:51:43 +08:00
kuaifan
4ed3db7e41 fix: 修复查看待办图片不符的情况 2025-03-19 08:48:51 +08:00
kuaifan
65ced28004 perf: 优化数据 2025-03-18 23:43:36 +08:00
kuaifan
4c282962b3 perf: 优化未读消息数 2025-03-18 18:48:39 +08:00
kuaifan
c64c436b9f perf: 优化搜索组件 2025-03-18 18:36:20 +08:00
kuaifan
378e270f41 no message 2025-03-18 17:45:53 +08:00
kuaifan
7217bd7d1a perf: 已归档/已删除任务列表支持按状态检索 2025-03-18 17:43:21 +08:00
kuaifan
ff2461d89d no message 2025-03-18 12:01:46 +08:00
kuaifan
0eb3430c14 perf: 优化消息流效果 2025-03-18 11:38:58 +08:00
kuaifan
da7c1e40e3 no message 2025-03-18 10:29:05 +08:00
kuaifan
8c9e928ddc no message 2025-03-18 01:52:18 +08:00
kuaifan
477aef7db6 perf: 优化AI上下文 2025-03-18 01:49:24 +08:00
kuaifan
cc97d9f1ea perf: 优化工作流获取 2025-03-17 20:31:30 +08:00
kuaifan
ee6cf05a92 perf: 优化转发功能 2025-03-17 10:15:12 +08:00
kuaifan
575db58476 build 2025-03-16 23:43:51 +08:00
kuaifan
986a2f8cbb no message 2025-03-16 23:28:12 +08:00
kuaifan
0b1da914cd no message 2025-03-16 23:06:45 +08:00
kuaifan
04acd7c56d feat: 可点击标注图标查看标注人员 2025-03-16 22:20:39 +08:00
kuaifan
4430d85242 perf: 优化转发消息 2025-03-16 21:51:19 +08:00
kuaifan
55ade32589 feat: 支持分享工作报告到消息 2025-03-16 21:39:12 +08:00
kuaifan
0ffbaaaeaa perf: 优化转发消息 2025-03-16 21:08:04 +08:00
kuaifan
62b40ddb84 perf: 优化转发消息 2025-03-16 20:43:09 +08:00
kuaifan
2d5ce87605 feat: 支持AI分析工作报告 2025-03-16 00:55:45 +08:00
kuaifan
7ca0bc5960 feat: 支持使用%发送工作报告 2025-03-15 17:53:21 +08:00
kuaifan
021c09e426 feat: 支持使用%发送工作报告 2025-03-15 17:06:47 +08:00
kuaifan
75db81f2f9 feat: 支持使用%发送工作报告 2025-03-15 15:59:06 +08:00
kuaifan
f162617765 no message 2025-03-14 23:14:44 +08:00
kuaifan
b7d10a4c58 perf: 优化工作报告列表 2025-03-14 23:12:40 +08:00
kuaifan
79ca1aea02 no message 2025-03-14 22:41:26 +08:00
kuaifan
957201804c feat: 新增自定义撤回及修改消息时限 2025-03-14 21:07:44 +08:00
kuaifan
cf5e126eaa perf: 优化引用消息 2025-03-14 19:53:00 +08:00
kuaifan
69fc0a118b no message 2025-03-14 15:01:35 +08:00
kuaifan
4dacc26567 perf: 优化全局提示 2025-03-14 14:50:32 +08:00
kuaifan
7de1ed7d45 no message 2025-03-14 12:11:08 +08:00
kuaifan
ab47f01625 perf: 优化全局提示 2025-03-14 12:07:49 +08:00
kuaifan
13c4fa4f1f no message 2025-03-14 09:25:33 +08:00
kuaifan
173631f115 no message 2025-03-14 09:11:04 +08:00
kuaifan
8462e9c097 no message 2025-03-14 07:28:29 +08:00
kuaifan
3c9447e1b6 no message 2025-03-13 22:01:46 +08:00
kuaifan
38eaf2eb02 perf: 优化草稿消息 2025-03-13 21:35:05 +08:00
kuaifan
c8364ed17b perf: 优化草稿消息 2025-03-13 20:54:35 +08:00
kuaifan
ba52738904 perf: 优化草稿消息 2025-03-13 20:37:41 +08:00
kuaifan
4061ae4275 perf: 优化草稿消息 2025-03-13 20:18:34 +08:00
kuaifan
82afb5b150 no message 2025-03-13 17:27:13 +08:00
kuaifan
e1203f0c8d no message 2025-03-13 17:12:11 +08:00
kuaifan
e6f6b3fee2 fix: 工作流存在已离职人员 2025-03-13 15:02:53 +08:00
kuaifan
e5efcd3d26 no message 2025-03-13 14:36:00 +08:00
kuaifan
bf45587c80 no message 2025-03-13 13:25:16 +08:00
kuaifan
29a0d22938 build 2025-03-13 01:40:11 +08:00
kuaifan
635cc04c50 perf: 优化消息定位 2025-03-13 01:38:35 +08:00
kuaifan
bc5343652b perf: 优化消息性能 2025-03-12 22:41:41 +08:00
kuaifan
03f140fe3b fix: 看不到未读消息定位提醒 2025-03-12 14:19:07 +08:00
kuaifan
3e4a119f61 build 2025-03-12 00:18:12 +08:00
kuaifan
3b7bcbc14a no message 2025-03-11 17:41:12 +08:00
kuaifan
3c49e96e02 no message 2025-03-11 16:24:31 +08:00
kuaifan
5be209ab59 no message 2025-03-11 15:53:53 +08:00
kuaifan
56ea048ab3 no message 2025-03-11 10:21:44 +08:00
kuaifan
9fc0bd0439 no message 2025-03-10 23:12:28 +08:00
kuaifan
1c2798cbf4 perf: 优化消息定位 2025-03-10 23:06:30 +08:00
kuaifan
9d8af2eaab perf: 优化MD消息 2025-03-10 22:38:42 +08:00
kuaifan
bba1e0d12f fix: 会话内消息搜索布局错位 2025-03-10 20:52:52 +08:00
kuaifan
c060e60e4a fix: 流程设置翻译不统一 2025-03-10 20:51:58 +08:00
kuaifan
1c504bd899 no message 2025-03-10 00:12:05 +08:00
kuaifan
b617648bd8 no message 2025-03-09 23:46:01 +08:00
kuaifan
e849c7a34f build 2025-03-09 23:11:14 +08:00
kuaifan
f6dd1ce98e no message 2025-03-09 23:02:35 +08:00
kuaifan
9c78db8d45 no message 2025-03-09 22:52:38 +08:00
kuaifan
5154348cf9 perf: 优化发送语音效果 2025-03-09 22:41:15 +08:00
kuaifan
4521cea3b4 perf: 优化发送语音效果 2025-03-09 19:54:09 +08:00
kuaifan
0ff1ac7743 no message 2025-03-09 18:54:33 +08:00
kuaifan
277a751ed4 no message 2025-03-09 18:40:03 +08:00
kuaifan
96be2a86ca no message 2025-03-09 18:17:18 +08:00
kuaifan
f28bff569a no message 2025-03-09 15:49:56 +08:00
kuaifan
e34aa77a54 perf: 录音转文字支持自定义语言 2025-03-09 15:32:38 +08:00
kuaifan
e53b65496f perf: 录音转文字支持自定义语言 2025-03-09 11:33:37 +08:00
kuaifan
f6ee630615 no message 2025-03-08 19:11:17 +08:00
kuaifan
ec2e1e3152 no message 2025-03-08 17:56:09 +08:00
kuaifan
6cffe9baed perf: 优化ES模块 2025-03-08 16:39:35 +08:00
kuaifan
b63df27409 perf: 优化emoji表情 2025-03-08 15:50:35 +08:00
kuaifan
617c466ac0 perf: 按住Ctrl/Command键可连续选择表情 2025-03-08 12:13:25 +08:00
kuaifan
ed8e443f3a perf: 优化ES模块 2025-03-08 12:12:48 +08:00
kuaifan
58cb49b125 perf: 优化ES模块 2025-03-08 10:15:58 +08:00
kuaifan
7dd5baa9ec fix: 定位签到失败的问题 2025-03-07 23:43:39 +08:00
kuaifan
bbf9107560 perf: md消息支持html代码 2025-03-07 23:23:51 +08:00
kuaifan
be527355ee no message 2025-03-07 22:15:27 +08:00
kuaifan
c866500120 perf: 优化脚本 2025-03-07 16:18:06 +08:00
kuaifan
3e2a40aaa0 perf: 优化安装命令 2025-03-07 15:13:19 +08:00
kuaifan
eef9fa56c6 perf: 优化ES索引名称 2025-03-07 12:57:37 +08:00
zzzzzhy
945d84dbc4 添加es证书配置 2025-03-07 12:57:37 +08:00
kuaifan
d353d33107 no message 2025-03-07 12:57:37 +08:00
kuaifan
f54bad5d79 no message 2025-03-06 16:16:43 +08:00
kuaifan
b605c70e91 no message 2025-03-06 14:52:40 +08:00
kuaifan
1752e88c42 no message 2025-03-05 15:19:38 +08:00
kuaifan
e2718a39a0 no message 2025-03-05 10:19:28 +08:00
kuaifan
25298ac69e build 2025-03-05 08:33:52 +08:00
kuaifan
cf9f389f75 no message 2025-03-05 08:23:08 +08:00
kuaifan
567c75830a perf: 新增录音转文字 2025-03-05 01:52:37 +08:00
kuaifan
7b1d352c95 perf: 新增录音转文字 2025-03-05 01:22:12 +08:00
kuaifan
4fa54381a6 no message 2025-03-04 20:12:32 +08:00
kuaifan
9c91f7cf83 no message 2025-03-04 19:56:13 +08:00
kuaifan
edd5cd1ca1 perf: 优化数据排序 2025-03-04 19:24:13 +08:00
kuaifan
f2ec6ad05e no message 2025-03-04 18:30:23 +08:00
kuaifan
a04ef4ac38 no message 2025-03-04 16:58:17 +08:00
kuaifan
43b3d1d379 no message 2025-03-04 16:46:45 +08:00
kuaifan
b65fdeacc2 no message 2025-03-04 09:33:26 +08:00
kuaifan
622fe1e5d9 no message 2025-03-04 08:44:11 +08:00
kuaifan
a6c7c0c7ad fix: 全屏预览图片关闭窗口 2025-03-04 07:38:06 +08:00
kuaifan
e5c8748b75 no message 2025-03-04 06:35:32 +08:00
kuaifan
f096d71cc1 no message 2025-03-03 23:45:42 +08:00
kuaifan
d73a152a36 no message 2025-03-03 22:48:09 +08:00
kuaifan
f4e6fd060e no message 2025-03-03 21:00:04 +08:00
kuaifan
c78ca1de5d no message 2025-03-03 18:11:32 +08:00
kuaifan
2b219c7256 no message 2025-03-03 14:53:45 +08:00
kuaifan
6ffa651742 no message 2025-03-03 13:16:03 +08:00
kuaifan
cb3b22a4bf fix: 点击排序导致任务不显示的情况 2025-03-03 12:51:33 +08:00
kuaifan
145bfdb0e9 no message 2025-03-03 12:49:04 +08:00
kuaifan
8c7b0c502d no message 2025-03-03 11:59:02 +08:00
kuaifan
684bf12a5c no message 2025-03-03 10:27:56 +08:00
kuaifan
aaa75aff14 build 2025-03-03 08:32:50 +08:00
kuaifan
f03600bd65 perf: 添加全局搜索功能 2025-03-03 08:20:17 +08:00
kuaifan
1c4c4fe3fb perf: 添加全局搜索功能 2025-03-03 07:01:49 +08:00
kuaifan
5e46b2cd1a perf: 添加全局搜索功能 2025-03-02 23:43:18 +08:00
kuaifan
027db7c0ec perf: 添加全局搜索功能 2025-03-02 19:00:09 +08:00
kuaifan
5bb17ddc6b no message 2025-03-02 18:59:54 +08:00
kuaifan
e8edd74bc3 no message 2025-03-01 23:59:51 +08:00
kuaifan
ed064a825a perf: 优化消息搜索 2025-03-01 23:59:14 +08:00
kuaifan
32c232a0b5 perf: 优化消息搜索 2025-03-01 20:54:02 +08:00
kuaifan
c2fd747c45 no message 2025-03-01 20:39:13 +08:00
kuaifan
9148853f2c perf: 团队管理支持调整部门区域尺寸 2025-03-01 14:51:07 +08:00
kuaifan
23d0f50a3d perf: 任务详情支持调整聊天区域尺寸 2025-03-01 14:50:44 +08:00
kuaifan
36cdf87bfe perf: 优化团队部门支持3级部门 2025-03-01 13:41:10 +08:00
kuaifan
cfd2e1fd7b perf: 可见群组ID 2025-03-01 12:06:03 +08:00
kuaifan
3cafac99ff perf: 支持在团队管理打开群聊 2025-03-01 11:54:20 +08:00
kuaifan
1dd4e8da71 perf: 优化回复消息自动@逻辑 2025-03-01 11:19:50 +08:00
kuaifan
543015a36e perf: 转发预览隐藏表情回应部分 2025-03-01 10:14:51 +08:00
kuaifan
2efdfc4b1f no message 2025-03-01 00:34:43 +08:00
kuaifan
7234d9307e no message 2025-03-01 00:21:21 +08:00
kuaifan
769ce1ce7c no message 2025-02-28 22:49:45 +08:00
kuaifan
62c1d5783e perf: 优化任务日志 2025-02-28 22:38:11 +08:00
kuaifan
a6bd4a2ffe no message 2025-02-28 21:24:09 +08:00
kuaifan
f1a9077b7e no message 2025-02-28 21:20:36 +08:00
kuaifan
2c3e80bd8f perf: 已删除任务支持按标签搜索 2025-02-28 21:07:54 +08:00
kuaifan
e52d066fb0 perf: 归档任务支持按标签搜索 2025-02-28 21:07:32 +08:00
kuaifan
5279d57018 perf: 项目面板添加按标签筛选 2025-02-28 20:54:25 +08:00
kuaifan
25e5eb4427 perf: 优化 AI 提示词 2025-02-26 20:50:17 +08:00
kuaifan
b01d5ce8c4 perf: 优化 AI 设置 2025-02-26 20:12:23 +08:00
kuaifan
ff41f5c041 no message 2025-02-26 11:58:53 +08:00
kuaifan
dd0770a93f no message 2025-02-25 21:36:55 +08:00
kuaifan
9a3e76fff3 no message 2025-02-25 21:08:54 +08:00
kuaifan
7c867578ee build 2025-02-25 21:08:40 +08:00
kuaifan
d543c27000 perf: 工作报告支持查看仅未读 2025-02-25 20:39:22 +08:00
kuaifan
a8be330baa perf: AI 支持引用文件 2025-02-25 20:31:13 +08:00
kuaifan
c128c58110 perf: 优化图文消息 2025-02-25 19:51:07 +08:00
kuaifan
e32a3887cd perf: 优化文本信息复制 2025-02-25 17:47:46 +08:00
kuaifan
94932c7486 perf: 优化图文消息 2025-02-25 17:38:12 +08:00
kuaifan
a1920745fb perf: 优化样式 2025-02-25 11:43:48 +08:00
kuaifan
51e8f9555e perf: 无法再AI机器人页面看到模型的问题 2025-02-25 11:37:51 +08:00
kuaifan
213ab8418b fix: 首次跟ai聊天没有记录的问题 2025-02-25 11:14:41 +08:00
kuaifan
707f1dd6cb no message 2025-02-24 10:41:35 +08:00
kuaifan
125ce036cd perf: 优化MD消息过长处理 2025-02-24 09:12:12 +08:00
kuaifan
172c562a71 build 2025-02-24 00:08:50 +08:00
kuaifan
80bbe6711c no message 2025-02-24 00:07:31 +08:00
kuaifan
3f56c64086 perf: 优化AI支持分析指定文件 2025-02-23 23:55:02 +08:00
kuaifan
e6167119e0 no message 2025-02-22 20:27:27 +08:00
kuaifan
368fae5f32 perf: 支持在AI对话中直接引用任务提问 2025-02-22 20:27:10 +08:00
kuaifan
6ae46cf7bb perf: 支持在AI对话中直接引用任务提问 2025-02-22 17:49:11 +08:00
kuaifan
e97806c85b no message 2025-02-22 17:49:04 +08:00
kuaifan
f31e88bed1 no message 2025-02-22 12:14:03 +08:00
kuaifan
6bd20038f9 no message 2025-02-22 11:29:46 +08:00
kuaifan
30cfb1200d no message 2025-02-22 11:26:16 +08:00
kuaifan
154e0039d1 perf: 优化 AI 参数 2025-02-22 11:13:16 +08:00
kuaifan
a8f3b02ee7 perf: 优化 Ollama AI 2025-02-22 01:29:54 +08:00
kuaifan
b3e83e13bc perf: 优化设置 2025-02-22 00:59:52 +08:00
kuaifan
d0a0e77c44 perf: 优化设置 2025-02-22 00:45:20 +08:00
kuaifan
a14896307f no message 2025-02-21 23:26:54 +08:00
kuaifan
976b300277 perf: 优化AI设置 2025-02-21 23:26:14 +08:00
kuaifan
ccbd873204 perf: 优化AI设置 2025-02-21 22:15:59 +08:00
kuaifan
9c1482f9e9 feat: 添加 Grok AI、Ollama AI 2025-02-21 17:04:59 +08:00
kuaifan
5a7f4efa91 feat: 添加 Grok AI、Ollama AI 2025-02-21 12:08:54 +08:00
kuaifan
f78c4a1fb0 perf: 优化AI设置 2025-02-21 11:37:39 +08:00
kuaifan
db6500369f perf: 优化AI消息 2025-02-20 01:12:48 +08:00
kuaifan
9e4beaa317 perf: 表情回复时更新对话列表 2025-02-14 21:14:15 +08:00
kuaifan
afd021737a build 2025-02-14 20:41:04 +08:00
kuaifan
3982ed56f7 no message 2025-02-14 15:15:36 +08:00
kuaifan
df4a01a7f9 perf: onlyoffice 支持打开超过100m的文件 2025-02-14 15:07:55 +08:00
kuaifan
a6fac96ec1 perf: 优化点击上传列表效果 2025-02-14 15:06:17 +08:00
kuaifan
8ed9186ff4 perf: AI支持自定义模型列表 2025-02-14 01:07:32 +08:00
kuaifan
821df75d4b fix: 撤回消息是消息列表不更新的情况 2025-02-13 21:21:32 +08:00
kuaifan
0c09a2445c build 2025-02-12 21:40:21 +08:00
kuaifan
e6983e858d no message 2025-02-12 21:33:10 +08:00
kuaifan
f8b69df955 fix: 修复偶现的是子窗口出现身份丢失的情况 2025-02-12 21:09:54 +08:00
kuaifan
15370a93c7 perf: 优化查看长消息内容 2025-02-12 20:46:18 +08:00
kuaifan
bc18aeeadc no message 2025-02-11 15:11:23 +08:00
kuaifan
a1f143b0aa build 2025-02-11 07:53:53 +09:00
kuaifan
c13fe9d590 perf: 优化审批功能 2025-02-11 03:33:58 +09:00
kuaifan
50203fbcb3 perf: AI机器人支持多会话 2025-02-11 03:12:25 +09:00
kuaifan
ffe7ebf711 perf: AI机器人支持多会话 2025-02-11 02:41:01 +09:00
kuaifan
f0b5e0c3b9 perf: AI机器人支持多会话 2025-02-10 18:45:35 +09:00
kuaifan
501235ef12 perf: AI机器人支持多会话 2025-02-10 16:48:08 +09:00
kuaifan
da0fa31181 perf: AI机器人支持多会话 2025-02-10 15:53:14 +09:00
kuaifan
0272933f70 perf: AI机器人支持多会话 2025-02-10 15:43:02 +09:00
kuaifan
30d88761b4 perf: AI机器人支持多会话 2025-02-10 12:39:36 +09:00
kuaifan
fb286cea3c perf: AI机器人支持自定义模型 2025-02-08 12:55:11 +09:00
kuaifan
6bcc7b6c49 perf: AI机器人支持多会话 2025-02-07 16:44:02 +09:00
kuaifan
6338a44cc1 no message 2025-02-07 16:41:52 +09:00
kuaifan
ae4680f20c build 2025-02-07 05:07:20 +09:00
kuaifan
2841874417 no message 2025-02-07 05:07:10 +09:00
kuaifan
b6a4e6b4de perf: 支持下载聊天引用的文件 2025-02-07 04:55:58 +09:00
kuaifan
34cfd1e344 perf: 优化翻译消息 2025-02-07 04:38:23 +09:00
kuaifan
b467dc55e5 perf: 支持显示思考过程 2025-02-05 15:38:07 +09:00
kuaifan
9fd8d44a6e build 2025-02-05 01:37:41 +09:00
kuaifan
64262134c4 perf: 支持自定义仪表盘欢迎词 2025-02-05 01:33:32 +09:00
kuaifan
0019c9ef41 build 2025-02-04 13:14:43 +09:00
kuaifan
2676ebd047 no message 2025-02-04 13:10:46 +09:00
kuaifan
97cdd56110 fix: 跨地区发消息出现消息过期的情况 2025-02-04 13:10:46 +09:00
kuaifan
d973451bdc feat: 添加 DeepSeek AI 2025-02-04 13:10:46 +09:00
kuaifan
80313f613e perf: ChatGPT 支持自定义 Base URL 2025-02-04 13:10:46 +09:00
kuaifan
5c564524a3 Merge pull request #247 from zzzzzhy/patch-2
定时任务判断fix
2025-01-17 20:24:22 +08:00
zzzzzhy
e081fbd92b 定时任务判断fix
修复定时更新https证书任务判断逻辑
2025-01-17 14:15:24 +08:00
kuaifan
0ecc20472a Merge pull request #246 from zzzzzhy/patch-1
安装软件依赖
2025-01-16 22:35:38 +08:00
zzzzzhy
b51052f0c6 安装软件依赖 2025-01-16 20:14:08 +08:00
kuaifan
cb106e42ee Merge pull request #245 from zzzzzhy/renew_cert
feat:添加https证书自动更新
2025-01-16 19:54:17 +08:00
zzzzzhy
52f9495ff8 feat:添加https证书自动更新 2025-01-16 06:44:26 +00:00
kuaifan
440b633bad perf: 优化仪表盘任务更新规则 2025-01-15 15:41:38 +08:00
kuaifan
a07913181a fix: 多线程下载文件损坏的问题 2025-01-15 15:27:39 +08:00
kuaifan
34ffd96c86 no message 2025-01-13 10:56:30 +08:00
kuaifan
46a623b430 perf: 优化仪表盘任务更新规则 2025-01-13 10:56:29 +08:00
kuaifan
c16e37023c fix: 多线程下载文件损坏的问题 2025-01-13 10:56:29 +08:00
kuaifan
1cb0cdf540 Merge pull request #242 from nightcp/fix-report-sign-repeat 2025-01-04 20:48:09 +08:00
nightcp
073d03a882 fix: 修复新建周报或日报唯一标识重复 2025-01-04 19:58:37 +08:00
kuaifan
30b9276ab4 no message 2025-01-03 08:19:55 +08:00
kuaifan
76c8b4a4c6 no message 2025-01-03 04:48:38 +08:00
kuaifan
9ea4781d93 perf: 更新小海豚表情包 2025-01-03 03:56:22 +08:00
kuaifan
07d583f73f perf: 优化任务时间冲突提示 2025-01-02 20:25:47 +08:00
kuaifan
12c74aef7a no message 2025-01-02 14:56:30 +08:00
kuaifan
64b10e3060 perf: 优化消息 2025-01-02 14:54:22 +08:00
kuaifan
ab2b29f267 perf: 群聊总人数排除机器人 2025-01-02 14:23:23 +08:00
kuaifan
be9a968ad9 perf: 群聊总人数排除机器人 2025-01-02 13:41:09 +08:00
kuaifan
5f87067a75 fix: 部分电脑无法复制的问题 2025-01-02 12:48:35 +08:00
weifs
ef273bd9dd fix: 修复任务可见性 - 任务重覆获取, 子任务负责人看不到任务问题 2024-12-24 00:11:23 +08:00
weifs
0737a9fae7 fix: 修复任务可见性 - 任务重覆获取, 子任务负责人看不到任务问题 2024-12-23 23:19:22 +08:00
549 changed files with 23707 additions and 9168 deletions

View File

@@ -17,7 +17,7 @@ LOG_CHANNEL=stack
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST="${APP_IPPR}.5"
DB_HOST=mariadb
DB_PORT=3306
DB_DATABASE=dootask
DB_USERNAME=dootask
@@ -34,7 +34,7 @@ SESSION_LIFETIME=120
MEMCACHED_HOST=127.0.0.1
REDIS_HOST="${APP_IPPR}.4"
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379

View File

@@ -115,9 +115,50 @@ jobs:
})
return data.id
build-client:
pack-vendor:
needs: [ check-version, create-release ]
if: needs.check-version.outputs.should_release == 'true'
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.0'
extensions: mbstring, intl, gd, xml, zip, swoole
tools: composer:v2
- name: Install Dependencies
run: composer install
- name: Create Vendor Archive
run: tar -czf vendor.tar.gz vendor/
- name: Upload Vendor Archive
uses: actions/github-script@v7
env:
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
with:
script: |
const fs = require('fs');
const data = await fs.promises.readFile('vendor.tar.gz');
await github.rest.repos.uploadReleaseAsset({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: process.env.RELEASE_ID,
name: 'vendor.tar.gz',
data: data
});
build-client:
needs: [ check-version, create-release, pack-vendor ]
if: needs.check-version.outputs.should_release == 'true'
permissions:
contents: write
strategy:
@@ -231,7 +272,7 @@ jobs:
./cmd electron win
publish-release:
needs: [ check-version, create-release, build-client ]
needs: [ check-version, create-release, pack-vendor, build-client ]
if: needs.check-version.outputs.should_release == 'true' && github.ref == 'refs/heads/pro'
permissions:
contents: write
@@ -258,7 +299,7 @@ jobs:
env:
PUBLISH_KEY: ${{ secrets.PUBLISH_KEY }}
run: |
pushd electron
pushd electron || exit
npm install
popd
popd || exit
node ./electron/build.js published

2
.gitignore vendored
View File

@@ -15,6 +15,7 @@
.idea
.vscode
.vagrant
.windsurfrules
.phpunit.result.cache
Homestead.json
Homestead.yaml
@@ -28,3 +29,4 @@ vars.yaml
laravels.conf
laravels.pid
README_LOCAL.md
dootask.lock

View File

@@ -2,6 +2,351 @@
All notable changes to this project will be documented in this file.
## [0.46.74]
### Features
- 新增系统分享搜索功能
### Performance
- 优化通用菜单
- 优化视频压缩
- 优化全文搜索
- 优化长按菜单
## [0.46.16]
### Bug Fixes
- 修复调整任务排序后出现空白的情况
- 修复移动任务时负责人和协助人可以同时选择的情况
- 修复无法从任务页面打开聊天的情况
- 修复移动端焦点抖动的问题
### Features
- 新增任务发送功能
- 新增会员详情窗口
- 添加从团队管理打开会话窗口
### Performance
- 优化移动任务
- 优化自己的对话不限修改撤回时间
- 优化访问链接
- 优化日历
- 优化长按事件
- 优化移动端任务窗口布局
- 优化长按操作
- 优化转发确认选项保持上一次选择
- 优化移动端布局
- 优化禁止选择会员效果
- 优化长按菜单位置
- 优化移动端打开会话等待效果
- 优化会议弹窗
- 任务详情点任务聊天时不要发送消息
- 优化国际化
## [0.45.64]
### Bug Fixes
- 修复部分页面出现空白的情况
- 修复输入框无法点击添加链接的情况
- 修复AI机器人不存在的情况
### Features
- 新增转发至AI开启新会话
### Performance
- 优化数据结构
- 优化图片存储名
- 优化消息窗口显示
- 优化目录结构
- 优化日历
- 优化任务时间范围选择
## [0.45.33]
### Bug Fixes
- 修复未读数错误暴增的情况
- 修复地址可能存在localhost的情况
- 修复消息编辑和发布时序号对不上
- 修复草稿出现上一次内容的情况
- 修复本地群消息通知没有会员昵称的问题
- 修复了拉人进群无法踢出去的问题
- 提及出现白色字的情况
### Features
- 添加移动端提示可能要发送的图片
### Performance
- 优化消息窗口
- 优化消息长按菜单
- 优化内置浏览器
- 优化App隐私政策提示
- 优化对话独立窗口显示
- 优化移动端选择交互
- 优化移动端选中消息文本
- 优化撤回消息逻辑
- 优化提及搜索
- 优化机器人Webhook消息
## [0.44.91]
### Features
- 添加我的机器人管理
### Performance
- 优化初始化逻辑
- 优化docker配置
## [0.44.82]
### Bug Fixes
- 修复搜索结果显示即将到期
### Features
- 新增独立窗口打开会话
### Performance
- 优化AI支持文件类型
## [0.44.74]
### Performance
- 优化AI解析文件
- 优化 WebSocket 消息
- 优化数据
## [0.44.67]
### Bug Fixes
- 修复查看待办图片不符的情况
### Performance
- 优化数据
- 优化未读消息数
- 优化搜索组件
- 已归档/已删除任务列表支持按状态检索
- 优化消息流效果
- 优化AI上下文
- 优化工作流获取
- 优化转发功能
## [0.44.53]
### Bug Fixes
- 工作流存在已离职人员
### Features
- 可点击标注图标查看标注人员
- 支持分享工作报告到消息
- 支持AI分析工作报告
- 支持使用%发送工作报告
- 新增自定义撤回及修改消息时限
### Performance
- 优化转发消息
- 优化工作报告列表
- 优化引用消息
- 优化全局提示
- 优化草稿消息
## [0.44.19]
### Bug Fixes
- 看不到未读消息定位提醒
### Performance
- 优化消息定位
- 优化消息性能
## [0.44.15]
### Bug Fixes
- 会话内消息搜索布局错位
- 流程设置翻译不统一
### Performance
- 优化消息定位
- 优化MD消息
## [0.44.3]
### Bug Fixes
- 定位签到失败的问题
### Performance
- 优化发送语音效果
- 录音转文字支持自定义语言
- 优化ES模块
- 优化emoji表情
- 按住Ctrl/Command键可连续选择表情
- Md消息支持html代码
- 优化脚本
- 优化安装命令
- 优化ES索引名称
## [0.43.73]
### Bug Fixes
- 全屏预览图片关闭窗口
- 点击排序导致任务不显示的情况
### Performance
- 新增录音转文字
- 优化数据排序
## [0.43.49]
### Performance
- 添加全局搜索功能
- 优化消息搜索
- 团队管理支持调整部门区域尺寸
- 任务详情支持调整聊天区域尺寸
- 优化团队部门支持3级部门
- 可见群组ID
- 支持在团队管理打开群聊
- 优化回复消息自动@逻辑
- 转发预览隐藏表情回应部分
- 优化任务日志
- 已删除任务支持按标签搜索
- 归档任务支持按标签搜索
- 项目面板添加按标签筛选
- 优化 AI 提示词
- 优化 AI 设置
## [0.43.18]
### Bug Fixes
- 首次跟ai聊天没有记录的问题
### Performance
- 工作报告支持查看仅未读
- AI 支持引用文件
- 优化图文消息
- 优化文本信息复制
- 优化样式
- 无法再AI机器人页面看到模型的问题
## [0.43.7]
### Features
- 添加 Grok AI、Ollama AI
### Performance
- 优化MD消息过长处理
- 优化AI支持分析指定文件
- 支持在AI对话中直接引用任务提问
- 优化 AI 参数
- 优化 Ollama AI
- 优化设置
- 优化AI设置
- 优化AI消息
## [0.42.85]
### Bug Fixes
- 撤回消息是消息列表不更新的情况
### Performance
- 表情回复时更新对话列表
- Onlyoffice 支持打开超过100m的文件
- 优化点击上传列表效果
- AI支持自定义模型列表
## [0.42.79]
### Bug Fixes
- 修复偶现的是子窗口出现身份丢失的情况
### Performance
- 优化查看长消息内容
## [0.42.74]
### Performance
- 优化审批功能
- AI机器人支持多会话
- AI机器人支持自定义模型
## [0.42.61]
### Performance
- 支持下载聊天引用的文件
- 优化翻译消息
- 支持显示思考过程
## [0.42.57]
### Bug Fixes
- 跨地区发消息出现消息过期的情况
- 多线程下载文件损坏的问题
- 修复新建周报或日报唯一标识重复
### Features
- 添加 DeepSeek AI
- 添加https证书自动更新
### Performance
- 支持自定义仪表盘欢迎词
- ChatGPT 支持自定义 Base URL
- 优化仪表盘任务更新规则
## [0.42.37]
### Bug Fixes
- 部分电脑无法复制的问题
- 修复任务可见性 - 任务重覆获取, 子任务负责人看不到任务问题
### Performance
- 更新小海豚表情包
- 优化任务时间冲突提示
- 优化消息
- 群聊总人数排除机器人
## [0.42.26]
### Bug Fixes

View File

@@ -0,0 +1,169 @@
<?php
namespace App\Console\Commands;
use App\Models\WebSocketDialogMsg;
use App\Module\ZincSearch\ZincSearchKeyValue;
use App\Module\ZincSearch\ZincSearchDialogMsg;
use Cache;
use Illuminate\Console\Command;
class SyncUserMsgToZincSearch extends Command
{
/**
* 更新数据
* --f: 全量更新 (默认)
* --i: 增量更新从上次更新的最后一个ID接上
*
* 清理数据
* --c: 清除索引
*/
protected $signature = 'zinc:sync-user-msg {--f} {--i} {--c} {--batch=1000}';
protected $description = '同步聊天会话用户和消息到 ZincSearch';
/**
* @return int
*/
public function handle(): int
{
// 注册信号处理器仅在支持pcntl扩展的环境下
if (extension_loaded('pcntl')) {
pcntl_async_signals(true); // 启用异步信号处理
pcntl_signal(SIGINT, [$this, 'handleSignal']); // Ctrl+C
pcntl_signal(SIGTERM, [$this, 'handleSignal']); // kill
}
// 检查锁,如果已被占用则退出
$lockInfo = $this->getLock();
if ($lockInfo) {
$this->error("命令已在运行中,开始时间: {$lockInfo['started_at']}");
return 1;
}
// 设置锁
$this->setLock();
// 清除索引
if ($this->option('c')) {
$this->info('清除索引...');
ZincSearchKeyValue::clear();
ZincSearchDialogMsg::clear();
$this->info("索引删除成功");
$this->releaseLock();
return 0;
}
$this->info('开始同步聊天数据...');
// 同步消息数据
$this->syncDialogMsgs();
// 完成
$this->info("\n同步完成");
$this->releaseLock();
return 0;
}
/**
* 获取锁信息
*
* @return array|null 如果锁存在返回锁信息否则返回null
*/
private function getLock(): ?array
{
$lockKey = md5($this->signature);
return Cache::has($lockKey) ? Cache::get($lockKey) : null;
}
/**
* 设置锁
*/
private function setLock(): void
{
$lockKey = md5($this->signature);
$lockInfo = [
'started_at' => date('Y-m-d H:i:s')
];
Cache::put($lockKey, $lockInfo, 300); // 5分钟
}
/**
* 释放锁
*/
private function releaseLock(): void
{
$lockKey = md5($this->signature);
Cache::forget($lockKey);
}
/**
* 处理终端信号
*
* @param int $signal
* @return void
*/
public function handleSignal(int $signal): void
{
// 释放锁
$this->releaseLock();
exit(0);
}
/**
* 同步消息数据
*
* @return void
*/
private function syncDialogMsgs(): void
{
// 获取上次同步的最后ID
$lastKey = "sync:dialogUserMsgLastId";
$lastId = $this->option('i') ? intval(ZincSearchKeyValue::get($lastKey, 0)) : 0;
if ($lastId > 0) {
$this->info("\n同步消息数据({$lastId}...");
} else {
$this->info("\n同步消息数据...");
}
$num = 0;
$count = WebSocketDialogMsg::where('id', '>', $lastId)->count();
$batchSize = $this->option('batch');
$total = 0;
$lastNum = 0;
do {
// 获取一批
$dialogMsgs = WebSocketDialogMsg::where('id', '>', $lastId)
->orderBy('id')
->limit($batchSize)
->get();
if ($dialogMsgs->isEmpty()) {
break;
}
$num += count($dialogMsgs);
$progress = round($num / $count * 100, 2);
if ($progress < 100) {
$progress = number_format($progress, 2);
}
$this->info("{$num}/{$count} ({$progress}%) 正在同步消息ID {$dialogMsgs->first()->id} ~ {$dialogMsgs->last()->id} ({$total}|{$lastNum})");
// 刷新锁
$this->setLock();
// 同步数据
$lastNum = ZincSearchDialogMsg::batchSync($dialogMsgs);
$total += $lastNum;
// 更新最后ID
$lastId = $dialogMsgs->last()->id;
ZincSearchKeyValue::set($lastKey, $lastId);
} while (count($dialogMsgs) == $batchSize);
$this->info("同步消息结束 - 最后ID {$lastId}");
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Events;
use App\Models\WebSocket;
use App\Services\RequestContext;
use Hhxsv5\LaravelS\Swoole\Events\WorkerStartInterface;
use Swoole\Http\Server;
@@ -25,5 +26,6 @@ class WorkerStartEvent implements WorkerStartInterface
private function handleFirstWorkerTasks()
{
WebSocket::query()->delete();
RequestContext::clearBaseUrlCache();
}
}

View File

@@ -12,11 +12,12 @@ use App\Models\File;
use App\Models\User;
use App\Module\Base;
use App\Module\Timer;
use App\Models\Setting;
use App\Module\Extranet;
use App\Module\TimeRange;
use App\Module\MsgTool;
use App\Module\Table\OnlineData;
use App\Models\FileContent;
use App\Models\ProjectTask;
use App\Models\AbstractModel;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogMsg;
@@ -25,6 +26,9 @@ use App\Models\WebSocketDialogConfig;
use App\Models\WebSocketDialogMsgRead;
use App\Models\WebSocketDialogMsgTodo;
use App\Models\WebSocketDialogMsgTranslate;
use App\Models\WebSocketDialogSession;
use App\Module\Table\OnlineData;
use App\Module\ZincSearch\ZincSearchDialogMsg;
use Hhxsv5\LaravelS\Swoole\Task\Task;
/**
@@ -116,34 +120,11 @@ class DialogController extends AbstractController
return Base::retError('请输入搜索关键词');
}
// 搜索会话
$list = DB::table('web_socket_dialog_users as u')
->select(['d.*', 'u.top_at', 'u.last_at', 'u.mark_unread', 'u.silence', 'u.hide', 'u.color', 'u.updated_at as user_at'])
->join('web_socket_dialogs as d', 'u.dialog_id', '=', 'd.id')
->where('u.userid', $user->userid)
->where('d.name', 'LIKE', "%{$key}%")
->whereNull('d.deleted_at')
->orderByDesc('u.top_at')
->orderByDesc('u.last_at')
->take(20)
->get()
->map(function($item) use ($user) {
return WebSocketDialog::synthesizeData($item, $user->userid);
})
->all();
$take = 20;
$list = WebSocketDialog::searchDialog($user->userid, $key, $take);
// 搜索联系人
if (count($list) < 20 && Base::judgeClientVersion("0.21.60")) {
$users = User::select(User::$basicField)
->where(function ($query) use ($key) {
if (str_contains($key, "@")) {
$query->where("email", "like", "%{$key}%");
} else {
$query->where("nickname", "like", "%{$key}%")
->orWhere("pinyin", "like", "%{$key}%")
->orWhere("profession", "like", "%{$key}%");
}
})->orderBy('userid')
->take(20 - count($list))
->get();
if (count($list) < $take && Base::judgeClientVersion("0.21.60")) {
$users = User::searchUser($key, $take - count($list));
$users->transform(function (User $item) use ($user) {
$id = 'u:' . $item->userid;
$lastAt = null;
@@ -169,29 +150,16 @@ class DialogController extends AbstractController
$list = array_merge($list, $users->toArray());
}
// 搜索消息会话
if (count($list) < 20) {
$prefix = DB::getTablePrefix();
if (preg_match('/[+\-><()~*"@]/', $key)) {
$against = "\"{$key}\"";
} else {
$against = "*{$key}*";
if (count($list) < $take) {
$searchResults = ZincSearchDialogMsg::search($user->userid, $key, 0, $take - count($list));
if ($searchResults) {
foreach ($searchResults as $item) {
if ($dialog = WebSocketDialog::find($item['id'])) {
$dialog = array_merge($dialog->toArray(), $item);
$list[] = WebSocketDialog::synthesizeData($dialog, $user->userid);
}
}
}
$msgs = DB::table('web_socket_dialog_users as u')
->select(['d.*', 'u.top_at', 'u.last_at', 'u.mark_unread', 'u.silence', 'u.hide', 'u.color', 'u.updated_at as user_at', 'm.id as search_msg_id'])
->join('web_socket_dialogs as d', 'u.dialog_id', '=', 'd.id')
->join('web_socket_dialog_msgs as m', 'm.dialog_id', '=', 'd.id')
->where('u.userid', $user->userid)
->where('m.bot', 0)
->whereNull('d.deleted_at')
->whereRaw("MATCH({$prefix}m.key) AGAINST('{$against}' IN BOOLEAN MODE)")
->orderByDesc('m.id')
->take(20 - count($list))
->get()
->map(function($item) use ($user) {
return WebSocketDialog::synthesizeData($item, $user->userid);
})
->all();
$list = array_merge($list, $msgs);
}
//
return Base::retSuccess('success', $list);
@@ -251,14 +219,10 @@ class DialogController extends AbstractController
//
$dialog_id = intval(Request::input('dialog_id'));
//
$item = DB::table('web_socket_dialog_users as u')
->select(['d.*', 'u.top_at', 'u.last_at', 'u.mark_unread', 'u.silence', 'u.hide', 'u.color', 'u.updated_at as user_at'])
->join('web_socket_dialogs as d', 'u.dialog_id', '=', 'd.id')
->where('u.userid', $user->userid)
->where('d.id', $dialog_id)
->whereNull('d.deleted_at')
->first();
return Base::retSuccess('success', WebSocketDialog::synthesizeData($item, $user->userid));
$dialog = WebSocketDialog::checkDialog($dialog_id);
$data = WebSocketDialog::synthesizeData($dialog, $user->userid);
//
return Base::retSuccess('success', $data);
}
/**
@@ -536,6 +500,9 @@ class DialogController extends AbstractController
->on('read.msg_id', '=', 'web_socket_dialog_msgs.id');
})->where('web_socket_dialog_msgs.dialog_id', $dialog_id);
//
if ($dialog->session_id > 0) {
$builder->whereSessionId($dialog->session_id);
}
if ($msg_type) {
if ($msg_type === 'tag') {
$builder->where('tag', '>', 0);
@@ -682,15 +649,18 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/search 14. 搜索消息位置
* @api {get} api/dialog/msg/search 14. 搜索消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName msg__search
*
* @apiParam {Number} dialog_id 对话ID
* @apiParam {String} key 搜索关键词
* @apiParam {Number} [dialog_id] 对话ID存在则搜索消息在对话的位置
* @apiParam {Number} [take] 搜索数量
* - dialog_id > 0, 默认:200最大:200
* - dialog_id <= 0, 默认:20最大:50
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@@ -698,22 +668,38 @@ class DialogController extends AbstractController
*/
public function msg__search()
{
User::auth();
$user = User::auth();
//
$dialog_id = intval(Request::input('dialog_id'));
$key = trim(Request::input('key'));
$dialogId = intval(Request::input('dialog_id'));
//
if (empty($key)) {
return Base::retError('关键词不能为空');
}
//
WebSocketDialog::checkDialog($dialog_id);
//
$data = WebSocketDialogMsg::whereDialogId($dialog_id)
->where('key', 'LIKE', "%{$key}%")
->take(200)
->pluck('id');
return Base::retSuccess('success', compact('data'));
if ($dialogId > 0) {
// 搜索位置
WebSocketDialog::checkDialog($dialogId);
//
$data = WebSocketDialogMsg::whereDialogId($dialogId)
->where('key', 'LIKE', "%{$key}%")
->take(Base::getPaginate(200, 200, 'take'))
->pluck('id');
return Base::retSuccess('success', compact('data'));
} else {
// 搜索消息
$list = [];
$searchResults = ZincSearchDialogMsg::search($user->userid, $key, 0, Base::getPaginate(50, 20, 'take'));
if ($searchResults) {
foreach ($searchResults as $item) {
if ($dialog = WebSocketDialog::find($item['id'])) {
$dialog = array_merge($dialog->toArray(), $item);
$list[] = WebSocketDialog::synthesizeData($dialog, $user->userid);
}
}
}
return Base::retSuccess('success', ['data' => $list]);
}
}
/**
@@ -1028,6 +1014,7 @@ class DialogController extends AbstractController
* @apiParam {String} [silence] 是否静默发送
* - no: 正常发送(默认)
* - yes: 静默发送
* @apiParam {String} [model_name] 模型名称仅AI机器人支持
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@@ -1048,16 +1035,20 @@ class DialogController extends AbstractController
$key = trim(Request::input('key'));
$text_type = strtolower(trim(Request::input('text_type')));
$silence = in_array(strtolower(trim(Request::input('silence'))), ['yes', 'true', '1']);
$model_name = trim(Request::input('model_name'));
$markdown = in_array($text_type, ['md', 'markdown']);
//
$result = [];
$dialogIds = $dialog_ids ? explode(',', $dialog_ids) : [$dialog_id ?: 0];
foreach ($dialogIds as $dialog_id) {
//
WebSocketDialog::checkDialog($dialog_id);
$dialog = WebSocketDialog::checkDialog($dialog_id);
//
if ($update_id > 0) {
$action = $update_mark ? "update-$update_id" : "change-$update_id";
if (!$user->bot && !$dialog->isSelfDialog()) {
Setting::validateMsgLimit('edit', $update_id);
}
} elseif ($reply_id > 0) {
$action = "reply-$reply_id";
if ($reply_check === 'yes') {
@@ -1096,16 +1087,21 @@ class DialogController extends AbstractController
if (empty($size)) {
return Base::retError('消息发送保存失败');
}
$ext = $markdown ? 'md' : 'htm';
$text = MsgTool::truncateText($text, 500, $ext);
$desc = strip_tags($markdown ? Base::markdown2html($text) : $text);
$type = $markdown ? 'md' : 'htm';
$desc = $text;
if ($markdown) {
$desc = preg_replace("/:::\s*reasoning[\s\S]*?:::/", "", $desc);
$desc = Base::markdown2html($desc);
}
$desc = strip_tags($desc);
$desc = mb_substr(WebSocketDialogMsg::filterEscape($desc), 0, 200);
$text = MsgTool::truncateText($text, 500, $type);
$msgData = [
'type' => $type, // 内容类型
'desc' => $desc, // 描述内容
'text' => $text, // 简要内容
'type' => $ext, // 内容类型
'file' => [
'name' => "LongText-{$strlen}.{$ext}",
'name' => "LongText-{$strlen}.{$type}",
'size' => $size,
'file' => $file,
'path' => $path,
@@ -1113,18 +1109,24 @@ class DialogController extends AbstractController
'thumb' => '',
'width' => -1,
'height' => -1,
'ext' => $ext,
'ext' => $type,
],
];
if (empty($key)) {
$key = $desc;
}
if ($model_name) {
$msgData['model_name'] = $model_name;
}
$result = WebSocketDialogMsg::sendMsg($action, $dialog_id, 'longtext', $msgData, $user->userid, false, false, $silence, $key);
} else {
$msgData = ['text' => $text];
if ($markdown) {
$msgData['type'] = 'md';
}
if ($model_name) {
$msgData['model_name'] = $model_name;
}
$result = WebSocketDialogMsg::sendMsg($action, $dialog_id, 'text', $msgData, $user->userid, false, false, $silence, $key);
}
}
@@ -1302,7 +1304,79 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/sendfile 25. 文件上传
* @api {post} api/dialog/msg/convertrecord 25. 录音转文字
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName msg__convertrecord
*
* @apiParam {String} base64 语音base64
* @apiParam {Number} duration 语音时长(毫秒)
* @apiParam {String} [language] 识别语言
* - 比如zh
* - 默认:自动识别
* - 格式:符合 ISO_639 标准
* - 此参数不一定起效果AI会根据语音和language参考翻译识别结果
* @apiParam {String} [translate] 翻译识别结果
* - 比如zh
* - 默认:不翻译结果
* - 格式:符合 ISO_639 标准
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function msg__convertrecord()
{
$user = User::auth();
$user->checkChatInformation();
//
$path = "uploads/tmp/chat/" . date("Ym") . "/" . $user->userid . "/";
$base64 = Request::input('base64');
$language = Request::input('language');
$translate = Request::input('translate');
$duration = intval(Request::input('duration'));
if ($duration < 600) {
return Base::retError('说话时间太短');
}
// 保存录音
$data = Base::record64save([
"base64" => $base64,
"path" => $path,
]);
if (Base::isError($data)) {
return Base::retError($data['msg']);
}
$recordData = $data['data'];
// 转文字
$extParams = [];
if ($language) {
$extParams = [
'language' => $language === 'zh-CHT' ? 'zh' : $language,
'prompt' => "将此语音识别为“" . Doo::getLanguages($language) . "”。",
];
}
$result = Extranet::openAItranscriptions($recordData['file'], $extParams);
if (Base::isError($result)) {
return $result;
}
if (strlen($result['data']) < 1) {
return Base::retError('转文字失败');
}
// 翻译
if ($translate) {
$result = Extranet::openAItranslations($result['data'], Doo::getLanguages($translate));
if (Base::isError($result)) {
return $result;
}
}
// 返回
return $result;
}
/**
* @api {post} api/dialog/msg/sendfile 26. 文件上传
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1334,7 +1408,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/sendfiles 26. 群发文件上传
* @api {post} api/dialog/msg/sendfiles 27. 群发文件上传
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1390,7 +1464,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/sendfileid 27. 通过文件ID发送文件
* @api {get} api/dialog/msg/sendfileid 28. 通过文件ID发送文件
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1400,6 +1474,7 @@ class DialogController extends AbstractController
* @apiParam {Number} file_id 消息ID
* @apiParam {Array} dialogids 转发给的对话ID
* @apiParam {Array} userids 转发给的成员ID
* @apiParam {String} leave_message 转发留言
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@@ -1412,55 +1487,63 @@ class DialogController extends AbstractController
$file_id = intval(Request::input("file_id"));
$dialogids = Request::input('dialogids');
$userids = Request::input('userids');
$leave_message = Request::input('leave_message');
//
if (empty($dialogids) && empty($userids)) {
return Base::retError("请选择转发对话或成员");
return Base::retError("请选择对话或成员");
}
//
$file = File::permissionFind($file_id, $user);
$fileLink = $file->getShareLink($user->userid);
$fileMsg = "<a class=\"mention file\" href=\"{{RemoteURL}}single/file/{$fileLink['code']}\" target=\"_blank\">~{$file->getNameAndExt()}</a>";
$fileMsg = "<p><a class=\"mention file\" href=\"{{RemoteURL}}single/file/{$fileLink['code']}\" target=\"_blank\">~{$file->getNameAndExt()}</a></p>";
if ($leave_message) {
$fileMsg .= "<p>{$leave_message}</p>";
}
//
return AbstractModel::transaction(function() use ($user, $fileMsg, $userids, $dialogids) {
$msgs = [];
$already = [];
if ($dialogids) {
if (!is_array($dialogids)) {
$dialogids = [$dialogids];
}
foreach ($dialogids as $dialogid) {
$res = WebSocketDialogMsg::sendMsg(null, $dialogid, 'text', ['text' => $fileMsg], $user->userid);
if (Base::isSuccess($res)) {
$msgs[] = $res['data'];
$already[] = $dialogid;
}
}
}
if ($userids) {
if (!is_array($userids)) {
$userids = [$userids];
}
foreach ($userids as $userid) {
if (!User::whereUserid($userid)->exists()) {
continue;
}
$dialog = WebSocketDialog::checkUserDialog($user, $userid);
if ($dialog && !in_array($dialog->id, $already)) {
$res = WebSocketDialogMsg::sendMsg(null, $dialog->id, 'text', ['text' => $fileMsg], $user->userid);
if (Base::isSuccess($res)) {
$msgs[] = $res['data'];
}
}
}
}
return Base::retSuccess('发送成功', [
'msgs' => $msgs
]);
});
return WebSocketDialogMsg::sendMsgBatch($user, $userids, $dialogids, $fileMsg);
}
/**
* @api {post} api/dialog/msg/sendanon 28. 发送匿名消息
* @api {get} api/dialog/msg/sendtaskid 29. 通过任务ID发送任务
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName msg__sendtaskid
*
* @apiParam {Number} task_id 消息ID
* @apiParam {Array} dialogids 转发给的对话ID
* @apiParam {Array} userids 转发给的成员ID
* @apiParam {String} leave_message 转发留言
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function msg__sendtaskid()
{
$user = User::auth();
//
$task_id = intval(Request::input("task_id"));
$dialogids = Request::input('dialogids');
$userids = Request::input('userids');
$leave_message = Request::input('leave_message');
//
if (empty($dialogids) && empty($userids)) {
return Base::retError("请选择对话或成员");
}
//
$task = ProjectTask::userTask($task_id, null);
$taskMsg = "<p><span class=\"mention task\" data-id=\"{$task_id}\">#{$task->name}</span></p>";
if ($leave_message) {
$taskMsg .= "<p>{$leave_message}</p>";
}
//
return WebSocketDialogMsg::sendMsgBatch($user, $userids, $dialogids, $taskMsg);
}
/**
* @api {post} api/dialog/msg/sendanon 30. 发送匿名消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1516,7 +1599,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/sendlocation 29. 发送位置消息
* @api {post} api/dialog/msg/sendlocation 31. 发送位置消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1576,7 +1659,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/readlist 30. 获取消息阅读情况
* @api {get} api/dialog/msg/readlist 32. 获取消息阅读情况
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1605,7 +1688,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/detail 31. 消息详情
* @api {get} api/dialog/msg/detail 33. 消息详情
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1665,7 +1748,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/download 32. 文件下载
* @api {get} api/dialog/msg/download 34. 文件下载
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1702,11 +1785,11 @@ class DialogController extends AbstractController
}
//
$filePath = public_path($array['path']);
return Base::BinaryFileResponse($filePath, $array['name']);
return Base::DownloadFileResponse($filePath, $array['name']);
}
/**
* @api {get} api/dialog/msg/withdraw 33. 聊天消息撤回
* @api {get} api/dialog/msg/withdraw 35. 聊天消息撤回
*
* @apiDescription 消息撤回限制24小时内需要token身份
* @apiVersion 1.0.0
@@ -1727,12 +1810,17 @@ class DialogController extends AbstractController
if (empty($msg)) {
return Base::retError("消息不存在或已被删除");
}
$dialog = WebSocketDialog::checkDialog($msg->dialog_id);
//
if (!$user->bot && !$dialog->isSelfDialog()) {
Setting::validateMsgLimit('rev', $msg);
}
$msg->withdrawMsg();
return Base::retSuccess("success");
}
/**
* @api {get} api/dialog/msg/voice2text 34. 语音消息转文字
* @api {get} api/dialog/msg/voice2text 36. 语音消息转文字
*
* @apiDescription 将语音消息转文字需要token身份
* @apiVersion 1.0.0
@@ -1784,7 +1872,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/translation 35. 翻译消息
* @api {get} api/dialog/msg/translation 37. 翻译消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1792,6 +1880,8 @@ class DialogController extends AbstractController
* @apiName msg__translation
*
* @apiParam {Number} msg_id 消息ID
* @apiParam {Number} [force] 强制翻译1是、0否
* - 默认不强制翻译,已翻译过的消息不再翻译
* @apiParam {String} [language] 目标语言,默认当前语言
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
@@ -1803,6 +1893,7 @@ class DialogController extends AbstractController
User::auth();
//
$msg_id = intval(Request::input("msg_id"));
$force = intval(Request::input("force"));
$language = Base::inputOrHeader('language');
$targetLanguage = Doo::getLanguages($language);
//
@@ -1820,13 +1911,20 @@ class DialogController extends AbstractController
//
$row = WebSocketDialogMsgTranslate::whereMsgId($msg_id)->whereLanguage($language)->first();
if ($row) {
return Base::retSuccess("success", $row->only(['msg_id', 'language', 'content']));
if ($force) {
$row->delete();
} else {
return Base::retSuccess("success", $row->only(['msg_id', 'language', 'content']));
}
}
//
$msgData = Base::json2array($msg->getRawOriginal('msg'));
if (empty($msgData['text'])) {
return Base::retError("消息内容为空");
}
if ($msg->type === 'text' && $msgData['type'] === 'md') {
$msgData['text'] = preg_replace('/:::\s*reasoning.*?:::/s', '', $msgData['text']);
}
$res = Extranet::openAItranslations($msgData['text'], $targetLanguage);
if (Base::isError($res)) {
return $res;
@@ -1843,7 +1941,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/mark 36. 消息标记操作
* @api {get} api/dialog/msg/mark 38. 消息标记操作
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1907,7 +2005,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/silence 37. 消息免打扰
* @api {get} api/dialog/msg/silence 39. 消息免打扰
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1970,7 +2068,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/forward 38. 转发消息给
* @api {get} api/dialog/msg/forward 40. 转发消息给
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1981,7 +2079,7 @@ class DialogController extends AbstractController
* @apiParam {Array} dialogids 转发给的对话ID
* @apiParam {Array} userids 转发给的成员ID
* @apiParam {Number} show_source 是否显示原发送者信息
* @apiParam {Array} leave_message 转发留言
* @apiParam {String} leave_message 转发留言
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@@ -1998,7 +2096,7 @@ class DialogController extends AbstractController
$leave_message = Request::input('leave_message');
//
if (empty($dialogids) && empty($userids)) {
return Base::retError("请选择转发对话或成员");
return Base::retError("请选择对话或成员");
}
//
$msg = WebSocketDialogMsg::whereId($msg_id)->first();
@@ -2011,7 +2109,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/emoji 39. emoji回复
* @api {get} api/dialog/msg/emoji 41. emoji回复
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2046,7 +2144,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/tag 40. 标注/取消标注
* @api {get} api/dialog/msg/tag 42. 标注/取消标注
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2075,7 +2173,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/todo 41. 设待办/取消待办
* @api {get} api/dialog/msg/todo 43. 设待办/取消待办
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2118,7 +2216,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/todolist 42. 获取消息待办情况
* @api {get} api/dialog/msg/todolist 44. 获取消息待办情况
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2148,7 +2246,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/done 43. 完成待办
* @api {get} api/dialog/msg/done 45. 完成待办
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2201,7 +2299,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/color 44. 设置颜色
* @api {get} api/dialog/msg/color 46. 设置颜色
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2242,7 +2340,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/group/add 45. 新增群组
* @api {get} api/dialog/group/add 47. 新增群组
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2304,7 +2402,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/group/edit 46. 修改群组
* @api {get} api/dialog/group/edit 48. 修改群组
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2366,7 +2464,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/group/adduser 47. 添加群成员
* @api {get} api/dialog/group/adduser 49. 添加群成员
*
* @apiDescription 需要token身份
* - 有群主时:只有群主可以邀请
@@ -2402,7 +2500,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/group/deluser 48. 移出(退出)群成员
* @api {get} api/dialog/group/deluser 50. 移出(退出)群成员
*
* @apiDescription 需要token身份
* - 只有群主、邀请人可以踢人
@@ -2446,7 +2544,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/group/transfer 49. 转让群组
* @api {get} api/dialog/group/transfer 51. 转让群组
*
* @apiDescription 需要token身份
* - 只有群主且是个人类型群可以解散
@@ -2495,7 +2593,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/group/disband 50. 解散群组
* @api {get} api/dialog/group/disband 52. 解散群组
*
* @apiDescription 需要token身份
* - 只有群主且是个人类型群可以解散
@@ -2523,7 +2621,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/group/searchuser 51. 搜索个人群(仅限管理员)
* @api {get} api/dialog/group/searchuser 53. 搜索个人群(仅限管理员)
*
* @apiDescription 需要token身份用于创建部门搜索个人群组
* @apiVersion 1.0.0
@@ -2552,7 +2650,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/okr/add 52. 创建OKR评论会话
* @api {post} api/dialog/okr/add 54. 创建OKR评论会话
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2591,7 +2689,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/okr/push 53. 推送OKR相关信息
* @api {post} api/dialog/okr/push 55. 推送OKR相关信息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2627,7 +2725,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/wordchain 54. 发送接龙消息
* @api {post} api/dialog/msg/wordchain 56. 发送接龙消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2713,7 +2811,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/vote 55. 发起投票
* @api {post} api/dialog/msg/vote 57. 发起投票
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2829,7 +2927,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/top 56. 置顶/取消置顶
* @api {get} api/dialog/msg/top 58. 置顶/取消置顶
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2889,7 +2987,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/topinfo 57. 获取置顶消息
* @api {get} api/dialog/msg/topinfo 59. 获取置顶消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2916,7 +3014,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/applied 58. 标记消息已应用
* @api {get} api/dialog/msg/applied 60. 标记消息已应用
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2965,7 +3063,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/sticker/search 59. 搜索在线表情
* @api {get} api/dialog/sticker/search 61. 搜索在线表情
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2989,7 +3087,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/config 60. 获取会话配置
* @api {get} api/dialog/config 62. 获取会话配置
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -3025,7 +3123,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/config/save 61. 保存会话配置
* @api {post} api/dialog/config/save 63. 保存会话配置
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -3063,10 +3161,142 @@ class DialogController extends AbstractController
]
)) {
WebSocketDialogMsg::sendMsg(null, $dialog_id, 'notice', [
'notice' => $value ? ("修改提示词:" . $value) : "取消提示词",
'notice' => $value ? ("修改提示词:" . Base::cutStr($value, 100)) : "取消提示词",
], User::userid(), true, true);
}
return Base::retSuccess('保存成功');
}
/**
* @api {get} api/dialog/session/create 64. AI-开启新会话
*
* @apiDescription 需要token身份仅限与AI用户会话
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName session_create
*
* @apiParam {Number} dialog_id 对话ID
* @apiParam {Number} [userid] 用户ID与 dialog_id 二选一userid 优先)
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function session__create()
{
$user = User::auth();
//
$dialog_id = intval(Request::input('dialog_id'));
$userid = intval(Request::input('userid'));
//
if ($userid) {
$dialog = WebSocketDialog::checkUserDialog($user, $userid);
} else {
$dialog = WebSocketDialog::checkDialog($dialog_id);
}
//
if ($dialog->type != 'user') {
return Base::retError('当前对话不支持');
}
//
$hasAiUser = WebSocketDialogUser::join('users as u', 'web_socket_dialog_users.userid', '=', 'u.userid')
->where('dialog_id', $dialog->id)
->where('u.email', 'like', 'ai-%@bot.system')
->exists();
if (!$hasAiUser) {
return Base::retError('当前对话不支持');
}
//
$session = WebSocketDialogSession::whereDialogId($dialog->id)->whereTitle('')->first();
if ($session) {
$dialog->session_id = $session->id;
$dialog->save();
return Base::retSuccess('success', $session);
}
//
$session = WebSocketDialogSession::create([
'dialog_id' => $dialog->id,
'status' => 1,
'title' => '',
]);
$session->save();
$dialog->session_id = $session->id;
$dialog->save();
//
return Base::retSuccess('success', $session);
}
/**
* @api {get} api/dialog/session/list 65. AI-获取会话列表
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName session_list
*
* @apiParam {Number} dialog_id 对话ID
*
* @apiParam {Number} [page] 当前页,默认:1
* @apiParam {Number} [pagesize] 每页显示数量,默认:20最大:50
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function session__list()
{
User::auth();
//
$dialog_id = intval(Request::input('dialog_id'));
//
$dialog = WebSocketDialog::checkDialog($dialog_id);
//
$sessions = WebSocketDialogSession::whereDialogId($dialog->id)
->orderByDesc('id')
->paginate(Base::getPaginate(100, 10));
$sessions->transform(function ($item) use ($dialog) {
if ($item->id === $dialog->session_id) {
$item->is_open = 1;
} else {
$item->is_open = 0;
}
return $item;
});
//
return Base::retSuccess('success', $sessions);
}
/**
* @api {get} api/dialog/session/open 66. AI-打开会话
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName session_open
*
* @apiParam {Number} session_id 会话ID
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function session__open()
{
User::auth();
//
$session_id = intval(Request::input('session_id'));
//
$session = WebSocketDialogSession::whereId($session_id)->first();
if (empty($session)) {
return Base::retError('会话不存在或已被删除');
}
//
$dialog = WebSocketDialog::checkDialog($session->dialog_id);
//
$dialog->session_id = $session->id;
$dialog->save();
//
return Base::retSuccess('success', $session);
}
}

View File

@@ -87,6 +87,7 @@ class FileController extends AbstractController
}
return Base::retError($msg, $data);
}
$fileLink->increment("num");
} else {
return Base::retError('参数错误');
}
@@ -106,6 +107,7 @@ class FileController extends AbstractController
*
* @apiParam {String} [link] 通过分享地址搜索https://t.hitosea.com/single/file/ODcwOCwzOSxpa0JBS2lmVQ==
* @apiParam {String} [key] 关键词
* @apiParam {Number} [take] 获取数量默认50最大100
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@@ -118,7 +120,7 @@ class FileController extends AbstractController
$link = trim(Request::input('link'));
$key = trim(Request::input('key'));
$id = 0;
$take = 50;
$take = Base::getPaginate(100, 50, 'take');
if (preg_match("/\/single\/file\/(.*?)$/i", $link, $match)) {
$id = intval(FileLink::whereCode($match[1])->value('file_id'));
$take = 1;
@@ -664,7 +666,7 @@ class FileController extends AbstractController
//
if ($status === 2) {
$parse = parse_url($url);
$from = 'http://' . env('APP_IPPR') . '.3' . $parse['path'] . '?' . $parse['query'];
$from = 'http://nginx' . $parse['path'] . '?' . $parse['query'];
$path = 'uploads/file/' . $file->type . '/' . date("Ym") . '/' . $file->id . '/' . $key;
$save = public_path($path);
Base::makeDir(dirname($save));

View File

@@ -941,7 +941,9 @@ class ProjectController extends AbstractController
* @apiName task__lists
*
* @apiParam {Object} [keys] 搜索条件
* - keys.name: ID、任务名称
* - keys.name: ID、任务名称、任务描述
* - keys.tag: 标签名称
* - keys.status: 任务状态 (completed: 已完成、uncompleted: 未完成、flow-xx: 流程状态ID)
*
* @apiParam {Number} [project_id] 项目ID
* @apiParam {Number} [parent_id] 主任务IDproject_id && parent_id ≤ 0 时 仅查询自己参与的任务)
@@ -994,7 +996,29 @@ class ProjectController extends AbstractController
if (Base::isNumber($keys['name'])) {
$builder->where("project_tasks.id", intval($keys['name']));
} else {
$builder->where("project_tasks.name", "like", "%{$keys['name']}%");
$builder->where(function ($query) use ($keys) {
$query->where("project_tasks.name", "like", "%{$keys['name']}%");
$query->orWhere("project_tasks.desc", "like", "%{$keys['name']}%");
});
}
}
if ($keys['tag']) {
$builder->whereHas('taskTag', function ($query) use ($keys) {
$query->where('project_task_tags.name', $keys['tag']);
});
}
if ($keys['status']) {
if ($keys['status'] == 'completed') {
$builder->whereNotNull('project_tasks.complete_at');
} elseif ($keys['status'] == 'uncompleted') {
$builder->whereNull('project_tasks.complete_at');
} elseif (str_starts_with($keys['status'], 'flow-')) {
$flow = str_replace('flow-', '', $keys['status']);
if (Base::isNumber($flow)) {
$builder->where('project_tasks.flow_item_id', intval($flow));
} elseif ($flow) {
$builder->where('project_tasks.flow_item_name', 'like', "%{$flow}%");
}
}
}
//
@@ -1058,20 +1082,20 @@ class ProjectController extends AbstractController
$query->where('project_users.owner', 1);
$query->where('project_users.userid', $userid);
});
$builder->leftJoin('project_task_users as project_sub_task_users', function ($query) use($userid) {
$query->on('project_sub_task_users.task_pid', '=', 'project_tasks.parent_id');
$query->where('project_sub_task_users.userid', $userid);
});
$builder->leftJoin('project_task_visibility_users', function ($query) use($userid) {
$query->on('project_task_visibility_users.task_id', '=', 'project_tasks.id');
$query->where('project_task_visibility_users.userid', $userid);
});
$builder->leftJoin('project_task_visibility_users as project_sub_task_visibility_users', function ($query) use($userid) {
$query->on('project_sub_task_visibility_users.task_id', '=', 'project_tasks.parent_id');
$query->where('project_sub_task_visibility_users.userid', $userid);
});
$builder->where(function ($query) use ($userid) {
$query->where("project_tasks.visibility", 1);
$query->orWhere("project_users.userid", $userid);
$query->orWhere("project_task_users.userid", $userid);
$query->orWhere("project_task_visibility_users.userid", $userid);
$query->orWhere("project_sub_task_users.userid", $userid);
$query->orWhere("project_sub_task_visibility_users.userid", $userid);
});
// 优化子查询汇总
$builder->leftJoinSub(function ($query) {
@@ -1159,6 +1183,7 @@ class ProjectController extends AbstractController
$list = ProjectTask::with(['taskUser'])
->select([
'projects.name as project_name',
'project_tasks.project_id',
'project_tasks.id',
'project_tasks.name',
'project_tasks.start_at',
@@ -1852,7 +1877,7 @@ class ProjectController extends AbstractController
}
//
$filePath = public_path($file->getRawOriginal('path'));
return Base::BinaryFileResponse($filePath, $file->name);
return Base::DownloadFileResponse($filePath, $file->name);
}
/**
@@ -2297,8 +2322,8 @@ class ProjectController extends AbstractController
* @apiGroup project
* @apiName task__flow
*
* @apiParam {Number} task_id 任务ID
* @apiParam {Number} project_id 项目ID - 存在时只返回这个项目的
* @apiParam {Number} [task_id] 任务ID
* @apiParam {Number} [project_id] 项目ID存在时只返回这个项目的工作流,主要用于任务移动到其他项目时)
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@@ -2392,6 +2417,11 @@ class ProjectController extends AbstractController
* @apiParam {Number} flow_item_id 工作流id
* @apiParam {Array} owner 负责人
* @apiParam {Array} assist 协助人
* @apiParam {String} [completed] 是否已完成
* - 没有 工作流id 时此参数才生效
* - 有值表示已完成
* - 空值表示未完成
* - 不存在不改变状态
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@@ -2408,7 +2438,7 @@ class ProjectController extends AbstractController
$flow_item_id = intval(Request::input('flow_item_id'));
$owner = Request::input('owner', []);
$assist = Request::input('assist', []);
$completeAt = trim(Request::input('complete_at', ''));
$completed = Request::exists('completed') ? (bool)Request::input('completed') : null;
//
$task = ProjectTask::userTask($task_id);
//
@@ -2429,13 +2459,13 @@ class ProjectController extends AbstractController
if (empty($flowItem)) {
return Base::retError('任务状态不存在');
}
} else if (!$flow_item_id && !$completeAt) {
} else {
if (projectFlowItem::whereProjectId($project->id)->count() > 0) {
return Base::retError('请选择移动后状态', [], 102);
}
}
//
$task->moveTask($project_id, $column_id, $flow_item_id, $owner, $assist, $completeAt);
$task->moveTask($project_id, $column_id, $flow_item_id, $owner, $assist, $completed);
//
$data = [];
$mainTask = ProjectTask::userTask($task_id)?->toArray();
@@ -2585,7 +2615,7 @@ class ProjectController extends AbstractController
$builder->with(['projectTask:id,parent_id,name'])->whereProjectId($project->id)->whereTaskOnly(0);
}
//
$list = $builder->orderByDesc('created_at')->paginate(Base::getPaginate(100, 20));
$list = $builder->orderByDesc('created_at')->orderByDesc('id')->paginate(Base::getPaginate(100, 20));
$list->transform(function (ProjectLog $log) use ($task_id) {
$timestamp = Carbon::parse($log->created_at)->timestamp;
if ($task_id === 0) {

View File

@@ -6,8 +6,10 @@ use App\Exceptions\ApiException;
use App\Models\AbstractModel;
use App\Models\ProjectTask;
use App\Models\Report;
use App\Models\ReportLink;
use App\Models\ReportReceive;
use App\Models\User;
use App\Models\WebSocketDialogMsg;
use App\Module\Base;
use App\Module\Doo;
use App\Tasks\PushTask;
@@ -28,11 +30,13 @@ class ReportController extends AbstractController
/**
* @api {get} api/report/my 01. 我发送的汇报
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup report
* @apiName my
*
* @apiParam {Object} [keys] 搜索条件
* - keys.key: 关键词
* - keys.type: 汇报类型weekly:周报daily:日报
* - keys.created_at: 汇报时间
* @apiParam {Number} [page] 当前页,默认:1
@@ -49,6 +53,15 @@ class ReportController extends AbstractController
$builder = Report::with(['receivesUser'])->whereUserid($user->userid);
$keys = Request::input('keys');
if (is_array($keys)) {
if ($keys['key']) {
if (str_contains($keys['key'], '@')) {
$builder->whereHas('sendUser', function ($q2) use ($keys) {
$q2->where("users.email", "LIKE", "%{$keys['key']}%");
});
} else {
$builder->where("title", "LIKE", "%{$keys['key']}%");
}
}
if (in_array($keys['type'], [Report::WEEKLY, Report::DAILY])) {
$builder->whereType($keys['type']);
}
@@ -64,13 +77,16 @@ class ReportController extends AbstractController
/**
* @api {get} api/report/receive 02. 我接收的汇报
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup report
* @apiName receive
*
* @apiParam {Object} [keys] 搜索条件
* - keys.key: 关键词
* - keys.department_id: 部门ID
* - keys.type: 汇报类型weekly:周报daily:日报
* - keys.status: 状态unread:未读read:已读
* - keys.created_at: 汇报时间
* @apiParam {Number} [page] 当前页,默认:1
* @apiParam {Number} [pagesize] 每页显示数量,默认:20最大:50
@@ -89,15 +105,29 @@ class ReportController extends AbstractController
$keys = Request::input('keys');
if (is_array($keys)) {
if ($keys['key']) {
$builder->where(function($query) use ($keys) {
$query->whereHas('sendUser', function ($q2) use ($keys) {
if (str_contains($keys['key'], '@')) {
$builder->whereHas('sendUser', function ($q2) use ($keys) {
$q2->where("users.email", "LIKE", "%{$keys['key']}%");
})->orWhere("title", "LIKE", "%{$keys['key']}%");
});
} elseif (Base::isNumber($keys['key'])) {
$builder->where("userid", intval($keys['key']));
} else {
$builder->where("title", "LIKE", "%{$keys['key']}%");
}
}
if ($keys['department_id']) {
$builder->whereHas('sendUser', function ($query) use ($keys) {
$query->where("users.department", "LIKE", "%,{$keys['department_id']},%");
});
}
if (in_array($keys['type'], [Report::WEEKLY, Report::DAILY])) {
$builder->whereType($keys['type']);
}
if (in_array($keys['status'], ['unread', 'read'])) {
$builder->whereHas("receivesUser", function ($query) use ($user, $keys) {
$query->where("report_receives.userid", $user->userid)->where("report_receives.read", $keys['status'] === 'unread' ? 0 : 1);
});
}
if (is_array($keys['created_at'])) {
if ($keys['created_at'][0] > 0) $builder->where('created_at', '>=', Base::newCarbon($keys['created_at'][0])->startOfDay());
if ($keys['created_at'][1] > 0) $builder->where('created_at', '<=', Base::newCarbon($keys['created_at'][1])->endOfDay());
@@ -115,6 +145,7 @@ class ReportController extends AbstractController
/**
* @api {get} api/report/store 03. 保存并发送工作汇报
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup report
* @apiName store
@@ -190,7 +221,6 @@ class ReportController extends AbstractController
$report->updateInstance([
"title" => $input["title"],
"type" => $input["type"],
"content" => htmlspecialchars($input["content"]),
]);
} else {
// 生成唯一标识
@@ -204,11 +234,25 @@ class ReportController extends AbstractController
"title" => $input["title"],
"type" => $input["type"],
"userid" => $user->userid,
"content" => htmlspecialchars($input["content"]),
]);
}
$report->save();
// 保存内容
$content = $input["content"];
preg_match_all("/<img\s+src=\"data:image\/(png|jpg|jpeg|webp);base64,(.*?)\"/s", $content, $matchs);
foreach ($matchs[2] as $key => $text) {
$tmpPath = "uploads/report/" . Carbon::parse($report->created_at)->format("Ym") . "/" . $report->id . "/attached/";
Base::makeDir(public_path($tmpPath));
$tmpPath .= md5($text) . "." . $matchs[1][$key];
if (Base::saveContentImage(public_path($tmpPath), base64_decode($text))) {
$paramet = getimagesize(public_path($tmpPath));
$content = str_replace($matchs[0][$key], '<img src="' . Base::fillUrl($tmpPath) . '" original-width="' . $paramet[0] . '" original-height="' . $paramet[1] . '"', $content);
}
}
$report->content = htmlspecialchars($content);
$report->save();
// 删除关联
$report->Receives()->delete();
if ($input["receive_content"]) {
@@ -240,6 +284,7 @@ class ReportController extends AbstractController
/**
* @api {get} api/report/template 04. 生成汇报模板
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup report
* @apiName template
@@ -411,11 +456,13 @@ class ReportController extends AbstractController
/**
* @api {get} api/report/detail 05. 报告详情
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup report
* @apiName detail
*
* @apiParam {Number} [id] 报告id
* @apiParam {Number} [id] 报告ID
* @apiParam {String} [code] 报告分享代码与ID二选一优先ID
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@@ -424,30 +471,43 @@ class ReportController extends AbstractController
public function detail(): array
{
$user = User::auth();
//
$id = intval(trim(Request::input("id")));
if (empty($id))
$code = trim(Request::input("code"));
//
if (empty($id) && empty($code)) {
return Base::retError("缺少ID参数");
$one = Report::getOne($id);
$one->type_val = $one->getRawOriginal("type");
// 标记为已读
if (!empty($one->receivesUser)) {
foreach ($one->receivesUser as $item) {
if ($item->userid === $user->userid && $item->pivot->read === 0) {
$one->receivesUser()->updateExistingPivot($user->userid, [
"read" => 1,
]);
}
//
if (!empty($id)) {
$one = Report::getOne($id);
$one->type_val = $one->getRawOriginal("type");
// 标记为已读
if (!empty($one->receivesUser)) {
foreach ($one->receivesUser as $item) {
if ($item->userid === $user->userid && $item->pivot->read === 0) {
$one->receivesUser()->updateExistingPivot($user->userid, [
"read" => 1,
]);
}
}
}
} else {
$link = ReportLink::whereCode($code)->first();
if (empty($link)) {
return Base::retError("报告不存在或已被删除");
}
$one = Report::getOne($link->rid);
$one->report_link = $link;
$link->increment("num");
}
return Base::retSuccess("success", $one);
}
/**
* @api {get} api/report/mark 06. 标记已读/未读
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup report
* @apiName mark
@@ -488,8 +548,71 @@ class ReportController extends AbstractController
}
/**
* @api {get} api/report/last_submitter 07. 获取最后一次提交的接收人
* @api {get} api/report/share 07. 分享报告到消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup report
* @apiName share
*
* @apiParam {Number} id 报告id
* @apiParam {Array} dialogids 转发给的对话ID
* @apiParam {Array} userids 转发给的成员ID
* @apiParam {String} leave_message 转发留言
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function share()
{
$user = User::auth();
//
$id = Request::input('id');
$dialogids = Request::input('dialogids');
$userids = Request::input('userids');
$leave_message = Request::input('leave_message');
//
if (is_array($id)) {
if (count(Base::arrayRetainInt($id)) > 20) {
return Base::retError("最多只能操作20条数据");
}
$builder = Report::whereIn("id", Base::arrayRetainInt($id));
} else {
$builder = Report::whereId(intval($id));
}
$reportMsgs = [];
$builder ->chunkById(100, function ($list) use (&$reportMsgs, $user) {
/** @var Report $item */
foreach ($list as $item) {
$reportLink = ReportLink::generateLink($item->id, $user->userid);
$reportMsgs[] = "<a class=\"mention report\" href=\"{{RemoteURL}}single/report/detail/{$reportLink['code']}\" target=\"_blank\">%{$item->title}</a>";
}
});
if (empty($reportMsgs)) {
return Base::retError("报告不存在或已被删除");
}
$reportTag = count($reportMsgs) > 1 ? 'li' : 'p';
$reportAttr = $reportTag === 'li' ? ' data-list="ordered"' : '';
$reportMsgs = array_map(function ($item) use ($reportAttr, $reportTag) {
return "<{$reportTag}{$reportAttr}>{$item}</{$reportTag}>";
}, $reportMsgs);
if ($reportTag === 'li') {
array_unshift($reportMsgs, "<ol>");
$reportMsgs[] = "</ol>";
}
if ($leave_message) {
$reportMsgs[] = "<p>{$leave_message}</p>";
}
$msgText = implode("", $reportMsgs);
//
return WebSocketDialogMsg::sendMsgBatch($user, $userids, $dialogids, $msgText);
}
/**
* @api {get} api/report/last_submitter 08. 获取最后一次提交的接收人
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup report
* @apiName last_submitter
@@ -505,8 +628,9 @@ class ReportController extends AbstractController
}
/**
* @api {get} api/report/unread 08. 获取未读
* @api {get} api/report/unread 09. 获取未读
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup report
* @apiName unread
@@ -529,8 +653,9 @@ class ReportController extends AbstractController
}
/**
* @api {get} api/report/read 09. 标记汇报已读,可批量
* @api {get} api/report/read 10. 标记汇报已读,可批量
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup report
* @apiName read

View File

@@ -2,8 +2,7 @@
namespace App\Http\Controllers\Api;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogMsg;
use App\Models\UserDevice;
use Request;
use Session;
use Response;
@@ -41,7 +40,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', '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', 'image_compress', 'image_quality', 'image_save_local', 'start_home']
* - 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', 'start_home']
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@@ -70,7 +69,11 @@ class SystemController extends AbstractController
'anon_message',
'voice2text',
'translation',
'convert_video',
'compress_video',
'e2e_message',
'msg_rev_limit',
'msg_edit_limit',
'auto_archived',
'archived_day',
'task_visible',
@@ -80,6 +83,7 @@ class SystemController extends AbstractController
'user_private_chat_mute',
'user_group_chat_mute',
'system_alias',
'system_welcome',
'image_compress',
'image_quality',
'image_save_local',
@@ -108,6 +112,9 @@ class SystemController extends AbstractController
if ($all['system_alias'] == env('APP_NAME')) {
$all['system_alias'] = '';
}
if ($all['system_welcome'] == '欢迎您,{username}') {
$all['system_welcome'] = '';
}
$setting = Base::setting('system', Base::newTrim($all));
} else {
$setting = Base::setting('system');
@@ -130,7 +137,11 @@ class SystemController extends AbstractController
$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';
$setting['msg_rev_limit'] = $setting['msg_rev_limit'] ?: '';
$setting['msg_edit_limit'] = $setting['msg_edit_limit'] ?: '';
$setting['auto_archived'] = $setting['auto_archived'] ?: 'close';
$setting['archived_day'] = floatval($setting['archived_day']) ?: 7;
$setting['task_visible'] = $setting['task_visible'] ?: 'close';
@@ -283,6 +294,8 @@ class SystemController extends AbstractController
* @apiParam {String} type
* - get: 获取(默认)
* - save: 保存设置(参数:[...]
* @apiParam {String} filter 过滤字段(可选)
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
@@ -292,6 +305,7 @@ class SystemController extends AbstractController
User::auth('admin');
//
$type = trim(Request::input('type'));
$filter = trim(Request::input('filter'));
$setting = Base::setting('aibotSetting');
if ($type == 'save') {
if (env("SYSTEM_SETTING") == 'disabled') {
@@ -306,10 +320,18 @@ class SystemController extends AbstractController
}
$setting = Base::setting('aibotSetting', Base::newTrim($setting));
}
if ($filter) {
$setting = array_filter($setting, function($value, $key) use ($filter) {
return str_starts_with($key, $filter);
}, ARRAY_FILTER_USE_BOTH);
}
//
if (env("SYSTEM_SETTING") == 'disabled') {
foreach ($setting as $key => $item) {
if (str_contains($key, '_key')) {
if (empty($item)) {
continue;
}
if (str_ends_with($key, '_key') || str_ends_with($key, '_secret')) {
$setting[$key] = substr($item, 0, 4) . str_repeat('*', strlen($item) - 8) . substr($item, -4);
}
}
@@ -319,7 +341,66 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/setting/checkin 05. 获取签到设置、保存签到设置(限管理员)
* @api {get} api/system/setting/aibot_models 05. 获取AI模型
*
* @apiDescription 获取所有AI机器人模型设置
* @apiVersion 1.0.0
* @apiGroup system
* @apiName aibot_models
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
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('{}'));
}
/**
* @api {get} api/system/setting/aibot_defmodels 06. 获取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 返回数据
*/
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 Extranet::ollamaModels($baseUrl, $key, $agency);
}
$models = Setting::AIDefaultModels($type);
if (empty($models)) {
return Base::retError('未找到默认模型');
}
return Base::retSuccess('success', [
'models' => $models
]);
}
/**
* @api {get} api/system/setting/checkin 07. 获取签到设置、保存签到设置(限管理员)
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -425,7 +506,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/setting/apppush 06. 获取APP推送设置、保存APP推送设置限管理员
* @api {get} api/system/setting/apppush 08. 获取APP推送设置、保存APP推送设置限管理员
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -470,7 +551,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/setting/thirdaccess 07. 第三方帐号(限管理员)
* @api {get} api/system/setting/thirdaccess 09. 第三方帐号(限管理员)
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -540,7 +621,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/setting/file 08. 文件设置(限管理员)
* @api {get} api/system/setting/file 10. 文件设置(限管理员)
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -580,7 +661,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/demo 09. 获取演示帐号
* @api {get} api/system/demo 11. 获取演示帐号
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -604,7 +685,7 @@ class SystemController extends AbstractController
}
/**
* @api {post} api/system/priority 10. 任务优先级
* @api {post} api/system/priority 12. 任务优先级
*
* @apiDescription 获取任务优先级、保存任务优先级
* @apiVersion 1.0.0
@@ -653,7 +734,7 @@ class SystemController extends AbstractController
}
/**
* @api {post} api/system/column/template 11. 创建项目模板
* @api {post} api/system/column/template 13. 创建项目模板
*
* @apiDescription 获取创建项目模板、保存创建项目模板
* @apiVersion 1.0.0
@@ -700,7 +781,7 @@ class SystemController extends AbstractController
}
/**
* @api {post} api/system/license 12. License
* @api {post} api/system/license 14. License
*
* @apiDescription 获取License信息、保存License限管理员
* @apiVersion 1.0.0
@@ -769,7 +850,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/get/info 13. 获取终端详细信息
* @api {get} api/system/get/info 15. 获取终端详细信息
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -798,7 +879,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/get/ip 14. 获取IP地址
* @api {get} api/system/get/ip 16. 获取IP地址
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -813,7 +894,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/get/cnip 15. 是否中国IP地址
* @api {get} api/system/get/cnip 17. 是否中国IP地址
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -830,7 +911,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/get/ipgcj02 16. 获取IP地址经纬度
* @api {get} api/system/get/ipgcj02 18. 获取IP地址经纬度
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -847,7 +928,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/get/ipinfo 17. 获取IP地址详细信息
* @api {get} api/system/get/ipinfo 19. 获取IP地址详细信息
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -864,7 +945,7 @@ class SystemController extends AbstractController
}
/**
* @api {post} api/system/imgupload 18. 上传图片
* @api {post} api/system/imgupload 20. 上传图片
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -873,7 +954,7 @@ class SystemController extends AbstractController
*
* @apiParam {File} image post-图片对象
* @apiParam {String} [image64] post-图片base64与'image'二选一)
* @apiParam {String} filename post-文件名
* @apiParam {String} [filename] post-文件名
* @apiParam {Number} [width] 压缩图片宽默认0
* @apiParam {Number} [height] 压缩图片高默认0
* @apiParam {String} [whcut] 压缩方式(等比缩放)
@@ -930,7 +1011,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/get/imgview 19. 浏览图片空间
* @api {get} api/system/get/imgview 21. 浏览图片空间
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1027,16 +1108,16 @@ class SystemController extends AbstractController
}
/**
* @api {post} api/system/fileupload 20. 上传文件
* @api {post} api/system/fileupload 22. 上传文件
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup system
* @apiName fileupload
*
* @apiParam {String} [image64] 图片base64
* @apiParam {String} filename 文件名
* @apiParam {String} [files] 文件名
* @apiParam {File} files 文件名
* @apiParam {String} [image64] 图片base64与'files'二选一)
* @apiParam {String} [filename] 文件名
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@@ -1071,7 +1152,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/get/updatelog 21. 获取更新日志
* @api {get} api/system/get/updatelog 23. 获取更新日志
*
* @apiDescription 获取更新日志
* @apiVersion 1.0.0
@@ -1104,7 +1185,7 @@ class SystemController extends AbstractController
if ($logResults) {
$logVersion = $logResults[0]['title'];
$logContent = implode("\n", array_map(function($item) {
return "## [{$item['title']}]" . $item['content'];
return "## {$item['title']}" . $item['content'];
}, $logResults));
}
return Base::retSuccess('success', [
@@ -1114,7 +1195,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/email/check 22. 邮件发送测试(限管理员)
* @api {get} api/system/email/check 24. 邮件发送测试(限管理员)
*
* @apiDescription 测试配置邮箱是否能发送邮件
* @apiVersion 1.0.0
@@ -1160,7 +1241,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/checkin/export 23. 导出签到数据(限管理员)
* @api {get} api/system/checkin/export 25. 导出签到数据(限管理员)
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -1329,7 +1410,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/checkin/down 24. 下载导出的签到数据
* @api {get} api/system/checkin/down 26. 下载导出的签到数据
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -1355,7 +1436,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/version 25. 获取版本号
* @api {get} api/system/version 27. 获取版本号
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -1363,23 +1444,29 @@ class SystemController extends AbstractController
*
* @apiSuccessExample {json} Success-Response:
{
"version": "0.0.1",
"device_count": 3, // 设备数量
"version": "0.0.1", // 服务端版本号
"publish": {
"provider": "generic",
"url": ""
}
}
// 如果header请求中存在version字段则返回数据包裹在 {ret:1,data:{},msg:"success"} 中
*/
public function version()
{
$url = url('');
$package = Base::getPackage();
$array = [
'device_count' => 0,
'version' => Base::getVersion(),
'publish' => [],
];
if (Doo::userId()) {
$array['device_count'] = UserDevice::whereUserid(Doo::userId())->count();
}
if (is_array($package['app'])) {
$i = 0;
$url = url('');
foreach ($package['app'] as $item) {
$urls = $item['urls'] && is_array($item['urls']) ? $item['urls'] : $item['url'];
if (is_array($item['publish']) && ($i === 0 || Base::hostContrast($url, $urls))) {
@@ -1388,11 +1475,14 @@ class SystemController extends AbstractController
$i++;
}
}
if (Request::hasHeader('version')) {
return Base::retSuccess('success', $array);
}
return $array;
}
/**
* @api {get} api/system/prefetch 26. 预加载的资源
* @api {get} api/system/prefetch 28. 预加载的资源
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -1432,7 +1522,7 @@ class SystemController extends AbstractController
}
// 添加office资源
$officePath = '';
$officeApi = 'http://' . env('APP_IPPR') . '.6/web-apps/apps/api/documents/api.js';
$officeApi = 'http://office/web-apps/apps/api/documents/api.js';
$content = @file_get_contents($officeApi);
if ($content) {
if (preg_match("/const\s+ver\s*=\s*'\/*([^']+)'/", $content, $matches)) {
@@ -1451,6 +1541,16 @@ class SystemController extends AbstractController
return !str_starts_with($item, 'office/{path}/');
});
}
// 添加OKR资源
$okrContent = @file_get_contents("http://nginx/apps/okr/");
preg_match_all('/<script[^>]*src=["\']([^"\']+)["\'][^>]*>/i', $okrContent, $scriptMatches);
foreach ($scriptMatches[1] as $src) {
$array[] = $src;
}
preg_match_all('/<link[^>]*rel=["\']stylesheet["\'][^>]*href=["\']([^"\']+)["\'][^>]*>/i', $okrContent, $linkMatches);
foreach ($linkMatches[1] as $href) {
$array[] = $href;
}
}
return array_map(function($item) use ($version) {

View File

@@ -19,6 +19,7 @@ use App\Models\UserBot;
use App\Models\WebSocket;
use App\Models\UmengAlias;
use App\Models\UserDelete;
use App\Models\UserDevice;
use App\Models\UserTransfer;
use App\Models\AbstractModel;
use App\Models\UserCheckinFace;
@@ -267,7 +268,23 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/reg/needinvite 06. 是否需要邀请码
* @api {get} api/users/logout 06. 退出登录
*
* @apiVersion 1.0.0
* @apiGroup users
* @apiName logout
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
*/
public function logout()
{
UserDevice::forget();
return Base::retSuccess('退出成功');
}
/**
* @api {get} api/users/reg/needinvite 07. 是否需要邀请码
*
* @apiDescription 用于判断注册是否需要邀请码
* @apiVersion 1.0.0
@@ -286,7 +303,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/info 07. 获取我的信息
* @api {get} api/users/info 08. 获取我的信息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -336,7 +353,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/editdata 08. 修改自己的资料
* @api {get} api/users/editdata 09. 修改自己的资料
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -428,7 +445,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/editpass 09. 修改自己的密码
* @api {get} api/users/editpass 10. 修改自己的密码
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -469,7 +486,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/search 10. 搜索会员列表
* @api {get} api/users/search 11. 搜索会员列表
*
* @apiDescription 搜索会员列表
* @apiVersion 1.0.0
@@ -611,7 +628,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/search/ai 11. 获取AI机器人
* @api {get} api/users/search/ai 12. 获取AI机器人
*
* @apiDescription 搜索会员列表
* @apiVersion 1.0.0
@@ -630,7 +647,7 @@ class UsersController extends AbstractController
//
$type = trim(Request::input('type'));
$botName = "ai-{$type}";
if (!UserBot::isAiBot("{$botName}@bot.system")) {
if (!UserBot::systemBotName($botName)) {
return Base::retError('AI机器人不存在');
}
//
@@ -642,7 +659,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/basic 12. 获取指定会员基础信息
* @api {get} api/users/basic 13. 获取指定会员基础信息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -685,7 +702,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/lists 13. 会员列表(限管理员)
* @api {get} api/users/lists 14. 会员列表(限管理员)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -834,7 +851,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/operation 14. 操作会员(限管理员)
* @api {get} api/users/operation 15. 操作会员(限管理员)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1092,7 +1109,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/email/verification 15. 邮箱验证
* @api {get} api/users/email/verification 16. 邮箱验证
*
* @apiDescription 不需要token身份
* @apiVersion 1.0.0
@@ -1140,7 +1157,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/umeng/alias 16. 设置友盟别名
* @api {get} api/users/umeng/alias 17. 设置友盟别名
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1154,6 +1171,7 @@ class UsersController extends AbstractController
* @apiParam {String} [userAgent] 浏览器信息
* @apiParam {String} [deviceModel] 设备型号
* @apiParam {String} [isNotified] 是否有通知权限0不通知、1通知
* @apiParam {Number} [isDebug] 是否调试0不调试、1调试
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@@ -1162,6 +1180,10 @@ class UsersController extends AbstractController
public function umeng__alias()
{
$data = Request::input();
// 判断是否调试
if (intval($data['isDebug'])) {
return Base::retError('调试模式下不允许使用');
}
// 表单验证
Base::validator($data, [
'alias.required' => '别名不能为空',
@@ -1212,7 +1234,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/meeting/open 17. 【会议】创建会议、加入会议
* @api {get} api/users/meeting/open 18. 【会议】创建会议、加入会议
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1330,7 +1352,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/meeting/link 18. 【会议】获取分享链接
* @api {get} api/users/meeting/link 19. 【会议】获取分享链接
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1359,7 +1381,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/meeting/tourist 19. 【会议】游客信息
* @api {get} api/users/meeting/tourist 20. 【会议】游客信息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1382,7 +1404,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/meeting/invitation 20. 【会议】发送邀请
* @api {get} api/users/meeting/invitation 21. 【会议】发送邀请
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1429,7 +1451,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/email/send 21. 发送邮箱验证码
* @api {get} api/users/email/send 22. 发送邮箱验证码
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1469,7 +1491,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/email/edit 22. 修改邮箱
* @api {get} api/users/email/edit 23. 修改邮箱
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1514,7 +1536,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/delete/account 23. 删除帐号
* @api {get} api/users/delete/account 24. 删除帐号
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1576,7 +1598,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/department/list 24. 部门列表(限管理员)
* @api {get} api/users/department/list 25. 部门列表(限管理员)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1595,7 +1617,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/department/add 25. 新建、修改部门(限管理员)
* @api {get} api/users/department/add 26. 新建、修改部门(限管理员)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1653,19 +1675,22 @@ class UsersController extends AbstractController
if (empty($parentDepartment)) {
return Base::retError('上级部门不存在或已被删除');
}
if ($parentDepartment->parent_id > 0) {
return Base::retError('上级部门层级错误');
if (count($parentDepartment->parents()) > 2) {
return Base::retError('部门层级最多只能创建3级');
}
if (UserDepartment::whereParentId($parent_id)->count() > 20) {
if ($id > 0 && UserDepartment::whereParentId($id)->whereId($parent_id)->exists()) {
return Base::retError('不能选择自己的子部门作为上级部门');
}
if (UserDepartment::whereParentId($parent_id)->count() >= 20) {
return Base::retError('每个部门最多只能创建20个子部门');
}
if ($id > 0 && UserDepartment::whereParentId($id)->exists()) {
return Base::retError('含有子部门无法修改上级部门');
}
}
if (empty($owner_userid) || !User::whereUserid($owner_userid)->exists()) {
return Base::retError('请选择正确的部门负责人');
}
if (UserDepartment::whereOwnerUserid($owner_userid)->count() >= 10) {
return Base::retError('每个用户最多只能负责10个部门');
}
//
$userDepartment->saveDepartment([
'name' => $name,
@@ -1674,11 +1699,11 @@ class UsersController extends AbstractController
], $dialog_useid);
Cache::forever("UserDepartment::rand", Base::generatePassword());
//
return Base::retSuccess($parent_id > 0 ? '保存成功' : '新建成功');
return Base::retSuccess($id > 0 ? '保存成功' : '新建成功');
}
/**
* @api {get} api/users/department/del 26. 删除部门(限管理员)
* @api {get} api/users/department/del 27. 删除部门(限管理员)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1701,6 +1726,9 @@ class UsersController extends AbstractController
if (empty($userDepartment)) {
return Base::retError('部门不存在或已被删除');
}
if (UserDepartment::whereParentId($id)->exists()) {
return Base::retError('含有子部门无法删除');
}
$userDepartment->deleteDepartment();
Cache::forever("UserDepartment::rand", Base::generatePassword());
//
@@ -1708,7 +1736,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/checkin/get 27. 获取签到设置
* @api {get} api/users/checkin/get 28. 获取签到设置
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1735,7 +1763,7 @@ class UsersController extends AbstractController
}
/**
* @api {post} api/users/checkin/save 28. 保存签到设置
* @api {post} api/users/checkin/save 29. 保存签到设置
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1810,7 +1838,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/checkin/list 29. 获取签到数据
* @api {get} api/users/checkin/list 30. 获取签到数据
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1857,7 +1885,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/socket/status 30. 获取socket状态
* @api {get} api/users/socket/status 31. 获取socket状态
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1880,7 +1908,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/key/client 31. 客户端KEY
* @api {get} api/users/key/client 32. 客户端KEY
*
* @apiDescription 获取客户端KEY用于加密数据发送给服务端
* @apiVersion 1.0.0
@@ -1922,7 +1950,51 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/bot/info 32. 机器人信息
* @api {get} api/users/bot/list 33. 机器人列表
*
* @apiDescription 需要token身份获取我的机器人列表
* @apiVersion 1.0.0
* @apiGroup users
* @apiName bot__list
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function bot__list()
{
// 获取当前认证用户
$user = User::auth();
// 使用连表查询一次性获取所有机器人数据
$bots = User::join('user_bots', 'user_bots.bot_id', '=', 'users.userid')
->where('user_bots.userid', $user->userid)
->select([
'users.userid',
'users.nickname',
'users.userimg',
'user_bots.clear_day',
'user_bots.webhook_url'
])
->orderByDesc('id')
->get()
->toArray();
foreach ($bots as &$bot) {
$bot['id'] = $bot['userid'];
$bot['name'] = $bot['nickname'];
$bot['avatar'] = $bot['userimg'];
$bot['system_name'] = UserBot::systemBotName($bot['name']);
unset($bot['userid'], $bot['nickname'], $bot['userimg']);
}
// 返回成功响应将机器人列表包装在list字段中
return Base::retSuccess('success', [
'list' => $bots
]);
}
/**
* @api {get} api/users/bot/info 34. 机器人信息
*
* @apiDescription 需要token身份获取我的机器人信息
* @apiVersion 1.0.0
@@ -1973,14 +2045,14 @@ class UsersController extends AbstractController
}
/**
* @api {post} api/users/bot/edit 33. 编辑机器人
* @api {post} api/users/bot/edit 35. 添加、编辑机器人
*
* @apiDescription 需要token身份编辑 我的机器人 或 管理员修改系统机器人 信息
* @apiVersion 1.0.0
* @apiGroup users
* @apiName bot__edit
*
* @apiParam {Number} id 机器人ID
* @apiParam {Number} [id] 机器人ID(编辑时必填,留空为添加)
* @apiParam {String} [name] 机器人名称
* @apiParam {String} [avatar] 机器人头像
* @apiParam {Number} [clear_day] 清理天数(仅 我的机器人)
@@ -1995,10 +2067,19 @@ class UsersController extends AbstractController
$user = User::auth();
//
$botId = intval(Request::input('id'));
$botUser = User::whereUserid($botId)->whereBot(1)->first();
if (empty($botUser)) {
return Base::retError('机器人不存在');
if (empty($botId)) {
$res = UserBot::newbot($user->userid, trim(Request::input('name')));
if (Base::isError($res)) {
return $res;
}
$botUser = $res['data'];
} else {
$botUser = User::whereUserid($botId)->whereBot(1)->first();
if (empty($botUser)) {
return Base::retError('机器人不存在');
}
}
//
$userBot = UserBot::whereBotId($botUser->userid)->whereUserid($user->userid)->first();
if (empty($userBot)) {
if (UserBot::systemBotName($botUser->email)) {
@@ -2055,17 +2136,68 @@ class UsersController extends AbstractController
$data['clear_day'] = $userBot->clear_day;
$data['webhook_url'] = $userBot->webhook_url;
}
return Base::retSuccess('修改成功', $data);
return Base::retSuccess($botId ? '修改成功' : '添加成功', $data);
}
/**
* @api {get} api/users/share/list 34. 获取分享列表
* @api {get} api/users/bot/delete 36. 删除机器人
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup users
* @apiName bot__delete
*
* @apiParam {Number} id 机器人ID
* @apiParam {String} remark 删除备注
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function bot__delete()
{
$user = User::auth();
//
$botId = intval(Request::input('id'));
$remark = trim(Request::input('remark'));
//
if (empty($remark)) {
return Base::retError('请输入删除备注');
}
if (mb_strlen($remark) > 255) {
return Base::retError('删除备注长度限制255个字');
}
//
$botUser = User::whereUserid($botId)->whereBot(1)->first();
if (empty($botUser)) {
return Base::retError('机器人不存在');
}
$userBot = UserBot::whereBotId($botUser->userid)->whereUserid($user->userid)->first();
if (empty($userBot)) {
if (UserBot::systemBotName($botUser->email)) {
// 系统机器人(仅限管理员)
return Base::retError('系统机器人不能删除');
} else {
// 其他用户的机器人(仅限主人)
return Base::retError('不是你的机器人');
}
}
//
if (!$botUser->deleteUser($remark)) {
return Base::retError('删除失败');
}
return Base::retSuccess('删除成功');
}
/**
* @api {get} api/users/share/list 37. 获取分享列表
*
* @apiVersion 1.0.0
* @apiGroup users
* @apiName share__list
*
* @apiParam {String} [type] 分享类型file-文件text-列表 默认file
* @apiParam {String} [key] 搜索关键词(用于搜索会话)
* @apiParam {Number} [pid] 父级文件id用于获取子目录和上传到指定目录的id
* @apiParam {Number} [upload_file_id] 上传文件id
*
@@ -2077,6 +2209,7 @@ class UsersController extends AbstractController
{
$user = User::auth();
$type = Request::input('type', 'file');
$key = Request::input('key');
$pid = intval(Request::input('pid', -1));
$uploadFileId = intval(Request::input('upload_file_id', -1));
// 上传文件
@@ -2109,10 +2242,14 @@ class UsersController extends AbstractController
'icon' => url("images/file/light/folder.png"),
'extend' => ['upload_file_id' => 0],
'name' => Doo::translate('文件'),
'sort' => Carbon::parse("9999")->timestamp,
];
}
$dialogList = WebSocketDialog::getDialogList($user->userid);
foreach ($dialogList['data'] as $dialog) {
$dialogTake = 50;
$dialogList = WebSocketDialog::searchDialog($user->userid, $key, $dialogTake);
$dialogIds = [];
$itemUrl = $type == "file" ? Base::fillUrl("api/dialog/msg/sendfiles") : Base::fillUrl("api/dialog/msg/sendtext");
foreach ($dialogList as $dialog) {
if ($dialog['avatar']) {
$avatar = url($dialog['avatar']);
} else if ($dialog['type'] == 'user') {
@@ -2129,7 +2266,8 @@ class UsersController extends AbstractController
'type' => 'item',
'name' => $dialog['name'],
'icon' => $avatar,
'url' => $type == "file" ? Base::fillUrl("api/dialog/msg/sendfiles") : Base::fillUrl("api/dialog/msg/sendtext"),
'url' => $itemUrl,
'sort' => Carbon::parse($dialog['last_at'])->timestamp,
'extend' => [
'dialog_ids' => $dialog['id'],
'text_type' => 'text',
@@ -2137,6 +2275,33 @@ class UsersController extends AbstractController
'silence' => 'no'
]
];
$dialogIds[] = $dialog['id'];
}
if ($key && count($dialogList) < $dialogTake) {
$dialogUsers = User::searchUser($key, $dialogTake - count($dialogList));
foreach ($dialogUsers as $item) {
$dialog = WebSocketDialog::getUserDialog($user->userid, $item->userid, now()->addDay());
if ($dialog && !in_array($dialog->id, $dialogIds)) {
$lists[] = [
'type' => 'item',
'name' => $item->nickname,
'icon' => $item->userimg,
'url' => $itemUrl,
'sort' => Carbon::parse($item->line_at)->timestamp,
'extend' => [
'dialog_ids' => $dialog->id,
'text_type' => 'text',
'reply_id' => 0,
'silence' => 'no'
]
];
$dialogIds[] = $dialog->id;
}
}
// 根据 $lists sort 从大到小排序
usort($lists, function ($a, $b) {
return $b['sort'] <=> $a['sort'];
});
}
}
// 返回
@@ -2144,7 +2309,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/annual/report 35. 年度报告
* @api {get} api/users/annual/report 38. 年度报告
*
* @apiVersion 1.0.0
* @apiGroup users
@@ -2311,4 +2476,58 @@ class UsersController extends AbstractController
//
return Base::retSuccess('success', $data);
}
/**
* @api {get} api/users/device/list 39. 获取设备列表
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup users
* @apiName device__list
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function device__list()
{
$user = User::auth();
//
$list = UserDevice::whereUserid($user->userid)->orderByDesc('id')->take(100)->get();
//
return Base::retSuccess('success', [
'list' => $list
]);
}
/**
* @api {get} api/users/device/logout 40. 登出设备(删除设备)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup users
* @apiName device__logout
*
* @apiParam {Number} id 设备id
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function device__logout()
{
$user = User::auth();
//
$id = intval(Request::input('id'));
if (empty($id)) {
return Base::retError('参数错误');
}
$userDevice = UserDevice::whereUserid($user->userid)->whereId($id)->first();
if (empty($userDevice)) {
return Base::retError('设备不存在或已被删除');
}
UserDevice::forget($userDevice->id);
//
return Base::retSuccess('操作成功');
}
}

View File

@@ -23,6 +23,7 @@ use App\Tasks\AutoArchivedTask;
use App\Tasks\DeleteBotMsgTask;
use App\Tasks\CheckinRemindTask;
use App\Tasks\CloseMeetingRoomTask;
use App\Tasks\ZincSearchSyncTask;
use App\Tasks\UnclaimedTaskRemindTask;
use Hhxsv5\LaravelS\Swoole\Task\Task;
use Laravolt\Avatar\Avatar;
@@ -241,11 +242,12 @@ class IndexController extends InvokeController
// App推送
Task::deliver(new AppPushTask());
// 删除过期的临时表数据
Task::deliver(new DeleteTmpTask('wg_tmp_msgs', 1));
Task::deliver(new DeleteTmpTask('task_worker', 12));
Task::deliver(new DeleteTmpTask('tmp_msgs', 1));
Task::deliver(new DeleteTmpTask('tmp'));
Task::deliver(new DeleteTmpTask('task_worker', 12));
Task::deliver(new DeleteTmpTask('file'));
Task::deliver(new DeleteTmpTask('tmp_file', 24));
Task::deliver(new DeleteTmpTask('user_device', 24));
// 删除机器人消息
Task::deliver(new DeleteBotMsgTask());
// 周期任务
@@ -258,6 +260,8 @@ class IndexController extends InvokeController
Task::deliver(new UnclaimedTaskRemindTask());
// 关闭会议室
Task::deliver(new CloseMeetingRoomTask());
// ZincSearch 同步
Task::deliver(new ZincSearchSyncTask());
return "success";
}
@@ -321,7 +325,7 @@ class IndexController extends InvokeController
"file" => Request::file('file'),
"type" => 'publish',
"path" => $draftPath,
"fileName" => true,
"saveName" => true,
]);
}
@@ -468,7 +472,7 @@ class IndexController extends InvokeController
action: "eeuiAppSendMessage",
data: [
{
action: 'setPageData',
action: 'setPageData', // 设置页面数据
data: {
showProgress: true,
titleFixed: true,
@@ -476,7 +480,7 @@ class IndexController extends InvokeController
}
},
{
action: 'createTarget',
action: 'createTarget', // 创建目标(访问新地址)
url: "{$redirectUrl}",
}
]
@@ -490,7 +494,7 @@ class IndexController extends InvokeController
if (in_array($ext, File::localExt)) {
$url = Base::fillUrl($path);
} else {
$url = 'http://' . env('APP_IPPR') . '.3/' . $path;
$url = 'http://nginx/' . $path;
}
$url = Base::urlAddparameter($url, [
'fullfilename' => Base::rightDelete($name, '.' . $ext) . '_' . filemtime($file) . '.' . $ext

View File

@@ -24,6 +24,9 @@ class WebApi
RequestContext::set('start_time', microtime(true));
RequestContext::set('header_language', $request->header('language'));
// 更新请求的基本URL
RequestContext::updateBaseUrl($request);
// 加载Doo类
Doo::load();

View File

@@ -210,8 +210,8 @@ class AbstractModel extends Model
/**
* 数据库更新或插入
* @param $where
* @param array $update 存在时更新的内容
* @param array $insert 不存在时插入的内容,如果没有则插入更新内容
* @param array|\Closure $update 存在时更新的内容
* @param array|\Closure $insert 不存在时插入的内容,如果没有则插入更新内容
* @param bool $isInsert 是否是插入数据
* @return AbstractModel|\Illuminate\Database\Eloquent\Builder|Model|object|static|null
*/
@@ -220,13 +220,25 @@ class AbstractModel extends Model
$row = static::where($where)->first();
if (empty($row)) {
$row = new static;
$array = array_merge($where, $insert ?: $update);
if ($insert instanceof \Closure) {
$insert = $insert();
}
if (empty($insert)) {
if ($update instanceof \Closure) {
$update = $update();
}
$insert = $update;
}
$array = array_merge($where, $insert);
if (isset($array[$row->primaryKey])) {
unset($array[$row->primaryKey]);
}
$row->updateInstance($array);
$isInsert = true;
} elseif ($update) {
if ($update instanceof \Closure) {
$update = $update();
}
$row->updateInstance($update);
$isInsert = false;
}

View File

@@ -6,6 +6,60 @@ use Cache;
use Carbon\Carbon;
use DB;
/**
* App\Models\ApproveProcInstHistory
*
* @property int $id
* @property int $proc_def_id 流程定义ID
* @property string|null $proc_def_name 流程定义名
* @property string|null $title 标题
* @property int|null $department_id 用户部门ID
* @property string|null $department 用户部门
* @property string|null $company 用户公司
* @property string|null $node_id 当前节点
* @property string|null $candidate 审批人
* @property int|null $task_id 当前任务
* @property string|null $start_time 开始时间
* @property string|null $end_time 结束时间
* @property int|null $duration 持续时间
* @property string|null $start_user_id 开始用户ID
* @property string|null $start_user_name 开始用户名
* @property int|null $is_finished 是否完成
* @property string|null $var
* @property int $state 当前状态: 0待审批1审批中2通过3拒绝4撤回
* @property string|null $latest_comment
* @property string|null $global_comment
* @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|ApproveProcInstHistory newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereCandidate($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereCompany($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereDepartment($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereDepartmentId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereDuration($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereEndTime($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereGlobalComment($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereIsFinished($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereLatestComment($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereNodeId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereProcDefId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereProcDefName($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereStartTime($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereStartUserId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereStartUserName($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereState($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereTaskId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereTitle($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereVar($value)
* @mixin \Eloquent
*/
class ApproveProcInstHistory extends AbstractModel
{
protected $table = 'approve_proc_inst_history';

View File

@@ -117,7 +117,7 @@ class File extends AbstractModel
'jpg', 'jpeg', 'webp', 'png', 'gif', 'bmp', 'ico', 'raw',
'tif', 'tiff',
'mp3', 'wav', 'mp4', 'flv',
'avi', 'mov', 'wmv', 'mkv', '3gp', 'rm',
// 'avi', 'mov', 'wmv', 'mkv', '3gp', 'rm', // 这一排是要转换的,无法使用本地播放
];
/**

View File

@@ -2,10 +2,10 @@
namespace App\Models;
use App\Module\Base;
use App\Module\Timer;
use Illuminate\Database\Eloquent\SoftDeletes;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* App\Models\FileContent
@@ -104,10 +104,10 @@ class FileContent extends AbstractModel
/**
* 获取格式内容(或下载)
* @param File $file
* @param $file
* @param $content
* @param $download
* @return array|\Symfony\Component\HttpFoundation\BinaryFileResponse
* @return array|StreamedResponse
*/
public static function formatContent($file, $content, $download = false)
{
@@ -119,7 +119,7 @@ class FileContent extends AbstractModel
} else {
$filePath = public_path($content['url']);
}
return Base::BinaryFileResponse($filePath, $name);
return Base::DownloadFileResponse($filePath, $name);
}
if (empty($content)) {
$content = match ($file->type) {
@@ -148,7 +148,7 @@ class FileContent extends AbstractModel
if ($download) {
$filePath = public_path($path);
if (isset($filePath)) {
return Base::BinaryFileResponse($filePath, $name);
return Base::DownloadFileResponse($filePath, $name);
} else {
abort(403, "This file not support download.");
}
@@ -156,4 +156,28 @@ class FileContent extends AbstractModel
}
return Base::retSuccess('success', [ 'content' => $content ]);
}
/**
* 获取文件内容
* @param $id
* @return self|null
*/
public static function idOrCodeToContent($id)
{
$builder = null;
if (Base::isNumber($id)) {
$builder = FileContent::whereFid($id);
} elseif ($id) {
$fileLink = FileLink::whereCode($id)->first();
if ($fileLink) {
$builder = FileContent::whereFid($fileLink->file_id);
}
}
/** @var self $fileContent */
$fileContent = $builder?->orderByDesc('id')->first();
if ($fileContent) {
$fileContent->content = Base::json2array($fileContent->content ?: []);
}
return $fileContent;
}
}

View File

@@ -219,7 +219,12 @@ class Project extends AbstractModel
'userid' => $userid,
], [
'important' => 1
]);
], function () use ($userid) {
return [
'important' => 1,
'bot' => User::isBot($userid) ? 1 : 0,
];
});
}
WebSocketDialogUser::whereDialogId($this->dialog_id)->whereNotIn('userid', $userids)->whereImportant(1)->remove();
});
@@ -415,6 +420,7 @@ class Project extends AbstractModel
$hasStart = false;
$hasEnd = false;
$upTaskList = [];
$projectUserids = $this->relationUserids();
foreach ($flows as $item) {
$id = intval($item['id']);
$turns = Base::arrayRetainInt($item['turns'] ?: [], true);
@@ -431,6 +437,12 @@ class Project extends AbstractModel
if ($userlimit && empty($userids)) {
throw new ApiException("状态[{$item['name']}]设置错误,设置限制负责人时必须填写状态负责人");
}
foreach ($userids as $userid) {
if (!in_array($userid, $projectUserids)) {
$nickname = User::userid2nickname($userid);
throw new ApiException("状态[{$item['name']}]设置错误,状态负责人[{$nickname}]不在项目成员内");
}
}
$flow = ProjectFlowItem::updateInsert([
'id' => $id,
'project_id' => $this->id,

View File

@@ -12,6 +12,7 @@ use App\Tasks\PushTask;
use App\Exceptions\ApiException;
use App\Observers\ProjectTaskObserver;
use Hhxsv5\LaravelS\Swoole\Task\Task;
use League\HTMLToMarkdown\HtmlConverter;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
@@ -617,7 +618,12 @@ class ProjectTask extends AbstractModel
$data['complete_at'] = false;
}
}
if ($newFlowItem->userids) {
$flowUserids = $newFlowItem->userids;
if ($flowUserids) {
// 确认负责人在任务中
$flowUserids = ProjectUser::whereProjectId($this->project_id)->whereIn('userid', $flowUserids)->pluck('userid')->toArray();
}
if ($flowUserids) {
// 判断自动添加负责人
$flowData['owner'] = $data['owner'] = $this->taskUser->where('owner', 1)->pluck('userid')->toArray();
if (in_array($newFlowItem->usertype, ["replace", "merge"])) {
@@ -626,14 +632,14 @@ class ProjectTask extends AbstractModel
$flowData['assist'] = $data['assist'] = $this->taskUser->where('owner', 0)->pluck('userid')->toArray();
$data['assist'] = array_merge($data['assist'], $data['owner']);
}
$data['owner'] = $newFlowItem->userids;
$data['owner'] = $flowUserids;
// 判断剔除模式:保留操作状态的人员
if ($newFlowItem->usertype == "merge") {
$data['owner'][] = User::userid();
}
} else {
// 添加模式
$data['owner'] = array_merge($data['owner'], $newFlowItem->userids);
$data['owner'] = array_merge($data['owner'], $flowUserids);
}
$data['owner'] = array_values(array_unique($data['owner']));
if (isset($data['assist'])) {
@@ -729,7 +735,9 @@ class ProjectTask extends AbstractModel
if (count($older) == 0 && count($array) == 1 && $array[0] == User::userid()) {
$this->addLog("认领{任务}");
} else {
$this->addLog("修改{任务}负责人", ['userid' => $array]);
if (array_merge(array_diff($array, $older), array_diff($older, $array))) {
$this->addLog("修改{任务}负责人", ['userid' => $array]);
}
}
$this->taskPush(array_values(array_diff($array, $older)), 0);
}
@@ -770,6 +778,7 @@ class ProjectTask extends AbstractModel
if (Arr::exists($data, 'times')) {
$oldAt = [Carbon::parse($this->start_at), Carbon::parse($this->end_at)];
$oldStringAt = $this->start_at ? ($oldAt[0]->toDateTimeString() . '~' . $oldAt[1]->toDateTimeString()) : '';
$isOverdue = $this->overdue;
$clearSubTaskTime = false;
$this->start_at = null;
$this->end_at = null;
@@ -835,7 +844,20 @@ class ProjectTask extends AbstractModel
}
});
}
$newStringAt = $this->start_at && !$clearSubTaskTime ? ($this->start_at->toDateTimeString() . '~' . $this->end_at->toDateTimeString()) : '';
$existAt = $this->start_at && !$clearSubTaskTime;
$newStringAt = $existAt ? ($this->start_at->toDateTimeString() . '~' . $this->end_at->toDateTimeString()) : '';
if ($isOverdue) {
$effectiveEndTime = $existAt ? Carbon::parse($this->end_at)->min(Carbon::now()) : Carbon::now();
$this->addLog("{任务}超期未完成", [
'cache' => [
'task_at' => $oldStringAt,
'change_at' => $newStringAt,
'over_sec' => $effectiveEndTime->diffInSeconds($oldAt[1]),
'owners' => $this->taskUser->where('owner', 1)->pluck('userid')->toArray(),
'assists' => $this->taskUser->where('owner', 0)->pluck('userid')->toArray(),
]
]);
}
$newDesc = $desc ? "(备注:{$desc}" : "";
$this->addLog("修改{任务}时间" . $newDesc, [
'change' => [$oldStringAt, $newStringAt]
@@ -870,6 +892,7 @@ class ProjectTask extends AbstractModel
}
// 协助人员
if (Arr::exists($data, 'assist')) {
$older = $this->taskUser->where('owner', 0)->pluck('userid')->toArray();
$array = [];
$assist = is_array($data['assist']) ? $data['assist'] : [$data['assist']];
if (count($assist) > 10) {
@@ -890,7 +913,9 @@ class ProjectTask extends AbstractModel
$array[] = $uid;
}
if ($array) {
$this->addLog("修改{任务}协助人员", ['userid' => $array]);
if (array_merge(array_diff($array, $older), array_diff($older, $array))) {
$this->addLog("修改{任务}协助人员", ['userid' => $array]);
}
}
$rows = ProjectTaskUser::whereTaskId($this->id)->whereOwner(0)->whereNotIn('userid', $array)->get();
if ($rows->isNotEmpty()) {
@@ -1180,7 +1205,12 @@ class ProjectTask extends AbstractModel
'userid' => $userid,
], [
'important' => 1
]);
], function () use ($userid) {
return [
'important' => 1,
'bot' => User::isBot($userid) ? 1 : 0,
];
});
}
WebSocketDialogUser::whereDialogId($this->dialog_id)->whereNotIn('userid', $userids)->whereImportant(1)->remove();
});
@@ -1324,6 +1354,9 @@ class ProjectTask extends AbstractModel
$addMsg = $this->parent_id == 0 && $this->dialog_id > 0;
if ($complete_at === null) {
// 标记未完成
if (!$this->complete_at) {
return; // 本来就未完成
}
$this->complete_at = null;
$this->addLog("标记{任务}未完成");
if ($addMsg) {
@@ -1333,6 +1366,9 @@ class ProjectTask extends AbstractModel
}
} else {
// 标记已完成
if ($this->complete_at) {
return; // 本来就已完成
}
if ($this->parent_id == 0) {
if (self::whereParentId($this->id)->whereCompleteAt(null)->exists()) {
throw new ApiException('子任务未完成', [
@@ -1343,6 +1379,16 @@ class ProjectTask extends AbstractModel
if (!$this->hasOwner()) {
throw new ApiException('请先领取任务');
}
if ($this->overdue) {
$this->addLog("{任务}超期未完成", [
'cache' => [
'task_at' => $this->start_at . '~' . $this->end_at,
'over_sec' => Carbon::now()->diffInSeconds($this->end_at),
'owners' => $this->taskUser->where('owner', 1)->pluck('userid')->toArray(),
'assists' => $this->taskUser->where('owner', 0)->pluck('userid')->toArray(),
]
]);
}
if (empty($complete_name)) {
$complete_name = '已完成';
}
@@ -1389,11 +1435,12 @@ class ProjectTask extends AbstractModel
$this->archived_at = null;
$this->archived_userid = User::userid();
$this->archived_follow = 0;
$this->addLog("任务取消归档");
$logText = "任务取消归档";
$userid = 0;
} else {
// 归档任务
if ($isAuto === true) {
$logText = "自动任务归档";
$logText = "任务自动归档";
$userid = 0;
} else {
$logText = "任务归档";
@@ -1402,13 +1449,20 @@ class ProjectTask extends AbstractModel
$this->archived_at = $archived_at;
$this->archived_userid = $userid;
$this->archived_follow = 0;
$this->addLog($logText, [], $userid);
}
// 添加日志
$this->addLog($logText, [], $userid);
// 推送状态
$this->pushMsg($archived_at === null ? 'recovery' : 'archived', [
'id' => $this->id,
'archived_at' => $this->archived_at,
'archived_userid' => $this->archived_userid,
]);
// 更新对话时间
if ($this->dialog_id > 0) {
WebSocketDialogUser::whereDialogId($this->dialog_id)->update(['updated_at' => Carbon::now()]); // 因为是若提醒,可以直接使用 update 更新
}
// 更新保存
self::whereParentId($this->id)->change([
'archived_at' => $this->archived_at,
'archived_userid' => $this->archived_userid,
@@ -1797,16 +1851,30 @@ class ProjectTask extends AbstractModel
* @param int $flowItemId
* @param array $owner
* @param array $assist
* @param string $completeAt
* @param string|null $completed
* @return bool
*/
public function moveTask(int $projectId, int $columnId,int $flowItemId = 0,array $owner = [], array $assist = [], string $completeAt='')
public function moveTask(int $projectId, int $columnId, int $flowItemId = 0, array $owner = [], array $assist = [], ?string $completed = null)
{
AbstractModel::transaction(function () use ($projectId, $columnId, $flowItemId, $owner, $assist, $completeAt) {
AbstractModel::transaction(function () use ($projectId, $columnId, $flowItemId, $owner, $assist, $completed) {
$newTaskUser = array_merge($owner, $assist);
//
$oldProject = Project::find($this->project_id);
$newProject = $this->project_id != $projectId ? Project::find($projectId) : $oldProject;
if (!$oldProject || !$newProject) {
throw new ApiException('项目不存在');
}
//
$this->project_id = $projectId;
$this->column_id = $columnId;
// 日志
$log = $this->addLog("移动{任务}", [
'change' => [$oldProject->name, $newProject->name]
]);
if ($this->dialog_id) {
$notice = $oldProject->id != $newProject->id ? "{$oldProject->name}」移动至「{$newProject->name}" : $log->detail;
WebSocketDialogMsg::sendMsg(null, $this->dialog_id, 'notice', ['notice' => $notice], User::userid(), true, true);
}
// 任务内容
if ($this->content) {
$this->content->project_id = $projectId;
@@ -1840,6 +1908,7 @@ class ProjectTask extends AbstractModel
]);
//
if ($flowItemId) {
// 更新任务流程
$flowItem = projectFlowItem::whereProjectId($projectId)->whereId($flowItemId)->first();
$this->flow_item_id = $flowItemId;
$this->flow_item_name = $flowItem->status . "|" . $flowItem->name;
@@ -1849,22 +1918,81 @@ class ProjectTask extends AbstractModel
$this->completeTask(null);
}
} else {
// 没有流程只更新状态
$this->flow_item_id = 0;
$this->flow_item_name = '';
}
//
if ($completeAt) {
$this->complete_at = $completeAt;
if ($completed !== null) {
$this->completeTask($completed ? Carbon::now(): null);
}
}
//
$this->save();
//
$this->addLog("移动{任务}");
});
$this->pushMsg('update');
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) {
$converter = new HtmlConverter(['strip_tags' => true]);
$descContent = Base::cutStr($converter->convert($taskDesc['content']), 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

View File

@@ -16,7 +16,7 @@ namespace App\Models;
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\Project $project
* @property-read \App\Models\User|null $user
* @property-read \App\Models\User $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)

View File

@@ -3,6 +3,7 @@
namespace App\Models;
use App\Exceptions\ApiException;
use App\Module\Base;
use Carbon\Carbon;
use Carbon\Traits\Creator;
use Illuminate\Database\Eloquent\Builder;
@@ -95,6 +96,24 @@ class Report extends AbstractModel
return $this->appendattrs['receives'];
}
/**
* 获取汇报内容
* @param $id
* @return self|null
*/
public static function idOrCodeToContent($id)
{
if (Base::isNumber($id)) {
return self::find($id);
} elseif ($id) {
$reportLink = ReportLink::whereCode($id)->first();
if ($reportLink) {
return self::find($reportLink->rid);
}
}
return null;
}
/**
* 获取单条记录
* @param $id
@@ -139,12 +158,12 @@ class Report extends AbstractModel
// 如果设置了周期偏移量
empty( $offset ) || $now_dt->subWeeks( abs( $offset ) );
$now_dt->startOfWeek(); // 设置为当周第一天
return $now_dt->year . $now_dt->weekOfYear;
return now()->year . $now_dt->weekOfYear;
},
Report::DAILY => function() use ($now_dt, $offset) {
// 如果设置了周期偏移量
empty( $offset ) || $now_dt->subDays( abs( $offset ) );
return $now_dt->format("Ymd");
return now()->format("Ymd");
},
default => "",
};

86
app/Models/ReportLink.php Normal file
View File

@@ -0,0 +1,86 @@
<?php
namespace App\Models;
use App\Exceptions\ApiException;
use App\Module\Base;
/**
* App\Models\ReportLink
*
* @property int $id
* @property int|null $rid 报告ID
* @property int|null $num 累计访问
* @property string|null $code 链接码
* @property int|null $userid 会员ID
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\Report|null $report
* @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|ReportLink newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ReportLink newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ReportLink query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|ReportLink whereCode($value)
* @method static \Illuminate\Database\Eloquent\Builder|ReportLink whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ReportLink whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ReportLink whereNum($value)
* @method static \Illuminate\Database\Eloquent\Builder|ReportLink whereRid($value)
* @method static \Illuminate\Database\Eloquent\Builder|ReportLink whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ReportLink whereUserid($value)
* @mixin \Eloquent
*/
class ReportLink extends AbstractModel
{
/**
* @return \Illuminate\Database\Eloquent\Relations\HasOne
*/
public function report(): \Illuminate\Database\Eloquent\Relations\HasOne
{
return $this->hasOne(Report::class, 'id', 'report_id');
}
/**
* 生成链接
* @param $rid
* @param $userid
* @param $refresh
* @return array
*/
public static function generateLink($rid, $userid, $refresh = false)
{
$report = Report::find($rid);
if (empty($report)) {
throw new ApiException('报告不存在或已被删除');
}
if ($report->userid != $userid) {
if (!ReportReceive::whereRid($rid)->whereUserid($userid)->exists()) {
throw new ApiException('您没有权限查看该报告');
}
}
$reportLink = ReportLink::whereRid($rid)->whereUserid($userid)->first();
if (empty($reportLink)) {
$reportLink = ReportLink::createInstance([
'rid' => $rid,
'userid' => $userid,
'code' => base64_encode("{$rid},{$userid}," . Base::generatePassword()),
]);
$reportLink->save();
} else {
if ($refresh == 'yes') {
$reportLink->code = base64_encode("{$rid},{$userid}," . Base::generatePassword());
$reportLink->save();
}
}
return [
'id' => $rid,
'url' => Base::fillUrl('single/report/detail/' . $reportLink->code),
'code' => $reportLink->code,
'num' => $reportLink->num
];
}
}

View File

@@ -2,8 +2,11 @@
namespace App\Models;
use App\Exceptions\ApiException;
use App\Module\Base;
use App\Module\Doo;
use App\Module\Timer;
use Carbon\Carbon;
/**
* App\Models\Setting
@@ -65,20 +68,34 @@ class Setting extends AbstractModel
$value['claude_key'] = $value['claude_token'];
}
$array = [];
$aiList = ['openai', 'claude', 'gemini', 'zhipu', 'qianwen', 'wenxin'];
$fieldList = ['key', 'model', 'agency', 'system', 'secret'];
$aiList = ['openai', 'claude', 'deepseek', 'gemini', 'grok', 'ollama', 'zhipu', 'qianwen', 'wenxin'];
$fieldList = ['key', 'secret', 'models', 'model', 'base_url', 'agency', 'temperature', 'system'];
foreach ($aiList as $aiName) {
foreach ($fieldList as $fieldName) {
$key = $aiName . '_' . $fieldName;
$array[$key] = $value[$key] ?: match ($key) {
'openai_model' => 'gpt-4o-mini',
'claude_model' => 'claude-3-5-sonnet-latest',
'gemini_model' => 'gemini-1.5-flash',
'zhipu_model' => 'glm-4',
'qianwen_model' => 'qwen-turbo',
'wenxin_model' => 'ernie-4.0-8k',
default => '',
};
$content = $value[$key] ? trim($value[$key]) : '';
switch ($fieldName) {
case 'models':
if ($content) {
$content = explode("\n", $content);
$content = array_filter($content);
}
if (empty($content)) {
$content = self::AIDefaultModels($aiName);
}
$content = implode("\n", $content);
break;
case 'model':
$models = Setting::AIModels2Array($array[$key . 's'], true);
$content = in_array($content, $models) ? $content : ($models[0] ?? '');
break;
case 'temperature':
if ($content) {
$content = floatval(min(1, max(0, floatval($content) ?: 0.7)));
}
break;
}
$array[$key] = $content;
}
}
$value = $array;
@@ -98,6 +115,121 @@ class Setting extends AbstractModel
return !!$array[$ai . '_key'];
}
/**
* AI默认模型
* @param string $ai
* @return array
*/
public static function AIDefaultModels($ai = 'openai')
{
return match ($ai) {
'openai' => [
'gpt-4 | GPT-4',
'gpt-4-turbo | GPT-4 Turbo',
'gpt-4o | GPT-4o',
'gpt-4o-mini | GPT-4o Mini',
'o1 | GPT-o1',
'o1-mini | GPT-o1 Mini',
'o3-mini | GPT-o3 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-3-5-sonnet-latest | Claude 3.5 Sonnet',
'claude-3-5-sonnet-20241022 | Claude 3.5 Sonnet 20241022',
'claude-3-5-haiku-latest | Claude 3.5 Haiku',
'claude-3-5-haiku-20241022 | Claude 3.5 Haiku 20241022',
'claude-3-opus-latest | Claude 3 Opus',
'claude-3-opus-20240229 | Claude 3 Opus 20240229',
'claude-3-haiku-20240307 | Claude 3 Haiku 20240307',
'claude-2.1 | Claude 2.1',
'claude-2.0 | Claude 2.0'
],
'deepseek' => [
'deepseek-chat | DeepSeek V3',
'deepseek-reasoner | DeepSeek R1'
],
'gemini' => [
'gemini-2.0-flash | Gemini 2.0 Flash',
'gemini-2.0-flash-lite-preview-02-05 | Gemini 2.0 Flash-Lite Preview',
'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-2-vision-1212 | Grok 2 Vision 1212',
'grok-2-vision | Grok 2 Vision',
'grok-2-vision-latest | Grok 2 Vision Latest',
'grok-2-1212 | Grok 2 1212',
'grok-2 | Grok 2',
'grok-2-latest | Grok 2 Latest',
'grok-vision-beta | Grok Vision Beta',
'grok-beta | Grok Beta',
],
'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 => [],
};
}
/**
* AI模型转数组
* @param $models
* @param bool $retValue
* @return array
*/
public static function AIModels2Array($models, $retValue = false)
{
$list = is_array($models) ? $models : explode("\n", $models);
$array = [];
foreach ($list as $item) {
$arr = Base::newTrim(explode('|', $item . '|'));
if ($arr[0]) {
$array[] = [
'value' => $arr[0],
'label' => $arr[1] ?: $arr[0]
];
}
}
if ($retValue) {
return array_column($array, 'value');
}
return $array;
}
/**
* 验证邮箱地址(过滤忽略地址)
* @param $array
@@ -134,4 +266,36 @@ class Setting extends AbstractModel
}
return $array;
}
/**
* 验证消息限制
* @param $type
* @param $msg
* @return void
*/
public static function validateMsgLimit($type, $msg)
{
$keyName = 'msg_edit_limit';
$error = '此消息不可修改';
if ($type == 'rev') {
$keyName = 'msg_rev_limit';
$error = '此消息不可撤回';
}
$limitNum = intval(Base::settingFind('system', $keyName, 0));
if ($limitNum <= 0) {
return;
}
if ($msg instanceof WebSocketDialogMsg) {
$dialogMsg = $msg;
} else {
$dialogMsg = WebSocketDialogMsg::find($msg);
}
if (!$dialogMsg) {
return;
}
$limitTime = Carbon::parse($dialogMsg->created_at)->addMinutes($limitNum);
if ($limitTime->lt(Carbon::now())) {
throw new ApiException('已超过' . Doo::translate(Base::forumMinuteDay($limitNum)) . '' . $error);
}
}
}

View File

@@ -32,13 +32,13 @@ use Hedeqiang\UMeng\IOS;
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereAlias($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereDevice($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereVersion($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereIsNotified($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias wherePlatform($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereUa($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereUserid($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereVersion($value)
* @mixin \Eloquent
*/
class UmengAlias extends AbstractModel

View File

@@ -242,6 +242,31 @@ class User extends AbstractModel
return in_array('admin', $this->identity);
}
/**
* 返回是否AI机器人
* @return bool
*/
public function isAiBot(&$aiName = '')
{
if (preg_match('/^ai-(.*?)@bot\.system$/', $this->email, $matches)) {
$aiName = $matches[1];
return true;
}
return false;
}
/**
* 返回是否用户机器人
* @return bool
*/
public function isUserBot()
{
if (preg_match('/^user-(.*?)@bot\.system$/', $this->email)) {
return true;
}
return false;
}
/**
* 判断是否管理员
*/
@@ -419,7 +444,9 @@ class User extends AbstractModel
{
$user = self::authInfo();
if (!$user) {
if (Base::token()) {
$token = Base::token();
if ($token) {
UserDevice::forget($token);
throw new ApiException('身份已失效,请重新登录', [], -1);
} else {
throw new ApiException('请登录后继续...', [], -1);
@@ -441,31 +468,46 @@ class User extends AbstractModel
private static function authInfo()
{
if (RequestContext::has('auth')) {
// 缓存
return RequestContext::get('auth');
}
if (Doo::userId() > 0
&& !Doo::userExpired()
&& $user = self::whereUserid(Doo::userId())->whereEmail(Doo::userEmail())->whereEncrypt(Doo::userEncrypt())->first()) {
$upArray = [];
if (Base::getIp() && $user->line_ip != Base::getIp()) {
$upArray['line_ip'] = Base::getIp();
}
if (Carbon::parse($user->line_at)->addSeconds(30)->lt(Carbon::now())) {
$upArray['line_at'] = Carbon::now();
}
$headerLanguage = RequestContext::get('header_language');
if (empty($user->lang) || $headerLanguage) {
if (Doo::checkLanguage($headerLanguage) && $user->lang != $headerLanguage) {
$upArray['lang'] = $headerLanguage;
}
}
if ($upArray) {
$user->updateInstance($upArray);
$user->save();
}
return RequestContext::save('auth', $user);
if (Doo::userId() <= 0) {
// 没有登录
return RequestContext::save('auth', false);
}
return RequestContext::save('auth', false);
if (Doo::userExpired()) {
// 登录过期
return RequestContext::save('auth', false);
}
if (!UserDevice::check()) {
// token 不存在
return RequestContext::save('auth', false);
}
$user = self::whereUserid(Doo::userId())->whereEmail(Doo::userEmail())->whereEncrypt(Doo::userEncrypt())->first();
if (!$user) {
// 登录信息不匹配
return RequestContext::save('auth', false);
}
// 更新登录信息
$upArray = [];
if (Base::getIp() && $user->line_ip != Base::getIp()) {
$upArray['line_ip'] = Base::getIp();
}
if (Carbon::parse($user->line_at)->addSeconds(30)->lt(Carbon::now())) {
$upArray['line_at'] = Carbon::now();
}
$headerLanguage = RequestContext::get('header_language');
if (empty($user->lang) || $headerLanguage) {
if (Doo::checkLanguage($headerLanguage) && $user->lang != $headerLanguage) {
$upArray['lang'] = $headerLanguage;
}
}
if ($upArray) {
$user->updateInstance($upArray);
$user->save();
}
return RequestContext::save('auth', $user);
}
/**
@@ -489,6 +531,7 @@ class User extends AbstractModel
} else {
$token = Doo::userToken();
}
UserDevice::record($token);
unset($userinfo->encrypt);
unset($userinfo->password);
return $userinfo->token = $token;
@@ -593,8 +636,14 @@ class User extends AbstractModel
return url("images/avatar/default_openai.png");
case 'ai-claude@bot.system':
return url("images/avatar/default_claude.png");
case 'ai-deepseek@bot.system':
return url("images/avatar/default_deepseek.png");
case 'ai-gemini@bot.system':
return url("images/avatar/default_gemini.png");
case 'ai-grok@bot.system':
return url("images/avatar/default_grok.png");
case 'ai-ollama@bot.system':
return url("images/avatar/default_ollama.png");
case 'ai-zhipu@bot.system':
return url("images/avatar/default_zhipu.png");
case 'bot-manager@bot.system':
@@ -700,4 +749,26 @@ class User extends AbstractModel
}
return (bool)User::find($userid)?->bot;
}
/**
* 搜索用户
* @param $key
* @param $take
* @return User[]|\Illuminate\Database\Eloquent\Builder[]|\Illuminate\Database\Eloquent\Collection|\Illuminate\Database\Query\Builder[]|\Illuminate\Support\Collection
*/
public static function searchUser($key, $take = 20)
{
return User::select(User::$basicField)
->where(function ($query) use ($key) {
if (str_contains($key, "@")) {
$query->where("email", "like", "%{$key}%");
} else {
$query->where("nickname", "like", "%{$key}%")
->orWhere("pinyin", "like", "%{$key}%")
->orWhere("profession", "like", "%{$key}%");
}
})->orderBy('userid')
->take($take)
->get();
}
}

View File

@@ -55,16 +55,6 @@ class UserBot extends AbstractModel
return str_ends_with($email, '@bot.system') && self::systemBotName($email);
}
/**
* 判断是否系统AI机器人
* @param $email
* @return bool
*/
public static function isAiBot($email)
{
return str_starts_with($email, 'ai-') && self::isSystemBot($email);
}
/**
* 系统机器人名称
* @param $name string 邮箱 或 邮箱前缀
@@ -83,10 +73,13 @@ class UserBot extends AbstractModel
'approval-alert' => '审批',
'ai-openai' => 'ChatGPT',
'ai-claude' => 'Claude',
'ai-wenxin' => '文心一言',
'ai-qianwen' => '通义千问',
'ai-deepseek' => 'DeepSeek',
'ai-gemini' => 'Gemini',
'ai-grok' => 'Grok',
'ai-ollama' => 'Ollama',
'ai-zhipu' => '智谱清言',
'ai-qianwen' => '通义千问',
'ai-wenxin' => '文心一言',
'bot-manager' => '机器人管理',
'meeting-alert' => '会议通知',
'okr-alert' => 'OKR提醒',
@@ -187,11 +180,35 @@ class UserBot extends AbstractModel
];
default:
if (preg_match('/^ai-(.*?)@bot\.system$/', $email)) {
return [
[
if (preg_match('/^ai-(.*?)@bot\.system$/', $email, $match)) {
if (!Base::judgeClientVersion('0.42.62')) {
return [
'key' => '%3A.clear',
'label' => Doo::translate('清空上下文')
];
}
$aibotSetting = Base::setting('aibotSetting');
$aibotModel = $aibotSetting[$match[1] . '_model'];
$aibotModels = Setting::AIModels2Array($aibotSetting[$match[1] . '_models']);
if (empty($aibotModels)) {
return [];
}
return [
[
'key' => '~ai-model-select',
'label' => Doo::translate('选择模型'),
'config' => [
'model' => $aibotModel,
'models' => $aibotModels
]
],
[
'key' => '~ai-session-create',
'label' => Doo::translate('开启新会话'),
],
[
'key' => '~ai-session-history',
'label' => Doo::translate('历史会话'),
]
];
}
@@ -425,4 +442,39 @@ class UserBot extends AbstractModel
default => [],
};
}
/**
* 创建我的机器人
* @param $userid
* @param $botName
* @return array
*/
public static function newbot($userid, $botName)
{
if (User::select(['users.*'])
->join('user_bots', 'users.userid', '=', 'user_bots.bot_id')
->where('users.bot', 1)
->where('user_bots.userid', $userid)
->count() >= 50) {
return Base::retError("超过最大创建数量。");
}
if (strlen($botName) < 2 || strlen($botName) > 20) {
return Base::retError("机器人名称由2-20个字符组成。");
}
$data = User::botGetOrCreate("user-" . Base::generatePassword(), [
'nickname' => $botName
], $userid);
if (empty($data)) {
return Base::retError("创建失败。");
}
$dialog = WebSocketDialog::checkUserDialog($data, $userid);
if ($dialog) {
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => '/hello',
'title' => '创建成功。',
'data' => $data,
], $data->userid);
}
return Base::retSuccess("创建成功。", $data);
}
}

View File

@@ -47,7 +47,7 @@ class UserCheckinFace extends AbstractModel
$record = base64_encode(file_get_contents($faceFile));
}
$url = 'http://' . env('APP_IPPR') . '.14' . ":7788/user";
$url = "http://face:7788/user";
$data = [
'name' => $nickname,
'enrollid' => $userid,
@@ -92,7 +92,7 @@ class UserCheckinFace extends AbstractModel
}
public static function deleteDeviceUser($userid) {
$url = 'http://' . env('APP_IPPR') . '.14' . ":7788/user/delete";
$url = "http://face:7788/user/delete";
$data = [
'enrollid' => $userid,
'backupnum' => 50, // 13 删除整个用户 50 删除图片

View File

@@ -37,11 +37,6 @@ use App\Module\Base;
*/
class UserDelete extends AbstractModel
{
/**
* 昵称
* @param $value
* @return string
*/
public function getCacheAttribute($value)
{
if (!is_array($value)) {
@@ -65,13 +60,25 @@ class UserDelete extends AbstractModel
*/
public static function userid2basic($userid)
{
$row = self::whereUserid($userid)->first();
if (empty($row) || empty($row->cache)) {
return null;
}
$cache = $row->cache;
$cache = array_intersect_key($cache, array_flip(array_merge(User::$basicField, ['department_name'])));
$cache['delete_at'] = $row->created_at->toDateTimeString();
return $cache;
return \Cache::remember("UserDelete:{$userid}", now()->addDays(3), function () use ($userid) {
$row = self::whereUserid($userid)->first();
if (empty($row) || empty($row->cache)) {
return null;
}
$cache = $row->cache;
$cache = array_intersect_key($cache, array_flip(array_merge(User::$basicField, ['department_name'])));
$cache['delete_at'] = $row->created_at->toDateTimeString();
return $cache;
});
}
/**
* userid 获取 昵称
* @param $userid
* @return string
*/
public static function userid2nickname($userid)
{
return self::userid2basic($userid)['nickname'] ?? '';
}
}

View File

@@ -34,6 +34,21 @@ use App\Exceptions\ApiException;
*/
class UserDepartment extends AbstractModel
{
/**
* 获取所有父级部门
* @return array
*/
public function parents()
{
$parents = [];
$parent = $this;
while ($parent) {
$parents[] = $parent;
$parent = $parent->parent_id ? self::find($parent->parent_id) : null;
}
return $parents;
}
/**
* 保存部门
* @param $data
@@ -131,9 +146,7 @@ class UserDepartment extends AbstractModel
});
// 解散群组
$dialog = WebSocketDialog::find($this->dialog_id);
if ($dialog) {
$dialog->deleteDialog();
}
$dialog?->deleteDialog();
//
$this->delete();
}

278
app/Models/UserDevice.php Normal file
View File

@@ -0,0 +1,278 @@
<?php
namespace App\Models;
use App\Module\Base;
use App\Module\Doo;
use Cache;
use Carbon\Carbon;
use DeviceDetector\DeviceDetector;
use Illuminate\Database\Eloquent\SoftDeletes;
use Request;
/**
* App\Models\UserDevice
*
* @property int $id
* @property int|null $userid 会员ID
* @property string|null $hash TOKEN MD5
* @property string|null $detail 详细信息
* @property string|null $expired_at 过期时间
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property \Illuminate\Support\Carbon|null $deleted_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|UserDevice newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice onlyTrashed()
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice whereDeletedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice whereDetail($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice whereExpiredAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice whereHash($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice whereUserid($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice withTrashed()
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice withoutTrashed()
* @mixin \Eloquent
*/
class UserDevice extends AbstractModel
{
use SoftDeletes;
protected $table = 'user_devices';
protected $appends = [
'is_current',
];
public function getDetailAttribute($value)
{
if (is_array($value)) {
return $value;
}
return Base::json2array($value);
}
public function getIsCurrentAttribute(): int
{
return $this->hash === md5(Doo::userToken()) ? 1 : 0;
}
/** ****************************************************************************** */
/** ****************************************************************************** */
/** ****************************************************************************** */
/**
* 缓存key
* @param string $hash
* @return string
*/
private static function ck(string $hash): string
{
return "user_devices:{$hash}";
}
/**
* 解析 UA 获取设备信息
* @param string $ua
* @return array
*/
private static function getDeviceInfo(string $ua): array
{
$result = [
'ip' => Base::getIp(),
'type' => '电脑',
'os' => 'Unknown',
'browser' => 'Unknown',
'version' => '',
'app_type' => '', // 客户端类型
'app_version' => '', // 客户端版本
];
if (empty($ua)) {
return $result;
}
// 使用 Device-Detector 解析 UA
$dd = new DeviceDetector($ua);
// 解析 UA 字符串
$dd->parse();
// 获取客户端信息(浏览器)
$clientInfo = $dd->getClient();
if (!empty($clientInfo)) {
$result['browser'] = $clientInfo['name'] ?? 'Unknown';
$result['version'] = $clientInfo['version'] ?? '';
}
// 获取操作系统信息
$osInfo = $dd->getOs();
if (!empty($osInfo)) {
$result['os'] = trim(($osInfo['name'] ?? '') . ' ' . ($osInfo['version'] ?? ''));
if (empty($result['os'])) {
$result['os'] = 'Unknown';
}
}
if (preg_match("/android_kuaifan_eeui/i", $ua)) {
// Android 客户端
$result['app_type'] = 'Android';
$result['app_version'] = self::getAfterVersion($ua, 'kuaifan_eeui/');
} elseif (preg_match("/ios_kuaifan_eeui/i", $ua)) {
// iOS 客户端
$result['app_type'] = 'iOS';
$result['app_version'] = self::getAfterVersion($ua, 'kuaifan_eeui/');
} elseif (preg_match("/dootask/i", $ua)) {
// DooTask 客户端
$result['app_type'] = $osInfo['name'];
$result['app_version'] = self::getAfterVersion($ua, 'dootask/');
} else {
// 其他客户端
$result['app_type'] = 'Web';
$result['app_version'] = Base::getClientVersion();
}
return $result;
}
/**
* 从 ua 的 find 之后的内容获取版本号
* @param string $ua
* @param string $find
* @return string
*/
private static function getAfterVersion(string $ua, string $find): string
{
$findPattern = preg_quote($find, '/');
if (preg_match("/{$findPattern}(.*?)(?:\s|$)/i", $ua, $matches)) {
$appInfo = $matches[1];
// 从内容中提取版本号寻找符合x.x.x格式的部分
if (preg_match("/(\d+\.\d+(?:\.\d+)*)/", $appInfo, $versionMatches)) {
return $versionMatches[1];
}
}
return '';
}
/** ****************************************************************************** */
/** ****************************************************************************** */
/** ****************************************************************************** */
/**
* 检查用户是否存在
* @return bool
*/
public static function check(): bool
{
$token = Doo::userToken();
$userid = Doo::userId();
$hash = md5($token);
if (Cache::has(self::ck($hash))) {
return true;
}
$row = self::whereHash($hash)->first();
if ($row) {
// 判断是否过期
if (Carbon::parse($row->expired_at)->isPast()) {
Cache::forget(self::ck($hash));
$row->delete();
return false;
}
// 更新缓存
self::record();
return true;
}
// 没有记录,尝试创建一个(防止升级后所有登录都失效,保证留一个可以保持登录) // todo 后期删除
return AbstractModel::transaction(function () use ($userid) {
if (self::whereUserid($userid)->withoutTrashed()->lockForUpdate()->exists()) {
return false;
}
return (bool)self::record();
});
}
/**
* 记录设备(添加、更新)
* @param string|null $token
* @return self|null
*/
public static function record(string $token = null): ?self
{
if (empty($token)) {
$token = Doo::userToken();
$userid = Doo::userId();
$expiredAt = Doo::userExpiredAt() ?: null;
} else {
$info = Doo::tokenDecode($token);
$userid = $info['userid'] ?? 0;
$expiredAt = $info['expired_at'] ?? null;
}
$deviceData = [
'detail' => Base::array2json(self::getDeviceInfo($_SERVER['HTTP_USER_AGENT'] ?? '')),
'expired_at' => $expiredAt,
];
$hash = md5($token);
$row = self::updateInsert([
'userid' => $userid,
'hash' => $hash,
], function() use ($deviceData) {
if (!Request::hasHeader('version')) {
unset($deviceData['detail']);
}
return $deviceData;
}, $deviceData);
if ($row) {
Cache::put(self::ck($hash), $row->userid, now()->addHour());
return $row;
}
return null;
}
/**
* 忘记设备(删除)
* @param UserDevice|string|int|null $token
* - UserDevice 表示指定的设备对象
* - string 表示指定的 token
* - int 表示指定的数据ID
* - null 表示当前登录的设备
* @return void
*/
public static function forget(UserDevice|string|int $token = null): void
{
if ($token instanceof UserDevice) {
$hash = $token->hash;
$token->delete();
} elseif (Base::isNumber($token)) {
$row = self::find(intval($token));
if ($row) {
$hash = $row->hash;
$row->delete();
}
} else {
if ($token === null) {
$token = Doo::userToken();
}
if ($token) {
$hash = md5($token);
self::whereHash($hash)->delete();
}
}
if (isset($hash)) {
Cache::forget(self::ck($hash));
}
}
}

View File

@@ -19,6 +19,7 @@ use Illuminate\Support\Facades\DB;
* @property int $id
* @property string|null $type 对话类型
* @property string|null $group_type 聊天室类型
* @property int|null $session_id 会话ID
* @property string|null $name 对话名称
* @property string $avatar 头像(群)
* @property int|null $owner_id 群主用户ID
@@ -48,6 +49,7 @@ use Illuminate\Support\Facades\DB;
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialog whereLinkId($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialog whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialog whereOwnerId($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialog whereSessionId($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialog whereTopMsgId($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialog whereTopUserid($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialog whereType($value)
@@ -95,6 +97,32 @@ class WebSocketDialog extends AbstractModel
->whereNull('users.disable_at');
}
/**
* 搜索对话
* @param $userid
* @param $key
* @param $take
* @return array
*/
public static function searchDialog($userid, $key, $take = 20)
{
return DB::table('web_socket_dialog_users as u')
->select(['d.*', 'u.top_at', 'u.last_at', 'u.mark_unread', 'u.silence', 'u.hide', 'u.color', 'u.updated_at as user_at'])
->join('web_socket_dialogs as d', 'u.dialog_id', '=', 'd.id')
->where('u.userid', $userid)
->where(function ($query) use ($key) {
$query->where('d.name', 'like', '%' . $key . '%');
})
->whereNull('d.deleted_at')
->orderByDesc('u.top_at')
->orderByDesc('u.last_at')
->take($take)
->get()
->map(function($item) use ($userid) {
return WebSocketDialog::synthesizeData($item, $userid);
})
->all();
}
/**
* 获取对话列表
@@ -265,7 +293,9 @@ class WebSocketDialog extends AbstractModel
// 未读消息
$data = array_merge($data, self::generateUnread($data['id'], $userid));
// 对话人数
$data['people'] = $data['people'] ?? WebSocketDialogUser::whereDialogId($data['id'])->count();
if (!isset($data['people'])) {
$data = array_merge($data, self::generatePeople($data['id']));
}
// 有待办
$data['todo_num'] = $data['todo_num'] ?? WebSocketDialogMsgTodo::whereDialogId($data['id'])->whereUserid($userid)->whereDoneAt(null)->count();
// 最后消息
@@ -294,7 +324,8 @@ class WebSocketDialog extends AbstractModel
$data['is_disable'] = $basic->isDisable(true);
$data['quick_msgs'] = UserBot::quickMsgs($basic->email);
} else {
$data['name'] = 'non-existent';
$data['name'] = UserDelete::userid2nickname($dialog_user->userid) ?: '[Delete]';
$data['is_disable'] = 1;
$data['dialog_delete'] = 1;
}
$data['dialog_user'] = $dialog_user;
@@ -401,6 +432,26 @@ class WebSocketDialog extends AbstractModel
return $data;
}
/**
* 获取对话人数
* @param $dialogId
* @return array
*/
public static function generatePeople($dialogId)
{
$counts = WebSocketDialogUser::whereDialogId($dialogId)
->groupBy('bot')
->selectRaw('bot, COUNT(*) as count')
->pluck('count', 'bot');
$userCount = $counts->get(0, 0); // 非机器人数量
$botCount = $counts->get(1, 0); // 机器人数量
return [
'people' => $userCount + $botCount,
'people_user' => $userCount,
'people_bot' => $botCount,
];
}
/**
* 加入聊天室
* @param int|array $userid 加入的会员ID或会员ID组
@@ -423,7 +474,11 @@ class WebSocketDialog extends AbstractModel
WebSocketDialogUser::updateInsert([
'dialog_id' => $this->id,
'userid' => $value,
], $updateData, [], $isInsert);
], $updateData, function() use ($value, $updateData) {
return array_merge($updateData, [
'bot' => User::isBot($value) ? 1 : 0
]);
}, $isInsert);
if ($isInsert) {
WebSocketDialogMsg::sendMsg(null, $this->id, 'notice', [
'notice' => User::userid2nickname($value) . " 已加入群组"
@@ -432,10 +487,9 @@ class WebSocketDialog extends AbstractModel
}
}
});
$this->pushMsg("groupUpdate", [
'id' => $this->id,
'people' => WebSocketDialogUser::whereDialogId($this->id)->count()
]);
$data = WebSocketDialog::generatePeople($this->id);
$data['id'] = $this->id;
$this->pushMsg("groupUpdate", $data);
return true;
}
@@ -492,10 +546,9 @@ class WebSocketDialog extends AbstractModel
});
});
//
$this->pushMsg("groupUpdate", [
'id' => $this->id,
'people' => WebSocketDialogUser::whereDialogId($this->id)->count()
]);
$data = WebSocketDialog::generatePeople($this->id);
$data['id'] = $this->id;
$this->pushMsg("groupUpdate", $data);
}
/**
@@ -640,6 +693,18 @@ class WebSocketDialog extends AbstractModel
Task::deliver($task);
}
/**
* 检查是否是单人对话
* @return bool
*/
public function isSelfDialog()
{
if ($this->type !== 'user') {
return false;
}
return WebSocketDialogUser::whereDialogId($this->id)->where('userid', '>', 0)->count() === 1;
}
/**
* 获取对话(同时检验对话身份)
* @param $dialog_id
@@ -648,6 +713,9 @@ class WebSocketDialog extends AbstractModel
*/
public static function checkDialog($dialog_id, $checkOwner = false)
{
if ($dialog_id <= 0) {
throw new ApiException('参数错误');
}
$dialog = WebSocketDialog::find($dialog_id);
if (empty($dialog)) {
throw new ApiException('对话不存在或已被删除', ['dialog_id' => $dialog_id], -4003);
@@ -661,18 +729,25 @@ class WebSocketDialog extends AbstractModel
throw new ApiException('仅限群主操作');
}
//
if ($dialog->group_type === 'task') {
// 任务群对话校验是否在项目内
$project_id = intval(ProjectTask::whereDialogId($dialog->id)->value('project_id'));
if ($project_id > 0) {
if (ProjectUser::whereProjectId($project_id)->whereUserid($userid)->exists()) {
switch ($dialog->group_type) {
case 'project':
case 'task':
// 项目群、任务群对话校验是否在项目内
if ($dialog->group_type === 'project') {
$projectId = intval(Project::whereDialogId($dialog->id)->value('id'));
} else {
$projectId = intval(ProjectTask::whereDialogId($dialog->id)->value('project_id'));
}
if ($projectId > 0 && ProjectUser::whereProjectId($projectId)->whereUserid($userid)->exists()) {
return $dialog;
}
}
}
if ($dialog->group_type == 'okr') {
return $dialog;
break;
case 'okr':
// OKR群对话不用校验
return $dialog;
}
//
if (!WebSocketDialogUser::whereDialogId($dialog->id)->whereUserid($userid)->exists()) {
WebSocketDialogMsgRead::forceRead($dialog_id, $userid);
throw new ApiException('不在成员列表内', ['dialog_id' => $dialog_id], -4003);
@@ -744,6 +819,17 @@ class WebSocketDialog extends AbstractModel
'dialog_id' => $dialog->id,
'userid' => $receiver,
])->save();
//
if ($user->isAiBot() || User::find($receiver)?->isAiBot()) {
$session = WebSocketDialogSession::create([
'dialog_id' => $dialog->id,
'status' => 1,
'title' => '',
]);
$session->save();
$dialog->session_id = $session->id;
$dialog->save();
}
return $dialog;
});
}
@@ -819,13 +905,15 @@ class WebSocketDialog extends AbstractModel
Base::makeDir(public_path($path));
copy($filePath, public_path($path) . basename($filePath));
} else {
$setting = Base::setting("system");
$data = Base::upload([
"file" => $files,
"type" => 'more',
"path" => $path,
"fileName" => $fileName,
"quality" => true,
"convertVideo" => true
"convertVideo" => $setting['convert_video'] === 'open',
"compressVideo" => $setting['compress_video'] === 'open',
]);
}
//

View File

@@ -13,7 +13,7 @@ namespace App\Models;
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\WebSocketDialog|null $dialog
* @property-read \App\Models\User|null $user
* @property-read \App\Models\User $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)

View File

@@ -18,6 +18,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property int $id
* @property int|null $dialog_id 对话ID
* @property string|null $dialog_type 对话类型
* @property int|null $session_id 会话ID
* @property int|null $userid 发送会员ID
* @property string|null $type 消息类型
* @property string|null $mtype 消息类型(用于搜索)
@@ -69,6 +70,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg whereReplyId($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg whereReplyNum($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg whereSend($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg whereSessionId($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg whereTag($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg whereTodo($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg whereType($value)
@@ -303,7 +305,12 @@ class WebSocketDialogMsg extends AbstractModel
];
//
$dialog = WebSocketDialog::find($this->dialog_id);
$dialog?->pushMsg('update', $resData);
if ($dialog) {
$dialog->pushMsg('update', $resData);
WebSocketDialogUser::whereDialogId($dialog->id)->change([
'updated_at' => Carbon::now()->toDateTimeString('millisecond'),
]);
}
//
return Base::retSuccess('success', $resData);
}
@@ -532,10 +539,6 @@ class WebSocketDialogMsg extends AbstractModel
*/
public function withdrawMsg()
{
$send_dt = Carbon::parse($this->created_at)->addDay();
if ($send_dt->lt(Carbon::now())) {
throw new ApiException('已超过24小时此消息不能撤回');
}
AbstractModel::transaction(function() {
$deleteRead = WebSocketDialogMsgRead::whereMsgId($this->id)->whereNull('read_at')->delete(); // 未阅读记录不需要软删除,直接删除即可
$this->delete();
@@ -657,6 +660,10 @@ class WebSocketDialogMsg extends AbstractModel
$text = $msgData['text'] ?? '';
if (!$text) return '';
if ($msgData['type'] === 'md') {
$text = preg_replace("/:::\s*reasoning[\s\S]*?:::/", "", $text);
if (preg_match('/:::\s*reasoning\s+/', $text)) {
return Doo::translate('思考中...');
}
$text = Base::markdown2html($text);
$text = self::previewConvertTaskList($text);
}
@@ -756,9 +763,15 @@ class WebSocketDialogMsg extends AbstractModel
$key = '';
switch ($this->type) {
case 'text':
if (!preg_match("/<span[^>]*?data-quick-key=([\"'])([^\"']+?)\\1[^>]*?>/is", $this->msg['text'])) {
$key = strip_tags($this->msg['text']);
if (preg_match("/<span[^>]*?data-quick-key=([\"'])([^\"']+?)\\1[^>]*?>/i", $this->msg['text'])) {
break;
}
$key = $this->msg['text'];
if ($this->msg['type'] === 'md') {
$key = preg_replace("/:::\s*reasoning[\s\S]*?:::/", "", $key);
$key = Base::markdown2html($key);
}
$key = strip_tags($key);
break;
case 'vote':
@@ -882,8 +895,16 @@ class WebSocketDialogMsg extends AbstractModel
$imageSaveLocal = Base::settingFind("system", "image_save_local");
preg_match_all("/<img[^>]*?src=([\"'])(.*?(png|jpg|jpeg|webp|gif).*?)\\1[^>]*?>/is", $text, $matchs);
foreach ($matchs[2] as $key => $str) {
$parsed = parse_url($str);
if (str_starts_with($parsed['path'], "/uploads/")) {
$relativePath = ltrim($parsed['path'], "/");
$relativePath = Base::thumbRestore($relativePath);
if (file_exists(public_path($relativePath))) {
$str = "{{RemoteURL}}{$relativePath}";
}
}
if ($imageSaveLocal === 'close') {
$imageSize = getimagesize($str);
$imageSize = @getimagesize($str);
if ($imageSize === false) {
$imageSize = ["auto", "auto"];
}
@@ -918,7 +939,7 @@ class WebSocketDialogMsg extends AbstractModel
}
}
}
// @成员、#任务、~文件
// @成员、#任务、~文件、%报告
preg_match_all("/<span\s+class=\"mention\"(.*?)>.*?<\/span>.*?<\/span>.*?<\/span>/s", $text, $matchs);
foreach ($matchs[1] as $key => $str) {
preg_match("/data-denotation-char=\"(.*?)\"/", $str, $matchChar);
@@ -926,6 +947,7 @@ class WebSocketDialogMsg extends AbstractModel
preg_match("/data-value=\"(.*?)\"/s", $str, $matchValye);
$keyId = $matchId[1];
if ($matchChar[1] === "~") {
// 文件特殊处理
if (Base::isNumber($keyId)) {
$file = File::permissionFind($keyId, User::auth());
if ($file->type == 'folder') {
@@ -941,6 +963,19 @@ class WebSocketDialogMsg extends AbstractModel
throw new ApiException('文件分享错误');
}
}
} elseif ($matchChar[1] === "%") {
// 报告特殊处理
if (Base::isNumber($keyId)) {
$reportLink = ReportLink::generateLink($keyId, User::userid());
$keyId = $reportLink['code'];
} else {
preg_match("/\/single\/report\/detail\/(.*?)$/i", $keyId, $match);
if ($match && strlen($match[1]) >= 8) {
$keyId = $match[1];
} else {
throw new ApiException('报告分享错误');
}
}
}
$text = str_replace($matchs[0][$key], "[:{$matchChar[1]}:{$keyId}:{$matchValye[1]}:]", $text);
}
@@ -969,31 +1004,18 @@ class WebSocketDialogMsg extends AbstractModel
foreach ($matchs[0] as $key => $str) {
$herf = $matchs[2][$key];
$title = $matchs[3][$key] ?: $herf;
preg_match("/\/single\/file\/(.*?)$/i", strip_tags($title), $match);
if ($match && strlen($match[1]) >= 8) {
$file = File::select(['files.id', 'files.name', 'files.ext'])->join('file_links as L', 'files.id', '=', 'L.file_id')->where('L.code', $match[1])->first();
if ($file && $file->name) {
$name = $file->ext ? "{$file->name}.{$file->ext}" : $file->name;
$text = str_replace($str, "[:~:{$match[1]}:{$name}:]", $text);
continue;
}
if (self::formatLink($str, strip_tags($title), $text)) {
continue;
}
$herf = base64_encode($herf);
$title = base64_encode($title);
$text = str_replace($str, "[:LINK:{$herf}:{$title}:]", $text);
}
// 文件分享链接
// 分享链接
preg_match_all("/(https?:\/\/)((\w|=|\?|\.|\/|&|-|:|\+|%|;|#|@|,|!)+)/i", $text, $matchs);
if ($matchs) {
foreach ($matchs[0] as $str) {
preg_match("/\/single\/file\/(.*?)$/i", $str, $match);
if ($match && strlen($match[1]) >= 8) {
$file = File::select(['files.id', 'files.name', 'files.ext'])->join('file_links as L', 'files.id', '=', 'L.file_id')->where('L.code', $match[1])->first();
if ($file && $file->name) {
$name = $file->ext ? "{$file->name}.{$file->ext}" : $file->name;
$text = str_replace($str, "[:~:{$match[1]}:{$name}:]", $text);
}
}
self::formatLink($str, $str, $text);
}
}
// 过滤标签
@@ -1023,10 +1045,41 @@ class WebSocketDialogMsg extends AbstractModel
$text = preg_replace("/\[:@:(.*?):(.*?):\]/i", "<span class=\"mention user\" data-id=\"$1\">@$2</span>", $text);
$text = preg_replace("/\[:#:(.*?):(.*?):\]/is", "<span class=\"mention task\" data-id=\"$1\">#$2</span>", $text);
$text = preg_replace("/\[:~:(.*?):(.*?):\]/i", "<a class=\"mention file\" href=\"{{RemoteURL}}single/file/$1\" target=\"_blank\">~$2</a>", $text);
$text = preg_replace("/\[:%:(.*?):(.*?):\]/i", "<a class=\"mention report\" href=\"{{RemoteURL}}single/report/detail/$1\" target=\"_blank\">%$2</a>", $text);
$text = preg_replace("/\[:QUICK:(.*?):(.*?):\]/i", "<span data-quick-key=\"$1\">$2</span>", $text);
return preg_replace("/^(<p><\/p>)+|(<p><\/p>)+$/i", "", $text);
}
/**
* 链接转换处理
* @param $search
* @param $subject
* @param $content
* @return bool
*/
public static function formatLink($search, $subject, &$content)
{
$ret = false;
preg_match("/\/single\/file\/(.*?)$/i", $subject, $match);
if ($match && strlen($match[1]) >= 8) {
$file = File::select(['files.id', 'files.name', 'files.ext'])->join('file_links as L', 'files.id', '=', 'L.file_id')->where('L.code', $match[1])->first();
if ($file && $file->name) {
$name = $file->ext ? "{$file->name}.{$file->ext}" : $file->name;
$content = str_replace($search, "[:~:{$match[1]}:{$name}:]", $content);
$ret = true;
}
}
preg_match("/\/single\/report\/detail\/(.*?)$/i", $subject, $match);
if ($match && strlen($match[1]) >= 8) {
$report = Report::select(['reports.id', 'reports.title'])->join('report_links as L', 'reports.id', '=', 'L.rid')->where('L.code', $match[1])->first();
if ($report && $report->title) {
$content = str_replace($search, "[:%:{$match[1]}:{$report->title}:]", $content);
$ret = true;
}
}
return $ret;
}
/**
* 发送消息、修改消息
* @param string $action 动作
@@ -1136,6 +1189,7 @@ class WebSocketDialogMsg extends AbstractModel
}
//
$updateData = [
'type' => $type,
'mtype' => $mtype,
'link' => $link,
'msg' => array_merge($oldMsg, $msg),
@@ -1178,6 +1232,7 @@ class WebSocketDialogMsg extends AbstractModel
$dialogMsg = self::createInstance([
'dialog_id' => $dialog_id,
'dialog_type' => $dialog->type,
'session_id' => $dialog->session_id,
'reply_id' => $reply_id,
'forward_id' => $forward_id,
'userid' => $sender,
@@ -1192,6 +1247,8 @@ class WebSocketDialogMsg extends AbstractModel
$dialogMsg->send = 1;
$dialogMsg->generateKeyAndSave($search_key);
//
WebSocketDialogSession::updateTitle($dialogMsg->session_id, $dialogMsg);
//
if ($dialogMsg->type === 'meeting') {
MeetingMsg::createInstance([
'meetingid' => $dialogMsg->msg['meetingid'],
@@ -1223,6 +1280,55 @@ class WebSocketDialogMsg extends AbstractModel
}
}
/**
* 批量发送消息
* @param User $user 发送的会员
* @param array $userids 接收的会员ID
* @param array $dialogids 接收的会话ID
* @param string $msgText 发送的消息
* @return array
*/
public static function sendMsgBatch($user, $userids, $dialogids, $msgText)
{
return AbstractModel::transaction(function() use ($user, $userids, $dialogids, $msgText) {
$msgs = [];
$already = [];
if ($dialogids) {
if (!is_array($dialogids)) {
$dialogids = [$dialogids];
}
foreach ($dialogids as $dialogid) {
$res = WebSocketDialogMsg::sendMsg(null, $dialogid, 'text', ['text' => $msgText], $user->userid);
if (Base::isSuccess($res)) {
$msgs[] = $res['data'];
$already[] = $dialogid;
}
}
}
if ($userids) {
if (!is_array($userids)) {
$userids = [$userids];
}
foreach ($userids as $userid) {
if (!User::whereUserid($userid)->exists()) {
continue;
}
$dialog = WebSocketDialog::checkUserDialog($user, $userid);
if ($dialog && !in_array($dialog->id, $already)) {
$res = WebSocketDialogMsg::sendMsg(null, $dialog->id, 'text', ['text' => $msgText], $user->userid);
if (Base::isSuccess($res)) {
$msgs[] = $res['data'];
}
}
}
}
return Base::retSuccess('发送成功', [
'msgs' => $msgs
]);
});
}
/**
* 将被@的人加入群
* @param WebSocketDialog $dialog 对话

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Models;
use App\Module\Base;
use App\Module\Extranet;
use Swoole\Coroutine;
use Cache;
/**
* App\Models\WebSocketDialogSession
*
* @property int $id
* @property int $dialog_id 对话ID
* @property string $title 会话标题
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\WebSocketDialog|null $dialog
* @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|WebSocketDialogSession newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogSession newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogSession query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogSession whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogSession whereDialogId($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogSession whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogSession whereTitle($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogSession whereUpdatedAt($value)
* @mixin \Eloquent
*/
class WebSocketDialogSession extends AbstractModel
{
/**
* 可以批量赋值的属性
*
* @var array
*/
protected $fillable = [
'dialog_id',
'userid',
'title',
];
/**
* 获取关联的对话
*/
public function dialog()
{
return $this->belongsTo(WebSocketDialog::class, 'dialog_id');
}
/**
* @param $sessionId
* @param WebSocketDialogMsg $dialogMsg
* @return void
*/
public static function updateTitle($sessionId, $dialogMsg)
{
if (!$sessionId) {
return;
}
if ($dialogMsg->type != 'text') {
return;
}
$cacheKey = 'dialog_session_title_' . $sessionId;
if (Cache::has($cacheKey)) {
return;
}
$originalTitle = $dialogMsg->key ?: $dialogMsg->msg['text'] ?: 'Untitled';
$title = Base::cutStr($originalTitle, 100);
if ($title == '...') {
return;
}
$session = self::whereId($sessionId)->first();
if (!$session) {
return;
}
$session->title = $title;
$session->save();
Cache::forever($cacheKey, true);
// 通过AI接口更新对话标题
go(function () use ($session, $title, $originalTitle) {
Coroutine::sleep(0.1);
$res = Extranet::openAIGenerateTitle($originalTitle);
if (Base::isError($res)) {
return;
}
$newTitle = $res['data'];
if ($newTitle && $newTitle != $title) {
$session->title = Base::cutStr($newTitle, 100);
$session->save();
}
});
}
}

View File

@@ -2,14 +2,13 @@
namespace App\Models;
use Carbon\Carbon;
/**
* App\Models\WebSocketDialogUser
*
* @property int $id
* @property int|null $dialog_id 对话ID
* @property int|null $userid 会员ID
* @property int|null $bot 是否机器人
* @property \Illuminate\Support\Carbon|null $top_at 置顶时间
* @property \Illuminate\Support\Carbon|null $last_at 最后消息时间
* @property int|null $mark_unread 是否标记为未读0否1是
@@ -30,6 +29,7 @@ use Carbon\Carbon;
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogUser query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogUser whereBot($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogUser whereColor($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogUser whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogUser whereDialogId($value)

View File

@@ -9,11 +9,12 @@ use App\Services\RequestContext;
use Cache;
use Carbon\Carbon;
use League\CommonMark\CommonMarkConverter;
use League\CommonMark\Exception\CommonMarkException;
use League\HTMLToMarkdown\HtmlConverter;
use Overtrue\Pinyin\Pinyin;
use Redirect;
use Request;
use Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Symfony\Component\HttpFoundation\File\File;
use Validator;
@@ -801,16 +802,16 @@ class Base
str_starts_with(str_replace(' ', '', $str), "data:image/")
) {
return $str;
} else {
if (RequestContext::has('fill_url_remote_url')) {
return "{{RemoteURL}}" . $str;
}
try {
return url($str);
} catch (\Throwable) {
return self::getSchemeAndHost() . "/" . $str;
}
}
if (RequestContext::has('fill_url_remote_url')) {
return "{{RemoteURL}}" . $str;
}
try {
$fillUrl = url($str);
} catch (\Throwable) {
$fillUrl = self::getSchemeAndHost() . "/" . $str;
}
return RequestContext::replaceBaseUrl($fillUrl);
}
/**
@@ -1294,7 +1295,7 @@ class Base
* 获取或设置
* @param $setname // 配置名称
* @param bool $array // 保存内容
* @param false $isUpdate // 保存内容为更新模式,默认否
* @param bool $isUpdate // 保存内容为更新模式,默认否
* @return array
*/
public static function setting($setname, $array = false, $isUpdate = false)
@@ -1475,14 +1476,36 @@ class Base
public static function forumHourDay($hour)
{
$hour = intval($hour);
if ($hour > 24) {
if ($hour >= 24) {
$day = floor($hour / 24);
$hour -= $day * 24;
return $day . '天' . $hour . '小时';
if ($hour > 0) {
return $day . '天' . $hour . '小时';
}
return $day . '天';
}
return $hour . '小时';
}
/**
* 分钟转天/小时/分钟
* @param $minute
* @return string
*/
public static function forumMinuteDay($minute)
{
$minute = intval($minute);
if ($minute >= 60) {
$hour = floor($minute / 60);
$minute -= $hour * 60;
if ($minute > 0) {
return Base::forumHourDay($hour) . $minute . '分钟';
}
return Base::forumHourDay($hour);
}
return $minute . '分钟';
}
/**
* 创建Carbon对象
* @param $var
@@ -1849,18 +1872,18 @@ class Base
if (!in_array($extension, ['mp3', 'wav'])) {
return Base::retError('语音格式错误');
}
$fileName = 'record_' . md5($base64) . '.' . $extension;
$saveName = 'record_' . md5($base64) . '.' . $extension;
$fileDir = $param['path'];
$filePath = public_path($fileDir);
Base::makeDir($filePath);
if (file_put_contents($filePath . $fileName, base64_decode(str_replace($res[1], '', $base64)))) {
$fileSize = filesize($filePath . $fileName);
if (file_put_contents($filePath . $saveName, base64_decode(str_replace($res[1], '', $base64)))) {
$fileSize = filesize($filePath . $saveName);
$array = [
"name" => $fileName, //原文件名
"name" => $saveName, //原文件名
"size" => Base::twoFloat($fileSize / 1024, true), //大小KB
"file" => $filePath . $fileName, //文件的完整路径 "D:\www....KzZ.jpg"
"path" => $fileDir . $fileName, //相对路径 "uploads/pic....KzZ.jpg"
"url" => Base::fillUrl($fileDir . $fileName), //完整的URL "https://.....hhsKzZ.jpg"
"file" => $filePath . $saveName, //文件的完整路径 "D:\www....KzZ.jpg"
"path" => $fileDir . $saveName, //相对路径 "uploads/pic....KzZ.jpg"
"url" => Base::fillUrl($fileDir . $saveName), //完整的URL "https://.....hhsKzZ.jpg"
"ext" => $extension, //文件后缀名
];
return Base::retSuccess('success', $array);
@@ -1871,8 +1894,23 @@ class Base
/**
* image64图片保存
* @param array $param [ image64=带前缀的base64, path=>文件路径, fileName=>文件名称, scale=>[压缩原图宽,高, 压缩方式], autoThumb=>false不要自动生成缩略图, 'quality'=>压缩图片质量(默认0不压缩) ]
* @return array [name=>文件名, size=>文件大小(单位KB),file=>绝对地址, path=>相对地址, url=>全路径地址, ext=>文件后缀名]
* @param array $param [
image64=带前缀的base64,
path=>文件路径,
fileName=>文件名称,
saveName=>保存文件名称,
scale=>[压缩原图宽,高, 压缩方式],
autoThumb=>false不要自动生成缩略图,
quality=>压缩图片质量(默认0不压缩)
]
* @return array [
name=>文件名,
size=>文件大小(单位KB),
file=>绝对地址,
path=>相对地址,
url=>全路径地址,
ext=>文件后缀名
]
*/
public static function image64save($param)
{
@@ -1883,8 +1921,8 @@ class Base
return Base::retError('图片格式错误');
}
$scaleName = "";
if ($param['fileName']) {
$fileName = basename($param['fileName']);
if ($param['saveName']) {
$saveName = basename($param['saveName']);
} else {
if ($param['scale'] && is_array($param['scale'])) {
list($width, $height) = $param['scale'];
@@ -1895,21 +1933,21 @@ class Base
}
}
}
$fileName = 'paste_' . md5($imgBase64) . '.' . $extension;
$saveName = 'paste_' . md5($imgBase64) . '.' . $extension;
$scaleName = md5_file($imgBase64) . $scaleName . '.' . $extension;
}
$fileDir = $param['path'];
$filePath = public_path($fileDir);
$fileFullPath = $filePath . $fileName;
$fileFullPath = $filePath . $saveName;
Base::makeDir($filePath);
if (file_put_contents($fileFullPath, base64_decode(str_replace($res[1], '', $imgBase64)))) {
$fileSize = filesize($fileFullPath);
$array = [
"name" => $fileName, //原文件名
"name" => $param['fileName'] ?: $saveName, //原文件名
"size" => Base::twoFloat($fileSize / 1024, true), //大小KB
"file" => $fileFullPath, //文件的完整路径 "D:\www....KzZ.jpg"
"path" => $fileDir . $fileName, //相对路径 "uploads/pic....KzZ.jpg"
"url" => Base::fillUrl($fileDir . $fileName), //完整的URL "https://.....hhsKzZ.jpg"
"path" => $fileDir . $saveName, //相对路径 "uploads/pic....KzZ.jpg"
"url" => Base::fillUrl($fileDir . $saveName), //完整的URL "https://.....hhsKzZ.jpg"
"thumb" => '', //缩略图(预览图) "https://.....hhsKzZ.jpg_thumb.jpg"
"width" => -1, //图片宽度
"height" => -1, //图片高度
@@ -1940,10 +1978,10 @@ class Base
// 重命名
if ($scaleName) {
$scaleName = str_replace(['{WIDTH}', '{HEIGHT}'], [$array['width'], $array['height']], $scaleName);
if (rename($array['file'], Base::rightDelete($array['file'], $fileName) . $scaleName)) {
$array['file'] = Base::rightDelete($array['file'], $fileName) . $scaleName;
$array['path'] = Base::rightDelete($array['path'], $fileName) . $scaleName;
$array['url'] = Base::rightDelete($array['url'], $fileName) . $scaleName;
if (rename($array['file'], Base::rightDelete($array['file'], $saveName) . $scaleName)) {
$array['file'] = Base::rightDelete($array['file'], $saveName) . $scaleName;
$array['path'] = Base::rightDelete($array['path'], $saveName) . $scaleName;
$array['url'] = Base::rightDelete($array['url'], $saveName) . $scaleName;
}
}
}
@@ -1977,12 +2015,14 @@ class Base
file=>Request::file,
path=>文件路径,
fileName=>文件名称,
saveName=>保存文件名称,
scale=>[压缩原图宽,高, 压缩方式],
size=>限制大小KB,
autoThumb=>false不要自动生成缩略图,
chmod=>权限(默认0644),
quality=>压缩图片质量(默认0不压缩),
convertVideo=>转换视频格式(默认false) ,
compressVideo=>压缩视频(默认false如果转换就不压缩) ,
]
* @return array [
name=>原文件名,
@@ -2068,10 +2108,10 @@ class Base
}
}
$scaleName = "";
if ($param['fileName'] === true) {
$fileName = $file->getClientOriginalName();
} elseif ($param['fileName']) {
$fileName = basename($param['fileName']);
if ($param['saveName'] === true) {
$saveName = $file->getClientOriginalName();
} elseif ($param['saveName']) {
$saveName = basename($param['saveName']);
} else {
if ($param['scale'] && is_array($param['scale'])) {
list($width, $height) = $param['scale'];
@@ -2082,19 +2122,19 @@ class Base
}
}
}
$fileName = md5_file($file);
$saveName = md5_file($file);
$scaleName = md5_file($file) . $scaleName;
if ($extension) {
$fileName = $fileName . '.' . $extension;
$saveName = $saveName . '.' . $extension;
$scaleName = $scaleName . '.' . $extension;
}
}
//
$file->move(public_path($param['path']), $fileName);
$file->move(public_path($param['path']), $saveName);
//
$path = $param['path'] . $fileName;
$path = $param['path'] . $saveName;
$array = [
"name" => $file->getClientOriginalName(), //原文件名
"name" => $param['fileName'] ?: $file->getClientOriginalName(), //原文件名
"size" => Base::twoFloat($fileSize / 1024, true), //大小KB
"file" => public_path($path), //文件的完整路径 "D:\www....KzZ.jpg"
"path" => $path, //相对路径 "uploads/pic....KzZ.jpg"
@@ -2140,6 +2180,7 @@ class Base
}
@shell_exec($command);
if (file_exists($output) && filesize($output) > 0) {
// 压缩后的文件正常
@unlink($array['file']);
$array = array_merge($array, [
"name" => Base::rightReplace($array['name'], ".{$array['ext']}", '.mp4'),
@@ -2150,6 +2191,27 @@ class Base
"ext" => 'mp4',
]);
}
$param['compressVideo'] = false; // 如果转换就不压缩
}
if ($param['compressVideo'] && $array['ext'] == 'mp4') {
// 压缩视频
$output = $array['file'] . '_compress';
$command = sprintf("ffmpeg -y -i %s -c:v libx264 -crf 28 -preset medium -c:a aac -b:a 96k %s 2>&1", escapeshellarg($array['file']), escapeshellarg($output));
@shell_exec($command);
if (file_exists($output) && filesize($output) > 0) {
// 压缩后的文件正常
if (filesize($output) < filesize($array['file'])) {
// 小于原文件
@unlink($array['file']);
$array = array_merge($array, [
"size" => Base::twoFloat(filesize($output) / 1024, true),
"file" => $output,
]);
} else {
// 大于原文件
@unlink($output);
}
}
}
if (in_array($array['ext'], ['mov', 'webm', 'mp4'])) {
// 视频尺寸
@@ -2184,10 +2246,10 @@ class Base
// 重命名
if ($scaleName) {
$scaleName = str_replace(['{WIDTH}', '{HEIGHT}'], [$array['width'], $array['height']], $scaleName);
if (rename($array['file'], Base::rightDelete($array['file'], $fileName) . $scaleName)) {
$array['file'] = Base::rightDelete($array['file'], $fileName) . $scaleName;
$array['path'] = Base::rightDelete($array['path'], $fileName) . $scaleName;
$array['url'] = Base::rightDelete($array['url'], $fileName) . $scaleName;
if (rename($array['file'], Base::rightDelete($array['file'], $saveName) . $scaleName)) {
$array['file'] = Base::rightDelete($array['file'], $saveName) . $scaleName;
$array['path'] = Base::rightDelete($array['path'], $saveName) . $scaleName;
$array['url'] = Base::rightDelete($array['url'], $saveName) . $scaleName;
}
}
}
@@ -2758,12 +2820,12 @@ class Base
}
/**
* BinaryFileResponse 下载文件
* DownloadFileResponse 下载文件
* @param File|\SplFileInfo|string $file 文件对象或路径
* @param string|null $name 下载文件名
* @return \Symfony\Component\HttpFoundation\BinaryFileResponse
* @return StreamedResponse
*/
public static function BinaryFileResponse($file, $name = null)
public static function DownloadFileResponse($file, $name = null)
{
try {
// 处理文件对象
@@ -2780,6 +2842,12 @@ class Base
throw new FileException('File must be readable and exist.');
}
// 获取文件信息
$size = $file->getSize();
if ($size === false || $size < 0) {
throw new FileException('Unable to determine file size.');
}
// 处理文件名
if (empty($name)) {
$name = basename($file->getPathname());
@@ -2791,34 +2859,98 @@ class Base
$name = Base::cutStr($name, 180);
$name = str_replace(['"', '<', '>', '|', '/', '\\', '?', ':'], '', $name);
// IE 浏览器特殊处理
$encodedName = $name;
if (isset($_SERVER['HTTP_USER_AGENT']) && preg_match("/MSIE|Internet Explorer|Trident/i", $_SERVER['HTTP_USER_AGENT'])) {
$encodedName = rawurlencode($name);
// 获取MIME类型
$mimeType = $file->getMimeType();
if (empty($mimeType)) {
$mimeType = 'application/octet-stream';
}
// 创建响应对象
return new \Symfony\Component\HttpFoundation\BinaryFileResponse($file->getPathname(), 200, [
'Content-Type' => $file->getMimeType() ?: 'application/octet-stream',
// 处理 Range 请求
$start = 0;
$end = $size - 1;
$length = $size;
$isRangeRequest = false;
if (isset($_SERVER['HTTP_RANGE'])) {
$range = str_replace('bytes=', '', $_SERVER['HTTP_RANGE']);
if (preg_match('/^(\d+)-(\d*)$/', $range, $matches)) {
$start = intval($matches[1]);
$end = !empty($matches[2]) ? intval($matches[2]) : $size - 1;
// 验证范围的有效性
if ($start >= 0 && $end < $size && $start <= $end) {
$length = $end - $start + 1;
$isRangeRequest = true;
} else {
$start = 0;
$end = $size - 1;
}
}
}
// 设置基本响应头
$headers = [
'Content-Type' => $mimeType,
'Content-Disposition' => sprintf(
'attachment; filename="%s"; filename*=UTF-8\'\'%s',
$encodedName,
$name,
rawurlencode($name)
),
// 添加缓存控制和安全相关的头
'Cache-Control' => 'private, no-transform, no-store, must-revalidate',
'Pragma' => 'public',
'Expires' => '0',
'Accept-Ranges' => 'bytes', // 支持断点续传
'X-Content-Type-Options' => 'nosniff', // 安全相关
]);
'Accept-Ranges' => 'bytes',
'Cache-Control' => 'private, no-transform, no-store, must-revalidate, max-age=0',
'Content-Length' => $length,
'Last-Modified' => gmdate('D, d M Y H:i:s', $file->getMTime()) . ' GMT',
'ETag' => sprintf('"%s"', md5_file($file->getPathname()))
];
if ($isRangeRequest) {
$headers['Content-Range'] = "bytes {$start}-{$end}/{$size}";
$statusCode = 206;
} else {
$statusCode = 200;
}
// 创建流式响应
return new StreamedResponse(
function () use ($file, $start, $length) {
$handle = fopen($file->getPathname(), 'rb');
if ($handle === false) {
throw new FileException('Cannot open file for reading');
}
if (fseek($handle, $start) === -1) {
fclose($handle);
throw new FileException('Cannot seek to position ' . $start);
}
$remaining = $length;
$bufferSize = 8192; // 8KB chunks
while ($remaining > 0 && !feof($handle)) {
$readSize = min($bufferSize, $remaining);
$buffer = fread($handle, $readSize);
if ($buffer === false) {
break;
}
echo $buffer;
flush();
$remaining -= strlen($buffer);
}
fclose($handle);
},
$statusCode,
$headers
);
} catch (\Exception $e) {
\Log::error('File download failed', [
'error' => $e->getMessage(),
'file' => $file->getPathname() ?? null,
'trace' => $e->getTraceAsString(),
'file' => $file ?? null,
'name' => $name ?? null,
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? null, // 添加更多调试信息
'ip' => request()->ip()
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? null,
'ip' => request()->ip(),
'range' => $_SERVER['HTTP_RANGE'] ?? null
]);
abort(403, 'File download failed');
}
@@ -2884,11 +3016,26 @@ class Base
*/
public static function markdown2html($markdown)
{
$converter = new CommonMarkConverter();
try {
$converter = new CommonMarkConverter();
return $converter->convert($markdown);
} catch (CommonMarkException $e) {
} catch (\League\CommonMark\Exception\CommonMarkException $e) {
return $markdown;
}
}
/**
* html 转 MD(markdown)
* @param $html
* @return mixed|string
*/
public static function html2markdown($html)
{
try {
$converter = new HtmlConverter();
return $converter->convert($html);
} catch (\Exception) {
return $html;
}
}
}

View File

@@ -15,9 +15,10 @@ class Extranet
/**
* 通过 openAI 语音转文字
* @param string $filePath
* @param array $extParams
* @return array
*/
public static function openAItranscriptions($filePath)
public static function openAItranscriptions($filePath, $extParams = [])
{
if (!file_exists($filePath)) {
return Base::retError("语音文件不存在");
@@ -27,32 +28,34 @@ class Extranet
if ($systemSetting['voice2text'] !== 'open' || empty($aibotSetting['openai_key'])) {
return Base::retError("语音转文字功能未开启");
}
//
$extra = [
'Content-Type' => 'multipart/form-data',
'Authorization' => 'Bearer ' . $aibotSetting['openai_key'],
];
if ($aibotSetting['openai_agency']) {
$extra['CURLOPT_PROXY'] = $aibotSetting['openai_agency'];
if (str_contains($aibotSetting['openai_agency'], 'socks')) {
$extra['CURLOPT_PROXYTYPE'] = CURLPROXY_SOCKS5;
} else {
$extra['CURLOPT_PROXYTYPE'] = CURLPROXY_HTTP;
}
$extra['CURLOPT_PROXYTYPE'] = str_contains($aibotSetting['openai_agency'], 'socks') ? CURLPROXY_SOCKS5 : CURLPROXY_HTTP;
}
$res = Ihttp::ihttp_request('https://api.openai.com/v1/audio/transcriptions', [
$post = array_merge($extParams, [
'file' => new \CURLFile($filePath),
'model' => 'whisper-1'
], $extra, 15);
if (Base::isError($res)) {
return Base::retError("语音转文字失败", $res);
'model' => 'whisper-1',
]);
$cacheKey = "openAItranscriptions::" . md5($filePath . '_' . Base::array2json($extra) . '_' . Base::array2json($extParams));
$result = Cache::remember($cacheKey, Carbon::now()->addDays(), function() use ($extra, $post) {
$res = Ihttp::ihttp_request('https://api.openai.com/v1/audio/transcriptions', $post, $extra, 15);
if (Base::isError($res)) {
return Base::retError("语音转文字失败", $res);
}
$resData = Base::json2array($res['data']);
if (empty($resData['text'])) {
return Base::retError("语音转文字失败", $resData);
}
return Base::retSuccess("success", $resData['text']);
});
if (Base::isError($result)) {
Cache::forget($cacheKey);
}
$resData = Base::json2array($res['data']);
if (empty($resData['text'])) {
return Base::retError("语音转文字失败", $resData);
}
//
return Base::retSuccess("success", $resData['text']);
return $result;
}
/**
@@ -74,18 +77,78 @@ class Extranet
];
if ($aibotSetting['openai_agency']) {
$extra['CURLOPT_PROXY'] = $aibotSetting['openai_agency'];
if (str_contains($aibotSetting['openai_agency'], 'socks')) {
$extra['CURLOPT_PROXYTYPE'] = CURLPROXY_SOCKS5;
} else {
$extra['CURLOPT_PROXYTYPE'] = CURLPROXY_HTTP;
}
$extra['CURLOPT_PROXYTYPE'] = str_contains($aibotSetting['openai_agency'], 'socks') ? CURLPROXY_SOCKS5 : CURLPROXY_HTTP;
}
$res = Ihttp::ihttp_request('https://api.openai.com/v1/chat/completions', json_encode([
"model" => "gpt-3.5-turbo",
$post = json_encode([
"model" => "gpt-4o-mini",
"messages" => [
[
"role" => "system",
"content" => "你是一个专业的翻译器,翻译的结果尽量符合“项目任务管理系统”的使用,并且翻译的结果不用额外添加换行尽量保持原格式,将提供的文本翻译成“{$targetLanguage}”语言。"
"content" => <<<EOF
你是一名专业翻译人员,请将 <translation_original_text> 标签内的内容翻译为{$targetLanguage}。
翻译要求:
- 翻译结果需符合“项目任务管理系统”的专业术语和使用场景。
- 保持原文格式、结构和排版不变。
- 语言表达准确、简洁,符合项目管理领域的行业规范。
- 注意专业术语的一致性和连贯性。
EOF
],
[
"role" => "user",
"content" => "<translation_original_text>{$text}</translation_original_text>"
]
]
]);
$cacheKey = "openAItranslations::" . md5(Base::array2json($extra) . '_' . Base::array2json($post));
$result = Cache::remember($cacheKey, Carbon::now()->addDays(), function() use ($extra, $post) {
$res = Ihttp::ihttp_request('https://api.openai.com/v1/chat/completions', $post, $extra, 15);
if (Base::isError($res)) {
return Base::retError("翻译失败", $res);
}
$resData = Base::json2array($res['data']);
if (empty($resData['choices'])) {
return Base::retError("翻译失败", $resData);
}
$result = $resData['choices'][0]['message']['content'];
$result = preg_replace('/^\"|\"$/', '', trim($result));
$result = preg_replace('/<\/*translation_original_text>/', '', trim($result));
if (empty($result)) {
return Base::retError("翻译失败", $result);
}
return Base::retSuccess("success", $result);
});
if (Base::isError($result)) {
Cache::forget($cacheKey);
}
return $result;
}
/**
* 通过 openAI 生成标题
* @param $text
* @return array
*/
public static function openAIGenerateTitle($text)
{
$aibotSetting = Base::setting('aibotSetting');
if (empty($aibotSetting['openai_key'])) {
return Base::retError("AI接口未配置");
}
$extra = [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $aibotSetting['openai_key'],
];
if ($aibotSetting['openai_agency']) {
$extra['CURLOPT_PROXY'] = $aibotSetting['openai_agency'];
$extra['CURLOPT_PROXYTYPE'] = str_contains($aibotSetting['openai_agency'], 'socks') ? CURLPROXY_SOCKS5 : CURLPROXY_HTTP;
}
$res = Ihttp::ihttp_request('https://api.openai.com/v1/chat/completions', json_encode([
"model" => "gpt-4o-mini",
"messages" => [
[
"role" => "system",
"content" => "你是一个专业的标题生成器,擅长为对话生成简洁的标题,请将提供的文本生成一个标题。"
],
[
"role" => "user",
@@ -94,20 +157,61 @@ class Extranet
]
]), $extra, 15);
if (Base::isError($res)) {
return Base::retError("翻译失败", $res);
return Base::retError("生成失败", $res);
}
$resData = Base::json2array($res['data']);
if (empty($resData['choices'])) {
return Base::retError("翻译失败", $resData);
return Base::retError("生成失败", $resData);
}
$result = $resData['choices'][0]['message']['content'];
$result = preg_replace('/^\"|\"$/', '', $result);
if (empty($result)) {
return Base::retError("翻译失败", $result);
return Base::retError("生成失败", $result);
}
return Base::retSuccess("success", $result);
}
/**
* 获取 ollama 模型
* @param $baseUrl
* @param $key
* @param $agency
* @return array
*/
public static function ollamaModels($baseUrl, $key = null, $agency = null)
{
$extra = [
'Content-Type' => 'application/json',
];
if ($key) {
$extra['Authorization'] = 'Bearer ' . $key;
}
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'];
}
}
return Base::retSuccess("success", [
'models' => $models,
'original' => $resData['models']
]);
}
/**
* 获取IP地址经纬度
* @param string $ip

View File

@@ -25,11 +25,23 @@ class MsgTool
}
$isMd = strtolower($type) === 'md';
$placeholders = [];
// 如果是Markdown转换为HTML
// 如果是Markdown先处理特殊标记及转换为HTML
if ($isMd) {
$converter = new CommonMarkConverter();
// 处理特殊标记
$pattern = '/:::\s*reasoning\s+(.*?)\s*:::/s';
$counter = 0;
$text = preg_replace_callback($pattern, function($matches) use ($type, $length, &$placeholders, &$counter) {
// 使用更简短的占位符避免被markdown解析
$placeholder = "@PH::{$counter}::PH@";
$placeholders[$placeholder] = "::: reasoning\n" . self::truncateText($matches[1], $length, $type) . "\n:::";
$counter++;
return $placeholder;
}, $text);
// 转换为HTML
try {
$converter = new CommonMarkConverter();
$text = $converter->convert($text);
} catch (CommonMarkException) {
return "";
@@ -50,10 +62,26 @@ class MsgTool
// 递归函数来遍历节点并截取内容
self::traverseNodes($body, $currentLength, $length, $truncatedHtml);
// 如果是Markdown转换回Markdown
// 如果是Markdown转换回Markdown及还原特殊标记
if ($isMd) {
$converter = new HtmlConverter();
$truncatedHtml = $converter->convert($truncatedHtml);
// 转换回Markdown
try {
$converter = new HtmlConverter();
$truncatedHtml = $converter->convert($truncatedHtml);
} catch (\Exception) {
return "";
}
// 还原特殊标记
if (!empty($placeholders)) {
$truncatedHtml = preg_replace('/@P?H?:*\s*$/', '', $truncatedHtml);
$preCount = substr_count($truncatedHtml, '@PH::');
$sufCount = substr_count($truncatedHtml, '::PH@');
$diffCount = $preCount - $sufCount;
if ($diffCount > 0) {
$truncatedHtml .= str_repeat('::PH@', $diffCount);
}
$truncatedHtml = strtr($truncatedHtml, $placeholders);
}
}
return $truncatedHtml;

View File

@@ -0,0 +1,259 @@
<?php
namespace App\Module;
use Exception;
use PhpOffice\PhpWord\IOFactory as WordIOFactory;
use PhpOffice\PhpSpreadsheet\IOFactory as SpreadsheetIOFactory;
use PhpOffice\PhpPresentation\IOFactory as PresentationIOFactory;
use Illuminate\Support\Facades\File as FileFacade;
class TextExtractor
{
private string $filePath;
private string $fileMimeType;
private string $fileExtension;
/**
* @param string $filePath
* @throws Exception
*/
public function __construct(string $filePath)
{
if (!file_exists($filePath)) {
throw new Exception("File does not exist: {$filePath}");
}
$this->filePath = $filePath;
$this->fileMimeType = FileFacade::mimeType($filePath);
$this->fileExtension = $this->detectFileType();
}
/**
* 从文件中提取文本
* @return string
* @throws Exception
*/
public function extractContent(): string
{
return match ($this->fileExtension) {
// Word文档
'docx' => $this->parseWordDocument(),
// Excel文档
'xlsx', 'xls', 'csv' => $this->parseSpreadsheet(),
// PowerPoint文档
'ppt', 'pptx' => $this->parsePresentation(),
// PDF文档
'pdf' => $this->parsePdf(),
// RTF文档
'rtf' => $this->parseRtf(),
// 其他文本文件
default => $this->parseOther(),
};
}
/**
* 获取文件类型
* @return string
*/
private function detectFileType(): string
{
return match ($this->fileMimeType) {
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
'application/vnd.ms-excel' => 'xls',
'text/csv', 'application/csv' => 'csv',
'application/vnd.ms-powerpoint' => 'ppt',
'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx',
'application/pdf' => 'pdf',
'application/rtf', 'text/rtf' => 'rtf',
default => strtolower(pathinfo($this->filePath, PATHINFO_EXTENSION)),
};
}
/**
* Parse Word documents (.doc, .docx)
* @return string
*/
private function parseWordDocument(): string
{
$phpWord = WordIOFactory::load($this->filePath);
$text = '';
// Extract text from each section
foreach ($phpWord->getSections() as $section) {
foreach ($section->getElements() as $element) {
if (method_exists($element, 'getText')) {
$text .= $element->getText() . "\n";
} elseif (method_exists($element, 'getElements')) {
foreach ($element->getElements() as $childElement) {
if (method_exists($childElement, 'getText')) {
$text .= $childElement->getText() . "\n";
}
}
}
}
}
return $text;
}
/**
* Parse spreadsheet files (.xlsx, .xls, .csv)
* @return string
*/
private function parseSpreadsheet(): string
{
$spreadsheet = SpreadsheetIOFactory::load($this->filePath);
$text = '';
// Extract text from all worksheets
foreach ($spreadsheet->getWorksheetIterator() as $worksheet) {
$text .= 'Worksheet: ' . $worksheet->getTitle() . "\n";
foreach ($worksheet->getRowIterator() as $row) {
$cellIterator = $row->getCellIterator();
$cellIterator->setIterateOnlyExistingCells(false);
$rowText = '';
foreach ($cellIterator as $cell) {
$value = $cell->getValue();
if (!empty($value)) {
$rowText .= $value . "\t";
}
}
if (!empty(trim($rowText))) {
$text .= trim($rowText) . "\n";
}
}
$text .= "\n";
}
return $text;
}
/**
* Parse presentation files (.ppt, .pptx)
* @return string
* @throws Exception
*/
private function parsePresentation(): string
{
$presentation = PresentationIOFactory::load($this->filePath);
$text = '';
// Extract text from all slides
foreach ($presentation->getAllSlides() as $slide) {
foreach ($slide->getShapeCollection() as $shape) {
if ($shape instanceof \PhpOffice\PhpPresentation\Shape\RichText) {
foreach ($shape->getParagraphs() as $paragraph) {
foreach ($paragraph->getRichTextElements() as $element) {
$text .= $element->getText();
}
$text .= "\n";
}
}
}
$text .= "\n";
}
return $text;
}
/**
* Parse PDF files (requires additional library like Smalot\PdfParser)
* @return string
* @throws Exception
*/
private function parsePdf(): string
{
// You'll need to install the Smalot PDF Parser: composer require smalot/pdfparser
if (!class_exists('\Smalot\PdfParser\Parser')) {
throw new \Exception("PDF Parser not available. Install with: composer require smalot/pdfparser");
}
$parser = new \Smalot\PdfParser\Parser();
$pdf = $parser->parseFile($this->filePath);
return $pdf->getText();
}
/**
* Parse RTF files
* @return string
*/
private function parseRtf(): string
{
// Simple RTF to text conversion
$content = file_get_contents($this->filePath);
// Remove RTF control words and groups
$content = preg_replace('/\\\\([a-z]{1,32})(-?[0-9]{1,10})?[ ]?/i', '', $content);
$content = preg_replace('/\\\\([^a-z]|[a-z]{33,})/i', '', $content);
$content = preg_replace('/\{\*?\\\\[^{}]*\}/', '', $content);
$content = preg_replace('/\{[\r\n]*\}/', '', $content);
// Convert special characters
$content = preg_replace('/\\\\\'([0-9a-f]{2})/i', '', $content);
// Remove remaining curly braces
$content = str_replace(['{', '}'], '', $content);
return $content ?: '';
}
/**
* Parse Other(text) files
* @return string
* @throws Exception
*/
private function parseOther(): string
{
$isBinary = !str_contains($this->fileMimeType, 'text/')
&& !str_contains($this->fileMimeType, 'application/json')
&& !str_contains($this->fileMimeType, 'application/xml');
if ($isBinary) {
throw new Exception("Unable to read the text content of this type of file");
}
return file_get_contents($this->filePath);
}
/** ********************************************************************* */
/** ********************************************************************* */
/** ********************************************************************* */
/**
* 获取文件内容
* @param $filePath
* @param int $fileMaxSize 最大文件大小单位字节默认1024KB
* @param int $contentMaxSize 最大内容大小单位字节默认300KB
* @return array
*/
public static function extractFile($filePath, int $fileMaxSize = 1024, int $contentMaxSize = 300): array
{
if (!file_exists($filePath) || !is_file($filePath)) {
return Base::retError("Failed to read contents of {$filePath}");
}
if (filesize($filePath) > $fileMaxSize * 1024) {
return Base::retError("File size exceeds " . Base::readableBytes($fileMaxSize * 1024) . ", unable to display content");
}
try {
$extractor = new self($filePath);
$content = $extractor->extractContent();
if (strlen($content) > $contentMaxSize * 1024) {
return Base::retError("Content size exceeds " . Base::readableBytes($contentMaxSize * 1024) . ", unable to display content");
}
return Base::retSuccess("success", $content);
} catch (Exception $e) {
return Base::retError($e->getMessage());
}
}
}

View File

@@ -0,0 +1,258 @@
<?php
namespace App\Module\ZincSearch;
/**
* ZincSearch 公共类
*/
class ZincSearchBase
{
private mixed $host;
private mixed $port;
private mixed $user;
private mixed $pass;
/**
* 构造函数
*/
public function __construct()
{
$this->host = env('ZINCSEARCH_HOST', 'search');
$this->port = env('ZINCSEARCH_PORT', '4080');
$this->user = env('DB_USERNAME', '');
$this->pass = env('DB_PASSWORD', '');
}
/**
* 通用请求方法
*/
private function request($path, $body = null, $method = 'POST')
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, "http://{$this->host}:{$this->port}{$path}");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_USERPWD, $this->user . ':' . $this->pass);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
$headers = ['Content-Type: application/json'];
if ($method === 'BULK') {
$headers = ['Content-Type: text/plain'];
$method = 'POST';
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$result = curl_exec($ch);
$error = curl_error($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($error) {
return ['success' => false, 'error' => $error];
}
$data = json_decode($result, true);
return [
'success' => $status >= 200 && $status < 300,
'status' => $status,
'data' => $data
];
}
// ==============================
// 索引管理相关方法
// ==============================
/**
* 创建索引
*/
public static function createIndex($index, $mappings = []): array
{
$body = json_encode([
'name' => $index,
'mappings' => $mappings
]);
return (new self())->request("/api/index", $body);
}
/**
* 获取索引信息
*/
public static function getIndex($index): array
{
return (new self())->request("/api/index/{$index}", null, 'GET');
}
/**
* 判断索引是否存在
*/
public static function indexExists($index): bool
{
$result = self::getIndex($index);
return $result['success'] && isset($result['data']['name']);
}
/**
* 获取所有索引
*/
public static function listIndices(): array
{
return (new self())->request("/api/index", null, 'GET');
}
/**
* 删除索引
*/
public static function deleteIndex($index): array
{
return (new self())->request("/api/index/{$index}", null, 'DELETE');
}
/**
* 删除所有索引
*/
public static function deleteAllIndices(): array
{
$instance = new self();
$result = $instance->request("/api/index", null, 'GET');
if (!$result['success']) {
return $result;
}
$indices = $result['data'] ?? [];
$deleteResults = [];
$success = true;
foreach ($indices as $index) {
$indexName = $index['name'] ?? '';
if (!empty($indexName)) {
$deleteResult = $instance->request("/api/index/{$indexName}", null, 'DELETE');
$deleteResults[$indexName] = $deleteResult;
if (!$deleteResult['success']) {
$success = false;
}
}
}
return [
'success' => $success,
'message' => $success ? '所有索引删除成功' : '部分索引删除失败',
'details' => $deleteResults
];
}
/**
* 分析文本
*/
public static function analyze($analyzer, $text): array
{
$body = json_encode([
'analyzer' => $analyzer,
'text' => $text
]);
return (new self())->request("/api/_analyze", $body);
}
// ==============================
// 文档管理相关方法
// ==============================
/**
* 写入单条文档
*/
public static function addDoc($index, $doc): array
{
$body = json_encode($doc);
return (new self())->request("/api/{$index}/_doc", $body);
}
/**
* 更新文档
*/
public static function updateDoc($index, $id, $doc): array
{
$body = json_encode($doc);
return (new self())->request("/api/{$index}/_update/{$id}", $body);
}
/**
* 删除文档
*/
public static function deleteDoc($index, $id): array
{
return (new self())->request("/api/{$index}/_doc/{$id}", null, 'DELETE');
}
/**
* 批量写入文档
*/
public static function addDocs($index, $docs): array
{
$body = json_encode([
'index' => $index,
'records' => $docs
]);
return (new self())->request("/api/_bulkv2", $body);
}
/**
* 使用原始BULK API批量写入文档
* 请求格式为Elasticsearch兼容格式
*/
public static function bulkDocs($data): array
{
return (new self())->request("/api/_bulk", $data, 'BULK');
}
// ==============================
// 搜索相关方法
// ==============================
/**
* 查询文档
*/
public static function search($index, $query, $from = 0, $size = 10): array
{
$searchParams = [
'search_type' => 'match',
'query' => [
'term' => $query
],
'from' => $from,
'max_results' => $size
];
$body = json_encode($searchParams);
return (new self())->request("/api/{$index}/_search", $body);
}
/**
* 高级查询文档
*/
public static function advancedSearch($index, $searchParams): array
{
$body = json_encode($searchParams);
return (new self())->request("/api/{$index}/_search", $body);
}
/**
* 兼容ES查询文档
*/
public static function elasticSearch($index, $searchParams): array
{
$body = json_encode($searchParams);
return (new self())->request("/es/{$index}/_search", $body);
}
/**
* 多索引查询
*/
public static function multiSearch($queries): array
{
$body = json_encode($queries);
return (new self())->request("/api/_msearch", $body);
}
}

View File

@@ -0,0 +1,565 @@
<?php
namespace App\Module\ZincSearch;
use App\Models\WebSocketDialogMsg;
use App\Models\WebSocketDialogUser;
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
use Swoole\Coroutine;
/**
* ZincSearch 会话消息类
*
* 使用方法:
*
* 1. 基础方法
* - 清空所有数据: clear();
*
* 2. 搜索方法
* - 关键词搜索: search('用户ID', '关键词');
*
* 3. 基本方法
* - 单个同步: sync(WebSocketDialogMsg $dialogMsg);
* - 批量同步: batchSync(WebSocketDialogMsg[] $dialogMsgs);
* - 用户同步: userSync(WebSocketDialogUser $dialogUser);
* - 删除消息: delete(WebSocketDialogMsg|WebSocketDialogUser|int $data);
*/
class ZincSearchDialogMsg
{
/**
* 索引名称
*/
protected static string $indexNameMsg = 'dialogMsg';
protected static string $indexNameUser = 'dialogUser';
// ==============================
// 基础方法
// ==============================
/**
* 确保索引存在
*/
private static function ensureIndex(): bool
{
if (!ZincSearchBase::indexExists(self::$indexNameMsg)) {
$mappings = [
'properties' => [
// 拓展数据
'dialog_userid' => ['type' => 'keyword', 'index' => true], // 对话ID+用户ID
'to_userid' => ['type' => 'numeric', 'index' => true], // 此消息发给的用户ID
// 消息数据
'id' => ['type' => 'numeric', 'index' => true],
'dialog_id' => ['type' => 'numeric', 'index' => true],
'dialog_type' => ['type' => 'keyword', 'index' => true],
'session_id' => ['type' => 'numeric', 'index' => true],
'userid' => ['type' => 'numeric', 'index' => true],
'type' => ['type' => 'keyword', 'index' => true],
'key' => ['type' => 'text', 'index' => true],
'created_at' => ['type' => 'date', 'index' => true],
'updated_at' => ['type' => 'date', 'index' => true],
]
];
$result = ZincSearchBase::createIndex(self::$indexNameMsg, $mappings);
return $result['success'] ?? false;
}
if (!ZincSearchBase::indexExists(self::$indexNameUser)) {
$mappings = [
'properties' => [
// 拓展数据
'dialog_userid' => ['type' => 'keyword', 'index' => true], // 对话ID+用户ID
// 用户数据
'id' => ['type' => 'numeric', 'index' => true],
'dialog_id' => ['type' => 'numeric', 'index' => true],
'userid' => ['type' => 'numeric', 'index' => true],
'top_at' => ['type' => 'date', 'index' => true],
'last_at' => ['type' => 'date', 'index' => true],
'mark_unread' => ['type' => 'numeric', 'index' => true],
'silence' => ['type' => 'numeric', 'index' => true],
'hide' => ['type' => 'numeric', 'index' => true],
'color' => ['type' => 'keyword', 'index' => true],
'created_at' => ['type' => 'date', 'index' => true],
'updated_at' => ['type' => 'date', 'index' => true],
]
];
$result = ZincSearchBase::createIndex(self::$indexNameUser, $mappings);
return $result['success'] ?? false;
}
return true;
}
/**
* 清空所有键值
*
* @return bool 是否成功
*/
public static function clear(): bool
{
// 检查索引是否存在然后删除
if (ZincSearchBase::indexExists(self::$indexNameMsg)) {
$deleteResult = ZincSearchBase::deleteIndex(self::$indexNameMsg);
if (!($deleteResult['success'] ?? false)) {
return false;
}
}
if (ZincSearchBase::indexExists(self::$indexNameUser)) {
$deleteResult = ZincSearchBase::deleteIndex(self::$indexNameUser);
if (!($deleteResult['success'] ?? false)) {
return false;
}
}
return self::ensureIndex();
}
// ==============================
// 搜索方法
// ==============================
/**
* 根据用户ID和消息关键词搜索会话
*
* @param string $userid 用户ID
* @param string $keyword 消息关键词
* @param int $from 起始位置
* @param int $size 返回结果数量
* @return array
*/
public static function search(string $userid, string $keyword, int $from = 0, int $size = 20): array
{
$searchParams = [
'query' => [
'bool' => [
'must' => [
['term' => ['to_userid' => $userid]],
['match_phrase' => ['key' => $keyword]]
]
]
],
'from' => $from,
'size' => $size,
'sort' => [
['updated_at' => 'desc']
]
];
try {
$result = ZincSearchBase::elasticSearch(self::$indexNameMsg, $searchParams);
$hits = $result['data']['hits']['hits'] ?? [];
// 收集所有的用户信息
$dialogUserids = [];
foreach ($hits as $hit) {
$source = $hit['_source'];
$dialogUserids[] = $source['dialog_userid'];
}
$userInfos = self::searchUser(array_unique($dialogUserids));
// 组合返回结果,将用户信息合并到消息中
$msgs = [];
foreach ($hits as $hit) {
$msgInfo = $hit['_source'];
$userInfo = $userInfos[$msgInfo['dialog_userid']] ?? [];
if ($userInfo) {
$msgs[] = [
'id' => $msgInfo['dialog_id'],
'search_msg_id' => $msgInfo['id'],
'user_at' => Carbon::parse($msgInfo['updated_at'])->format('Y-m-d H:i:s'),
'mark_unread' => $userInfo['mark_unread'],
'silence' => $userInfo['silence'],
'hide' => $userInfo['hide'],
'color' => $userInfo['color'],
'top_at' => Carbon::parse($userInfo['top_at'])->format('Y-m-d H:i:s'),
'last_at' => Carbon::parse($userInfo['last_at'])->format('Y-m-d H:i:s'),
];
}
}
return $msgs;
} catch (\Exception $e) {
Log::error('search: ' . $e->getMessage());
return [];
}
}
/**
* 根据对话用户ID搜索用户信息
* @param array $dialogUserids
* @return array
*/
private static function searchUser(array $dialogUserids): array
{
if (empty($dialogUserids)) {
return [];
}
$userInfos = [];
// 构建用户查询条件
$userSearchParams = [
'query' => [
'bool' => [
'should' => []
]
],
'size' => count($dialogUserids) // 确保取到所有符合条件的记录
];
// 添加所有 dialog_userid 到查询条件
foreach ($dialogUserids as $dialogUserid) {
$userSearchParams['query']['bool']['should'][] = [
'term' => ['dialog_userid' => $dialogUserid]
];
}
// 查询用户信息
$userResult = ZincSearchBase::elasticSearch(self::$indexNameUser, $userSearchParams);
$userHits = $userResult['data']['hits']['hits'] ?? [];
// 以 dialog_userid 为键保存用户信息
foreach ($userHits as $userHit) {
$userSource = $userHit['_source'];
$userInfos[$userSource['dialog_userid']] = $userSource;
}
return $userInfos;
}
// ==============================
// 基本方法
// ==============================
/**
* 生成 dialog_userid
*
* @param WebSocketDialogUser $dialogUser
* @return string
*/
private static function generateDialogUserid(WebSocketDialogUser $dialogUser): string
{
return "{$dialogUser->dialog_id}_{$dialogUser->userid}";
}
/**
* 生成文档内容
*
* @param WebSocketDialogMsg $dialogMsg
* @param WebSocketDialogUser $dialogUser
* @return array
*/
private static function generateMsgData(WebSocketDialogMsg $dialogMsg, WebSocketDialogUser $dialogUser): array
{
return [
'_id' => self::$indexNameMsg . "_" . $dialogMsg->id . "_" . $dialogUser->userid,
'dialog_userid' => self::generateDialogUserid($dialogUser),
'to_userid' => $dialogUser->userid,
'id' => $dialogMsg->id,
'dialog_id' => $dialogMsg->dialog_id,
'dialog_type' => $dialogMsg->dialog_type,
'session_id' => $dialogMsg->session_id,
'userid' => $dialogMsg->userid,
'type' => $dialogMsg->type,
'key' => $dialogMsg->key,
'created_at' => $dialogMsg->created_at,
'updated_at' => $dialogMsg->updated_at,
];
}
private static function generateUserData(WebSocketDialogUser $dialogUser): array
{
return [
'_id' => self::$indexNameUser . "_" . $dialogUser->id,
'dialog_userid' => self::generateDialogUserid($dialogUser),
'id' => $dialogUser->id,
'dialog_id' => $dialogUser->dialog_id,
'userid' => $dialogUser->userid,
'top_at' => $dialogUser->top_at,
'last_at' => $dialogUser->last_at,
'mark_unread' => $dialogUser->mark_unread,
'silence' => $dialogUser->silence,
'hide' => $dialogUser->hide,
'color' => $dialogUser->color,
'created_at' => $dialogUser->created_at,
'updated_at' => $dialogUser->updated_at,
];
}
/**
* 同步消息
*
* @param WebSocketDialogMsg $dialogMsg
* @return bool
*/
public static function sync(WebSocketDialogMsg $dialogMsg): bool
{
if (!self::ensureIndex()) {
return false;
}
if ($dialogMsg->bot) {
// 如果是机器人消息,跳过
return true;
}
try {
// 获取此会话的所有用户
$dialogUsers = WebSocketDialogUser::whereDialogId($dialogMsg->dialog_id)->get();
if ($dialogUsers->isEmpty()) {
return true;
}
$msgs = [];
$users = [];
foreach ($dialogUsers as $dialogUser) {
if (empty($dialogMsg->key)) {
// 如果消息没有关键词,跳过
continue;
}
if ($dialogUser->userid == 0) {
// 跳过系统用户
continue;
}
$msgs[] = self::generateMsgData($dialogMsg, $dialogUser);
$users[$dialogUser->id] = self::generateUserData($dialogUser);
}
if ($msgs) {
// 批量写入消息
ZincSearchBase::addDocs(self::$indexNameMsg, $msgs);
}
if ($users) {
// 批量写入用户
ZincSearchBase::addDocs(self::$indexNameUser, array_values($users));
}
return true;
} catch (\Exception $e) {
Log::error('sync: ' . $e->getMessage());
return false;
}
}
/**
* 批量同步消息
*
* @param WebSocketDialogMsg[] $dialogMsgs
* @return int 成功同步的消息数
*/
public static function batchSync($dialogMsgs): int
{
if (!self::ensureIndex()) {
return 0;
}
$count = 0;
try {
$msgs = [];
$users = [];
$userDialogs = [];
// 预处理收集所有涉及的对话ID
$dialogIds = [];
foreach ($dialogMsgs as $dialogMsg) {
$dialogIds[] = $dialogMsg->dialog_id;
}
$dialogIds = array_unique($dialogIds);
// 获取所有相关的用户-对话关系
if (!empty($dialogIds)) {
$dialogUsers = WebSocketDialogUser::whereIn('dialog_id', $dialogIds)->get();
// 按对话ID组织用户
foreach ($dialogUsers as $dialogUser) {
$userDialogs[$dialogUser->dialog_id][] = $dialogUser;
}
}
// 为每条消息准备所有相关用户的文档
foreach ($dialogMsgs as $dialogMsg) {
if (!isset($userDialogs[$dialogMsg->dialog_id])) {
// 如果该会话没有用户,跳过
continue;
}
if ($dialogMsg->bot) {
// 如果是机器人消息,跳过
continue;
}
/** @var WebSocketDialogUser $dialogUser */
foreach ($userDialogs[$dialogMsg->dialog_id] as $dialogUser) {
if (empty($dialogMsg->key)) {
// 如果消息没有关键词,跳过
continue;
}
if ($dialogUser->userid == 0) {
// 跳过系统用户
continue;
}
$msgs[] = self::generateMsgData($dialogMsg, $dialogUser);
$users[$dialogUser->id] = self::generateUserData($dialogUser);
$count++;
}
}
if ($msgs) {
// 批量写入消息
ZincSearchBase::addDocs(self::$indexNameMsg, $msgs);
}
if ($users) {
// 批量写入用户
ZincSearchBase::addDocs(self::$indexNameUser, array_values($users));
}
} catch (\Exception $e) {
Log::error('batchSync: ' . $e->getMessage());
}
return $count;
}
/**
* 同步用户
* @param WebSocketDialogUser $dialogUser
* @return bool
*/
public static function userSync(WebSocketDialogUser $dialogUser): bool
{
if (!self::ensureIndex()) {
return false;
}
$data = self::generateUserData($dialogUser);
// 生成查询用户条件
$searchParams = [
'query' => [
'bool' => [
'must' => [
['term' => ['dialog_userid' => $data['dialog_userid']]]
]
]
],
'size' => 1
];
try {
// 查询用户是否存在
$result = ZincSearchBase::elasticSearch(self::$indexNameUser, $searchParams);
$hits = $result['data']['hits']['hits'] ?? [];
// 同步用户(存在更新、不存在添加)
$result = ZincSearchBase::addDoc(self::$indexNameUser, $data);
if (!isset($result['success'])) {
return false;
}
// 用户不存在,同步消息
if (empty($hits)) {
go(function () use ($dialogUser) {
Coroutine::sleep(0.1);
$lastId = 0; // 上次同步的最后ID
$batchSize = 500; // 每批处理的消息数量
// 分批同步消息
do {
// 获取一批
$dialogMsgs = WebSocketDialogMsg::whereDialogId($dialogUser->dialog_id)
->where('id', '>', $lastId)
->orderBy('id')
->limit($batchSize)
->get();
if ($dialogMsgs->isEmpty()) {
break;
}
// 同步数据
ZincSearchDialogMsg::batchSync($dialogMsgs);
// 更新最后ID
$lastId = $dialogMsgs->last()->id;
} while (count($dialogMsgs) == $batchSize);
});
}
return true;
} catch (\Exception $e) {
Log::error('userSync: ' . $e->getMessage());
return false;
}
}
/**
* 删除
*
* @param WebSocketDialogMsg|WebSocketDialogUser|int $data
* @return int
*/
public static function delete(mixed $data): int
{
$batchSize = 500; // 每批处理的文档数量
$totalDeleted = 0; // 总共删除的文档数量
$from = 0;
// 根据数据类型生成查询条件
if ($data instanceof WebSocketDialogMsg) {
$query = [
'field' => 'id',
'term' => (string) $data->id
];
} elseif ($data instanceof WebSocketDialogUser) {
$query = [
'field' => 'dialog_userid',
'term' => self::generateDialogUserid($data),
];
} else {
$query = [
'field' => 'id',
'term' => (string) $data
];
}
try {
while (true) {
// 根据消息ID查找相关文档
$result = ZincSearchBase::advancedSearch(self::$indexNameMsg, [
'search_type' => 'term',
'query' => $query,
'from' => $from,
'max_results' => $batchSize
]);
$hits = $result['data']['hits']['hits'] ?? [];
// 如果没有更多文档,退出循环
if (empty($hits)) {
break;
}
// 删除本批次找到的所有文档
foreach ($hits as $hit) {
if (isset($hit['_id'])) {
ZincSearchBase::deleteDoc(self::$indexNameMsg, $hit['_id']);
$totalDeleted++;
}
}
// 如果返回的文档数少于批次大小,说明已经没有更多文档了
if (count($hits) < $batchSize) {
break;
}
// 移动到下一批
$from += $batchSize;
}
} catch (\Exception $e) {
Log::error('delete: ' . $e->getMessage());
}
return $totalDeleted;
}
}

View File

@@ -0,0 +1,276 @@
<?php
namespace App\Module\ZincSearch;
/**
* ZincSearch 键值存储类
*
* 使用方法:
*
* 1. 基础方法
* - 确保索引存在: ensureIndex();
* - 清空所有数据: clear();
*
* 2. 基本操作
* - 设置键值: set('site_name', '我的网站');
* - 设置复杂数据: set('site_config', ['logo' => 'logo.png', 'theme' => 'dark']);
* - 合并现有数据: set('site_config', ['footer' => '版权所有'], true);
* - 获取键值: $siteName = get('site_name');
* - 获取键值带默认值: $theme = get('theme', 'light');
* - 删除键值: delete('temporary_data');
*
* 3. 批量操作
* - 批量设置: batchSet(['user_count' => 100, 'active_users' => 50]);
* - 批量获取: $stats = batchGet(['user_count', 'active_users']);
*/
class ZincSearchKeyValue
{
/**
* 索引名称
*/
protected static string $indexName = 'keyValue';
// ==============================
// 基础方法
// ==============================
/**
* 确保索引存在
*/
public static function ensureIndex(): bool
{
if (!ZincSearchBase::indexExists(self::$indexName)) {
$mappings = [
'properties' => [
'key' => ['type' => 'keyword', 'index' => true],
'value' => ['type' => 'text', 'index' => true],
'created_at' => ['type' => 'date', 'index' => true],
'updated_at' => ['type' => 'date', 'index' => true]
]
];
$result = ZincSearchBase::createIndex(self::$indexName, $mappings);
return $result['success'] ?? false;
}
return true;
}
/**
* 清空所有键值
*
* @return bool 是否成功
*/
public static function clear(): bool
{
// 检查索引是否存在
if (!ZincSearchBase::indexExists(self::$indexName)) {
return true;
}
// 删除再重建索引
$deleteResult = ZincSearchBase::deleteIndex(self::$indexName);
if (!($deleteResult['success'] ?? false)) {
return false;
}
return self::ensureIndex();
}
// ==============================
// 基本操作
// ==============================
/**
* 设置键值
*
* @param string $key 键名
* @param mixed $value 值
* @param bool $merge 是否合并现有数据(如果值是数组)
* @return bool 是否成功
*/
public static function set(string $key, mixed $value, bool $merge = false): bool
{
if (!self::ensureIndex()) {
return false;
}
// 检查键是否已存在
if ($merge && is_array($value)) {
$existingData = self::get($key);
if (is_array($existingData)) {
$value = array_merge($existingData, $value);
}
}
// 检查是否存在相同键的文档 - 使用精确查询而不是普通搜索
$searchParams = [
'search_type' => 'term',
'query' => [
'field' => 'key',
'term' => $key
],
'from' => 0,
'max_results' => 1
];
$result = ZincSearchBase::advancedSearch(self::$indexName, $searchParams);
$docs = $result['data']['hits']['hits'] ?? [];
$now = date('c');
if (!empty($docs)) {
$docId = $docs[0]['_id'] ?? null;
if ($docId) {
// 更新现有文档
$docData = [
'key' => $key,
'value' => $value,
'updated_at' => $now
];
$updateResult = ZincSearchBase::updateDoc(self::$indexName, $docId, $docData);
return $updateResult['success'] ?? false;
}
}
// 创建新文档
$docData = [
'key' => $key,
'value' => $value,
'created_at' => $now,
'updated_at' => $now
];
$addResult = ZincSearchBase::addDoc(self::$indexName, $docData);
return $addResult['success'] ?? false;
}
/**
* 获取键值
*
* @param string $key 键名
* @param mixed $default 默认值
* @return mixed 值或默认值
*/
public static function get(string $key, mixed $default = null): mixed
{
if (!self::ensureIndex() || empty($key)) {
return $default;
}
// 精确匹配键名
$searchParams = [
'search_type' => 'term',
'query' => [
'field' => 'key',
'term' => $key
],
'from' => 0,
'max_results' => 1
];
$result = ZincSearchBase::advancedSearch(self::$indexName, $searchParams);
if (!($result['success'] ?? false)) {
return $default;
}
$hits = $result['data']['hits']['hits'] ?? [];
if (empty($hits)) {
return $default;
}
return $hits[0]['_source']['value'] ?? $default;
}
/**
* 删除键值
*
* @param string $key 键名
* @return bool 是否成功
*/
public static function delete(string $key): bool
{
if (!self::ensureIndex() || empty($key)) {
return false;
}
// 查找文档ID
$searchParams = [
'search_type' => 'term',
'query' => [
'field' => 'key',
'term' => $key
],
'from' => 0,
'max_results' => 1
];
$result = ZincSearchBase::advancedSearch(self::$indexName, $searchParams);
if (!($result['success'] ?? false)) {
return false;
}
$hits = $result['data']['hits']['hits'] ?? [];
if (empty($hits)) {
return true; // 不存在视为删除成功
}
$docId = $hits[0]['_id'] ?? null;
if (empty($docId)) {
return false;
}
$deleteResult = ZincSearchBase::deleteDoc(self::$indexName, $docId);
return $deleteResult['success'] ?? false;
}
// ==============================
// 批量操作
// ==============================
/**
* 批量设置键值对
*
* @param array $keyValues 键值对数组
* @return bool 是否全部成功
*/
public static function batchSet(array $keyValues): bool
{
if (!self::ensureIndex() || empty($keyValues)) {
return false;
}
$docs = [];
$now = date('c');
foreach ($keyValues as $key => $value) {
$docs[] = [
'key' => $key,
'value' => $value,
'created_at' => $now,
'updated_at' => $now
];
}
$result = ZincSearchBase::addDocs(self::$indexName, $docs);
return $result['success'] ?? false;
}
/**
* 批量获取键值
*
* @param array $keys 键名数组
* @return array 键值对数组
*/
public static function batchGet(array $keys): array
{
if (!self::ensureIndex() || empty($keys)) {
return [];
}
$results = [];
// 遍历查询每个键
foreach ($keys as $key) {
$results[$key] = self::get($key);
}
return $results;
}
}

View File

@@ -92,7 +92,7 @@ class ProjectTaskObserver
}
$array = [];
if (in_array('task', $dataType)) {
$array = array_merge($array, ProjectTaskUser::whereTaskId($projectTask->id)->pluck('userid')->toArray());
$array = array_merge($array, ProjectTaskUser::whereTaskId($projectTask->id)->orWhere('task_pid' ,$projectTask->id)->pluck('userid')->toArray());
}
if (in_array('visibility', $dataType)) {
$array = array_merge($array, ProjectTaskVisibilityUser::whereTaskId($projectTask->id)->pluck('userid')->toArray());

View File

@@ -17,6 +17,9 @@ class ProjectTaskUserObserver
public function created(ProjectTaskUser $projectTaskUser)
{
Deleted::forget('projectTask', $projectTaskUser->task_id, $projectTaskUser->userid);
if ($projectTaskUser->task_pid) {
Deleted::forget('projectTask', $projectTaskUser->task_pid, $projectTaskUser->userid);
}
}
/**

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Observers;
use App\Models\WebSocketDialogMsg;
use App\Module\ZincSearch\ZincSearchDialogMsg;
class WebSocketDialogMsgObserver
{
/**
* Handle the WebSocketDialogMsg "created" event.
*
* @param \App\Models\WebSocketDialogMsg $webSocketDialogMsg
* @return void
*/
public function created(WebSocketDialogMsg $webSocketDialogMsg)
{
ZincSearchDialogMsg::sync($webSocketDialogMsg);
}
/**
* Handle the WebSocketDialogMsg "updated" event.
*
* @param \App\Models\WebSocketDialogMsg $webSocketDialogMsg
* @return void
*/
public function updated(WebSocketDialogMsg $webSocketDialogMsg)
{
ZincSearchDialogMsg::sync($webSocketDialogMsg);
}
/**
* Handle the WebSocketDialogMsg "deleted" event.
*
* @param \App\Models\WebSocketDialogMsg $webSocketDialogMsg
* @return void
*/
public function deleted(WebSocketDialogMsg $webSocketDialogMsg)
{
ZincSearchDialogMsg::delete($webSocketDialogMsg);
}
/**
* Handle the WebSocketDialogMsg "restored" event.
*
* @param \App\Models\WebSocketDialogMsg $webSocketDialogMsg
* @return void
*/
public function restored(WebSocketDialogMsg $webSocketDialogMsg)
{
//
}
/**
* Handle the WebSocketDialogMsg "force deleted" event.
*
* @param \App\Models\WebSocketDialogMsg $webSocketDialogMsg
* @return void
*/
public function forceDeleted(WebSocketDialogMsg $webSocketDialogMsg)
{
//
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Observers;
use App\Models\Deleted;
use App\Models\WebSocketDialogUser;
use App\Module\ZincSearch\ZincSearchDialogMsg;
use Carbon\Carbon;
class WebSocketDialogUserObserver
@@ -29,6 +30,7 @@ class WebSocketDialogUserObserver
}
}
Deleted::forget('dialog', $webSocketDialogUser->dialog_id, $webSocketDialogUser->userid);
ZincSearchDialogMsg::userSync($webSocketDialogUser);
}
/**
@@ -39,7 +41,7 @@ class WebSocketDialogUserObserver
*/
public function updated(WebSocketDialogUser $webSocketDialogUser)
{
//
ZincSearchDialogMsg::userSync($webSocketDialogUser);
}
/**
@@ -51,6 +53,7 @@ class WebSocketDialogUserObserver
public function deleted(WebSocketDialogUser $webSocketDialogUser)
{
Deleted::record('dialog', $webSocketDialogUser->dialog_id, $webSocketDialogUser->userid);
ZincSearchDialogMsg::delete($webSocketDialogUser);
}
/**

View File

@@ -7,11 +7,13 @@ use App\Models\ProjectTask;
use App\Models\ProjectTaskUser;
use App\Models\ProjectUser;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogMsg;
use App\Models\WebSocketDialogUser;
use App\Observers\ProjectObserver;
use App\Observers\ProjectTaskObserver;
use App\Observers\ProjectTaskUserObserver;
use App\Observers\ProjectUserObserver;
use App\Observers\WebSocketDialogMsgObserver;
use App\Observers\WebSocketDialogObserver;
use App\Observers\WebSocketDialogUserObserver;
use Illuminate\Auth\Events\Registered;
@@ -43,6 +45,7 @@ class EventServiceProvider extends ServiceProvider
ProjectTaskUser::observe(ProjectTaskUserObserver::class);
ProjectUser::observe(ProjectUserObserver::class);
WebSocketDialog::observe(WebSocketDialogObserver::class);
WebSocketDialogMsg::observe(WebSocketDialogMsgObserver::class);
WebSocketDialogUser::observe(WebSocketDialogUserObserver::class);
}
}

View File

@@ -139,4 +139,64 @@ class RequestContext
self::$context[$requestId] ??= [];
self::$context[$requestId] = array_merge(self::$context[$requestId], $data);
}
/** ***************************************************************************************** */
/** ***************************************************************************************** */
/** ***************************************************************************************** */
/**
* 更新请求的基本URL
*
* @param Request $request
* @return void
*/
public static function updateBaseUrl($request)
{
if ($request->path() !== 'api/system/setting') {
return;
}
$schemeAndHttpHost = $request->getSchemeAndHttpHost();
if (str_contains($schemeAndHttpHost, '127.0.0.1') || str_contains($schemeAndHttpHost, 'localhost')) {
return;
}
\Cache::forever('RequestContext::base_url', $schemeAndHttpHost);
}
/**
* 替换请求的基本URL
*
* @param string $url
* @return string
*/
public static function replaceBaseUrl(string $url): string
{
// 先提取主机部分
$pattern = '/^(https?:\/\/[^\/?#:]+(:\d+)?)/i';
if (!preg_match($pattern, $url, $matches)) {
return $url; // 如果不是有效URL直接返回
}
$schemeAndHttpHost = $matches[1] ?? '';
if (!$schemeAndHttpHost) {
return $url;
}
// 只检查主机部分是否为本地主机
if (str_contains($schemeAndHttpHost, '127.0.0.1') || str_contains($schemeAndHttpHost, 'localhost')) {
$baseUrl = \Cache::get('RequestContext::base_url');
if ($baseUrl) {
return $baseUrl . substr($url, strlen($schemeAndHttpHost));
}
}
return $url;
}
/**
* 清除基本URL缓存
*/
public static function clearBaseUrlCache(): void
{
\Cache::forget('RequestContext::base_url');
}
}

View File

@@ -2,19 +2,24 @@
namespace App\Tasks;
use App\Models\FileContent;
use App\Models\Project;
use App\Models\ProjectTask;
use App\Models\Report;
use App\Models\User;
use App\Models\UserBot;
use App\Models\UserDepartment;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogConfig;
use App\Models\WebSocketDialogMsg;
use App\Module\Base;
use App\Module\Doo;
use App\Module\Ihttp;
use App\Module\TextExtractor;
use Carbon\Carbon;
use DB;
use Exception;
use League\HTMLToMarkdown\HtmlConverter;
use DB;
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
@@ -86,8 +91,16 @@ class BotReceiveMsgTask extends AbstractTask
}
// 提取指令
$command = $this->extractCommand($msg, $this->mention);
if (empty($command)) {
try {
$command = $this->extractCommand($msg, $botUser, $this->mention);
if (empty($command)) {
return;
}
} catch (Exception $e) {
WebSocketDialogMsg::sendMsg(null, $msg->dialog_id, 'template', [
'type' => 'content',
'content' => $e->getMessage() ?: "指令解析失败。",
], $botUser->userid, false, false, true); // todo 未能在任务end事件来发送任务
return;
}
@@ -195,32 +208,11 @@ class BotReceiveMsgTask extends AbstractTask
* 创建
*/
case '/newbot':
if (User::select(['users.*'])
->join('user_bots', 'users.userid', '=', 'user_bots.bot_id')
->where('users.bot', 1)
->where('user_bots.userid', $msg->userid)
->count() >= 50) {
$content = "超过最大创建数量。";
break;
}
if (strlen($array[1]) < 2 || strlen($array[1]) > 20) {
$content = "机器人名称由2-20个字符组成。";
break;
}
$data = User::botGetOrCreate("user-" . Base::generatePassword(), [
'nickname' => $array[1]
], $msg->userid);
if (empty($data)) {
$content = "创建失败。";
break;
}
$dialog = WebSocketDialog::checkUserDialog($data, $msg->userid);
if ($dialog) {
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => '/hello',
'title' => '创建成功。',
'data' => $data,
], $data->userid); // todo 未能在任务end事件来发送任务
$res = UserBot::newbot($msg->userid, $array[1]);
if (Base::isError($res)) {
$content = $res['msg'];
} else {
$data = $res['data'];
}
break;
@@ -411,14 +403,14 @@ class BotReceiveMsgTask extends AbstractTask
*/
private function botWebhookBusiness(string $command, WebSocketDialogMsg $msg, User $botUser, WebSocketDialog $dialog)
{
$serverUrl = 'http://' . env('APP_IPPR') . '.3';
$serverUrl = 'http://nginx';
$userBot = null;
$extras = [];
$replyText = null;
$errorContent = null;
if (preg_match('/^ai-(.*?)@bot\.system$/', $botUser->email, $matches)) {
if ($botUser->isAiBot($type)) {
// AI机器人
$setting = Base::setting('aibotSetting');
$type = $matches[1];
$extras = [
'model_type' => match ($type) {
'qianwen' => 'qwen',
@@ -427,33 +419,74 @@ class BotReceiveMsgTask extends AbstractTask
'model_name' => $setting[$type . '_model'],
'system_message' => $setting[$type . '_system'],
'api_key' => $setting[$type . '_key'],
'base_url' => $setting[$type . '_base_url'],
'agency' => $setting[$type . '_agency'],
'server_url' => $serverUrl,
];
if ($setting[$type . '_temperature']) {
$extras['temperature'] = floatval($setting[$type . '_temperature']);
}
if ($msg->msg['model_name']) {
$extras['model_name'] = $msg->msg['model_name'];
}
if (preg_match("/(.*?)(\s+|\s*[_-]\s*)(think|thinking|reasoning)\s*$/", $extras['model_name'], $match)) {
$extras['model_name'] = $match[1];
$extras['max_tokens'] = 20000;
$extras['thinking'] = 4096;
$extras['temperature'] = 1.0;
}
if ($dialog->session_id) {
$extras['context_key'] = 'session_' . $dialog->session_id;
}
if ($type === 'wenxin') {
$extras['api_key'] .= ':' . $setting['wenxin_secret'];
}
if ($type === 'ollama') {
if (empty($extras['base_url'])) {
$errorContent = '机器人未启用。';
}
if (empty($extras['api_key'])) {
$extras['api_key'] = Base::strRandom(6);
}
}
if (empty($extras['api_key'])) {
$errorContent = '机器人未启用。';
}
if (in_array($this->client['platform'], ['win', 'mac', 'web']) && !Base::judgeClientVersion("0.41.11", $this->client['version'])) {
$errorContent = '当前客户端版本低所需版本≥v0.41.11)。';
}
if ($msg->reply_id > 0) {
$replyMsg = WebSocketDialogMsg::find($msg->reply_id);
$replyCommand = '';
if ($replyMsg) {
$replyCommand = $this->extractCommand($replyMsg);
if ($replyCommand) {
$replyCommand = "<quoted>" . Base::cutStr($replyCommand, 2000) . "</quoted>\n\nThe content within the above <quoted> tags is a citation.\n\n";
}
$replyCommand = $this->extractReplyCommand($msg->reply_id, $botUser);
if (Base::isError($replyCommand)) {
$errorContent = $replyCommand['msg'];
} else {
$command = <<<EOF
<quoted_content>
{$replyCommand['data']}
</quoted_content>
The content within the above quoted_content tags is a citation.
{$command}
EOF;
}
$command = $replyCommand . $command;
}
$this->AIGenerateSystemMessageOrBeforeText($msg->userid, $dialog, $extras);
$this->AIGenerateSystemMessage($msg->userid, $dialog, $extras);
$webhookUrl = "{$serverUrl}/ai/chat";
} else {
// 用户机器人
if ($botUser->isUserBot() && str_starts_with($command, '/')) {
// 用户机器人不处理指令类型命令
return;
}
if ($msg->reply_id > 0) {
$replyCommand = $this->extractReplyCommand($msg->reply_id, $botUser);
if (Base::isSuccess($replyCommand)) {
$replyText = $replyCommand['data'] ?: '';
}
}
$userBot = UserBot::whereBotId($botUser->userid)->first();
$webhookUrl = $userBot?->webhook_url;
}
@@ -471,6 +504,7 @@ class BotReceiveMsgTask extends AbstractTask
try {
$data = [
'text' => $command,
'reply_text' => $replyText,
'token' => User::generateToken($botUser),
'dialog_id' => $dialog->id,
'dialog_type' => $dialog->type,
@@ -532,15 +566,18 @@ class BotReceiveMsgTask extends AbstractTask
/**
* 提取消息指令(提取消息内容)
* @param WebSocketDialogMsg $msg
* @param User $botUser
* @param bool $mention
* @return string
* @throws Exception
*/
private function extractCommand(WebSocketDialogMsg $msg, bool $mention = false)
private function extractCommand(WebSocketDialogMsg $msg, User $botUser, bool $mention = false)
{
if ($msg->type !== 'text') {
return '';
}
$original = $msg->msg['text'];
$original = $msg->msg['text'] ?: '';
if ($mention) {
$original = preg_replace("/<span class=\"mention user\" data-id=\"(\d+)\">(.*?)<\/span>/", "", $original);
}
@@ -549,26 +586,127 @@ class BotReceiveMsgTask extends AbstractTask
if (str_starts_with($command, '%3A.')) {
$command = ":" . substr($command, 4);
}
return $command;
}
if ($botUser->isAiBot()) {
// AI 机器人
$contents = [];
if (preg_match_all("/<span class=\"mention task\" data-id=\"(\d+)\">(.*?)<\/span>/", $original, $match)) {
// 任务
$taskIds = Base::newIntval($match[1]);
foreach ($taskIds as $index => $taskId) {
$taskInfo = ProjectTask::with(['content'])->whereId($taskId)->first();
if (!$taskInfo) {
throw new Exception("任务不存在或已被删除");
}
$taskName = addslashes($taskInfo->name) . " (ID:{$taskId})";
$taskContext = implode("\n", $taskInfo->AIContext());
$contents[] = "<task_content path=\"{$taskName}\">\n{$taskContext}\n</task_content>";
$original = str_replace($match[0][$index], "'{$taskName}' (see below for task_content tag)", $original);
}
}
if (preg_match_all("/<a class=\"mention ([^'\"]*)\" href=\"([^\"']+?)\"[^>]*?>[~%]([^>]*)<\/a>/", $original, $match)) {
// 文件、报告
$urlPaths = $match[2];
foreach ($urlPaths as $index => $urlPath) {
$pathTag = null;
$pathName = null;
$pathContent = null;
// 文件
if (preg_match("/single\/file\/(.*?)$/", $urlPath, $fileMatch)) {
$fileInfo = FileContent::idOrCodeToContent($fileMatch[1]);
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][$index]) . " (ID:{$fileInfo->id})";
$pathContent = $fileResult['data'];
}
// 报告
elseif (preg_match("/single\/report\/detail\/(.*?)$/", $urlPath, $reportMatch)) {
$reportInfo = Report::idOrCodeToContent($reportMatch[1]);
if (!$reportInfo) {
throw new Exception("报告不存在或已被删除");
}
$pathTag = "report_content";
$pathName = addslashes($match[3][$index]) . " (ID:{$reportInfo->id})";
$pathContent = $reportInfo->content;
}
if ($pathTag) {
$contents[] = "<{$pathTag} path=\"{$pathName}\">\n{$pathContent}\n</{$pathTag}>";
$original = str_replace($match[0][$index], "'{$pathName}' (see below for {$pathTag} tag)", $original);
}
}
}
$original = Base::html2markdown($original);
if ($contents) {
// 添加tag内容
$original .= "\n\n" . implode("\n\n", $contents);
}
return $original;
} elseif ($botUser->isUserBot()) {
// 用户机器人
return Base::html2markdown($original);
} else {
$command = trim(strip_tags($original));
// 其他机器人(系统)
return trim(strip_tags($original));
}
if (empty($command)) {
return '';
}
return $command;
}
/**
* 生成AI系统提示词或前置消息
* 提取回复消息指令
* @param $id
* @param User $botUser
* @return array
*/
private function extractReplyCommand($id, User $botUser)
{
$replyMsg = WebSocketDialogMsg::find($id);
$replyCommand = null;
if ($replyMsg) {
switch ($replyMsg->type) {
case 'text':
try {
$replyCommand = $this->extractCommand($replyMsg, $botUser);
} catch (Exception) {
return Base::retError('error', "引用消息解析失败。");
}
break;
case 'file':
if ($botUser->isAiBot()) {
$msgData = Base::json2array($replyMsg->getRawOriginal('msg'));
$fileResult = TextExtractor::extractFile(public_path($msgData['path']));
if (Base::isError($fileResult)) {
return Base::retError('error', $fileResult['msg']);
} else {
$replyCommand = $fileResult['data'];
}
}
break;
}
}
return Base::retSuccess('success', $replyCommand);
}
/**
* 生成AI系统提示词
* @param int|null $userid
* @param WebSocketDialog $dialog
* @param array $extras
* @return void
*/
private function AIGenerateSystemMessageOrBeforeText(int|null $userid, WebSocketDialog $dialog, array &$extras)
private function AIGenerateSystemMessage(int|null $userid, WebSocketDialog $dialog, array &$extras)
{
$system_message = null;
$before_text = [];
$system_messages = [];
switch ($dialog->type) {
case "user":
$aiPrompt = WebSocketDialogConfig::where([
@@ -577,7 +715,7 @@ class BotReceiveMsgTask extends AbstractTask
'type' => 'ai_prompt',
])->value('value');
if ($aiPrompt) {
$system_message = $aiPrompt;
$extras['system_message'] = $aiPrompt;
}
break;
case "group":
@@ -587,16 +725,14 @@ class BotReceiveMsgTask extends AbstractTask
case 'project':
$projectInfo = Project::whereDialogId($dialog->id)->first();
if ($projectInfo) {
$projectText = "当前我在项目【{$projectInfo->name}】中";
if ($projectInfo->archived_at) {
$projectText .= ",此项目已经归档";
}
$before_text[] = $projectText;
if ($projectInfo->desc) {
$before_text[] = "项目描述:{$projectInfo->desc}";
}
$before_text[] = <<<EOF
如果你判断我想要添加任务,请按照以下格式回复:
$projectDesc = $projectInfo->desc ?: "-";
$projectStatus = $projectInfo->archived_at ? '已归档' : '正在进行中';
$system_messages[] = <<<EOF
当前我在项目【{$projectInfo->name}】中
项目描述:{$projectDesc}
项目状态:{$projectStatus}
如果你判断我想要或需要添加任务,请按照以下格式回复:
::: create-task-list
title: 任务标题1
@@ -611,48 +747,14 @@ class BotReceiveMsgTask extends AbstractTask
case 'task':
$taskInfo = ProjectTask::with(['content'])->whereDialogId($dialog->id)->first();
if ($taskInfo) {
$taskText = "当前我在任务【{$taskInfo->name}】中";
if ($taskInfo->archived_at) {
$taskText .= ",此任务已经归档";
} elseif ($taskInfo->complete_at) {
$taskText .= ",此任务已经完成";
} elseif ($taskInfo->end_at && Carbon::parse($taskInfo->end_at)->lt(Carbon::now())) {
$taskText .= ",此任务已经过期";
}
$before_text[] = $taskText;
if ($taskInfo->content) {
$taskDesc = $taskInfo->content?->getContentInfo();
if ($taskDesc) {
$converter = new HtmlConverter(['strip_tags' => true]);
$descContent = Base::cutStr($converter->convert($taskDesc['content']), 2000);
$before_text[] = <<<EOF
任务描述:
```md
{$descContent}
```
EOF;
}
}
$subTask = ProjectTask::select(['id', 'name', 'complete_at', 'end_at'])->whereParentId($taskInfo->id)->get();
if ($subTask->isNotEmpty()) {
$subTaskContent = $subTask->map(function($item) {
$status = "";
if ($item->complete_at) {
$status = " (已完成)";
} elseif ($item->end_at && Carbon::parse($item->end_at)->lt(Carbon::now())) {
$status = " (已过期)";
}
return " - {$item->name} {$status}";
})->join("\n");
if ($subTaskContent) {
$before_text[] = <<<EOF
子任务列表:
{$subTaskContent}
EOF;
}
}
$before_text[] = <<<EOF
如果你判断我想要添加子任务,请按照以下格式回复:
$taskContext = implode("\n", $taskInfo->AIContext());
$system_messages[] = <<<EOF
当前我在任务【{$taskInfo->name}】中
当前时间:{$taskInfo->updated_at}
任务ID{$taskInfo->id}
{$taskContext}
如果你判断我想要或需要添加子任务,请按照以下格式回复:
::: create-subtask-list
title: 子任务标题1
@@ -661,17 +763,23 @@ class BotReceiveMsgTask extends AbstractTask
EOF;
}
break;
case 'department':
$userDepartment = UserDepartment::whereDialogId($dialog->id)->first();
if ($userDepartment) {
$system_messages[] = "当前我在【{$userDepartment->name}】的部门群聊中";
}
break;
case 'all':
$before_text[] = "当前我团队【全体成员】的群聊中";
$system_messages[] = "当前我【全体成员】的群聊中";
break;
}
break;
}
if ($system_message) {
$extras['system_message'] = $system_message;
if ($extras['system_message']) {
array_unshift($system_messages, $extras['system_message']);
}
if ($before_text) {
$extras['before_text'] = Base::newTrim($before_text);
if ($system_messages) {
$extras['system_message'] = implode("\n\n====\n\n", Base::newTrim($system_messages));
}
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Tasks;
use App\Models\File;
use App\Models\TaskWorker;
use App\Models\Tmp;
use App\Models\UserDevice;
use App\Models\WebSocketTmpMsg;
use App\Module\Base;
use Carbon\Carbon;
@@ -33,89 +34,75 @@ class DeleteTmpTask extends AbstractTask
public function start()
{
switch ($this->data) {
/**
* 表pre_tmp_msgs
*/
case 'wg_tmp_msgs':
{
WebSocketTmpMsg::where('created_at', '<', Carbon::now()->subHours($this->hours))
->orderBy('id')
->chunk(500, function ($msgs) {
/** @var WebSocketTmpMsg $msg */
foreach ($msgs as $msg) {
$msg->delete();
}
});
}
break;
/**
* 表pre_tmp
*/
case 'tmp':
{
Tmp::where('created_at', '<', Carbon::now()->subHours($this->hours))
->orderBy('id')
->chunk(500, function ($tmps) {
/** @var Tmp $tmp */
foreach ($tmps as $tmp) {
$tmp->delete();
}
});
}
break;
/**
* 表pre_task_worker
*/
case 'task_worker':
{
TaskWorker::onlyTrashed()
->where('deleted_at', '<', Carbon::now()->subHours($this->hours))
->orderBy('id')
->forceDelete();
}
break;
/**
* 表pre_file
*/
case 'file':
{
$day = intval(env("AUTO_EMPTY_FILE_RECYCLE", 365));
if ($day <= 0) {
return;
}
File::onlyTrashed()
->where('deleted_at', '<', Carbon::now()->subHours($day))
->orderBy('id')
->chunk(500, function ($files) {
/** @var File $file */
foreach ($files as $file) {
$file->forceDeleteFile();
}
});
}
break;
/**
* tmp_file 删除临时文件
*/
case 'tmp_file':
{
$day = intval(env("AUTO_EMPTY_TEMP_FILE", 30));
if ($day <= 0) {
return;
}
$files = Base::recursiveFiles(public_path('uploads/tmp'));
foreach ($files as $file) {
$time = @filemtime($file);
if ($time && $time < time() - 3600 * 24 * $day) {
unlink($file);
case 'tmp_msgs':
WebSocketTmpMsg::where('created_at', '<', Carbon::now()->subHours($this->hours))
->orderBy('id')
->chunk(500, function ($msgs) {
/** @var WebSocketTmpMsg $msg */
foreach ($msgs as $msg) {
$msg->delete();
}
});
break;
case 'tmp':
Tmp::where('created_at', '<', Carbon::now()->subHours($this->hours))
->orderBy('id')
->chunk(500, function ($tmps) {
/** @var Tmp $tmp */
foreach ($tmps as $tmp) {
$tmp->delete();
}
});
break;
case 'task_worker':
TaskWorker::onlyTrashed()
->where('deleted_at', '<', Carbon::now()->subHours($this->hours))
->orderBy('id')
->forceDelete();
break;
case 'file':
$day = intval(env("AUTO_EMPTY_FILE_RECYCLE", 365));
if ($day <= 0) {
return;
}
File::onlyTrashed()
->where('deleted_at', '<', Carbon::now()->subHours($day))
->orderBy('id')
->chunk(500, function ($files) {
/** @var File $file */
foreach ($files as $file) {
$file->forceDeleteFile();
}
});
break;
case 'tmp_file':
$day = intval(env("AUTO_EMPTY_TEMP_FILE", 30));
if ($day <= 0) {
return;
}
$files = Base::recursiveFiles(public_path('uploads/tmp'));
foreach ($files as $file) {
$time = @filemtime($file);
if ($time && $time < time() - 3600 * 24 * $day) {
unlink($file);
}
}
break;
case 'user_device':
UserDevice::where('expired_at', '<', Carbon::now()->subHours($this->hours))
->orderBy('id')
->chunk(500, function ($devices) {
/** @var UserDevice $device */
foreach ($devices as $device) {
UserDevice::forget($device);
}
});
break;
}
}

View File

@@ -119,14 +119,20 @@ class WebSocketDialogMsgTask extends AbstractTask
$mention = array_intersect([0, $userid], $mentions) ? 1 : 0;
$silence = $mention ? false : $silence;
$dot = $msg->type === 'record' ? 1 : 0;
WebSocketDialogMsgRead::createInstance([
$msgRead = WebSocketDialogMsgRead::createInstance([
'dialog_id' => $msg->dialog_id,
'msg_id' => $msg->id,
'userid' => $userid,
'mention' => $mention,
'silence' => $silence,
'dot' => $dot,
])->saveOrIgnore();
]);
if ($msgRead->saveOrIgnore()) {
if ($dialog->session_id && $dialog->session_id != $msg->session_id) {
$msgRead->read_at = Carbon::now();
$msgRead->save();
}
}
$array[$userid] = [
'userid' => $userid,
'mention' => $mention,

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Tasks;
use Carbon\Carbon;
use Illuminate\Support\Facades\Cache;
/**
* 同步聊天数据到ZincSearch
*/
class ZincSearchSyncTask extends AbstractTask
{
public function __construct()
{
parent::__construct();
}
public function start()
{
// 120分钟执行一次
$time = intval(Cache::get("ZincSearchSyncTask:Time"));
if (time() - $time < 120 * 60) {
return;
}
// 执行开始120分钟后缓存标记失效
Cache::put("ZincSearchSyncTask:Time", time(), Carbon::now()->addMinutes(120));
// 开始执行同步
@shell_exec("php /var/www/artisan zinc:sync-user-msg --i");
// 执行完成5分钟后缓存标记失效5分钟任务可重复执行
Cache::put("ZincSearchSyncTask:Time", time(), Carbon::now()->addMinutes(5));
}
public function end()
{
}
}

View File

@@ -142,6 +142,7 @@ install() {
if /root/.acme.sh/acme.sh --installcert -d "${domain}" --fullchainpath "${sslPath}/${domain}.crt" --keypath "${sslPath}/${domain}.key" --ecc --force; then
success "SSL 证书配置成功"
sleep 2
cp -r /root/.acme.sh/${domain}_ecc/*.conf ${sslPath}/
fi
else
error "SSL 证书生成失败"
@@ -165,5 +166,54 @@ error_page 497 https://\$host\$request_uri;
EOF
}
check
install
UPDATE_LOG="$(dirname "$PWD")/docker/nginx/site/ssl/update.log"
SSL_PATH="$(dirname "$PWD")/docker/nginx/site/ssl"
upgrade_cert(){
curl https://get.acme.sh | sh
if [[ 0 -ne $? ]]; then
echo "安装证书更新脚本失败"
echo $(date)": 安装证书更新脚本失败" >> ${UPDATE_LOG}
exit 1
fi
file=$1
domain=$(basename "$file" .key)
old_crt_md5=$(md5sum ${SSL_PATH}/${domain}.crt| awk '{print $1}')
/root/.acme.sh/acme.sh --renew --standalone -d ${domain} --fullchainpath "${SSL_PATH}/${domain}.crt" --keypath "${SSL_PATH}/${domain}.key" --ecc --force
new_crt_md5=$(md5sum ${SSL_PATH}/${domain}.crt| awk '{print $1}')
if [ "${old_key_md5}" == "${new_key_md5}" ]; then
echo "${domain} 证书更新脚本失败"
echo $(date)": ${domain} 证书更新失败" >> ${UPDATE_LOG}
echo $(date)": ${old_crt_md5} == ${new_crt_md5}" >> ${UPDATE_LOG}
else
echo "${domain} 证书更新脚本成功"
echo $(date)": ${domain} 证书更新成功" >> ${UPDATE_LOG}
fi
}
check_expire(){
apk add --no-cache openssl socat
find ${SSL_PATH} -type f -name "*.key" | while read -r file; do
CERT_PATH=$file
expiry_date=$(openssl x509 -enddate -noout -in "$CERT_PATH" | cut -d= -f2)
expiry_timestamp=$(date -d "$expiry_date" +%s)
current_timestamp=$(date +%s)
days_remaining=$(( (expiry_timestamp - current_timestamp) / 86400 ))
echo "剩余时间${days_remaining}天" >> ${UPDATE_LOG}
if [ "$days_remaining" -lt 30 ]; then
upgrade_cert $file
fi
done
}
case "${1}" in
"install")
check
install
;;
"renew")
check_expire
;;
*)
echo "test"
;;
esac

4
bin/version.js vendored

File diff suppressed because one or more lines are too long

121
cmd
View File

@@ -156,9 +156,9 @@ run_electron() {
npm install
fi
if [ ! -d "./electron/node_modules" ]; then
pushd electron
pushd electron || exit
npm install
popd
popd || exit
fi
#
if [ -d "./electron/dist" ]; then
@@ -178,8 +178,9 @@ run_electron() {
run_exec() {
local container=$1
local cmd=$2
local name=`docker_name $container`
shift 1
local cmd=$@
local name=$(docker_name "$container")
if [ -z "$name" ]; then
error "没有找到 $container 容器!"
exit 1
@@ -322,10 +323,21 @@ https_auto() {
if [[ "$restart_nginx" == "y" ]]; then
$COMPOSE up -d
fi
docker run -it --rm -v $(pwd):/work nginx:alpine sh "/work/bin/https"
docker run -it --rm -v $(pwd):/work nginx:alpine sh /work/bin/https install
if [[ 0 -eq $? ]]; then
run_exec nginx "nginx -s reload"
fi
new_job="* 6 * * * docker run -it --rm -v $(pwd):/work nginx:alpine sh /work/bin/https renew"
current_crontab=$(crontab -l 2>/dev/null)
if ! echo "$current_crontab" | grep -v "https renew"; then
echo "任务已存在,无需添加。"
else
crontab -l |{
cat
echo "$new_job"
} | crontab -
echo "任务已添加。"
fi
}
env_get() {
@@ -405,14 +417,50 @@ if [ $# -gt 0 ]; then
rm -rf vendor
rm -rf composer.lock
fi
mkdir -p "${cur_path}/docker/log/supervisor"
mkdir -p "${cur_path}/docker/mysql/data"
chmod -R 775 "${cur_path}/docker/log/supervisor"
chmod -R 775 "${cur_path}/docker/mysql/data"
# 目录权限
volumes=(
"bootstrap/cache"
"docker"
"public"
"storage"
)
cmda=""
cmdb=""
for vol in "${volumes[@]}"; do
tmp_path="${cur_path}/${vol}"
mkdir -p "${tmp_path}"
chmod -R 775 "${tmp_path}"
rm -f "${tmp_path}/dootask.lock"
cmda="${cmda} -v ${tmp_path}:/usr/share/${vol}"
cmdb="${cmdb} touch /usr/share/${vol}/dootask.lock &&"
done
# 目录权限检测
remaining=10
while true; do
((remaining=$remaining-1))
writable="yes"
docker run --rm ${cmda} nginx:alpine sh -c "${cmdb} touch /usr/share/docker/dootask.lock" &> /dev/null
for vol in "${volumes[@]}"; do
if [ ! -f "${vol}/dootask.lock" ]; then
if [ $remaining -lt 0 ]; then
error "目录【${vol}】权限不足!"
exit 1
else
writable="no"
break
fi
fi
done
if [ "$writable" == "yes" ]; then
break
else
sleep 3
fi
done
# 启动容器
[[ "$(arg_get port)" -gt 0 ]] && env_set APP_PORT "$(arg_get port)"
$COMPOSE up php -d
# 安装composer依赖
# 安装PHP依赖
run_exec php "composer install"
if [ ! -f "${cur_path}/vendor/autoload.php" ]; then
run_exec php "composer config repo.packagist composer https://packagist.phpcomposer.com"
@@ -424,50 +472,37 @@ if [ $# -gt 0 ]; then
exit 1
fi
[[ -z "$(env_get APP_KEY)" ]] && run_exec php "php artisan key:generate"
# 设置生产模式
switch_debug "false"
# 检查数据库
remaining=20
while [ ! -f "${cur_path}/docker/mysql/data/$(env_get DB_DATABASE)/db.opt" ]; do
((remaining=$remaining-1))
if [ $remaining -lt 0 ]; then
error "数据库初始化失败!"
exit 1
fi
chmod -R 775 "${cur_path}/docker/mysql/data"
done
# 数据库迁移
remaining=20
while [ ! -f "${cur_path}/docker/mysql/data/$(env_get DB_DATABASE)/$(env_get DB_PREFIX)migrations.ibd" ]; do
((remaining=$remaining-1))
if [ $remaining -lt 0 ]; then
error "数据库安装失败!"
exit 1
fi
sleep 3
run_exec php "php artisan migrate --seed"
done
# 设置初始化密码
res=`run_exec mariadb "sh /etc/mysql/repassword.sh"`
$COMPOSE up -d
restart_php
run_exec php "php artisan migrate --seed"
# 启动其他容器
$COMPOSE up -d --remove-orphans
success "安装完成"
info "地址: http://${GreenBG}127.0.0.1:$(env_get APP_PORT)${Font}"
info "$res"
# 设置初始化密码
run_exec mariadb "sh /etc/mysql/repassword.sh"
elif [[ "$1" == "update" ]]; then
shift 1
if [[ "$@" != "nobackup" ]]; then
run_mysql backup
fi
if [[ -z "$(arg_get local)" ]]; then
git fetch --all
git reset --hard origin/$(git branch | sed -n -e 's/^\* \(.*\)/\1/p')
current_branch=$(git branch | sed -n -e 's/^\* \(.*\)/\1/p')
db_changes=$(git diff --name-only HEAD..origin/$current_branch | grep -E "^database/")
if [[ -n "$db_changes" ]]; then
info "数据库有迁移变动,执行数据库备份..."
run_mysql backup
fi
git reset --hard origin/$current_branch
git pull
run_exec php "composer update"
else
info "执行数据库备份..."
run_mysql backup
fi
run_exec php "php artisan migrate"
run_exec nginx "nginx -s reload"
restart_php
$COMPOSE up -d
$COMPOSE up -d --remove-orphans
elif [[ "$1" == "uninstall" ]]; then
shift 1
read -rp "确定要卸载(含:删除容器、数据库、日志)吗?(Y/n): " uninstall
@@ -513,7 +548,7 @@ if [ $# -gt 0 ]; then
success "修改成功"
elif [[ "$1" == "repassword" ]]; then
shift 1
run_exec mariadb "sh /etc/mysql/repassword.sh \"$@\""
run_exec mariadb "sh /etc/mysql/repassword.sh $@"
elif [[ "$1" == "serve" ]] || [[ "$1" == "dev" ]] || [[ "$1" == "development" ]]; then
shift 1
run_compile dev
@@ -539,9 +574,9 @@ if [ $# -gt 0 ]; then
elif [[ "$1" == "npm" ]]; then
shift 1
npm $@
cd electron
pushd electron || exit
npm $@
cd ..
popd || exit
docker run --rm -it -v ${cur_path}/resources/mobile:/work -w /work --entrypoint=/bin/bash node:16 -c "npm $@"
elif [[ "$1" == "doc" ]]; then
shift 1

View File

@@ -11,6 +11,7 @@
"php": "^8.0",
"ext-curl": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
"ext-gd": "*",
"ext-imagick": "*",
"ext-json": "*",
@@ -32,10 +33,14 @@
"league/html-to-markdown": "^5.1",
"maatwebsite/excel": "^3.1.31",
"madnest/madzipper": "^v1.1.0",
"matomo/device-detector": "^6.4",
"mews/captcha": "^3.2.6",
"orangehill/iseed": "^3.0.1",
"overtrue/pinyin": "^4.0",
"phpoffice/phppresentation": "^1.1",
"phpoffice/phpword": "^1.3",
"predis/predis": "^1.1.7",
"smalot/pdfparser": "^2.11",
"symfony/mailer": "^6.0"
},
"require-dev": {
@@ -84,7 +89,10 @@
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true
"sort-packages": true,
"allow-plugins": {
"php-http/discovery": true
}
},
"minimum-stability": "dev",
"prefer-stable": true,

1152
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddBotToWebSocketDialogUsersTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('web_socket_dialog_users', function (Blueprint $table) {
if (!Schema::hasColumn('web_socket_dialog_users', 'bot')) {
$table->tinyInteger('bot')->nullable()->default(0)->after('userid')->comment('是否机器人');
$table->index('bot');
}
});
// 获取表前缀
$prefix = DB::getTablePrefix();
// 使用原生SQL更新数据
/** @noinspection SqlNoDataSourceInspection */
DB::statement("
UPDATE {$prefix}web_socket_dialog_users du
INNER JOIN {$prefix}users u ON u.userid = du.userid
SET du.bot = 1
WHERE u.bot = 1
");
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('web_socket_dialog_users', function (Blueprint $table) {
if (Schema::hasColumn('web_socket_dialog_users', 'bot')) {
$table->dropIndex('bot');
$table->dropColumn('bot');
}
});
}
}

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class WebSocketDialogMsgsAddSessionId extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('web_socket_dialog_msgs', function (Blueprint $table) {
if (!Schema::hasColumn('web_socket_dialog_msgs', 'session_id')) {
$table->bigInteger('session_id')->index()->nullable()->default(0)->after('dialog_type')->comment('会话ID');
}
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('web_socket_dialog_msgs', function (Blueprint $table) {
$table->dropColumn('session_id');
});
}
}

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class WebSocketDialogsAddSessionId extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('web_socket_dialogs', function (Blueprint $table) {
if (!Schema::hasColumn('web_socket_dialogs', 'session_id')) {
$table->bigInteger('session_id')->index()->nullable()->default(0)->after('group_type')->comment('会话ID');
}
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('web_socket_dialogs', function (Blueprint $table) {
$table->dropColumn('session_id');
});
}
}

View File

@@ -0,0 +1,58 @@
<?php
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogMsg;
use App\Models\WebSocketDialogSession;
use App\Module\Base;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateWebSocketDialogSessionsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
if (Schema::hasTable('web_socket_dialog_sessions')) {
return;
}
Schema::create('web_socket_dialog_sessions', function (Blueprint $table) {
$table->id();
$table->bigInteger('dialog_id')->unsigned()->index()->comment('对话ID');
$table->string('title', 255)->default('')->comment('会话标题');
$table->timestamps();
});
$list = WebSocketDialog::select(['web_socket_dialogs.*', 'u.email'])
->join('web_socket_dialog_users as du', 'web_socket_dialogs.id', '=', 'du.dialog_id')
->join('users as u', 'du.userid', '=', 'u.userid')
->where('u.email', 'like', 'ai-%@bot.system')
->where('web_socket_dialogs.type', 'user')
->get();
foreach ($list as $item) {
$title = WebSocketDialogMsg::whereDialogId($item->id)->where('key', '!=', '')->orderBy('id')->value('key');
$session = WebSocketDialogSession::createInstance([
'dialog_id' => $item->id,
'title' => $title ? Base::cutStr($title, 100) : 'Unknown',
'created_at' => $item->created_at,
]);
$session->save();
$item->session_id = $session->id;
$item->save();
WebSocketDialogMsg::whereDialogId($item->id)->update(['session_id' => $session->id]);
}
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('web_socket_dialog_sessions');
}
}

View File

@@ -0,0 +1,50 @@
<?php
use App\Models\Setting;
use App\Module\Base;
use Illuminate\Database\Migrations\Migration;
class UpdateAiModelsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
$row = Setting::whereName('aibotSetting')->first();
if (empty($row)) {
return;
}
$value = Base::json2array($row->getRawOriginal('setting'));
foreach ($value as $key => $item) {
if (str_ends_with($key, '_models')) {
$value[$key] = preg_replace('/\s*:\s*/', ' | ', $item);
}
}
$row->setting = Base::array2json($value);
$row->save();
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
$row = Setting::whereName('aibotSetting')->first();
if (empty($row)) {
return;
}
$value = Base::json2array($row->getRawOriginal('setting'));
foreach ($value as $key => $item) {
if (str_ends_with($key, '_models')) {
$value[$key] = preg_replace('/\s*\|\s*/', ': ', $item);
}
}
$row->setting = Base::array2json($value);
$row->save();
}
}

View File

@@ -0,0 +1,54 @@
<?php
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogMsg;
use App\Models\WebSocketDialogSession;
use App\Module\Base;
use Illuminate\Database\Migrations\Migration;
class UpdateWebSocketDialogMsgsSessionId extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
$list = WebSocketDialog::select(['web_socket_dialogs.*', 'u.email'])
->join('web_socket_dialog_users as du', 'web_socket_dialogs.id', '=', 'du.dialog_id')
->join('users as u', 'du.userid', '=', 'u.userid')
->where('u.email', 'like', 'ai-%@bot.system')
->where('web_socket_dialogs.type', 'user')
->get();
foreach ($list as $item) {
$msg = WebSocketDialogMsg::whereDialogId($item->id)->whereSessionId(0)->orderBy('id')->first();
if ($msg || empty($item->session_id)) {
$title = $msg?->key;
$session = WebSocketDialogSession::createInstance([
'dialog_id' => $item->id,
'title' => $title ? Base::cutStr($title, 100) : 'Unknown',
'created_at' => $item->created_at,
]);
$session->save();
if (empty($item->session_id)) {
$item->session_id = $session->id;
$item->save();
}
if ($msg) {
WebSocketDialogMsg::whereDialogId($item->id)->whereSessionId(0)->update(['session_id' => $session->id]);
}
}
}
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
//
}
}

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateReportLinksTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('report_links', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('rid')->nullable()->default(0)->index()->comment('报告ID');
$table->integer('num')->nullable()->default(0)->comment('累计访问');
$table->string('code')->nullable()->default('')->comment('链接码');
$table->bigInteger('userid')->nullable()->default(0)->index()->comment('会员ID');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('report_links');
}
}

View File

@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateUserDevicesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
if (Schema::hasTable('user_devices'))
return;
Schema::create('user_devices', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('userid')->index()->nullable()->default(0)->comment('会员ID');
$table->string('hash')->index()->nullable()->default('')->comment('TOKEN MD5');
$table->longText('detail')->nullable()->comment('详细信息');
$table->timestamp('expired_at')->nullable()->comment('过期时间');
$table->timestamps();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('user_devices');
}
}

View File

@@ -2,7 +2,7 @@ services:
php:
container_name: "dootask-php-${APP_ID}"
image: "kuaifan/php:swoole-8.0.rc18"
shm_size: "2gb"
shm_size: 2G
ulimits:
core:
soft: 0
@@ -25,8 +25,10 @@ services:
extnetwork:
ipv4_address: "${APP_IPPR}.2"
depends_on:
- redis
- mariadb
mariadb:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
nginx:
@@ -41,20 +43,16 @@ services:
networks:
extnetwork:
ipv4_address: "${APP_IPPR}.3"
links:
- php
- office
- fileview
- drawio-webapp
- drawio-export
- minder
- okr
- ai
restart: unless-stopped
redis:
container_name: "dootask-redis-${APP_ID}"
image: "redis:alpine"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
networks:
extnetwork:
ipv4_address: "${APP_IPPR}.4"
@@ -73,6 +71,11 @@ services:
MYSQL_DATABASE: "${DB_DATABASE}"
MYSQL_USER: "${DB_USERNAME}"
MYSQL_PASSWORD: "${DB_PASSWORD}"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "${DB_USERNAME}", "-p${DB_PASSWORD}"]
interval: 5s
timeout: 5s
retries: 5
networks:
extnetwork:
ipv4_address: "${APP_IPPR}.5"
@@ -84,6 +87,7 @@ services:
volumes:
- ./docker/office/logs:/var/log/onlyoffice
- ./docker/office/data:/var/www/onlyoffice/Data
- ./docker/office/etc/documentserver/default.json:/etc/onlyoffice/documentserver/default.json
- ./docker/office/resources/require.js:/var/www/onlyoffice/documentserver/web-apps/vendor/requirejs/require.js
- ./docker/office/resources/common/main/resources/img/header:/var/www/onlyoffice/documentserver/web-apps/apps/common/main/resources/img/header
- ./docker/office/resources/documenteditor/main/resources/css/app.css:/var/www/onlyoffice/documentserver/web-apps/apps/documenteditor/main/resources/css/app.css
@@ -101,12 +105,11 @@ services:
fileview:
container_name: "dootask-fileview-${APP_ID}"
image: "kuaifan/fileview:4.2.0-SNAPSHOT-RC25"
image: "kuaifan/fileview:4.4.0-4"
environment:
KK_CONTEXT_PATH: "/fileview"
KK_OFFICE_PREVIEW_SWITCH_DISABLED: true
KK_FILE_UPLOAD_ENABLED: true
KK_MEDIA: "mp3,wav,mp4,mov,avi,wmv"
KK_MEDIA_CONVERT_DISABLE: true
networks:
extnetwork:
ipv4_address: "${APP_IPPR}.7"
@@ -124,8 +127,6 @@ services:
networks:
extnetwork:
ipv4_address: "${APP_IPPR}.8"
depends_on:
- drawio-export
restart: unless-stopped
drawio-export:
@@ -162,21 +163,18 @@ services:
networks:
extnetwork:
ipv4_address: "${APP_IPPR}.11"
depends_on:
- mariadb
restart: unless-stopped
ai:
container_name: "dootask-ai-${APP_ID}"
image: "kuaifan/dootask-ai:0.2.6"
image: "kuaifan/dootask-ai:0.3.5"
environment:
REDIS_HOST: "${REDIS_HOST}"
REDIS_PORT: "${REDIS_PORT}"
TIMEOUT: 600
networks:
extnetwork:
ipv4_address: "${APP_IPPR}.12"
depends_on:
- redis
restart: unless-stopped
okr:
@@ -184,7 +182,7 @@ services:
image: "kuaifan/doookr:0.4.5"
environment:
TZ: "${TIMEZONE:-PRC}"
DOO_TASK_URL: "http://${APP_IPPR}.3"
DOO_TASK_URL: "http://nginx"
MYSQL_HOST: "${DB_HOST}"
MYSQL_PORT: "${DB_PORT}"
MYSQL_DBNAME: "${DB_DATABASE}"
@@ -196,8 +194,6 @@ services:
networks:
extnetwork:
ipv4_address: "${APP_IPPR}.13"
depends_on:
- mariadb
restart: unless-stopped
face:
@@ -214,14 +210,31 @@ services:
MYSQL_PASSWORD: "${DB_PASSWORD}"
MYSQL_DB_NAME: "${DB_DATABASE}"
DB_PREFIX: "${DB_PREFIX}"
REPORT_API: "http://${APP_IPPR}.3:80/api/public/checkin/report"
depends_on:
- mariadb
REPORT_API: "http://nginx/api/public/checkin/report"
networks:
extnetwork:
ipv4_address: "${APP_IPPR}.14"
restart: unless-stopped
search:
container_name: "dootask-search-${APP_ID}"
image: "public.ecr.aws/zinclabs/zincsearch:0.4.10"
volumes:
- ./docker/search/zincsearch:/data
environment:
ZINC_DATA_PATH: "/data"
ZINC_FIRST_ADMIN_USER: "${DB_USERNAME}"
ZINC_FIRST_ADMIN_PASSWORD: "${DB_PASSWORD}"
deploy:
resources:
limits:
cpus: '1'
memory: 1G
networks:
extnetwork:
ipv4_address: "${APP_IPPR}.15"
restart: unless-stopped
networks:
extnetwork:
name: "dootask-networks-${APP_ID}"

View File

@@ -1 +1,2 @@
*/
*
!.gitignore

View File

@@ -1,27 +1,99 @@
#!/bin/sh
#
# 重置用户密码脚本
#
# 使用方法:
# ./repassword.sh [账号标识符] [自定义密码]
#
# 参数说明:
# [账号标识符]: 可选可以是用户ID(纯数字)或邮箱地址。不提供时默认为第一个管理员用户
# [自定义密码]: 可选,指定要设置的新密码。不提供时会自动生成随机密码
#
# 使用示例:
# ./repassword.sh # 重置第一个管理员用户密码(随机生成)
# ./repassword.sh 123 # 重置ID=123的用户密码(随机生成)
# ./repassword.sh user@example.com # 重置邮箱为user@example.com的用户密码(随机生成)
# ./repassword.sh 123 newpass # 重置ID=123的用户密码为"newpass"
# ./repassword.sh user@example.com newpass # 重置邮箱为user@example.com的用户密码为"newpass"
#
new_password=$1
account_identifier=$1
custom_password=$2
GreenBG="\033[42;37m"
RedBG="\033[41;37m"
Font="\033[0m"
# 生成随机密码
new_encrypt=$(date +%s%N | md5sum | awk '{print $1}' | cut -c 1-6)
if [ -z "$new_password" ]; then
if [ -z "$custom_password" ]; then
new_password=$(date +%s%N | md5sum | awk '{print $1}' | cut -c 1-16)
else
new_password=$custom_password
fi
md5_password=$(echo -n `echo -n $new_password | md5sum | awk '{print $1}'`$new_encrypt | md5sum | awk '{print $1}')
content=$(echo "select \`email\` from ${MYSQL_PREFIX}users where \`userid\`=1;" | mysql -u$MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE)
account=$(echo "$content" | sed -n '2p')
# 构建查询条件
if [ -z "$account_identifier" ]; then
# 默认查询第一个管理员
admin_query=$(echo "SELECT userid FROM ${MYSQL_PREFIX}users WHERE identity LIKE '%,admin,%' ORDER BY userid LIMIT 1;" | mysql -u$MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE)
identifier_value=$(echo "$admin_query" | sed -n '2p')
if [ -z "$identifier_value" ]; then
echo "${RedBG}错误:未找到管理员用户!${Font}"
exit 1
fi
where_field="userid"
identifier_type="管理员ID"
else
# 检查是否为纯数字ID
# 使用更兼容的 shell 语法检查是否为纯数字
case "$account_identifier" in
''|*[!0-9]*)
# 非纯数字,视为邮箱
where_field="email"
identifier_type="邮箱"
identifier_value="$account_identifier"
;;
*)
# 纯数字视为ID
where_field="userid"
identifier_type="ID"
identifier_value="$account_identifier"
;;
esac
fi
# 构建 WHERE 条件(为邮箱添加引号)
if [ "$where_field" = "email" ]; then
where_condition="where $where_field='$identifier_value'"
else
where_condition="where $where_field=$identifier_value"
fi
# 查询用户信息
content=$(echo "select userid,email from ${MYSQL_PREFIX}users $where_condition;" | mysql -u$MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE)
# 提取用户ID和邮箱
user_id=$(echo "$content" | sed -n '2p' | awk '{print $1}')
account=$(echo "$content" | sed -n '2p' | awk '{print $2}')
if [ -z "$account" ]; then
echo "错误:账号不存在!"
echo "${RedBG}错误:${identifier_type} '${identifier_value}' 的账号不存在!${Font}"
exit 1
fi
# 更新密码
mysql -u$MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE <<EOF
update ${MYSQL_PREFIX}users set \`encrypt\`='${new_encrypt}',\`password\`='${md5_password}' where \`userid\`=1;
update ${MYSQL_PREFIX}users set encrypt='${new_encrypt}',password='${md5_password}' $where_condition;
EOF
echo "账号: ${GreenBG}${account}${Font}"
# 只在 identifier_type="ID" 时才输出ID
if [ "$identifier_type" = "ID" ]; then
echo "ID: ${GreenBG}${user_id}${Font}"
fi
# 输出邮箱和密码
echo "邮箱: ${GreenBG}${account}${Font}"
echo "密码: ${GreenBG}${new_password}${Font}"

View File

@@ -1 +1,2 @@
*
!.gitignore

View File

@@ -78,129 +78,7 @@ server {
proxy_pass http://service;
}
location /fileview {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-PORT $remote_port;
proxy_set_header X-Forwarded-Host $the_host;
proxy_set_header X-Forwarded-Proto $the_scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header Scheme $scheme;
proxy_set_header Server-Protocol $server_protocol;
proxy_set_header Server-Name $server_name;
proxy_set_header Server-Addr $server_addr;
proxy_set_header Server-Port $server_port;
proxy_pass http://fileview:8012;
}
location /office/ {
proxy_http_version 1.1;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-PORT $remote_port;
proxy_set_header X-Forwarded-Host $the_host/office;
proxy_set_header X-Forwarded-Proto $the_scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header Scheme $scheme;
proxy_set_header Server-Protocol $server_protocol;
proxy_set_header Server-Name $server_name;
proxy_set_header Server-Addr $server_addr;
proxy_set_header Server-Port $server_port;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 3600s;
proxy_pass http://office/;
}
location /drawio/webapp/ {
proxy_http_version 1.1;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-PORT $remote_port;
proxy_set_header X-Forwarded-Host $the_host/drawio/webapp;
proxy_set_header X-Forwarded-Proto $the_scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header Scheme $scheme;
proxy_set_header Server-Protocol $server_protocol;
proxy_set_header Server-Name $server_name;
proxy_set_header Server-Addr $server_addr;
proxy_set_header Server-Port $server_port;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_pass http://drawio-webapp:8080/;
}
location /drawio/export/ {
proxy_http_version 1.1;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-PORT $remote_port;
proxy_set_header X-Forwarded-Host $the_host/drawio/export;
proxy_set_header X-Forwarded-Proto $the_scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header Scheme $scheme;
proxy_set_header Server-Protocol $server_protocol;
proxy_set_header Server-Name $server_name;
proxy_set_header Server-Addr $server_addr;
proxy_set_header Server-Port $server_port;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_pass http://drawio-export:8000/;
}
location /minder/ {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-PORT $remote_port;
proxy_set_header X-Forwarded-Host $the_host;
proxy_set_header X-Forwarded-Proto $the_scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header Scheme $scheme;
proxy_set_header Server-Protocol $server_protocol;
proxy_set_header Server-Name $server_name;
proxy_set_header Server-Addr $server_addr;
proxy_set_header Server-Port $server_port;
proxy_pass http://minder/;
}
# 审批
location /approve/ {
proxy_pass http://approve/;
}
location /approve/api/ {
auth_request /approveAuth;
proxy_pass http://approve/api/;
}
location /approveAuth {
internal;
proxy_set_header Content-Type "application/json";
proxy_set_header Content-Length $request_length;
proxy_pass http://service/api/approve/verifyToken;
}
# OKR
location /apps/okr/ {
proxy_pass http://okr:5566/apps/okr/;
}
# AI
location /ai/ {
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_set_header Scheme $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_pass http://ai:5001/;
}
include /etc/nginx/conf.d/location/*.conf;
}
include /etc/nginx/conf.d/conf.d/*.conf;

View File

@@ -0,0 +1,12 @@
location /ai/ {
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 3600s;
proxy_set_header Scheme $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_pass http://ai:5001/;
}

View File

@@ -0,0 +1,13 @@
location /approve/ {
proxy_pass http://approve/;
}
location /approve/api/ {
auth_request /approveAuth;
proxy_pass http://approve/api/;
}
location /approveAuth {
internal;
proxy_set_header Content-Type "application/json";
proxy_set_header Content-Length $request_length;
proxy_pass http://service/api/approve/verifyToken;
}

View File

@@ -0,0 +1,35 @@
location /drawio/webapp/ {
proxy_http_version 1.1;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-PORT $remote_port;
proxy_set_header X-Forwarded-Host $the_host/drawio/webapp;
proxy_set_header X-Forwarded-Proto $the_scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header Scheme $scheme;
proxy_set_header Server-Protocol $server_protocol;
proxy_set_header Server-Name $server_name;
proxy_set_header Server-Addr $server_addr;
proxy_set_header Server-Port $server_port;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_pass http://drawio-webapp:8080/;
}
location /drawio/export/ {
proxy_http_version 1.1;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-PORT $remote_port;
proxy_set_header X-Forwarded-Host $the_host/drawio/export;
proxy_set_header X-Forwarded-Proto $the_scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header Scheme $scheme;
proxy_set_header Server-Protocol $server_protocol;
proxy_set_header Server-Name $server_name;
proxy_set_header Server-Addr $server_addr;
proxy_set_header Server-Port $server_port;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_pass http://drawio-export:8000/;
}

View File

@@ -0,0 +1,16 @@
location /fileview {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-PORT $remote_port;
proxy_set_header X-Forwarded-Host $the_host;
proxy_set_header X-Forwarded-Proto $the_scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header Scheme $scheme;
proxy_set_header Server-Protocol $server_protocol;
proxy_set_header Server-Name $server_name;
proxy_set_header Server-Addr $server_addr;
proxy_set_header Server-Port $server_port;
proxy_pass http://fileview:8012;
}

View File

@@ -0,0 +1,16 @@
location /minder/ {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-PORT $remote_port;
proxy_set_header X-Forwarded-Host $the_host;
proxy_set_header X-Forwarded-Proto $the_scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header Scheme $scheme;
proxy_set_header Server-Protocol $server_protocol;
proxy_set_header Server-Name $server_name;
proxy_set_header Server-Addr $server_addr;
proxy_set_header Server-Port $server_port;
proxy_pass http://minder/;
}

View File

@@ -0,0 +1,20 @@
location /office/ {
proxy_http_version 1.1;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-PORT $remote_port;
proxy_set_header X-Forwarded-Host $the_host/office;
proxy_set_header X-Forwarded-Proto $the_scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header Scheme $scheme;
proxy_set_header Server-Protocol $server_protocol;
proxy_set_header Server-Name $server_name;
proxy_set_header Server-Addr $server_addr;
proxy_set_header Server-Port $server_port;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 3600s;
proxy_pass http://office/;
}

View File

@@ -0,0 +1,3 @@
location /apps/okr/ {
proxy_pass http://okr:5566/apps/okr/;
}

View File

@@ -1 +1,2 @@
*
!.gitignore

View File

@@ -0,0 +1,508 @@
{
"statsd": {
"useMetrics": false,
"host": "localhost",
"port": "8125",
"prefix": "ds."
},
"log": {
"filePath": "",
"options": {
"replaceConsole": true
}
},
"queue": {
"type": "rabbitmq",
"visibilityTimeout": 300,
"retentionPeriod": 900
},
"email": {
"smtpServerConfiguration": {
"host": "localhost",
"port": 587,
"auth": {
"user": "onlyoffice",
"pass": "onlyoffice"
}
},
"connectionConfiguration": {
"disableFileAccess": false,
"disableUrlAccess": false
},
"contactDefaults": {
"from": "from@example.com",
"to": "to@example.com"
}
},
"notification": {
"rules": {
"licenseExpirationWarning": {
"enable": false,
"transportType": [
"email"
],
"template": {
"title": "%s Docs license expiration warning",
"body": "Attention! Your license is about to expire on %s.\nUpon reaching this date, you will no longer be entitled to receive personal technical support and install new Docs versions released after this date."
},
"policies": {
"repeatInterval": "1d"
}
},
"licenseExpirationError": {
"enable": false,
"transportType": [
"email"
],
"template": {
"title": "%s Docs license expiration warning",
"body": "Attention! Your license expired on %s.\nYou are no longer entitled to receive personal technical support and install new Docs versions released after this date.\nPlease contact sales@onlyoffice.com to discuss license renewal."
},
"policies": {
"repeatInterval": "1d"
}
},
"licenseLimitEdit": {
"enable": false,
"transportType": [
"email"
],
"template": {
"title": "%s Docs license connection limit warning",
"body": "Attention! You have reached %s%% of the %s limit set by your license."
},
"policies": {
"repeatInterval": "1h"
}
},
"licenseLimitLiveViewer": {
"enable": false,
"transportType": [
"email"
],
"template": {
"title": "%s Docs license connection limit warning",
"body": "Attention! You have reached %s%% of the live viewer %s limit set by your license."
},
"policies": {
"repeatInterval": "1h"
}
}
}
},
"storage": {
"name": "storage-fs",
"fs": {
"folderPath": "",
"urlExpires": 900,
"secretString": "verysecretstring"
},
"region": "",
"endpoint": "http://localhost/s3",
"bucketName": "cache",
"storageFolderName": "files",
"cacheFolderName": "data",
"urlExpires": 604800,
"accessKeyId": "AKID",
"secretAccessKey": "SECRET",
"sslEnabled": false,
"s3ForcePathStyle": true,
"externalHost": ""
},
"persistentStorage": {
},
"rabbitmq": {
"url": "amqp://guest:guest@localhost:5672",
"socketOptions": {},
"exchangepubsub": "ds.pubsub",
"queueconverttask": "ds.converttask",
"queueconvertresponse": "ds.convertresponse",
"exchangeconvertdead": "ds.exchangeconvertdead",
"queueconvertdead": "ds.convertdead",
"queuedelayed": "ds.delayed"
},
"activemq": {
"connectOptions": {
"port": 5672,
"host": "localhost",
"reconnect": false
},
"queueconverttask": "ds.converttask",
"queueconvertresponse": "ds.convertresponse",
"queueconvertdead": "ActiveMQ.DLQ",
"queuedelayed": "ds.delayed",
"topicpubsub": "ds.pubsub"
},
"dnscache": {
"enable" : true,
"ttl" : 300,
"cachesize" : 1000
},
"openpgpjs": {
"config": {
},
"encrypt": {
"passwords": ["verysecretstring"]
},
"decrypt": {
"passwords": ["verysecretstring"]
}
},
"aesEncrypt": {
"config": {
"keyByteLength": 32,
"saltByteLength": 64,
"initializationVectorByteLength": 16,
"iterationsByteLength": 5
},
"secret": "verysecretstring"
},
"bottleneck": {
"getChanges": {
}
},
"win-ca": {
"inject": "+"
},
"wopi": {
"enable": false,
"host" : "",
"htmlTemplate" : "../../web-apps/apps/api/wopi",
"wopiZone" : "external-http",
"favIconUrlWord" : "/web-apps/apps/documenteditor/main/resources/img/favicon.ico",
"favIconUrlCell" : "/web-apps/apps/spreadsheeteditor/main/resources/img/favicon.ico",
"favIconUrlSlide" : "/web-apps/apps/presentationeditor/main/resources/img/favicon.ico",
"favIconUrlPdf" : "/web-apps/apps/pdfeditor/main/resources/img/favicon.ico",
"fileInfoBlockList" : ["FileUrl"],
"pdfView": ["djvu", "xps", "oxps"],
"pdfEdit": ["pdf"],
"forms": ["pdf"],
"wordView": ["doc", "dotm", "dot", "fodt", "ott", "rtf", "mht", "mhtml", "html", "htm", "xml", "epub", "fb2", "sxw", "stw", "wps", "wpt", "docxf", "oform"],
"wordEdit": ["docx", "dotx", "docm", "odt", "txt"],
"cellView": ["xls", "xlsb", "xltm", "xlt", "fods", "ots", "sxc", "xml", "et", "ett"],
"cellEdit": ["xlsx", "xltx", "xlsm", "ods", "csv"],
"slideView": ["ppt", "ppsx", "ppsm", "pps", "potm", "pot", "fodp", "otp", "sxi", "dps", "dpt"],
"slideEdit": ["pptx", "potx", "pptm", "odp"],
"publicKey": "BgIAAACkAABSU0ExAAgAAAEAAQBpTpiJQ2hD8plpGTfEEmcq4IKyr31HikXpuVSBraMfqyodn2PGXBJ3daNSmdPOc0Nz4HO9Auljn8YYXDPBdpiABptSKvEDPF23Q+Qytg0+vCRyondyBcW91w7KLzXce3fnk8ZfJ8QtbZPL9m11wJIWZueQF+l0HKYx4lty+nccbCanytFTADkGQ3SnmExGEF3rBz6I9+OcrDDK9NKPJgEmCiuyei/d4XbPgKls3EIG0h38X5mVF2VytfWm2Yu850B6z3N4MYhj4b4vsYT62zEC4pMRUeb8dIBy4Jsmr3avtmeO00MUH6DVyPC8nirixj2YIOPKk13CdVqGDSXA3cvl",
"modulus": "5cvdwCUNhlp1wl2TyuMgmD3G4iqevPDI1aAfFEPTjme2r3avJpvgcoB0/OZREZPiAjHb+oSxL77hY4gxeHPPekDnvIvZpvW1cmUXlZlf/B3SBkLcbKmAz3bh3S96sisKJgEmj9L0yjCsnOP3iD4H610QRkyYp3RDBjkAU9HKpyZsHHf6clviMaYcdOkXkOdmFpLAdW32y5NtLcQnX8aT53d73DUvyg7XvcUFcneiciS8Pg22MuRDt108A/EqUpsGgJh2wTNcGMafY+kCvXPgc0NzztOZUqN1dxJcxmOfHSqrH6OtgVS56UWKR32vsoLgKmcSxDcZaZnyQ2hDiZhOaQ==",
"exponent": 65537,
"privateKey": "-----BEGIN PRIVATE KEY-----\nMIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDly93AJQ2GWnXC\nXZPK4yCYPcbiKp688MjVoB8UQ9OOZ7avdq8mm+BygHT85lERk+ICMdv6hLEvvuFj\niDF4c896QOe8i9mm9bVyZReVmV/8HdIGQtxsqYDPduHdL3qyKwomASaP0vTKMKyc\n4/eIPgfrXRBGTJindEMGOQBT0cqnJmwcd/pyW+Ixphx06ReQ52YWksB1bfbLk20t\nxCdfxpPnd3vcNS/KDte9xQVyd6JyJLw+DbYy5EO3XTwD8SpSmwaAmHbBM1wYxp9j\n6QK9c+BzQ3PO05lSo3V3ElzGY58dKqsfo62BVLnpRYpHfa+yguAqZxLENxlpmfJD\naEOJmE5pAgMBAAECggEALiL+RKOr0Xu8BOgQ0j1DwA03LxVrhXe6etmJI+JySTcd\ngKENjWziZVrRIi2DvUm5qMMl7WhSwslKK1eexxZJY7xASqSxcEoIwgz17T07/jxm\nfIdUBiUKDZ1Kv8PWmIr3oKW+fkXWi/m1zlIe0qXRpTmsGNEsHQLEqi0rmaiXTXOR\n/2Ldwi6kZR3sWFx97YS4Mx/pueGJTXEai6AVEZzN5Gog6xD8HXR1Rvq+hhd+MocG\nfnU4HgilKRfoJlWd9FOscgSufKG0L3ViO4fSKU46l5aullDYUk5ECMWiwuKSqSE7\nqD45jI3mbOre7S4u3S3TWdD3lzwiXL49LdwKlEC4mQKBgQD0sLr0GH4Wr+QX2xJE\nuA/Cb8QW41l8iSCBTRZZR/sJOd+o3rbcVidlzO/EbZblXG4ZPDmRjgBCGKIP5EZi\n0DsL+Wv32WOo44LpxJGhqExbm0H1iZ1zZ97l0P8fvIhHE42gmaLToOIGDhPSXGvv\nzlqOHbGbq4jsERc1jp1bej5q6wKBgQDwaueIc4pRchH98QYidcyr8Vwg9KhbnfYX\ny3W4RPlZtBdF34iJaio+ASzugo/zy1RTcVrsCskYWXyKDUQz1yu0iCng+fDCUnTm\nXGmEoEGNhk4vTJOt7hBav1/Ja/dUipGf6mXUuanwJ0e+1/Et/B0ah5X1Um5AyNZI\nM+SyRz3u+wKBgQCjvtUNXoqaghCBCmB6TjZ1prexnWkYFugCv2SSUMIk1W7gIlJ6\ntsjcrj1R1Qii6qzfBFd+GWoA0V06h0e2/qRVCg//p6GytrW33IycgvS+ZPLJ7tLI\nFR2r66WfRlpoPiSL8eRt/P7kkG0hXCn7K7ub2TEu/Ka/W1yNwad6PR8iCwKBgQC8\nXcZSrtQsxAc8w99emJVoEo9wcsCGJ9ltA0iUu9XyZpvlbyJ3J+s48YrWxQ0sop7L\nUgE+96Rfo51kPMi3JVtk81p8ntf4KMrWwokaFMXHsPcJMCJ1IBVIRLE0C5eZcYhv\nlyN57I4tT1lzOZYJxYK4Cot/zrn7oF/j6mTBGfh4iQKBgQCiJMUxRz01/czH/XSX\ngo3dVbHQ4FEOufWnE3Eb93S8r0/eq1RM118rb0TqzuiadW2xYDU4nucWQlrlmq0d\nFY/m+Hy97pqyk6jmoU5I/D+ssBIoYHWLnH9/xfvDEk2JGSJSHtzu0D4EDC/rgQ49\nMbYsO5oUrF8tPlhj5vzbf3GKLA==\n-----END PRIVATE KEY-----\n",
"publicKeyOld": "BgIAAACkAABSU0ExAAgAAAEAAQBpTpiJQ2hD8plpGTfEEmcq4IKyr31HikXpuVSBraMfqyodn2PGXBJ3daNSmdPOc0Nz4HO9Auljn8YYXDPBdpiABptSKvEDPF23Q+Qytg0+vCRyondyBcW91w7KLzXce3fnk8ZfJ8QtbZPL9m11wJIWZueQF+l0HKYx4lty+nccbCanytFTADkGQ3SnmExGEF3rBz6I9+OcrDDK9NKPJgEmCiuyei/d4XbPgKls3EIG0h38X5mVF2VytfWm2Yu850B6z3N4MYhj4b4vsYT62zEC4pMRUeb8dIBy4Jsmr3avtmeO00MUH6DVyPC8nirixj2YIOPKk13CdVqGDSXA3cvl",
"modulusOld": "5cvdwCUNhlp1wl2TyuMgmD3G4iqevPDI1aAfFEPTjme2r3avJpvgcoB0/OZREZPiAjHb+oSxL77hY4gxeHPPekDnvIvZpvW1cmUXlZlf/B3SBkLcbKmAz3bh3S96sisKJgEmj9L0yjCsnOP3iD4H610QRkyYp3RDBjkAU9HKpyZsHHf6clviMaYcdOkXkOdmFpLAdW32y5NtLcQnX8aT53d73DUvyg7XvcUFcneiciS8Pg22MuRDt108A/EqUpsGgJh2wTNcGMafY+kCvXPgc0NzztOZUqN1dxJcxmOfHSqrH6OtgVS56UWKR32vsoLgKmcSxDcZaZnyQ2hDiZhOaQ==",
"exponentOld": 65537,
"privateKeyOld": "-----BEGIN PRIVATE KEY-----\nMIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDly93AJQ2GWnXC\nXZPK4yCYPcbiKp688MjVoB8UQ9OOZ7avdq8mm+BygHT85lERk+ICMdv6hLEvvuFj\niDF4c896QOe8i9mm9bVyZReVmV/8HdIGQtxsqYDPduHdL3qyKwomASaP0vTKMKyc\n4/eIPgfrXRBGTJindEMGOQBT0cqnJmwcd/pyW+Ixphx06ReQ52YWksB1bfbLk20t\nxCdfxpPnd3vcNS/KDte9xQVyd6JyJLw+DbYy5EO3XTwD8SpSmwaAmHbBM1wYxp9j\n6QK9c+BzQ3PO05lSo3V3ElzGY58dKqsfo62BVLnpRYpHfa+yguAqZxLENxlpmfJD\naEOJmE5pAgMBAAECggEALiL+RKOr0Xu8BOgQ0j1DwA03LxVrhXe6etmJI+JySTcd\ngKENjWziZVrRIi2DvUm5qMMl7WhSwslKK1eexxZJY7xASqSxcEoIwgz17T07/jxm\nfIdUBiUKDZ1Kv8PWmIr3oKW+fkXWi/m1zlIe0qXRpTmsGNEsHQLEqi0rmaiXTXOR\n/2Ldwi6kZR3sWFx97YS4Mx/pueGJTXEai6AVEZzN5Gog6xD8HXR1Rvq+hhd+MocG\nfnU4HgilKRfoJlWd9FOscgSufKG0L3ViO4fSKU46l5aullDYUk5ECMWiwuKSqSE7\nqD45jI3mbOre7S4u3S3TWdD3lzwiXL49LdwKlEC4mQKBgQD0sLr0GH4Wr+QX2xJE\nuA/Cb8QW41l8iSCBTRZZR/sJOd+o3rbcVidlzO/EbZblXG4ZPDmRjgBCGKIP5EZi\n0DsL+Wv32WOo44LpxJGhqExbm0H1iZ1zZ97l0P8fvIhHE42gmaLToOIGDhPSXGvv\nzlqOHbGbq4jsERc1jp1bej5q6wKBgQDwaueIc4pRchH98QYidcyr8Vwg9KhbnfYX\ny3W4RPlZtBdF34iJaio+ASzugo/zy1RTcVrsCskYWXyKDUQz1yu0iCng+fDCUnTm\nXGmEoEGNhk4vTJOt7hBav1/Ja/dUipGf6mXUuanwJ0e+1/Et/B0ah5X1Um5AyNZI\nM+SyRz3u+wKBgQCjvtUNXoqaghCBCmB6TjZ1prexnWkYFugCv2SSUMIk1W7gIlJ6\ntsjcrj1R1Qii6qzfBFd+GWoA0V06h0e2/qRVCg//p6GytrW33IycgvS+ZPLJ7tLI\nFR2r66WfRlpoPiSL8eRt/P7kkG0hXCn7K7ub2TEu/Ka/W1yNwad6PR8iCwKBgQC8\nXcZSrtQsxAc8w99emJVoEo9wcsCGJ9ltA0iUu9XyZpvlbyJ3J+s48YrWxQ0sop7L\nUgE+96Rfo51kPMi3JVtk81p8ntf4KMrWwokaFMXHsPcJMCJ1IBVIRLE0C5eZcYhv\nlyN57I4tT1lzOZYJxYK4Cot/zrn7oF/j6mTBGfh4iQKBgQCiJMUxRz01/czH/XSX\ngo3dVbHQ4FEOufWnE3Eb93S8r0/eq1RM118rb0TqzuiadW2xYDU4nucWQlrlmq0d\nFY/m+Hy97pqyk6jmoU5I/D+ssBIoYHWLnH9/xfvDEk2JGSJSHtzu0D4EDC/rgQ49\nMbYsO5oUrF8tPlhj5vzbf3GKLA==\n-----END PRIVATE KEY-----\n",
"refreshLockInterval": "10m",
"dummy" : {
"enable": false,
"sampleFilePath": ""
}
},
"tenants": {
"baseDir": "",
"baseDomain": "",
"filenameConfig": "config.json",
"filenameSecret": "secret.key",
"filenameLicense": "license.lic",
"defaultTenant": "localhost",
"cache" : {
"stdTTL": 300,
"checkperiod": 60,
"useClones": false
}
},
"externalRequest": {
"directIfIn" : {
"allowList": [],
"jwtToken": true
},
"action": {
"allow": true,
"blockPrivateIP": true,
"proxyUrl": "",
"proxyUser": {
"username": "",
"password": ""
},
"proxyHeaders": {
}
}
},
"services": {
"CoAuthoring": {
"server": {
"port": 8000,
"workerpercpu": 1,
"mode": "development",
"limits_tempfile_upload": 104857600,
"limits_image_size": 26214400,
"limits_image_download_timeout": {
"connectionAndInactivity": "2m",
"wholeCycle": "2m"
},
"callbackRequestTimeout": {
"connectionAndInactivity": "10m",
"wholeCycle": "10m"
},
"healthcheckfilepath": "../public/healthcheck.docx",
"savetimeoutdelay": 5000,
"edit_singleton": false,
"forgottenfiles": "forgotten",
"forgottenfilesname": "output",
"maxRequestChanges": 20000,
"openProtectedFile": true,
"isAnonymousSupport": true,
"editorDataStorage": "editorDataMemory",
"editorStatStorage": "",
"assemblyFormatAsOrigin": true,
"newFileTemplate" : "../../document-templates/new",
"downloadFileAllowExt": ["pdf", "xlsx"],
"tokenRequiredParams": true,
"forceSaveUsingButtonWithoutChanges": false
},
"requestDefaults": {
"headers": {
"User-Agent": "Node.js/6.13",
"Connection": "Keep-Alive"
},
"gzip": true,
"rejectUnauthorized": true
},
"autoAssembly": {
"enable": false,
"interval": "5m",
"step": "1m"
},
"utils": {
"utils_common_fontdir": "null",
"utils_fonts_search_patterns": "*.ttf;*.ttc;*.otf",
"limits_image_types_upload": "jpg;jpeg;jpe;png;gif;bmp;svg;tiff;tif"
},
"sql": {
"type": "postgres",
"tableChanges": "doc_changes",
"tableResult": "task_result",
"dbHost": "localhost",
"dbPort": 5432,
"dbName": "onlyoffice",
"dbUser": "onlyoffice",
"dbPass": "onlyoffice",
"charset": "utf8",
"connectionlimit": 10,
"max_allowed_packet": 1048575,
"pgPoolExtraOptions": {
"idleTimeoutMillis": 30000,
"maxLifetimeSeconds ": 60000,
"statement_timeout ": 60000,
"query_timeout ": 60000,
"connectionTimeoutMillis": 60000
},
"damengExtraOptions": {
"columnNameUpperCase": false,
"columnNameCase": "lower",
"connectTimeout": 60000,
"loginEncrypt": false,
"localTimezone": 0,
"poolTimeout": 60,
"socketTimeout": 60000,
"queueTimeout": 60000
},
"oracleExtraOptions": {
"connectTimeout": 60
},
"msSqlExtraOptions": {
"options": {
"encrypt": false,
"trustServerCertificate": true
},
"pool": {
"idleTimeoutMillis": 30000
}
},
"mysqlExtraOptions": {
"connectTimeout": 60000,
"queryTimeout": 60000
}
},
"redis": {
"name": "redis",
"prefix": "ds:",
"host": "127.0.0.1",
"port": 6379,
"options": {},
"optionsCluster": {},
"iooptions": {
"lazyConnect": true
},
"iooptionsClusterNodes": [
],
"iooptionsClusterOptions": {
"lazyConnect": true
}
},
"pubsub": {
"maxChanges": 1000
},
"expire": {
"saveLock": 60,
"presence": 300,
"locks": 604800,
"changeindex": 86400,
"lockDoc": 30,
"message": 86400,
"lastsave": 604800,
"forcesave": 604800,
"forcesaveLock": 5000,
"saved": 3600,
"documentsCron": "0 */2 * * * *",
"files": 86400,
"filesCron": "00 00 */1 * * *",
"filesremovedatonce": 100,
"sessionidle": "1h",
"sessionabsolute": "30d",
"sessionclosecommand": "2m",
"pemStdTTL": "1h",
"pemCheckPeriod": "10m",
"updateVersionStatus": "5m",
"monthUniqueUsers": "1y"
},
"ipfilter": {
"rules": [{"address": "*", "allowed": true}],
"useforrequest": false,
"errorcode": 403
},
"request-filtering-agent" : {
"allowPrivateIPAddress": false,
"allowMetaIPAddress": false
},
"secret": {
"browser": {"string": "secret", "file": ""},
"inbox": {"string": "secret", "file": ""},
"outbox": {"string": "secret", "file": ""},
"session": {"string": "secret", "file": ""}
},
"token": {
"enable": {
"browser": false,
"request": {
"inbox": false,
"outbox": false
}
},
"browser": {
"secretFromInbox": true
},
"inbox": {
"header": "Authorization",
"prefix": "Bearer ",
"inBody": false
},
"outbox": {
"header": "Authorization",
"prefix": "Bearer ",
"algorithm": "HS256",
"expires": "5m",
"inBody": false,
"urlExclusionRegex": ""
},
"session": {
"algorithm": "HS256",
"expires": "30d"
},
"verifyOptions": {
"clockTolerance": 60
}
},
"plugins": {
"uri": "/sdkjs-plugins",
"autostart": []
},
"themes": {
"uri": "/web-apps/apps/common/main/resources/themes"
},
"editor":{
"spellcheckerUrl": "",
"reconnection":{
"attempts": 50,
"delay": "2s"
},
"binaryChanges": false,
"websocketMaxPayloadSize": "1.5MB",
"maxChangesSize": "0mb"
},
"sockjs": {
"sockjs_url": "",
"disable_cors": true,
"websocket": true
},
"socketio": {
"connection": {
"path": "/doc/",
"serveClient": false,
"pingTimeout": 20000,
"pingInterval": 25000,
"maxHttpBufferSize": 1e8
}
},
"callbackBackoffOptions": {
"retries": 0,
"timeout":{
"factor": 2,
"minTimeout": 1000,
"maxTimeout": 2147483647,
"randomize": false
},
"httpStatus": "429,500-599"
}
}
},
"license" : {
"license_file": "",
"warning_limit_percents": 70,
"packageType": 0,
"warning_license_expiration": "30d"
},
"FileConverter": {
"converter": {
"maxDownloadBytes": 1048576000,
"downloadTimeout": {
"connectionAndInactivity": "2m",
"wholeCycle": "2m"
},
"downloadAttemptMaxCount": 3,
"downloadAttemptDelay": 1000,
"maxprocesscount": 1,
"fontDir": "null",
"presentationThemesDir": "null",
"x2tPath": "null",
"docbuilderPath": "null",
"args": "",
"spawnOptions": {},
"errorfiles": "",
"streamWriterBufferSize": 8388608,
"maxRedeliveredCount": 2,
"inputLimits": [
{
"type": "docx;dotx;docm;dotm",
"zip": {
"uncompressed": "500MB",
"template": "*.xml"
}
},
{
"type": "xlsx;xltx;xlsm;xltm",
"zip": {
"uncompressed": "3000MB",
"template": "*.xml"
}
},
{
"type": "pptx;ppsx;potx;pptm;ppsm;potm",
"zip": {
"uncompressed": "500MB",
"template": "*.xml"
}
}
]
}
}
}

View File

@@ -1 +1,2 @@
*/
*
!.gitignore

2
docker/search/zincsearch/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

1
electron/.gitignore vendored
View File

@@ -10,3 +10,4 @@ cache/*
.devload
.native
.build

157
electron/build.js vendored
View File

@@ -492,12 +492,9 @@ async function startBuild(data) {
console.log("版本:", config.version + ` (${config.codeVerson})`);
console.log("系统:", platform.replace('build-', '').toUpperCase());
console.log("架构:", archs.map(arch => arch.toUpperCase()).join(', '));
console.log("发布:", publish ? '是' : '否');
if (publish) {
console.log("升级提示:", release ? '是' : '否');
if (platform === 'build-mac') {
console.log("公证:", notarize ? '是' : '否');
}
console.log("发布:", publish ? `是(${release ? '提示升级' : '静默升级'}` : '否');
if (platform === 'build-mac') {
console.log("公证:", notarize ? '是' : '否');
}
console.log("===============\n");
// drawio
@@ -698,6 +695,19 @@ if (["dev"].includes(argv[2])) {
});
} else {
// 手编译(默认)
let cachedConfig = {};
try {
const buildConfigPath = path.join(__dirname, '.build');
if (fs.existsSync(buildConfigPath)) {
const configContent = fs.readFileSync(buildConfigPath, 'utf-8');
if (configContent.trim()) {
cachedConfig = JSON.parse(configContent);
}
}
} catch (error) {
console.warn('读取缓存配置失败:', error.message);
}
const questions = [
{
type: 'checkbox',
@@ -707,14 +717,14 @@ if (["dev"].includes(argv[2])) {
{
name: "MacOS",
value: platforms[0],
checked: true
},
{
name: "Windows",
value: platforms[1]
}
],
validate: function(answer) {
default: (cachedConfig && cachedConfig.platform) || [],
validate: (answer) => {
if (answer.length < 1) {
return '请至少选择一个系统';
}
@@ -725,18 +735,27 @@ if (["dev"].includes(argv[2])) {
type: 'checkbox',
name: 'arch',
message: "选择系统架构",
choices: [
{
name: "arm64",
value: architectures[0],
checked: true
},
{
name: "x64",
value: architectures[1]
choices: ({platform}) => {
const array = [
{
name: "arm64",
value: architectures[0],
},
{
name: "x64",
value: architectures[1]
}
]
if (platform.find(item => item === 'build-mac')) {
array.push({
name: "通用" + (platform.length > 1 ? " (仅MacOS)" : ""),
value: 'universal'
})
}
],
validate: function(answer) {
return array;
},
default: (cachedConfig && cachedConfig.arch) || [],
validate: (answer) => {
if (answer.length < 1) {
return '请至少选择一个架构';
}
@@ -746,82 +765,94 @@ if (["dev"].includes(argv[2])) {
{
type: 'list',
name: 'publish',
message: "选择是否发布",
message: "选择是否发布",
choices: [{
name: "否",
value: false
}, {
name: "是",
value: true
}]
}
];
// 根据publish选项动态添加后续问题
const publishQuestions = [
}],
default: (cachedConfig && cachedConfig.publish !== undefined) ?
(cachedConfig.publish ? 1 : 0) : 0
},
{
type: 'list',
name: 'release',
message: "选择是否弹出升级提示框",
message: "选择升级方式",
when: ({publish}) => publish,
choices: [{
name: "",
name: "弹出提示",
value: true
}, {
name: "",
name: "静默",
value: false
}]
}],
default: (cachedConfig && cachedConfig.release !== undefined) ?
(cachedConfig.release ? 0 : 1) : 0
},
{
type: 'list',
name: 'notarize',
message: "选择是否需要公证MacOS应用",
when: (answers) => answers.platform === 'build-mac', // 只在MacOS时显示
message: ({platform}) => platform.length > 1 ? "选择是否公证(仅MacOS" : "选择是否公证",
when: ({platform}) => platform.find(item => item === 'build-mac'),
choices: [{
name: "否",
value: false
}, {
name: "是",
value: true
}]
}],
default: (cachedConfig && cachedConfig.notarize !== undefined) ?
(cachedConfig.notarize ? 1 : 0) : 0
}
];
// 先询问基本问题
inquirer.prompt(questions).then(async answers => {
// 如果选择发布,继续询问发布相关问题
if (answers.publish) {
const publishAnswers = await inquirer.prompt(publishQuestions);
Object.assign(answers, publishAnswers);
// 开始提问
const prompt = inquirer.createPromptModule();
prompt(questions)
.then(async answers => {
answers = Object.assign({
release: false,
notarize: false
}, answers);
if (!PUBLISH_KEY && (!GITHUB_TOKEN || !utils.strExists(GITHUB_REPOSITORY, "/"))) {
console.error("发布需要 PUBLISH_KEY 或 GitHub Token 和 Repository, 请检查环境变量!");
process.exit()
// 缓存当前配置
try {
fs.writeFileSync(path.join(__dirname, '.build'), JSON.stringify(answers, null, 4), 'utf-8');
} catch (error) {
console.warn('保存配置缓存失败:', error.message);
}
if (answers.notarize === true) {
if (!APPLEID || !APPLEIDPASS) {
console.error("公证MacOS应用需要 Apple ID 和 Apple ID 密码, 请检查环境变量!");
// 发布判断环境变量
if (answers.publish) {
if (!PUBLISH_KEY && (!GITHUB_TOKEN || !utils.strExists(GITHUB_REPOSITORY, "/"))) {
console.error("发布需要 PUBLISH_KEY 或 GitHub Token 和 Repository, 请检查环境变量!");
process.exit()
}
}
} else {
// 如果不发布,设置默认值
answers.release = false;
answers.notarize = false;
}
// 开始构建
for (const platform of answers.platform) {
for (const data of config.app) {
data.configure = {
platform,
archs: answers.arch,
publish: answers.publish,
release: answers.release,
notarize: answers.notarize
};
await startBuild(data);
// 公证判断环境变量
if (answers.notarize === true) {
if (!APPLEID || !APPLEIDPASS) {
console.error("公证需要 Apple ID 和 Apple ID 密码, 请检查环境变量!");
process.exit()
}
}
}
});
// 开始构建
for (const platform of answers.platform) {
for (const data of config.app) {
data.configure = {
platform,
archs: answers.arch,
publish: answers.publish,
release: answers.release,
notarize: answers.notarize
};
await startBuild(data);
}
}
})
.catch(_ => { });
}

View File

@@ -17,6 +17,11 @@ const PERMITTED_URL_SCHEMES = ["http:", "https:", MAILTO_PREFIX];
const electronMenu = {
language: {
copy: "复制",
back: "后退",
forward: "前进",
reload: "重新加载",
print: "打印",
openInBrowser: "在浏览器中打开",
saveImageAs: "图片存储为...",
copyImage: "复制图片",
@@ -116,77 +121,100 @@ const electronMenu = {
}
},
webContentsMenu(webContents) {
webContentsMenu(webContents, isBrowser = false) {
webContents.on("context-menu", function (e, params) {
const popupMenu = new Menu();
if (params.linkURL || params.srcURL) {
const url = params.linkURL || params.srcURL;
const popupMenu = new Menu();
if (!electronMenu.isBlobOrDataUrl(url) && !utils.isLocalAssetPath(url)) {
popupMenu.append(
new MenuItem({
label: electronMenu.language.openInBrowser,
accelerator: "o",
click() {
electronMenu.safeOpenURL(url);
},
}),
);
popupMenu.append(new MenuItem({
label: electronMenu.language.openInBrowser,
click: async function () {
electronMenu.safeOpenURL(url);
},
}));
}
if (params.hasImageContents) {
if (!electronMenu.isBlob(url)) {
popupMenu.append(
new MenuItem({
label: electronMenu.language.saveImageAs,
accelerator: "s",
click: async function () {
await electronMenu.saveImageAs(url, params);
},
}),
);
}
popupMenu.append(
new MenuItem({
label: electronMenu.language.copyImage,
accelerator: "c",
click() {
webContents.copyImageAt(params.x, params.y);
popupMenu.append(new MenuItem({
label: electronMenu.language.saveImageAs,
click: async function () {
await electronMenu.saveImageAs(url, params);
},
}),
);
}));
}
popupMenu.append(new MenuItem({
label: electronMenu.language.copyImage,
click: async function () {
webContents.copyImageAt(params.x, params.y);
},
}));
}
if (!electronMenu.isBlobOrDataUrl(url)) {
if (url.startsWith(MAILTO_PREFIX)) {
popupMenu.append(
new MenuItem({
label: electronMenu.language.copyEmailAddress,
accelerator: "a",
click() {
clipboard.writeText(url.substring(MAILTO_PREFIX.length));
},
}),
);
popupMenu.append(new MenuItem({
label: electronMenu.language.copyEmailAddress,
click: async function () {
clipboard.writeText(url.substring(MAILTO_PREFIX.length));
},
}));
} else if (!utils.isLocalAssetPath(url)) {
popupMenu.append(
new MenuItem({
label: params.hasImageContents ? electronMenu.language.copyImageAddress : electronMenu.language.copyLinkAddress,
accelerator: "a",
click() {
clipboard.writeText(url);
},
}),
);
popupMenu.append(new MenuItem({
label: params.hasImageContents ? electronMenu.language.copyImageAddress : electronMenu.language.copyLinkAddress,
click: async function () {
clipboard.writeText(url);
},
}));
}
}
}
if (isBrowser) {
if (popupMenu.items.length > 0) {
popupMenu.popup({});
e.preventDefault();
popupMenu.insert(0, new MenuItem({type: 'separator'}))
}
popupMenu.insert(0, new MenuItem({
label: electronMenu.language.print,
click: () => webContents.print()
}))
popupMenu.insert(0, new MenuItem({
label: electronMenu.language.reload,
click: () => webContents.reload()
}))
popupMenu.insert(0, new MenuItem({
label: electronMenu.language.forward,
enabled: webContents.navigationHistory.canGoForward(),
click: () => webContents.navigationHistory.goForward()
}))
popupMenu.insert(0, new MenuItem({
label: electronMenu.language.back,
enabled: webContents.navigationHistory.canGoBack(),
click: () => webContents.navigationHistory.goBack()
}))
}
if (params.selectionText) {
if (popupMenu.items.length > 0) {
popupMenu.insert(0, new MenuItem({type: 'separator'}))
}
popupMenu.insert(0, new MenuItem({
label: electronMenu.language.copy,
role: 'copy'
}))
}
if (popupMenu.items.length > 0) {
popupMenu.popup({});
e.preventDefault();
}
})
}
},
}
module.exports = electronMenu;

View File

@@ -33,27 +33,25 @@ contextBridge.exposeInMainWorld(
request: (msg, callback, error) => {
msg.reqId = reqId++;
reqInfo[msg.reqId] = {callback: callback, error: error};
//TODO Maybe a special function for this better than this hack?
//File watch special case where the callback is called multiple times
if (msg.action == 'watchFile') {
fileChangedListeners[msg.path] = msg.listener;
delete msg.listener;
}
ipcRenderer.send('rendererReq', msg);
},
registerMsgListener: function (action, callback) {
ipcRenderer.on(action, function (event, args) {
callback(args);
});
},
sendMessage: function (action, args) {
ipcRenderer.send(action, args);
},
sendAsync: function (action, args) {
return ipcRenderer.invoke(action, args)
},
listener: function (action, callback) {
ipcRenderer.on(action, function (event, args) {
callback(args);
});
},
listenOnce: function (action, callback) {
ipcRenderer.once(action, function (event, args) {
callback(args);

174
electron/electron.js vendored
View File

@@ -58,7 +58,8 @@ let childWindow = [],
mediaType = null,
webTabWindow = null,
webTabView = [],
webTabHeight = 38;
webTabHeight = 40,
webTabClosedByShortcut = false;
let showState = {},
onShowWindow = (win) => {
@@ -145,28 +146,6 @@ function createMainWindow() {
}
})
mainWindow.webContents.session.setPermissionRequestHandler((webContents, permission, callback) => {
const allowedPermissions = [
'cookies', // cookies
'media', // 摄像头和麦克风
'notifications', // 通知
'clipboard-read', // 剪贴板读取
'clipboard-write' // 剪贴板写入
];
callback(allowedPermissions.includes(permission));
});
mainWindow.webContents.session.setPermissionCheckHandler((webContents, permission) => {
const allowedPermissions = [
'cookies',
'media',
'notifications',
'clipboard-read',
'clipboard-write'
];
return allowedPermissions.includes(permission);
});
mainWindow.on('page-title-updated', (event, title) => {
if (title == "index.html") {
event.preventDefault()
@@ -185,7 +164,14 @@ function createMainWindow() {
if (!willQuitApp) {
utils.onBeforeUnload(event, mainWindow).then(() => {
if (['darwin', 'win32'].includes(process.platform)) {
mainWindow.hide();
if (mainWindow.isFullScreen()) {
mainWindow.once('leave-full-screen', () => {
mainWindow.hide();
})
mainWindow.setFullScreen(false)
} else {
mainWindow.hide();
}
} else {
app.quit();
}
@@ -200,11 +186,12 @@ function createMainWindow() {
// 新窗口处理
mainWindow.webContents.setWindowOpenHandler(({url}) => {
if (allowedCalls.test(url)) {
return {action: 'allow'}
}
utils.onBeforeOpenWindow(mainWindow.webContents, url).then(() => {
openExternal(url)
})
} else {
utils.onBeforeOpenWindow(mainWindow.webContents, url).then(() => {
openExternal(url)
})
}
return {action: 'deny'}
})
@@ -440,11 +427,12 @@ function createChildWindow(args) {
// 新窗口处理
browser.webContents.setWindowOpenHandler(({url}) => {
if (allowedCalls.test(url)) {
return {action: 'allow'}
}
utils.onBeforeOpenWindow(browser.webContents, url).then(() => {
openExternal(url)
})
} else {
utils.onBeforeOpenWindow(browser.webContents, url).then(() => {
openExternal(url)
})
}
return {action: 'deny'}
})
@@ -452,13 +440,18 @@ function createChildWindow(args) {
electronMenu.webContentsMenu(browser.webContents)
// 加载地址
const hash = args.hash || args.path;
const hash = `${args.hash || args.path}`;
if (/^https?:/i.test(hash)) {
browser.loadURL(hash).then(_ => { }).catch(_ => { })
browser.loadURL(hash)
.then(_ => { })
.catch(_ => { })
} else if (isPreload) {
browser.webContents.executeJavaScript(`if(typeof window.__initializeApp === 'function'){window.__initializeApp('${hash}')}else{throw new Error('no function')}`, true).catch(() => {
utils.loadUrlOrFile(browser, devloadUrl, hash)
});
browser
.webContents
.executeJavaScript(`if(typeof window.__initializeApp === 'function'){window.__initializeApp('${hash}')}else{throw new Error('no function')}`, true)
.catch(() => {
utils.loadUrlOrFile(browser, devloadUrl, hash)
});
} else {
utils.loadUrlOrFile(browser, devloadUrl, hash)
}
@@ -519,8 +512,15 @@ function createMediaWindow(args, type = 'image') {
mediaWindow.addListener('close', event => {
if (!willQuitApp) {
event.preventDefault()
mediaWindow.webContents.send('on-close');
mediaWindow.hide();
if (mediaWindow.isFullScreen()) {
mediaWindow.once('leave-full-screen', () => {
mediaWindow.hide();
})
mediaWindow.setFullScreen(false)
} else {
mediaWindow.webContents.send('on-close');
mediaWindow.hide();
}
}
})
@@ -572,10 +572,6 @@ function createWebTabWindow(args) {
args = {url: args}
}
if (!allowedUrls.test(args.url)) {
return;
}
// 创建父级窗口
if (!webTabWindow) {
const titleBarOverlay = {
@@ -597,7 +593,7 @@ function createWebTabWindow(args) {
autoHideMenuBar: true,
titleBarStyle: 'hidden',
titleBarOverlay,
backgroundColor: nativeTheme.shouldUseDarkColors ? '#3B3B3D' : '#EFF0F4',
backgroundColor: nativeTheme.shouldUseDarkColors ? '#575757' : '#FFFFFF',
webPreferences: {
preload: path.join(__dirname, 'electron-preload.js'),
webSecurity: true,
@@ -607,6 +603,12 @@ function createWebTabWindow(args) {
},
}, userConf.get('webTabWindow', {})))
const originalClose = webTabWindow.close;
webTabWindow.close = function() {
webTabClosedByShortcut = true;
return originalClose.apply(this, arguments);
};
webTabWindow.on('resize', () => {
resizeWebTab(0)
})
@@ -624,12 +626,15 @@ function createWebTabWindow(args) {
})
webTabWindow.on('close', event => {
if (!willQuitApp) {
closeWebTab(0)
event.preventDefault()
} else {
userConf.set('webTabWindow', webTabWindow.getBounds())
if (webTabClosedByShortcut) {
webTabClosedByShortcut = false
if (!willQuitApp) {
closeWebTab(0)
event.preventDefault()
return
}
}
userConf.set('webTabWindow', webTabWindow.getBounds())
})
webTabWindow.on('closed', () => {
@@ -656,6 +661,8 @@ function createWebTabWindow(args) {
if (utils.isMetaOrControl(input) && input.key.toLowerCase() === 'r') {
reloadWebTab(0)
event.preventDefault()
} else if (utils.isMetaOrControl(input) && input.key.toLowerCase() === 'w') {
webTabClosedByShortcut = true
} else if (utils.isMetaOrControl(input) && input.shift && input.key.toLowerCase() === 'i') {
devToolsWebTab(0)
}
@@ -699,9 +706,10 @@ function createWebTabWindow(args) {
})
browserView.webContents.setWindowOpenHandler(({url}) => {
if (allowedCalls.test(url)) {
return {action: 'allow'}
openExternal(url)
} else {
createWebTabWindow({url})
}
createWebTabWindow({url})
return {action: 'deny'}
})
browserView.webContents.on('page-title-updated', (event, title) => {
@@ -750,10 +758,18 @@ function createWebTabWindow(args) {
if (utils.isMetaOrControl(input) && input.key.toLowerCase() === 'r') {
browserView.webContents.reload()
event.preventDefault()
} else if (utils.isMetaOrControl(input) && input.key.toLowerCase() === 'w') {
webTabClosedByShortcut = true
} else if (utils.isMetaOrControl(input) && input.shift && input.key.toLowerCase() === 'i') {
browserView.webContents.toggleDevTools()
}
})
const originalUA = browserView.webContents.session.getUserAgent() || browserView.webContents.getUserAgent()
browserView.webContents.setUserAgent(originalUA + " SubTaskWindow/" + process.platform + "/" + os.arch() + "/1.0");
electronMenu.webContentsMenu(browserView.webContents, true)
browserView.webContents.loadURL(args.url).then(_ => { }).catch(_ => { })
webTabWindow.addBrowserView(browserView)
@@ -896,7 +912,7 @@ function monitorThemeChanges() {
preloadWindow?.setBackgroundColor(backgroundColor);
mediaWindow?.setBackgroundColor(backgroundColor);
childWindow.some(({browser}) => browser.setBackgroundColor(backgroundColor))
webTabWindow?.setBackgroundColor(nativeTheme.shouldUseDarkColors ? '#3B3B3D' : '#EFF0F4')
webTabWindow?.setBackgroundColor(nativeTheme.shouldUseDarkColors ? '#575757' : '#FFFFFF')
// 通知所有窗口
BrowserWindow.getAllWindows().forEach(window => {
window.webContents.send('systemThemeChanged', {
@@ -1027,6 +1043,16 @@ ipcMain.on('openChildWindow', (event, args) => {
event.returnValue = "ok"
})
/**
* 显示预加载窗口(用于调试)
*/
ipcMain.on('showPreloadWindow', (event) => {
if (preloadWindow) {
onShowWindow(preloadWindow)
}
event.returnValue = "ok"
})
/**
* 更新路由窗口
* @param args {?name, ?path} // name: 不是要更改的窗口名,是要把窗口名改成什么, path: 地址
@@ -1269,13 +1295,17 @@ ipcMain.on('windowMax', (event) => {
})
/**
* 给主窗口发送信息
* @param args {channel, data}
* 给所有窗口广播指令(除了本身)
* @param args {type, payload}
*/
ipcMain.on('sendForwardMain', (event, args) => {
if (mainWindow) {
mainWindow.webContents.send(args.channel, args.data)
}
ipcMain.on('broadcastCommand', (event, args) => {
const channel = args.channel || args.command
const payload = args.payload || args.data
BrowserWindow.getAllWindows().forEach(window => {
if (window.webContents.id !== event.sender.id) {
window.webContents.send(channel, payload)
}
})
event.returnValue = "ok"
})
@@ -1419,15 +1449,17 @@ ipcMain.handle('getStore', (event, args) => {
//================================================================
let autoUpdating = 0
autoUpdater.logger = loger
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = true
autoUpdater.on('update-available', info => {
mainWindow.webContents.send("updateAvailable", info)
})
autoUpdater.on('update-downloaded', info => {
mainWindow.webContents.send("updateDownloaded", info)
})
if (autoUpdater) {
autoUpdater.logger = loger
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = true
autoUpdater.on('update-available', info => {
mainWindow.webContents.send("updateAvailable", info)
})
autoUpdater.on('update-downloaded', info => {
mainWindow.webContents.send("updateDownloaded", info)
})
}
/**
* 检查更新
@@ -1437,6 +1469,9 @@ ipcMain.on('updateCheckAndDownload', (event, args) => {
if (autoUpdating + 3600 > utils.dayjs().unix()) {
return // 限制1小时仅执行一次
}
if (!autoUpdater) {
return
}
if (args.provider) {
autoUpdater.setFeedURL(args)
}
@@ -1502,7 +1537,7 @@ ipcMain.on('updateQuitAndInstall', (event, args) => {
// 退出并安装更新
setTimeout(_ => {
mainWindow.hide()
autoUpdater.quitAndInstall(true, true)
autoUpdater?.quitAndInstall(true, true)
}, 600)
})
@@ -2476,10 +2511,9 @@ function windowAction(method) {
function openExternal(url) {
//Only open http(s), mailto, tel, and callto links
if (allowedUrls.test(url)) {
shell.openExternal(url);
shell.openExternal(url).catch(_ => {});
return true;
}
return false;
}

View File

@@ -12,6 +12,7 @@ exports.default = async function notarizing(context) {
}
return await notarize({
tool: "notarytool",
appBundleId: config.build.appId,
appPath: `${appOutDir}/${appName}.app`,
appleId: APPLEID,

View File

@@ -6,7 +6,7 @@
"license": "MIT",
"scripts": {
"start": "electron-forge start",
"start-quiet": "sleep 3 && electron-forge start &> /dev/null",
"start-quiet": "sleep 3 && electron-forge start",
"build": "electron-builder",
"build-mac": "electron-builder --mac",
"build-win": "electron-builder --win",
@@ -26,17 +26,17 @@
"url": "https://github.com/kuaifan/dootask.git"
},
"devDependencies": {
"@electron-forge/cli": "^7.6.0",
"@electron-forge/maker-deb": "^7.6.0",
"@electron-forge/maker-rpm": "^7.6.0",
"@electron-forge/maker-squirrel": "^7.6.0",
"@electron-forge/maker-zip": "^7.6.0",
"@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",
"dotenv": "^16.4.5",
"electron": "^33.2.1",
"electron": "^34.3.4",
"electron-builder": "^25.1.8",
"electron-notarize": "^1.2.2",
"form-data": "^4.0.1",
"inquirer": "^8.2.0",
"inquirer": "^12.4.2",
"ora": "^4.1.1"
},
"dependencies": {
@@ -108,7 +108,14 @@
"target": "dmg",
"arch": [
"x64",
"arm64"
"arm64",
"universal"
]
},
{
"target": "pkg",
"arch": [
"universal"
]
}
]

View File

@@ -23,11 +23,17 @@ html, body {
color: #333;
}
.app {
display: flex;
align-items: center;
}
.nav {
font-family: var(--tab-font-family);
font-feature-settings: 'clig', 'kern';
flex: 1;
width: 0;
display: flex;
width: 100%;
cursor: default;
background-color: var(--tab-background);
-webkit-app-region: drag;
@@ -35,8 +41,8 @@ html, body {
.nav ul {
display: flex;
height: 30px;
margin: 8px 46px 0 0;
height: 35px;
margin-top: 5px;
user-select: none;
overflow-x: auto;
overflow-y: hidden;
@@ -51,7 +57,7 @@ html, body {
position: relative;
box-sizing: border-box;
align-items: center;
height: 100%;
height: calc(100% - 5px);
padding: 7px 8px;
margin: 0 8px 0 0;
min-width: 100px;
@@ -73,31 +79,7 @@ html, body {
.nav ul li.active {
color: var(--tab-active-color);
background: var(--tab-active-background);
border-radius: 6px 6px 0 0;
}
.nav ul li.active::before {
position: absolute;
bottom: 0;
left: -6px;
width: 6px;
height: 6px;
background-image: url(../image/select_left.png);
background-repeat: no-repeat;
background-size: cover;
content: '';
}
.nav ul li.active::after {
position: absolute;
right: -6px;
bottom: 0;
width: 6px;
height: 6px;
background-image: url(../image/select_right.png);
background-repeat: no-repeat;
background-size: cover;
content: '';
border-radius: 4px;
}
.nav ul li.active .tab-icon.background {
@@ -120,14 +102,13 @@ html, body {
/* 浏览器打开 */
.browser {
position: absolute;
top: 0;
right: 0;
flex-shrink: 0;
display: flex;
align-items: center;
height: 38px;
height: 40px;
padding: 0 14px;
cursor: pointer;
background-color: var(--tab-background);
-webkit-app-region: none;
}
.browser span {
@@ -247,13 +228,6 @@ body.darwin.full-screen .nav ul {
--tab-active-background: #575757;
--tab-close-color: #E3E3E3;
}
.nav ul li.active::before {
background-image: url(../image/dark/select_left.png);
}
.nav ul li.active::after {
background-image: url(../image/dark/select_right.png);
}
.nav ul li.active .tab-icon.background {
background-image: url(../image/dark/link_normal_selected_icon.png);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 B

View File

@@ -20,7 +20,7 @@
</li>
</ul>
</div>
<div v-if="tabs.length > 0" class="browser" @click="onBrowser">
<div v-if="canBrowser" class="browser" @click="onBrowser">
<span></span>
</div>
</div>
@@ -119,15 +119,23 @@
}
},
computed: {
activeItem() {
if (this.tabs.length === 0) {
return null
}
return this.tabs.find(item => item.id === this.activeId)
},
pageTitle() {
const activeItem = this.tabs.find(item => item.id === this.activeId)
return activeItem ? activeItem.title : 'Untitled'
return this.activeItem ? this.activeItem.title : 'Untitled'
},
canBrowser() {
return !(this.activeItem && /^file:/.test(this.activeItem.url))
}
},
watch: {
pageTitle(title) {
document.title = title;
}
},
},
methods: {
onSwitch(item) {

View File

@@ -92,6 +92,7 @@
args.images.forEach(src => {
const img = document.createElement('img');
img.src = thumbnailUrl(src);
img.setAttribute('alt', 'image');
img.setAttribute('data-original', src);
container.appendChild(img);
});

17
electron/utils.js vendored
View File

@@ -275,11 +275,24 @@ const utils = {
* @returns {string|string}
*/
getDomain(weburl) {
let urlReg = /http(s)?:\/\/([^\/]+)/i;
let domain = (weburl + "").match(urlReg);
const urlReg = /http(s)?:\/\/([^\/]+)/i;
const domain = `${weburl}`.match(urlReg);
return ((domain != null && domain.length > 0) ? domain[2] : "");
},
/**
* 提取 URL 协议
* @param weburl
* @returns {string}
*/
getProtocol(weburl) {
try {
return new URL(weburl).protocol
} catch(e){
return ""
}
},
/**
* 显示窗口
* @param win

View File

@@ -14,7 +14,7 @@
消息内容最大不能超过(*)字
消息发送保存失败
说话时间太短
请选择转发对话或成员
请选择对话或成员
发送成功
不是发送人
文件不存在
@@ -171,9 +171,9 @@ LDAP 用户禁止修改邮箱
部门不存在或已被删除
最多只能创建(*)个部门
上级部门不存在或已被删除
上级部门层级错误
部门层级最多只能创建(*)级
每个部门最多只能创建(*)个子部门
含有子部门无法修改上级部门
含有子部门无法删除
请选择正确的部门负责人
新建成功
此功能未开启,请联系管理员开启
@@ -255,7 +255,8 @@ LDAP 用户禁止修改邮箱
此消息不支持设待办
仅支持设此待办人员【(*)】取消
转发成功
已超过(*)小时,此消息不撤回
已超过(*),此消息不撤回
已超过(*),此消息不可修改
文件分享错误
获取会话失败
消息不存在
@@ -351,6 +352,7 @@ LDAP 用户禁止修改邮箱
移交子任务身份
任务取消归档
自动任务归档
任务自动归档
任务归档
创建任务来自周期任务ID(*)
已创建新的周期任务ID(*),此任务关闭周期
@@ -467,6 +469,7 @@ OKR提醒
缺卡提醒
打卡提醒
任务待领取
指令解析失败。
非常抱歉,我不是你的机器人,无法完成你的指令。
您没有创建机器人。
机器人不存在。
@@ -489,6 +492,7 @@ webhook地址最长仅支持255个字符。
不支持的指令
机器人未启用。
当前客户端版本低(所需版本≥(*))。
引用消息解析失败。
审批结果
审批评论通知
审批通知
@@ -805,3 +809,41 @@ AI机器人不存在
内容不存在
长文本
选择模型
当前对话不支持
会话不存在或已被删除
开启新会话
历史会话
未找到默认模型
思考中...
请先填写 Base URL
获取失败
任务超期未完成
每个用户最多只能负责(*)个部门
不能选择自己的子部门作为上级部门
转文字失败
状态[(*)]设置错误,状态负责人[(*)]不在项目成员内
(*)天(*)小时(*)分钟
(*)天(*)小时
(*)天(*)分钟
(*)天
(*)小时(*)分钟
(*)小时
(*)分钟
任务不存在或已被删除
文件不存在或已被删除
报告不存在或已被删除
文件读取失败:(*)
请输入删除备注
删除备注长度限制(*)个字
系统机器人不能删除
「(*)」移动至「(*)」
设备不存在或已被删除

View File

@@ -547,7 +547,6 @@ SMTP服务器
空白模板
立即上传
立即升级
立即登录
签到功能
签到数据
签到日期
@@ -1025,7 +1024,6 @@ Pro版
隐藏共享文件
单聊
显示文件
ID、任务名...
仅显示我的
语音
群头像
@@ -1099,6 +1097,8 @@ MD 格式发送
执行
上一周
下一周
前一天
后一天
汇报名称
选择接收人
使用我上次的汇报对象
@@ -1221,7 +1221,6 @@ OKR 结果分析
AI 机器人
任务相关
请填写名称!
访问OpenAI网站查看
使用代理
支持 http 或 socks 代理
例如http://proxy.com 或 socks5://proxy.com
@@ -1359,7 +1358,6 @@ APP 推送
搜索项目名称
服务器版本过低,请升级服务器。
不显示原发送者信息
转发给
留言
多选
@我的
@@ -1517,9 +1515,10 @@ License Key
发起会议
返回参数错误
未知错误
网络异常,请重试。
请求失败,请重试。
网络异常,请稍后重试。
请求失败,请稍后重试。
任务待领取
指令解析失败。
非常抱歉,我不是你的机器人,无法完成你的指令。
您没有创建机器人。
机器人不存在。
@@ -1545,6 +1544,7 @@ API接口文档
不支持的指令
机器人未启用。
当前客户端版本低(所需版本≥(*))。
引用消息解析失败。
审批结果
审批评论通知
审批通知
@@ -1705,14 +1705,11 @@ WiFi签到延迟时长为±1分钟。
选择群组
输入关键词搜索群
仅支持选择个人群转为部门群
含有子部门无法修改上级部门
删除部门
你确定要删除【(*)】部门吗?
注意:此操作不可恢复,部门下的成员将移至默认部门。
维护中...
你确定要登出系统?
(*)评论了(*)的「(**)」审批
抄送(*)提交的「(**)」记录
(*)提交的「(**)」待你审批
@@ -1912,3 +1909,155 @@ WiFi签到延迟时长为±1分钟。
开启麦克风失败!
开启摄像头失败!
群机器人
群成员 ((*)人)
此消息已经过期
DeepSeek大语言模型算法是北京深度求索人工智能基础技术研究有限公司推出的深度合成服务算法。
API请求的基础URL路径如果没有请留空
欢迎词
仪表盘欢迎词,(*)代表用户昵称
思考过程
隐藏翻译
重新翻译
当前客户端不支持该指令
默认模型
与(*)会话历史
当前
新会话
模型温度,低则保守,高则多样
例如0.7范围0-1默认0.7
模型列表
一行一个模型名称
请选择默认模型
可选数据来自模型列表
使用默认模型列表
未知操作
获取失败
获取成功
Grok是由xAI开发的生成式人工智能聊天机器人旨在通过实时回答用户问题来提供帮助。
Ollama 是一个轻量级、可扩展的框架,旨在让用户能够在本地机器上构建和运行大型语言模型。
AI 列表
AI 设置
思考中...
请先填写 Base URL
例如http://proxy.com
API请求的URL路径
如果没有请留空
获取本地模型列表
任务超期未完成
打开部门群
群组 ID
最多选择(*)个部门
已共享
联系人
未搜到跟「(*)」相关的结果
暂无相关结果
请输入关键字
请输入关键字搜索
正在拼命搜索...
加载失败,请重启软件
发送原语音
转文字失败
请稍后再试...
选择识别语言
自动识别
选择翻译结果
不翻译结果
即将到期
创建任务
项目已归档,无法查看
默认90
出差
仅未读
仅已读
汇报状态
无法录音NotFoundError: Requested device not found无可用麦克风
关闭窗口
忽略
网络连接失败
重试
检查
撤回消息限制
消息发出后的可撤回时长。
修改消息限制
消息发出后的可修改时长。
汇报部门
确认转发
确认发送
分享到消息
分享报告到消息
确认分享
每次最多分享(*)个
标注人员:(*) (ID: (*))
标注人员不存在
附言
任务不存在或已被删除
文件不存在或已被删除
报告不存在或已被删除
文件读取失败:(*)
独立窗口显示
独立窗口
在消息中显示
添加机器人
消息保留
您没有创建机器人
清理时间
请输入备注原因
删除机器人:(*)
回复/引用消息文本
默认90天
机器人名称
后退
前进
重新加载
打印
你可能要发送的照片
AI开启新会话
AI开启新会话失败
打开会话窗口
转为已完成
转为未完成
「(*)」移动至「(*)」
发送任务
视频转换
关闭视频格式转换功能。
视频压缩
关闭视频压缩功能。
将MOV、WEBM格式的视频转换为MP4格式。
对MP4格式的视频进行压缩处理。
登录时间
更新时间
过期时间
当前设备
浏览器
客户端
是否在该设备上退出登录?
登录设备

File diff suppressed because it is too large Load Diff

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