Compare commits
311 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
754036c472 | ||
|
|
720438fd91 | ||
|
|
ba76df1b00 | ||
|
|
44d85c2864 | ||
|
|
1c8b73a381 | ||
|
|
b445af932c | ||
|
|
5121739fe4 | ||
|
|
96106498d8 | ||
|
|
0116d92021 | ||
|
|
43746634a5 | ||
|
|
5183786fb0 | ||
|
|
5ba0eed721 | ||
|
|
7d08c735ef | ||
|
|
e3067b685c | ||
|
|
b219ca4c1c | ||
|
|
9e5d16ff16 | ||
|
|
da630458e1 | ||
|
|
ee2eceffb0 | ||
|
|
c8d22e7b5f | ||
|
|
342e8725bd | ||
|
|
3ced00de1f | ||
|
|
7fa075fa75 | ||
|
|
95ca496691 | ||
|
|
50b1d93f08 | ||
|
|
8958f2f234 | ||
|
|
00b4d6a748 | ||
|
|
f4de0d8276 | ||
|
|
cfa749f4f3 | ||
|
|
eeaff08673 | ||
|
|
0475e88dc2 | ||
|
|
e1f73a4639 | ||
|
|
e2296a6f64 | ||
|
|
1a6abf4e1b | ||
|
|
315851eb5f | ||
|
|
0b99b4a9a0 | ||
|
|
e8235dd0a2 | ||
|
|
123c74de46 | ||
|
|
9419ddd174 | ||
|
|
0666a8f5c2 | ||
|
|
81c019105c | ||
|
|
6584259454 | ||
|
|
03d0f56095 | ||
|
|
406f64a7c5 | ||
|
|
1353a2c4c9 | ||
|
|
fb88f3bd96 | ||
|
|
22b3598704 | ||
|
|
b62c580d5e | ||
|
|
6a63ceaecc | ||
|
|
591f9e61fb | ||
|
|
7011c81bcd | ||
|
|
3cf7055122 | ||
|
|
aba31eda83 | ||
|
|
1b30582dd9 | ||
|
|
0fb66358cc | ||
|
|
e226f444f7 | ||
|
|
95bf70f568 | ||
|
|
a6597b44c3 | ||
|
|
51c01c5445 | ||
|
|
161bf75a1d | ||
|
|
2f16e2c608 | ||
|
|
aea2e79b37 | ||
|
|
f433d13a2f | ||
|
|
e9abf6ed05 | ||
|
|
0c32b25ddf | ||
|
|
a03dec91c5 | ||
|
|
7c5a966944 | ||
|
|
652dc0953b | ||
|
|
03860a6dce | ||
|
|
c6bee25264 | ||
|
|
068de0fa9f | ||
|
|
4b45d5ca26 | ||
|
|
a268391e68 | ||
|
|
89bdd86f14 | ||
|
|
e533bd7e35 | ||
|
|
09ed978e80 | ||
|
|
4b106e1f41 | ||
|
|
feeeb26d94 | ||
|
|
bef0d2d992 | ||
|
|
6e6bd8a6be | ||
|
|
631fa0db4e | ||
|
|
65d30b7a30 | ||
|
|
5ba5f27ca7 | ||
|
|
acc437bf2d | ||
|
|
5fd2505a33 | ||
|
|
7f6abc331b | ||
|
|
c190aab8b9 | ||
|
|
0f71abdac3 | ||
|
|
8ddc507bd5 | ||
|
|
1c4bae2d91 | ||
|
|
73ca4b1ea5 | ||
|
|
18a922b5cd | ||
|
|
11b98978c1 | ||
|
|
379d3811a8 | ||
|
|
0401b8a6e6 | ||
|
|
6148b996d8 | ||
|
|
39781c9cd7 | ||
|
|
18758a1614 | ||
|
|
b044d8d90e | ||
|
|
02e56f87bc | ||
|
|
d9b9ee221b | ||
|
|
21ec9188ca | ||
|
|
4d768becf5 | ||
|
|
a27049386b | ||
|
|
b23e3d7359 | ||
|
|
7660164583 | ||
|
|
5e1f3c5564 | ||
|
|
197fa9c01c | ||
|
|
554e3d0c2f | ||
|
|
b800cde34d | ||
|
|
775fdd2be0 | ||
|
|
7908ae4258 | ||
|
|
bfbd8229a1 | ||
|
|
afbf8dedbf | ||
|
|
569912abef | ||
|
|
7c94f6bc9a | ||
|
|
b825b5b063 | ||
|
|
50098b5e70 | ||
|
|
e237b4db1c | ||
|
|
2a25cf3bbd | ||
|
|
02275bb417 | ||
|
|
788cae3efe | ||
|
|
0dec70c53a | ||
|
|
f534f012d2 | ||
|
|
bb83875c99 | ||
|
|
d048aa33f7 | ||
|
|
8f3e250073 | ||
|
|
63a792d169 | ||
|
|
eb3524a22d | ||
|
|
f657a24a1a | ||
|
|
a5228448d7 | ||
|
|
1ec4796f72 | ||
|
|
6964158cf6 | ||
|
|
4fc4dd1b16 | ||
|
|
3e851f0c3c | ||
|
|
b8befaa973 | ||
|
|
b05046af29 | ||
|
|
eecc6c9e53 | ||
|
|
d4e754d601 | ||
|
|
a8a54593e2 | ||
|
|
5bbffc4f5c | ||
|
|
0833018399 | ||
|
|
f6850fc795 | ||
|
|
c0b4674568 | ||
|
|
5a8996d90a | ||
|
|
548b30e5b3 | ||
|
|
80f9329004 | ||
|
|
f672280236 | ||
|
|
90a4a01de7 | ||
|
|
09cebb90fe | ||
|
|
70389aab3d | ||
|
|
d9132a722f | ||
|
|
ea7a4e46e0 | ||
|
|
07b91058af | ||
|
|
c27ace6a6a | ||
|
|
1c0a5b17ca | ||
|
|
9b12a829d2 | ||
|
|
0f41172468 | ||
|
|
8597705a77 | ||
|
|
3f733ce857 | ||
|
|
40f8ec77b8 | ||
|
|
0af967d6c9 | ||
|
|
f6d43c9f39 | ||
|
|
70b0538dd5 | ||
|
|
439262b930 | ||
|
|
968b2587ae | ||
|
|
15f471a032 | ||
|
|
5175157ba6 | ||
|
|
e51e8f7196 | ||
|
|
00b34fda42 | ||
|
|
b34fabab54 | ||
|
|
487c7e2824 | ||
|
|
46c79a8772 | ||
|
|
bfb4144e57 | ||
|
|
dc1bb72070 | ||
|
|
8e084d2362 | ||
|
|
d5a75f887d | ||
|
|
710609e98b | ||
|
|
b73ab76bfb | ||
|
|
27b64df870 | ||
|
|
eabb897f96 | ||
|
|
68c5e47bad | ||
|
|
2ae5af7019 | ||
|
|
860d1ca9b3 | ||
|
|
66a9d1f25e | ||
|
|
bbfeedcdb3 | ||
|
|
079e273edb | ||
|
|
393aab4c4b | ||
|
|
4f2bf7549c | ||
|
|
acdf23571c | ||
|
|
62ec634db3 | ||
|
|
c53e978106 | ||
|
|
a7fa757d0d | ||
|
|
5fb1bd4175 | ||
|
|
e792ab7b4d | ||
|
|
02544d29fd | ||
|
|
20acbd0331 | ||
|
|
115b4aacb8 | ||
|
|
8746caab06 | ||
|
|
625648c908 | ||
|
|
734b5f9534 | ||
|
|
a0579318bd | ||
|
|
a437e3cbd3 | ||
|
|
1b242dc04e | ||
|
|
a1a51914a2 | ||
|
|
f6cab9b5a9 | ||
|
|
a3649c04e2 | ||
|
|
a562bfdb08 | ||
|
|
8ffe64ad8e | ||
|
|
a116d06d61 | ||
|
|
c26f73a5a8 | ||
|
|
f5847a57c1 | ||
|
|
fe9d23a0ff | ||
|
|
cdc27004bf | ||
|
|
b914164a77 | ||
|
|
35e58f90bc | ||
|
|
16d360c582 | ||
|
|
4c075b4d11 | ||
|
|
8c9c1c5afa | ||
|
|
d093163cd4 | ||
|
|
9bd6fcefd3 | ||
|
|
5139947643 | ||
|
|
01ff10385a | ||
|
|
9969c3a7ac | ||
|
|
f7ed2ec3e3 | ||
|
|
fedeeb3076 | ||
|
|
8157c27529 | ||
|
|
0eba0c6a4b | ||
|
|
13fb9db52b | ||
|
|
f6818ba880 | ||
|
|
dbf3b3cc79 | ||
|
|
24534069da | ||
|
|
4cec0a7350 | ||
|
|
0b86fa7bee | ||
|
|
b406e22695 | ||
|
|
3fca783dd8 | ||
|
|
6de4865052 | ||
|
|
facc2fab24 | ||
|
|
ddc0931e90 | ||
|
|
d5d32038f5 | ||
|
|
a20edd9bec | ||
|
|
3da90337ef | ||
|
|
9633f7644e | ||
|
|
a19cf0e1c3 | ||
|
|
1a841c4b5d | ||
|
|
937e7ba154 | ||
|
|
4cc0c85a6c | ||
|
|
943941e0f6 | ||
|
|
b160021e67 | ||
|
|
1bcc035979 | ||
|
|
ef67dc144f | ||
|
|
cc96fcd6a0 | ||
|
|
d1f00b2d48 | ||
|
|
2fe28d2335 | ||
|
|
f9276f4d83 | ||
|
|
099004a080 | ||
|
|
cc1df8d7d0 | ||
|
|
686a2e4fff | ||
|
|
e98fe3eec5 | ||
|
|
fc34ff38d3 | ||
|
|
b6b44b3782 | ||
|
|
c906636776 | ||
|
|
db282d1a04 | ||
|
|
898656963d | ||
|
|
6426e0238a | ||
|
|
08e8faf3ff | ||
|
|
d21adf6004 | ||
|
|
c3ac7dd1ab | ||
|
|
f1cfba3ad8 | ||
|
|
1ceed3461c | ||
|
|
d25c26156d | ||
|
|
d75c22114c | ||
|
|
05a754f446 | ||
|
|
b5e6eff65d | ||
|
|
eaa8ae66db | ||
|
|
5e4a08538b | ||
|
|
b01a54437a | ||
|
|
5f0fc78f30 | ||
|
|
325dc5e2fe | ||
|
|
a15b29122e | ||
|
|
074ccc8aab | ||
|
|
3809046fbc | ||
|
|
83ceb3264f | ||
|
|
9055858d55 | ||
|
|
2a465b5f1d | ||
|
|
b4101f856a | ||
|
|
44baa743c0 | ||
|
|
46dd449b2f | ||
|
|
f21d45e697 | ||
|
|
1e0a19ea7a | ||
|
|
dcfa47291e | ||
|
|
bd32c9555e | ||
|
|
f8f612544e | ||
|
|
1c0271f55e | ||
|
|
a10bc74de1 | ||
|
|
958ef80602 | ||
|
|
124b63f325 | ||
|
|
40f5ba5004 | ||
|
|
32f30826b9 | ||
|
|
b4aa8b37ea | ||
|
|
8368bbec47 | ||
|
|
618e482507 | ||
|
|
43711a1a59 | ||
|
|
bbe071545d | ||
|
|
4710479b46 | ||
|
|
c28a375b5d | ||
|
|
750d3429e0 | ||
|
|
4c34fe9b85 | ||
|
|
34c56980d4 | ||
|
|
a5b8609df1 | ||
|
|
1fd7f0314a | ||
|
|
25e82d690e |
1
.github/workflows/publish.yml
vendored
1
.github/workflows/publish.yml
vendored
@@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- "pro"
|
||||
- "dev"
|
||||
|
||||
jobs:
|
||||
check-version:
|
||||
|
||||
51
.gitignore
vendored
51
.gitignore
vendored
@@ -1,32 +1,61 @@
|
||||
# Dependencies
|
||||
/node_modules
|
||||
/vendor
|
||||
|
||||
# Build and temporary files
|
||||
/build
|
||||
/public/hot
|
||||
/public/tmp
|
||||
/tmp
|
||||
|
||||
# Uploads and user-generated content
|
||||
/public/summary
|
||||
/public/uploads/*
|
||||
/public/.well-known
|
||||
/public/.user.ini
|
||||
/storage/*.key
|
||||
|
||||
# Storage and configuration
|
||||
/config/LICENSE
|
||||
/vendor
|
||||
/build
|
||||
/tmp
|
||||
._*
|
||||
/storage/*.key
|
||||
|
||||
# Environment and configuration
|
||||
.env
|
||||
vars.yaml
|
||||
|
||||
# IDE and editor files
|
||||
.idea
|
||||
.vscode
|
||||
.vagrant
|
||||
.windsurfrules
|
||||
.phpunit.result.cache
|
||||
|
||||
# Development tools
|
||||
.vagrant
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
|
||||
# Development file
|
||||
/index.html
|
||||
|
||||
# Testing
|
||||
.phpunit.result.cache
|
||||
test.*
|
||||
|
||||
# Logs and debug files
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
test.*
|
||||
|
||||
# Lock files
|
||||
dootask.lock
|
||||
package-lock.json
|
||||
|
||||
# Laravel/Swoole specific
|
||||
laravels-timer-process.pid
|
||||
.DS_Store
|
||||
vars.yaml
|
||||
laravels.conf
|
||||
laravels.pid
|
||||
|
||||
# System files
|
||||
._*
|
||||
.DS_Store
|
||||
|
||||
# Documentation
|
||||
AGENTS.md
|
||||
README_LOCAL.md
|
||||
dootask.lock
|
||||
|
||||
13
.gitpod.yml
13
.gitpod.yml
@@ -1,13 +0,0 @@
|
||||
# This configuration file was automatically generated by Gitpod.
|
||||
# Please adjust to your needs (see https://www.gitpod.io/docs/config-gitpod-file)
|
||||
# and commit this file to your remote git repository to share the goodness with others.
|
||||
|
||||
tasks:
|
||||
- init: sudo ./cmd install
|
||||
command: ./cmd dev
|
||||
|
||||
ports:
|
||||
- port: 2222
|
||||
visibility: public
|
||||
- port: 22222
|
||||
visibility: public
|
||||
3985
CHANGELOG.md
3985
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,10 @@
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use Request;
|
||||
use Session;
|
||||
use Response;
|
||||
use Madzipper;
|
||||
use Carbon\Carbon;
|
||||
use App\Module\Down;
|
||||
use App\Models\User;
|
||||
use App\Module\Base;
|
||||
use App\Module\Doo;
|
||||
@@ -23,6 +23,7 @@ use App\Models\WebSocketDialogMsg;
|
||||
use App\Module\Apps;
|
||||
use App\Module\BillMultipleExport;
|
||||
use Hhxsv5\LaravelS\Swoole\Task\Task;
|
||||
use Swoole\Coroutine;
|
||||
|
||||
/**
|
||||
* @apiDefine approve
|
||||
@@ -40,7 +41,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/approve/verifyToken 01. 验证APi登录
|
||||
* @api {get} api/approve/verifyToken 验证APi登录
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -62,7 +63,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/approve/procdef/all 02. 查询流程定义
|
||||
* @api {post} api/approve/procdef/all 查询流程定义
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -89,7 +90,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/approve/procdef/del 03. 删除流程定义
|
||||
* @api {get} api/approve/procdef/del 删除流程定义
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -115,7 +116,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/approve/process/start 04. 启动流程(审批中)
|
||||
* @api {post} api/approve/process/start 启动流程(审批中)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -178,7 +179,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/approve/process/addGlobalComment 05. 添加全局评论
|
||||
* @api {post} api/approve/process/addGlobalComment 添加全局评论
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -223,7 +224,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/approve/task/complete 06. 审批
|
||||
* @api {post} api/approve/task/complete 审批
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -303,7 +304,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/approve/task/withdraw 07. 撤回
|
||||
* @api {post} api/approve/task/withdraw 撤回
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -348,7 +349,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/approve/process/findTask 08. 查询需要我审批的流程(审批中)
|
||||
* @api {post} api/approve/process/findTask 查询需要我审批的流程(审批中)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -391,7 +392,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/approve/process/startByMyselfAll 09. 查询我启动的流程(全部)
|
||||
* @api {post} api/approve/process/startByMyselfAll 查询我启动的流程(全部)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -434,7 +435,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/approve/process/startByMyself 10. 查询我启动的流程(审批中)
|
||||
* @api {post} api/approve/process/startByMyself 查询我启动的流程(审批中)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -472,7 +473,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/approve/process/findProcNotify 11. 查询抄送我的流程(审批中)
|
||||
* @api {post} api/approve/process/findProcNotify 查询抄送我的流程(审批中)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -516,7 +517,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/approve/identitylink/findParticipant 12. 查询流程实例的参与者(审批中)
|
||||
* @api {get} api/approve/identitylink/findParticipant 查询流程实例的参与者(审批中)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -551,7 +552,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/approve/procHistory/findTask 13. 查询需要我审批的流程(已结束)
|
||||
* @api {post} api/approve/procHistory/findTask 查询需要我审批的流程(已结束)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -594,7 +595,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/approve/procHistory/startByMyself 14. 查询我启动的流程(已结束)
|
||||
* @api {post} api/approve/procHistory/startByMyself 查询我启动的流程(已结束)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -632,7 +633,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/approve/procHistory/findProcNotify 15. 查询抄送我的流程(已结束)
|
||||
* @api {post} api/approve/procHistory/findProcNotify 查询抄送我的流程(已结束)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -676,7 +677,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/approve/identitylinkHistory/findParticipant 16. 查询流程实例的参与者(已结束)
|
||||
* @api {get} api/approve/identitylinkHistory/findParticipant 查询流程实例的参与者(已结束)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -711,7 +712,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/approve/process/detail 17. 根据流程ID查询流程详情
|
||||
* @api {get} api/approve/process/detail 根据流程ID查询流程详情
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -733,7 +734,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/approve/export 18. 导出数据
|
||||
* @api {post} api/approve/export 导出数据
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -768,131 +769,192 @@ class ApproveController extends AbstractController
|
||||
if (Carbon::parse($date[1])->timestamp - Carbon::parse($date[0])->timestamp > 35 * 86400) {
|
||||
return Base::retError('日期范围限制最大35天');
|
||||
}
|
||||
//
|
||||
$ret = Ihttp::ihttp_post($this->flow_url . '/api/v1/workflow/process/findAllProcIns', json_encode($data));
|
||||
$process = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true);
|
||||
if (!$process || $process['status'] != 200) {
|
||||
return Base::retError($process['message'] ?? '查询失败');
|
||||
$botUser = User::botGetOrCreate('system-msg');
|
||||
if (empty($botUser)) {
|
||||
return Base::retError('系统机器人不存在');
|
||||
}
|
||||
$dialog = WebSocketDialog::checkUserDialog($botUser, $user->userid);
|
||||
//
|
||||
$res = Base::arrayKeyToUnderline($process['data']);
|
||||
//
|
||||
$headings = [];
|
||||
$headings[] = Doo::translate('申请编号');
|
||||
$headings[] = Doo::translate('标题');
|
||||
$headings[] = Doo::translate('申请状态');
|
||||
$headings[] = Doo::translate('发起时间');
|
||||
$headings[] = Doo::translate('完成时间');
|
||||
$headings[] = Doo::translate('发起人工号');
|
||||
$headings[] = Doo::translate('发起人User ID');
|
||||
$headings[] = Doo::translate('发起人姓名');
|
||||
$headings[] = Doo::translate('发起人部门');
|
||||
$headings[] = Doo::translate('发起人部门ID');
|
||||
$headings[] = Doo::translate('部门负责人');
|
||||
$headings[] = Doo::translate('历史审批人');
|
||||
$headings[] = Doo::translate('历史办理人');
|
||||
$headings[] = Doo::translate('审批记录');
|
||||
$headings[] = Doo::translate('当前处理人');
|
||||
$headings[] = Doo::translate('审批节点');
|
||||
$headings[] = Doo::translate('审批人数');
|
||||
$headings[] = Doo::translate('审批耗时');
|
||||
$headings[] = Doo::translate('假期类型');
|
||||
$headings[] = Doo::translate('开始时间');
|
||||
$headings[] = Doo::translate('结束时间');
|
||||
$headings[] = Doo::translate('时长');
|
||||
$headings[] = Doo::translate('请假事由');
|
||||
$headings[] = Doo::translate('请假单位');
|
||||
//
|
||||
$datas = [];
|
||||
foreach ($res as $val) {
|
||||
$doo = Doo::load();
|
||||
go(function () use ($doo, $data, $user, $botUser, $dialog) {
|
||||
Coroutine::sleep(1);
|
||||
//
|
||||
$nickname = Base::filterEmoji($val['start_user_name']);
|
||||
$participant = $this->getUserProcessParticipantById($val['id']); // 获取参与人
|
||||
$participant = $this->handleParticipant($val, $participant['data']); // 处理参与人返回数据
|
||||
//
|
||||
$job_number = ''; // 发起人工号
|
||||
$department_leader = User::userid2nickname(UserDepartment::find(1, ['owner_userid'])['owner_userid']); // 部门负责人
|
||||
$historical_approver = $participant['historical_approver'] ?? ''; // 历史审批人
|
||||
$historical_agent = ''; // 历史办理人
|
||||
$approval_record = $participant['approval_record'] ?? ''; // 审批记录
|
||||
$current_handler = !$val['is_finished'] ? implode(',', User::whereIn('userid', explode(';', $val['candidate']))->pluck('nickname')->toArray()) : ''; // 当前处理人
|
||||
$approved_node = $participant['approved_node'] ?? 0; // 审批节点
|
||||
$approved_num = $participant['approved_num'] ?? 0; // 审批人数
|
||||
// 计算审批耗时
|
||||
$startTime = Carbon::parse($val['start_time'])->timestamp;
|
||||
$endTime = $val['end_time'] ? Carbon::parse($val['end_time'])->timestamp : time();
|
||||
$approval_time = Doo::translate(Timer::timeDiff($startTime, $endTime)); // 审批耗时
|
||||
// 计算时长
|
||||
$varStartTime = Carbon::parse($val['var']['start_time']);
|
||||
$varEndTime = Carbon::parse($val['var']['end_time']);
|
||||
$duration = $varEndTime->floatDiffInHours($varStartTime);
|
||||
$duration_unit = Doo::translate('小时'); // 时长单位
|
||||
$datas[] = [
|
||||
$val['id'], // 申请编号
|
||||
$val['proc_def_name'], // 标题
|
||||
$this->getStateDescription($val['state']), // 申请状态
|
||||
$val['start_time'], // 发起时间
|
||||
$val['end_time'], // 完成时间
|
||||
$job_number, // 发起人工号
|
||||
$val['start_user_id'], // 发起人User ID
|
||||
$nickname, // 发起人姓名
|
||||
$val['department'], // 发起人部门
|
||||
$val['department_id'], // 发起人部门ID
|
||||
$department_leader, // 部门负责人
|
||||
$historical_approver, // 历史审批人
|
||||
$historical_agent, // 历史办理人
|
||||
$approval_record, // 审批记录
|
||||
$current_handler, // 当前处理人
|
||||
$approved_node, // 审批节点
|
||||
$approved_num, // 审批人数
|
||||
$approval_time, // 审批耗时
|
||||
$val['var']['type'], // 假期类型
|
||||
$val['var']['start_time'], // 开始时间
|
||||
$val['var']['end_time'], // 结束时间
|
||||
$duration, // 时长
|
||||
$val['var']['description'], // 请假事由
|
||||
$duration_unit, // 请假单位
|
||||
$content = [];
|
||||
$content[] = [
|
||||
'content' => '导出审批数据已完成',
|
||||
'style' => 'font-weight: bold;padding-bottom: 4px;',
|
||||
];
|
||||
}
|
||||
if (empty($datas)) {
|
||||
return Base::retError('没有任何数据');
|
||||
}
|
||||
//
|
||||
$ret = Ihttp::ihttp_post($this->flow_url . '/api/v1/workflow/process/findAllProcIns', json_encode($data));
|
||||
$process = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true);
|
||||
if (!$process || $process['status'] != 200) {
|
||||
$content[] = [
|
||||
'content' => $process['message'] ?? '查询失败',
|
||||
'style' => 'color: #ff0000;',
|
||||
];
|
||||
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
|
||||
'type' => 'content',
|
||||
'title' => $content[0]['content'],
|
||||
'content' => $content,
|
||||
], $botUser->userid, true, false, true);
|
||||
return;
|
||||
}
|
||||
//
|
||||
$res = Base::arrayKeyToUnderline($process['data']);
|
||||
//
|
||||
$headings = [];
|
||||
$headings[] = $doo->translate('申请编号');
|
||||
$headings[] = $doo->translate('标题');
|
||||
$headings[] = $doo->translate('申请状态');
|
||||
$headings[] = $doo->translate('发起时间');
|
||||
$headings[] = $doo->translate('完成时间');
|
||||
$headings[] = $doo->translate('发起人工号');
|
||||
$headings[] = $doo->translate('发起人User ID');
|
||||
$headings[] = $doo->translate('发起人姓名');
|
||||
$headings[] = $doo->translate('发起人部门');
|
||||
$headings[] = $doo->translate('发起人部门ID');
|
||||
$headings[] = $doo->translate('部门负责人');
|
||||
$headings[] = $doo->translate('历史审批人');
|
||||
$headings[] = $doo->translate('历史办理人');
|
||||
$headings[] = $doo->translate('审批记录');
|
||||
$headings[] = $doo->translate('当前处理人');
|
||||
$headings[] = $doo->translate('审批节点');
|
||||
$headings[] = $doo->translate('审批人数');
|
||||
$headings[] = $doo->translate('审批耗时');
|
||||
$headings[] = $doo->translate('假期类型');
|
||||
$headings[] = $doo->translate('开始时间');
|
||||
$headings[] = $doo->translate('结束时间');
|
||||
$headings[] = $doo->translate('时长');
|
||||
$headings[] = $doo->translate('请假事由');
|
||||
$headings[] = $doo->translate('请假单位');
|
||||
//
|
||||
$datas = [];
|
||||
foreach ($res as $val) {
|
||||
//
|
||||
$nickname = Base::filterEmoji($val['start_user_name']);
|
||||
$participant = $this->getUserProcessParticipantById($val['id']); // 获取参与人
|
||||
$participant = $this->handleParticipant($val, $participant['data']); // 处理参与人返回数据
|
||||
//
|
||||
$job_number = ''; // 发起人工号
|
||||
$department_leader = User::userid2nickname(UserDepartment::find(1, ['owner_userid'])['owner_userid']); // 部门负责人
|
||||
$historical_approver = $participant['historical_approver'] ?? ''; // 历史审批人
|
||||
$historical_agent = ''; // 历史办理人
|
||||
$approval_record = $participant['approval_record'] ?? ''; // 审批记录
|
||||
$current_handler = !$val['is_finished'] ? implode(',', User::whereIn('userid', explode(';', $val['candidate']))->pluck('nickname')->toArray()) : ''; // 当前处理人
|
||||
$approved_node = $participant['approved_node'] ?? 0; // 审批节点
|
||||
$approved_num = $participant['approved_num'] ?? 0; // 审批人数
|
||||
// 计算审批耗时
|
||||
$startTime = Carbon::parse($val['start_time'])->timestamp;
|
||||
$endTime = $val['end_time'] ? Carbon::parse($val['end_time'])->timestamp : time();
|
||||
$approval_time = $doo->translate(Timer::timeDiff($startTime, $endTime)); // 审批耗时
|
||||
// 计算时长
|
||||
$varStartTime = Carbon::parse($val['var']['start_time']);
|
||||
$varEndTime = Carbon::parse($val['var']['end_time']);
|
||||
$duration = $varEndTime->floatDiffInHours($varStartTime);
|
||||
$duration_unit = $doo->translate('小时'); // 时长单位
|
||||
$datas[] = [
|
||||
$val['id'], // 申请编号
|
||||
$val['proc_def_name'], // 标题
|
||||
$this->getStateDescription($val['state']), // 申请状态
|
||||
$val['start_time'], // 发起时间
|
||||
$val['end_time'], // 完成时间
|
||||
$job_number, // 发起人工号
|
||||
$val['start_user_id'], // 发起人User ID
|
||||
$nickname, // 发起人姓名
|
||||
$val['department'], // 发起人部门
|
||||
$val['department_id'], // 发起人部门ID
|
||||
$department_leader, // 部门负责人
|
||||
$historical_approver, // 历史审批人
|
||||
$historical_agent, // 历史办理人
|
||||
$approval_record, // 审批记录
|
||||
$current_handler, // 当前处理人
|
||||
$approved_node, // 审批节点
|
||||
$approved_num, // 审批人数
|
||||
$approval_time, // 审批耗时
|
||||
$val['var']['type'], // 假期类型
|
||||
$val['var']['start_time'], // 开始时间
|
||||
$val['var']['end_time'], // 结束时间
|
||||
$duration, // 时长
|
||||
$val['var']['description'], // 请假事由
|
||||
$duration_unit, // 请假单位
|
||||
];
|
||||
}
|
||||
if (empty($datas)) {
|
||||
$content[] = [
|
||||
'content' => '没有任何数据',
|
||||
'style' => 'color: #ff0000;',
|
||||
];
|
||||
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
|
||||
'type' => 'content',
|
||||
'title' => $content[0]['content'],
|
||||
'content' => $content,
|
||||
], $botUser->userid, true, false, true);
|
||||
return;
|
||||
}
|
||||
//
|
||||
$title = $doo->translate("审批记录");
|
||||
$sheets = [
|
||||
BillExport::create()->setTitle($title)->setHeadings($headings)->setData($datas)->setStyles(["A1:Y1" => ["font" => ["bold" => true]]])
|
||||
];
|
||||
//
|
||||
$fileName = $title . '_' . Timer::time() . '.xlsx';
|
||||
$filePath = "temp/approve/export/" . date("Ym", Timer::time());
|
||||
$export = new BillMultipleExport($sheets);
|
||||
$res = $export->store($filePath . "/" . $fileName);
|
||||
if ($res != 1) {
|
||||
$content[] = [
|
||||
'content' => "导出失败,{$fileName}!",
|
||||
'style' => 'color: #ff0000;',
|
||||
];
|
||||
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
|
||||
'type' => 'content',
|
||||
'title' => $content[0]['content'],
|
||||
'content' => $content,
|
||||
], $botUser->userid, true, false, true);
|
||||
return;
|
||||
}
|
||||
$xlsPath = storage_path("app/" . $filePath . "/" . $fileName);
|
||||
$zipFile = "app/" . $filePath . "/" . Base::rightDelete($fileName, '.xlsx') . ".zip";
|
||||
$zipPath = storage_path($zipFile);
|
||||
if (file_exists($zipPath)) {
|
||||
Base::deleteDirAndFile($zipPath, true);
|
||||
}
|
||||
try {
|
||||
Madzipper::make($zipPath)->add($xlsPath)->close();
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
//
|
||||
if (file_exists($zipPath)) {
|
||||
$key = Down::cache_encode([
|
||||
'file' => $zipFile,
|
||||
]);
|
||||
$fileUrl = Base::fillUrl('api/approve/down?key=' . $key);
|
||||
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
|
||||
'type' => 'file_download',
|
||||
'title' => '导出审批数据已完成',
|
||||
'name' => $fileName,
|
||||
'size' => filesize($zipPath),
|
||||
'url' => $fileUrl,
|
||||
], $botUser->userid, true, false, true);
|
||||
} else {
|
||||
$content[] = [
|
||||
'content' => "打包失败,请稍后再试...",
|
||||
'style' => 'color: #ff0000;',
|
||||
];
|
||||
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
|
||||
'type' => 'content',
|
||||
'title' => $content[0]['content'],
|
||||
'content' => $content,
|
||||
], $botUser->userid, true, false, true);
|
||||
}
|
||||
});
|
||||
//
|
||||
$title = Doo::translate("审批记录");
|
||||
$sheets = [
|
||||
BillExport::create()->setTitle($title)->setHeadings($headings)->setData($datas)->setStyles(["A1:Y1" => ["font" => ["bold" => true]]])
|
||||
];
|
||||
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
|
||||
'type' => 'content',
|
||||
'content' => '正在导出审批数据,请稍等...',
|
||||
], $botUser->userid, true, false, true);
|
||||
//
|
||||
$fileName = $title . '_' . Timer::time() . '.xlsx';
|
||||
$filePath = "temp/approve/export/" . date("Ym", Timer::time());
|
||||
$export = new BillMultipleExport($sheets);
|
||||
$res = $export->store($filePath . "/" . $fileName);
|
||||
if ($res != 1) {
|
||||
return Base::retError('导出失败,' . $fileName . '!');
|
||||
}
|
||||
$xlsPath = storage_path("app/" . $filePath . "/" . $fileName);
|
||||
$zipFile = "app/" . $filePath . "/" . Base::rightDelete($fileName, '.xlsx') . ".zip";
|
||||
$zipPath = storage_path($zipFile);
|
||||
if (file_exists($zipPath)) {
|
||||
Base::deleteDirAndFile($zipPath, true);
|
||||
}
|
||||
try {
|
||||
Madzipper::make($zipPath)->add($xlsPath)->close();
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
//
|
||||
if (file_exists($zipPath)) {
|
||||
$base64 = base64_encode(Base::array2string([
|
||||
'file' => $zipFile,
|
||||
]));
|
||||
Session::put('approve::export:userid', $user->userid);
|
||||
return Base::retSuccess('success', [
|
||||
'size' => Base::twoFloat(filesize($zipPath) / 1024, true),
|
||||
'url' => Base::fillUrl('api/approve/down?key=' . urlencode($base64)),
|
||||
]);
|
||||
} else {
|
||||
return Base::retError('打包失败,请稍后再试...');
|
||||
}
|
||||
return Base::retSuccess('success');
|
||||
}
|
||||
|
||||
function getStateDescription($state)
|
||||
@@ -908,7 +970,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/approve/down 19. 下载导出的审批数据
|
||||
* @api {get} api/approve/down 下载导出的审批数据
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup approve
|
||||
@@ -920,15 +982,10 @@ class ApproveController extends AbstractController
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
$userid = Session::get('approve::export:userid');
|
||||
if (empty($userid)) {
|
||||
return Base::ajaxError("请求已过期,请重新导出!", [], 0, 502);
|
||||
}
|
||||
//
|
||||
$array = Base::string2array(base64_decode(urldecode(Request::input('key'))));
|
||||
$array = Down::cache_decode();
|
||||
$file = $array['file'];
|
||||
if (empty($file) || !file_exists(storage_path($file))) {
|
||||
return Base::ajaxError("文件不存在!", [], 0, 502);
|
||||
return Base::ajaxError("文件不存在!", [], 0, 403);
|
||||
}
|
||||
return Response::download(storage_path($file));
|
||||
}
|
||||
@@ -1135,7 +1192,7 @@ class ApproveController extends AbstractController
|
||||
|
||||
|
||||
/**
|
||||
* @api {get} api/approve/user/status 20. 获取用户审批状态
|
||||
* @api {get} api/approve/user/status 获取用户审批状态
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup approve
|
||||
@@ -1155,7 +1212,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/approve/process/doto 21. 查询需要我审批的流程数量
|
||||
* @api {get} api/approve/process/doto 查询需要我审批的流程数量
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
|
||||
@@ -10,18 +10,18 @@ use App\Models\WebSocketDialog;
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
|
||||
/**
|
||||
* @apiDefine dialog
|
||||
* @apiDefine complaint
|
||||
*
|
||||
* 投诉
|
||||
*/
|
||||
class ComplaintController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @api {get} api/complaint/lists 01. 获取举报投诉列表
|
||||
* @api {get} api/complaint/lists 获取举报投诉列表
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup dialog
|
||||
* @apiGroup complaint
|
||||
* @apiName lists
|
||||
*
|
||||
* @apiParam {Number} [type] 类型
|
||||
@@ -33,6 +33,34 @@ class ComplaintController extends AbstractController
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*
|
||||
* @apiSuccessExample {json} Success-Response-Data:
|
||||
* {
|
||||
* "current_page": 1,
|
||||
* "data": [
|
||||
* {
|
||||
* "id": 1,
|
||||
* "dialog_id": 100,
|
||||
* "userid": 1,
|
||||
* "type": 1,
|
||||
* "reason": "举报原因",
|
||||
* "imgs": [],
|
||||
* "status": 0,
|
||||
* "created_at": "2025-01-01 00:00:00",
|
||||
* "updated_at": "2025-01-01 00:00:00"
|
||||
* }
|
||||
* ],
|
||||
* "first_page_url": "http://example.com/api/complaint/lists?page=1",
|
||||
* "from": 1,
|
||||
* "last_page": 1,
|
||||
* "last_page_url": "http://example.com/api/complaint/lists?page=1",
|
||||
* "next_page_url": null,
|
||||
* "path": "http://example.com/api/complaint/lists",
|
||||
* "per_page": 50,
|
||||
* "prev_page_url": null,
|
||||
* "to": 1,
|
||||
* "total": 1
|
||||
* }
|
||||
*/
|
||||
public function lists()
|
||||
{
|
||||
@@ -56,21 +84,25 @@ class ComplaintController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/complaint/submit 02. 举报投诉
|
||||
* @api {post} api/complaint/submit 举报投诉
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup dialog
|
||||
* @apiGroup complaint
|
||||
* @apiName submit
|
||||
*
|
||||
* @apiParam {Number} dialog_id 对话ID
|
||||
* @apiParam {Number} type 类型
|
||||
* @apiParam {String} reason 原因
|
||||
* @apiParam {String} imgs 图片
|
||||
* @apiBody {Number} dialog_id 对话ID
|
||||
* @apiBody {Number} type 类型
|
||||
* @apiBody {String} reason 原因
|
||||
* @apiBody {Object[]} [imgs] 图片数组(可选)
|
||||
* @apiBody {String} imgs.path 图片路径
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*
|
||||
* @apiSuccessExample {json} Success-Response-Data:
|
||||
* []
|
||||
*/
|
||||
public function submit()
|
||||
{
|
||||
@@ -125,19 +157,22 @@ class ComplaintController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/complaint/action 03. 举报投诉 - 操作
|
||||
* @api {post} api/complaint/action 举报投诉 - 操作
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiDescription 需要token身份(管理员权限)
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup dialog
|
||||
* @apiGroup complaint
|
||||
* @apiName action
|
||||
*
|
||||
* @apiParam {Number} id ID
|
||||
* @apiParam {Number} type 类型
|
||||
* @apiBody {Number} id 投诉ID
|
||||
* @apiBody {String} type 操作类型:handle=已处理,delete=删除
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*
|
||||
* @apiSuccessExample {json} Success-Response-Data:
|
||||
* []
|
||||
*/
|
||||
public function action()
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,11 +11,12 @@ use App\Models\FileContent;
|
||||
use App\Models\FileLink;
|
||||
use App\Models\FileUser;
|
||||
use App\Models\User;
|
||||
use App\Models\UserRecentItem;
|
||||
use App\Module\Base;
|
||||
use App\Module\Down;
|
||||
use App\Module\Timer;
|
||||
use App\Module\Ihttp;
|
||||
use Response;
|
||||
use Session;
|
||||
use Swoole\Coroutine;
|
||||
use Carbon\Carbon;
|
||||
use Redirect;
|
||||
@@ -30,7 +31,7 @@ use ZipArchive;
|
||||
class FileController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @api {get} api/file/lists 01. 获取文件列表
|
||||
* @api {get} api/file/lists 获取文件列表
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -53,7 +54,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/one 02. 获取单条数据
|
||||
* @api {get} api/file/one 获取单条数据
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -87,6 +88,12 @@ class FileController extends AbstractController
|
||||
}
|
||||
return Base::retError($msg, $data);
|
||||
}
|
||||
|
||||
// 如果文件不允许游客访问,则需要登录
|
||||
if (!$file->guest_access) {
|
||||
User::auth();
|
||||
}
|
||||
|
||||
$fileLink->increment("num");
|
||||
} else {
|
||||
return Base::retError('参数错误');
|
||||
@@ -98,7 +105,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/search 03. 搜索文件列表
|
||||
* @api {get} api/file/search 搜索文件列表
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -134,7 +141,11 @@ class FileController extends AbstractController
|
||||
$builder->where("id", $id);
|
||||
}
|
||||
if ($key) {
|
||||
$builder->where("name", "like", "%{$key}%");
|
||||
if (!$id && Base::isNumber($key)) {
|
||||
$builder->where("id", $key);
|
||||
} else {
|
||||
$builder->where("name", "like", "%{$key}%");
|
||||
}
|
||||
}
|
||||
$array = $builder->take($take)->get()->toArray();
|
||||
// 搜索共享的
|
||||
@@ -171,7 +182,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/add 04. 添加、修改文件(夹)
|
||||
* @api {get} api/file/add 添加、修改文件(夹)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -197,8 +208,8 @@ class FileController extends AbstractController
|
||||
$pid = intval(Request::input('pid'));
|
||||
if (mb_strlen($name) < 2) {
|
||||
return Base::retError('文件名称不可以少于2个字');
|
||||
} elseif (mb_strlen($name) > 32) {
|
||||
return Base::retError('文件名称最多只能设置32个字');
|
||||
} elseif (mb_strlen($name) > 100) {
|
||||
return Base::retError('文件名称最多只能设置100个字');
|
||||
}
|
||||
$tmpName = preg_replace("/[\\\\\/:*?\"<>|]/", '', $name);
|
||||
if ($tmpName != $name) {
|
||||
@@ -280,7 +291,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/copy 05. 复制文件(夹)
|
||||
* @api {get} api/file/copy 复制文件(夹)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -341,7 +352,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/move 06. 移动文件(夹)
|
||||
* @api {get} api/file/move 移动文件(夹)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -416,7 +427,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/remove 07. 删除文件(夹)
|
||||
* @api {get} api/file/remove 删除文件(夹)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -455,7 +466,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/content 08. 获取文件内容
|
||||
* @api {get} api/file/content 获取文件内容
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -519,6 +530,16 @@ class FileController extends AbstractController
|
||||
$builder->whereId($history_id);
|
||||
}
|
||||
$content = $builder->orderByDesc('id')->first();
|
||||
if (isset($user)) {
|
||||
UserRecentItem::record(
|
||||
$user->userid,
|
||||
UserRecentItem::TYPE_FILE,
|
||||
$file->id,
|
||||
UserRecentItem::SOURCE_FILESYSTEM,
|
||||
intval($file->pid)
|
||||
);
|
||||
}
|
||||
|
||||
if ($down === 'preview') {
|
||||
return Redirect::to(FileContent::formatPreview($file, $content?->content));
|
||||
}
|
||||
@@ -526,7 +547,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/content/save 09. 保存文件内容
|
||||
* @api {get} api/file/content/save 保存文件内容
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -621,9 +642,9 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/office/token 10. 获取token
|
||||
* @api {get} api/file/office/token 获取token
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiDescription 用于生成office在线编辑的token
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup file
|
||||
* @apiName office__token
|
||||
@@ -636,8 +657,6 @@ class FileController extends AbstractController
|
||||
*/
|
||||
public function office__token()
|
||||
{
|
||||
User::auth();
|
||||
//
|
||||
File::isNeedInstallApp('office');
|
||||
//
|
||||
$config = Request::input('config');
|
||||
@@ -648,7 +667,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/content/office 11. 保存文件内容(office)
|
||||
* @api {get} api/file/content/office 保存文件内容(office)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -704,7 +723,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/content/upload 12. 保存文件内容(上传文件)
|
||||
* @api {get} api/file/content/upload 保存文件内容(上传文件)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -732,7 +751,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/content/history 13. 获取内容历史
|
||||
* @api {get} api/file/content/history 获取内容历史
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -764,7 +783,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/content/restore 14. 恢复文件历史
|
||||
* @api {get} api/file/content/restore 恢复文件历史
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -806,7 +825,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/share 15. 获取共享信息
|
||||
* @api {get} api/file/share 获取共享信息
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -842,7 +861,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/share/update 16. 设置共享
|
||||
* @api {get} api/file/share/update 设置共享
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -932,7 +951,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/share/out 17. 退出共享
|
||||
* @api {get} api/file/share/out 退出共享
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -966,7 +985,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/link 18. 获取链接
|
||||
* @api {get} api/file/link 获取链接
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -977,6 +996,9 @@ class FileController extends AbstractController
|
||||
* @apiParam {String} refresh 刷新链接
|
||||
* - no: 只获取(默认)
|
||||
* - yes: 刷新链接,之前的将失效
|
||||
* @apiParam {String} guest_access 是否允许游客访问
|
||||
* - no: 不允许(默认)
|
||||
* - yes: 允许游客访问
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
@@ -988,15 +1010,22 @@ class FileController extends AbstractController
|
||||
//
|
||||
$id = intval(Request::input('id'));
|
||||
$refresh = Request::input('refresh', 'no');
|
||||
$guestAccess = Request::input('guest_access', 'no');
|
||||
//
|
||||
$file = File::permissionFind($id, $user);
|
||||
|
||||
// 更新文件的游客访问权限
|
||||
$file->guest_access = $guestAccess === 'yes' ? 1 : 0;
|
||||
$file->save();
|
||||
|
||||
$fileLink = $file->getShareLink($user->userid, $refresh == 'yes');
|
||||
$fileLink['guest_access'] = $file->guest_access;
|
||||
//
|
||||
return Base::retSuccess('success', $fileLink);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/download/pack 19. 打包文件
|
||||
* @api {get} api/file/download/pack 打包文件
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -1012,17 +1041,11 @@ class FileController extends AbstractController
|
||||
*/
|
||||
public function download__pack()
|
||||
{
|
||||
$key = Request::input('key');
|
||||
if ($key) {
|
||||
$userid = Session::get('file::pack:userid');
|
||||
if (empty($userid)) {
|
||||
return Base::ajaxError("请求已过期,请重新导出!", [], 0, 502);
|
||||
}
|
||||
//
|
||||
$array = Base::string2array(base64_decode(urldecode($key)));
|
||||
if (Request::has('key')) {
|
||||
$array = Down::cache_decode();
|
||||
$file = $array['file'];
|
||||
if (empty($file) || !file_exists(storage_path($file))) {
|
||||
return Base::ajaxError("文件不存在!", [], 0, 502);
|
||||
return Base::ajaxError("文件不存在!", [], 0, 403);
|
||||
}
|
||||
return Response::download(storage_path($file));
|
||||
}
|
||||
@@ -1087,11 +1110,10 @@ class FileController extends AbstractController
|
||||
return Base::retError('文件总大小已超过1GB,请分批下载');
|
||||
}
|
||||
|
||||
$base64 = base64_encode(Base::array2string([
|
||||
$key = Down::cache_encode([
|
||||
'file' => $zipFile,
|
||||
]));
|
||||
$fileUrl = Base::fillUrl('api/file/download/pack?key=' . urlencode($base64));
|
||||
Session::put('file::pack:userid', $user->userid);
|
||||
]);
|
||||
$fileUrl = Base::fillUrl('api/file/download/pack?key=' . $key);
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
Base::makeDir(dirname($zipPath));
|
||||
@@ -1100,17 +1122,18 @@ class FileController extends AbstractController
|
||||
return Base::retError('创建压缩文件失败');
|
||||
}
|
||||
|
||||
go(function () use ($zipPath, $fileUrl, $zip, $files, $fileName, $botUser, $dialog) {
|
||||
$userid = $user->userid;
|
||||
go(function () use ($userid, $zipPath, $fileUrl, $zip, $files, $fileName, $botUser, $dialog) {
|
||||
Coroutine::sleep(0.1);
|
||||
// 压缩进度
|
||||
$progress = 0;
|
||||
$zip->registerProgressCallback(0.05, function ($ratio) use ($fileUrl, $fileName, &$progress) {
|
||||
$zip->registerProgressCallback(0.05, function ($ratio) use ($userid, $fileUrl, $fileName, &$progress) {
|
||||
$progress = round($ratio * 100);
|
||||
File::filePushMsg('compress', [
|
||||
File::pushMsgSimple('compress', [
|
||||
'name' => $fileName,
|
||||
'url' => $fileUrl,
|
||||
'progress' => $progress
|
||||
]);
|
||||
], $userid);
|
||||
});
|
||||
//
|
||||
foreach ($files as $file) {
|
||||
@@ -1119,11 +1142,11 @@ class FileController extends AbstractController
|
||||
$zip->close();
|
||||
//
|
||||
if ($progress < 100) {
|
||||
File::filePushMsg('compress', [
|
||||
File::pushMsgSimple('compress', [
|
||||
'name' => $fileName,
|
||||
'url' => $fileUrl,
|
||||
'progress' => 100
|
||||
]);
|
||||
], $userid);
|
||||
}
|
||||
//
|
||||
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,7 @@ use Illuminate\Support\Facades\Validator;
|
||||
class ReportController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @api {get} api/report/my 01. 我发送的汇报
|
||||
* @api {get} api/report/my 我发送的汇报
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -75,7 +75,7 @@ class ReportController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/report/receive 02. 我接收的汇报
|
||||
* @api {get} api/report/receive 我接收的汇报
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -143,7 +143,7 @@ class ReportController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/report/store 03. 保存并发送工作汇报
|
||||
* @api {get} api/report/store 保存并发送工作汇报
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -282,7 +282,7 @@ class ReportController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/report/template 04. 生成汇报模板
|
||||
* @api {get} api/report/template 生成汇报模板
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -454,7 +454,7 @@ class ReportController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/report/detail 05. 报告详情
|
||||
* @api {get} api/report/detail 报告详情
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -505,7 +505,7 @@ class ReportController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/report/mark 06. 标记已读/未读
|
||||
* @api {get} api/report/mark 标记已读/未读
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -548,7 +548,7 @@ class ReportController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/report/share 07. 分享报告到消息
|
||||
* @api {get} api/report/share 分享报告到消息
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -610,7 +610,7 @@ class ReportController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/report/last_submitter 08. 获取最后一次提交的接收人
|
||||
* @api {get} api/report/last_submitter 获取最后一次提交的接收人
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -628,7 +628,7 @@ class ReportController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/report/unread 09. 获取未读
|
||||
* @api {get} api/report/unread 获取未读
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -653,7 +653,7 @@ class ReportController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/report/read 10. 标记汇报已读,可批量
|
||||
* @api {get} api/report/read 标记汇报已读,可批量
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\UserDevice;
|
||||
use App\Models\WebSocketDialog;
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
use App\Module\AI;
|
||||
use App\Module\Down;
|
||||
use Request;
|
||||
use Session;
|
||||
use Response;
|
||||
use Madzipper;
|
||||
use Carbon\Carbon;
|
||||
@@ -13,7 +16,6 @@ use App\Models\User;
|
||||
use App\Module\Base;
|
||||
use App\Module\Timer;
|
||||
use App\Models\Setting;
|
||||
use App\Module\Extranet;
|
||||
use LdapRecord\Container;
|
||||
use App\Module\BillExport;
|
||||
use Guanguans\Notify\Factory;
|
||||
@@ -22,6 +24,7 @@ use App\Module\Apps;
|
||||
use App\Module\BillMultipleExport;
|
||||
use LdapRecord\LdapRecordException;
|
||||
use Guanguans\Notify\Messages\EmailMessage;
|
||||
use Swoole\Coroutine;
|
||||
|
||||
/**
|
||||
* @apiDefine system
|
||||
@@ -32,7 +35,7 @@ class SystemController extends AbstractController
|
||||
{
|
||||
|
||||
/**
|
||||
* @api {get} api/system/setting 01. 获取设置、保存设置
|
||||
* @api {get} api/system/setting 获取设置、保存设置
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
@@ -104,10 +107,10 @@ class SystemController extends AbstractController
|
||||
}
|
||||
}
|
||||
if ($all['voice2text'] == 'open' && !Setting::AIOpen()) {
|
||||
return Base::retError('开启语音转文字功能需要在应用中开启 ChatGPT AI 机器人。');
|
||||
return Base::retError('开启语音转文字功能需要先设置 AI 助理。');
|
||||
}
|
||||
if ($all['translation'] == 'open' && !Setting::AIOpen()) {
|
||||
return Base::retError('开启翻译功能需要在应用中开启 ChatGPT AI 机器人。');
|
||||
return Base::retError('开启翻译功能需要先设置 AI 助理。');
|
||||
}
|
||||
if ($all['system_alias'] == env('APP_NAME')) {
|
||||
$all['system_alias'] = '';
|
||||
@@ -155,11 +158,11 @@ class SystemController extends AbstractController
|
||||
$setting['server_timezone'] = config('app.timezone');
|
||||
$setting['server_version'] = Base::getVersion();
|
||||
//
|
||||
return Base::retSuccess('success', $setting ?: json_decode('{}'));
|
||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/setting/email 02. 获取邮箱设置、保存邮箱设置(限管理员)
|
||||
* @api {get} api/system/setting/email 获取邮箱设置、保存邮箱设置(限管理员)
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
@@ -225,11 +228,11 @@ class SystemController extends AbstractController
|
||||
$setting = array_intersect_key($setting, array_flip(['reg_verify']));
|
||||
}
|
||||
//
|
||||
return Base::retSuccess('success', $setting ?: json_decode('{}'));
|
||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/setting/meeting 03. 获取会议设置、保存会议设置(限管理员)
|
||||
* @api {get} api/system/setting/meeting 获取会议设置、保存会议设置(限管理员)
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
@@ -279,11 +282,53 @@ class SystemController extends AbstractController
|
||||
$setting['api_secret'] = substr($setting['api_secret'], 0, 4) . str_repeat('*', strlen($setting['api_secret']) - 8) . substr($setting['api_secret'], -4);
|
||||
}
|
||||
//
|
||||
return Base::retSuccess('success', $setting ?: json_decode('{}'));
|
||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/setting/aibot 04. 获取会议设置、保存AI机器人设置(限管理员)
|
||||
* @api {get} api/system/setting/ai AI助手设置(限管理员)
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
* @apiName setting__ai
|
||||
*
|
||||
* @apiParam {String} type
|
||||
* - get: 获取(默认)
|
||||
* - save: 保存设置(参数:['ai_provider', 'ai_api_key', 'ai_api_url', 'ai_proxy'])
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function setting__ai()
|
||||
{
|
||||
User::auth('admin');
|
||||
//
|
||||
$type = trim(Request::input('type'));
|
||||
if ($type == 'save') {
|
||||
if (env("SYSTEM_SETTING") == 'disabled') {
|
||||
return Base::retError('当前环境禁止修改');
|
||||
}
|
||||
$all = Base::newTrim(Request::input());
|
||||
foreach ($all as $key => $value) {
|
||||
if (!in_array($key, [
|
||||
'ai_provider',
|
||||
'ai_api_key',
|
||||
'ai_api_url',
|
||||
'ai_proxy',
|
||||
])) {
|
||||
unset($all[$key]);
|
||||
}
|
||||
}
|
||||
$setting = Base::setting('aiSetting', Base::newTrim($all));
|
||||
} else {
|
||||
$setting = Base::setting('aiSetting');
|
||||
}
|
||||
//
|
||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/setting/aibot 获取会议设置、保存AI机器人设置(限管理员)
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
@@ -337,11 +382,11 @@ class SystemController extends AbstractController
|
||||
}
|
||||
}
|
||||
//
|
||||
return Base::retSuccess('success', $setting ?: json_decode('{}'));
|
||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/setting/aibot_models 05. 获取AI模型
|
||||
* @api {get} api/system/setting/aibot_models 获取AI模型
|
||||
*
|
||||
* @apiDescription 获取所有AI机器人模型设置
|
||||
* @apiVersion 1.0.0
|
||||
@@ -362,7 +407,7 @@ class SystemController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/setting/aibot_defmodels 06. 获取AI默认模型
|
||||
* @api {get} api/system/setting/aibot_defmodels 获取AI默认模型
|
||||
*
|
||||
* @apiDescription 获取AI机器人默认模型
|
||||
* @apiVersion 1.0.0
|
||||
@@ -388,9 +433,9 @@ class SystemController extends AbstractController
|
||||
if (empty($baseUrl)) {
|
||||
return Base::retError('请先填写 Base URL');
|
||||
}
|
||||
return Extranet::ollamaModels($baseUrl, $key, $agency);
|
||||
return AI::ollamaModels($baseUrl, $key, $agency);
|
||||
}
|
||||
$models = Setting::AIDefaultModels($type);
|
||||
$models = Setting::AIBotDefaultModels($type);
|
||||
if (empty($models)) {
|
||||
return Base::retError('未找到默认模型');
|
||||
}
|
||||
@@ -400,7 +445,7 @@ class SystemController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/setting/checkin 07. 获取签到设置、保存签到设置(限管理员)
|
||||
* @api {get} api/system/setting/checkin 获取签到设置、保存签到设置(限管理员)
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
@@ -436,8 +481,13 @@ class SystemController extends AbstractController
|
||||
'face_remark',
|
||||
'face_retip',
|
||||
'locat_remark',
|
||||
'locat_map_type',
|
||||
'locat_bd_lbs_key',
|
||||
'locat_bd_lbs_point', // 格式:{"lng":116.404, "lat":39.915, "radius":500}
|
||||
'locat_amap_key',
|
||||
'locat_amap_point', // 格式:{"lng":116.404, "lat":39.915, "radius":500}
|
||||
'locat_tencent_key',
|
||||
'locat_tencent_point', // 格式:{"lng":116.404, "lat":39.915, "radius":500}
|
||||
'manual_remark',
|
||||
'modes',
|
||||
'key',
|
||||
@@ -455,14 +505,25 @@ class SystemController extends AbstractController
|
||||
}
|
||||
if (is_array($all['modes'])) {
|
||||
if (in_array('locat', $all['modes'])) {
|
||||
if (empty($all['locat_bd_lbs_key'])) {
|
||||
return Base::retError('请填写百度地图AK');
|
||||
$mapTypes = [
|
||||
'baidu' => ['key' => 'locat_bd_lbs_key', 'point' => 'locat_bd_lbs_point', 'msg' => '请填写百度地图AK'],
|
||||
'amap' => ['key' => 'locat_amap_key', 'point' => 'locat_amap_point', 'msg' => '请填写高德地图Key'],
|
||||
'tencent' => ['key' => 'locat_tencent_key', 'point' => 'locat_tencent_point', 'msg' => '请填写腾讯地图Key'],
|
||||
];
|
||||
$type = $all['locat_map_type'];
|
||||
if (!isset($mapTypes[$type])) {
|
||||
return Base::retError('请选择地图类型');
|
||||
}
|
||||
if (!is_array($all['locat_bd_lbs_point'])) {
|
||||
$conf = $mapTypes[$type];
|
||||
if (empty($all[$conf['key']])) {
|
||||
return Base::retError($conf['msg']);
|
||||
}
|
||||
if (!is_array($all[$conf['point']])) {
|
||||
return Base::retError('请选择允许签到位置');
|
||||
}
|
||||
$all['locat_bd_lbs_point']['radius'] = intval($all['locat_bd_lbs_point']['radius']);
|
||||
if (empty($all['locat_bd_lbs_point']['lng']) || empty($all['locat_bd_lbs_point']['lat']) || empty($all['locat_bd_lbs_point']['radius'])) {
|
||||
$all[$conf['point']]['radius'] = intval($all[$conf['point']]['radius']);
|
||||
$point = $all[$conf['point']];
|
||||
if (empty($point['lng']) || empty($point['lat']) || empty($point['radius'])) {
|
||||
return Base::retError('请选择有效的签到位置');
|
||||
}
|
||||
}
|
||||
@@ -494,7 +555,10 @@ class SystemController extends AbstractController
|
||||
$setting['face_remark'] = $setting['face_remark'] ?: Doo::translate('考勤机');
|
||||
$setting['face_retip'] = $setting['face_retip'] ?: 'open';
|
||||
$setting['locat_remark'] = $setting['locat_remark'] ?: Doo::translate('定位签到');
|
||||
$setting['locat_map_type'] = $setting['locat_map_type'] ?: 'baidu';
|
||||
$setting['locat_bd_lbs_point'] = is_array($setting['locat_bd_lbs_point']) ? $setting['locat_bd_lbs_point'] : ['radius' => 500];
|
||||
$setting['locat_amap_point'] = is_array($setting['locat_amap_point']) ? $setting['locat_amap_point'] : ['radius' => 500];
|
||||
$setting['locat_tencent_point'] = is_array($setting['locat_tencent_point']) ? $setting['locat_tencent_point'] : ['radius' => 500];
|
||||
$setting['manual_remark'] = $setting['manual_remark'] ?: Doo::translate('手动签到');
|
||||
$setting['time'] = $setting['time'] ? Base::json2array($setting['time']) : ['09:00', '18:00'];
|
||||
$setting['advance'] = intval($setting['advance']) ?: 120;
|
||||
@@ -508,11 +572,11 @@ class SystemController extends AbstractController
|
||||
$setting['cmd'] = base64_encode($setting['cmd']);
|
||||
}
|
||||
//
|
||||
return Base::retSuccess('success', $setting ?: json_decode('{}'));
|
||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/setting/apppush 08. 获取APP推送设置、保存APP推送设置(限管理员)
|
||||
* @api {get} api/system/setting/apppush 获取APP推送设置、保存APP推送设置(限管理员)
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
@@ -553,11 +617,11 @@ class SystemController extends AbstractController
|
||||
//
|
||||
$setting['push'] = $setting['push'] ?: 'close';
|
||||
//
|
||||
return Base::retSuccess('success', $setting ?: json_decode('{}'));
|
||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/setting/thirdaccess 09. 第三方帐号(限管理员)
|
||||
* @api {get} api/system/setting/thirdaccess 第三方帐号(限管理员)
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
@@ -623,11 +687,11 @@ class SystemController extends AbstractController
|
||||
$setting['ldap_port'] = intval($setting['ldap_port']) ?: 389;
|
||||
$setting['ldap_sync_local'] = $setting['ldap_sync_local'] ?: 'close';
|
||||
//
|
||||
return Base::retSuccess('success', $setting ?: json_decode('{}'));
|
||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/setting/file 10. 文件设置(限管理员)
|
||||
* @api {get} api/system/setting/file 文件设置(限管理员)
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
@@ -663,11 +727,11 @@ class SystemController extends AbstractController
|
||||
$setting = Base::setting('fileSetting');
|
||||
}
|
||||
//
|
||||
return Base::retSuccess('success', $setting ?: json_decode('{}'));
|
||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/demo 11. 获取演示帐号
|
||||
* @api {get} api/system/demo 获取演示帐号
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
@@ -691,7 +755,7 @@ class SystemController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/system/priority 12. 任务优先级
|
||||
* @api {post} api/system/priority 任务优先级
|
||||
*
|
||||
* @apiDescription 获取任务优先级、保存任务优先级
|
||||
* @apiVersion 1.0.0
|
||||
@@ -736,11 +800,11 @@ class SystemController extends AbstractController
|
||||
$setting = Base::setting('priority');
|
||||
}
|
||||
//
|
||||
return Base::retSuccess('success', $setting);
|
||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/system/column/template 13. 创建项目模板
|
||||
* @api {post} api/system/column/template 创建项目模板
|
||||
*
|
||||
* @apiDescription 获取创建项目模板、保存创建项目模板
|
||||
* @apiVersion 1.0.0
|
||||
@@ -783,11 +847,11 @@ class SystemController extends AbstractController
|
||||
$setting = Base::setting('columnTemplate');
|
||||
}
|
||||
//
|
||||
return Base::retSuccess('success', $setting);
|
||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/system/license 14. License
|
||||
* @api {post} api/system/license License
|
||||
*
|
||||
* @apiDescription 获取License信息、保存License(限管理员)
|
||||
* @apiVersion 1.0.0
|
||||
@@ -853,11 +917,11 @@ class SystemController extends AbstractController
|
||||
];
|
||||
}
|
||||
//
|
||||
return Base::retSuccess('success', $data);
|
||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $data ?: json_decode('{}'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/get/info 15. 获取终端详细信息
|
||||
* @api {get} api/system/get/info 获取终端详细信息
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
@@ -876,8 +940,6 @@ class SystemController extends AbstractController
|
||||
}
|
||||
return Base::retSuccess('success', [
|
||||
'ip' => Base::getIp(),
|
||||
'ip-info' => Extranet::getIpInfo(Base::getIp()),
|
||||
'ip-gcj02' => Extranet::getIpGcj02(Base::getIp()),
|
||||
'ip-iscn' => Base::isCnIp(Base::getIp()),
|
||||
'header' => Request::header(),
|
||||
'token' => Doo::userToken(),
|
||||
@@ -886,7 +948,7 @@ class SystemController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/get/ip 16. 获取IP地址
|
||||
* @api {get} api/system/get/ip 获取IP地址
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
@@ -901,7 +963,7 @@ class SystemController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/get/cnip 17. 是否中国IP地址
|
||||
* @api {get} api/system/get/cnip 是否中国IP地址
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
@@ -918,41 +980,7 @@ class SystemController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/get/ipgcj02 18. 获取IP地址经纬度
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
* @apiName get__ipgcj02
|
||||
*
|
||||
* @apiParam {String} ip IP值
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function get__ipgcj02() {
|
||||
return Extranet::getIpGcj02(Request::input("ip"));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/get/ipinfo 19. 获取IP地址详细信息
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
* @apiName get__ipinfo
|
||||
*
|
||||
* @apiParam {String} ip IP值
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function get__ipinfo() {
|
||||
return Extranet::getIpInfo(Request::input("ip"));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/system/imgupload 20. 上传图片
|
||||
* @api {post} api/system/imgupload 上传图片
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -1018,7 +1046,7 @@ class SystemController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/get/imgview 21. 浏览图片空间
|
||||
* @api {get} api/system/get/imgview 浏览图片空间
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -1115,7 +1143,7 @@ class SystemController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/system/fileupload 22. 上传文件
|
||||
* @api {post} api/system/fileupload 上传文件
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -1159,7 +1187,7 @@ class SystemController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/get/updatelog 23. 获取更新日志
|
||||
* @api {get} api/system/get/updatelog 获取更新日志
|
||||
*
|
||||
* @apiDescription 获取更新日志
|
||||
* @apiVersion 1.0.0
|
||||
@@ -1202,7 +1230,7 @@ class SystemController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/email/check 24. 邮件发送测试(限管理员)
|
||||
* @api {get} api/system/email/check 邮件发送测试(限管理员)
|
||||
*
|
||||
* @apiDescription 测试配置邮箱是否能发送邮件
|
||||
* @apiVersion 1.0.0
|
||||
@@ -1248,7 +1276,7 @@ class SystemController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/checkin/export 25. 导出签到数据(限管理员)
|
||||
* @api {get} api/system/checkin/export 导出签到数据(限管理员)
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
@@ -1264,7 +1292,7 @@ class SystemController extends AbstractController
|
||||
*/
|
||||
public function checkin__export()
|
||||
{
|
||||
User::auth('admin');
|
||||
$user = User::auth('admin');
|
||||
//
|
||||
$setting = Base::setting('checkinSetting');
|
||||
if ($setting['open'] !== 'open') {
|
||||
@@ -1294,130 +1322,183 @@ class SystemController extends AbstractController
|
||||
$secondStart = strtotime("2000-01-01 {$time[0]}") - strtotime("2000-01-01 00:00:00");
|
||||
$secondEnd = strtotime("2000-01-01 {$time[1]}") - strtotime("2000-01-01 00:00:00");
|
||||
//
|
||||
$headings = [];
|
||||
$headings[] = Doo::translate('签到人');
|
||||
$headings[] = Doo::translate('签到日期');
|
||||
$headings[] = Doo::translate('班次时间');
|
||||
$headings[] = Doo::translate('首次签到时间');
|
||||
$headings[] = Doo::translate('首次签到结果');
|
||||
$headings[] = Doo::translate('最后签到时间');
|
||||
$headings[] = Doo::translate('最后签到结果');
|
||||
$headings[] = Doo::translate('参数数据');
|
||||
$botUser = User::botGetOrCreate('system-msg');
|
||||
if (empty($botUser)) {
|
||||
return Base::retError('系统机器人不存在');
|
||||
}
|
||||
$dialog = WebSocketDialog::checkUserDialog($botUser, $user->userid);
|
||||
//
|
||||
$sheets = [];
|
||||
$startD = Carbon::parse($date[0])->startOfDay();
|
||||
$endD = Carbon::parse($date[1])->endOfDay();
|
||||
$users = User::whereIn('userid', $userid)->take(100)->get();
|
||||
/** @var User $user */
|
||||
foreach ($users as $user) {
|
||||
$recordTimes = UserCheckinRecord::getTimes($user->userid, [$startD, $endD]);
|
||||
$doo = Doo::load();
|
||||
go(function () use ($doo, $secondStart, $secondEnd, $time, $userid, $date, $user, $botUser, $dialog) {
|
||||
Coroutine::sleep(1);
|
||||
//
|
||||
$nickname = Base::filterEmoji($user->nickname);
|
||||
$styles = ["A1:H1" => ["font" => ["bold" => true]]];
|
||||
$datas = [];
|
||||
$startT = $startD->timestamp;
|
||||
$endT = $endD->timestamp;
|
||||
$index = 1;
|
||||
while ($startT < $endT) {
|
||||
$index++;
|
||||
$sameDate = date("Y-m-d", $startT);
|
||||
$sameTimes = $recordTimes[$sameDate] ?? [];
|
||||
$sameCollect = UserCheckinRecord::atCollect($sameDate, $sameTimes);
|
||||
$firstBetween = [Carbon::createFromTimestamp($startT), Carbon::createFromTimestamp($startT + $secondEnd - 1)];
|
||||
$lastBetween = [Carbon::createFromTimestamp($startT + $secondStart + 1), Carbon::createFromTimestamp($startT + 86400)];
|
||||
$firstRecord = $sameCollect?->whereBetween("datetime", $firstBetween)->first();
|
||||
$lastRecord = $sameCollect?->whereBetween("datetime", $lastBetween)->last();
|
||||
$firstTimestamp = $firstRecord['timestamp'] ?: 0;
|
||||
$lastTimestamp = $lastRecord['timestamp'] ?: 0;
|
||||
if (Timer::time() < $startT + $secondStart) {
|
||||
$firstResult = "-";
|
||||
} else {
|
||||
$firstResult = Doo::translate("正常");
|
||||
if (empty($firstTimestamp)) {
|
||||
$firstResult = Doo::translate("缺卡");
|
||||
$styles["E{$index}"] = ["font" => ["color" => ["rgb" => "ff0000"]]];
|
||||
} elseif ($firstTimestamp > $startT + $secondStart) {
|
||||
$firstResult = Doo::translate("迟到");
|
||||
$styles["E{$index}"] = ["font" => ["color" => ["rgb" => "436FF6"]]];
|
||||
$headings = [];
|
||||
$headings[] = $doo->translate('签到人');
|
||||
$headings[] = $doo->translate('签到日期');
|
||||
$headings[] = $doo->translate('班次时间');
|
||||
$headings[] = $doo->translate('首次签到时间');
|
||||
$headings[] = $doo->translate('首次签到结果');
|
||||
$headings[] = $doo->translate('最后签到时间');
|
||||
$headings[] = $doo->translate('最后签到结果');
|
||||
$headings[] = $doo->translate('参数数据');
|
||||
//
|
||||
$content = [];
|
||||
$content[] = [
|
||||
'content' => '导出签到数据已完成',
|
||||
'style' => 'font-weight: bold;padding-bottom: 4px;',
|
||||
];
|
||||
//
|
||||
$sheets = [];
|
||||
$startD = Carbon::parse($date[0])->startOfDay();
|
||||
$endD = Carbon::parse($date[1])->endOfDay();
|
||||
$users = User::whereIn('userid', $userid)->take(100)->get();
|
||||
/** @var User $user */
|
||||
foreach ($users as $user) {
|
||||
$recordTimes = UserCheckinRecord::getTimes($user->userid, [$startD, $endD]);
|
||||
//
|
||||
$nickname = Base::filterEmoji($user->nickname);
|
||||
$styles = ["A1:H1" => ["font" => ["bold" => true]]];
|
||||
$datas = [];
|
||||
$startT = $startD->timestamp;
|
||||
$endT = $endD->timestamp;
|
||||
$index = 1;
|
||||
while ($startT < $endT) {
|
||||
$index++;
|
||||
$sameDate = date("Y-m-d", $startT);
|
||||
$sameTimes = $recordTimes[$sameDate] ?? [];
|
||||
$sameCollect = UserCheckinRecord::atCollect($sameDate, $sameTimes);
|
||||
$firstBetween = [Carbon::createFromTimestamp($startT), Carbon::createFromTimestamp($startT + $secondEnd - 1)];
|
||||
$lastBetween = [Carbon::createFromTimestamp($startT + $secondStart + 1), Carbon::createFromTimestamp($startT + 86400)];
|
||||
$firstRecord = $sameCollect?->whereBetween("datetime", $firstBetween)->first();
|
||||
$lastRecord = $sameCollect?->whereBetween("datetime", $lastBetween)->last();
|
||||
$firstTimestamp = $firstRecord['timestamp'] ?: 0;
|
||||
$lastTimestamp = $lastRecord['timestamp'] ?: 0;
|
||||
if (Timer::time() < $startT + $secondStart) {
|
||||
$firstResult = "-";
|
||||
} else {
|
||||
$firstResult = $doo->translate("正常");
|
||||
if (empty($firstTimestamp)) {
|
||||
$firstResult = $doo->translate("缺卡");
|
||||
$styles["E{$index}"] = ["font" => ["color" => ["rgb" => "ff0000"]]];
|
||||
} elseif ($firstTimestamp > $startT + $secondStart) {
|
||||
$firstResult = $doo->translate("迟到");
|
||||
$styles["E{$index}"] = ["font" => ["color" => ["rgb" => "436FF6"]]];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Timer::time() < $startT + $secondEnd) {
|
||||
$lastResult = "-";
|
||||
$lastTimestamp = 0;
|
||||
} else {
|
||||
$lastResult = Doo::translate("正常");
|
||||
if (empty($lastTimestamp) || $lastTimestamp === $firstTimestamp) {
|
||||
$lastResult = Doo::translate("缺卡");
|
||||
$styles["G{$index}"] = ["font" => ["color" => ["rgb" => "ff0000"]]];
|
||||
} elseif ($lastTimestamp < $startT + $secondEnd) {
|
||||
$lastResult = Doo::translate("早退");
|
||||
$styles["G{$index}"] = ["font" => ["color" => ["rgb" => "436FF6"]]];
|
||||
if (Timer::time() < $startT + $secondEnd) {
|
||||
$lastResult = "-";
|
||||
$lastTimestamp = 0;
|
||||
} else {
|
||||
$lastResult = $doo->translate("正常");
|
||||
if (empty($lastTimestamp) || $lastTimestamp === $firstTimestamp) {
|
||||
$lastResult = $doo->translate("缺卡");
|
||||
$styles["G{$index}"] = ["font" => ["color" => ["rgb" => "ff0000"]]];
|
||||
} elseif ($lastTimestamp < $startT + $secondEnd) {
|
||||
$lastResult = $doo->translate("早退");
|
||||
$styles["G{$index}"] = ["font" => ["color" => ["rgb" => "436FF6"]]];
|
||||
}
|
||||
}
|
||||
$firstTimestamp = $firstTimestamp ? date("H:i", $firstTimestamp) : "-";
|
||||
$lastTimestamp = $lastTimestamp ? date("H:i", $lastTimestamp) : "-";
|
||||
$section = array_map(function($item) {
|
||||
return $item[0] . "-" . ($item[1] ?: "None");
|
||||
}, UserCheckinRecord::atSection($sameTimes));
|
||||
$datas[] = [
|
||||
"{$nickname} (ID: {$user->userid})",
|
||||
$sameDate,
|
||||
implode("-", $time),
|
||||
$firstTimestamp,
|
||||
$firstResult,
|
||||
$lastTimestamp,
|
||||
$lastResult,
|
||||
implode(", ", $section),
|
||||
];
|
||||
$startT += 86400;
|
||||
}
|
||||
$firstTimestamp = $firstTimestamp ? date("H:i", $firstTimestamp) : "-";
|
||||
$lastTimestamp = $lastTimestamp ? date("H:i", $lastTimestamp) : "-";
|
||||
$section = array_map(function($item) {
|
||||
return $item[0] . "-" . ($item[1] ?: "None");
|
||||
}, UserCheckinRecord::atSection($sameTimes));
|
||||
$datas[] = [
|
||||
"{$nickname} (ID: {$user->userid})",
|
||||
$sameDate,
|
||||
implode("-", $time),
|
||||
$firstTimestamp,
|
||||
$firstResult,
|
||||
$lastTimestamp,
|
||||
$lastResult,
|
||||
implode(", ", $section),
|
||||
];
|
||||
$startT += 86400;
|
||||
$title = (count($sheets) + 1) . "." . ($nickname ?: $user->userid);
|
||||
$sheets[] = BillExport::create()->setTitle($title)->setHeadings($headings)->setData($datas)->setStyles($styles);
|
||||
}
|
||||
$title = (count($sheets) + 1) . "." . ($nickname ?: $user->userid);
|
||||
$sheets[] = BillExport::create()->setTitle($title)->setHeadings($headings)->setData($datas)->setStyles($styles);
|
||||
}
|
||||
if (empty($sheets)) {
|
||||
return Base::retError('没有任何数据');
|
||||
}
|
||||
if (empty($sheets)) {
|
||||
$content[] = [
|
||||
'content' => '没有任何数据',
|
||||
'style' => 'color: #ff0000;',
|
||||
];
|
||||
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
|
||||
'type' => 'content',
|
||||
'title' => $content[0]['content'],
|
||||
'content' => $content,
|
||||
], $botUser->userid, true, false, true);
|
||||
return;
|
||||
}
|
||||
//
|
||||
$fileName = $users[0]->nickname;
|
||||
if (count($users) > 1) {
|
||||
$fileName .= "等" . count($userid) . "位成员的签到记录";
|
||||
} else {
|
||||
$fileName .= '的签到记录';
|
||||
}
|
||||
$fileName = $doo->translate($fileName) . '_' . Timer::time() . '.xlsx';
|
||||
$filePath = "temp/checkin/export/" . date("Ym", Timer::time());
|
||||
$export = new BillMultipleExport($sheets);
|
||||
$res = $export->store($filePath . "/" . $fileName);
|
||||
if ($res != 1) {
|
||||
$content[] = [
|
||||
'content' => "导出失败,{$fileName}!",
|
||||
'style' => 'color: #ff0000;',
|
||||
];
|
||||
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
|
||||
'type' => 'content',
|
||||
'title' => $content[0]['content'],
|
||||
'content' => $content,
|
||||
], $botUser->userid, true, false, true);
|
||||
return;
|
||||
}
|
||||
$xlsPath = storage_path("app/" . $filePath . "/" . $fileName);
|
||||
$zipFile = "app/" . $filePath . "/" . Base::rightDelete($fileName, '.xlsx') . ".zip";
|
||||
$zipPath = storage_path($zipFile);
|
||||
if (file_exists($zipPath)) {
|
||||
Base::deleteDirAndFile($zipPath, true);
|
||||
}
|
||||
try {
|
||||
Madzipper::make($zipPath)->add($xlsPath)->close();
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
//
|
||||
if (file_exists($zipPath)) {
|
||||
$key = Down::cache_encode([
|
||||
'file' => $zipFile,
|
||||
]);
|
||||
$fileUrl = Base::fillUrl('api/system/checkin/down?key=' . $key);
|
||||
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
|
||||
'type' => 'file_download',
|
||||
'title' => '导出签到数据已完成',
|
||||
'name' => $fileName,
|
||||
'size' => filesize($zipPath),
|
||||
'url' => $fileUrl,
|
||||
], $botUser->userid, true, false, true);
|
||||
} else {
|
||||
$content[] = [
|
||||
'content' => "打包失败,请稍后再试...",
|
||||
'style' => 'color: #ff0000;',
|
||||
];
|
||||
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
|
||||
'type' => 'content',
|
||||
'title' => $content[0]['content'],
|
||||
'content' => $content,
|
||||
], $botUser->userid, true, false, true);
|
||||
}
|
||||
});
|
||||
//
|
||||
$fileName = $users[0]->nickname;
|
||||
if (count($users) > 1) {
|
||||
$fileName .= "等" . count($userid) . "位成员的签到记录";
|
||||
} else {
|
||||
$fileName .= '的签到记录';
|
||||
}
|
||||
$fileName = Doo::translate($fileName) . '_' . Timer::time() . '.xlsx';
|
||||
$filePath = "temp/checkin/export/" . date("Ym", Timer::time());
|
||||
$export = new BillMultipleExport($sheets);
|
||||
$res = $export->store($filePath . "/" . $fileName);
|
||||
if ($res != 1) {
|
||||
return Base::retError('导出失败,' . $fileName . '!');
|
||||
}
|
||||
$xlsPath = storage_path("app/" . $filePath . "/" . $fileName);
|
||||
$zipFile = "app/" . $filePath . "/" . Base::rightDelete($fileName, '.xlsx') . ".zip";
|
||||
$zipPath = storage_path($zipFile);
|
||||
if (file_exists($zipPath)) {
|
||||
Base::deleteDirAndFile($zipPath, true);
|
||||
}
|
||||
try {
|
||||
Madzipper::make($zipPath)->add($xlsPath)->close();
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
|
||||
'type' => 'content',
|
||||
'content' => '正在导出签到数据,请稍等...',
|
||||
], $botUser->userid, true, false, true);
|
||||
//
|
||||
if (file_exists($zipPath)) {
|
||||
$base64 = base64_encode(Base::array2string([
|
||||
'file' => $zipFile,
|
||||
]));
|
||||
Session::put('checkin::export:userid', $user->userid);
|
||||
return Base::retSuccess('success', [
|
||||
'size' => Base::twoFloat(filesize($zipPath) / 1024, true),
|
||||
'url' => Base::fillUrl('api/system/checkin/down?key=' . urlencode($base64)),
|
||||
]);
|
||||
} else {
|
||||
return Base::retError('打包失败,请稍后再试...');
|
||||
}
|
||||
return Base::retSuccess('success');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/checkin/down 26. 下载导出的签到数据
|
||||
* @api {get} api/system/checkin/down 下载导出的签到数据
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
@@ -1429,21 +1510,16 @@ class SystemController extends AbstractController
|
||||
*/
|
||||
public function checkin__down()
|
||||
{
|
||||
$userid = Session::get('checkin::export:userid');
|
||||
if (empty($userid)) {
|
||||
return Base::ajaxError("请求已过期,请重新导出!", [], 0, 502);
|
||||
}
|
||||
//
|
||||
$array = Base::string2array(base64_decode(urldecode(Request::input('key'))));
|
||||
$array = Down::cache_decode();
|
||||
$file = $array['file'];
|
||||
if (empty($file) || !file_exists(storage_path($file))) {
|
||||
return Base::ajaxError("文件不存在!", [], 0, 502);
|
||||
return Base::ajaxError("文件不存在!", [], 0, 403);
|
||||
}
|
||||
return Response::download(storage_path($file));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/version 27. 获取版本号
|
||||
* @api {get} api/system/version 获取版本号
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
@@ -1489,7 +1565,7 @@ class SystemController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/prefetch 28. 预加载的资源
|
||||
* @api {get} api/system/prefetch 预加载的资源
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
378
app/Http/Controllers/Api/apidoc.md
Normal file
378
app/Http/Controllers/Api/apidoc.md
Normal file
@@ -0,0 +1,378 @@
|
||||
# apiDoc 参数标签说明(完整速查)
|
||||
|
||||
apiDoc 使用内联注释为 RESTful API 自动生成文档。
|
||||
以下为所有官方支持的参数与其说明。
|
||||
|
||||
---
|
||||
|
||||
## @api
|
||||
**定义 API 方法的基本信息**
|
||||
|
||||
```js
|
||||
@api {method} path title
|
||||
```
|
||||
|
||||
- **method**:请求方法,如 `GET`、`POST`、`PUT`、`DELETE` 等
|
||||
- **path**:请求路径,例如 `/user/:id`
|
||||
- **title**:简短标题(显示在文档中)
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@api {get} /user/:id Get user info
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiBody
|
||||
**定义请求体参数**
|
||||
|
||||
```js
|
||||
@apiBody [{type}] [field=defaultValue] [description]
|
||||
```
|
||||
|
||||
- `{type}` 参数类型(如 String, Number, Object, String[])
|
||||
- `[field]` 可选字段(方括号表示可选)
|
||||
- `=defaultValue` 默认值
|
||||
- `description` 参数说明
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiBody {String} lastname Mandatory Lastname.
|
||||
@apiBody {Object} [address] Optional address object.
|
||||
@apiBody {String} [address[city]] Optional city.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiDefine
|
||||
**定义可复用的文档块**
|
||||
|
||||
```js
|
||||
@apiDefine name [title] [description]
|
||||
```
|
||||
|
||||
- `name`:唯一标识
|
||||
- `title`:简短标题
|
||||
- `description`:多行描述
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiDefine MyError
|
||||
@apiError UserNotFound The <code>id</code> of the User was not found.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiDeprecated
|
||||
**标记接口为弃用状态**
|
||||
|
||||
```js
|
||||
@apiDeprecated [text]
|
||||
```
|
||||
|
||||
- `text`:提示文本,可带链接到新方法
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiDeprecated use now (#User:GetDetails)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiDescription
|
||||
**描述接口详细说明**
|
||||
|
||||
```js
|
||||
@apiDescription text
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiDescription This is the Description.
|
||||
It is multiline capable.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiError
|
||||
**定义错误返回参数**
|
||||
|
||||
```js
|
||||
@apiError [(group)] [{type}] field [description]
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiError UserNotFound The id of the User was not found.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiErrorExample
|
||||
**定义错误返回示例**
|
||||
|
||||
```js
|
||||
@apiErrorExample [{type}] [title]
|
||||
example
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiErrorExample {json} Error-Response:
|
||||
HTTP/1.1 404 Not Found
|
||||
{ "error": "UserNotFound" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiExample
|
||||
**定义接口使用示例**
|
||||
|
||||
```js
|
||||
@apiExample [{type}] title
|
||||
example
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiExample {curl} Example usage:
|
||||
curl -i http://localhost/user/4711
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiGroup
|
||||
**定义所属分组**
|
||||
|
||||
```js
|
||||
@apiGroup name
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiGroup User
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiHeader
|
||||
**定义请求头参数**
|
||||
|
||||
```js
|
||||
@apiHeader [(group)] [{type}] [field=defaultValue] [description]
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiHeader {String} access-key Users unique access-key.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiHeaderExample
|
||||
**定义请求头示例**
|
||||
|
||||
```js
|
||||
@apiHeaderExample [{type}] [title]
|
||||
example
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiHeaderExample {json} Header-Example:
|
||||
{
|
||||
"Accept-Encoding": "gzip, deflate"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiIgnore
|
||||
**忽略当前文档块**
|
||||
|
||||
```js
|
||||
@apiIgnore [hint]
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiIgnore Not finished method
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiName
|
||||
**定义接口唯一名称**
|
||||
|
||||
```js
|
||||
@apiName name
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiName GetUser
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiParam
|
||||
**定义请求参数**
|
||||
|
||||
```js
|
||||
@apiParam [(group)] [{type}] [field=defaultValue] [description]
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiParam {Number} id Users unique ID.
|
||||
@apiParam {String} [firstname] Optional firstname.
|
||||
@apiParam {String} country="DE" Mandatory with default.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiParamExample
|
||||
**定义参数请求示例**
|
||||
|
||||
```js
|
||||
@apiParamExample [{type}] [title]
|
||||
example
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiParamExample {json} Request-Example:
|
||||
{ "id": 4711 }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiPermission
|
||||
**定义权限要求**
|
||||
|
||||
```js
|
||||
@apiPermission name
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiPermission admin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiPrivate
|
||||
**标记接口为私有(可过滤)**
|
||||
|
||||
```js
|
||||
@apiPrivate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiQuery
|
||||
**定义查询参数(?query)**
|
||||
|
||||
```js
|
||||
@apiQuery [{type}] [field=defaultValue] [description]
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiQuery {Number} id Users unique ID.
|
||||
@apiQuery {String} [sort="asc"] Sort order.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiSampleRequest
|
||||
**定义接口测试请求 URL**
|
||||
|
||||
```js
|
||||
@apiSampleRequest url
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiSampleRequest http://test.github.com/some_path/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiSuccess
|
||||
**定义成功返回参数**
|
||||
|
||||
```js
|
||||
@apiSuccess [(group)] [{type}] field [description]
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiSuccess {String} firstname Firstname of the User.
|
||||
@apiSuccess {String} lastname Lastname of the User.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiSuccessExample
|
||||
**定义成功返回示例**
|
||||
|
||||
```js
|
||||
@apiSuccessExample [{type}] [title]
|
||||
example
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiSuccessExample {json} Success-Response:
|
||||
HTTP/1.1 200 OK
|
||||
{ "firstname": "John", "lastname": "Doe" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiUse
|
||||
**引用定义块(@apiDefine)**
|
||||
|
||||
```js
|
||||
@apiUse name
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiDefine MySuccess
|
||||
@apiSuccess {String} firstname User firstname.
|
||||
|
||||
@apiUse MySuccess
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiVersion
|
||||
**定义接口版本**
|
||||
|
||||
```js
|
||||
@apiVersion version
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiVersion 1.6.2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 附录:常用标签速查表
|
||||
|
||||
| 标签 | 作用 | 示例 |
|
||||
|------|------|------|
|
||||
| `@api` | 定义接口 | `@api {get} /user/:id` |
|
||||
| `@apiName` | 唯一名称 | `@apiName GetUser` |
|
||||
| `@apiGroup` | 所属分组 | `@apiGroup User` |
|
||||
| `@apiParam` | 请求参数 | `@apiParam {Number} id Users unique ID.` |
|
||||
| `@apiBody` | 请求体参数 | `@apiBody {String} name Username.` |
|
||||
| `@apiQuery` | 查询参数 | `@apiQuery {String} keyword Search term.` |
|
||||
| `@apiHeader` | Header 参数 | `@apiHeader {String} token Auth token.` |
|
||||
| `@apiSuccess` | 成功返回字段 | `@apiSuccess {String} name Username.` |
|
||||
| `@apiError` | 错误返回字段 | `@apiError NotFound User not found.` |
|
||||
| `@apiVersion` | 版本号 | `@apiVersion 1.0.0` |
|
||||
@@ -1,89 +1,137 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 给apidoc项目增加顺序编号
|
||||
* 给apidoc项目增加顺序编号 / 支持恢复
|
||||
*/
|
||||
|
||||
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
|
||||
|
||||
$path = dirname(__FILE__). '/';
|
||||
$lists = scandir($path);
|
||||
//
|
||||
foreach ($lists AS $item) {
|
||||
$fillPath = $path . $item;
|
||||
if (str_ends_with($fillPath, 'Controller.php')) {
|
||||
$content = file_get_contents($fillPath);
|
||||
preg_match_all("/\* @api \{(.+?)\} (.*?)\n/i", $content, $matchs);
|
||||
$i = 1;
|
||||
foreach ($matchs[2] AS $key=>$text) {
|
||||
if (in_array(strtolower($matchs[1][$key]), array('get', 'post'))) {
|
||||
$expl = explode(" ", __sRemove($text));
|
||||
$end = $expl[1];
|
||||
if ($expl[2]) {
|
||||
$end = '';
|
||||
foreach ($expl AS $k=>$v) { if ($k >= 2) { $end.= " ".$v; } }
|
||||
}
|
||||
$newtext = "* @api {".$matchs[1][$key]."} ".$expl[0]." ".__zeroFill($i, 2).". ".trim($end);
|
||||
$content = str_replace("* @api {".$matchs[1][$key]."} ".$text, $newtext, $content);
|
||||
$i++;
|
||||
//
|
||||
echo $newtext;
|
||||
echo "\r\n";
|
||||
}
|
||||
}
|
||||
if ($i > 1) {
|
||||
file_put_contents($fillPath, $content);
|
||||
}
|
||||
}
|
||||
}
|
||||
echo "Success \n";
|
||||
const NUMBER_WIDTH = 2;
|
||||
|
||||
/** ************************************************************** */
|
||||
/** ************************************************************** */
|
||||
/** ************************************************************** */
|
||||
$isRestore = isset($argv[1]) && strtolower($argv[1]) === 'restore';
|
||||
|
||||
/**
|
||||
* 替换所有空格
|
||||
* @param $str
|
||||
* @return mixed
|
||||
*/
|
||||
function __sRemove($str) {
|
||||
$str = str_replace(" ", " ", $str);
|
||||
if (__strExists($str, " ")) {
|
||||
return __sRemove($str);
|
||||
}
|
||||
return $str;
|
||||
$basePath = dirname(__FILE__) . '/';
|
||||
$controllerFiles = glob($basePath . '*Controller.php');
|
||||
|
||||
if (!$controllerFiles) {
|
||||
echo "No Controller.php files found\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
foreach ($controllerFiles as $filePath) {
|
||||
$original = file_get_contents($filePath);
|
||||
[$updated, $linesChanged] = processFile($original, $isRestore);
|
||||
|
||||
if (count($linesChanged) === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
file_put_contents($filePath, $updated);
|
||||
|
||||
foreach ($linesChanged as $line) {
|
||||
echo $line . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo $isRestore ? "Restore Success \n" : "Success \n";
|
||||
|
||||
/**
|
||||
* 是否包含字符
|
||||
* @param $string
|
||||
* @param $find
|
||||
* @return bool
|
||||
* 处理单个文件内容
|
||||
*
|
||||
* @param string $content
|
||||
* @param bool $restore
|
||||
* @return array{string, array<int, string>}
|
||||
*/
|
||||
function __strExists($string, $find)
|
||||
function processFile(string $content, bool $restore): array
|
||||
{
|
||||
return str_contains($string, $find);
|
||||
$lineChanges = [];
|
||||
$counter = 1;
|
||||
|
||||
$pattern = '/\* @api \{([^\}]+)\}\s+([^\s]+)([^\r\n]*)(\r?\n)/';
|
||||
|
||||
$updated = preg_replace_callback(
|
||||
$pattern,
|
||||
function (array $matches) use ($restore, &$counter, &$lineChanges) {
|
||||
$method = trim($matches[1]);
|
||||
if (!in_array(strtolower($method), ['get', 'post'], true)) {
|
||||
return $matches[0];
|
||||
}
|
||||
|
||||
$endpoint = trim($matches[2]);
|
||||
$suffix = normalizeDescription(stripExistingNumbering($matches[3]));
|
||||
|
||||
if (!$restore) {
|
||||
$numberedSuffix = formatNumber($counter) . '.';
|
||||
if ($suffix !== '') {
|
||||
$numberedSuffix .= ' ' . $suffix;
|
||||
}
|
||||
$counter++;
|
||||
} else {
|
||||
$numberedSuffix = $suffix;
|
||||
}
|
||||
|
||||
$newLine = renderAnnotation($method, $endpoint, $numberedSuffix);
|
||||
|
||||
if ($newLine !== rtrim($matches[0], "\r\n")) {
|
||||
$lineChanges[] = $newLine;
|
||||
}
|
||||
|
||||
return $newLine . $matches[4];
|
||||
},
|
||||
$content
|
||||
);
|
||||
|
||||
if ($updated === null) {
|
||||
return [$content, []];
|
||||
}
|
||||
|
||||
return [$updated, $lineChanges];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $str 补零
|
||||
* @param int $length
|
||||
* @param int $after
|
||||
* @return bool|string
|
||||
* 生成格式化后的注释行
|
||||
*/
|
||||
function __zeroFill($str, $length = 0, $after = 1) {
|
||||
if (strlen($str) >= $length) {
|
||||
return $str;
|
||||
function renderAnnotation(string $method, string $endpoint, string $suffix = ''): string
|
||||
{
|
||||
$line = "* @api {" . $method . "} " . $endpoint;
|
||||
|
||||
if ($suffix !== '') {
|
||||
if ($suffix[0] !== ' ') {
|
||||
$line .= ' ';
|
||||
}
|
||||
$line .= $suffix;
|
||||
}
|
||||
$_str = '';
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$_str .= '0';
|
||||
}
|
||||
if ($after) {
|
||||
$_ret = substr($_str . $str, $length * -1);
|
||||
} else {
|
||||
$_ret = substr($str . $_str, 0, $length);
|
||||
}
|
||||
return $_ret;
|
||||
|
||||
return $line;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除已有编号部分
|
||||
*/
|
||||
function stripExistingNumbering(string $text): string
|
||||
{
|
||||
$trimmed = ltrim($text);
|
||||
$pattern = '/^\d+\.\s*/';
|
||||
return preg_replace($pattern, '', $trimmed) ?? $trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 压缩多余空格
|
||||
*/
|
||||
function normalizeDescription(string $text): string
|
||||
{
|
||||
$text = trim($text);
|
||||
if ($text === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return preg_replace('/\s+/', ' ', $text) ?? $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成固定宽度的数字
|
||||
*/
|
||||
function formatNumber(int $number): string
|
||||
{
|
||||
return str_pad((string) $number, NUMBER_WIDTH, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ use App\Tasks\ZincSearchSyncTask;
|
||||
use App\Tasks\UnclaimedTaskRemindTask;
|
||||
use Hhxsv5\LaravelS\Swoole\Task\Task;
|
||||
use Laravolt\Avatar\Avatar;
|
||||
use Swoole\Coroutine;
|
||||
|
||||
|
||||
/**
|
||||
@@ -62,6 +61,10 @@ class IndexController extends InvokeController
|
||||
$array = Base::json2array(file_get_contents($hotFile));
|
||||
$style = null;
|
||||
$script = preg_replace("/^(\/\/(.*?))(:\d+)?\//i", "$1:" . $array['APP_DEV_PORT'] . "/", asset_main("resources/assets/js/app.js"));
|
||||
$proxyUri = Base::liveEnv('VSCODE_PROXY_URI');
|
||||
if (is_string($proxyUri) && preg_match('/^https?:\/\//i', $proxyUri)) {
|
||||
$script = preg_replace('/^(https?:\/\/|\/\/)[^\/]+/', rtrim($proxyUri, '/'), $script, 1);
|
||||
}
|
||||
} else {
|
||||
$array = Base::json2array(file_get_contents($manifestFile));
|
||||
$style = asset_main($array['resources/assets/js/app.js']['css'][0]);
|
||||
@@ -255,6 +258,7 @@ class IndexController extends InvokeController
|
||||
Task::deliver(new DeleteTmpTask('file'));
|
||||
Task::deliver(new DeleteTmpTask('tmp_file', 24));
|
||||
Task::deliver(new DeleteTmpTask('user_device', 24));
|
||||
Task::deliver(new DeleteTmpTask('umeng_log', 24 * 3));
|
||||
// 删除机器人消息
|
||||
Task::deliver(new DeleteBotMsgTask());
|
||||
// 周期任务
|
||||
@@ -353,9 +357,7 @@ class IndexController extends InvokeController
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (empty($avaiPath)) {
|
||||
abort(404);
|
||||
}
|
||||
abort_if(empty($avaiPath), 404);
|
||||
$lists = Base::recursiveFiles($dirPath, false);
|
||||
$files = [];
|
||||
foreach ($lists as $file) {
|
||||
@@ -433,13 +435,9 @@ class IndexController extends InvokeController
|
||||
$path = Arr::get($data, 'path');
|
||||
$file = public_path($path);
|
||||
// 防止 ../ 穿越获取到系统文件
|
||||
if (!str_starts_with(realpath($file), public_path())) {
|
||||
abort(404);
|
||||
}
|
||||
//
|
||||
if (!file_exists($file)) {
|
||||
abort(404);
|
||||
}
|
||||
abort_if(!str_starts_with(realpath($file), public_path()), 404);
|
||||
// 如果文件不存在,直接返回 404
|
||||
abort_if(!file_exists($file), 404);
|
||||
//
|
||||
parse_str($data['query'], $query);
|
||||
$name = Arr::get($query, 'name');
|
||||
|
||||
@@ -19,8 +19,7 @@ class WebApi
|
||||
*/
|
||||
public function handle($request, Closure $next)
|
||||
{
|
||||
// 为每个请求生成唯一ID
|
||||
$request->requestId = RequestContext::generateRequestId();
|
||||
// 记录请求信息
|
||||
RequestContext::set('start_time', microtime(true));
|
||||
RequestContext::set('header_language', $request->header('language'));
|
||||
|
||||
@@ -76,6 +75,6 @@ class WebApi
|
||||
public function terminate()
|
||||
{
|
||||
// 请求结束后清理上下文
|
||||
RequestContext::clear();
|
||||
RequestContext::clean();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
* @property int|null $size 大小(B)
|
||||
* @property int|null $userid 拥有者ID
|
||||
* @property int|null $share 是否共享
|
||||
* @property int|null $guest_access 是否允许游客访问
|
||||
* @property int|null $pshare 所属分享ID
|
||||
* @property int|null $created_id 创建者
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
@@ -44,6 +45,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|File whereCreatedId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|File whereDeletedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|File whereExt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|File whereGuestAccess($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|File whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|File whereName($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|File wherePid($value)
|
||||
@@ -642,6 +644,29 @@ class File extends AbstractModel
|
||||
Task::deliver($task);
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件推送消息
|
||||
* @param $action
|
||||
* @param array|null $data 发送内容
|
||||
* @param int $userid 会员ID
|
||||
*/
|
||||
public static function pushMsgSimple($action, $data, $userid)
|
||||
{
|
||||
if (empty($data) || empty($userid)) {
|
||||
return;
|
||||
}
|
||||
$msg = [
|
||||
'type' => 'file',
|
||||
'action' => $action,
|
||||
'data' => $data,
|
||||
];
|
||||
$params = [
|
||||
'userid' => $userid,
|
||||
'msg' => $msg
|
||||
];
|
||||
Task::deliver(new PushTask($params));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取推送会员
|
||||
* @param $action
|
||||
@@ -956,30 +981,6 @@ class File extends AbstractModel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件推送消息
|
||||
* @param $action
|
||||
* @param array|null $data 发送内容
|
||||
* @param array $userid 会员ID
|
||||
*/
|
||||
public static function filePushMsg($action, $data = null, $userid = null)
|
||||
{
|
||||
$userid = User::userid();
|
||||
if (empty($userid)) {
|
||||
return;
|
||||
}
|
||||
$msg = [
|
||||
'type' => 'file',
|
||||
'action' => $action,
|
||||
'data' => $data,
|
||||
];
|
||||
$params = [
|
||||
'userid' => $userid,
|
||||
'msg' => $msg
|
||||
];
|
||||
Task::deliver(new PushTask($params));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据文件类型判断是否需要安装应用
|
||||
* @param $type
|
||||
|
||||
@@ -129,9 +129,7 @@ class FileContent extends AbstractModel
|
||||
],
|
||||
default => json_decode('{}'),
|
||||
};
|
||||
if ($download) {
|
||||
abort(403, "This file is empty.");
|
||||
}
|
||||
abort_if($download, 403, "This file is empty.");
|
||||
} else {
|
||||
$path = $content['url'];
|
||||
if ($file->ext) {
|
||||
@@ -147,11 +145,8 @@ class FileContent extends AbstractModel
|
||||
}
|
||||
if ($download) {
|
||||
$filePath = public_path($path);
|
||||
if (isset($filePath)) {
|
||||
return Base::DownloadFileResponse($filePath, $name);
|
||||
} else {
|
||||
abort(403, "This file not support download.");
|
||||
}
|
||||
abort_if(!isset($filePath),403, "This file not support download.");
|
||||
return Base::DownloadFileResponse($filePath, $name);
|
||||
}
|
||||
}
|
||||
return Base::retSuccess('success', [ 'content' => $content ]);
|
||||
|
||||
@@ -129,6 +129,7 @@ class Project extends AbstractModel
|
||||
'projects.*',
|
||||
'project_users.owner',
|
||||
'project_users.top_at',
|
||||
'project_users.sort',
|
||||
])
|
||||
->leftJoin('project_users', function ($leftJoin) use ($userid) {
|
||||
$leftJoin
|
||||
@@ -153,6 +154,7 @@ class Project extends AbstractModel
|
||||
'projects.*',
|
||||
'project_users.owner',
|
||||
'project_users.top_at',
|
||||
'project_users.sort',
|
||||
])
|
||||
->join('project_users', 'projects.id', '=', 'project_users.project_id')
|
||||
->where('project_users.userid', $userid);
|
||||
@@ -423,24 +425,25 @@ class Project extends AbstractModel
|
||||
$projectUserids = $this->relationUserids();
|
||||
foreach ($flows as $item) {
|
||||
$id = intval($item['id']);
|
||||
$name = trim(str_replace('|', '·', $item['name']));
|
||||
$turns = Base::arrayRetainInt($item['turns'] ?: [], true);
|
||||
$userids = Base::arrayRetainInt($item['userids'] ?: [], true);
|
||||
$usertype = trim($item['usertype']);
|
||||
$userlimit = intval($item['userlimit']);
|
||||
$columnid = intval($item['columnid']);
|
||||
if ($usertype == 'replace' && empty($userids)) {
|
||||
throw new ApiException("状态[{$item['name']}]设置错误,设置流转模式时必须填写状态负责人");
|
||||
throw new ApiException("状态[{$name}]设置错误,设置流转模式时必须填写状态负责人");
|
||||
}
|
||||
if ($usertype == 'merge' && empty($userids)) {
|
||||
throw new ApiException("状态[{$item['name']}]设置错误,设置剔除模式时必须填写状态负责人");
|
||||
throw new ApiException("状态[{$name}]设置错误,设置剔除模式时必须填写状态负责人");
|
||||
}
|
||||
if ($userlimit && empty($userids)) {
|
||||
throw new ApiException("状态[{$item['name']}]设置错误,设置限制负责人时必须填写状态负责人");
|
||||
throw new ApiException("状态[{$name}]设置错误,设置限制负责人时必须填写状态负责人");
|
||||
}
|
||||
foreach ($userids as $userid) {
|
||||
if (!in_array($userid, $projectUserids)) {
|
||||
$nickname = User::userid2nickname($userid);
|
||||
throw new ApiException("状态[{$item['name']}]设置错误,状态负责人[{$nickname}]不在项目成员内");
|
||||
throw new ApiException("状态[{$name}]设置错误,状态负责人[{$nickname}]不在项目成员内");
|
||||
}
|
||||
}
|
||||
$flow = ProjectFlowItem::updateInsert([
|
||||
@@ -448,8 +451,9 @@ class Project extends AbstractModel
|
||||
'project_id' => $this->id,
|
||||
'flow_id' => $projectFlow->id,
|
||||
], [
|
||||
'name' => trim($item['name']),
|
||||
'name' => $name,
|
||||
'status' => trim($item['status']),
|
||||
'color' => trim($item['color']),
|
||||
'sort' => intval($item['sort']),
|
||||
'turns' => $turns,
|
||||
'userids' => $userids,
|
||||
@@ -469,7 +473,7 @@ class Project extends AbstractModel
|
||||
$hasEnd = true;
|
||||
}
|
||||
if (!$isInsert) {
|
||||
$upTaskList[$flow->id] = $flow->status . "|" . $flow->name;
|
||||
$upTaskList[$flow->id] = $flow->status . "|" . $flow->name . "|" . $flow->color;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -590,7 +594,7 @@ class Project extends AbstractModel
|
||||
$project->save();
|
||||
//
|
||||
if ($flow == 'open') {
|
||||
$project->addFlow(Base::json2array('[{"id":-10,"name":"待处理","status":"start","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0},{"id":-11,"name":"进行中","status":"progress","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0},{"id":-12,"name":"待测试","status":"test","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0},{"id":-13,"name":"已完成","status":"end","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0},{"id":-14,"name":"已取消","status":"end","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0}]'));
|
||||
$project->addFlow(Base::json2array('[{"id":-10,"name":"待处理","status":"start","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0},{"id":-11,"name":"进行中","status":"progress","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0},{"id":-12,"name":"待测试","status":"test","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0},{"id":-13,"name":"已完成","status":"end","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0},{"id":-14,"name":"已取消","status":"end","color":"#999999","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0}]'));
|
||||
}
|
||||
});
|
||||
//
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Module\Base;
|
||||
* @property int|null $flow_id 流程ID
|
||||
* @property string|null $name 名称
|
||||
* @property string|null $status 状态
|
||||
* @property string|null $color 自定义颜色
|
||||
* @property array $turns 可流转
|
||||
* @property array $userids 状态负责人ID
|
||||
* @property string|null $usertype 流转模式
|
||||
@@ -30,6 +31,7 @@ use App\Module\Base;
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereColor($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereColumnid($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereFlowId($value)
|
||||
|
||||
@@ -128,8 +128,8 @@ class ProjectPermission extends AbstractModel
|
||||
/**
|
||||
* 更新项目权限
|
||||
*
|
||||
* @param int $projectId
|
||||
* @param array $permissions
|
||||
* @param int $projectId
|
||||
* @param $newPermissions
|
||||
* @return ProjectPermission
|
||||
*/
|
||||
public static function updatePermissions($projectId, $newPermissions)
|
||||
@@ -146,9 +146,9 @@ class ProjectPermission extends AbstractModel
|
||||
|
||||
/**
|
||||
* 检查用户是否有执行特定动作的权限
|
||||
* @param string $action 动作名称
|
||||
* @param Project $project 项目实例
|
||||
* @param ProjectTask $task 任务实例
|
||||
* @param string $action 动作名称
|
||||
* @param ProjectTask|null $task 任务实例
|
||||
* @return bool
|
||||
*/
|
||||
public static function userTaskPermission(Project $project, $action, ProjectTask $task = null)
|
||||
|
||||
@@ -10,6 +10,7 @@ namespace App\Models;
|
||||
* @property string $name 标签名称
|
||||
* @property string|null $desc 标签描述
|
||||
* @property string|null $color 颜色
|
||||
* @property int $sort 排序
|
||||
* @property int $userid 创建人
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
@@ -29,6 +30,7 @@ namespace App\Models;
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag whereName($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag whereProjectId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag whereSort($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag whereUpdatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag whereUserid($value)
|
||||
* @mixin \Eloquent
|
||||
@@ -36,7 +38,6 @@ namespace App\Models;
|
||||
class ProjectTag extends AbstractModel
|
||||
{
|
||||
protected $hidden = [
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
@@ -50,6 +51,7 @@ class ProjectTag extends AbstractModel
|
||||
'name',
|
||||
'desc',
|
||||
'color',
|
||||
'sort',
|
||||
'userid'
|
||||
];
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -486,7 +485,7 @@ class ProjectTask extends AbstractModel
|
||||
foreach ($projectFlowItem as $item) {
|
||||
if ($item->status == 'start') {
|
||||
$task->flow_item_id = $item->id;
|
||||
$task->flow_item_name = $item->status . "|" . $item->name;
|
||||
$task->flow_item_name = $item->status . "|" . $item->name . "|" . $item->color;
|
||||
$owner = array_merge($owner, $item->userids);
|
||||
break;
|
||||
}
|
||||
@@ -650,7 +649,7 @@ class ProjectTask extends AbstractModel
|
||||
$data['column_id'] = $newFlowItem->columnid;
|
||||
}
|
||||
$this->flow_item_id = $newFlowItem->id;
|
||||
$this->flow_item_name = $newFlowItem->status . "|" . $newFlowItem->name;
|
||||
$this->flow_item_name = $newFlowItem->status . "|" . $newFlowItem->name . "|" . $newFlowItem->color;
|
||||
$this->addLog("修改{任务}状态", [
|
||||
'flow' => $flowData,
|
||||
'change' => [$currentFlowItem?->name, $newFlowItem->name]
|
||||
@@ -1144,9 +1143,14 @@ class ProjectTask extends AbstractModel
|
||||
*/
|
||||
public function copyTask()
|
||||
{
|
||||
return AbstractModel::transaction(function() {
|
||||
// 复制任务
|
||||
$task = $this->replicate();
|
||||
$source = $this->fresh(['content', 'taskFile', 'taskUser']);
|
||||
if (!$source) {
|
||||
throw new ApiException('任务不存在');
|
||||
}
|
||||
|
||||
return AbstractModel::transaction(function () use ($source) {
|
||||
// 复制任务(使用最新数据,避免复制临时字段)
|
||||
$task = $source->replicate();
|
||||
$task->dialog_id = 0;
|
||||
$task->archived_at = null;
|
||||
$task->archived_userid = 0;
|
||||
@@ -1155,21 +1159,21 @@ class ProjectTask extends AbstractModel
|
||||
$task->created_at = Carbon::now();
|
||||
$task->save();
|
||||
// 复制任务内容
|
||||
if ($this->content) {
|
||||
$tmp = $this->content->replicate();
|
||||
if ($source->content) {
|
||||
$tmp = $source->content->replicate();
|
||||
$tmp->task_id = $task->id;
|
||||
$tmp->created_at = Carbon::now();
|
||||
$tmp->save();
|
||||
}
|
||||
// 复制任务附件
|
||||
foreach ($this->taskFile as $taskFile) {
|
||||
foreach ($source->taskFile as $taskFile) {
|
||||
$tmp = $taskFile->replicate();
|
||||
$tmp->task_id = $task->id;
|
||||
$tmp->created_at = Carbon::now();
|
||||
$tmp->save();
|
||||
}
|
||||
// 复制任务成员
|
||||
foreach ($this->taskUser as $taskUser) {
|
||||
foreach ($source->taskUser as $taskUser) {
|
||||
$tmp = $taskUser->replicate();
|
||||
$tmp->task_id = $task->id;
|
||||
$tmp->task_pid = $task->id;
|
||||
@@ -1556,8 +1560,9 @@ class ProjectTask extends AbstractModel
|
||||
* @param string $action
|
||||
* @param array|self $data 发送内容,默认为[id, parent_id, project_id, column_id, dialog_id]
|
||||
* @param array $userid 指定会员,默认为项目所有成员
|
||||
* @param bool $ignoreSelf 是否忽略当前连接
|
||||
*/
|
||||
public function pushMsg($action, $data = null, $userid = null)
|
||||
public function pushMsg($action, $data = null, $userid = null, $ignoreSelf = true)
|
||||
{
|
||||
if (!$this->project) {
|
||||
return;
|
||||
@@ -1569,77 +1574,91 @@ class ProjectTask extends AbstractModel
|
||||
'project_id' => $this->project_id,
|
||||
'column_id' => $this->column_id,
|
||||
'dialog_id' => $this->dialog_id,
|
||||
'visibility' => $this->visibility,
|
||||
];
|
||||
} elseif ($data instanceof self) {
|
||||
$data = $data->toArray();
|
||||
}
|
||||
//
|
||||
|
||||
// 获取接收会员
|
||||
if ($userid === null) {
|
||||
$userids = $this->project->relationUserids();
|
||||
} else {
|
||||
$userids = is_array($userid) ? $userid : [$userid];
|
||||
}
|
||||
//
|
||||
$array = [];
|
||||
if (Arr::exists($data, 'owner') || Arr::exists($data, 'assist')) {
|
||||
$taskUser = ProjectTaskUser::select(['userid', 'owner'])->whereTaskId($data['id'])->get();
|
||||
// 负责人
|
||||
$owners = $taskUser->where('owner', 1)->pluck('userid')->toArray();
|
||||
$owners = array_intersect($userids, $owners);
|
||||
if ($owners) {
|
||||
$array[] = [
|
||||
'userid' => array_values($owners),
|
||||
'data' => array_merge($data, [
|
||||
'owner' => 1,
|
||||
'assist' => 1,
|
||||
])
|
||||
];
|
||||
}
|
||||
// 协助人
|
||||
$assists = $taskUser->where('owner', 0)->pluck('userid')->toArray();
|
||||
$assists = array_intersect($userids, $assists);
|
||||
if ($assists) {
|
||||
$array[] = [
|
||||
'userid' => array_values($assists),
|
||||
'data' => array_merge($data, [
|
||||
'owner' => 0,
|
||||
'assist' => 1,
|
||||
])
|
||||
];
|
||||
}
|
||||
// 其他人
|
||||
switch ($data['visibility']) {
|
||||
case 1:
|
||||
// 项目人员,除了负责人、协助人项目其他人
|
||||
$userids = array_diff($userids, $owners, $assists);
|
||||
break;
|
||||
case 2:
|
||||
// 任务人员,除了负责人、协助人
|
||||
$userids = [];
|
||||
break;
|
||||
case 3:
|
||||
// 指定成员
|
||||
$specifys = ProjectTaskVisibilityUser::select(['userid'])->whereTaskId($data['id'])->pluck('userid')->toArray();
|
||||
$userids = array_diff($specifys, $owners, $assists);
|
||||
break;
|
||||
default:
|
||||
$userids = [];
|
||||
break;
|
||||
}
|
||||
if ($userids) {
|
||||
$array[] = [
|
||||
'userid' => array_values($userids),
|
||||
'data' => array_merge($data, [
|
||||
'owner' => 0,
|
||||
'assist' => 0,
|
||||
])
|
||||
];
|
||||
}
|
||||
$userids = array_values(array_unique(array_map('intval', $userids)));
|
||||
if (empty($userids)) {
|
||||
return;
|
||||
}
|
||||
//
|
||||
|
||||
// 按可见性分组推送
|
||||
$taskUser = ProjectTaskUser::select(['userid', 'owner'])->whereTaskId($data['id'])->get();
|
||||
$ownerList = $taskUser->where('owner', 1)->pluck('userid')->toArray();
|
||||
$assistList = $taskUser->where('owner', 0)->pluck('userid')->toArray();
|
||||
|
||||
$ownerUsers = array_values(array_intersect($userids, $ownerList));
|
||||
$assistUsers = array_values(array_diff(array_intersect($userids, $assistList), $ownerUsers));
|
||||
|
||||
$array = [];
|
||||
|
||||
// 负责人
|
||||
if ($ownerUsers) {
|
||||
$array[] = [
|
||||
'userid' => $ownerUsers,
|
||||
'data' => array_merge($data, [
|
||||
'owner' => 1,
|
||||
'assist' => 0,
|
||||
])
|
||||
];
|
||||
}
|
||||
|
||||
// 协助人
|
||||
if ($assistUsers) {
|
||||
$array[] = [
|
||||
'userid' => $assistUsers,
|
||||
'data' => array_merge($data, [
|
||||
'owner' => 0,
|
||||
'assist' => 1,
|
||||
])
|
||||
];
|
||||
}
|
||||
|
||||
// 其他人
|
||||
$otherUsers = [];
|
||||
switch (intval($data['visibility'])) {
|
||||
case 1:
|
||||
// 项目人员:除了负责人、协助人项目其他人
|
||||
$otherUsers = array_diff($userids, $ownerUsers, $assistUsers);
|
||||
break;
|
||||
case 2:
|
||||
// 任务人员:除了负责人、协助人
|
||||
// $otherUsers = [];
|
||||
break;
|
||||
case 3:
|
||||
// 指定成员
|
||||
$specifys = ProjectTaskVisibilityUser::select(['userid'])->whereTaskId($data['id'])->pluck('userid')->toArray();
|
||||
$otherUsers = array_diff(array_intersect($userids, $specifys), $ownerUsers, $assistUsers);
|
||||
break;
|
||||
}
|
||||
|
||||
if ($otherUsers) {
|
||||
$array[] = [
|
||||
'userid' => array_values($otherUsers),
|
||||
'data' => array_merge($data, [
|
||||
'owner' => 0,
|
||||
'assist' => 0,
|
||||
])
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($array)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 推送
|
||||
foreach ($array as $item) {
|
||||
$params = [
|
||||
'ignoreFd' => Request::header('fd'),
|
||||
'ignoreFd' => $ignoreSelf ? Request::header('fd') : null,
|
||||
'userid' => $item['userid'],
|
||||
'msg' => [
|
||||
'type' => 'projectTask',
|
||||
@@ -1908,7 +1927,7 @@ class ProjectTask extends AbstractModel
|
||||
// 更新任务流程
|
||||
$flowItem = projectFlowItem::whereProjectId($projectId)->whereId($flowItemId)->first();
|
||||
$this->flow_item_id = $flowItemId;
|
||||
$this->flow_item_name = $flowItem->status . "|" . $flowItem->name;
|
||||
$this->flow_item_name = $flowItem->status . "|" . $flowItem->name . "|" . $flowItem->color;
|
||||
if ($flowItem->status == 'end') {
|
||||
$this->completeTask(Carbon::now(), $flowItem->name);
|
||||
} else {
|
||||
@@ -1958,8 +1977,7 @@ class ProjectTask extends AbstractModel
|
||||
if ($this->content) {
|
||||
$taskDesc = $this->content?->getContentInfo();
|
||||
if ($taskDesc) {
|
||||
$converter = new HtmlConverter(['strip_tags' => true]);
|
||||
$descContent = Base::cutStr($converter->convert($taskDesc['content']), 2000);
|
||||
$descContent = Base::cutStr(Base::html2markdown($taskDesc['content'], ['strip_tags' => true]), 2000);
|
||||
$contexts[] = <<<EOF
|
||||
任务描述:
|
||||
```md
|
||||
|
||||
154
app/Models/ProjectTaskRelation.php
Normal file
154
app/Models/ProjectTaskRelation.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Module\Base;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* App\Models\ProjectTaskRelation
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $task_id 任务ID
|
||||
* @property int $related_task_id 关联任务ID
|
||||
* @property string $direction 关系方向: mention/mentioned_by
|
||||
* @property int|null $dialog_id 来源会话ID
|
||||
* @property int|null $msg_id 来源消息ID
|
||||
* @property int|null $userid 提及人
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read \App\Models\ProjectTask|null $relatedTask
|
||||
* @property-read \App\Models\ProjectTask|null $task
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereDialogId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereDirection($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereMsgId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereRelatedTaskId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereTaskId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereUpdatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereUserid($value)
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class ProjectTaskRelation extends AbstractModel
|
||||
{
|
||||
public const DIRECTION_MENTION = 'mention';
|
||||
public const DIRECTION_MENTIONED_BY = 'mentioned_by';
|
||||
|
||||
protected $fillable = [
|
||||
'task_id',
|
||||
'related_task_id',
|
||||
'direction',
|
||||
'dialog_id',
|
||||
'msg_id',
|
||||
'userid',
|
||||
];
|
||||
|
||||
public function task(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProjectTask::class, 'task_id');
|
||||
}
|
||||
|
||||
public function relatedTask(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProjectTask::class, 'related_task_id');
|
||||
}
|
||||
|
||||
public static function recordMentionsFromMessage(WebSocketDialogMsg $msg): void
|
||||
{
|
||||
if ($msg->type !== 'text') {
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = $msg->msg;
|
||||
if (!is_array($payload)) {
|
||||
$payload = Base::json2array($msg->getRawOriginal('msg'));
|
||||
}
|
||||
|
||||
$text = $payload['text'] ?? '';
|
||||
if (!$text || !preg_match_all('/<span class="mention task" data-id="(\d+)">#?(.*?)<\/span>/i', $text, $matches)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$targetIds = array_values(array_unique(array_filter(array_map('intval', $matches[1] ?? []))));
|
||||
if (empty($targetIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sourceTasks = ProjectTask::with('project')->whereDialogId($msg->dialog_id)->get();
|
||||
if ($sourceTasks->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$targetTasks = ProjectTask::with('project')->whereIn('id', $targetIds)->get()->keyBy('id');
|
||||
if ($targetTasks->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$pushTasks = [];
|
||||
foreach ($sourceTasks as $sourceTask) {
|
||||
foreach ($targetIds as $targetId) {
|
||||
if ($targetId === $sourceTask->id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$targetTask = $targetTasks->get($targetId);
|
||||
if (!$targetTask) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mentionRelation = static::updateOrCreate(
|
||||
[
|
||||
'task_id' => $sourceTask->id,
|
||||
'related_task_id' => $targetTask->id,
|
||||
'direction' => self::DIRECTION_MENTION,
|
||||
],
|
||||
[
|
||||
'dialog_id' => $msg->dialog_id,
|
||||
'msg_id' => $msg->id,
|
||||
'userid' => $msg->userid,
|
||||
]
|
||||
);
|
||||
|
||||
if ($mentionRelation->wasRecentlyCreated || $mentionRelation->wasChanged()) {
|
||||
$pushTasks[$sourceTask->id] = $sourceTask;
|
||||
}
|
||||
|
||||
$reverseRelation = static::updateOrCreate(
|
||||
[
|
||||
'task_id' => $targetTask->id,
|
||||
'related_task_id' => $sourceTask->id,
|
||||
'direction' => self::DIRECTION_MENTIONED_BY,
|
||||
],
|
||||
[
|
||||
'dialog_id' => $msg->dialog_id,
|
||||
'msg_id' => $msg->id,
|
||||
'userid' => $msg->userid,
|
||||
]
|
||||
);
|
||||
|
||||
if ($reverseRelation->wasRecentlyCreated || $reverseRelation->wasChanged()) {
|
||||
$pushTasks[$targetTask->id] = $targetTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($pushTasks as $task) {
|
||||
$task->loadMissing('project');
|
||||
if (!$task->project) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$task->pushMsg('relation', null, null, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,18 @@ class ProjectTaskUser extends AbstractModel
|
||||
$item->save();
|
||||
}
|
||||
if ($item->projectTask) {
|
||||
$item->projectTask->addLog("移交{任务}身份", ['userid' => [$originalUserid, ' => ', $newUserid]], 0, 1);
|
||||
$item->projectTask->addLog("移交{任务}身份", [
|
||||
'change' => [
|
||||
[
|
||||
'type' => 'user',
|
||||
'data' => $originalUserid,
|
||||
],
|
||||
[
|
||||
'type' => 'user',
|
||||
'data' => $newUserid,
|
||||
]
|
||||
],
|
||||
], 0, 1);
|
||||
if (!in_array($item->task_pid, $tastIds)) {
|
||||
$tastIds[] = $item->task_pid;
|
||||
$item->projectTask->syncDialogUser();
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Module\Base;
|
||||
* @property int|null $userid 成员ID
|
||||
* @property int|null $owner 是否负责人
|
||||
* @property \Illuminate\Support\Carbon|null $top_at 置顶时间
|
||||
* @property int|null $sort 排序(ASC)
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read \App\Models\Project|null $project
|
||||
@@ -28,6 +29,7 @@ use App\Module\Base;
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereOwner($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereProjectId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereSort($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereTopAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereUpdatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereUserid($value)
|
||||
@@ -74,7 +76,18 @@ class ProjectUser extends AbstractModel
|
||||
$item->project->name = "【{$name}】{$item->project->name}";
|
||||
$item->project->save();
|
||||
}
|
||||
$item->project->addLog("移交项目身份", ['userid' => [$originalUserid, ' => ', $newUserid]]);
|
||||
$item->project->addLog("移交项目身份", [
|
||||
'change' => [
|
||||
[
|
||||
'type' => 'user',
|
||||
'data' => $originalUserid
|
||||
],
|
||||
[
|
||||
'type' => 'user',
|
||||
'data' => $newUserid
|
||||
],
|
||||
],
|
||||
]);
|
||||
$item->project->syncDialogUser();
|
||||
$projectIds[] = $item->project_id;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ class Setting extends AbstractModel
|
||||
}
|
||||
$value = Base::json2array($value);
|
||||
switch ($this->name) {
|
||||
// 系统设置
|
||||
case 'system':
|
||||
$value['system_alias'] = $value['system_alias'] ?: env('APP_NAME');
|
||||
$value['image_compress'] = $value['image_compress'] ?: 'open';
|
||||
@@ -58,11 +59,21 @@ class Setting extends AbstractModel
|
||||
}
|
||||
break;
|
||||
|
||||
// 文件设置
|
||||
case 'fileSetting':
|
||||
$value['permission_pack_type'] = $value['permission_pack_type'] ?: 'all';
|
||||
$value['permission_pack_userids'] = is_array($value['permission_pack_userids']) ? $value['permission_pack_userids'] : [];
|
||||
break;
|
||||
|
||||
// AI 助手设置
|
||||
case 'aiSetting':
|
||||
$value['ai_provider'] = $value['ai_provider'] ?: 'openai';
|
||||
$value['ai_api_key'] = $value['ai_api_key'] ?: '';
|
||||
$value['ai_api_url'] = $value['ai_api_url'] ?: '';
|
||||
$value['ai_proxy'] = $value['ai_proxy'] ?: '';
|
||||
break;
|
||||
|
||||
// AI 机器人设置
|
||||
case 'aibotSetting':
|
||||
if ($value['claude_token'] && empty($value['claude_key'])) {
|
||||
$value['claude_key'] = $value['claude_token'];
|
||||
@@ -81,12 +92,12 @@ class Setting extends AbstractModel
|
||||
$content = array_filter($content);
|
||||
}
|
||||
if (empty($content)) {
|
||||
$content = self::AIDefaultModels($aiName);
|
||||
$content = self::AIBotDefaultModels($aiName);
|
||||
}
|
||||
$content = implode("\n", $content);
|
||||
break;
|
||||
case 'model':
|
||||
$models = Setting::AIModels2Array($array[$key . 's'], true);
|
||||
$models = Setting::AIBotModels2Array($array[$key . 's'], true);
|
||||
$content = in_array($content, $models) ? $content : ($models[0] ?? '');
|
||||
break;
|
||||
case 'temperature':
|
||||
@@ -105,22 +116,20 @@ class Setting extends AbstractModel
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否开启AI
|
||||
* @param $ai
|
||||
* 是否开启 AI 助理
|
||||
* @return bool
|
||||
*/
|
||||
public static function AIOpen($ai = 'openai')
|
||||
public static function AIOpen()
|
||||
{
|
||||
$array = Base::setting('aibotSetting');
|
||||
return !!$array[$ai . '_key'];
|
||||
return !!Base::settingFind('aiSetting', 'ai_api_key');
|
||||
}
|
||||
|
||||
/**
|
||||
* AI默认模型
|
||||
* AI 机器人默认模型
|
||||
* @param string $ai
|
||||
* @return array
|
||||
*/
|
||||
public static function AIDefaultModels($ai = 'openai')
|
||||
public static function AIBotDefaultModels($ai = 'openai')
|
||||
{
|
||||
return match ($ai) {
|
||||
'openai' => [
|
||||
@@ -205,12 +214,12 @@ class Setting extends AbstractModel
|
||||
}
|
||||
|
||||
/**
|
||||
* AI模型转数组
|
||||
* AI 机器人模型转数组
|
||||
* @param $models
|
||||
* @param bool $retValue
|
||||
* @return array
|
||||
*/
|
||||
public static function AIModels2Array($models, $retValue = false)
|
||||
public static function AIBotModels2Array($models, $retValue = false)
|
||||
{
|
||||
$list = is_array($models) ? $models : explode("\n", $models);
|
||||
$array = [];
|
||||
|
||||
@@ -70,6 +70,9 @@ class UmengAlias extends AbstractModel
|
||||
return;
|
||||
}
|
||||
|
||||
$instance = null;
|
||||
$responsePayload = null;
|
||||
|
||||
try {
|
||||
switch ($first['platform']) {
|
||||
case 'ios':
|
||||
@@ -81,8 +84,11 @@ class UmengAlias extends AbstractModel
|
||||
default:
|
||||
return;
|
||||
}
|
||||
$instance->send($first['data']);
|
||||
$responsePayload = $instance->send($first['data']);
|
||||
} catch (\Exception $e) {
|
||||
$responsePayload = [
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
$first['retry'] = intval($first['retry'] ?? 0) + 1;
|
||||
if ($first['retry'] > 3) {
|
||||
info("[PushMsg] fail: " . $e->getMessage());
|
||||
@@ -91,6 +97,12 @@ class UmengAlias extends AbstractModel
|
||||
self::$waitSend[] = $first;
|
||||
}
|
||||
} finally {
|
||||
if ($instance !== null) {
|
||||
UmengLog::create([
|
||||
'request' => Base::array2json($first['data']),
|
||||
'response' => Base::array2json($responsePayload),
|
||||
]);
|
||||
}
|
||||
self::sendTask();
|
||||
}
|
||||
}
|
||||
@@ -153,7 +165,7 @@ class UmengAlias extends AbstractModel
|
||||
$description = $array['description'] ?: 'no description'; // 描述
|
||||
$extra = is_array($array['extra']) ? $array['extra'] : []; // 额外参数
|
||||
$seconds = intval($array['seconds']) ?: 86400; // 有效时间(单位:秒)
|
||||
$badge = intval($array['badge']) ?: 0; // 角标数(iOS)
|
||||
$badge = intval($array['badge']) ?: 0; // 角标数
|
||||
//
|
||||
switch ($platform) {
|
||||
case 'ios':
|
||||
@@ -203,6 +215,7 @@ class UmengAlias extends AbstractModel
|
||||
'title' => $title,
|
||||
'after_open' => 'go_app',
|
||||
'play_sound' => true,
|
||||
'set_badge' => min(99, $badge),
|
||||
],
|
||||
], $extra),
|
||||
'type' => 'customizedcast',
|
||||
@@ -213,13 +226,19 @@ class UmengAlias extends AbstractModel
|
||||
'policy' => [
|
||||
'expire_time' => Carbon::now()->addSeconds($seconds)->toDateTimeString(),
|
||||
],
|
||||
'category' => 1,
|
||||
'channel_properties' => [
|
||||
'main_activity' => 'com.dootask.task.WelcomeActivity',
|
||||
'oppo_channel_id' => 'dootask',
|
||||
'vivo_category' => 'IM',
|
||||
'huawei_channel_importance' => 'NORMAL',
|
||||
'huawei_channel_category' => 'IM',
|
||||
'channel_fcm' => 0,
|
||||
],
|
||||
'local_properties' => [
|
||||
'importance' => 'IMPORTANCE_DEFAULT',
|
||||
'category' => 'CATEGORY_MESSAGE',
|
||||
]
|
||||
]
|
||||
]);
|
||||
break;
|
||||
|
||||
32
app/Models/UmengLog.php
Normal file
32
app/Models/UmengLog.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
/**
|
||||
* App\Models\UmengLog
|
||||
*
|
||||
* @property int $id
|
||||
* @property string|null $request 请求参数
|
||||
* @property string|null $response 推送返回
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog whereRequest($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog whereResponse($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog whereUpdatedAt($value)
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class UmengLog extends AbstractModel
|
||||
{
|
||||
protected $guarded = [];
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Module\Base;
|
||||
use App\Module\Doo;
|
||||
@@ -15,15 +14,15 @@ use Carbon\Carbon;
|
||||
* App\Models\User
|
||||
*
|
||||
* @property int $userid
|
||||
* @property array $identity
|
||||
* @property array $department
|
||||
* @property array $identity 身份
|
||||
* @property array $department 所属部门
|
||||
* @property string|null $az A-Z
|
||||
* @property string|null $pinyin 拼音(主要用于搜索)
|
||||
* @property string|null $email
|
||||
* @property string|null $email 邮箱
|
||||
* @property string|null $tel 联系电话
|
||||
* @property string $nickname
|
||||
* @property string|null $profession
|
||||
* @property string $userimg
|
||||
* @property string $nickname 昵称
|
||||
* @property string|null $profession 职位/职称
|
||||
* @property string $userimg 头像
|
||||
* @property string|null $encrypt
|
||||
* @property string|null $password 登录密码
|
||||
* @property int|null $changepass 登录需要修改密码
|
||||
@@ -34,7 +33,7 @@ use Carbon\Carbon;
|
||||
* @property \Illuminate\Support\Carbon|null $line_at 最后在线时间(接口)
|
||||
* @property int|null $task_dialog_id 最后打开的任务会话ID
|
||||
* @property string|null $created_ip 注册IP
|
||||
* @property \Illuminate\Support\Carbon|null $disable_at
|
||||
* @property \Illuminate\Support\Carbon|null $disable_at 禁用时间(离职时间)
|
||||
* @property int|null $email_verity 邮箱是否已验证
|
||||
* @property int|null $bot 是否机器人
|
||||
* @property string|null $lang 语言首选项
|
||||
@@ -173,10 +172,9 @@ class User extends AbstractModel
|
||||
return UserDepartment::where('owner_userid', $this->userid)->exists();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取机器人所有者
|
||||
* @return int|mixed
|
||||
* @return int
|
||||
*/
|
||||
public function getBotOwner()
|
||||
{
|
||||
@@ -184,9 +182,9 @@ class User extends AbstractModel
|
||||
return 0;
|
||||
}
|
||||
$key = "userBotOwner::" . $this->userid;
|
||||
return Cache::remember($key, now()->addMonth(), function() {
|
||||
return intval(Cache::remember($key, now()->addMonth(), function() {
|
||||
return intval(UserBot::whereBotId($this->userid)->value('userid')) ?: $this->userid;
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -537,6 +535,22 @@ class User extends AbstractModel
|
||||
return $userinfo->token = $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成无设备的 token(主要用于接口调用,此 token 不检查设备是否存在)
|
||||
* @param self $userinfo
|
||||
* @param $ttl
|
||||
* @return mixed
|
||||
*/
|
||||
public static function generateTokenNoDevice($userinfo, $ttl)
|
||||
{
|
||||
$key = 'user_token_no_device_' . $userinfo->userid;
|
||||
return Cache::remember($key, $ttl, function () use ($userinfo, $ttl) {
|
||||
$token = Doo::tokenEncode($userinfo->userid, $userinfo->email, $userinfo->encrypt);
|
||||
Cache::put(UserDevice::ck(md5($token)), $userinfo->userid, $ttl);
|
||||
return $token;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* userid 获取 基础信息
|
||||
* @param int $userid 会员ID
|
||||
@@ -723,11 +737,11 @@ class User extends AbstractModel
|
||||
}
|
||||
}
|
||||
if ($update) {
|
||||
$botUser->updateInstance($update);
|
||||
if (isset($update['nickname'])) {
|
||||
if (isset($update['nickname']) && $botUser->nickname != $update['nickname']) {
|
||||
$botUser->az = Base::getFirstCharter($botUser->nickname);
|
||||
$botUser->pinyin = Base::cn2pinyin($botUser->nickname);
|
||||
}
|
||||
$botUser->updateInstance($update);
|
||||
$botUser->save();
|
||||
}
|
||||
return $botUser;
|
||||
@@ -736,18 +750,17 @@ class User extends AbstractModel
|
||||
/**
|
||||
* 是否机器人
|
||||
* @param $userid
|
||||
* @return bool|mixed
|
||||
* @return bool
|
||||
*/
|
||||
public static function isBot($userid)
|
||||
{
|
||||
if (empty($userid)) {
|
||||
return false;
|
||||
}
|
||||
$userid = intval($userid);
|
||||
if (RequestContext::has("isBot_" . $userid)) {
|
||||
return RequestContext::get("isBot_" . $userid);
|
||||
}
|
||||
return (bool)User::find($userid)?->bot;
|
||||
// 这个不会有变化,所以可以使用永久缓存
|
||||
return (bool)Cache::rememberForever('is-bot-user-' . $userid, function () use ($userid) {
|
||||
return (bool)User::find($userid)?->bot;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -97,39 +97,33 @@ class UserBot extends AbstractModel
|
||||
{
|
||||
switch ($email) {
|
||||
case 'check-in@bot.system':
|
||||
$menu = [
|
||||
/*[
|
||||
'key' => 'it',
|
||||
'label' => Doo::translate('IT资讯')
|
||||
], [
|
||||
'key' => '36ke',
|
||||
'label' => Doo::translate('36氪')
|
||||
], [
|
||||
'key' => '60s',
|
||||
'label' => Doo::translate('60s读世界')
|
||||
], [
|
||||
'key' => 'joke',
|
||||
'label' => Doo::translate('开心笑话')
|
||||
], [
|
||||
'key' => 'soup',
|
||||
'label' => Doo::translate('心灵鸡汤')
|
||||
]*/
|
||||
];
|
||||
$menu = [];
|
||||
$setting = Base::setting('checkinSetting');
|
||||
if ($setting['open'] !== 'open') {
|
||||
return $menu;
|
||||
}
|
||||
if (in_array('locat', $setting['modes']) && Base::isEEUIApp()) {
|
||||
$menu[] = [
|
||||
'key' => 'locat-checkin',
|
||||
'label' => Doo::translate('定位签到'),
|
||||
'config' => [
|
||||
'key' => $setting['locat_bd_lbs_key'],
|
||||
'lng' => $setting['locat_bd_lbs_point']['lng'],
|
||||
'lat' => $setting['locat_bd_lbs_point']['lat'],
|
||||
'radius' => $setting['locat_bd_lbs_point']['radius'],
|
||||
]
|
||||
$mapTypes = [
|
||||
'baidu' => ['key' => 'locat_bd_lbs_key', 'point' => 'locat_bd_lbs_point', 'msg' => '请填写百度地图AK'],
|
||||
'amap' => ['key' => 'locat_amap_key', 'point' => 'locat_amap_point', 'msg' => '请填写高德地图Key'],
|
||||
'tencent' => ['key' => 'locat_tencent_key', 'point' => 'locat_tencent_point', 'msg' => '请填写腾讯地图Key'],
|
||||
];
|
||||
$type = $setting['locat_map_type'];
|
||||
if (isset($mapTypes[$type])) {
|
||||
$conf = $mapTypes[$type];
|
||||
$point = $setting[$conf['point']];
|
||||
$menu[] = [
|
||||
'key' => 'locat-checkin',
|
||||
'label' => Doo::translate('定位签到'),
|
||||
'config' => [
|
||||
'type' => $type,
|
||||
'key' => $setting[$conf['key']],
|
||||
'lng' => $point['lng'],
|
||||
'lat' => $point['lat'],
|
||||
'radius' => intval($point['radius']),
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
if (in_array('manual', $setting['modes'])) {
|
||||
$menu[] = [
|
||||
@@ -180,28 +174,8 @@ class UserBot extends AbstractModel
|
||||
];
|
||||
|
||||
default:
|
||||
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
|
||||
]
|
||||
],
|
||||
if (preg_match('/^(ai-|user-session-)(.*?)@bot\.system$/', $email, $match)) {
|
||||
$menus = [
|
||||
[
|
||||
'key' => '~ai-session-create',
|
||||
'label' => Doo::translate('开启新会话'),
|
||||
@@ -211,6 +185,27 @@ class UserBot extends AbstractModel
|
||||
'label' => Doo::translate('历史会话'),
|
||||
]
|
||||
];
|
||||
if ($match[1] === "ai-") {
|
||||
$aibotSetting = Base::setting('aibotSetting');
|
||||
$aibotModel = $aibotSetting[$match[2] . '_model'];
|
||||
$aibotModels = Setting::AIBotModels2Array($aibotSetting[$match[2] . '_models']);
|
||||
if ($aibotModels) {
|
||||
$menus = array_merge(
|
||||
[
|
||||
[
|
||||
'key' => '~ai-model-select',
|
||||
'label' => Doo::translate('选择模型'),
|
||||
'config' => [
|
||||
'model' => $aibotModel,
|
||||
'models' => $aibotModels
|
||||
]
|
||||
]
|
||||
],
|
||||
$menus
|
||||
);
|
||||
}
|
||||
}
|
||||
return $menus;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -239,7 +234,6 @@ class UserBot extends AbstractModel
|
||||
return '暂未开放手动签到。';
|
||||
}
|
||||
UserBot::checkinBotCheckin('manual-' . $userid, Timer::time(), true);
|
||||
return null;
|
||||
} elseif ($command === 'locat-checkin') {
|
||||
$setting = Base::setting('checkinSetting');
|
||||
if ($setting['open'] !== 'open') {
|
||||
@@ -251,16 +245,14 @@ class UserBot extends AbstractModel
|
||||
if (empty($extra)) {
|
||||
return '当前客户端版本低(所需版本≥v0.39.75)。';
|
||||
}
|
||||
if ($extra['type'] === 'bd') {
|
||||
if (in_array($extra['type'], ['baidu', 'amap', 'tencent'])) {
|
||||
// todo 判断距离
|
||||
} else {
|
||||
return '错误的定位签到。';
|
||||
}
|
||||
UserBot::checkinBotCheckin('locat-' . $userid, Timer::time(), true);
|
||||
return null;
|
||||
} else {
|
||||
return Extranet::checkinBotQuickMsg($command);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -445,11 +437,12 @@ class UserBot extends AbstractModel
|
||||
|
||||
/**
|
||||
* 创建我的机器人
|
||||
* @param $userid
|
||||
* @param $botName
|
||||
* @param int $userid 创建人userid
|
||||
* @param string $botName 机器人名称
|
||||
* @param bool $sessionSupported 是否支持会话
|
||||
* @return array
|
||||
*/
|
||||
public static function newbot($userid, $botName)
|
||||
public static function newBot($userid, $botName, $sessionSupported = false)
|
||||
{
|
||||
if (User::select(['users.*'])
|
||||
->join('user_bots', 'users.userid', '=', 'user_bots.bot_id')
|
||||
@@ -461,7 +454,8 @@ class UserBot extends AbstractModel
|
||||
if (strlen($botName) < 2 || strlen($botName) > 20) {
|
||||
return Base::retError("机器人名称由2-20个字符组成。");
|
||||
}
|
||||
$data = User::botGetOrCreate("user-" . Base::generatePassword(), [
|
||||
$botType = ($sessionSupported ? "user-session-" : "user-normal-") . Base::generatePassword();
|
||||
$data = User::botGetOrCreate($botType, [
|
||||
'nickname' => $botName
|
||||
], $userid);
|
||||
if (empty($data)) {
|
||||
@@ -469,6 +463,14 @@ class UserBot extends AbstractModel
|
||||
}
|
||||
$dialog = WebSocketDialog::checkUserDialog($data, $userid);
|
||||
if ($dialog) {
|
||||
if ($sessionSupported) {
|
||||
$dialogSession = WebSocketDialogSession::create([
|
||||
'dialog_id' => $dialog->id,
|
||||
]);
|
||||
$dialogSession->save();
|
||||
$dialog->session_id = $dialogSession->id;
|
||||
$dialog->save();
|
||||
}
|
||||
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
|
||||
'type' => '/hello',
|
||||
'title' => '创建成功。',
|
||||
|
||||
@@ -13,7 +13,7 @@ use App\Module\Base;
|
||||
* @property int|null $userid 用户id
|
||||
* @property string|null $email 邮箱帐号
|
||||
* @property string|null $reason 注销原因
|
||||
* @property string $cache 会员资料缓存
|
||||
* @property string|null $cache 会员资料缓存
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use Cache;
|
||||
|
||||
/**
|
||||
* App\Models\UserDepartment
|
||||
@@ -168,4 +169,67 @@ class UserDepartment extends AbstractModel
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归获取所有子部门ID
|
||||
* @param int $departmentId
|
||||
* @return array
|
||||
*/
|
||||
public static function getAllSubDepartmentIds($departmentId)
|
||||
{
|
||||
$subIds = [];
|
||||
$directSubs = self::whereParentId($departmentId)->pluck('id')->toArray();
|
||||
|
||||
foreach ($directSubs as $subId) {
|
||||
$subIds[] = $subId;
|
||||
// 递归获取子部门的子部门
|
||||
$subSubIds = self::getAllSubDepartmentIds($subId);
|
||||
$subIds = array_merge($subIds, $subSubIds);
|
||||
}
|
||||
|
||||
return array_unique($subIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取部门基本信息(缓存时间1小时)
|
||||
* @param int|array $ids
|
||||
* @return \Illuminate\Support\Collection|static|null
|
||||
*/
|
||||
public static function getDepartmentsByIds($ids)
|
||||
{
|
||||
$ids = is_array($ids) ? $ids : [$ids];
|
||||
$departments = collect();
|
||||
$uncachedIds = [];
|
||||
|
||||
foreach ($ids as $id) {
|
||||
$cacheKey = "department_info_{$id}";
|
||||
$department = Cache::get($cacheKey);
|
||||
if ($department) {
|
||||
$departments->push($department);
|
||||
} else {
|
||||
$uncachedIds[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($uncachedIds)) {
|
||||
$dbDepartments = self::select(['id', 'name', 'parent_id', 'owner_userid'])->whereIn('id', $uncachedIds)->get();
|
||||
foreach ($dbDepartments as $department) {
|
||||
$cacheKey = "department_info_{$department->id}";
|
||||
Cache::put($cacheKey, $department, 60 * 60); // 1小时
|
||||
$departments->push($department);
|
||||
}
|
||||
}
|
||||
|
||||
// 保持返回顺序与传入ids一致
|
||||
$departments = $departments->keyBy('id');
|
||||
$result = collect();
|
||||
foreach ($ids as $id) {
|
||||
if ($departments->has($id)) {
|
||||
$result->push($departments->get($id));
|
||||
}
|
||||
}
|
||||
|
||||
return is_array($ids) ? $result : $result->first();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Models;
|
||||
|
||||
use App\Module\Base;
|
||||
use App\Module\Doo;
|
||||
use App\Module\Lock;
|
||||
use Cache;
|
||||
use Carbon\Carbon;
|
||||
use DeviceDetector\DeviceDetector;
|
||||
@@ -78,7 +79,7 @@ class UserDevice extends AbstractModel
|
||||
* @param string $hash
|
||||
* @return string
|
||||
*/
|
||||
private static function ck(string $hash): string
|
||||
public static function ck(string $hash): string
|
||||
{
|
||||
return "user_devices:{$hash}";
|
||||
}
|
||||
@@ -218,13 +219,8 @@ class UserDevice extends AbstractModel
|
||||
self::record();
|
||||
return $hash;
|
||||
}
|
||||
// 没有记录,尝试创建一个(防止升级后所有登录都失效,保证留一个可以保持登录) // todo 后期删除
|
||||
return AbstractModel::transaction(function () use ($hash, $userid) {
|
||||
if (self::whereUserid($userid)->withoutTrashed()->lockForUpdate()->exists()) {
|
||||
return null;
|
||||
}
|
||||
return self::record() ? $hash : null;
|
||||
});
|
||||
// 没有记录
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,46 +230,48 @@ class UserDevice extends AbstractModel
|
||||
*/
|
||||
public static function record(string $token = null): ?self
|
||||
{
|
||||
return AbstractModel::transaction(function () use ($token) {
|
||||
if (empty($token)) {
|
||||
$token = Doo::userToken();
|
||||
$userid = Doo::userId();
|
||||
$expiredAt = Doo::userExpiredAt();
|
||||
} else {
|
||||
$info = Doo::tokenDecode($token);
|
||||
$userid = $info['userid'] ?? 0;
|
||||
$expiredAt = $info['expired_at'];
|
||||
}
|
||||
|
||||
$hash = md5($token);
|
||||
$row = self::whereHash($hash)->lockForUpdate()->first();
|
||||
if (empty($row)) {
|
||||
// 生成一个新的设备记录
|
||||
$row = self::createInstance([
|
||||
'userid' => $userid,
|
||||
'hash' => $hash,
|
||||
]);
|
||||
if (!$row->save()) {
|
||||
return null;
|
||||
}
|
||||
// 删除多余的设备记录
|
||||
$currentDeviceCount = self::whereUserid($userid)->count();
|
||||
if ($currentDeviceCount > self::$deviceLimit) {
|
||||
$rows = self::whereUserid($userid)->orderBy('id')->take($currentDeviceCount - self::$deviceLimit)->get();
|
||||
foreach ($rows as $row) {
|
||||
UserDevice::forget($row);
|
||||
if (empty($token)) {
|
||||
$token = Doo::userToken();
|
||||
$userid = Doo::userId();
|
||||
$expiredAt = Doo::userExpiredAt();
|
||||
} else {
|
||||
$info = Doo::tokenDecode($token);
|
||||
$userid = $info['userid'] ?? 0;
|
||||
$expiredAt = $info['expired_at'];
|
||||
}
|
||||
$hash = md5($token);
|
||||
//
|
||||
return Lock::withLock("userDeviceRecord:{$hash}", function () use ($expiredAt, $userid, $hash, $token) {
|
||||
return AbstractModel::transaction(function () use ($expiredAt, $userid, $hash, $token) {
|
||||
$row = self::whereHash($hash)->first();
|
||||
if (empty($row)) {
|
||||
// 生成一个新的设备记录
|
||||
$row = self::createInstance([
|
||||
'userid' => $userid,
|
||||
'hash' => $hash,
|
||||
]);
|
||||
if (!$row->save()) {
|
||||
return null;
|
||||
}
|
||||
// 删除多余的设备记录
|
||||
$currentDeviceCount = self::whereUserid($userid)->count();
|
||||
if ($currentDeviceCount > self::$deviceLimit) {
|
||||
$rows = self::whereUserid($userid)->orderBy('id')->take($currentDeviceCount - self::$deviceLimit)->get();
|
||||
foreach ($rows as $row) {
|
||||
UserDevice::forget($row);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$row->expired_at = $expiredAt;
|
||||
if (Request::hasHeader('version')) {
|
||||
$deviceInfo = array_merge(Base::json2array($row->detail), self::getDeviceInfo($_SERVER['HTTP_USER_AGENT'] ?? ''));
|
||||
$row->detail = Base::array2json($deviceInfo);
|
||||
}
|
||||
$row->save();
|
||||
$row->expired_at = $expiredAt;
|
||||
if (Request::hasHeader('version')) {
|
||||
$deviceInfo = array_merge(Base::json2array($row->detail), self::getDeviceInfo($_SERVER['HTTP_USER_AGENT'] ?? ''));
|
||||
$row->detail = Base::array2json($deviceInfo);
|
||||
}
|
||||
$row->save();
|
||||
|
||||
Cache::put(self::ck($hash), $row->userid, now()->addHour());
|
||||
return $row;
|
||||
Cache::put(self::ck($hash), $row->userid, now()->addHour());
|
||||
return $row;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
340
app/Models/UserFavorite.php
Normal file
340
app/Models/UserFavorite.php
Normal file
@@ -0,0 +1,340 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use App\Models\File;
|
||||
|
||||
/**
|
||||
* App\Models\UserFavorite
|
||||
*
|
||||
* @property int $id
|
||||
* @property int|null $userid 用户ID
|
||||
* @property string|null $favoritable_type 收藏类型(比如:task/project/file/message)
|
||||
* @property int|null $favoritable_id 收藏对象ID
|
||||
* @property string $remark 收藏备注
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read \Illuminate\Database\Eloquent\Model|\Eloquent $favoritable
|
||||
* @property-read \App\Models\User|null $user
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereFavoritableId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereFavoritableType($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereRemark($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereUpdatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereUserid($value)
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class UserFavorite extends AbstractModel
|
||||
{
|
||||
const TYPE_TASK = 'task';
|
||||
const TYPE_PROJECT = 'project';
|
||||
const TYPE_FILE = 'file';
|
||||
const TYPE_MESSAGE = 'message';
|
||||
|
||||
protected $fillable = [
|
||||
'userid',
|
||||
'favoritable_type',
|
||||
'favoritable_id',
|
||||
'remark',
|
||||
];
|
||||
|
||||
/**
|
||||
* 关联用户
|
||||
*/
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'userid', 'userid');
|
||||
}
|
||||
|
||||
/**
|
||||
* 多态关联
|
||||
*/
|
||||
public function favoritable()
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换收藏状态
|
||||
* @param int $userid 用户ID
|
||||
* @param string $type 收藏类型
|
||||
* @param int $id 收藏对象ID
|
||||
* @return array ['favorited' => bool, 'action' => 'added'|'removed']
|
||||
*/
|
||||
public static function toggleFavorite($userid, $type, $id)
|
||||
{
|
||||
$favorite = self::whereUserid($userid)
|
||||
->whereFavoritableType($type)
|
||||
->whereFavoritableId($id)
|
||||
->first();
|
||||
|
||||
if ($favorite) {
|
||||
// 取消收藏
|
||||
$favorite->delete();
|
||||
return ['favorited' => false, 'action' => 'removed', 'remark' => ''];
|
||||
}
|
||||
|
||||
// 添加收藏
|
||||
$favorite = self::create([
|
||||
'userid' => $userid,
|
||||
'favoritable_type' => $type,
|
||||
'favoritable_id' => $id,
|
||||
]);
|
||||
|
||||
return ['favorited' => true, 'action' => 'added', 'remark' => $favorite->remark ?? ''];
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新收藏备注
|
||||
* @param int $userid
|
||||
* @param string $type
|
||||
* @param int $id
|
||||
* @param string $remark
|
||||
* @return static|null
|
||||
*/
|
||||
public static function updateRemark($userid, $type, $id, $remark)
|
||||
{
|
||||
$favorite = self::whereUserid($userid)
|
||||
->whereFavoritableType($type)
|
||||
->whereFavoritableId($id)
|
||||
->first();
|
||||
|
||||
if (!$favorite) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$favorite->remark = $remark;
|
||||
$favorite->save();
|
||||
|
||||
return $favorite;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已收藏
|
||||
* @param int $userid 用户ID
|
||||
* @param string $type 收藏类型
|
||||
* @param int $id 收藏对象ID
|
||||
* @return bool
|
||||
*/
|
||||
public static function isFavorited($userid, $type, $id)
|
||||
{
|
||||
return self::whereUserid($userid)
|
||||
->whereFavoritableType($type)
|
||||
->whereFavoritableId($id)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户收藏列表
|
||||
* @param int $userid 用户ID
|
||||
* @param string|null $type 收藏类型过滤
|
||||
* @param int $page 页码
|
||||
* @param int $pageSize 每页数量
|
||||
* @return array
|
||||
*/
|
||||
public static function getUserFavorites($userid, $type = null, $page = 1, $pageSize = 20)
|
||||
{
|
||||
$query = self::whereUserid($userid)->orderByDesc('created_at');
|
||||
|
||||
if ($type) {
|
||||
$query->whereFavoritableType($type);
|
||||
}
|
||||
|
||||
$favorites = $query->paginate($pageSize, ['*'], 'page', $page);
|
||||
|
||||
$data = [
|
||||
'tasks' => [],
|
||||
'projects' => [],
|
||||
'files' => [],
|
||||
'messages' => []
|
||||
];
|
||||
|
||||
// 分组收集ID
|
||||
$taskIds = [];
|
||||
$projectIds = [];
|
||||
$fileIds = [];
|
||||
$messageIds = [];
|
||||
|
||||
foreach ($favorites->items() as $favorite) {
|
||||
switch ($favorite->favoritable_type) {
|
||||
case self::TYPE_TASK:
|
||||
$taskIds[] = $favorite->favoritable_id;
|
||||
break;
|
||||
case self::TYPE_PROJECT:
|
||||
$projectIds[] = $favorite->favoritable_id;
|
||||
break;
|
||||
case self::TYPE_FILE:
|
||||
$fileIds[] = $favorite->favoritable_id;
|
||||
break;
|
||||
case self::TYPE_MESSAGE:
|
||||
$messageIds[] = $favorite->favoritable_id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 批量查询具体数据
|
||||
if (!empty($taskIds)) {
|
||||
$tasks = ProjectTask::select([
|
||||
'project_tasks.id',
|
||||
'project_tasks.name',
|
||||
'project_tasks.project_id',
|
||||
'project_tasks.complete_at',
|
||||
'project_tasks.created_at',
|
||||
'project_tasks.flow_item_id',
|
||||
'project_tasks.flow_item_name',
|
||||
'projects.name as project_name'
|
||||
])
|
||||
->leftJoin('projects', 'project_tasks.project_id', '=', 'projects.id')
|
||||
->whereIn('project_tasks.id', $taskIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
foreach ($favorites->items() as $favorite) {
|
||||
if ($favorite->favoritable_type === self::TYPE_TASK && isset($tasks[$favorite->favoritable_id])) {
|
||||
$task = $tasks[$favorite->favoritable_id];
|
||||
|
||||
// 解析 flow_item_name 字段(格式:status|name|color)
|
||||
$flowItemParts = explode('|', $task->flow_item_name ?: '');
|
||||
$flowItemStatus = $flowItemParts[0] ?? '';
|
||||
$flowItemName = $flowItemParts[1] ?? $task->flow_item_name;
|
||||
$flowItemColor = $flowItemParts[2] ?? '';
|
||||
|
||||
$data['tasks'][] = [
|
||||
'id' => $task->id,
|
||||
'name' => $task->name,
|
||||
'project_id' => $task->project_id,
|
||||
'project_name' => $task->project_name,
|
||||
'complete_at' => $task->complete_at,
|
||||
'flow_item_id' => $task->flow_item_id,
|
||||
'flow_item_name' => $flowItemName,
|
||||
'flow_item_status' => $flowItemStatus,
|
||||
'flow_item_color' => $flowItemColor,
|
||||
'favorited_at' => Carbon::parse($favorite->created_at)->format('Y-m-d H:i:s'),
|
||||
'remark' => $favorite->remark,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($projectIds)) {
|
||||
$projects = Project::select([
|
||||
'id', 'name', 'desc', 'archived_at', 'created_at'
|
||||
])->whereIn('id', $projectIds)->get()->keyBy('id');
|
||||
|
||||
foreach ($favorites->items() as $favorite) {
|
||||
if ($favorite->favoritable_type === self::TYPE_PROJECT && isset($projects[$favorite->favoritable_id])) {
|
||||
$project = $projects[$favorite->favoritable_id];
|
||||
$data['projects'][] = [
|
||||
'id' => $project->id,
|
||||
'name' => $project->name,
|
||||
'desc' => $project->desc,
|
||||
'archived_at' => $project->archived_at,
|
||||
'favorited_at' => Carbon::parse($favorite->created_at)->format('Y-m-d H:i:s'),
|
||||
'remark' => $favorite->remark,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($fileIds)) {
|
||||
$files = File::select([
|
||||
'id', 'name', 'ext', 'size', 'pid', 'created_at'
|
||||
])->whereIn('id', $fileIds)->get()->keyBy('id');
|
||||
|
||||
foreach ($favorites->items() as $favorite) {
|
||||
if ($favorite->favoritable_type === self::TYPE_FILE && isset($files[$favorite->favoritable_id])) {
|
||||
$file = $files[$favorite->favoritable_id];
|
||||
$fileData = File::handleImageUrl(array_merge(
|
||||
$file->only(['id', 'ext']),
|
||||
[
|
||||
'name' => $file->name,
|
||||
'size' => $file->size,
|
||||
'pid' => $file->pid,
|
||||
]
|
||||
));
|
||||
$data['files'][] = [
|
||||
'id' => $file->id,
|
||||
'name' => $file->name,
|
||||
'ext' => $file->ext,
|
||||
'size' => $file->size,
|
||||
'pid' => $file->pid,
|
||||
'image_url' => $fileData['image_url'] ?? null,
|
||||
'image_width' => $fileData['image_width'] ?? null,
|
||||
'image_height' => $fileData['image_height'] ?? null,
|
||||
'favorited_at' => Carbon::parse($favorite->created_at)->format('Y-m-d H:i:s'),
|
||||
'remark' => $favorite->remark,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($messageIds)) {
|
||||
$messages = WebSocketDialogMsg::select([
|
||||
'id', 'dialog_id', 'userid', 'type', 'msg', 'created_at'
|
||||
])->whereIn('id', $messageIds)->get()->keyBy('id');
|
||||
|
||||
foreach ($favorites->items() as $favorite) {
|
||||
if ($favorite->favoritable_type === self::TYPE_MESSAGE && isset($messages[$favorite->favoritable_id])) {
|
||||
$message = $messages[$favorite->favoritable_id];
|
||||
|
||||
// 使用 previewMsg 获取消息预览文本
|
||||
$previewText = '';
|
||||
if ($message->msg && is_array($message->msg)) {
|
||||
$previewText = WebSocketDialogMsg::previewMsg($message);
|
||||
}
|
||||
|
||||
// 如果没有预览文本,使用消息类型作为标题
|
||||
if (empty($previewText)) {
|
||||
$previewText = '[' . ucfirst($message->type) . ']';
|
||||
}
|
||||
|
||||
$data['messages'][] = [
|
||||
'id' => $message->id,
|
||||
'name' => $previewText,
|
||||
'dialog_id' => $message->dialog_id,
|
||||
'userid' => $message->userid,
|
||||
'type' => $message->type,
|
||||
'favorited_at' => Carbon::parse($favorite->created_at)->format('Y-m-d H:i:s'),
|
||||
'remark' => $favorite->remark,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'data' => $data,
|
||||
'total' => $favorites->total(),
|
||||
'current_page' => $favorites->currentPage(),
|
||||
'per_page' => $favorites->perPage(),
|
||||
'last_page' => $favorites->lastPage(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理用户收藏
|
||||
* @param int $userid 用户ID
|
||||
* @param string|null $type 收藏类型,null表示全部类型
|
||||
* @return int 删除的记录数
|
||||
*/
|
||||
public static function cleanUserFavorites($userid, $type = null)
|
||||
{
|
||||
$query = self::whereUserid($userid);
|
||||
|
||||
if ($type) {
|
||||
$query->whereFavoritableType($type);
|
||||
}
|
||||
|
||||
return $query->delete();
|
||||
}
|
||||
}
|
||||
79
app/Models/UserRecentItem.php
Normal file
79
app/Models/UserRecentItem.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* App\Models\UserRecentItem
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $userid 用户ID
|
||||
* @property string $target_type 目标类型(task/file/task_file/message_file 等)
|
||||
* @property int $target_id 目标ID
|
||||
* @property string $source_type 来源类型(project/filesystem/project_task/dialog 等)
|
||||
* @property int $source_id 来源ID
|
||||
* @property \Illuminate\Support\Carbon|null $browsed_at 浏览时间
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereBrowsedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereSourceId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereSourceType($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereTargetId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereTargetType($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereUpdatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereUserid($value)
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class UserRecentItem extends AbstractModel
|
||||
{
|
||||
public const TYPE_TASK = 'task';
|
||||
public const TYPE_FILE = 'file';
|
||||
public const TYPE_TASK_FILE = 'task_file';
|
||||
public const TYPE_MESSAGE_FILE = 'message_file';
|
||||
|
||||
public const SOURCE_PROJECT = 'project';
|
||||
public const SOURCE_FILESYSTEM = 'filesystem';
|
||||
public const SOURCE_PROJECT_TASK = 'project_task';
|
||||
public const SOURCE_DIALOG = 'dialog';
|
||||
|
||||
protected $fillable = [
|
||||
'userid',
|
||||
'target_type',
|
||||
'target_id',
|
||||
'source_type',
|
||||
'source_id',
|
||||
'browsed_at',
|
||||
];
|
||||
|
||||
protected $dates = [
|
||||
'browsed_at',
|
||||
];
|
||||
|
||||
public static function record(int $userid, string $targetType, int $targetId, string $sourceType = '', int $sourceId = 0): self
|
||||
{
|
||||
return self::updateOrCreate(
|
||||
[
|
||||
'userid' => $userid,
|
||||
'target_type' => $targetType,
|
||||
'target_id' => $targetId,
|
||||
'source_type' => $sourceType,
|
||||
'source_id' => $sourceId,
|
||||
],
|
||||
[
|
||||
'browsed_at' => Carbon::now(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
138
app/Models/UserTaskBrowse.php
Normal file
138
app/Models/UserTaskBrowse.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* App\Models\UserTaskBrowse
|
||||
*
|
||||
* @property int $id
|
||||
* @property int|null $userid 用户ID
|
||||
* @property int|null $task_id 任务ID
|
||||
* @property \Illuminate\Support\Carbon|null $browsed_at 浏览时间
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read \App\Models\ProjectTask|null $task
|
||||
* @property-read \App\Models\User|null $user
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse whereBrowsedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse whereTaskId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse whereUpdatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse whereUserid($value)
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class UserTaskBrowse extends AbstractModel
|
||||
{
|
||||
protected $fillable = [
|
||||
'userid',
|
||||
'task_id',
|
||||
'browsed_at',
|
||||
];
|
||||
|
||||
protected $dates = [
|
||||
'browsed_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* 关联用户
|
||||
*/
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'userid', 'userid');
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联任务
|
||||
*/
|
||||
public function task()
|
||||
{
|
||||
return $this->belongsTo(ProjectTask::class, 'task_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录用户浏览任务
|
||||
* @param int $userid 用户ID
|
||||
* @param int $task_id 任务ID
|
||||
* @return UserTaskBrowse
|
||||
*/
|
||||
public static function recordBrowse($userid, $task_id)
|
||||
{
|
||||
$record = self::updateOrCreate(
|
||||
[
|
||||
'userid' => $userid,
|
||||
'task_id' => $task_id,
|
||||
],
|
||||
[
|
||||
'browsed_at' => Carbon::now(),
|
||||
]
|
||||
);
|
||||
|
||||
UserRecentItem::record(
|
||||
$userid,
|
||||
UserRecentItem::TYPE_TASK,
|
||||
$task_id,
|
||||
UserRecentItem::SOURCE_PROJECT,
|
||||
0
|
||||
);
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户浏览历史
|
||||
* @param int $userid 用户ID
|
||||
* @param int $limit 获取数量
|
||||
* @return \Illuminate\Database\Eloquent\Collection
|
||||
*/
|
||||
public static function getUserBrowseHistory($userid, $limit = 20)
|
||||
{
|
||||
return self::with(['task' => function ($query) {
|
||||
$query->select([
|
||||
'id', 'name', 'project_id', 'column_id', 'parent_id',
|
||||
'flow_item_id', 'flow_item_name',
|
||||
'complete_at', 'archived_at'
|
||||
]);
|
||||
}])
|
||||
->whereUserid($userid)
|
||||
->whereHas('task', function ($query) {
|
||||
// 只获取存在且未被删除的任务
|
||||
$query->whereNull('archived_at');
|
||||
})
|
||||
->orderByDesc('browsed_at')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理用户浏览历史
|
||||
* @param int $userid 用户ID
|
||||
* @param int $keepCount 保留数量,0表示全部删除
|
||||
* @return int 删除的记录数
|
||||
*/
|
||||
public static function cleanUserBrowseHistory($userid, $keepCount = 100)
|
||||
{
|
||||
if ($keepCount === 0) {
|
||||
return self::whereUserid($userid)->delete();
|
||||
}
|
||||
|
||||
$keepIds = self::whereUserid($userid)
|
||||
->orderByDesc('browsed_at')
|
||||
->limit($keepCount)
|
||||
->pluck('id');
|
||||
|
||||
return self::whereUserid($userid)
|
||||
->whereNotIn('id', $keepIds)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
@@ -705,6 +705,48 @@ class WebSocketDialog extends AbstractModel
|
||||
return WebSocketDialogUser::whereDialogId($this->id)->where('userid', '>', 0)->count() === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否支持创建会话
|
||||
* @return bool
|
||||
*/
|
||||
public function isSessionDialog()
|
||||
{
|
||||
// 这个不会有变化,所以可以使用永久缓存
|
||||
return Cache::rememberForever('is-session-dialog-' . $this->id, function () {
|
||||
if ($this->type !== 'user') {
|
||||
return false;
|
||||
}
|
||||
$data = $this->dialogUserBuilder()->get();
|
||||
foreach ($data as $item) {
|
||||
if (preg_match('/^(ai-|user-session-)(.*?)@bot\.system$/', $item->email)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否是AI对话
|
||||
* @return bool
|
||||
*/
|
||||
public function isAiDialog()
|
||||
{
|
||||
// 这个不会有变化,所以可以使用永久缓存
|
||||
return Cache::rememberForever('is-ai-dialog-' . $this->id, function () {
|
||||
if ($this->type !== 'user') {
|
||||
return false;
|
||||
}
|
||||
$data = $this->dialogUserBuilder()->get();
|
||||
foreach ($data as $item) {
|
||||
if (preg_match('/^ai-(.*?)@bot\.system$/', $item->email)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对话(同时检验对话身份)
|
||||
* @param $dialog_id
|
||||
@@ -778,6 +820,7 @@ class WebSocketDialog extends AbstractModel
|
||||
WebSocketDialogUser::createInstance([
|
||||
'dialog_id' => $dialog->id,
|
||||
'userid' => $value,
|
||||
'bot' => User::isBot($value) ? 1 : 0,
|
||||
'important' => !in_array($group_type, ['user', 'all']),
|
||||
'last_at' => in_array($group_type, ['user', 'department', 'all']) ? Carbon::now() : null,
|
||||
])->save();
|
||||
@@ -814,17 +857,17 @@ class WebSocketDialog extends AbstractModel
|
||||
WebSocketDialogUser::createInstance([
|
||||
'dialog_id' => $dialog->id,
|
||||
'userid' => $user->userid,
|
||||
'bot' => User::isBot($user->userid) ? 1 : 0,
|
||||
])->save();
|
||||
WebSocketDialogUser::createInstance([
|
||||
'dialog_id' => $dialog->id,
|
||||
'userid' => $receiver,
|
||||
'bot' => User::isBot($receiver) ? 1 : 0,
|
||||
])->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;
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Cache;
|
||||
use Carbon\Carbon;
|
||||
use App\Module\Base;
|
||||
use App\Module\Doo;
|
||||
use App\Module\Image;
|
||||
use App\Tasks\PushTask;
|
||||
use App\Models\ProjectTaskRelation;
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Tasks\WebSocketDialogMsgTask;
|
||||
use Hhxsv5\LaravelS\Swoole\Task\Task;
|
||||
@@ -315,6 +317,24 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
return Base::retSuccess('success', $resData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否完成所有待办
|
||||
* @param bool $noCache 是否禁止缓存
|
||||
* @return int 1=已完成 0=未完成
|
||||
*/
|
||||
public function isTodoDone(?bool $noCache = false): int
|
||||
{
|
||||
if ($noCache) {
|
||||
Cache::forget('todo_done_' . $this->id);
|
||||
}
|
||||
if ($this->todo <= 0) {
|
||||
return 1;
|
||||
}
|
||||
return (int) Cache::remember('todo_done_' . $this->id, Carbon::now()->addDays(), function () {
|
||||
return WebSocketDialogMsgTodo::whereMsgId($this->id)->whereDoneAt(null)->exists() ? 0 : 1;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 标注、取消标注
|
||||
* @param int $sender 标注的会员ID
|
||||
@@ -367,23 +387,15 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
if (in_array($this->type, ['tag', 'todo', 'notice'])) {
|
||||
return Base::retError('此消息不支持设待办');
|
||||
}
|
||||
$dialog = WebSocketDialog::find($this->dialog_id);
|
||||
$current = WebSocketDialogMsgTodo::whereMsgId($this->id)->pluck('userid')->toArray();
|
||||
$cancel = array_diff($current, $userids);
|
||||
$setup = array_diff($userids, $current);
|
||||
//
|
||||
$this->todo = $setup || count($current) > count($cancel) ? $sender : 0;
|
||||
$this->save();
|
||||
$upData = [
|
||||
'id' => $this->id,
|
||||
'todo' => $this->todo,
|
||||
'dialog_id' => $this->dialog_id,
|
||||
];
|
||||
$dialog = WebSocketDialog::find($this->dialog_id);
|
||||
//
|
||||
$retData = [
|
||||
'add' => [],
|
||||
'update' => $upData
|
||||
];
|
||||
$addData = [];
|
||||
if ($cancel) {
|
||||
$res = self::sendMsg(null, $this->dialog_id, 'todo', [
|
||||
'action' => 'remove',
|
||||
@@ -395,7 +407,7 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
]
|
||||
], $sender);
|
||||
if (Base::isSuccess($res)) {
|
||||
$retData['add'][] = $res['data'];
|
||||
$addData[] = $res['data'];
|
||||
WebSocketDialogMsgTodo::whereMsgId($this->id)->whereIn('userid', $cancel)->delete();
|
||||
}
|
||||
}
|
||||
@@ -410,7 +422,7 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
]
|
||||
], $sender);
|
||||
if (Base::isSuccess($res)) {
|
||||
$retData['add'][] = $res['data'];
|
||||
$addData[] = $res['data'];
|
||||
$useridList = $dialog->dialogUser->pluck('userid')->toArray();
|
||||
foreach ($setup as $userid) {
|
||||
if (!in_array($userid, $useridList)) {
|
||||
@@ -425,8 +437,18 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
}
|
||||
}
|
||||
//
|
||||
$upData = [
|
||||
'id' => $this->id,
|
||||
'todo' => $this->todo,
|
||||
'todo_done' => $this->isTodoDone(true),
|
||||
'dialog_id' => $this->dialog_id,
|
||||
];
|
||||
$dialog->pushMsg('update', $upData);
|
||||
return Base::retSuccess($this->todo ? '设置成功' : '取消成功', $retData);
|
||||
//
|
||||
return Base::retSuccess($this->todo ? '设置成功' : '取消成功', [
|
||||
'add' => $addData,
|
||||
'update' => $upData,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -456,27 +478,10 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
'parent_id' => $this->id, // 转发的消息ID
|
||||
'parent_userid' => $this->userid, // 转发的消息会员ID
|
||||
'show' => $showSource, // 是否显示原发送者信息
|
||||
'leave' => $leaveMessage ? 1 : 0, // 是否留言(用于判断是否发给AI)
|
||||
];
|
||||
$msgs = [];
|
||||
$already = [];
|
||||
if ($dialogids) {
|
||||
if (!is_array($dialogids)) {
|
||||
$dialogids = [$dialogids];
|
||||
}
|
||||
foreach ($dialogids as $dialogid) {
|
||||
$res = self::sendMsg('forward-' . $forwardId, $dialogid, $this->type, $msgData, $user->userid);
|
||||
if (Base::isSuccess($res)) {
|
||||
$msgs[] = $res['data'];
|
||||
$already[] = $dialogid;
|
||||
}
|
||||
if ($leaveMessage) {
|
||||
$res = self::sendMsg(null, $dialogid, 'text', ['text' => $leaveMessage], $user->userid);
|
||||
if (Base::isSuccess($res)) {
|
||||
$msgs[] = $res['data'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$dialogs = [];
|
||||
if ($userids) {
|
||||
if (!is_array($userids)) {
|
||||
$userids = [$userids];
|
||||
@@ -486,17 +491,35 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
continue;
|
||||
}
|
||||
$dialog = WebSocketDialog::checkUserDialog($user, $userid);
|
||||
if ($dialog && !in_array($dialog->id, $already)) {
|
||||
$res = self::sendMsg('forward-' . $forwardId, $dialog->id, $this->type, $msgData, $user->userid);
|
||||
if (Base::isSuccess($res)) {
|
||||
$msgs[] = $res['data'];
|
||||
}
|
||||
if ($leaveMessage) {
|
||||
$res = self::sendMsg(null, $dialog->id, 'text', ['text' => $leaveMessage], $user->userid);
|
||||
if (Base::isSuccess($res)) {
|
||||
$msgs[] = $res['data'];
|
||||
}
|
||||
}
|
||||
if ($dialog) {
|
||||
$dialogs[$dialog->id] = $dialog;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($dialogids) {
|
||||
if (!is_array($dialogids)) {
|
||||
$dialogids = [$dialogids];
|
||||
}
|
||||
foreach ($dialogids as $dialogid) {
|
||||
if (isset($dialogs[$dialogid])) {
|
||||
continue;
|
||||
}
|
||||
$dialog = WebSocketDialog::find($dialogid);
|
||||
if ($dialog) {
|
||||
$dialogs[$dialog->id] = $dialog;
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($dialogs as $dialog) {
|
||||
$res = self::sendMsg('forward-' . $forwardId, $dialog->id, $this->type, $msgData, $user->userid);
|
||||
if (Base::isSuccess($res)) {
|
||||
$msgs[] = $res['data'];
|
||||
}
|
||||
if ($leaveMessage) {
|
||||
$action = $dialog->isAiDialog() ? "reply-{$res['data']['id']}" : null;
|
||||
$res = self::sendMsg($action, $dialog->id, 'text', ['text' => $leaveMessage], $user->userid);
|
||||
if (Base::isSuccess($res)) {
|
||||
$msgs[] = $res['data'];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -655,7 +678,7 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
* @param bool $preserveHtml 保留html格式
|
||||
* @return string|string[]|null
|
||||
*/
|
||||
private static function previewTextMsg($msgData, $preserveHtml = false)
|
||||
public static function previewTextMsg($msgData, $preserveHtml = false)
|
||||
{
|
||||
$text = $msgData['text'] ?? '';
|
||||
if (!$text) return '';
|
||||
@@ -664,8 +687,16 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
if (preg_match('/:::\s*reasoning\s+/', $text)) {
|
||||
return Doo::translate('思考中...');
|
||||
}
|
||||
$text = Base::markdown2html($text);
|
||||
$text = self::previewConvertTaskList($text);
|
||||
$title = '';
|
||||
if (preg_match('/^#{1,2}\s+(.+)/m', $text, $matches)) {
|
||||
$title = trim($matches[1]);
|
||||
}
|
||||
if ($title) {
|
||||
$text = $title;
|
||||
} else {
|
||||
$text = Base::markdown2html($text);
|
||||
$text = self::previewConvertTaskList($text);
|
||||
}
|
||||
}
|
||||
$text = preg_replace("/<img\s+class=\"emoticon\"[^>]*?alt=\"(\S+)\"[^>]*?>/", "[$1]", $text);
|
||||
$text = preg_replace("/<img\s+class=\"emoticon\"[^>]*?>/", "[" . Doo::translate('动画表情') . "]", $text);
|
||||
@@ -821,6 +852,111 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
return $msg;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取消息内容
|
||||
* 根据消息类型(文件、文本等)提取相应的内容文本
|
||||
*
|
||||
* @param int $maxLength 最大长度,超过则截取,0表示不限制
|
||||
* @return string 提取出的消息文本内容
|
||||
*/
|
||||
public function extractMessageContent(int $maxLength = 0): string
|
||||
{
|
||||
$reserves = [];
|
||||
switch ($this->type) {
|
||||
case "file":
|
||||
// 提取文件消息
|
||||
$msgData = Base::json2array($this->getRawOriginal('msg'));
|
||||
$result = $this->convertMentionFormat("path", $msgData['path'], $msgData['name'], $reserves);
|
||||
break;
|
||||
|
||||
case "text":
|
||||
// 提取文本消息
|
||||
$result = $this->msg['text'] ?: '';
|
||||
if (empty($result)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 提取快捷键
|
||||
if (preg_match("/<span[^>]*?data-quick-key=([\"'])([^\"']+?)\\1[^>]*?>(.*?)<\/span>/is", $result, $match)) {
|
||||
$command = $match[2] ?? '';
|
||||
$command = preg_replace("/^%3A\.?/", ":", $command);
|
||||
$command = trim($command);
|
||||
if ($command) {
|
||||
return $command;
|
||||
}
|
||||
}
|
||||
|
||||
// 提及任务、文件、报告
|
||||
$result = preg_replace_callback_array([
|
||||
// 用户
|
||||
"/<span class=\"mention user\" data-id=\"(\d+)\">(.*?)<\/span>/" => function () {
|
||||
return "";
|
||||
},
|
||||
|
||||
// 任务
|
||||
"/<span class=\"mention task\" data-id=\"(\d+)\">#?(.*?)<\/span>/" => function ($match) use (&$reserves) {
|
||||
return $this->convertMentionFormat("task", $match[1], $match[2], $reserves);
|
||||
},
|
||||
|
||||
// 文件
|
||||
"/<a class=\"mention file\" href=\"([^\"']+?)\"[^>]*?>~?(.*?)<\/a>/" => function ($match) use (&$reserves) {
|
||||
if (preg_match("/single\/file\/(.*?)$/", $match[1], $subMatch)) {
|
||||
return $this->convertMentionFormat("file", $subMatch[1], $match[2], $reserves);
|
||||
}
|
||||
return "";
|
||||
},
|
||||
|
||||
// 报告
|
||||
"/<a class=\"mention report\" href=\"([^\"']+?)\"[^>]*?>%?(.*?)<\/a>/" => function ($match) use (&$reserves) {
|
||||
if (preg_match("/single\/report\/detail\/(.*?)$/", $match[1], $subMatch)) {
|
||||
return $this->convertMentionFormat("report", $subMatch[1], $match[2], $reserves);
|
||||
}
|
||||
return "";
|
||||
},
|
||||
], $result);
|
||||
|
||||
// 转成 markdown
|
||||
if ($this->msg['type'] !== 'md') {
|
||||
$result = Base::html2markdown($result);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// 其他类型消息不处理
|
||||
return '';
|
||||
}
|
||||
|
||||
// 处理 reserves
|
||||
foreach ($reserves as $rand => $mention) {
|
||||
$result = str_replace($rand, $mention, $result);
|
||||
}
|
||||
|
||||
// 截取最大长度
|
||||
if ($maxLength > 0 && mb_strlen($result) > $maxLength) {
|
||||
$result = mb_substr($result, 0, $maxLength);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换提及消息格式
|
||||
* 将提及的任务、文件、报告等转换为统一的格式 [type#key#name]
|
||||
*
|
||||
* @param string $type 提及类型(task、file、report、path)
|
||||
* @param string $key 提及对象的唯一标识
|
||||
* @param string $name 提及对象的显示名称
|
||||
* @return string 格式化后的提及字符串
|
||||
*/
|
||||
private function convertMentionFormat($type, $key, $name, &$reserves)
|
||||
{
|
||||
$key = str_replace(['#', '-->'], '', $key);
|
||||
$name = str_replace(['#', '-->'], '', $name);
|
||||
$rand = Base::generatePassword(12);
|
||||
$reserves[$rand] = "<!--{$type}#{$key}#{$name}-->";
|
||||
return $rand;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理文本消息内容,用于发送前
|
||||
* @param $text
|
||||
@@ -1197,6 +1333,7 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
];
|
||||
$dialogMsg->updateInstance($updateData);
|
||||
$dialogMsg->generateKeyAndSave($search_key);
|
||||
ProjectTaskRelation::recordMentionsFromMessage($dialogMsg);
|
||||
//
|
||||
WebSocketDialogUser::whereDialogId($dialog->id)->whereUserid($sender)->whereHide(1)->change([
|
||||
'hide' => 0, // 修改消息时,显示会话(仅自己)
|
||||
@@ -1263,6 +1400,7 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
'updated_at' => Carbon::now()->toDateTimeString('millisecond'),
|
||||
]);
|
||||
});
|
||||
ProjectTaskRelation::recordMentionsFromMessage($dialogMsg);
|
||||
//
|
||||
$task = new WebSocketDialogMsgTask($dialogMsg->id);
|
||||
if ($push_self) {
|
||||
@@ -1328,7 +1466,6 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 将被@的人加入群
|
||||
* @param WebSocketDialog $dialog 对话
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* App\Models\WebSocketDialogMsgRead
|
||||
@@ -76,24 +77,48 @@ class WebSocketDialogMsgRead extends AbstractModel
|
||||
*/
|
||||
public static function onlyMarkRead($list)
|
||||
{
|
||||
$dialogMsg = [];
|
||||
if (empty($list)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$collection = collect($list);
|
||||
if ($collection->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = Carbon::now();
|
||||
$ids = [];
|
||||
$msgCounts = [];
|
||||
|
||||
/** @var WebSocketDialogMsgRead $item */
|
||||
foreach ($list as $item) {
|
||||
$item->read_at = Carbon::now();
|
||||
$item->save();
|
||||
if (isset($dialogMsg[$item->msg_id])) {
|
||||
$dialogMsg[$item->msg_id]['readNum']++;
|
||||
} else {
|
||||
$dialogMsg[$item->msg_id] = [
|
||||
'dialogMsg' => $item->webSocketDialogMsg,
|
||||
'readNum' => 1
|
||||
];
|
||||
foreach ($collection as $item) {
|
||||
$ids[] = $item->id;
|
||||
if ($item->msg_id) {
|
||||
$msgCounts[$item->msg_id] = ($msgCounts[$item->msg_id] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
foreach ($dialogMsg as $item) {
|
||||
if ($item['dialogMsg']) {
|
||||
$item['dialogMsg']->increment('read', $item['readNum']);
|
||||
|
||||
if (!empty($ids)) {
|
||||
DB::table((new self())->getTable())
|
||||
->whereIn('id', $ids)
|
||||
->whereNull('read_at')
|
||||
->update(['read_at' => $now]);
|
||||
}
|
||||
|
||||
if (!empty($msgCounts)) {
|
||||
$cases = [];
|
||||
$bindings = [];
|
||||
foreach ($msgCounts as $msgId => $num) {
|
||||
$cases[] = 'WHEN ? THEN ?';
|
||||
$bindings[] = $msgId;
|
||||
$bindings[] = $num;
|
||||
}
|
||||
$msgIds = array_keys($msgCounts);
|
||||
$bindings = array_merge($bindings, $msgIds);
|
||||
$placeholders = implode(',', array_fill(0, count($msgIds), '?'));
|
||||
$table = DB::getTablePrefix() . (new WebSocketDialogMsg())->getTable();
|
||||
$sql = "UPDATE {$table} SET `read` = `read` + CASE `id` " . implode(' ', $cases) . " END WHERE `deleted_at` IS NULL AND `id` IN ({$placeholders})";
|
||||
DB::update($sql, $bindings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,15 +66,14 @@ class WebSocketDialogSession extends AbstractModel
|
||||
if ($dialogMsg->type != 'text') {
|
||||
return;
|
||||
}
|
||||
if ($dialogMsg->msg['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;
|
||||
}
|
||||
$title = $dialogMsg->key ?: WebSocketDialogMsg::previewTextMsg($dialogMsg->msg) ?: 'Untitled';
|
||||
$session = self::whereId($sessionId)->first();
|
||||
if (!$session) {
|
||||
return;
|
||||
@@ -82,6 +81,6 @@ class WebSocketDialogSession extends AbstractModel
|
||||
$session->title = $title;
|
||||
$session->save();
|
||||
Cache::forever($cacheKey, true);
|
||||
Task::deliver(new UpdateSessionTitleViaAiTask($session->id, $originalTitle));
|
||||
Task::deliver(new UpdateSessionTitleViaAiTask($session->id, $dialogMsg->msg['text']));
|
||||
}
|
||||
}
|
||||
|
||||
933
app/Module/AI.php
Normal file
933
app/Module/AI.php
Normal file
@@ -0,0 +1,933 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Cache;
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* AI 助手模块
|
||||
*/
|
||||
class AI
|
||||
{
|
||||
protected $post = [];
|
||||
protected $headers = [];
|
||||
protected $urlPath = '';
|
||||
protected $timeout = 30;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
* @param array $post
|
||||
* @param array $headers
|
||||
*/
|
||||
public function __construct($post = [], $headers = [])
|
||||
{
|
||||
$this->post = $post ?? [];
|
||||
$this->headers = $headers ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置请求参数
|
||||
* @param array $post
|
||||
*/
|
||||
public function setPost($post)
|
||||
{
|
||||
$this->post = array_merge($this->post, $post);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置请求头
|
||||
* @param array $headers
|
||||
*/
|
||||
public function setHeaders($headers)
|
||||
{
|
||||
$this->headers = array_merge($this->headers, $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置请求路径
|
||||
* @param string $urlPath
|
||||
*/
|
||||
public function setUrlPath($urlPath)
|
||||
{
|
||||
$this->urlPath = $urlPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置请求超时时间
|
||||
* @param int $timeout
|
||||
*/
|
||||
public function setTimeout($timeout)
|
||||
{
|
||||
$this->timeout = $timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求 AI 接口
|
||||
* @param bool $resRaw 是否返回原始数据
|
||||
* @return array
|
||||
*/
|
||||
public function request($resRaw = false)
|
||||
{
|
||||
$aiSetting = Base::setting('aiSetting');
|
||||
if (!Setting::AIOpen()) {
|
||||
return Base::retError("AI 助手未开启");
|
||||
}
|
||||
|
||||
$headers = [
|
||||
'Content-Type' => 'application/json',
|
||||
'Authorization' => 'Bearer ' . $aiSetting['ai_api_key'],
|
||||
];
|
||||
if ($aiSetting['ai_proxy']) {
|
||||
$headers['CURLOPT_PROXY'] = $aiSetting['ai_proxy'];
|
||||
$headers['CURLOPT_PROXYTYPE'] = str_contains($aiSetting['ai_proxy'], 'socks') ? CURLPROXY_SOCKS5 : CURLPROXY_HTTP;
|
||||
}
|
||||
$headers = array_merge($headers, $this->headers);
|
||||
|
||||
$url = $aiSetting['ai_api_url'] ?: 'https://api.openai.com/v1';
|
||||
$url = $url . ($this->urlPath ?: '/chat/completions');
|
||||
|
||||
$result = Ihttp::ihttp_request($url, $this->post, $headers, $this->timeout);
|
||||
if (Base::isError($result)) {
|
||||
return Base::retError("AI 接口请求失败", $result);
|
||||
}
|
||||
$result = $result['data'];
|
||||
|
||||
if (!$resRaw) {
|
||||
$resData = Base::json2array($result);
|
||||
if (empty($resData['choices'])) {
|
||||
return Base::retError("AI 接口返回数据格式错误", $resData);
|
||||
}
|
||||
$result = $resData['choices'][0]['message']['content'];
|
||||
$result = trim($result);
|
||||
if (empty($result)) {
|
||||
return Base::retError("AI 接口返回数据为空");
|
||||
}
|
||||
}
|
||||
|
||||
return Base::retSuccess("success", $result);
|
||||
}
|
||||
|
||||
/** ******************************************************************************************** */
|
||||
/** ******************************************************************************************** */
|
||||
/** ******************************************************************************************** */
|
||||
|
||||
/**
|
||||
* 通过 openAI 语音转文字
|
||||
* @param string $filePath 语音文件路径
|
||||
* @param array $extParams 扩展参数
|
||||
* @param bool $noCache 是否禁用缓存
|
||||
* @return array
|
||||
*/
|
||||
public static function transcriptions($filePath, $extParams = [], $noCache = false)
|
||||
{
|
||||
if (!file_exists($filePath)) {
|
||||
return Base::retError("语音文件不存在");
|
||||
}
|
||||
$systemSetting = Base::setting('system');
|
||||
if ($systemSetting['voice2text'] !== 'open') {
|
||||
return Base::retError("语音转文字功能未开启");
|
||||
}
|
||||
|
||||
$cacheKey = "openAItranscriptions::" . md5($filePath . '_' . Base::array2json($extParams));
|
||||
if ($noCache) {
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
|
||||
$result = Cache::remember($cacheKey, Carbon::now()->addDays(), function () use ($extParams, $filePath) {
|
||||
$post = array_merge($extParams, [
|
||||
'file' => new \CURLFile($filePath),
|
||||
'model' => 'whisper-1',
|
||||
]);
|
||||
$header = [
|
||||
'Content-Type' => 'multipart/form-data',
|
||||
];
|
||||
|
||||
$ai = new self($post, $header);
|
||||
$ai->setUrlPath('/audio/transcriptions');
|
||||
$ai->setTimeout(15);
|
||||
|
||||
$res = $ai->request(true);
|
||||
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", [
|
||||
'file' => $filePath,
|
||||
'text' => $resData['text'],
|
||||
]);
|
||||
});
|
||||
if (Base::isError($result)) {
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 openAI 翻译
|
||||
* @param string $text 需要翻译的文本内容
|
||||
* @param string $targetLanguage 目标语言(如:English, 简体中文, 日本語等)
|
||||
* @param bool $noCache 是否禁用缓存
|
||||
* @return array
|
||||
*/
|
||||
public static function translations($text, $targetLanguage, $noCache = false)
|
||||
{
|
||||
$systemSetting = Base::setting('system');
|
||||
if ($systemSetting['translation'] !== 'open') {
|
||||
return Base::retError("翻译功能未开启");
|
||||
}
|
||||
|
||||
$cacheKey = "openAItranslations::" . md5($text . '_' . $targetLanguage);
|
||||
if ($noCache) {
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
|
||||
$result = Cache::remember($cacheKey, Carbon::now()->addDays(7), function () use ($text, $targetLanguage) {
|
||||
$post = json_encode([
|
||||
"model" => "gpt-5-nano",
|
||||
"reasoning_effort" => "minimal",
|
||||
"messages" => [
|
||||
[
|
||||
"role" => "system",
|
||||
"content" => <<<EOF
|
||||
你是一名资深的专业翻译专家,专门从事项目任务管理系统的多语言本地化工作。
|
||||
|
||||
翻译任务:将提供的文本内容翻译为 {$targetLanguage}
|
||||
|
||||
专业要求:
|
||||
1. 术语一致性:确保项目管理、任务管理、团队协作等专业术语的准确翻译
|
||||
2. 上下文理解:根据项目管理场景选择最合适的表达方式
|
||||
3. 格式保持:严格保持原文的格式、结构、标点符号和排版
|
||||
4. 语言规范:使用目标语言的标准表达,符合该语言的语法和习惯
|
||||
5. 专业性:体现项目管理领域的专业水准和准确性
|
||||
6. 简洁性:避免冗余表达,保持语言简洁明了
|
||||
|
||||
注意事项:
|
||||
- 保留所有HTML标签、特殊符号、数字、日期格式
|
||||
- 对于专有名词(如软件名称、品牌名)保持原文
|
||||
- 确保翻译后的文本自然流畅,符合目标语言的表达习惯
|
||||
- 如遇到歧义表达,优先选择项目管理场景下的含义
|
||||
|
||||
请直接返回翻译结果,不要包含任何解释或标记。
|
||||
EOF
|
||||
],
|
||||
[
|
||||
"role" => "user",
|
||||
"content" => "请将以下内容翻译为 {$targetLanguage}:\n\n{$text}"
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
$ai = new self($post);
|
||||
$ai->setTimeout(60);
|
||||
|
||||
$res = $ai->request();
|
||||
if (Base::isError($res)) {
|
||||
return Base::retError("翻译请求失败", $res);
|
||||
}
|
||||
$result = $res['data'];
|
||||
if (empty($result)) {
|
||||
return Base::retError("翻译结果为空");
|
||||
}
|
||||
|
||||
return Base::retSuccess("success", [
|
||||
'translated_text' => $result,
|
||||
'target_language' => $targetLanguage,
|
||||
'translated_at' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
});
|
||||
|
||||
if (Base::isError($result)) {
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 openAI 生成标题
|
||||
* @param string $text 需要生成标题的文本内容
|
||||
* @param bool $noCache 是否禁用缓存
|
||||
* @return array
|
||||
*/
|
||||
public static function generateTitle($text, $noCache = false)
|
||||
{
|
||||
$cacheKey = "openAIGenerateTitle::" . md5($text);
|
||||
if ($noCache) {
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
|
||||
$result = Cache::remember($cacheKey, Carbon::now()->addHours(24), function () use ($text) {
|
||||
$post = json_encode([
|
||||
"model" => "gpt-5-nano",
|
||||
"reasoning_effort" => "minimal",
|
||||
"messages" => [
|
||||
[
|
||||
"role" => "system",
|
||||
"content" => <<<EOF
|
||||
你是一个专业的标题生成器,专门为项目任务管理系统的对话内容生成精准、简洁的标题。
|
||||
|
||||
要求:
|
||||
1. 标题要准确概括文本的核心内容和主要意图
|
||||
2. 标题长度控制在5-20个字符之间
|
||||
3. 语言简洁明了,避免冗余词汇
|
||||
4. 适合在项目管理场景中使用
|
||||
5. 不要包含引号或特殊符号
|
||||
6. 如果是技术讨论,突出技术要点
|
||||
7. 如果是项目管理内容,突出关键动作或目标
|
||||
8. 如果是需求讨论,突出需求的核心点
|
||||
|
||||
请直接返回标题,不要包含任何解释或其他内容。
|
||||
EOF
|
||||
],
|
||||
[
|
||||
"role" => "user",
|
||||
"content" => "请为以下内容生成一个合适的标题:\n\n" . $text
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
$ai = new self($post);
|
||||
$ai->setTimeout(10);
|
||||
|
||||
$res = $ai->request();
|
||||
if (Base::isError($res)) {
|
||||
return Base::retError("标题生成失败", $res);
|
||||
}
|
||||
$result = $res['data'];
|
||||
if (empty($result)) {
|
||||
return Base::retError("生成的标题为空");
|
||||
}
|
||||
|
||||
return Base::retSuccess("success", [
|
||||
'title' => $result,
|
||||
'length' => mb_strlen($result),
|
||||
'generated_at' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
});
|
||||
|
||||
if (Base::isError($result)) {
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 openAI 生成任务标题和描述
|
||||
* @param string $text 用户提供的提示词
|
||||
* @param array $context 上下文信息
|
||||
* @return array
|
||||
*/
|
||||
public static function generateTask($text, $context = [])
|
||||
{
|
||||
// 构建上下文提示信息
|
||||
$contextPrompt = self::buildTaskContextPrompt($context);
|
||||
|
||||
$post = json_encode([
|
||||
"model" => "gpt-5-nano",
|
||||
"reasoning_effort" => "minimal",
|
||||
"messages" => [
|
||||
[
|
||||
"role" => "system",
|
||||
"content" => <<<EOF
|
||||
你是一个专业的任务管理专家,擅长将想法和需求转化为清晰、可执行的项目任务。
|
||||
|
||||
任务生成要求:
|
||||
1. 根据输入内容分析并生成合适的任务标题和详细描述
|
||||
2. 标题要简洁明了,准确概括任务核心目标,长度控制在8-30个字符
|
||||
3. 描述需覆盖任务背景、具体要求、交付标准、风险提示等关键信息
|
||||
4. 描述内容使用Markdown格式,合理组织标题、列表、加粗等结构
|
||||
5. 内容需适配项目管理系统,表述专业、逻辑清晰,并与用户输入语言保持一致
|
||||
6. 优先遵循用户在输入中给出的风格、长度或复杂度要求;默认情况下将详细描述控制在120-200字内,如用户要求简单或简短,则控制在80-120字内
|
||||
7. 当任务具有多个执行步骤、阶段或协作角色时,请拆解出 2-6 个关键子任务;如无必要,可返回空数组
|
||||
8. 子任务应聚焦单一可执行动作,名称控制在8-30个字符内,避免重复和含糊表述
|
||||
|
||||
返回格式要求:
|
||||
必须严格按照以下 JSON 结构返回,禁止输出额外文字或 Markdown 代码块标记;即使某项为空,也保留对应字段:
|
||||
{
|
||||
"title": "任务标题",
|
||||
"content": "任务的详细描述内容,使用Markdown格式,根据实际情况组织结构",
|
||||
"subtasks": [
|
||||
"子任务名称1",
|
||||
"子任务名称2"
|
||||
]
|
||||
}
|
||||
|
||||
内容格式建议(非强制):
|
||||
- 可以使用标题、列表、加粗等Markdown格式
|
||||
- 可以包含任务背景、具体要求、验收标准等部分
|
||||
- 根据任务性质灵活组织内容结构
|
||||
- 仅在确有必要时生成子任务,并确保每个子任务都是独立、可执行、便于追踪的动作
|
||||
- 若用户明确要求简洁或简单,保持描述紧凑,避免添加冗余段落或重复信息
|
||||
|
||||
上下文信息处理指南:
|
||||
- 如果已有标题和内容,优先考虑优化改进而非完全重写
|
||||
- 如果使用了任务模板,严格按照模板的结构和格式要求生成
|
||||
- 如果已设置负责人或时间计划,在任务描述中体现相关要求
|
||||
- 根据优先级等级调整任务的紧急程度和详细程度
|
||||
|
||||
注意事项:
|
||||
- 标题要体现任务的核心动作和目标
|
||||
- 描述要包含足够的细节让执行者理解任务
|
||||
- 如果涉及技术开发,要明确技术要求和实现方案
|
||||
- 如果涉及设计,要说明设计要求和期望效果
|
||||
- 如果涉及测试,要明确测试范围和验收标准
|
||||
EOF
|
||||
],
|
||||
[
|
||||
"role" => "user",
|
||||
"content" => $contextPrompt . "\n\n请根据以上上下文并结合以下提示词生成一个完整的项目任务(包含标题和详细描述):\n\n" . $text
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
$ai = new self($post);
|
||||
$ai->setTimeout(60);
|
||||
|
||||
$res = $ai->request();
|
||||
if (Base::isError($res)) {
|
||||
return Base::retError("任务生成失败", $res);
|
||||
}
|
||||
|
||||
// 清理可能的markdown代码块标记
|
||||
$content = $res['data'];
|
||||
$content = preg_replace('/^\s*```json\s*/', '', $content);
|
||||
$content = preg_replace('/\s*```\s*$/', '', $content);
|
||||
|
||||
if (empty($content)) {
|
||||
return Base::retError("任务生成结果为空");
|
||||
}
|
||||
|
||||
// 解析JSON
|
||||
$parsedData = Base::json2array($content);
|
||||
if (!$parsedData || !isset($parsedData['title']) || !isset($parsedData['content'])) {
|
||||
return Base::retError("任务生成格式错误", $content);
|
||||
}
|
||||
|
||||
$title = trim($parsedData['title']);
|
||||
$markdownContent = trim($parsedData['content']);
|
||||
$rawSubtasks = $parsedData['subtasks'] ?? [];
|
||||
|
||||
if (empty($title) || empty($markdownContent)) {
|
||||
return Base::retError("生成的任务标题或内容为空", $parsedData);
|
||||
}
|
||||
|
||||
$subtasks = [];
|
||||
if (is_array($rawSubtasks)) {
|
||||
foreach ($rawSubtasks as $raw) {
|
||||
if (is_array($raw)) {
|
||||
$name = trim($raw['title'] ?? $raw['name'] ?? '');
|
||||
} else {
|
||||
$name = trim($raw);
|
||||
}
|
||||
|
||||
if (!empty($name)) {
|
||||
$subtasks[] = $name;
|
||||
}
|
||||
|
||||
if (count($subtasks) >= 8) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Base::retSuccess("success", [
|
||||
'title' => $title,
|
||||
'content' => Base::markdown2html($markdownContent), // 将 Markdown 转换为 HTML
|
||||
'subtasks' => $subtasks
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 openAI 生成项目名称与任务列表
|
||||
* @param string $text 用户提供的提示词
|
||||
* @param array $context 上下文信息
|
||||
* @return array
|
||||
*/
|
||||
public static function generateProject($text, $context = [])
|
||||
{
|
||||
$text = trim((string)$text);
|
||||
if ($text === '') {
|
||||
return Base::retError("项目提示词不能为空");
|
||||
}
|
||||
|
||||
$context['current_name'] = trim($context['current_name'] ?? '');
|
||||
$context['current_columns'] = self::normalizeProjectColumns($context['current_columns'] ?? []);
|
||||
|
||||
if (!empty($context['template_examples']) && is_array($context['template_examples'])) {
|
||||
$examples = [];
|
||||
foreach ($context['template_examples'] as $item) {
|
||||
$name = trim($item['name'] ?? '');
|
||||
$columns = self::normalizeProjectColumns($item['columns'] ?? []);
|
||||
if (empty($columns)) {
|
||||
continue;
|
||||
}
|
||||
$examples[] = [
|
||||
'name' => $name,
|
||||
'columns' => $columns,
|
||||
];
|
||||
if (count($examples) >= 6) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
$context['template_examples'] = $examples;
|
||||
} else {
|
||||
$context['template_examples'] = [];
|
||||
}
|
||||
|
||||
$contextPrompt = self::buildProjectContextPrompt($context);
|
||||
|
||||
$post = json_encode([
|
||||
"model" => "gpt-5-nano",
|
||||
"reasoning_effort" => "minimal",
|
||||
"messages" => [
|
||||
[
|
||||
"role" => "system",
|
||||
"content" => <<<EOF
|
||||
你是一名资深的项目规划顾问,帮助团队快速搭建符合需求的项目。
|
||||
|
||||
生成要求:
|
||||
1. 产出一个简洁、有辨识度的项目名称(不超过18个汉字或36个字符)
|
||||
2. 给出 3 - 8 个项目任务列表,用于看板列或阶段分组
|
||||
3. 任务列表名称保持 4 - 12 个字符,聚焦阶段或责任划分,避免冗长描述
|
||||
4. 结合用户描述的业务特征,必要时可包含里程碑或交付节点
|
||||
5. 尽量参考上下文提供的现有内容或模板,不要与之完全重复
|
||||
|
||||
输出格式:
|
||||
必须严格返回 JSON,禁止携带额外说明或 Markdown 代码块,结构如下:
|
||||
{
|
||||
"name": "项目名称",
|
||||
"columns": ["列表1", "列表2", "列表3"]
|
||||
}
|
||||
|
||||
校验标准:
|
||||
- 列表名称应当互不重复且语义明确
|
||||
- 若上下文包含已有名称或列表,请在此基础上迭代优化
|
||||
EOF
|
||||
],
|
||||
[
|
||||
"role" => "user",
|
||||
"content" => ($contextPrompt ? $contextPrompt . "\n\n" : "") . "请根据以上信息,并结合以下提示词生成适合的项目名称和任务列表:\n\n" . $text
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$ai = new self($post);
|
||||
$ai->setTimeout(45);
|
||||
|
||||
$res = $ai->request();
|
||||
if (Base::isError($res)) {
|
||||
return Base::retError("项目生成失败", $res);
|
||||
}
|
||||
|
||||
$content = $res['data'];
|
||||
$content = preg_replace('/^\s*```json\s*/', '', $content);
|
||||
$content = preg_replace('/\s*```\s*$/', '', $content);
|
||||
|
||||
if (empty($content)) {
|
||||
return Base::retError("项目生成结果为空");
|
||||
}
|
||||
|
||||
$parsedData = Base::json2array($content);
|
||||
if (!$parsedData || !isset($parsedData['name'])) {
|
||||
return Base::retError("项目生成格式错误", $content);
|
||||
}
|
||||
|
||||
$name = trim($parsedData['name']);
|
||||
$columns = self::normalizeProjectColumns($parsedData['columns'] ?? []);
|
||||
|
||||
if ($name === '') {
|
||||
return Base::retError("生成的项目名称为空", $parsedData);
|
||||
}
|
||||
|
||||
if (empty($columns)) {
|
||||
$columns = $context['current_columns'];
|
||||
}
|
||||
|
||||
return Base::retSuccess("success", [
|
||||
'name' => $name,
|
||||
'columns' => $columns,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 openAI 生成聊天消息
|
||||
* @param string $text 用户提供的提示词
|
||||
* @param array $context 上下文信息
|
||||
* @return array
|
||||
*/
|
||||
public static function generateMessage($text, $context = [])
|
||||
{
|
||||
$text = trim((string)$text);
|
||||
if ($text === '') {
|
||||
return Base::retError("消息提示词不能为空");
|
||||
}
|
||||
|
||||
$contextPrompt = self::buildMessageContextPrompt($context);
|
||||
|
||||
$post = json_encode([
|
||||
"model" => "gpt-5-nano",
|
||||
"reasoning_effort" => "minimal",
|
||||
"messages" => [
|
||||
[
|
||||
"role" => "system",
|
||||
"content" => <<<EOF
|
||||
你是一名专业的沟通助手,协助用户编写得体、清晰且具行动指向的即时消息。
|
||||
|
||||
写作要求:
|
||||
1. 根据用户提供的需求与上下文生成完整消息,语气需符合业务沟通场景,保持真诚、礼貌且高效
|
||||
2. 默认使用简洁的短段落,可使用 Markdown 基础格式(加粗、列表、引用)增强结构,但不要输出代码块或 JSON
|
||||
3. 如果上下文包含引用信息或草稿,请在消息中自然呼应相关要点
|
||||
4. 如无特别说明,将消息长度控制在 60-180 字;若需更短或更长,遵循用户描述
|
||||
5. 如需提出行动或问题,请明确表达,避免含糊
|
||||
|
||||
输出规范:
|
||||
- 仅返回可直接发送的消息内容
|
||||
- 禁止在内容前后添加额外说明、标签或引导语
|
||||
EOF
|
||||
],
|
||||
[
|
||||
"role" => "user",
|
||||
"content" => ($contextPrompt ? $contextPrompt . "\n\n" : "") . "请根据以上信息,并结合以下提示词生成一条待发送的消息:\n\n" . $text
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$ai = new self($post);
|
||||
$ai->setTimeout(45);
|
||||
|
||||
$res = $ai->request();
|
||||
if (Base::isError($res)) {
|
||||
return Base::retError("消息生成失败", $res);
|
||||
}
|
||||
|
||||
$content = trim($res['data']);
|
||||
$content = preg_replace('/^\s*```(?:markdown|md|text)?\s*/i', '', $content);
|
||||
$content = preg_replace('/\s*```\s*$/', '', $content);
|
||||
$content = trim($content);
|
||||
|
||||
if ($content === '') {
|
||||
return Base::retError("消息生成结果为空");
|
||||
}
|
||||
|
||||
return Base::retSuccess("success", [
|
||||
'text' => $content,
|
||||
'html' => Base::markdown2html($content),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建任务生成的上下文提示信息
|
||||
* @param array $context 上下文信息
|
||||
* @return string
|
||||
*/
|
||||
private static function buildTaskContextPrompt($context)
|
||||
{
|
||||
$prompts = [];
|
||||
|
||||
// 当前任务信息
|
||||
if (!empty($context['current_title']) || !empty($context['current_content'])) {
|
||||
$prompts[] = "## 当前任务信息";
|
||||
if (!empty($context['current_title'])) {
|
||||
$prompts[] = "当前标题:" . $context['current_title'];
|
||||
}
|
||||
if (!empty($context['current_content'])) {
|
||||
$prompts[] = "当前内容:" . $context['current_content'];
|
||||
}
|
||||
$prompts[] = "请在此基础上优化改进,而不是完全重写。";
|
||||
}
|
||||
|
||||
// 任务模板信息
|
||||
if (!empty($context['template_name']) || !empty($context['template_content'])) {
|
||||
$prompts[] = "## 任务模板要求";
|
||||
if (!empty($context['template_name'])) {
|
||||
$prompts[] = "模板名称:" . $context['template_name'];
|
||||
}
|
||||
if (!empty($context['template_content'])) {
|
||||
$prompts[] = "模板内容结构:" . $context['template_content'];
|
||||
}
|
||||
$prompts[] = "请严格按照此模板的结构和格式要求生成内容。";
|
||||
}
|
||||
|
||||
// 项目状态信息
|
||||
$statusInfo = [];
|
||||
if (!empty($context['has_owner'])) {
|
||||
$statusInfo[] = "已设置负责人";
|
||||
}
|
||||
if (!empty($context['has_time_plan'])) {
|
||||
$statusInfo[] = "已设置计划时间";
|
||||
}
|
||||
if (!empty($context['priority_level'])) {
|
||||
$statusInfo[] = "优先级:" . $context['priority_level'];
|
||||
}
|
||||
|
||||
if (!empty($statusInfo)) {
|
||||
$prompts[] = "## 任务状态";
|
||||
$prompts[] = implode(",", $statusInfo);
|
||||
$prompts[] = "请在任务描述中体现相应的要求和约束。";
|
||||
}
|
||||
|
||||
return empty($prompts) ? "" : implode("\n", $prompts);
|
||||
}
|
||||
|
||||
private static function buildProjectContextPrompt($context)
|
||||
{
|
||||
$prompts = [];
|
||||
|
||||
if (!empty($context['current_name']) || !empty($context['current_columns'])) {
|
||||
$prompts[] = "## 当前项目草稿";
|
||||
if (!empty($context['current_name'])) {
|
||||
$prompts[] = "已有名称:" . $context['current_name'];
|
||||
}
|
||||
if (!empty($context['current_columns'])) {
|
||||
$prompts[] = "现有任务列表:" . implode("、", $context['current_columns']);
|
||||
}
|
||||
$prompts[] = "请在此基础上进行优化和补充。";
|
||||
}
|
||||
|
||||
if (!empty($context['template_examples'])) {
|
||||
$prompts[] = "## 常用模板示例";
|
||||
foreach ($context['template_examples'] as $example) {
|
||||
$line = '';
|
||||
if (!empty($example['name'])) {
|
||||
$line .= $example['name'] . ":";
|
||||
}
|
||||
$line .= implode("、", $example['columns']);
|
||||
$prompts[] = "- " . $line;
|
||||
}
|
||||
$prompts[] = "可以借鉴以上结构,但要结合用户需求生成更贴合的方案。";
|
||||
}
|
||||
|
||||
return empty($prompts) ? "" : implode("\n", $prompts);
|
||||
}
|
||||
|
||||
private static function buildMessageContextPrompt($context)
|
||||
{
|
||||
$prompts = [];
|
||||
|
||||
if (!empty($context['dialog_name']) || !empty($context['dialog_type']) || !empty($context['group_type'])) {
|
||||
$prompts[] = "## 会话信息";
|
||||
if (!empty($context['dialog_name'])) {
|
||||
$prompts[] = "名称:" . Base::cutStr($context['dialog_name'], 60);
|
||||
}
|
||||
if (!empty($context['dialog_type'])) {
|
||||
$typeMap = ['group' => '群聊', 'user' => '单聊'];
|
||||
$prompts[] = "类型:" . ($typeMap[$context['dialog_type']] ?? $context['dialog_type']);
|
||||
}
|
||||
if (!empty($context['group_type'])) {
|
||||
$prompts[] = "分类:" . Base::cutStr($context['group_type'], 60);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($context['members']) && is_array($context['members'])) {
|
||||
$members = array_slice(array_filter($context['members']), 0, 10);
|
||||
if (!empty($members)) {
|
||||
$prompts[] = "## 会话成员";
|
||||
$prompts[] = implode(",", array_map(fn($name) => Base::cutStr($name, 30), $members));
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($context['recent_messages']) && is_array($context['recent_messages'])) {
|
||||
$prompts[] = "## 最近消息";
|
||||
foreach ($context['recent_messages'] as $item) {
|
||||
$sender = Base::cutStr(trim($item['sender'] ?? ''), 40) ?: '成员';
|
||||
$summary = Base::cutStr(trim($item['summary'] ?? ''), 120);
|
||||
if ($summary !== '') {
|
||||
$prompts[] = "- {$sender}:{$summary}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($context['quote_summary'])) {
|
||||
$prompts[] = "## 引用消息";
|
||||
$quoteUser = Base::cutStr(trim($context['quote_user'] ?? ''), 40);
|
||||
$quoteText = Base::cutStr(trim($context['quote_summary']), 200);
|
||||
if ($quoteUser !== '') {
|
||||
$prompts[] = "{$quoteUser}:{$quoteText}";
|
||||
} else {
|
||||
$prompts[] = $quoteText;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($context['current_draft'])) {
|
||||
$prompts[] = "## 当前草稿";
|
||||
$prompts[] = Base::cutStr(trim($context['current_draft']), 200);
|
||||
}
|
||||
|
||||
return empty($prompts) ? "" : implode("\n", $prompts);
|
||||
}
|
||||
|
||||
private static function normalizeProjectColumns($columns)
|
||||
{
|
||||
if (is_string($columns)) {
|
||||
$columns = preg_split('/[\n\r,,;;|]/u', $columns);
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
if (is_array($columns)) {
|
||||
foreach ($columns as $item) {
|
||||
if (is_array($item)) {
|
||||
$item = $item['name'] ?? $item['title'] ?? reset($item);
|
||||
}
|
||||
$item = trim((string)$item);
|
||||
if ($item === '') {
|
||||
continue;
|
||||
}
|
||||
$item = mb_substr($item, 0, 30);
|
||||
if (!in_array($item, $normalized)) {
|
||||
$normalized[] = $item;
|
||||
}
|
||||
if (count($normalized) >= 8) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 openAI 生成职场笑话、心灵鸡汤
|
||||
* @param bool $noCache 是否禁用缓存
|
||||
* @return array 返回20个笑话和20个心灵鸡汤
|
||||
*/
|
||||
public static function generateJokeAndSoup($noCache = false)
|
||||
{
|
||||
$cacheKey = "openAIJokeAndSoup::" . md5(date('Y-m-d'));
|
||||
if ($noCache) {
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
|
||||
$result = Cache::remember($cacheKey, Carbon::now()->addHours(6), function () {
|
||||
$post = json_encode([
|
||||
"model" => "gpt-5-nano",
|
||||
"reasoning_effort" => "minimal",
|
||||
"messages" => [
|
||||
[
|
||||
"role" => "system",
|
||||
"content" => <<<EOF
|
||||
你是一个专业的内容生成器。
|
||||
|
||||
要求:
|
||||
1. 笑话要幽默风趣,适合职场环境,内容积极正面
|
||||
2. 心灵鸡汤要励志温暖,适合职场人士阅读
|
||||
3. 每个笑话和鸡汤都要简洁明了,尽量不超过100字
|
||||
4. 必须严格按照以下JSON格式返回,不要markdown格式,不要包含任何其他内容:
|
||||
|
||||
{
|
||||
"jokes": [
|
||||
"笑话内容1",
|
||||
"笑话内容2",
|
||||
...
|
||||
],
|
||||
"soups": [
|
||||
"心灵鸡汤内容1",
|
||||
"心灵鸡汤内容2",
|
||||
...
|
||||
]
|
||||
}
|
||||
EOF
|
||||
],
|
||||
[
|
||||
"role" => "user",
|
||||
"content" => "请生成20个职场笑话和20个心灵鸡汤"
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
$ai = new self($post);
|
||||
$ai->setTimeout(120);
|
||||
|
||||
$res = $ai->request();
|
||||
if (Base::isError($res)) {
|
||||
return Base::retError("生成失败", $res);
|
||||
}
|
||||
|
||||
// 清理可能的markdown代码块标记
|
||||
$content = $res['data'];
|
||||
$content = preg_replace('/^\s*```json\s*/', '', $content);
|
||||
$content = preg_replace('/\s*```\s*$/', '', $content);
|
||||
if (empty($content)) {
|
||||
return Base::retError("翻译结果为空");
|
||||
}
|
||||
|
||||
// 解析JSON
|
||||
$parsedData = Base::json2array($content);
|
||||
if (!$parsedData || !isset($parsedData['jokes']) || !isset($parsedData['soups'])) {
|
||||
return Base::retError("生成内容格式错误", $content);
|
||||
}
|
||||
|
||||
// 验证数据完整性
|
||||
if (!is_array($parsedData['jokes']) || !is_array($parsedData['soups'])) {
|
||||
return Base::retError("生成内容格式错误", $parsedData);
|
||||
}
|
||||
|
||||
// 过滤空内容并确保有内容
|
||||
$jokes = array_filter(array_map('trim', $parsedData['jokes']));
|
||||
$soups = array_filter(array_map('trim', $parsedData['soups']));
|
||||
|
||||
if (empty($jokes) || empty($soups)) {
|
||||
return Base::retError("生成内容为空", $parsedData);
|
||||
}
|
||||
|
||||
return Base::retSuccess("success", [
|
||||
'jokes' => array_values($jokes),
|
||||
'soups' => array_values($soups),
|
||||
'total_jokes' => count($jokes),
|
||||
'total_soups' => count($soups),
|
||||
'generated_at' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
});
|
||||
|
||||
if (Base::isError($result)) {
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
return $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']
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1404,11 +1404,13 @@ class Base
|
||||
*/
|
||||
public static function ajaxError($msg, $data = [], $ret = 0, $abortCode = 404)
|
||||
{
|
||||
if (Request::header('Content-Type') === 'application/json') {
|
||||
return Base::retError($msg, $data, $ret);
|
||||
} else {
|
||||
abort($abortCode, $msg);
|
||||
if (Request::header('Content-Type') !== 'application/json') {
|
||||
$translateMsg = Doo::translate($msg);
|
||||
abort($abortCode, $translateMsg, [
|
||||
'X-Error-Message-Base64' => base64_encode($translateMsg),
|
||||
]);
|
||||
}
|
||||
return Base::retError($msg, $data, $ret);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1858,12 +1860,22 @@ class Base
|
||||
* 获取每页数量
|
||||
* @param $max
|
||||
* @param $default
|
||||
* @param string $inputName
|
||||
* @param string|array $inputName
|
||||
* @return mixed
|
||||
*/
|
||||
public static function getPaginate($max, $default, $inputName = 'pagesize')
|
||||
public static function getPaginate($max, $default, $inputName = ['pagesize', 'take'])
|
||||
{
|
||||
return Min(Max(Base::nullShow(Request::input($inputName), $default), 1), $max);
|
||||
$value = null;
|
||||
if (!is_array($inputName)) {
|
||||
$inputName = [$inputName];
|
||||
}
|
||||
foreach ($inputName as $name) {
|
||||
if (Request::exists($name)) {
|
||||
$value = Request::input($name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Min(Max(Base::nullShow($value, $default), 1), $max);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3040,7 +3052,7 @@ class Base
|
||||
{
|
||||
try {
|
||||
$converter = new CommonMarkConverter();
|
||||
return $converter->convert($markdown);
|
||||
return $converter->convert($markdown)->getContent();
|
||||
} catch (\League\CommonMark\Exception\CommonMarkException $e) {
|
||||
return $markdown;
|
||||
}
|
||||
@@ -3049,15 +3061,73 @@ class Base
|
||||
/**
|
||||
* html 转 MD(markdown)
|
||||
* @param $html
|
||||
* @param array $options
|
||||
* @return mixed|string
|
||||
*/
|
||||
public static function html2markdown($html)
|
||||
public static function html2markdown($html, $options = [])
|
||||
{
|
||||
try {
|
||||
$converter = new HtmlConverter();
|
||||
$converter = new HtmlConverter($options);
|
||||
return $converter->convert($html);
|
||||
} catch (\Exception) {
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 实时读取 .env 配置(不受配置缓存影响)
|
||||
* @param string $key 配置键名
|
||||
* @param mixed $default 默认值
|
||||
* @return mixed
|
||||
*/
|
||||
public static function liveEnv($key, $default = null)
|
||||
{
|
||||
$envFile = base_path('.env');
|
||||
if (!file_exists($envFile)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$envContent = file_get_contents($envFile);
|
||||
$lines = explode("\n", $envContent);
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
|
||||
// 跳过注释和空行
|
||||
if (empty($line) || str_starts_with($line, '#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 解析 KEY=VALUE
|
||||
if (str_contains($line, '=')) {
|
||||
[$envKey, $envValue] = explode('=', $line, 2);
|
||||
$envKey = trim($envKey);
|
||||
|
||||
if ($envKey === $key) {
|
||||
$envValue = trim($envValue);
|
||||
|
||||
// 移除引号
|
||||
if (preg_match('/^(["\'])(.*)\1$/', $envValue, $matches)) {
|
||||
$envValue = $matches[2];
|
||||
}
|
||||
|
||||
// 处理布尔值
|
||||
$lowerValue = strtolower($envValue);
|
||||
if ($lowerValue === 'true') {
|
||||
return true;
|
||||
}
|
||||
if ($lowerValue === 'false') {
|
||||
return false;
|
||||
}
|
||||
if ($lowerValue === 'null' || $lowerValue === '(null)') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $envValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
||||
83
app/Module/ClientContext.php
Normal file
83
app/Module/ClientContext.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module;
|
||||
|
||||
/**
|
||||
* 客户端上下文
|
||||
*/
|
||||
class ClientContext
|
||||
{
|
||||
public array $context = [];
|
||||
public float $createdAt = 0;
|
||||
public float $updatedAt = 0;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = microtime(true);
|
||||
$this->updatedAt = microtime(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置上下文
|
||||
* @param string $key
|
||||
* @param mixed $value
|
||||
* @return void
|
||||
*/
|
||||
public function set(string $key, mixed $value): void
|
||||
{
|
||||
$this->context[$key] = $value;
|
||||
$this->updatedAt = microtime(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量设置上下文
|
||||
* @param array $data
|
||||
* @return void
|
||||
*/
|
||||
public function setMultiple(array $data): void
|
||||
{
|
||||
foreach ($data as $key => $value) {
|
||||
$this->context[$key] = $value;
|
||||
}
|
||||
$this->updatedAt = microtime(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取上下文
|
||||
* @param string $key
|
||||
* @param mixed $default
|
||||
* @return mixed
|
||||
*/
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->context[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断上下文是否存在
|
||||
* @param string $key
|
||||
* @return bool
|
||||
*/
|
||||
public function has(string $key): bool
|
||||
{
|
||||
return isset($this->context[$key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新上下文
|
||||
* @return void
|
||||
*/
|
||||
public function update(): void
|
||||
{
|
||||
$this->updatedAt = microtime(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除上下文
|
||||
* @return void
|
||||
*/
|
||||
public function clear(): void
|
||||
{
|
||||
$this->context = [];
|
||||
}
|
||||
}
|
||||
@@ -2,73 +2,40 @@
|
||||
|
||||
namespace App\Module;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Models\User;
|
||||
use Cache;
|
||||
use Carbon\Carbon;
|
||||
use FFI;
|
||||
use App\Module\Interface\DooSo;
|
||||
use App\Services\RequestContext;
|
||||
|
||||
class Doo
|
||||
{
|
||||
private static $doo;
|
||||
private static $userLanguage = "";
|
||||
private const DOO_INSTANCE = 'doo_instance';
|
||||
private const DOO_LANGUAGE = 'doo_language';
|
||||
|
||||
/**
|
||||
* char转为字符串
|
||||
* @param $text
|
||||
* @return string
|
||||
*/
|
||||
private static function string($text): string
|
||||
{
|
||||
return FFI::string($text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 装载
|
||||
* 加载Doo实例
|
||||
* - 如果已经存在,则直接返回
|
||||
* - 否则,创建一个新的FFI实例,并初始化
|
||||
* @param $token
|
||||
* @param $language
|
||||
* @return DooSo
|
||||
*/
|
||||
public static function load($token = null, $language = null)
|
||||
public static function load($token = null, $language = null): DooSo
|
||||
{
|
||||
self::$doo = FFI::cdef(<<<EOF
|
||||
void initialize(char* work, char* token, char* lang);
|
||||
char* license();
|
||||
char* licenseDecode(char* license);
|
||||
char* licenseSave(char* license);
|
||||
int userId();
|
||||
char* userExpiredAt();
|
||||
char* userEmail();
|
||||
char* userEncrypt();
|
||||
char* userToken();
|
||||
char* userCreate(char* email, char* password);
|
||||
char* tokenEncode(int userid, char* email, char* encrypt, int days);
|
||||
char* tokenDecode(char* val);
|
||||
char* translate(char* val, char* val);
|
||||
char* md5s(char* text, char* password);
|
||||
char* macs();
|
||||
char* dooSN();
|
||||
char* version();
|
||||
char* pgpGenerateKeyPair(char* name, char* email, char* passphrase);
|
||||
char* pgpEncrypt(char* plainText, char* publicKey);
|
||||
char* pgpDecrypt(char* cipherText, char* privateKey, char* passphrase);
|
||||
EOF, "/usr/lib/doo/doo.so");
|
||||
$token = $token ?: Base::token();
|
||||
$language = $language ?: Base::headerOrInput('language');
|
||||
self::$doo->initialize("/var/www", $token, $language);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取实例
|
||||
* @param $token
|
||||
* @param $language
|
||||
* @return mixed
|
||||
*/
|
||||
public static function doo($token = null, $language = null)
|
||||
{
|
||||
if (self::$doo == null) {
|
||||
self::load($token, $language);
|
||||
if (RequestContext::has(self::DOO_INSTANCE)) {
|
||||
return RequestContext::get(self::DOO_INSTANCE);
|
||||
}
|
||||
return self::$doo;
|
||||
|
||||
$request = request();
|
||||
if ($request && method_exists($request, 'header')) {
|
||||
$token = $token ?: Base::token();
|
||||
$language = $language ?: Base::headerOrInput('language');
|
||||
}
|
||||
$instance = new DooSo($token, $language);
|
||||
|
||||
RequestContext::set(self::DOO_INSTANCE, $instance);
|
||||
RequestContext::set(self::DOO_LANGUAGE, $language);
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,41 +44,7 @@ class Doo
|
||||
*/
|
||||
public static function license(): array
|
||||
{
|
||||
$array = Base::json2array(self::string(self::doo()->license()));
|
||||
|
||||
$ips = explode(",", $array['ip']);
|
||||
$array['ip'] = [];
|
||||
foreach ($ips as $ip) {
|
||||
if (Base::is_ipv4($ip)) {
|
||||
$array['ip'][] = $ip;
|
||||
}
|
||||
}
|
||||
|
||||
$domains = explode(",", $array['domain']);
|
||||
$array['domain'] = [];
|
||||
foreach ($domains as $domain) {
|
||||
if (Base::is_domain($domain)) {
|
||||
$array['domain'][] = $domain;
|
||||
}
|
||||
}
|
||||
|
||||
$macs = explode(",", $array['mac']);
|
||||
$array['mac'] = [];
|
||||
foreach ($macs as $mac) {
|
||||
if (Base::isMac($mac)) {
|
||||
$array['mac'][] = $mac;
|
||||
}
|
||||
}
|
||||
|
||||
$emails = explode(",", $array['email']);
|
||||
$array['email'] = [];
|
||||
foreach ($emails as $email) {
|
||||
if (Base::isEmail($email)) {
|
||||
$array['email'][] = $email;
|
||||
}
|
||||
}
|
||||
|
||||
return $array;
|
||||
return self::load()->license();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,26 +72,13 @@ class Doo
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析License
|
||||
* @param $license
|
||||
* @return array
|
||||
*/
|
||||
public static function licenseDecode($license): array
|
||||
{
|
||||
return Base::json2array(self::string(self::doo()->licenseDecode($license)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存License
|
||||
* @param $license
|
||||
*/
|
||||
public static function licenseSave($license): void
|
||||
{
|
||||
$res = self::string(self::doo()->licenseSave($license));
|
||||
if ($res != 'success') {
|
||||
throw new ApiException($res ?: 'LICENSE 保存失败');
|
||||
}
|
||||
self::load()->licenseSave($license);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,7 +87,7 @@ class Doo
|
||||
*/
|
||||
public static function userId(): int
|
||||
{
|
||||
return intval(self::doo()->userId());
|
||||
return self::load()->userId();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,8 +96,7 @@ class Doo
|
||||
*/
|
||||
public static function userExpired(): bool
|
||||
{
|
||||
$expiredAt = self::userExpiredAt();
|
||||
return $expiredAt && Carbon::parse($expiredAt)->isBefore(Carbon::now());
|
||||
return self::load()->userExpired();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,8 +105,7 @@ class Doo
|
||||
*/
|
||||
public static function userExpiredAt(): ?string
|
||||
{
|
||||
$expiredAt = self::string(self::doo()->userExpiredAt());
|
||||
return $expiredAt === 'forever' ? null : $expiredAt;
|
||||
return self::load()->userExpiredAt();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -196,7 +114,7 @@ class Doo
|
||||
*/
|
||||
public static function userEmail(): string
|
||||
{
|
||||
return self::string(self::doo()->userEmail());
|
||||
return self::load()->userEmail();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -205,7 +123,7 @@ class Doo
|
||||
*/
|
||||
public static function userEncrypt(): string
|
||||
{
|
||||
return self::string(self::doo()->userEncrypt());
|
||||
return self::load()->userEncrypt();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -214,7 +132,7 @@ class Doo
|
||||
*/
|
||||
public static function userToken(): string
|
||||
{
|
||||
return self::string(self::doo()->userToken());
|
||||
return self::load()->userToken();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -225,23 +143,7 @@ class Doo
|
||||
*/
|
||||
public static function userCreate($email, $password): User|null
|
||||
{
|
||||
$data = Base::json2array(self::string(self::doo()->userCreate($email, $password)));
|
||||
if (Base::isError($data)) {
|
||||
throw new ApiException($data['msg'] ?: '注册失败');
|
||||
}
|
||||
if (\DB::transactionLevel() > 0) {
|
||||
try {
|
||||
\DB::commit();
|
||||
\DB::beginTransaction();
|
||||
} catch (\Throwable) {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
$user = User::whereEmail($email)->first();
|
||||
if (empty($user)) {
|
||||
throw new ApiException('注册失败');
|
||||
}
|
||||
return $user;
|
||||
return self::load()->userCreate($email, $password);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -254,7 +156,7 @@ class Doo
|
||||
*/
|
||||
public static function tokenEncode($userid, $email, $encrypt, int $days = 15): string
|
||||
{
|
||||
return self::string(self::doo()->tokenEncode($userid, $email, $encrypt, $days));
|
||||
return self::load()->tokenEncode($userid, $email, $encrypt, $days);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -264,40 +166,42 @@ class Doo
|
||||
*/
|
||||
public static function tokenDecode($token): array
|
||||
{
|
||||
$array = Base::json2array(self::string(self::doo()->tokenDecode($token)));
|
||||
$array['expired_at'] = $array['expired_at'] === 'forever' ? null : $array['expired_at'];
|
||||
return $array;
|
||||
return self::load()->tokenDecode($token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 翻译
|
||||
* @param $text
|
||||
* @param string $lang
|
||||
* @param ?string $lang
|
||||
* @return string
|
||||
*/
|
||||
public static function translate($text, string $lang = ""): string
|
||||
public static function translate($text, ?string $lang = ""): string
|
||||
{
|
||||
return self::string(self::doo()->translate($text, $lang ?: self::$userLanguage));
|
||||
if (empty($lang)) {
|
||||
$lang = RequestContext::get(self::DOO_LANGUAGE);
|
||||
}
|
||||
return self::load()->translate($text, $lang);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置语言
|
||||
* @param string|integer $lang 语言 或 会员ID
|
||||
* @param int|string $lang 语言 或 会员ID
|
||||
* @return void
|
||||
*/
|
||||
public static function setLanguage($lang) {
|
||||
public static function setLanguage(int|string $lang): void
|
||||
{
|
||||
if (Base::isNumber($lang)) {
|
||||
$lang = User::find(intval($lang))?->lang ?: "";
|
||||
}
|
||||
self::$userLanguage = $lang;
|
||||
RequestContext::set(self::DOO_LANGUAGE, $lang);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取语言列表 或 语言名称
|
||||
* @param string|false $lang
|
||||
* @param bool|string $lang
|
||||
* @return string|string[]
|
||||
*/
|
||||
public static function getLanguages($lang = false)
|
||||
public static function getLanguages(bool|string $lang = false): array|string
|
||||
{
|
||||
$array = [
|
||||
"zh" => "简体中文",
|
||||
@@ -334,7 +238,7 @@ class Doo
|
||||
*/
|
||||
public static function md5s($text, string $password = ""): string
|
||||
{
|
||||
return self::string(self::doo()->md5s($text, $password));
|
||||
return self::load()->md5s($text, $password);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -343,14 +247,7 @@ class Doo
|
||||
*/
|
||||
public static function macs(): array
|
||||
{
|
||||
$macs = explode(",", self::string(self::doo()->macs()));
|
||||
$array = [];
|
||||
foreach ($macs as $mac) {
|
||||
if (Base::isMac($mac)) {
|
||||
$array[] = $mac;
|
||||
}
|
||||
}
|
||||
return $array;
|
||||
return self::load()->macs();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -359,7 +256,7 @@ class Doo
|
||||
*/
|
||||
public static function dooSN(): string
|
||||
{
|
||||
return self::string(self::doo()->dooSN());
|
||||
return self::load()->dooSN();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -368,7 +265,7 @@ class Doo
|
||||
*/
|
||||
public static function dooVersion(): string
|
||||
{
|
||||
return self::string(self::doo()->version());
|
||||
return self::load()->dooVersion();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -380,7 +277,7 @@ class Doo
|
||||
*/
|
||||
public static function pgpGenerateKeyPair($name, $email, string $passphrase = ""): array
|
||||
{
|
||||
return Base::json2array(self::string(self::doo()->pgpGenerateKeyPair($name, $email, $passphrase)));
|
||||
return self::load()->pgpGenerateKeyPair($name, $email, $passphrase);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -391,11 +288,7 @@ class Doo
|
||||
*/
|
||||
public static function pgpEncrypt($plaintext, $publicKey): string
|
||||
{
|
||||
if (strlen($publicKey) < 50) {
|
||||
$keyCache = Base::json2array(Cache::get("KeyPair::" . $publicKey));
|
||||
$publicKey = $keyCache['public_key'];
|
||||
}
|
||||
return self::string(self::doo()->pgpEncrypt($plaintext, $publicKey));
|
||||
return self::load()->pgpEncrypt($plaintext, $publicKey);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -407,12 +300,7 @@ class Doo
|
||||
*/
|
||||
public static function pgpDecrypt($encryptedText, $privateKey, $passphrase = null): string
|
||||
{
|
||||
if (strlen($privateKey) < 50) {
|
||||
$keyCache = Base::json2array(Cache::get("KeyPair::" . $privateKey));
|
||||
$privateKey = $keyCache['private_key'];
|
||||
$passphrase = $keyCache['passphrase'];
|
||||
}
|
||||
return self::string(self::doo()->pgpDecrypt($encryptedText, $privateKey, $passphrase));
|
||||
return self::load()->pgpDecrypt($encryptedText, $privateKey, $passphrase);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -423,9 +311,7 @@ class Doo
|
||||
*/
|
||||
public static function pgpEncryptApi($plaintext, $publicKey): string
|
||||
{
|
||||
$content = Base::array2json($plaintext);
|
||||
$content = self::pgpEncrypt($content, $publicKey);
|
||||
return preg_replace("/\s*-----(BEGIN|END) PGP MESSAGE-----\s*/i", "", $content);
|
||||
return self::load()->pgpEncryptApi($plaintext, $publicKey);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -437,9 +323,7 @@ class Doo
|
||||
*/
|
||||
public static function pgpDecryptApi($encryptedText, $privateKey, $passphrase = null): array
|
||||
{
|
||||
$content = "-----BEGIN PGP MESSAGE-----\n\n" . $encryptedText . "\n-----END PGP MESSAGE-----";
|
||||
$content = self::pgpDecrypt($content, $privateKey, $passphrase);
|
||||
return Base::json2array($content);
|
||||
return self::load()->pgpDecryptApi($encryptedText, $privateKey, $passphrase);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -449,24 +333,7 @@ class Doo
|
||||
*/
|
||||
public static function pgpParseStr($string): array
|
||||
{
|
||||
$array = [
|
||||
'encrypt_type' => '',
|
||||
'encrypt_id' => '',
|
||||
'client_type' => '',
|
||||
'client_key' => '',
|
||||
];
|
||||
$string = str_replace(";", "&", $string);
|
||||
parse_str($string, $params);
|
||||
foreach ($params as $key => $value) {
|
||||
$key = strtolower(trim($key));
|
||||
if ($key) {
|
||||
$array[$key] = trim($value);
|
||||
}
|
||||
}
|
||||
if ($array['client_type'] === 'pgp' && $array['client_key']) {
|
||||
$array['client_key'] = self::pgpPublicFormat($array['client_key']);
|
||||
}
|
||||
return $array;
|
||||
return self::load()->pgpParseStr($string);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -476,10 +343,6 @@ class Doo
|
||||
*/
|
||||
public static function pgpPublicFormat($key): string
|
||||
{
|
||||
$key = str_replace(["-", "_", "$"], ["+", "/", "\n"], $key);
|
||||
if (!str_contains($key, '-----BEGIN PGP PUBLIC KEY BLOCK-----')) {
|
||||
$key = "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n" . $key . "\n-----END PGP PUBLIC KEY BLOCK-----";
|
||||
}
|
||||
return $key;
|
||||
return self::load()->pgpPublicFormat($key);
|
||||
}
|
||||
}
|
||||
|
||||
37
app/Module/Down.php
Normal file
37
app/Module/Down.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module;
|
||||
|
||||
use Request;
|
||||
use Cache;
|
||||
|
||||
class Down
|
||||
{
|
||||
/**
|
||||
* @param $data
|
||||
* @param null $ttl
|
||||
* @return string
|
||||
*/
|
||||
public static function cache_encode($data, $ttl = null): string
|
||||
{
|
||||
$base64 = base64_encode(Base::array2string($data));
|
||||
$key = md5($base64);
|
||||
Cache::put("down::{$key}", $base64, $ttl ?: now()->addHour());
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ?string $inputName
|
||||
* @return array
|
||||
*/
|
||||
public static function cache_decode(?string $inputName = 'key'): array
|
||||
{
|
||||
$key = Request::input($inputName);
|
||||
$base64 = Cache::get("down::{$key}");
|
||||
if (empty($base64)) {
|
||||
return Base::ajaxError("请求已过期,请重新导出!", [], 0, 403);
|
||||
}
|
||||
//
|
||||
return Base::string2array(base64_decode($base64));
|
||||
}
|
||||
}
|
||||
@@ -4,319 +4,12 @@ namespace App\Module;
|
||||
|
||||
use Cache;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
|
||||
/**
|
||||
* 外网资源请求
|
||||
*/
|
||||
class Extranet
|
||||
{
|
||||
/**
|
||||
* 通过 openAI 语音转文字
|
||||
* @param string $filePath
|
||||
* @param array $extParams
|
||||
* @return array
|
||||
*/
|
||||
public static function openAItranscriptions($filePath, $extParams = [])
|
||||
{
|
||||
if (!file_exists($filePath)) {
|
||||
return Base::retError("语音文件不存在");
|
||||
}
|
||||
$systemSetting = Base::setting('system');
|
||||
$aibotSetting = Base::setting('aibotSetting');
|
||||
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'];
|
||||
$extra['CURLOPT_PROXYTYPE'] = str_contains($aibotSetting['openai_agency'], 'socks') ? CURLPROXY_SOCKS5 : CURLPROXY_HTTP;
|
||||
}
|
||||
$post = array_merge($extParams, [
|
||||
'file' => new \CURLFile($filePath),
|
||||
'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);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 openAI 翻译
|
||||
* @param $text
|
||||
* @param $targetLanguage
|
||||
* @return array
|
||||
*/
|
||||
public static function openAItranslations($text, $targetLanguage)
|
||||
{
|
||||
$systemSetting = Base::setting('system');
|
||||
$aibotSetting = Base::setting('aibotSetting');
|
||||
if ($systemSetting['translation'] !== 'open' || empty($aibotSetting['openai_key'])) {
|
||||
return Base::retError("翻译功能未开启");
|
||||
}
|
||||
$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;
|
||||
}
|
||||
$post = json_encode([
|
||||
"model" => "gpt-4o-mini",
|
||||
"messages" => [
|
||||
[
|
||||
"role" => "system",
|
||||
"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",
|
||||
"content" => $text
|
||||
]
|
||||
]
|
||||
]), $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('/^\"|\"$/', '', $result);
|
||||
if (empty($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
|
||||
* @return array
|
||||
*/
|
||||
public static function getIpGcj02(string $ip = ''): array
|
||||
{
|
||||
if (empty($ip)) {
|
||||
$ip = Base::getIp();
|
||||
}
|
||||
$cacheKey = "getIpPoint::" . md5($ip);
|
||||
$result = Cache::rememberForever($cacheKey, function () use ($ip) {
|
||||
return Ihttp::ihttp_request("https://www.ifreesite.com/ipaddress/address.php?q=" . $ip, [], [], 12);
|
||||
});
|
||||
if (Base::isError($result)) {
|
||||
Cache::forget($cacheKey);
|
||||
return $result;
|
||||
}
|
||||
$data = $result['data'];
|
||||
$lastPos = strrpos($data, ',');
|
||||
$long = floatval(Base::getMiddle(substr($data, $lastPos + 1), null, ')'));
|
||||
$lat = floatval(Base::getMiddle(substr($data, strrpos(substr($data, 0, $lastPos), ',') + 1), null, ','));
|
||||
return Base::retSuccess("success", [
|
||||
'long' => $long,
|
||||
'lat' => $lat,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 百度接口:根据ip获取经纬度
|
||||
* @param string $ip
|
||||
* @return array
|
||||
*/
|
||||
public static function getIpGcj02ByBaidu(string $ip = ''): array
|
||||
{
|
||||
if (empty($ip)) {
|
||||
$ip = Base::getIp();
|
||||
}
|
||||
|
||||
$cacheKey = "getIpPoint::" . md5($ip);
|
||||
$result = Cache::rememberForever($cacheKey, function () use ($ip) {
|
||||
$ak = Config::get('app.baidu_app_key');
|
||||
$url = 'http://api.map.baidu.com/location/ip?ak=' . $ak . '&ip=' . $ip . '&coor=bd09ll';
|
||||
return Ihttp::ihttp_request($url, [], [], 12);
|
||||
});
|
||||
|
||||
if (Base::isError($result)) {
|
||||
Cache::forget($cacheKey);
|
||||
return $result;
|
||||
}
|
||||
$data = json_decode($result['data'], true);
|
||||
|
||||
// x坐标纬度, y坐标经度
|
||||
$long = Arr::get($data, 'content.point.x');
|
||||
$lat = Arr::get($data, 'content.point.y');
|
||||
return Base::retSuccess("success", [
|
||||
'long' => $long,
|
||||
'lat' => $lat,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取IP地址详情
|
||||
* @param string $ip
|
||||
* @return array
|
||||
*/
|
||||
public static function getIpInfo(string $ip = ''): array
|
||||
{
|
||||
if (empty($ip)) {
|
||||
$ip = Base::getIp();
|
||||
}
|
||||
$cacheKey = "getIpInfo::" . md5($ip);
|
||||
$result = Cache::rememberForever($cacheKey, function () use ($ip) {
|
||||
return Ihttp::ihttp_request("http://ip.taobao.com/service/getIpInfo.php?accessKey=alibaba-inc&ip=" . $ip, [], [], 12);
|
||||
});
|
||||
if (Base::isError($result)) {
|
||||
Cache::forget($cacheKey);
|
||||
return $result;
|
||||
}
|
||||
$data = json_decode($result['data'], true);
|
||||
if (!is_array($data) || intval($data['code']) != 0) {
|
||||
Cache::forget($cacheKey);
|
||||
return Base::retError("error ip: -1");
|
||||
}
|
||||
$data = $data['data'];
|
||||
if (!is_array($data) || !isset($data['country'])) {
|
||||
return Base::retError("error ip: -2");
|
||||
}
|
||||
$data['text'] = $data['country'];
|
||||
$data['textSmall'] = $data['country'];
|
||||
if ($data['region'] && $data['region'] != $data['country'] && $data['region'] != "XX") {
|
||||
$data['text'] .= " " . $data['region'];
|
||||
$data['textSmall'] = $data['region'];
|
||||
}
|
||||
if ($data['city'] && $data['city'] != $data['region'] && $data['city'] != "XX") {
|
||||
$data['text'] .= " " . $data['city'];
|
||||
$data['textSmall'] .= " " . $data['city'];
|
||||
}
|
||||
if ($data['county'] && $data['county'] != $data['city'] && $data['county'] != "XX") {
|
||||
$data['text'] .= " " . $data['county'];
|
||||
$data['textSmall'] .= " " . $data['county'];
|
||||
}
|
||||
return Base::retSuccess("success", $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否工作日
|
||||
* @param string $Ymd 年月日(如:20220102)
|
||||
@@ -372,125 +65,6 @@ class Extranet
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 随机笑话接口
|
||||
* @return string
|
||||
*/
|
||||
public static function randJoke(): string
|
||||
{
|
||||
$data = self::curl("https://hmajax.itheima.net/api/randjoke");
|
||||
$data = Base::json2array($data);
|
||||
if ($data['message'] === '获取成功' && $text = trim($data['data'])) {
|
||||
return $text;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 心灵鸡汤
|
||||
* @return string
|
||||
*/
|
||||
public static function soups(): string
|
||||
{
|
||||
$data = self::curl("https://hmajax.itheima.net/api/ambition");
|
||||
$data = Base::json2array($data);
|
||||
if ($data['message'] === '获取成功' && $text = trim($data['data'])) {
|
||||
return $text;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 签到机器人网络内容
|
||||
* @param $type
|
||||
* @return string
|
||||
*/
|
||||
public static function checkinBotQuickMsg($type): string
|
||||
{
|
||||
$text = "维护中...";
|
||||
switch ($type) {
|
||||
case "it":
|
||||
$data = self::curl('http://vvhan.api.hitosea.com/api/hotlist?type=itNews', 3600);
|
||||
if ($data = Base::json2array($data)) {
|
||||
$i = 1;
|
||||
$array = array_map(function ($item) use (&$i) {
|
||||
if ($item['title'] && $item['desc']) {
|
||||
return "<p>" . ($i++) . ". <strong><a href='{$item['mobilUrl']}' target='_blank'>{$item['title']}</a></strong></p><p>{$item['desc']}</p>";
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}, $data['data']);
|
||||
$array = array_values(array_filter($array));
|
||||
if ($array) {
|
||||
array_unshift($array, "<p><strong>{$data['title']}</strong>({$data['update_time']})</p>");
|
||||
$text = implode("<p> </p>", $array);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "36ke":
|
||||
$data = self::curl('http://vvhan.api.hitosea.com/api/hotlist?type=36Ke', 3600);
|
||||
if ($data = Base::json2array($data)) {
|
||||
$i = 1;
|
||||
$array = array_map(function ($item) use (&$i) {
|
||||
if ($item['title'] && $item['desc']) {
|
||||
return "<p>" . ($i++) . ". <strong><a href='{$item['mobilUrl']}' target='_blank'>{$item['title']}</a></strong></p><p>{$item['desc']}</p>";
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}, $data['data']);
|
||||
$array = array_values(array_filter($array));
|
||||
if ($array) {
|
||||
array_unshift($array, "<p><strong>{$data['title']}</strong>({$data['update_time']})</p>");
|
||||
$text = implode("<p> </p>", $array);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "60s":
|
||||
$data = self::curl('http://vvhan.api.hitosea.com/api/60s?type=json', 3600);
|
||||
if ($data = Base::json2array($data)) {
|
||||
$i = 1;
|
||||
$array = array_map(function ($item) use (&$i) {
|
||||
if ($item) {
|
||||
return "<p>" . ($i++) . ". {$item}</p>";
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}, $data['data']);
|
||||
$array = array_values(array_filter($array));
|
||||
if ($array) {
|
||||
array_unshift($array, "<p><strong>{$data['name']}</strong>({$data['time'][0]})</p>");
|
||||
$text = implode("<p> </p>", $array);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "joke":
|
||||
$text = "笑话被掏空";
|
||||
$data = self::curl('http://vvhan.api.hitosea.com/api/joke?type=json', 5);
|
||||
if ($data = Base::json2array($data)) {
|
||||
if ($data = trim($data['joke'])) {
|
||||
$text = "开心笑话:{$data}";
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "soup":
|
||||
$text = "鸡汤分完了";
|
||||
$data = self::curl('https://api.ayfre.com/jt/?type=bot', 5);
|
||||
if ($data) {
|
||||
$text = "心灵鸡汤:{$data}";
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
$text = "";
|
||||
break;
|
||||
}
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取搜狗表情包
|
||||
* @param $keyword
|
||||
|
||||
412
app/Module/Interface/DooSo.php
Normal file
412
app/Module/Interface/DooSo.php
Normal file
@@ -0,0 +1,412 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module\Interface;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Module\Base;
|
||||
use App\Models\User;
|
||||
use Cache;
|
||||
use Carbon\Carbon;
|
||||
use DB;
|
||||
use FFI;
|
||||
use FFI\CData;
|
||||
use FFI\Exception;
|
||||
use Throwable;
|
||||
|
||||
class DooSo
|
||||
{
|
||||
private mixed $so;
|
||||
|
||||
public function __construct($token = null, $language = null)
|
||||
{
|
||||
$this->so = FFI::cdef(<<<EOF
|
||||
void initialize(char* work, char* token, char* lang);
|
||||
char* license();
|
||||
char* licenseDecode(char* license);
|
||||
char* licenseSave(char* license);
|
||||
int userId();
|
||||
char* userExpiredAt();
|
||||
char* userEmail();
|
||||
char* userEncrypt();
|
||||
char* userToken();
|
||||
char* userCreate(char* email, char* password);
|
||||
char* tokenEncode(int userid, char* email, char* encrypt, int days);
|
||||
char* tokenDecode(char* val);
|
||||
char* translate(char* val, char* val);
|
||||
char* md5s(char* text, char* password);
|
||||
char* macs();
|
||||
char* dooSN();
|
||||
char* version();
|
||||
char* pgpGenerateKeyPair(char* name, char* email, char* passphrase);
|
||||
char* pgpEncrypt(char* plainText, char* publicKey);
|
||||
char* pgpDecrypt(char* cipherText, char* privateKey, char* passphrase);
|
||||
EOF, "/usr/lib/doo/doo.so");
|
||||
$this->so->initialize("/var/www", $token, $language);
|
||||
return $this->so;
|
||||
}
|
||||
|
||||
/**
|
||||
* char转为字符串
|
||||
* @param $text
|
||||
* @return string
|
||||
*/
|
||||
private static function string($text): string
|
||||
{
|
||||
if (!($text instanceof CData)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
return FFI::string($text);
|
||||
} catch (Exception) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* License
|
||||
* @return array
|
||||
*/
|
||||
public function license(): array
|
||||
{
|
||||
$array = Base::json2array(self::string($this->so->license()));
|
||||
|
||||
$ips = explode(",", $array['ip']);
|
||||
$array['ip'] = [];
|
||||
foreach ($ips as $ip) {
|
||||
if (Base::is_ipv4($ip)) {
|
||||
$array['ip'][] = $ip;
|
||||
}
|
||||
}
|
||||
|
||||
$domains = explode(",", $array['domain']);
|
||||
$array['domain'] = [];
|
||||
foreach ($domains as $domain) {
|
||||
if (Base::is_domain($domain)) {
|
||||
$array['domain'][] = $domain;
|
||||
}
|
||||
}
|
||||
|
||||
$macs = explode(",", $array['mac']);
|
||||
$array['mac'] = [];
|
||||
foreach ($macs as $mac) {
|
||||
if (Base::isMac($mac)) {
|
||||
$array['mac'][] = $mac;
|
||||
}
|
||||
}
|
||||
|
||||
$emails = explode(",", $array['email']);
|
||||
$array['email'] = [];
|
||||
foreach ($emails as $email) {
|
||||
if (Base::isEmail($email)) {
|
||||
$array['email'][] = $email;
|
||||
}
|
||||
}
|
||||
|
||||
return $array;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析License
|
||||
* @param $license
|
||||
* @return array
|
||||
*/
|
||||
public function licenseDecode($license): array
|
||||
{
|
||||
return Base::json2array(self::string($this->so->licenseDecode($license)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存License
|
||||
* @param $license
|
||||
*/
|
||||
public function licenseSave($license): void
|
||||
{
|
||||
$res = self::string($this->so->licenseSave($license));
|
||||
if ($res != 'success') {
|
||||
throw new ApiException($res ?: 'LICENSE 保存失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前会员ID(来自请求的token)
|
||||
* @return int
|
||||
*/
|
||||
public function userId(): int
|
||||
{
|
||||
return intval($this->so->userId());
|
||||
}
|
||||
|
||||
/**
|
||||
* token是否过期(来自请求的token)
|
||||
* @return bool
|
||||
*/
|
||||
public function userExpired(): bool
|
||||
{
|
||||
$expiredAt = $this->userExpiredAt();
|
||||
return $expiredAt && Carbon::parse($expiredAt)->isBefore(Carbon::now());
|
||||
}
|
||||
|
||||
/**
|
||||
* token过期时间(来自请求的token)
|
||||
* @return string|null
|
||||
*/
|
||||
public function userExpiredAt(): ?string
|
||||
{
|
||||
$expiredAt = self::string($this->so->userExpiredAt());
|
||||
return $expiredAt === 'forever' ? null : $expiredAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前会员邮箱地址(来自请求的token)
|
||||
* @return string
|
||||
*/
|
||||
public function userEmail(): string
|
||||
{
|
||||
return self::string($this->so->userEmail());
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前会员Encrypt(来自请求的token)
|
||||
* @return string
|
||||
*/
|
||||
public function userEncrypt(): string
|
||||
{
|
||||
return self::string($this->so->userEncrypt());
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前会员token(来自请求的token)
|
||||
* @return string
|
||||
*/
|
||||
public function userToken(): string
|
||||
{
|
||||
return self::string($this->so->userToken());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建帐号
|
||||
* @param $email
|
||||
* @param $password
|
||||
* @return User|null
|
||||
*/
|
||||
public function userCreate($email, $password): User|null
|
||||
{
|
||||
$data = Base::json2array(self::string($this->so->userCreate($email, $password)));
|
||||
if (Base::isError($data)) {
|
||||
throw new ApiException($data['msg'] ?: '注册失败');
|
||||
}
|
||||
if (DB::transactionLevel() > 0) {
|
||||
try {
|
||||
DB::commit();
|
||||
DB::beginTransaction();
|
||||
} catch (Throwable) {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
$user = User::whereEmail($email)->first();
|
||||
if (empty($user)) {
|
||||
throw new ApiException('注册失败');
|
||||
}
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成token(编码token)
|
||||
* @param $userid
|
||||
* @param $email
|
||||
* @param $encrypt
|
||||
* @param int $days 有效时间(天)
|
||||
* @return string
|
||||
*/
|
||||
public function tokenEncode($userid, $email, $encrypt, int $days = 15): string
|
||||
{
|
||||
return self::string($this->so->tokenEncode($userid, $email, $encrypt, $days));
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码token
|
||||
* @param $token
|
||||
* @return array
|
||||
*/
|
||||
public function tokenDecode($token): array
|
||||
{
|
||||
$array = Base::json2array(self::string($this->so->tokenDecode($token)));
|
||||
$array['expired_at'] = $array['expired_at'] === 'forever' ? null : $array['expired_at'];
|
||||
return $array;
|
||||
}
|
||||
|
||||
/**
|
||||
* 翻译
|
||||
* @param $text
|
||||
* @param ?string $lang
|
||||
* @return string
|
||||
*/
|
||||
public function translate($text, ?string $lang = ""): string
|
||||
{
|
||||
if (empty($text)) {
|
||||
return "";
|
||||
}
|
||||
if (empty($lang)) {
|
||||
$lang = "";
|
||||
}
|
||||
return self::string($this->so->translate($text, $lang));
|
||||
}
|
||||
|
||||
/**
|
||||
* md5防破解
|
||||
* @param $text
|
||||
* @param string $password
|
||||
* @return string
|
||||
*/
|
||||
public function md5s($text, string $password = ""): string
|
||||
{
|
||||
return self::string($this->so->md5s($text, $password));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取php容器mac地址组
|
||||
* @return array
|
||||
*/
|
||||
public function macs(): array
|
||||
{
|
||||
$macs = explode(",", self::string($this->so->macs()));
|
||||
$array = [];
|
||||
foreach ($macs as $mac) {
|
||||
if (Base::isMac($mac)) {
|
||||
$array[] = $mac;
|
||||
}
|
||||
}
|
||||
return $array;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前SN
|
||||
* @return string
|
||||
*/
|
||||
public function dooSN(): string
|
||||
{
|
||||
return self::string($this->so->dooSN());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前版本
|
||||
* @return string
|
||||
*/
|
||||
public function dooVersion(): string
|
||||
{
|
||||
return self::string($this->so->version());
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成PGP密钥对
|
||||
* @param $name
|
||||
* @param $email
|
||||
* @param string $passphrase
|
||||
* @return array
|
||||
*/
|
||||
public function pgpGenerateKeyPair($name, $email, string $passphrase = ""): array
|
||||
{
|
||||
return Base::json2array(self::string($this->so->pgpGenerateKeyPair($name, $email, $passphrase)));
|
||||
}
|
||||
|
||||
/**
|
||||
* PGP加密
|
||||
* @param $plaintext
|
||||
* @param $publicKey
|
||||
* @return string
|
||||
*/
|
||||
public function pgpEncrypt($plaintext, $publicKey): string
|
||||
{
|
||||
if (strlen($publicKey) < 50) {
|
||||
$keyCache = Base::json2array(Cache::get("KeyPair::" . $publicKey));
|
||||
$publicKey = $keyCache['public_key'];
|
||||
}
|
||||
return self::string($this->so->pgpEncrypt($plaintext, $publicKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* PGP解密
|
||||
* @param $encryptedText
|
||||
* @param $privateKey
|
||||
* @param null $passphrase
|
||||
* @return string
|
||||
*/
|
||||
public function pgpDecrypt($encryptedText, $privateKey, $passphrase = null): string
|
||||
{
|
||||
if (strlen($privateKey) < 50) {
|
||||
$keyCache = Base::json2array(Cache::get("KeyPair::" . $privateKey));
|
||||
$privateKey = $keyCache['private_key'];
|
||||
$passphrase = $keyCache['passphrase'];
|
||||
}
|
||||
return self::string($this->so->pgpDecrypt($encryptedText, $privateKey, $passphrase));
|
||||
}
|
||||
|
||||
/**
|
||||
* PGP加密API
|
||||
* @param $plaintext
|
||||
* @param $publicKey
|
||||
* @return string
|
||||
*/
|
||||
public function pgpEncryptApi($plaintext, $publicKey): string
|
||||
{
|
||||
$content = Base::array2json($plaintext);
|
||||
$content = $this->pgpEncrypt($content, $publicKey);
|
||||
return preg_replace("/\s*-----(BEGIN|END) PGP MESSAGE-----\s*/i", "", $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* PGP解密API
|
||||
* @param $encryptedText
|
||||
* @param null $privateKey
|
||||
* @param null $passphrase
|
||||
* @return array
|
||||
*/
|
||||
public function pgpDecryptApi($encryptedText, $privateKey, $passphrase = null): array
|
||||
{
|
||||
$content = "-----BEGIN PGP MESSAGE-----\n\n" . $encryptedText . "\n-----END PGP MESSAGE-----";
|
||||
$content = $this->pgpDecrypt($content, $privateKey, $passphrase);
|
||||
return Base::json2array($content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析PGP参数
|
||||
* @param $string
|
||||
* @return string[]
|
||||
*/
|
||||
public function pgpParseStr($string): array
|
||||
{
|
||||
$array = [
|
||||
'encrypt_type' => '',
|
||||
'encrypt_id' => '',
|
||||
'client_type' => '',
|
||||
'client_key' => '',
|
||||
];
|
||||
$string = str_replace(";", "&", $string);
|
||||
parse_str($string, $params);
|
||||
foreach ($params as $key => $value) {
|
||||
$key = strtolower(trim($key));
|
||||
if ($key) {
|
||||
$array[$key] = trim($value);
|
||||
}
|
||||
}
|
||||
if ($array['client_type'] === 'pgp' && $array['client_key']) {
|
||||
$array['client_key'] = $this->pgpPublicFormat($array['client_key']);
|
||||
}
|
||||
return $array;
|
||||
}
|
||||
|
||||
/**
|
||||
* 还原公钥格式
|
||||
* @param $key
|
||||
* @return string
|
||||
*/
|
||||
public function pgpPublicFormat($key): string
|
||||
{
|
||||
$key = str_replace(["-", "_", "$"], ["+", "/", "\n"], $key);
|
||||
if (!str_contains($key, '-----BEGIN PGP PUBLIC KEY BLOCK-----')) {
|
||||
$key = "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n" . $key . "\n-----END PGP PUBLIC KEY BLOCK-----";
|
||||
}
|
||||
return $key;
|
||||
}
|
||||
}
|
||||
52
app/Module/Lock.php
Normal file
52
app/Module/Lock.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module;
|
||||
|
||||
use Closure;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
class Lock
|
||||
{
|
||||
/**
|
||||
* 使用Redis分布式锁执行闭包
|
||||
* @param string $key 锁的key
|
||||
* @param Closure $closure 要执行的闭包函数
|
||||
* @param int $ttl 锁的过期时间(毫秒),默认10000(10秒)
|
||||
* @param int $waitTimeout 等待锁的超时时间(毫秒),0表示不等待,默认10000(10秒)
|
||||
* @return mixed 闭包函数的返回值
|
||||
* @throws Exception 如果获取锁失败或闭包执行异常
|
||||
*/
|
||||
public static function withLock(string $key, Closure $closure, int $ttl = 10000, int $waitTimeout = 10000)
|
||||
{
|
||||
$lockKey = "lock:{$key}";
|
||||
$lockValue = uniqid('', true); // 生成唯一值,用于安全释放锁
|
||||
|
||||
// 尝试获取锁,如果waitTimeout为0则直接返回false,否则等待指定时间
|
||||
$acquired = false;
|
||||
if ($waitTimeout > 0) {
|
||||
$end = microtime(true) + ($waitTimeout / 1000);
|
||||
while (microtime(true) < $end) {
|
||||
if (Redis::set($lockKey, $lockValue, 'PX', $ttl, 'NX')) {
|
||||
$acquired = true;
|
||||
break;
|
||||
}
|
||||
usleep(100000); // 休眠100ms后重试
|
||||
}
|
||||
} else {
|
||||
$acquired = Redis::set($lockKey, $lockValue, 'PX', $ttl, 'NX');
|
||||
}
|
||||
|
||||
if (!$acquired) {
|
||||
throw new Exception("Failed to acquire lock for key: {$key}");
|
||||
}
|
||||
|
||||
try {
|
||||
// 执行闭包
|
||||
return $closure();
|
||||
} finally {
|
||||
// 安全释放锁(仅当锁值未变时删除)
|
||||
Redis::eval("if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end", 1, $lockKey, $lockValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,33 +2,101 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Module\ClientContext;
|
||||
use Illuminate\Http\Request;
|
||||
use Swoole\Coroutine;
|
||||
|
||||
/**
|
||||
* 请求上下文
|
||||
*/
|
||||
class RequestContext
|
||||
{
|
||||
/** @var array<string, array<string, mixed>> */
|
||||
private static array $context = [];
|
||||
/** @var string 请求ID的上下文键 */
|
||||
private const CONTEXT_KEY = 'request_id';
|
||||
|
||||
private const REQUEST_ID_PREFIX = 'req_';
|
||||
/** @var string 请求ID前缀 */
|
||||
private const REQUEST_ID_PREFIX = 'req';
|
||||
|
||||
/** @var int 上下文的TTL(生存时间) */
|
||||
private const TTL_SECONDS = 3600; // 上下文 TTL 为 1 小时
|
||||
|
||||
/** @var array<string, ClientContext> 存储每个请求的上下文数据 */
|
||||
private static array $context = [];
|
||||
|
||||
/**
|
||||
* 生成请求唯一ID
|
||||
*/
|
||||
public static function generateRequestId(): string
|
||||
{
|
||||
return self::REQUEST_ID_PREFIX . uniqid() . mt_rand(10000, 99999);
|
||||
$pid = getmypid();
|
||||
$cid = Coroutine::getCid() ?? 0;
|
||||
$microtime = str_replace('.', '', microtime(true));
|
||||
return self::REQUEST_ID_PREFIX . '_' . $pid . '_' . $cid . '_' . $microtime . '_' . mt_rand(1000, 9999);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前请求ID
|
||||
*/
|
||||
private static function getCurrentRequestId(): ?string
|
||||
public static function getCurrentRequestId($requestId = null): ?string
|
||||
{
|
||||
/** @var Request $request */
|
||||
// 如果提供了有效的请求ID,直接返回
|
||||
if ($requestId && str_starts_with($requestId, self::REQUEST_ID_PREFIX)) {
|
||||
return $requestId;
|
||||
}
|
||||
|
||||
// 尝试从当前请求获取
|
||||
$request = request();
|
||||
return $request?->requestId;
|
||||
if ($request && method_exists($request, 'attributes') && $request->attributes) {
|
||||
if (!$request->attributes->has(static::CONTEXT_KEY)) {
|
||||
$request->attributes->set(static::CONTEXT_KEY, self::generateRequestId());
|
||||
}
|
||||
return $request->attributes->get(static::CONTEXT_KEY);
|
||||
}
|
||||
|
||||
// 如果没有请求上下文,生成一个新的请求ID
|
||||
return self::generateRequestId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前请求的上下文示例
|
||||
*/
|
||||
public static function getCurrentRequestContext($requestId = null): ?ClientContext
|
||||
{
|
||||
$requestId = self::getCurrentRequestId($requestId);
|
||||
if ($requestId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isset(self::$context[$requestId])) {
|
||||
// 如果上下文不存在,则创建一个新的上下文
|
||||
self::$context[$requestId] = new ClientContext();
|
||||
} else {
|
||||
// 如果上下文已存在,更新访问时间
|
||||
self::$context[$requestId]->update();
|
||||
}
|
||||
|
||||
return self::$context[$requestId];
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期上下文数据,防止内存泄漏
|
||||
*/
|
||||
public static function cleanExpired(): void
|
||||
{
|
||||
$now = microtime(true);
|
||||
|
||||
// 清理过期的上下文
|
||||
foreach (self::$context as $requestId => $context) {
|
||||
if ($now - $context->updatedAt > self::TTL_SECONDS) {
|
||||
unset(self::$context[$requestId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** ***************************************************************************************** */
|
||||
/** ***************************************************************************************** */
|
||||
/** ***************************************************************************************** */
|
||||
|
||||
/**
|
||||
* 设置请求上下文
|
||||
*
|
||||
@@ -39,13 +107,34 @@ class RequestContext
|
||||
*/
|
||||
public static function set(string $key, mixed $value, ?string $requestId = null): void
|
||||
{
|
||||
$requestId = $requestId ?? self::getCurrentRequestId();
|
||||
if ($requestId === null) {
|
||||
$context = self::getCurrentRequestContext($requestId);
|
||||
if ($context === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::$context[$requestId] ??= [];
|
||||
self::$context[$requestId][$key] = $value;
|
||||
$context->set($key, $value);
|
||||
|
||||
// 概率性清理,避免频繁清理影响性能
|
||||
if (mt_rand(1, 100) === 1) {
|
||||
self::cleanExpired();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量设置上下文数据
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @param string|null $requestId
|
||||
* @return void
|
||||
*/
|
||||
public static function setMultiple(array $data, ?string $requestId = null): void
|
||||
{
|
||||
$context = self::getCurrentRequestContext($requestId);
|
||||
if ($context === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$context->setMultiple($data);
|
||||
}
|
||||
|
||||
// 与 set 方法的区别是,save 方法会返回传入的 value 值
|
||||
@@ -65,12 +154,28 @@ class RequestContext
|
||||
*/
|
||||
public static function get(string $key, mixed $default = null, ?string $requestId = null): mixed
|
||||
{
|
||||
$requestId = $requestId ?? self::getCurrentRequestId();
|
||||
if ($requestId === null) {
|
||||
$context = self::getCurrentRequestContext($requestId);
|
||||
if ($context === null) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return self::$context[$requestId][$key] ?? $default;
|
||||
return $context->get($key, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前请求的所有上下文数据
|
||||
*
|
||||
* @param string|null $requestId
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function getAll(?string $requestId = null): array
|
||||
{
|
||||
$context = self::getCurrentRequestContext($requestId);
|
||||
if ($context === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $context->context ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,12 +187,12 @@ class RequestContext
|
||||
*/
|
||||
public static function has(string $key, ?string $requestId = null): bool
|
||||
{
|
||||
$requestId = $requestId ?? self::getCurrentRequestId();
|
||||
if ($requestId === null) {
|
||||
$context = self::getCurrentRequestContext($requestId);
|
||||
if ($context === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isset(self::$context[$requestId][$key]);
|
||||
return $context->has($key);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,9 +201,9 @@ class RequestContext
|
||||
* @param string|null $requestId
|
||||
* @return void
|
||||
*/
|
||||
public static function clear(?string $requestId = null): void
|
||||
public static function clean(?string $requestId = null): void
|
||||
{
|
||||
$requestId = $requestId ?? self::getCurrentRequestId();
|
||||
$requestId = self::getCurrentRequestId($requestId);
|
||||
if ($requestId === null) {
|
||||
return;
|
||||
}
|
||||
@@ -106,40 +211,6 @@ class RequestContext
|
||||
unset(self::$context[$requestId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前请求的所有上下文数据
|
||||
*
|
||||
* @param string|null $requestId
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function getAll(?string $requestId = null): array
|
||||
{
|
||||
$requestId = $requestId ?? self::getCurrentRequestId();
|
||||
if ($requestId === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return self::$context[$requestId] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量设置上下文数据
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @param string|null $requestId
|
||||
* @return void
|
||||
*/
|
||||
public static function setMultiple(array $data, ?string $requestId = null): void
|
||||
{
|
||||
$requestId = $requestId ?? self::getCurrentRequestId();
|
||||
if ($requestId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::$context[$requestId] ??= [];
|
||||
self::$context[$requestId] = array_merge(self::$context[$requestId], $data);
|
||||
}
|
||||
|
||||
/** ***************************************************************************************** */
|
||||
/** ***************************************************************************************** */
|
||||
/** ***************************************************************************************** */
|
||||
|
||||
@@ -42,6 +42,7 @@ class AutoArchivedTask extends AbstractTask
|
||||
->whereNotNull('project_tasks.complete_at')
|
||||
->where('project_tasks.complete_at', '<=', Carbon::now()->subDays($archivedDay))
|
||||
->where('project_tasks.archived_userid', 0)
|
||||
->where('project_tasks.parent_id', 0)
|
||||
->whereNull('project_tasks.archived_at')
|
||||
->where('projects.archive_method', '!=', 'custom')
|
||||
->take(100)
|
||||
@@ -63,6 +64,7 @@ class AutoArchivedTask extends AbstractTask
|
||||
->join('projects', 'projects.id', '=', 'project_tasks.project_id')
|
||||
->whereNotNull('project_tasks.complete_at')
|
||||
->where('project_tasks.archived_userid', 0)
|
||||
->where('project_tasks.parent_id', 0)
|
||||
->whereNull('project_tasks.archived_at')
|
||||
->where('projects.archive_method', 'custom')
|
||||
->whereRaw("DATEDIFF(NOW(), {$prefix}project_tasks.complete_at) >= {$prefix}projects.archive_days")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ use App\Models\File;
|
||||
use App\Models\TaskWorker;
|
||||
use App\Models\Tmp;
|
||||
use App\Models\UserDevice;
|
||||
use App\Models\UmengLog;
|
||||
use App\Models\WebSocketTmpMsg;
|
||||
use App\Module\Base;
|
||||
use Carbon\Carbon;
|
||||
@@ -103,6 +104,17 @@ class DeleteTmpTask extends AbstractTask
|
||||
}
|
||||
});
|
||||
break;
|
||||
|
||||
case 'umeng_log':
|
||||
UmengLog::where('created_at', '<', Carbon::now()->subHours($this->hours))
|
||||
->orderBy('id')
|
||||
->chunk(500, function ($logs) {
|
||||
/** @var UmengLog $log */
|
||||
foreach ($logs as $log) {
|
||||
$log->delete();
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
namespace App\Tasks;
|
||||
|
||||
use App\Module\AI;
|
||||
use App\Module\Base;
|
||||
use App\Module\Extranet;
|
||||
use Cache;
|
||||
use Carbon\Carbon;
|
||||
|
||||
@@ -26,25 +26,27 @@ class JokeSoupTask extends AbstractTask
|
||||
|
||||
public function start()
|
||||
{
|
||||
// 判断每分钟执行一次
|
||||
if (Cache::get(self::keyName("YmdHi")) == date("YmdHi")) {
|
||||
// 判断每小时执行一次
|
||||
if (Cache::get(self::keyName("YmdH")) == date("YmdH")) {
|
||||
return;
|
||||
}
|
||||
Cache::put(self::keyName("YmdHi"), date("YmdHi"), Carbon::now()->addDay());
|
||||
//
|
||||
$array = Base::json2array(Cache::get(self::keyName("jokes")));
|
||||
$data = Extranet::randJoke();
|
||||
if ($data) {
|
||||
$array[] = $data;
|
||||
Cache::put(self::keyName("YmdH"), date("YmdH"), Carbon::now()->addDay());
|
||||
|
||||
// 开始生成笑话和心灵鸡汤
|
||||
$result = AI::generateJokeAndSoup();
|
||||
if (Base::isError($result)) {
|
||||
Cache::forget(self::keyName("YmdH"));
|
||||
return;
|
||||
}
|
||||
Cache::forever(self::keyName("jokes"), Base::array2json(array_slice($array, -200)));
|
||||
//
|
||||
$array = Base::json2array(Cache::get(self::keyName("soups")));
|
||||
$data = Extranet::soups();
|
||||
if ($data) {
|
||||
$array[] = $data;
|
||||
|
||||
// 笑话和心灵鸡汤的缓存
|
||||
foreach (['jokes', 'soups'] as $key) {
|
||||
if ($result['data'][$key] && is_array($result['data'][$key])) {
|
||||
$array = Base::json2array(Cache::get(self::keyName($key)));
|
||||
$array = array_merge($array, $result['data'][$key]);
|
||||
Cache::forever(self::keyName($key), Base::array2json(array_slice($array, -200)));
|
||||
}
|
||||
}
|
||||
Cache::forever(self::keyName("soups"), Base::array2json(array_slice($array, -200)));
|
||||
}
|
||||
|
||||
public function end()
|
||||
|
||||
@@ -40,7 +40,7 @@ class LoopTask extends AbstractTask
|
||||
foreach ($projectFlowItem as $flowItem) {
|
||||
if ($flowItem->status == 'start') {
|
||||
$task->flow_item_id = $flowItem->id;
|
||||
$task->flow_item_name = $flowItem->status . "|" . $flowItem->name;
|
||||
$task->flow_item_name = $flowItem->status . "|" . $flowItem->name . "|" . $flowItem->color;
|
||||
if ($flowItem->userids) {
|
||||
$userids = array_values(array_unique($flowItem->userids));
|
||||
foreach ($userids as $uid) {
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
namespace App\Tasks;
|
||||
|
||||
use App\Models\WebSocketDialogSession;
|
||||
use App\Module\AI;
|
||||
use App\Module\Base;
|
||||
use App\Module\Extranet;
|
||||
|
||||
/**
|
||||
* 通过AI接口更新对话标题
|
||||
*/
|
||||
class UpdateSessionTitleViaAiTask extends AbstractTask
|
||||
{
|
||||
protected $sessionId;
|
||||
protected $msgText;
|
||||
|
||||
public function __construct($sessionId, $msgText)
|
||||
{
|
||||
parent::__construct();
|
||||
@@ -29,12 +32,12 @@ class UpdateSessionTitleViaAiTask extends AbstractTask
|
||||
return;
|
||||
}
|
||||
|
||||
$res = Extranet::openAIGenerateTitle($this->msgText);
|
||||
if (Base::isError($res)) {
|
||||
$result = AI::generateTitle($this->msgText);
|
||||
if (Base::isError($result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$newTitle = $res['data'];
|
||||
$newTitle = $result['data']['title'];
|
||||
if ($newTitle && $newTitle != $session->title) {
|
||||
$session->title = Base::cutStr($newTitle, 100);
|
||||
$session->save();
|
||||
|
||||
@@ -190,8 +190,10 @@ class WebSocketDialogMsgTask extends AbstractTask
|
||||
$setting = Base::setting('appPushSetting');
|
||||
if ($setting['push'] === 'open') {
|
||||
$umengTitle = User::userid2nickname($msg->userid);
|
||||
$umengBody = WebSocketDialogMsg::previewMsg($msg);
|
||||
if ($dialog->type == 'group') {
|
||||
$umengTitle = "{$dialog->getGroupName()} ($umengTitle)";
|
||||
$umengBody = $umengTitle . ': ' . $umengBody;
|
||||
$umengTitle = $dialog->getGroupName();
|
||||
}
|
||||
$langs = User::select(['userid', 'lang'])
|
||||
->whereIn('userid', $umengUserid)
|
||||
@@ -205,7 +207,7 @@ class WebSocketDialogMsgTask extends AbstractTask
|
||||
Doo::setLanguage($lang);
|
||||
$umengMsg = [
|
||||
'title' => $umengTitle,
|
||||
'body' => WebSocketDialogMsg::previewMsg($msg),
|
||||
'body' => $umengBody,
|
||||
'description' => "MID:{$msg->id}",
|
||||
'seconds' => 3600,
|
||||
'badge' => 1,
|
||||
|
||||
390
bin/version.js
vendored
390
bin/version.js
vendored
File diff suppressed because one or more lines are too long
59
cmd
59
cmd
@@ -119,6 +119,14 @@ switch_debug() {
|
||||
fi
|
||||
}
|
||||
|
||||
# 检查是否有sudo
|
||||
check_sudo() {
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
error "请使用 sudo 运行此脚本"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 检查docker、docker-compose
|
||||
check_docker() {
|
||||
docker --version &> /dev/null
|
||||
@@ -175,7 +183,15 @@ web_build() {
|
||||
fi
|
||||
if [ "$type" = "dev" ]; then
|
||||
echo "<script>window.location.href=window.location.href.replace(/:\d+/, ':' + $(env_get APP_PORT))</script>" > ./index.html
|
||||
env_set APP_DEV_PORT $(rand 20001 30000)
|
||||
if [ -z "$(env_get APP_DEV_PORT)" ]; then
|
||||
env_set APP_DEV_PORT $(rand 20001 30000)
|
||||
fi
|
||||
if [ -n "${VSCODE_PROXY_URI:-}" ]; then
|
||||
APP_REAL_URI=$(TARGET_PORT="$(env_get APP_PORT)" node -p "process.env.VSCODE_PROXY_URI.replace(/\{\{port\}\}/g, process.env.TARGET_PORT || '')")
|
||||
VSCODE_PROXY_URI=$(APP_DEV_PORT="$(env_get APP_DEV_PORT)" node -p "process.env.VSCODE_PROXY_URI.replace(/\{\{port\}\}/g, process.env.APP_DEV_PORT || '')")
|
||||
echo "<script>window.location.href='${APP_REAL_URI}'</script>" > ./index.html
|
||||
fi
|
||||
env_set VSCODE_PROXY_URI "${VSCODE_PROXY_URI:-}"
|
||||
fi
|
||||
switch_debug "$type"
|
||||
#
|
||||
@@ -246,25 +262,33 @@ mysql_snapshot() {
|
||||
password=$(env_get DB_PASSWORD)
|
||||
# 还原数据库
|
||||
mkdir -p ${WORK_DIR}/docker/mysql/backup
|
||||
list=`ls -1 "${WORK_DIR}/docker/mysql/backup" | grep ".sql.gz"`
|
||||
if [ -z "$list" ]; then
|
||||
shopt -s nullglob
|
||||
backup_files=("${WORK_DIR}/docker/mysql/backup/"*.sql.gz)
|
||||
shopt -u nullglob
|
||||
if [ ${#backup_files[@]} -eq 0 ]; then
|
||||
error "没有备份文件!"
|
||||
exit 1
|
||||
fi
|
||||
echo "$list"
|
||||
read -rp "请输入备份文件名称还原:" inputname
|
||||
filename="${WORK_DIR}/docker/mysql/backup/${inputname}"
|
||||
if [ ! -f "$filename" ]; then
|
||||
error "备份文件:${inputname} 不存在!"
|
||||
exit 1
|
||||
fi
|
||||
echo "可用备份列表:"
|
||||
for idx in "${!backup_files[@]}"; do
|
||||
printf "%2d) %s\n" "$((idx + 1))" "$(basename "${backup_files[$idx]}")"
|
||||
done
|
||||
while true; do
|
||||
read -rp "请输入备份文件编号还原:" selection
|
||||
if [[ "$selection" =~ ^[0-9]+$ ]] && [ "$selection" -ge 1 ] && [ "$selection" -le ${#backup_files[@]} ]; then
|
||||
break
|
||||
fi
|
||||
warning "编号无效,请重新输入。"
|
||||
done
|
||||
filename="${backup_files[$((selection - 1))]}"
|
||||
inputname="$(basename "$filename")"
|
||||
container_name=`docker_name mariadb`
|
||||
if [ -z "$container_name" ]; then
|
||||
error "没有找到 mariadb 容器!"
|
||||
exit 1
|
||||
fi
|
||||
docker cp $filename ${container_name}:/
|
||||
container_exec mariadb "gunzip < /${inputname} | mysql -u${username} -p${password} $database"
|
||||
docker cp "$filename" "${container_name}:/"
|
||||
container_exec mariadb "gunzip < '/${inputname}' | mysql -u${username} -p${password} $database"
|
||||
container_exec php "php artisan migrate"
|
||||
judge "还原数据库"
|
||||
fi
|
||||
@@ -459,6 +483,8 @@ EOF
|
||||
|
||||
# 安装函数
|
||||
handle_install() {
|
||||
check_sudo
|
||||
|
||||
local relock=$(arg_get relock)
|
||||
local port=$(arg_get port)
|
||||
|
||||
@@ -479,7 +505,8 @@ handle_install() {
|
||||
for vol in "${volumes[@]}"; do
|
||||
tmp_path="${WORK_DIR}/${vol}"
|
||||
mkdir -p "${tmp_path}"
|
||||
chmod -R 775 "${tmp_path}"
|
||||
find "${tmp_path}" -type d -exec chmod 775 {} \;
|
||||
|
||||
rm -f "${tmp_path}/dootask.lock"
|
||||
cmda="${cmda} -v ${tmp_path}:/usr/share/${vol}"
|
||||
cmdb="${cmdb} touch /usr/share/${vol}/dootask.lock &&"
|
||||
@@ -547,6 +574,8 @@ handle_install() {
|
||||
|
||||
# 更新函数
|
||||
handle_update() {
|
||||
check_sudo
|
||||
|
||||
local target_branch=$(arg_get branch)
|
||||
local is_local=$(arg_get local)
|
||||
local force_update=$(arg_get force)
|
||||
@@ -617,7 +646,7 @@ handle_update() {
|
||||
fi
|
||||
|
||||
# 更新依赖
|
||||
exec_judge "container_exec php 'composer update --optimize-autoloader'" "更新PHP依赖失败"
|
||||
exec_judge "container_exec php 'composer install --optimize-autoloader'" "更新PHP依赖失败"
|
||||
else
|
||||
# 本地更新模式
|
||||
echo "执行数据库备份..."
|
||||
@@ -644,6 +673,7 @@ handle_update() {
|
||||
|
||||
# 卸载函数
|
||||
handle_uninstall() {
|
||||
check_sudo
|
||||
# 确认卸载
|
||||
echo -e "${RedBG}警告:此操作将永久删除以下内容:${Font}"
|
||||
echo "- 数据库"
|
||||
@@ -775,6 +805,7 @@ case "$1" in
|
||||
shift 1
|
||||
container_exec php "php app/Http/Controllers/Api/apidoc.php"
|
||||
docker run -it --rm -v ${WORK_DIR}:/home/node/apidoc kuaifan/apidoc -i app/Http/Controllers/Api -o public/docs
|
||||
container_exec php "php app/Http/Controllers/Api/apidoc.php restore"
|
||||
;;
|
||||
"debug")
|
||||
shift 1
|
||||
|
||||
805
composer.lock
generated
805
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
|
||||
|
||||
use App\Module\Base;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
class CreateAiSettingsTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
$setting = Base::setting('aibotSetting');
|
||||
Base::setting('aiSetting', [
|
||||
'ai_provider' => 'openai',
|
||||
'ai_api_key' => $setting['openai_key'],
|
||||
'ai_api_url' => $setting['openai_base_url'],
|
||||
'ai_proxy' => $setting['openai_agency'],
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
// This migration does not need to be reversible
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddColorToProjectFlowItemsTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('project_flow_items', function (Blueprint $table) {
|
||||
if (!Schema::hasColumn('project_flow_items', 'color')) {
|
||||
$table->string('color', 20)->nullable()->default('')->after('status')->comment('自定义颜色');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('project_flow_items', function (Blueprint $table) {
|
||||
$table->dropColumn('color');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class UpdateFilesNameLengthTo200 extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('files', function (Blueprint $table) {
|
||||
$table->string('name', 255)->change();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('files', function (Blueprint $table) {
|
||||
//
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddSortFieldToProjectUsersTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('project_users', function (Blueprint $table) {
|
||||
// 添加一个排序sort字段
|
||||
if (!Schema::hasColumn('project_users', 'sort')) {
|
||||
$table->integer('sort')->nullable()->default(0)->after('top_at')->comment('排序(ASC)');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('project_users', function (Blueprint $table) {
|
||||
// 删除排序sort字段
|
||||
if (Schema::hasColumn('project_users', 'sort')) {
|
||||
$table->dropColumn('sort');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddGuestAccessToFilesTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
$isAdd = false;
|
||||
Schema::table('files', function (Blueprint $table) use (&$isAdd) {
|
||||
if (!Schema::hasColumn('files', 'guest_access')) {
|
||||
$table->tinyInteger('guest_access')->nullable()->default(0)->comment('是否允许游客访问')->after('share');
|
||||
$isAdd = true;
|
||||
}
|
||||
});
|
||||
if ($isAdd) {
|
||||
// 更新现有记录的guest_access字段为0(默认不允许游客访问)
|
||||
\DB::table('files')->whereNull('guest_access')->update(['guest_access' => 0]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('files', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('files', 'guest_access')) {
|
||||
$table->dropColumn('guest_access');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateUserTaskBrowsesTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
if (Schema::hasTable('user_task_browses'))
|
||||
return;
|
||||
|
||||
Schema::create('user_task_browses', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->bigInteger('userid')->index()->nullable()->default(0)->comment('用户ID');
|
||||
$table->bigInteger('task_id')->index()->nullable()->default(0)->comment('任务ID');
|
||||
$table->timestamp('browsed_at')->index()->nullable()->comment('浏览时间');
|
||||
$table->timestamps();
|
||||
|
||||
// 复合索引:用户ID + 浏览时间(用于按时间排序获取用户浏览历史)
|
||||
$table->index(['userid', 'browsed_at']);
|
||||
// 唯一索引:用户ID + 任务ID(防止重复记录,相同任务会更新浏览时间)
|
||||
$table->unique(['userid', 'task_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::dropIfExists('user_task_browses');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateUserRecentItemsTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
if (Schema::hasTable('user_recent_items')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::create('user_recent_items', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->bigInteger('userid')->index()->default(0)->comment('用户ID');
|
||||
$table->string('target_type', 50)->default('')->comment('目标类型(task/file/task_file/message_file 等)');
|
||||
$table->bigInteger('target_id')->default(0)->comment('目标ID');
|
||||
$table->string('source_type', 50)->default('')->comment('来源类型(project/filesystem/project_task/dialog 等)');
|
||||
$table->bigInteger('source_id')->default(0)->comment('来源ID');
|
||||
$table->timestamp('browsed_at')->nullable()->index()->comment('浏览时间');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['userid', 'browsed_at']);
|
||||
$table->unique(['userid', 'target_type', 'target_id', 'source_type', 'source_id'], 'recent_unique');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::dropIfExists('user_recent_items');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateUserFavoritesTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
if (Schema::hasTable('user_favorites'))
|
||||
return;
|
||||
|
||||
Schema::create('user_favorites', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->bigInteger('userid')->index()->nullable()->default(0)->comment('用户ID');
|
||||
$table->string('favoritable_type', 50)->index()->nullable()->default('')->comment('收藏类型(比如:task/project/file/message)');
|
||||
$table->bigInteger('favoritable_id')->index()->nullable()->default(0)->comment('收藏对象ID');
|
||||
$table->timestamps();
|
||||
|
||||
// 复合索引:用户ID + 收藏类型(用于按类型获取收藏列表)
|
||||
$table->index(['userid', 'favoritable_type']);
|
||||
// 唯一索引:用户ID + 收藏类型 + 收藏对象ID(防止重复收藏)
|
||||
$table->unique(['userid', 'favoritable_type', 'favoritable_id'], 'user_favorites_unique');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::dropIfExists('user_favorites');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddRemarkToUserFavoritesTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('user_favorites', function (Blueprint $table) {
|
||||
if (!Schema::hasColumn('user_favorites', 'remark')) {
|
||||
$table->string('remark', 255)->default('')->after('favoritable_id')->comment('收藏备注');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('user_favorites', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('user_favorites', 'remark')) {
|
||||
$table->dropColumn('remark');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddSortToProjectTagsTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
$added = false;
|
||||
Schema::table('project_tags', function (Blueprint $table) use (&$added) {
|
||||
if (!Schema::hasColumn('project_tags', 'sort')) {
|
||||
$table->unsignedInteger('sort')->default(0)->after('color')->comment('排序');
|
||||
$added = true;
|
||||
}
|
||||
});
|
||||
|
||||
if ($added) {
|
||||
\App\Models\ProjectTag::query()
|
||||
->select('project_id')
|
||||
->distinct()
|
||||
->orderBy('project_id')
|
||||
->chunk(100, function ($projectIds) {
|
||||
foreach ($projectIds as $project) {
|
||||
$tags = \App\Models\ProjectTag::query()
|
||||
->where('project_id', $project->project_id)
|
||||
->orderByDesc('id')
|
||||
->get(['id']);
|
||||
$index = 0;
|
||||
foreach ($tags as $tag) {
|
||||
\App\Models\ProjectTag::where('id', $tag->id)->update(['sort' => $index++]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('project_tags', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('project_tags', 'sort')) {
|
||||
$table->dropColumn('sort');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class BackfillSortProjectTaskTemplates extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
if (!Schema::hasTable('project_task_templates') || !Schema::hasColumn('project_task_templates', 'sort')) {
|
||||
return;
|
||||
}
|
||||
|
||||
\App\Models\ProjectTaskTemplate::query()
|
||||
->select('project_id')
|
||||
->distinct()
|
||||
->orderBy('project_id')
|
||||
->chunk(100, function ($projects) {
|
||||
foreach ($projects as $project) {
|
||||
$templates = \App\Models\ProjectTaskTemplate::query()
|
||||
->where('project_id', $project->project_id)
|
||||
->orderByDesc('id')
|
||||
->get(['id']);
|
||||
$index = 0;
|
||||
foreach ($templates as $template) {
|
||||
\App\Models\ProjectTaskTemplate::where('id', $template->id)->update(['sort' => $index++]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('project_task_relations', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('task_id')->comment('任务ID');
|
||||
$table->unsignedBigInteger('related_task_id')->comment('关联任务ID');
|
||||
$table->string('direction', 32)->default('mention')->comment('关系方向: mention/mentioned_by');
|
||||
$table->unsignedBigInteger('dialog_id')->nullable()->comment('来源会话ID');
|
||||
$table->unsignedBigInteger('msg_id')->nullable()->comment('来源消息ID');
|
||||
$table->unsignedBigInteger('userid')->nullable()->comment('提及人');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['task_id', 'related_task_id', 'direction'], 'project_task_relations_unique');
|
||||
$table->index(['task_id', 'direction']);
|
||||
$table->index('related_task_id');
|
||||
$table->index('dialog_id');
|
||||
$table->index('msg_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('project_task_relations');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('umeng_logs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->text('request')->nullable()->comment('请求参数');
|
||||
$table->text('response')->nullable()->comment('推送返回');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('umeng_logs');
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
services:
|
||||
php:
|
||||
container_name: "dootask-php-${APP_ID}"
|
||||
image: "kuaifan/php:swoole-8.0.rc20"
|
||||
image: "kuaifan/php:swoole-8.0.rc21"
|
||||
shm_size: 2G
|
||||
ulimits:
|
||||
core:
|
||||
@@ -96,7 +96,7 @@ services:
|
||||
appstore:
|
||||
container_name: "dootask-appstore-${APP_ID}"
|
||||
privileged: true
|
||||
image: "dootask/appstore:0.1.0"
|
||||
image: "dootask/appstore:0.3.0"
|
||||
volumes:
|
||||
- shared_data:/usr/share/dootask
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[program:crontab]
|
||||
directory=/var/www/docker/crontab
|
||||
command=/etc/init.d/cron start
|
||||
command=/usr/sbin/cron -f
|
||||
numprocs=1
|
||||
autostart=true
|
||||
autorestart=false
|
||||
|
||||
@@ -35,6 +35,13 @@ server {
|
||||
add_header Access-Control-Expose-Headers "Date, Last-Modified, Age" always;
|
||||
|
||||
location / {
|
||||
if ($uri ~* "^/uploads/.*\.(jpe?g|png|gif|webp)$") {
|
||||
add_header Access-Control-Allow-Origin "http://localhost:22223" always;
|
||||
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization" always;
|
||||
add_header Access-Control-Allow-Credentials "true" always;
|
||||
}
|
||||
|
||||
try_files $uri @laravels;
|
||||
}
|
||||
|
||||
|
||||
2
electron/build.js
vendored
2
electron/build.js
vendored
@@ -8,7 +8,7 @@ const yauzl = require('yauzl');
|
||||
const axios = require('axios');
|
||||
const FormData =require('form-data');
|
||||
const tar = require('tar');
|
||||
const utils = require('./utils');
|
||||
const utils = require('./lib/utils');
|
||||
const config = require('../package.json')
|
||||
const env = require('dotenv').config({ path: './.env' })
|
||||
const argv = process.argv;
|
||||
|
||||
687
electron/electron-down.js
vendored
Normal file
687
electron/electron-down.js
vendored
Normal file
@@ -0,0 +1,687 @@
|
||||
const {BrowserWindow, screen, shell, ipcMain} = require('electron')
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const loger = require("electron-log");
|
||||
const {default: electronDl, download, CancelError} = require("@dootask/electron-dl");
|
||||
const utils = require("./lib/utils");
|
||||
const {DownloadManager, DownloadStore} = require("./lib/download-manager");
|
||||
|
||||
const downloadManager = new DownloadManager();
|
||||
|
||||
let downloadWindow = null,
|
||||
downloadLanguageCode = 'zh',
|
||||
downloadWaiting = false;
|
||||
|
||||
function initialize(onStarted = null) {
|
||||
// 下载配置
|
||||
electronDl({
|
||||
showBadge: false,
|
||||
showProgressBar: false,
|
||||
|
||||
onStarted: (item) => {
|
||||
downloadManager.add(item);
|
||||
downloadWaiting = false;
|
||||
syncDownloadItems();
|
||||
if (typeof onStarted === 'function') {
|
||||
onStarted(item)
|
||||
}
|
||||
},
|
||||
onCancel: (item) => {
|
||||
downloadManager.refresh(item.getSavePath())
|
||||
syncDownloadItems();
|
||||
},
|
||||
onInterrupted: (item) => {
|
||||
downloadManager.refresh(item.getSavePath());
|
||||
syncDownloadItems();
|
||||
// 尝试更新下载项的错误信息
|
||||
downloadManager.updateError(item, {
|
||||
language: downloadLanguageCode,
|
||||
}).then(success => {
|
||||
if (success) {
|
||||
syncDownloadItems();
|
||||
}
|
||||
});
|
||||
},
|
||||
onProgress: (item) => {
|
||||
downloadManager.refresh(item.path);
|
||||
syncDownloadItems();
|
||||
},
|
||||
onCompleted: (item) => {
|
||||
downloadManager.refresh(item.path);
|
||||
syncDownloadItems();
|
||||
}
|
||||
});
|
||||
|
||||
// IPC
|
||||
ipcMain.handle('downloadManager', async (event, {action, path}) => {
|
||||
switch (action) {
|
||||
case "get": {
|
||||
return {
|
||||
items: downloadManager.get(),
|
||||
waiting: downloadWaiting,
|
||||
};
|
||||
}
|
||||
|
||||
case "pause": {
|
||||
downloadManager.pause(path);
|
||||
syncDownloadItems();
|
||||
return true;
|
||||
}
|
||||
|
||||
case "resume": {
|
||||
downloadManager.resume(path);
|
||||
syncDownloadItems();
|
||||
return true;
|
||||
}
|
||||
|
||||
case "cancel": {
|
||||
downloadManager.cancel(path);
|
||||
syncDownloadItems();
|
||||
return true;
|
||||
}
|
||||
|
||||
case "remove": {
|
||||
downloadManager.remove(path);
|
||||
syncDownloadItems();
|
||||
return true;
|
||||
}
|
||||
|
||||
case "removeAll": {
|
||||
downloadManager.removeAll();
|
||||
syncDownloadItems();
|
||||
return true;
|
||||
}
|
||||
|
||||
case "openFile": {
|
||||
if (!fs.existsSync(path)) {
|
||||
throw new Error('file not found');
|
||||
}
|
||||
return shell.openPath(path);
|
||||
}
|
||||
|
||||
case "showFolder": {
|
||||
if (!fs.existsSync(path)) {
|
||||
throw new Error('file not found');
|
||||
}
|
||||
shell.showItemInFolder(path);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function createDownload(window_, url, options = {}) {
|
||||
downloadWaiting = true;
|
||||
syncDownloadItems();
|
||||
try {
|
||||
return await download(window_, url, options);
|
||||
} catch (error) {
|
||||
// electron-dl rejects with CancelError when a download is cancelled; treat it as expected.
|
||||
const isCancelError = (typeof CancelError === 'function' && error instanceof CancelError)
|
||||
|| error?.name === 'CancelError';
|
||||
if (!isCancelError) {
|
||||
throw error;
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
downloadWaiting = false;
|
||||
syncDownloadItems();
|
||||
}
|
||||
}
|
||||
|
||||
function syncDownloadItems() {
|
||||
// 同步下载项到渲染进程
|
||||
if (downloadWindow) {
|
||||
downloadWindow.webContents.send('download-items', {
|
||||
items: downloadManager.get(),
|
||||
waiting: downloadWaiting,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getLanguageData(code) {
|
||||
const packs = {
|
||||
zh: {
|
||||
// 语言设置
|
||||
locale: 'zh-CN',
|
||||
title: '下载管理器',
|
||||
|
||||
// 界面文本
|
||||
searchPlaceholder: '搜索文件名或链接...',
|
||||
noItems: '暂无任务',
|
||||
noSearchResult: '未找到匹配的结果',
|
||||
|
||||
// 操作按钮
|
||||
refresh: '刷新',
|
||||
removeAll: "清空历史",
|
||||
copyLink: '复制链接',
|
||||
resume: '继续',
|
||||
pause: '暂停',
|
||||
cancel: '取消',
|
||||
remove: '删除',
|
||||
showInFolder: '显示在文件夹',
|
||||
|
||||
// 状态文本
|
||||
progressing: '下载中',
|
||||
completed: '已完成',
|
||||
cancelled: '已取消',
|
||||
interrupted: '失败',
|
||||
paused: '已暂停',
|
||||
|
||||
// 成功消息
|
||||
copied: "已复制",
|
||||
refreshSuccess: '刷新成功',
|
||||
|
||||
// 确认对话框
|
||||
confirmCancel: '确定要取消此下载任务并删除记录吗?',
|
||||
confirmRemove: '确定要从历史记录中删除此项吗?',
|
||||
confirmRemoveAll: '确定要清空下载历史吗?',
|
||||
|
||||
// 错误消息
|
||||
copyFailed: '复制失败: ',
|
||||
pauseFailed: '暂停失败: ',
|
||||
resumeFailed: '继续失败: ',
|
||||
removeFailed: '删除失败: ',
|
||||
removeAllFailed: '清空失败: ',
|
||||
openFailed: '打开文件失败: ',
|
||||
showFailed: '显示文件失败: ',
|
||||
},
|
||||
'zh-CHT': {
|
||||
locale: 'zh-TW',
|
||||
title: '下載管理器',
|
||||
|
||||
// 界面文本
|
||||
searchPlaceholder: '搜尋檔案名稱或連結...',
|
||||
noItems: '暫無任務',
|
||||
noSearchResult: '未找到匹配的結果',
|
||||
|
||||
// 操作按钮
|
||||
refresh: '重新整理',
|
||||
removeAll: "清空歷史",
|
||||
copyLink: '複製連結',
|
||||
resume: '繼續',
|
||||
pause: '暫停',
|
||||
cancel: '取消',
|
||||
remove: '刪除',
|
||||
showInFolder: '顯示在資料夾',
|
||||
|
||||
// 状态文本
|
||||
progressing: '下載中',
|
||||
completed: '已完成',
|
||||
cancelled: '已取消',
|
||||
interrupted: '失敗',
|
||||
paused: '已暫停',
|
||||
|
||||
// 成功消息
|
||||
copied: "已複製",
|
||||
refreshSuccess: '重新整理成功',
|
||||
|
||||
// 确认对话框
|
||||
confirmCancel: '確定要取消此下載任務並刪除記錄嗎?',
|
||||
confirmRemove: '確定要從歷史記錄中刪除此項嗎?',
|
||||
confirmRemoveAll: '確定要清空下載歷史嗎?',
|
||||
|
||||
// 错误消息
|
||||
copyFailed: '複製失敗: ',
|
||||
pauseFailed: '暫停失敗: ',
|
||||
resumeFailed: '繼續失敗: ',
|
||||
removeFailed: '刪除失敗: ',
|
||||
removeAllFailed: '清空失敗: ',
|
||||
openFailed: '開啟檔案失敗: ',
|
||||
showFailed: '顯示檔案失敗: ',
|
||||
},
|
||||
en: {
|
||||
locale: 'en-US',
|
||||
title: 'Download Manager',
|
||||
|
||||
// 界面文本
|
||||
searchPlaceholder: 'Search filename or link...',
|
||||
noItems: 'No tasks',
|
||||
noSearchResult: 'No matching results found',
|
||||
|
||||
// 操作按钮
|
||||
refresh: 'Refresh',
|
||||
removeAll: "Clear History",
|
||||
copyLink: 'Copy Link',
|
||||
resume: 'Resume',
|
||||
pause: 'Pause',
|
||||
cancel: 'Cancel',
|
||||
remove: 'Remove',
|
||||
showInFolder: 'Show in Folder',
|
||||
|
||||
// 状态文本
|
||||
progressing: 'Downloading',
|
||||
completed: 'Completed',
|
||||
cancelled: 'Cancelled',
|
||||
interrupted: 'Failed',
|
||||
paused: 'Paused',
|
||||
|
||||
// 成功消息
|
||||
copied: "Copied",
|
||||
refreshSuccess: 'Refresh successful',
|
||||
|
||||
// 确认对话框
|
||||
confirmCancel: 'Are you sure you want to cancel this download task and delete the record?',
|
||||
confirmRemove: 'Are you sure you want to remove this item from history?',
|
||||
confirmRemoveAll: 'Are you sure you want to clear download history?',
|
||||
|
||||
// 错误消息
|
||||
copyFailed: 'Copy failed: ',
|
||||
pauseFailed: 'Pause failed: ',
|
||||
resumeFailed: 'Resume failed: ',
|
||||
removeFailed: 'Remove failed: ',
|
||||
removeAllFailed: 'Clear failed: ',
|
||||
openFailed: 'Open file failed: ',
|
||||
showFailed: 'Show file failed: ',
|
||||
},
|
||||
ko: {
|
||||
locale: 'ko-KR',
|
||||
title: '다운로드 관리자',
|
||||
|
||||
// 界面文本
|
||||
searchPlaceholder: '파일명 또는 링크 검색...',
|
||||
noItems: '작업 없음',
|
||||
noSearchResult: '일치하는 결과를 찾을 수 없습니다',
|
||||
|
||||
// 操作按钮
|
||||
refresh: '새로고침',
|
||||
removeAll: "기록 지우기",
|
||||
copyLink: '링크 복사',
|
||||
resume: '계속',
|
||||
pause: '일시정지',
|
||||
cancel: '취소',
|
||||
remove: '삭제',
|
||||
showInFolder: '폴더에서 보기',
|
||||
|
||||
// 状态文本
|
||||
progressing: '다운로드 중',
|
||||
completed: '완료됨',
|
||||
cancelled: '취소됨',
|
||||
interrupted: '실패',
|
||||
paused: '일시정지됨',
|
||||
|
||||
// 成功消息
|
||||
copied: "복사됨",
|
||||
refreshSuccess: '새로고침 성공',
|
||||
|
||||
// 确认对话框
|
||||
confirmCancel: '이 다운로드 작업을 취소하고 기록을 삭제하시겠습니까?',
|
||||
confirmRemove: '기록에서 이 항목을 삭제하시겠습니까?',
|
||||
confirmRemoveAll: '다운로드 기록을 지우시겠습니까?',
|
||||
|
||||
// 错误消息
|
||||
copyFailed: '복사 실패: ',
|
||||
pauseFailed: '일시정지 실패: ',
|
||||
resumeFailed: '계속 실패: ',
|
||||
removeFailed: '삭제 실패: ',
|
||||
removeAllFailed: '지우기 실패: ',
|
||||
openFailed: '파일 열기 실패: ',
|
||||
showFailed: '파일 표시 실패: ',
|
||||
},
|
||||
ja: {
|
||||
locale: 'ja-JP',
|
||||
title: 'ダウンロードマネージャー',
|
||||
|
||||
// 界面文本
|
||||
searchPlaceholder: 'ファイル名またはリンクを検索...',
|
||||
noItems: 'タスクがありません',
|
||||
noSearchResult: '一致する結果が見つかりません',
|
||||
|
||||
// 操作按钮
|
||||
refresh: '更新',
|
||||
removeAll: "履歴をクリア",
|
||||
copyLink: 'リンクをコピー',
|
||||
resume: '再開',
|
||||
pause: '一時停止',
|
||||
cancel: 'キャンセル',
|
||||
remove: '削除',
|
||||
showInFolder: 'フォルダで表示',
|
||||
|
||||
// 状态文本
|
||||
progressing: 'ダウンロード中',
|
||||
completed: '完了',
|
||||
cancelled: 'キャンセル済み',
|
||||
interrupted: '失敗',
|
||||
paused: '一時停止中',
|
||||
|
||||
// 成功消息
|
||||
copied: "コピーしました",
|
||||
refreshSuccess: '更新が完了しました',
|
||||
|
||||
// 确认对话框
|
||||
confirmCancel: 'このダウンロードタスクをキャンセルして記録を削除しますか?',
|
||||
confirmRemove: '履歴からこの項目を削除しますか?',
|
||||
confirmRemoveAll: 'ダウンロード履歴をクリアしますか?',
|
||||
|
||||
// 错误消息
|
||||
copyFailed: 'コピーに失敗しました: ',
|
||||
pauseFailed: '一時停止に失敗しました: ',
|
||||
resumeFailed: '再開に失敗しました: ',
|
||||
removeFailed: '削除に失敗しました: ',
|
||||
removeAllFailed: 'クリアに失敗しました: ',
|
||||
openFailed: 'ファイルを開けませんでした: ',
|
||||
showFailed: 'ファイルの表示に失敗しました: ',
|
||||
},
|
||||
de: {
|
||||
locale: 'de-DE',
|
||||
title: 'Download-Manager',
|
||||
|
||||
// 界面文本
|
||||
searchPlaceholder: 'Dateiname oder Link suchen...',
|
||||
noItems: 'Keine Aufgaben',
|
||||
noSearchResult: 'Keine übereinstimmenden Ergebnisse gefunden',
|
||||
|
||||
// 操作按钮
|
||||
refresh: 'Aktualisieren',
|
||||
removeAll: "Verlauf löschen",
|
||||
copyLink: 'Link kopieren',
|
||||
resume: 'Fortsetzen',
|
||||
pause: 'Pause',
|
||||
cancel: 'Abbrechen',
|
||||
remove: 'Entfernen',
|
||||
showInFolder: 'Im Ordner anzeigen',
|
||||
|
||||
// 状态文本
|
||||
progressing: 'Wird heruntergeladen',
|
||||
completed: 'Abgeschlossen',
|
||||
cancelled: 'Abgebrochen',
|
||||
interrupted: 'Fehlgeschlagen',
|
||||
paused: 'Pausiert',
|
||||
|
||||
// 成功消息
|
||||
copied: "Kopiert",
|
||||
refreshSuccess: 'Erfolgreich aktualisiert',
|
||||
|
||||
// 确认对话框
|
||||
confirmCancel: 'Sind Sie sicher, dass Sie diese Download-Aufgabe abbrechen und den Eintrag löschen möchten?',
|
||||
confirmRemove: 'Sind Sie sicher, dass Sie diesen Eintrag aus dem Verlauf entfernen möchten?',
|
||||
confirmRemoveAll: 'Sind Sie sicher, dass Sie den Download-Verlauf löschen möchten?',
|
||||
|
||||
// 错误消息
|
||||
copyFailed: 'Kopieren fehlgeschlagen: ',
|
||||
pauseFailed: 'Pause fehlgeschlagen: ',
|
||||
resumeFailed: 'Fortsetzen fehlgeschlagen: ',
|
||||
removeFailed: 'Entfernen fehlgeschlagen: ',
|
||||
removeAllFailed: 'Löschen fehlgeschlagen: ',
|
||||
openFailed: 'Datei öffnen fehlgeschlagen: ',
|
||||
showFailed: 'Datei anzeigen fehlgeschlagen: ',
|
||||
},
|
||||
fr: {
|
||||
locale: 'fr-FR',
|
||||
title: 'Gestionnaire de téléchargements',
|
||||
|
||||
// 界面文本
|
||||
searchPlaceholder: 'Rechercher nom de fichier ou lien...',
|
||||
noItems: 'Aucune tâche',
|
||||
noSearchResult: 'Aucun résultat correspondant trouvé',
|
||||
|
||||
// 操作按钮
|
||||
refresh: 'Actualiser',
|
||||
removeAll: "Effacer l'historique",
|
||||
copyLink: 'Copier le lien',
|
||||
resume: 'Reprendre',
|
||||
pause: 'Pause',
|
||||
cancel: 'Annuler',
|
||||
remove: 'Supprimer',
|
||||
showInFolder: 'Afficher dans le dossier',
|
||||
|
||||
// 状态文本
|
||||
progressing: 'Téléchargement en cours',
|
||||
completed: 'Terminé',
|
||||
cancelled: 'Annulé',
|
||||
interrupted: 'Échoué',
|
||||
paused: 'En pause',
|
||||
|
||||
// 成功消息
|
||||
copied: "Copié",
|
||||
refreshSuccess: 'Actualisation réussie',
|
||||
|
||||
// 确认对话框
|
||||
confirmCancel: 'Êtes-vous sûr de vouloir annuler cette tâche de téléchargement et supprimer l\'enregistrement ?',
|
||||
confirmRemove: 'Êtes-vous sûr de vouloir supprimer cet élément de l\'historique ?',
|
||||
confirmRemoveAll: 'Êtes-vous sûr de vouloir effacer l\'historique des téléchargements ?',
|
||||
|
||||
// 错误消息
|
||||
copyFailed: 'Échec de la copie : ',
|
||||
pauseFailed: 'Échec de la pause : ',
|
||||
resumeFailed: 'Échec de la reprise : ',
|
||||
removeFailed: 'Échec de la suppression : ',
|
||||
removeAllFailed: 'Échec de l\'effacement : ',
|
||||
openFailed: 'Échec de l\'ouverture du fichier : ',
|
||||
showFailed: 'Échec de l\'affichage du fichier : ',
|
||||
},
|
||||
id: {
|
||||
locale: 'id-ID',
|
||||
title: 'Manajer Unduhan',
|
||||
|
||||
// 界面文本
|
||||
searchPlaceholder: 'Cari nama file atau tautan...',
|
||||
noItems: 'Tidak ada tugas',
|
||||
noSearchResult: 'Tidak ada hasil yang cocok ditemukan',
|
||||
|
||||
// 操作按钮
|
||||
refresh: 'Segarkan',
|
||||
removeAll: "Hapus Riwayat",
|
||||
copyLink: 'Salin Tautan',
|
||||
resume: 'Lanjutkan',
|
||||
pause: 'Jeda',
|
||||
cancel: 'Batal',
|
||||
remove: 'Hapus',
|
||||
showInFolder: 'Tampilkan di Folder',
|
||||
|
||||
// 状态文本
|
||||
progressing: 'Mengunduh',
|
||||
completed: 'Selesai',
|
||||
cancelled: 'Dibatalkan',
|
||||
interrupted: 'Gagal',
|
||||
paused: 'Dijeda',
|
||||
|
||||
// 成功消息
|
||||
copied: "Disalin",
|
||||
refreshSuccess: 'Berhasil disegarkan',
|
||||
|
||||
// 确认对话框
|
||||
confirmCancel: 'Apakah Anda yakin ingin membatalkan tugas unduhan ini dan menghapus catatan?',
|
||||
confirmRemove: 'Apakah Anda yakin ingin menghapus item ini dari riwayat?',
|
||||
confirmRemoveAll: 'Apakah Anda yakin ingin menghapus riwayat unduhan?',
|
||||
|
||||
// 错误消息
|
||||
copyFailed: 'Gagal menyalin: ',
|
||||
pauseFailed: 'Gagal menjeda: ',
|
||||
resumeFailed: 'Gagal melanjutkan: ',
|
||||
removeFailed: 'Gagal menghapus: ',
|
||||
removeAllFailed: 'Gagal menghapus: ',
|
||||
openFailed: 'Gagal membuka file: ',
|
||||
showFailed: 'Gagal menampilkan file: ',
|
||||
},
|
||||
ru: {
|
||||
locale: 'ru-RU',
|
||||
title: 'Менеджер загрузок',
|
||||
|
||||
// 界面文本
|
||||
searchPlaceholder: 'Поиск имени файла или ссылки...',
|
||||
noItems: 'Нет задач',
|
||||
noSearchResult: 'Совпадающих результатов не найдено',
|
||||
|
||||
// 操作按钮
|
||||
refresh: 'Обновить',
|
||||
removeAll: "Очистить историю",
|
||||
copyLink: 'Копировать ссылку',
|
||||
resume: 'Возобновить',
|
||||
pause: 'Пауза',
|
||||
cancel: 'Отмена',
|
||||
remove: 'Удалить',
|
||||
showInFolder: 'Показать в папке',
|
||||
|
||||
// 状态文本
|
||||
progressing: 'Загружается',
|
||||
completed: 'Завершено',
|
||||
cancelled: 'Отменено',
|
||||
interrupted: 'Ошибка',
|
||||
paused: 'На паузе',
|
||||
|
||||
// 成功消息
|
||||
copied: "Скопировано",
|
||||
refreshSuccess: 'Успешно обновлено',
|
||||
|
||||
// 确认对话框
|
||||
confirmCancel: 'Вы уверены, что хотите отменить эту задачу загрузки и удалить запись?',
|
||||
confirmRemove: 'Вы уверены, что хотите удалить этот элемент из истории?',
|
||||
confirmRemoveAll: 'Вы уверены, что хотите очистить историю загрузок?',
|
||||
|
||||
// 错误消息
|
||||
copyFailed: 'Ошибка копирования: ',
|
||||
pauseFailed: 'Ошибка паузы: ',
|
||||
resumeFailed: 'Ошибка возобновления: ',
|
||||
removeFailed: 'Ошибка удаления: ',
|
||||
removeAllFailed: 'Ошибка очистки: ',
|
||||
openFailed: 'Ошибка открытия файла: ',
|
||||
showFailed: 'Ошибка отображения файла: ',
|
||||
}
|
||||
};
|
||||
downloadLanguageCode = code;
|
||||
return packs[code] || packs.zh;
|
||||
}
|
||||
|
||||
async function open(language = 'zh', theme = 'light') {
|
||||
// 获取语言包
|
||||
const finalLanguage = getLanguageData(language);
|
||||
|
||||
// 如果窗口已存在,直接显示
|
||||
if (downloadWindow) {
|
||||
// 更新窗口数据
|
||||
await updateWindow(language, theme)
|
||||
// 显示窗口并聚焦
|
||||
downloadWindow.show();
|
||||
downloadWindow.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// 窗口默认参数
|
||||
const downloadWindowOptions = {
|
||||
width: 700,
|
||||
height: 480,
|
||||
minWidth: 500,
|
||||
minHeight: 350,
|
||||
center: true,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
title: finalLanguage.title,
|
||||
backgroundColor: utils.getDefaultBackgroundColor(),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'electron-preload.js'),
|
||||
webSecurity: true,
|
||||
nodeIntegration: true,
|
||||
contextIsolation: true,
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复窗口位置
|
||||
const downloadWindowBounds = DownloadStore.get('downloadWindowBounds', {});
|
||||
if (
|
||||
downloadWindowBounds.width !== undefined &&
|
||||
downloadWindowBounds.height !== undefined &&
|
||||
downloadWindowBounds.x !== undefined &&
|
||||
downloadWindowBounds.y !== undefined
|
||||
) {
|
||||
// 获取所有显示器的可用区域
|
||||
const displays = screen.getAllDisplays();
|
||||
// 检查窗口是否在任意一个屏幕内
|
||||
let isInScreen = false;
|
||||
for (const display of displays) {
|
||||
const area = display.workArea;
|
||||
if (
|
||||
downloadWindowBounds.x + downloadWindowBounds.width > area.x &&
|
||||
downloadWindowBounds.x < area.x + area.width &&
|
||||
downloadWindowBounds.y + downloadWindowBounds.height > area.y &&
|
||||
downloadWindowBounds.y < area.y + area.height
|
||||
) {
|
||||
isInScreen = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 如果超出所有屏幕,则移动到主屏幕可见区域
|
||||
if (!isInScreen) {
|
||||
const primaryArea = screen.getPrimaryDisplay().workArea;
|
||||
downloadWindowBounds.x = primaryArea.x + 50;
|
||||
downloadWindowBounds.y = primaryArea.y + 50;
|
||||
// 防止窗口太大超出屏幕
|
||||
downloadWindowBounds.width = Math.min(downloadWindowBounds.width, primaryArea.width - 100);
|
||||
downloadWindowBounds.height = Math.min(downloadWindowBounds.height, primaryArea.height - 100);
|
||||
}
|
||||
downloadWindowOptions.center = false;
|
||||
downloadWindowOptions.width = downloadWindowBounds.width;
|
||||
downloadWindowOptions.height = downloadWindowBounds.height;
|
||||
downloadWindowOptions.x = downloadWindowBounds.x;
|
||||
downloadWindowOptions.y = downloadWindowBounds.y;
|
||||
}
|
||||
|
||||
// 创建窗口
|
||||
downloadWindow = new BrowserWindow(downloadWindowOptions);
|
||||
|
||||
// 禁止修改窗口标题
|
||||
downloadWindow.on('page-title-updated', (event) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
// 监听窗口关闭保存窗口位置
|
||||
downloadWindow.on('close', () => {
|
||||
const bounds = downloadWindow.getBounds();
|
||||
DownloadStore.set('downloadWindowBounds', bounds);
|
||||
});
|
||||
|
||||
// 监听窗口关闭事件
|
||||
downloadWindow.on('closed', () => {
|
||||
downloadWindow = null;
|
||||
});
|
||||
|
||||
// 加载下载管理器页面
|
||||
const htmlPath = path.join(__dirname, 'render', 'download', 'index.html');
|
||||
const themeParam = (theme === 'dark' ? 'dark' : 'light');
|
||||
await downloadWindow.loadFile(htmlPath, {query: {theme: themeParam}});
|
||||
|
||||
// 将语言包发送到渲染进程
|
||||
downloadWindow.webContents.once('dom-ready', () => {
|
||||
updateWindow(language, theme)
|
||||
});
|
||||
|
||||
// 显示窗口
|
||||
downloadWindow.show();
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (downloadWindow) {
|
||||
downloadWindow.close();
|
||||
downloadWindow = null;
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (downloadWindow) {
|
||||
downloadWindow.destroy();
|
||||
downloadWindow = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateWindow(language, theme) {
|
||||
if (downloadWindow) {
|
||||
try {
|
||||
const finalLanguage = getLanguageData(language);
|
||||
downloadWindow.setTitle(finalLanguage.title);
|
||||
downloadWindow.webContents.send('download-theme', theme);
|
||||
downloadWindow.webContents.send('download-language', finalLanguage);
|
||||
syncDownloadItems()
|
||||
} catch (error) {
|
||||
loger.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initialize,
|
||||
createDownload,
|
||||
open,
|
||||
close,
|
||||
destroy,
|
||||
updateWindow
|
||||
}
|
||||
2
electron/electron-menu.js
vendored
2
electron/electron-menu.js
vendored
@@ -9,7 +9,7 @@ const {
|
||||
const fs = require('fs')
|
||||
const url = require('url')
|
||||
const request = require("request");
|
||||
const utils = require('./utils')
|
||||
const utils = require('./lib/utils')
|
||||
|
||||
const MAILTO_PREFIX = "mailto:";
|
||||
|
||||
|
||||
8
electron/electron-preload.js
vendored
8
electron/electron-preload.js
vendored
@@ -32,7 +32,13 @@ contextBridge.exposeInMainWorld(
|
||||
'electron', {
|
||||
request: (msg, callback, error) => {
|
||||
msg.reqId = reqId++;
|
||||
reqInfo[msg.reqId] = {callback: callback, error: error};
|
||||
if (typeof callback !== "function") {
|
||||
callback = function () {};
|
||||
}
|
||||
if (typeof error !== "function") {
|
||||
error = function () {};
|
||||
}
|
||||
reqInfo[msg.reqId] = {callback, error};
|
||||
if (msg.action == 'watchFile') {
|
||||
fileChangedListeners[msg.path] = msg.listener;
|
||||
delete msg.listener;
|
||||
|
||||
493
electron/electron.js
vendored
493
electron/electron.js
vendored
@@ -1,7 +1,17 @@
|
||||
// Node.js 核心模块
|
||||
const fs = require('fs')
|
||||
const os = require("os");
|
||||
const path = require('path')
|
||||
const spawn = require("child_process").spawn;
|
||||
const fsProm = require('fs/promises');
|
||||
const crc = require('crc');
|
||||
const zlib = require('zlib');
|
||||
|
||||
// Web 服务相关
|
||||
const express = require('express')
|
||||
const axios = require('axios');
|
||||
|
||||
// Electron 核心模块
|
||||
const {
|
||||
app,
|
||||
ipcMain,
|
||||
@@ -13,57 +23,86 @@ const {
|
||||
nativeTheme,
|
||||
Tray,
|
||||
Menu,
|
||||
BrowserView,
|
||||
WebContentsView,
|
||||
BrowserWindow
|
||||
} = require('electron')
|
||||
|
||||
// 禁用渲染器后台化
|
||||
app.commandLine.appendSwitch('disable-renderer-backgrounding');
|
||||
app.commandLine.appendSwitch('disable-backgrounding-occluded-windows');
|
||||
|
||||
// Electron 扩展和工具
|
||||
const {autoUpdater} = require("electron-updater")
|
||||
const Store = require("electron-store");
|
||||
const loger = require("electron-log");
|
||||
const electronConf = require('electron-config')
|
||||
const userConf = new electronConf()
|
||||
const fsProm = require('fs/promises');
|
||||
const PDFDocument = require('pdf-lib').PDFDocument;
|
||||
const Screenshots = require("electron-screenshots-tool").Screenshots;
|
||||
const crc = require('crc');
|
||||
const zlib = require('zlib');
|
||||
const utils = require('./utils');
|
||||
|
||||
// PDF 处理
|
||||
const PDFDocument = require('pdf-lib').PDFDocument;
|
||||
|
||||
// 本地模块和配置
|
||||
const utils = require('./lib/utils');
|
||||
const config = require('./package.json');
|
||||
const electronDown = require("./electron-down");
|
||||
const electronMenu = require("./electron-menu");
|
||||
const spawn = require("child_process").spawn;
|
||||
const { startMCPServer, stopMCPServer } = require("./lib/mcp");
|
||||
|
||||
// 实例初始化
|
||||
const userConf = new electronConf()
|
||||
const store = new Store();
|
||||
|
||||
// 平台检测常量
|
||||
const isMac = process.platform === 'darwin'
|
||||
const isWin = process.platform === 'win32'
|
||||
|
||||
// URL 和调用验证正则
|
||||
const allowedUrls = /^(?:https?|mailto|tel|callto):/i;
|
||||
const allowedCalls = /^(?:mailto|tel|callto):/i;
|
||||
const cacheDir = path.join(os.tmpdir(), 'dootask-cache')
|
||||
let updaterLockFile = path.join(cacheDir, '.dootask_updater.lock');
|
||||
let enableStoreBkp = true;
|
||||
let dialogOpen = false;
|
||||
let enablePlugins = false;
|
||||
|
||||
let mainWindow = null,
|
||||
mainTray = null,
|
||||
// 路径和缓存配置
|
||||
const cacheDir = path.join(os.tmpdir(), 'dootask-cache')
|
||||
const updaterLockFile = path.join(cacheDir, '.dootask_updater.lock');
|
||||
|
||||
// 应用状态标志
|
||||
let enableStoreBkp = true,
|
||||
dialogOpen = false,
|
||||
enablePlugins = false,
|
||||
isReady = false,
|
||||
willQuitApp = false,
|
||||
devloadPath = path.resolve(__dirname, ".devload"),
|
||||
isDevelopMode = false,
|
||||
serverPort = 22223,
|
||||
serverPublicDir = path.join(__dirname, 'public'),
|
||||
serverUrl = "";
|
||||
isDevelopMode = false;
|
||||
|
||||
// 服务器配置
|
||||
let serverPort = 22223,
|
||||
mcpPort = 22224,
|
||||
serverPublicDir = path.join(__dirname, 'public'),
|
||||
serverUrl = "",
|
||||
serverTimer = null;
|
||||
|
||||
// 截图相关变量
|
||||
let screenshotObj = null,
|
||||
screenshotKey = null;
|
||||
|
||||
let childWindow = [],
|
||||
// 窗口实例变量
|
||||
let mainWindow = null,
|
||||
mainTray = null,
|
||||
preloadWindow = null,
|
||||
mediaWindow = null,
|
||||
mediaType = null,
|
||||
webTabWindow = null,
|
||||
webTabView = [],
|
||||
webTabWindow = null;
|
||||
|
||||
// 窗口数组和状态
|
||||
let childWindow = [],
|
||||
webTabView = [];
|
||||
|
||||
// 窗口配置和状态
|
||||
let mediaType = null,
|
||||
webTabHeight = 40,
|
||||
webTabClosedByShortcut = false;
|
||||
|
||||
// 开发模式路径
|
||||
let devloadPath = path.resolve(__dirname, ".devload");
|
||||
|
||||
// 窗口显示状态管理
|
||||
let showState = {},
|
||||
onShowWindow = (win) => {
|
||||
try {
|
||||
@@ -76,6 +115,7 @@ let showState = {},
|
||||
}
|
||||
}
|
||||
|
||||
// 开发模式加载
|
||||
if (fs.existsSync(devloadPath)) {
|
||||
let devloadContent = fs.readFileSync(devloadPath, 'utf8')
|
||||
if (devloadContent.startsWith('http')) {
|
||||
@@ -84,25 +124,41 @@ if (fs.existsSync(devloadPath)) {
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存目录检查
|
||||
if (!fs.existsSync(cacheDir)) {
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 初始化下载
|
||||
electronDown.initialize(() => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send("openDownloadWindow", {})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 启动web服务
|
||||
*/
|
||||
async function startWebServer() {
|
||||
if (serverUrl) {
|
||||
async function startWebServer(force = false) {
|
||||
if (serverUrl && !force) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// 每次启动前清理缓存
|
||||
utils.clearServerCache();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// 创建Express应用
|
||||
const app = express();
|
||||
const expressApp = express();
|
||||
|
||||
// 健康检查
|
||||
expressApp.head('/health', (req, res) => {
|
||||
res.status(200).send('OK');
|
||||
});
|
||||
|
||||
// 使用express.static中间件提供静态文件服务
|
||||
// Express内置了全面的MIME类型支持,无需手动配置
|
||||
app.use(express.static(serverPublicDir, {
|
||||
expressApp.use(express.static(serverPublicDir, {
|
||||
// 设置默认文件
|
||||
index: ['index.html', 'index.htm'],
|
||||
// 启用etag缓存
|
||||
@@ -113,40 +169,131 @@ async function startWebServer() {
|
||||
dotfiles: 'ignore',
|
||||
// 自定义头部
|
||||
setHeaders: (res, path, stat) => {
|
||||
// 对HTML文件禁用缓存,方便开发调试
|
||||
if (path.endsWith('.html')) {
|
||||
res.set('Cache-Control', 'no-cache');
|
||||
const ext = path.split('.').pop().toLowerCase();
|
||||
// HTML、JS、CSS文件禁用缓存,方便开发调试
|
||||
if (['html', 'js', 'css'].includes(ext)){
|
||||
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.set('Pragma', 'no-cache');
|
||||
res.set('Expires', '0');
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// 404处理中间件
|
||||
app.use((req, res) => {
|
||||
expressApp.use((req, res) => {
|
||||
res.status(404).send('File not found');
|
||||
});
|
||||
|
||||
// 错误处理中间件
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('Server error:', err);
|
||||
res.status(500).send('Internal Server Error');
|
||||
expressApp.use((err, req, res, next) => {
|
||||
// 不是ENOENT错误,记录error级别日志
|
||||
if (err.code !== 'ENOENT') {
|
||||
loger.error('Server error:', err);
|
||||
res.status(500).send('Internal Server Error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 没有path,说明是404错误
|
||||
if (!err.path) {
|
||||
loger.warn('File not found:', req.url);
|
||||
res.status(404).send('File not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// 不是临时文件错误,普通404
|
||||
if (!err.path.includes('.com.dootask.task.')) {
|
||||
loger.warn('File not found:', err.path);
|
||||
res.status(404).send('File not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// 防止死循环 - 如果已经是重定向请求,直接返回404
|
||||
if (req.query._dt_restored) {
|
||||
const redirectTime = parseInt(req.query._dt_restored);
|
||||
const timeDiff = Date.now() - redirectTime;
|
||||
// 10秒内的重定向认为是死循环,直接返回404
|
||||
if (timeDiff < 10000) {
|
||||
loger.warn('Recent redirect detected, avoiding loop:', timeDiff + 'ms ago');
|
||||
res.status(404).send('File not found');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
loger.warn('Temporary file cleaned up by system:', err.path, req.url);
|
||||
|
||||
// 临时文件被系统清理,尝试从serverPublicDir重新读取并恢复
|
||||
const requestedUrl = new URL(req.url, serverUrl);
|
||||
const requestedFile = path.join(serverPublicDir, requestedUrl.pathname === '/' ? '/index.html' : requestedUrl.pathname);
|
||||
try {
|
||||
// 检查文件是否存在于serverPublicDir
|
||||
fs.accessSync(requestedFile, fs.constants.F_OK);
|
||||
|
||||
// 确保目标目录存在
|
||||
const targetDir = path.dirname(err.path);
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, {recursive: true});
|
||||
}
|
||||
|
||||
// 从ASAR文件中读取文件并写入到临时位置
|
||||
fs.writeFileSync(err.path, fs.readFileSync(requestedFile));
|
||||
|
||||
// 文件恢复成功后,301重定向到带__redirect参数的URL
|
||||
requestedUrl.searchParams.set('_dt_restored', Date.now());
|
||||
res.redirect(301, requestedUrl.toString());
|
||||
} catch (accessErr) {
|
||||
// 文件不存在于serverPublicDir,返回404
|
||||
loger.warn('Source file not found:', requestedFile, 'Error:', accessErr.message);
|
||||
res.status(404).send('File not found');
|
||||
}
|
||||
});
|
||||
|
||||
// 启动服务器
|
||||
const server = app.listen(serverPort, 'localhost', () => {
|
||||
console.log(`Express static file server running at http://localhost:${serverPort}/`);
|
||||
console.log(`Serving files from: ${serverPublicDir}`);
|
||||
const server = expressApp.listen(serverPort, 'localhost', () => {
|
||||
loger.info(`Express static file server running at http://localhost:${serverPort}/`);
|
||||
loger.info(`Serving files from: ${serverPublicDir}`);
|
||||
serverUrl = `http://localhost:${serverPort}/`;
|
||||
resolve(server);
|
||||
// 启动健康检查定时器
|
||||
serverTimeout();
|
||||
});
|
||||
|
||||
// 错误处理
|
||||
server.on('error', (err) => {
|
||||
console.error('Server error:', err);
|
||||
loger.error('Server error:', err);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 健康检查定时器
|
||||
*/
|
||||
function serverTimeout() {
|
||||
clearTimeout(serverTimer)
|
||||
serverTimer = setTimeout(async () => {
|
||||
if (!serverUrl) {
|
||||
return; // 没有服务器URL,直接返回
|
||||
}
|
||||
try {
|
||||
const res = await axios.head(serverUrl + 'health')
|
||||
if (res.status === 200) {
|
||||
serverTimeout() // 健康检查通过,重新设置定时器
|
||||
return;
|
||||
}
|
||||
loger.error('Server health check failed with status: ' + res.status);
|
||||
} catch (err) {
|
||||
loger.error('Server health check error:', err);
|
||||
}
|
||||
// 如果健康检查失败,尝试重新启动服务器
|
||||
try {
|
||||
await startWebServer(true)
|
||||
loger.info('Server restarted successfully');
|
||||
} catch (error) {
|
||||
loger.error('Failed to restart server:', error);
|
||||
}
|
||||
}, 10000)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建主窗口
|
||||
*/
|
||||
@@ -164,7 +311,7 @@ function createMainWindow() {
|
||||
webSecurity: true,
|
||||
nodeIntegration: true,
|
||||
contextIsolation: true,
|
||||
nativeWindowOpen: true
|
||||
backgroundThrottling: false,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -208,10 +355,10 @@ function createMainWindow() {
|
||||
// 新窗口处理
|
||||
mainWindow.webContents.setWindowOpenHandler(({url}) => {
|
||||
if (allowedCalls.test(url)) {
|
||||
openExternal(url)
|
||||
openExternal(url).catch(() => {})
|
||||
} else {
|
||||
utils.onBeforeOpenWindow(mainWindow.webContents, url).then(() => {
|
||||
openExternal(url)
|
||||
openExternal(url).catch(() => {})
|
||||
})
|
||||
}
|
||||
return {action: 'deny'}
|
||||
@@ -244,7 +391,7 @@ function createUpdaterWindow(updateTitle) {
|
||||
|
||||
// 检查updater应用是否存在
|
||||
if (!fs.existsSync(updaterPath)) {
|
||||
console.log('Updater not found:', updaterPath);
|
||||
loger.error('Updater not found:', updaterPath);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -256,13 +403,13 @@ function createUpdaterWindow(updateTitle) {
|
||||
try {
|
||||
spawn('icacls', [updaterPath, '/grant', 'everyone:F'], { stdio: 'inherit', shell: true });
|
||||
} catch (e) {
|
||||
console.log('Failed to set executable permission:', e);
|
||||
loger.error('Failed to set executable permission:', e);
|
||||
}
|
||||
} else if (process.platform === 'darwin') {
|
||||
try {
|
||||
spawn('chmod', ['+x', updaterPath], {stdio: 'inherit'});
|
||||
} catch (e) {
|
||||
console.log('Failed to set executable permission:', e);
|
||||
loger.error('Failed to set executable permission:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -285,11 +432,11 @@ function createUpdaterWindow(updateTitle) {
|
||||
child.unref();
|
||||
|
||||
child.on('error', (err) => {
|
||||
console.log('Updater process error:', err);
|
||||
loger.error('Updater process error:', err);
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
console.log('Failed to create updater process:', e);
|
||||
loger.error('Failed to create updater process:', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,7 +462,6 @@ function preCreateChildWindow() {
|
||||
webSecurity: true,
|
||||
nodeIntegration: true,
|
||||
contextIsolation: true,
|
||||
nativeWindowOpen: true
|
||||
}
|
||||
});
|
||||
|
||||
@@ -373,9 +519,11 @@ function createChildWindow(args) {
|
||||
webSecurity: true,
|
||||
nodeIntegration: true,
|
||||
contextIsolation: true,
|
||||
nativeWindowOpen: true
|
||||
}, webPreferences),
|
||||
}, config)
|
||||
if (!options.webPreferences.contextIsolation) {
|
||||
delete options.webPreferences.preload;
|
||||
}
|
||||
if (options.parent) {
|
||||
options.parent = mainWindow
|
||||
}
|
||||
@@ -397,7 +545,7 @@ function createChildWindow(args) {
|
||||
} else {
|
||||
// 创建新窗口
|
||||
browser = new BrowserWindow(options)
|
||||
console.log("create new window")
|
||||
loger.info("create new window")
|
||||
}
|
||||
|
||||
browser.on('page-title-updated', (event, title) => {
|
||||
@@ -450,10 +598,10 @@ function createChildWindow(args) {
|
||||
// 新窗口处理
|
||||
browser.webContents.setWindowOpenHandler(({url}) => {
|
||||
if (allowedCalls.test(url)) {
|
||||
openExternal(url)
|
||||
openExternal(url).catch(() => {})
|
||||
} else {
|
||||
utils.onBeforeOpenWindow(browser.webContents, url).then(() => {
|
||||
openExternal(url)
|
||||
openExternal(url).catch(() => {})
|
||||
})
|
||||
}
|
||||
return {action: 'deny'}
|
||||
@@ -622,9 +770,8 @@ function createWebTabWindow(args) {
|
||||
webSecurity: true,
|
||||
nodeIntegration: true,
|
||||
contextIsolation: true,
|
||||
nativeWindowOpen: true
|
||||
},
|
||||
}, userConf.get('webTabWindow', {})))
|
||||
}, userConf.get('webTabWindow') || {}))
|
||||
|
||||
const originalClose = webTabWindow.close;
|
||||
webTabWindow.close = function() {
|
||||
@@ -700,17 +847,16 @@ function createWebTabWindow(args) {
|
||||
webTabWindow.show();
|
||||
|
||||
// 创建 tab 子窗口
|
||||
const viewOptions = Object.assign({
|
||||
useHTMLTitleAndIcon: true,
|
||||
useLoadingView: true,
|
||||
useErrorView: true,
|
||||
}, args.config || {})
|
||||
const viewOptions = args.config || {}
|
||||
viewOptions.webPreferences = Object.assign({
|
||||
preload: path.join(__dirname, 'electron-preload.js'),
|
||||
nodeIntegration: true,
|
||||
contextIsolation: true
|
||||
}, args.webPreferences || {})
|
||||
const browserView = new BrowserView(viewOptions)
|
||||
if (!viewOptions.webPreferences.contextIsolation) {
|
||||
delete viewOptions.webPreferences.preload;
|
||||
}
|
||||
const browserView = new WebContentsView(viewOptions)
|
||||
if (args.backgroundColor) {
|
||||
browserView.setBackgroundColor(args.backgroundColor)
|
||||
} else if (nativeTheme.shouldUseDarkColors) {
|
||||
@@ -729,7 +875,7 @@ function createWebTabWindow(args) {
|
||||
})
|
||||
browserView.webContents.setWindowOpenHandler(({url}) => {
|
||||
if (allowedCalls.test(url)) {
|
||||
openExternal(url)
|
||||
openExternal(url).catch(() => {})
|
||||
} else {
|
||||
createWebTabWindow({url})
|
||||
}
|
||||
@@ -747,6 +893,20 @@ function createWebTabWindow(args) {
|
||||
if (!errorDescription) {
|
||||
return
|
||||
}
|
||||
// 主框架加载失败时,展示内置的错误页面
|
||||
if (isMainFrame) {
|
||||
const originalUrl = validatedURL || args.url || ''
|
||||
const filePath = path.join(__dirname, 'render', 'tabs', 'error.html')
|
||||
browserView.webContents.loadFile(filePath, {
|
||||
query: {
|
||||
id: String(browserView.webContents.id),
|
||||
url: originalUrl,
|
||||
code: String(errorCode),
|
||||
desc: errorDescription,
|
||||
}
|
||||
}).then(_ => { }).catch(_ => { })
|
||||
return
|
||||
}
|
||||
utils.onDispatchEvent(webTabWindow.webContents, {
|
||||
event: 'title',
|
||||
id: browserView.webContents.id,
|
||||
@@ -762,6 +922,9 @@ function createWebTabWindow(args) {
|
||||
}).then(_ => { })
|
||||
})
|
||||
browserView.webContents.on('did-start-loading', _ => {
|
||||
webTabView.forEach(({id: vid, view}) => {
|
||||
view.setVisible(vid === browserView.webContents.id)
|
||||
})
|
||||
utils.onDispatchEvent(webTabWindow.webContents, {
|
||||
event: 'start-loading',
|
||||
id: browserView.webContents.id,
|
||||
@@ -772,6 +935,7 @@ function createWebTabWindow(args) {
|
||||
event: 'stop-loading',
|
||||
id: browserView.webContents.id,
|
||||
}).then(_ => { })
|
||||
|
||||
// 加载完成暗黑模式下把窗口背景色改成白色,避免透明网站背景色穿透
|
||||
if (nativeTheme.shouldUseDarkColors) {
|
||||
browserView.setBackgroundColor('#FFFFFF')
|
||||
@@ -794,8 +958,9 @@ function createWebTabWindow(args) {
|
||||
electronMenu.webContentsMenu(browserView.webContents, true)
|
||||
|
||||
browserView.webContents.loadURL(args.url).then(_ => { }).catch(_ => { })
|
||||
browserView.setVisible(true)
|
||||
|
||||
webTabWindow.addBrowserView(browserView)
|
||||
webTabWindow.contentView.addChildView(browserView)
|
||||
webTabView.push({
|
||||
id: browserView.webContents.id,
|
||||
view: browserView
|
||||
@@ -811,15 +976,36 @@ function createWebTabWindow(args) {
|
||||
|
||||
/**
|
||||
* 获取当前内置浏览器标签
|
||||
* @returns {Electron.BrowserView|undefined}
|
||||
* @returns {Electron.WebContentsView|undefined}
|
||||
*/
|
||||
function currentWebTab() {
|
||||
const views = webTabWindow.getBrowserViews()
|
||||
const view = views.length ? views[views.length - 1] : undefined
|
||||
if (!view) {
|
||||
return undefined
|
||||
// 第一:使用当前可见的标签
|
||||
try {
|
||||
const item = webTabView.find(({view}) => view?.getVisible && view.getVisible())
|
||||
if (item) {
|
||||
return item
|
||||
}
|
||||
} catch (e) {}
|
||||
// 第二:使用当前聚焦的 webContents
|
||||
try {
|
||||
const focused = webContents.getFocusedWebContents?.()
|
||||
if (focused) {
|
||||
const item = webTabView.find(it => it.id === focused.id)
|
||||
if (item) {
|
||||
return item
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
// 兜底:根据 children 顺序选择最上层的可用视图
|
||||
const children = webTabWindow.contentView.children || []
|
||||
for (let i = children.length - 1; i >= 0; i--) {
|
||||
const id = children[i]?.webContents?.id
|
||||
const item = webTabView.find(it => it.id === id)
|
||||
if (item) {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return webTabView.find(item => item.id == view.webContents.id)
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -872,8 +1058,10 @@ function activateWebTab(id) {
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
webTabView.forEach(({id: vid, view}) => {
|
||||
view.setVisible(vid === item.id)
|
||||
})
|
||||
resizeWebTab(item.id)
|
||||
webTabWindow.setTopBrowserView(item.view)
|
||||
item.view.webContents.focus()
|
||||
utils.onDispatchEvent(webTabWindow.webContents, {
|
||||
event: 'switch',
|
||||
@@ -893,7 +1081,7 @@ function closeWebTab(id) {
|
||||
if (webTabView.length === 1) {
|
||||
webTabWindow.hide()
|
||||
}
|
||||
webTabWindow.removeBrowserView(item.view)
|
||||
webTabWindow.contentView.removeChildView(item.view)
|
||||
try {
|
||||
item.view.webContents.close()
|
||||
} catch (e) {
|
||||
@@ -955,11 +1143,11 @@ if (!getTheLock) {
|
||||
app.on('ready', async () => {
|
||||
isReady = true
|
||||
isWin && app.setAppUserModelId(config.appId)
|
||||
// 启动web服务
|
||||
// 启动 Web 服务器
|
||||
try {
|
||||
await startWebServer()
|
||||
} catch (error) {
|
||||
dialog.showErrorBox('启动失败', `服务器启动失败:${error.message}`);
|
||||
dialog.showErrorBox('启动失败', `Web 服务器启动失败:${error.message}`);
|
||||
app.quit();
|
||||
return;
|
||||
}
|
||||
@@ -1031,7 +1219,7 @@ app.on('before-quit', () => {
|
||||
willQuitApp = true
|
||||
})
|
||||
|
||||
app.on("will-quit",function(){
|
||||
app.on("will-quit", () => {
|
||||
globalShortcut.unregisterAll();
|
||||
})
|
||||
|
||||
@@ -1156,7 +1344,7 @@ ipcMain.on('webTabExternal', (event) => {
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
openExternal(item.view.webContents.getURL())
|
||||
openExternal(item.view.webContents.getURL()).catch(() => {})
|
||||
event.returnValue = "ok"
|
||||
})
|
||||
|
||||
@@ -1182,6 +1370,99 @@ ipcMain.on('webTabDestroyAll', (event) => {
|
||||
event.returnValue = "ok"
|
||||
})
|
||||
|
||||
/**
|
||||
* 内置浏览器 - 后退
|
||||
*/
|
||||
ipcMain.on('webTabGoBack', (event) => {
|
||||
const item = currentWebTab()
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
if (item.view.webContents.canGoBack()) {
|
||||
item.view.webContents.goBack()
|
||||
// 导航后更新状态
|
||||
setTimeout(() => {
|
||||
utils.onDispatchEvent(webTabWindow.webContents, {
|
||||
event: 'navigation-state',
|
||||
id: item.id,
|
||||
canGoBack: item.view.webContents.canGoBack(),
|
||||
canGoForward: item.view.webContents.canGoForward()
|
||||
}).then(_ => { })
|
||||
}, 100)
|
||||
}
|
||||
event.returnValue = "ok"
|
||||
})
|
||||
|
||||
/**
|
||||
* 内置浏览器 - 前进
|
||||
*/
|
||||
ipcMain.on('webTabGoForward', (event) => {
|
||||
const item = currentWebTab()
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
if (item.view.webContents.canGoForward()) {
|
||||
item.view.webContents.goForward()
|
||||
// 导航后更新状态
|
||||
setTimeout(() => {
|
||||
utils.onDispatchEvent(webTabWindow.webContents, {
|
||||
event: 'navigation-state',
|
||||
id: item.id,
|
||||
canGoBack: item.view.webContents.canGoBack(),
|
||||
canGoForward: item.view.webContents.canGoForward()
|
||||
}).then(_ => { })
|
||||
}, 100)
|
||||
}
|
||||
event.returnValue = "ok"
|
||||
})
|
||||
|
||||
/**
|
||||
* 内置浏览器 - 刷新
|
||||
*/
|
||||
ipcMain.on('webTabReload', (event) => {
|
||||
const item = currentWebTab()
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
item.view.webContents.reload()
|
||||
// 刷新完成后会触发 did-stop-loading 事件,在那里会更新导航状态
|
||||
event.returnValue = "ok"
|
||||
})
|
||||
|
||||
/**
|
||||
* 内置浏览器 - 停止加载
|
||||
*/
|
||||
ipcMain.on('webTabStop', (event) => {
|
||||
const item = currentWebTab()
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
item.view.webContents.stop()
|
||||
event.returnValue = "ok"
|
||||
})
|
||||
|
||||
/**
|
||||
* 内置浏览器 - 获取导航状态
|
||||
*/
|
||||
ipcMain.on('webTabGetNavigationState', (event) => {
|
||||
const item = currentWebTab()
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
|
||||
const canGoBack = item.view.webContents.canGoBack()
|
||||
const canGoForward = item.view.webContents.canGoForward()
|
||||
|
||||
utils.onDispatchEvent(webTabWindow.webContents, {
|
||||
event: 'navigation-state',
|
||||
id: item.id,
|
||||
canGoBack,
|
||||
canGoForward
|
||||
}).then(_ => { })
|
||||
|
||||
event.returnValue = "ok"
|
||||
})
|
||||
|
||||
/**
|
||||
* 隐藏窗口(mac、win隐藏,其他关闭)
|
||||
*/
|
||||
@@ -1221,6 +1502,7 @@ ipcMain.on('childWindowCloseAll', (event) => {
|
||||
})
|
||||
preloadWindow?.close()
|
||||
mediaWindow?.close()
|
||||
electronDown.close()
|
||||
event.returnValue = "ok"
|
||||
})
|
||||
|
||||
@@ -1233,6 +1515,7 @@ ipcMain.on('childWindowDestroyAll', (event) => {
|
||||
})
|
||||
preloadWindow?.destroy()
|
||||
mediaWindow?.destroy()
|
||||
electronDown.destroy()
|
||||
event.returnValue = "ok"
|
||||
})
|
||||
|
||||
@@ -1379,6 +1662,19 @@ ipcMain.on('setDockBadge', (event, args) => {
|
||||
event.returnValue = "ok"
|
||||
})
|
||||
|
||||
/**
|
||||
* MCP 服务器状态切换
|
||||
* @param args
|
||||
*/
|
||||
ipcMain.on('mcpServerToggle', (event, args) => {
|
||||
const { running } = args;
|
||||
if (running === 'running') {
|
||||
startMCPServer(mainWindow, mcpPort)
|
||||
} else {
|
||||
stopMCPServer()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 复制Base64图片
|
||||
* @param args
|
||||
@@ -1400,7 +1696,7 @@ ipcMain.on('copyImageAt', (event, args) => {
|
||||
try {
|
||||
event.sender.copyImageAt(args.x, args.y);
|
||||
} catch (e) {
|
||||
// loger.error(e)
|
||||
loger.error('copyImageAt error:', e)
|
||||
}
|
||||
event.returnValue = "ok"
|
||||
})
|
||||
@@ -1483,6 +1779,14 @@ ipcMain.handle('getStore', (event, args) => {
|
||||
return store.get(args)
|
||||
});
|
||||
|
||||
/**
|
||||
* 清理服务器缓存
|
||||
*/
|
||||
ipcMain.on('clearServerCache', (event) => {
|
||||
utils.clearServerCache();
|
||||
event.returnValue = "ok";
|
||||
});
|
||||
|
||||
//================================================================
|
||||
// Update
|
||||
//================================================================
|
||||
@@ -1569,6 +1873,7 @@ ipcMain.on('updateQuitAndInstall', (event, args) => {
|
||||
})
|
||||
preloadWindow?.destroy()
|
||||
mediaWindow?.destroy()
|
||||
electronDown.destroy()
|
||||
|
||||
// 启动更新子窗口
|
||||
createUpdaterWindow(args.updateTitle)
|
||||
@@ -1717,7 +2022,6 @@ function exportVsdx(event, args, directFinalize) {
|
||||
webSecurity: true,
|
||||
nodeIntegration: true,
|
||||
contextIsolation: true,
|
||||
nativeWindowOpen: true
|
||||
},
|
||||
})
|
||||
|
||||
@@ -2330,7 +2634,7 @@ async function saveFile(fileObject, data, origStat, overwrite, defEnc) {
|
||||
|
||||
async function doSaveFile(isNew) {
|
||||
if (enableStoreBkp && !isNew) {
|
||||
//Copy file to backup file (after conflict and stat is checked)
|
||||
//Copy file to back up file (after conflict and stat is checked)
|
||||
let bkpFh;
|
||||
|
||||
try {
|
||||
@@ -2466,7 +2770,7 @@ function getPluginFile(plugin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function uninstallPlugin(plugin) {
|
||||
async function uninstallPlugin(plugin) {
|
||||
const pluginFile = getPluginFile(plugin);
|
||||
|
||||
if (pluginFile != null) {
|
||||
@@ -2527,7 +2831,7 @@ async function deleteFile(file) {
|
||||
}
|
||||
}
|
||||
|
||||
function windowAction(method) {
|
||||
async function windowAction(method) {
|
||||
let win = BrowserWindow.getFocusedWindow();
|
||||
|
||||
if (win) {
|
||||
@@ -2547,16 +2851,14 @@ function windowAction(method) {
|
||||
}
|
||||
}
|
||||
|
||||
function openExternal(url) {
|
||||
async function openExternal(url) {
|
||||
//Only open http(s), mailto, tel, and callto links
|
||||
if (allowedUrls.test(url)) {
|
||||
shell.openExternal(url).catch(_ => {});
|
||||
return true;
|
||||
await shell.openExternal(url)
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function watchFile(path) {
|
||||
async function watchFile(path) {
|
||||
let win = BrowserWindow.getFocusedWindow();
|
||||
|
||||
if (win) {
|
||||
@@ -2568,12 +2870,13 @@ function watchFile(path) {
|
||||
prev: prev
|
||||
});
|
||||
} catch (e) {
|
||||
} // Ignore
|
||||
// Ignore
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function unwatchFile(path) {
|
||||
async function unwatchFile(path) {
|
||||
fs.unwatchFile(path);
|
||||
}
|
||||
|
||||
@@ -2602,7 +2905,7 @@ ipcMain.on("rendererReq", async (event, args) => {
|
||||
ret = await getDocumentsFolder();
|
||||
break;
|
||||
case 'checkFileExists':
|
||||
ret = await checkFileExists(args.pathParts);
|
||||
ret = checkFileExists(args.pathParts);
|
||||
break;
|
||||
case 'showOpenDialog':
|
||||
dialogOpen = true;
|
||||
@@ -2623,7 +2926,7 @@ ipcMain.on("rendererReq", async (event, args) => {
|
||||
ret = await uninstallPlugin(args.plugin);
|
||||
break;
|
||||
case 'getPluginFile':
|
||||
ret = await getPluginFile(args.plugin);
|
||||
ret = getPluginFile(args.plugin);
|
||||
break;
|
||||
case 'isPluginsEnabled':
|
||||
ret = enablePlugins;
|
||||
@@ -2635,7 +2938,7 @@ ipcMain.on("rendererReq", async (event, args) => {
|
||||
ret = await readFile(args.filename, args.encoding);
|
||||
break;
|
||||
case 'clipboardAction':
|
||||
ret = await clipboardAction(args.method, args.data);
|
||||
ret = clipboardAction(args.method, args.data);
|
||||
break;
|
||||
case 'deleteFile':
|
||||
ret = await deleteFile(args.file);
|
||||
@@ -2652,6 +2955,15 @@ ipcMain.on("rendererReq", async (event, args) => {
|
||||
case 'openExternal':
|
||||
ret = await openExternal(args.url);
|
||||
break;
|
||||
case 'openDownloadWindow':
|
||||
ret = await electronDown.open(args.language || 'zh', args.theme || 'light');
|
||||
break;
|
||||
case 'updateDownloadWindow':
|
||||
ret = await electronDown.updateWindow(args.language, args.theme);
|
||||
break;
|
||||
case 'createDownload':
|
||||
ret = await electronDown.createDownload(mainWindow, args.url, args.options || {});
|
||||
break;
|
||||
case 'watchFile':
|
||||
ret = await watchFile(args.path);
|
||||
break;
|
||||
@@ -2659,12 +2971,13 @@ ipcMain.on("rendererReq", async (event, args) => {
|
||||
ret = await unwatchFile(args.path);
|
||||
break;
|
||||
case 'getCurDir':
|
||||
ret = await getCurDir();
|
||||
ret = getCurDir();
|
||||
break;
|
||||
}
|
||||
|
||||
event.reply('mainResp', {success: true, data: ret, reqId: args.reqId});
|
||||
} catch (e) {
|
||||
event.reply('mainResp', {error: true, msg: e.message, e: e, reqId: args.reqId});
|
||||
loger.error('Renderer request error', e.message, e.stack);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<body>
|
||||
|
||||
|
||||
<div id="app">
|
||||
<div id="app" data-preload="init">
|
||||
<div class="app-view-loading no-dark-content">
|
||||
<div>
|
||||
<div>PAGE LOADING</div>
|
||||
@@ -33,6 +33,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
setTimeout(function () {
|
||||
if (document.getElementById("app")?.getAttribute("data-preload") === "false") {
|
||||
window.location.reload();
|
||||
}
|
||||
}, 6000);
|
||||
</script>
|
||||
|
||||
<!--script-->
|
||||
|
||||
</body>
|
||||
|
||||
239
electron/lib/download-manager.js
vendored
Normal file
239
electron/lib/download-manager.js
vendored
Normal file
@@ -0,0 +1,239 @@
|
||||
const path = require("path");
|
||||
const loger = require("electron-log");
|
||||
const Store = require('electron-store');
|
||||
const utils = require("./utils");
|
||||
const store = new Store({
|
||||
name: 'download-manager',
|
||||
defaults: {
|
||||
downloadHistory: [],
|
||||
}
|
||||
});
|
||||
|
||||
const DownloadStore = {
|
||||
get(key, defaultValue) {
|
||||
return store.get(key, defaultValue);
|
||||
},
|
||||
set(key, value) {
|
||||
store.set(key, value);
|
||||
},
|
||||
};
|
||||
|
||||
class DownloadManager {
|
||||
static key = 'downloadHistory';
|
||||
|
||||
constructor() {
|
||||
const history = DownloadStore.get(DownloadManager.key, []);
|
||||
if (utils.isArray(history)) {
|
||||
this.downloadHistory = history.map(item => ({
|
||||
...item,
|
||||
|
||||
// 历史记录中,将 progressing 状态改为 interrupted
|
||||
state: item.state === 'progressing' ? 'interrupted' : item.state,
|
||||
|
||||
// 移除源对象,避免序列化问题
|
||||
_source: undefined,
|
||||
}));
|
||||
} else {
|
||||
this.downloadHistory = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换下载项格式
|
||||
* @param {Electron.DownloadItem} downloadItem
|
||||
*/
|
||||
convert(downloadItem) {
|
||||
return {
|
||||
filename: path.basename(downloadItem.getSavePath()) || downloadItem.getFilename(),
|
||||
path: downloadItem.getSavePath(),
|
||||
url: downloadItem.getURL(),
|
||||
urls: downloadItem.getURLChain(),
|
||||
mine: downloadItem.getMimeType(),
|
||||
received: downloadItem.getReceivedBytes(),
|
||||
total: downloadItem.getTotalBytes(),
|
||||
percent: downloadItem.getPercentComplete(),
|
||||
speed: downloadItem.getCurrentBytesPerSecond(),
|
||||
state: downloadItem.getState(),
|
||||
paused: downloadItem.isPaused(),
|
||||
startTime: downloadItem.getStartTime(),
|
||||
endTime: downloadItem.getEndTime(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加下载项
|
||||
* @param {Electron.DownloadItem} downloadItem
|
||||
*/
|
||||
add(downloadItem) {
|
||||
// 根据保存路径,如果下载项已存在,则取消下载(避免重复下载)
|
||||
this.cancel(downloadItem.getSavePath());
|
||||
|
||||
// 添加下载项
|
||||
this.downloadHistory.unshift({
|
||||
...this.convert(downloadItem),
|
||||
error: null,
|
||||
_source: downloadItem,
|
||||
});
|
||||
if (this.downloadHistory.length > 1000) {
|
||||
this.downloadHistory = this.downloadHistory.slice(0, 1000);
|
||||
}
|
||||
DownloadStore.set(DownloadManager.key, this.downloadHistory);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取下载列表
|
||||
* @returns {*}
|
||||
*/
|
||||
get() {
|
||||
return this.downloadHistory.map(item => {
|
||||
return {
|
||||
...item,
|
||||
|
||||
// 移除源对象,避免序列化问题
|
||||
_source: undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新下载项
|
||||
* @param {string} path
|
||||
*/
|
||||
refresh(path) {
|
||||
const item = this.downloadHistory.find(d => d.path === path)
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
const downloadItem = item._source;
|
||||
if (!downloadItem) {
|
||||
loger.warn(`Download item not found for path: ${path}`);
|
||||
return;
|
||||
}
|
||||
Object.assign(item, this.convert(downloadItem))
|
||||
DownloadStore.set(DownloadManager.key, this.downloadHistory);
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试更新下载项的错误信息
|
||||
* @param {Electron.DownloadItem} downloadItem
|
||||
* @param {Object} headers
|
||||
*/
|
||||
async updateError(downloadItem, headers = {}) {
|
||||
const urls = downloadItem.getURLChain()
|
||||
const url = urls.length > 0 ? urls[0] : downloadItem.getURL()
|
||||
const path = downloadItem.getSavePath()
|
||||
|
||||
const item = this.downloadHistory.find(d => d.path === path)
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'HEAD',
|
||||
headers,
|
||||
})
|
||||
let error = null
|
||||
if (res.headers.get('X-Error-Message-Base64')) {
|
||||
error = Buffer.from(res.headers.get('X-Error-Message-Base64'), 'base64').toString('utf-8')
|
||||
} else if (res.headers.get('X-Error-Message')) {
|
||||
error = res.headers.get('X-Error-Message')
|
||||
}
|
||||
if (error) {
|
||||
Object.assign(item, {error});
|
||||
DownloadStore.set(DownloadManager.key, this.downloadHistory);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// 忽略错误
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停下载项
|
||||
* @param {string} path
|
||||
*/
|
||||
pause(path) {
|
||||
const item = this.downloadHistory.find(d => d.path === path)
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
const downloadItem = item._source;
|
||||
if (!downloadItem) {
|
||||
loger.warn(`Download item not found for path: ${path}`);
|
||||
return;
|
||||
}
|
||||
downloadItem.pause();
|
||||
this.refresh(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复下载项
|
||||
* @param {string} path
|
||||
*/
|
||||
resume(path) {
|
||||
const item = this.downloadHistory.find(d => d.path === path)
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
const downloadItem = item._source;
|
||||
if (!downloadItem) {
|
||||
loger.warn(`Download item not found for path: ${path}`);
|
||||
return;
|
||||
}
|
||||
downloadItem.resume();
|
||||
this.refresh(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消下载项
|
||||
* @param {string} path
|
||||
*/
|
||||
cancel(path) {
|
||||
const item = this.downloadHistory.find(d => d.path === path)
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
const downloadItem = item._source;
|
||||
if (!downloadItem) {
|
||||
loger.warn(`Download item not found for path: ${path}`);
|
||||
return;
|
||||
}
|
||||
downloadItem.cancel();
|
||||
this.refresh(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消所有下载项
|
||||
*/
|
||||
cancelAll() {
|
||||
this.downloadHistory.forEach(item => {
|
||||
this.cancel(item.path);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除下载项
|
||||
* @param {string} path
|
||||
*/
|
||||
remove(path) {
|
||||
const index = this.downloadHistory.findIndex(item => item.path === path);
|
||||
if (index > -1) {
|
||||
this.cancel(path);
|
||||
this.downloadHistory.splice(index, 1);
|
||||
DownloadStore.set(DownloadManager.key, this.downloadHistory);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空下载项
|
||||
*/
|
||||
removeAll() {
|
||||
this.cancelAll();
|
||||
this.downloadHistory = [];
|
||||
DownloadStore.set(DownloadManager.key, []);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {DownloadStore, DownloadManager};
|
||||
1278
electron/lib/mcp.js
vendored
Normal file
1278
electron/lib/mcp.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
18
electron/utils.js → electron/lib/utils.js
vendored
18
electron/utils.js → electron/lib/utils.js
vendored
@@ -682,6 +682,24 @@ const utils = {
|
||||
} else {
|
||||
return "#FFFFFF";
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 清理服务器缓存
|
||||
*/
|
||||
clearServerCache() {
|
||||
try {
|
||||
// 清理require缓存中的express相关模块
|
||||
Object.keys(require.cache).forEach(key => {
|
||||
if (key.includes('express') || key.includes('static')) {
|
||||
delete require.cache[key];
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Server cache cleared');
|
||||
} catch (e) {
|
||||
console.error('Failed to clear server cache:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,34 +26,39 @@
|
||||
"url": "https://github.com/kuaifan/dootask.git"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "^7.7.0",
|
||||
"@electron-forge/maker-deb": "^7.7.0",
|
||||
"@electron-forge/maker-rpm": "^7.7.0",
|
||||
"@electron-forge/maker-squirrel": "^7.7.0",
|
||||
"@electron-forge/maker-zip": "^7.7.0",
|
||||
"@electron-forge/cli": "^7.10.2",
|
||||
"@electron-forge/maker-deb": "^7.10.2",
|
||||
"@electron-forge/maker-rpm": "^7.10.2",
|
||||
"@electron-forge/maker-squirrel": "^7.10.2",
|
||||
"@electron-forge/maker-zip": "^7.10.2",
|
||||
"@types/crc": "^3.8.3",
|
||||
"@types/electron-config": "^0.2.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"electron": "^34.3.4",
|
||||
"electron-builder": "^25.1.8",
|
||||
"electron": "^38.4.0",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-notarize": "^1.2.2",
|
||||
"form-data": "^4.0.1",
|
||||
"inquirer": "^12.4.2",
|
||||
"form-data": "^4.0.4",
|
||||
"inquirer": "^12.9.1",
|
||||
"ora": "^4.1.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.7",
|
||||
"@dootask/electron-dl": "^4.0.0-rc.2",
|
||||
"axios": "^1.11.0",
|
||||
"crc": "^3.8.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"electron-config": "^2.0.0",
|
||||
"electron-log": "^5.2.2",
|
||||
"electron-log": "^5.4.2",
|
||||
"electron-screenshots-tool": "^1.1.2",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"electron-store": "^8.2.0",
|
||||
"electron-updater": "^6.3.9",
|
||||
"electron-updater": "^6.6.2",
|
||||
"express": "^5.1.0",
|
||||
"fastmcp": "^3.21.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"request": "^2.88.2",
|
||||
"tar": "^7.4.3",
|
||||
"zod": "^3.23.8",
|
||||
"yauzl": "^3.2.0"
|
||||
},
|
||||
"trayIcon": {
|
||||
@@ -74,12 +79,13 @@
|
||||
"output": "dist"
|
||||
},
|
||||
"files": [
|
||||
"lib/**/*",
|
||||
"render/**/*",
|
||||
"public/**/*",
|
||||
"electron-down.js",
|
||||
"electron-menu.js",
|
||||
"electron-preload.js",
|
||||
"electron.js",
|
||||
"utils.js"
|
||||
"electron.js"
|
||||
],
|
||||
"extraFiles": [
|
||||
{
|
||||
|
||||
522
electron/render/download/index.html
Normal file
522
electron/render/download/index.html
Normal file
@@ -0,0 +1,522 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Download</title>
|
||||
<link rel="stylesheet" href="./style.css">
|
||||
<script>
|
||||
const getQueryParam = (name) => {
|
||||
const url = window.location.href;
|
||||
const match = url.match(new RegExp('[?&]' + name + '=([^&#]*)'));
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
const updateTheme = (theme) => {
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle('dark', theme === 'dark');
|
||||
root.classList.toggle('light', theme === 'light');
|
||||
};
|
||||
|
||||
updateTheme(getQueryParam('theme'))
|
||||
</script>
|
||||
<script src="../tabs/assets/js/vue.global.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" class="download-manager">
|
||||
<div class="toolbar">
|
||||
<label class="search">
|
||||
<input class="search-input" v-model.trim="query" :placeholder="lang.searchPlaceholder"></input>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button class="action-btn" @click="onRemoveAll" :disabled="items.length === 0">{{ lang.removeAll }}</button>
|
||||
<button class="action-btn" @click="onRefresh">{{ lang.refresh }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="tab-content all-tasks">
|
||||
<div v-if="list.length === 0 && waiting === false" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="48" height="48" fill="currentColor">
|
||||
<path d="M5 20h14v-2H5v2zM19 9h-4V3H9v6H5l7 7 7-7z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="empty-text">{{ query ? lang.noSearchResult : lang.noItems }}</div>
|
||||
</div>
|
||||
<div v-else class="task-list">
|
||||
<!-- 骨架条目 -->
|
||||
<div v-if="waiting" class="task-item skeleton-item">
|
||||
<div class="task-icon">
|
||||
<div class="skeleton-file-icon"></div>
|
||||
</div>
|
||||
<div class="task-info">
|
||||
<div class="task-name">
|
||||
<div class="skeleton-name"></div>
|
||||
</div>
|
||||
<div class="task-meta">
|
||||
<span class="skeleton-size"></span>
|
||||
<span class="skeleton-time"></span>
|
||||
<span class="skeleton-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-actions">
|
||||
<div class="skeleton-btn"></div>
|
||||
<div class="skeleton-btn"></div>
|
||||
<div class="skeleton-btn"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 任务列表 -->
|
||||
<div
|
||||
v-for="(item, index) in list"
|
||||
:key="index"
|
||||
class="task-item"
|
||||
:class="{'progressing-item': item.state === 'progressing'}"
|
||||
:style="item.state === 'progressing' ? {'--progress': item.percent + '%'} : {'--progress': '0%'}">
|
||||
<div class="task-icon">
|
||||
<div class="file-icon" :class="getFileTypeClass(item)" v-html="getFileIcon(item)"></div>
|
||||
</div>
|
||||
<div class="task-info">
|
||||
<div class="task-name">
|
||||
<div class="task-name-clickable" :title="item.filename" @click="onOpenFile(item)">{{ item.filename }}</div>
|
||||
</div>
|
||||
<div class="task-meta">
|
||||
<!-- 大小 -->
|
||||
<span v-if="item.state === 'progressing'" class="file-size">
|
||||
{{ formatBytes(item.received) }}<template v-if="item.total > 0"> / {{ formatBytes(item.total) }}</template><template v-if="item.percent >= 0"> ({{ item.percent }}%)</template>
|
||||
</span>
|
||||
<span v-else class="file-size">
|
||||
{{ formatBytes(item.total) }}
|
||||
</span>
|
||||
<!-- 时间 -->
|
||||
<span v-if="item.state === 'completed'" class="download-time">{{ formatTime(item.endTime) }}</span>
|
||||
<span v-else class="download-time">{{ formatTime(item.startTime) }}</span>
|
||||
<!-- 状态 -->
|
||||
<span v-if="item.state !== 'progressing' || item.paused" class="state" :class="getStateClass(item)">
|
||||
{{ getStateText(item) }}{{item.error ? `: ${item.error}` : ''}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-actions">
|
||||
<!-- 下载速度 -->
|
||||
<span v-if="item.state === 'progressing' && item.speed" class="speed">{{ formatBytes(item.speed) }}/s</span>
|
||||
<!-- 复制链接 -->
|
||||
<button @click="copyUrl(item)" class="icon-btn" :title="lang.copyLink">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet" focusable="false" viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- 暂停和继续 -->
|
||||
<template v-if="item.state === 'progressing'">
|
||||
<button v-if="item.paused" @click="onResume(item)" class="icon-btn" :title="lang.resume">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button v-else @click="onPause(item)" class="icon-btn" :title="lang.pause">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
<!-- 显示文件夹 -->
|
||||
<button v-if="item.state === 'completed'" @click="onShowFolder(item)" class="icon-btn" :title="lang.showInFolder">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M20 6h-8l-2-2H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 12H4V8h16v10z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- 删除 -->
|
||||
<button @click="onRemove(item)" class="icon-btn danger" :title="lang.remove">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 信息提示框 -->
|
||||
<div v-if="toast.show" class="toast" :class="toast.type">
|
||||
<div class="toast-content">
|
||||
<svg v-if="toast.type === 'success'" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
</svg>
|
||||
<svg v-else-if="toast.type === 'error'" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
|
||||
</svg>
|
||||
<span class="toast-message">{{ toast.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const {createApp} = Vue;
|
||||
|
||||
createApp({
|
||||
data() {
|
||||
return {
|
||||
query: '',
|
||||
|
||||
items: [],
|
||||
waiting: false,
|
||||
|
||||
lang: {
|
||||
// 语言设置
|
||||
locale: 'zh-CN',
|
||||
title: '下载管理器',
|
||||
|
||||
// 界面文本
|
||||
searchPlaceholder: '搜索文件名或链接...',
|
||||
noItems: '暂无任务',
|
||||
noSearchResult: '未找到匹配的结果',
|
||||
|
||||
// 操作按钮
|
||||
refresh: '刷新',
|
||||
removeAll: "清空历史",
|
||||
copyLink: '复制链接',
|
||||
resume: '继续',
|
||||
pause: '暂停',
|
||||
cancel: '取消',
|
||||
remove: '删除',
|
||||
showInFolder: '显示在文件夹',
|
||||
|
||||
// 状态文本
|
||||
progressing: '下载中',
|
||||
completed: '已完成',
|
||||
cancelled: '已取消',
|
||||
interrupted: '失败',
|
||||
paused: '已暂停',
|
||||
|
||||
// 成功消息
|
||||
copied: "已复制",
|
||||
refreshSuccess: '刷新成功',
|
||||
|
||||
// 确认对话框
|
||||
confirmCancel: '确定要取消此下载任务并删除记录吗?',
|
||||
confirmRemove: '确定要从历史记录中删除此项吗?',
|
||||
confirmRemoveAll: '确定要清空下载历史吗?',
|
||||
|
||||
// 错误消息
|
||||
copyFailed: '复制失败: ',
|
||||
pauseFailed: '暂停失败: ',
|
||||
resumeFailed: '继续失败: ',
|
||||
removeFailed: '删除失败: ',
|
||||
removeAllFailed: '清空失败: ',
|
||||
openFailed: '打开文件失败: ',
|
||||
showFailed: '显示文件失败: ',
|
||||
},
|
||||
toast: {
|
||||
show: false,
|
||||
type: 'success', // success, error
|
||||
message: '',
|
||||
timer: null
|
||||
},
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.getList();
|
||||
|
||||
// 监听下载任务列表
|
||||
electron?.listener('download-items', ({items, waiting}) => {
|
||||
this.items = items
|
||||
this.waiting = waiting
|
||||
});
|
||||
|
||||
// 接收主题
|
||||
electron?.listener('download-theme', (theme) => {
|
||||
updateTheme(theme)
|
||||
});
|
||||
|
||||
// 接收语言包
|
||||
electron?.listener('download-language', (lang) => {
|
||||
if (lang && typeof lang === 'object') {
|
||||
this.lang = {...this.lang, ...lang};
|
||||
document.title = this.lang.title || document.title;
|
||||
}
|
||||
});
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.toast.timer) {
|
||||
clearTimeout(this.toast.timer);
|
||||
this.toast.timer = null;
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
list() {
|
||||
const q = (this.query || '').toLowerCase();
|
||||
return q
|
||||
? this.items.filter(t => (t.filename || '').toLowerCase().includes(q) || (t.url || '').toLowerCase().includes(q))
|
||||
: this.items;
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async sendAsync(action, args = {}) {
|
||||
try {
|
||||
return await electron?.sendAsync("downloadManager", {
|
||||
action,
|
||||
...args
|
||||
});
|
||||
} catch (e) {
|
||||
e.message = `${e.message}`.replace(/Error invoking remote method 'downloadManager': Error:\s+/, '');
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
async copyUrl({url, urls}) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(urls.length > 0 ? urls[0] : url);
|
||||
this.showToast(this.lang.copied);
|
||||
} catch (e) {
|
||||
this.errorToast(this.lang.copyFailed + e.message);
|
||||
}
|
||||
},
|
||||
|
||||
async getList() {
|
||||
try {
|
||||
const data = await this.sendAsync('get');
|
||||
this.items = data.items || [];
|
||||
this.waiting = data.waiting || false;
|
||||
} catch (e) {
|
||||
console.error('加载下载任务失败:', e);
|
||||
}
|
||||
},
|
||||
|
||||
async onRefresh() {
|
||||
await this.getList();
|
||||
this.showToast(this.lang.refreshSuccess, 'success');
|
||||
},
|
||||
|
||||
async onPause({path}) {
|
||||
try {
|
||||
await this.sendAsync('pause', {path});
|
||||
} catch (e) {
|
||||
this.errorToast(this.lang.pauseFailed + e.message);
|
||||
}
|
||||
},
|
||||
|
||||
async onResume({path}) {
|
||||
try {
|
||||
await this.sendAsync('resume', {path});
|
||||
} catch (e) {
|
||||
this.errorToast(this.lang.resumeFailed + e.message);
|
||||
}
|
||||
},
|
||||
|
||||
async onRemove({state, path}) {
|
||||
if (!confirm(state === 'progressing' ? this.lang.confirmCancel : this.lang.confirmRemove)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.sendAsync('remove', {path});
|
||||
} catch (e) {
|
||||
this.errorToast(this.lang.removeFailed + e.message);
|
||||
}
|
||||
},
|
||||
|
||||
async onRemoveAll() {
|
||||
if (!confirm(this.lang.confirmRemoveAll)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.sendAsync('removeAll');
|
||||
} catch (e) {
|
||||
this.errorToast(this.lang.removeAllFailed + e.message);
|
||||
}
|
||||
},
|
||||
|
||||
async onOpenFile({path}) {
|
||||
try {
|
||||
await this.sendAsync('openFile', {path});
|
||||
} catch (e) {
|
||||
this.errorToast(this.lang.openFailed + e.message);
|
||||
}
|
||||
},
|
||||
|
||||
async onShowFolder({path}) {
|
||||
try {
|
||||
await this.sendAsync('showFolder', {path});
|
||||
} catch (e) {
|
||||
this.errorToast(this.lang.showFailed + e.message);
|
||||
}
|
||||
},
|
||||
|
||||
isPaused({state, paused}) {
|
||||
return state === 'progressing' && paused;
|
||||
},
|
||||
|
||||
getStateText({state, paused}) {
|
||||
if (this.isPaused({state, paused})) {
|
||||
return this.lang.paused;
|
||||
}
|
||||
const stateMap = {
|
||||
'progressing': this.lang.progressing,
|
||||
'completed': this.lang.completed,
|
||||
'cancelled': this.lang.cancelled,
|
||||
'interrupted': this.lang.interrupted,
|
||||
};
|
||||
return stateMap[state] || state;
|
||||
},
|
||||
|
||||
getStateClass({state, paused}) {
|
||||
if (this.isPaused({state, paused})) {
|
||||
return 'paused';
|
||||
}
|
||||
return state
|
||||
},
|
||||
|
||||
getFileTypeClass({filename}) {
|
||||
const typeMap = {
|
||||
'pdf': 'file-pdf',
|
||||
'doc': 'file-word',
|
||||
'docx': 'file-word',
|
||||
'xls': 'file-excel',
|
||||
'xlsx': 'file-excel',
|
||||
'ppt': 'file-powerpoint',
|
||||
'pptx': 'file-powerpoint',
|
||||
'jpg': 'file-image',
|
||||
'jpeg': 'file-image',
|
||||
'png': 'file-image',
|
||||
'gif': 'file-image',
|
||||
'svg': 'file-image',
|
||||
'webp': 'file-image',
|
||||
'bmp': 'file-image',
|
||||
'mp4': 'file-video',
|
||||
'avi': 'file-video',
|
||||
'mov': 'file-video',
|
||||
'mkv': 'file-video',
|
||||
'webm': 'file-video',
|
||||
'wmv': 'file-video',
|
||||
'mp3': 'file-audio',
|
||||
'wav': 'file-audio',
|
||||
'flac': 'file-audio',
|
||||
'aac': 'file-audio',
|
||||
'm4a': 'file-audio',
|
||||
'zip': 'file-archive',
|
||||
'rar': 'file-archive',
|
||||
'7z': 'file-archive',
|
||||
'tar': 'file-archive',
|
||||
'gz': 'file-archive',
|
||||
'txt': 'file-text',
|
||||
'md': 'file-text',
|
||||
'rtf': 'file-text',
|
||||
'js': 'file-code',
|
||||
'ts': 'file-code',
|
||||
'css': 'file-code',
|
||||
'html': 'file-code',
|
||||
'php': 'file-code',
|
||||
'py': 'file-code',
|
||||
'java': 'file-code',
|
||||
'cpp': 'file-code',
|
||||
'c': 'file-code',
|
||||
'exe': 'file-exe',
|
||||
'msi': 'file-exe',
|
||||
'dmg': 'file-exe',
|
||||
'deb': 'file-exe',
|
||||
'rpm': 'file-exe',
|
||||
'_': 'file-unknown'
|
||||
};
|
||||
|
||||
return typeMap[this.getFileExt(filename)] || typeMap['_'];
|
||||
},
|
||||
|
||||
getFileIcon({filename}) {
|
||||
const iconMap = {
|
||||
'pdf': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ea4335"><path d="M20 2H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8.5 7.5c0 .83-.67 1.5-1.5 1.5H9v2H7.5V7H10c.83 0 1.5.67 1.5 1.5v1zm5 2c0 .83-.67 1.5-1.5 1.5h-2.5V7H15c.83 0 1.5.67 1.5 1.5v3zm4-3H19v1h1.5V11H19v1h1.5v1.5H17.5V7h4v1.5z"/></svg>',
|
||||
|
||||
'doc': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#4285f4"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 2 2h12c1.11 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/></svg>',
|
||||
'docx': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#4285f4"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 2 2h12c1.11 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/></svg>',
|
||||
|
||||
'xls': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#0f9d58"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 2 2h12c1.11 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/></svg>',
|
||||
'xlsx': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#0f9d58"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 2 2h12c1.11 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/></svg>',
|
||||
|
||||
'ppt': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ff6d01"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 2 2h12c1.11 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/></svg>',
|
||||
'pptx': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ff6d01"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 2 2h12c1.11 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/></svg>',
|
||||
|
||||
'jpg': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#9c27b0"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>',
|
||||
'jpeg': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#9c27b0"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>',
|
||||
'png': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#9c27b0"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>',
|
||||
'gif': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#9c27b0"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>',
|
||||
|
||||
'mp4': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#607d8b"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>',
|
||||
'avi': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#607d8b"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>',
|
||||
'mov': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#607d8b"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>',
|
||||
|
||||
'mp3': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ff9800"><path d="M12 3v9.28c-.47-.17-.97-.28-1.5-.28C8.01 12 6 14.01 6 16.5S8.01 21 10.5 21s4.5-2.01 4.5-4.5V7h4V3h-7z"/></svg>',
|
||||
'wav': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ff9800"><path d="M12 3v9.28c-.47-.17-.97-.28-1.5-.28C8.01 12 6 14.01 6 16.5S8.01 21 10.5 21s4.5-2.01 4.5-4.5V7h4V3h-7z"/></svg>',
|
||||
|
||||
'zip': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#795548"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 2 2h12c1.11 0 2-.9 2-2V8l-6-6zm-1 7V3.5L18.5 9H13z"/></svg>',
|
||||
'rar': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#795548"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 2 2h12c1.11 0 2-.9 2-2V8l-6-6zm-1 7V3.5L18.5 9H13z"/></svg>',
|
||||
|
||||
'txt': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#757575"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 2 2h12c1.11 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/></svg>',
|
||||
|
||||
'js': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#f7df1e"><rect width="24" height="24" fill="#323330"/><path d="M12 12v8h2c2 0 3-1 3-3s-1-3-3-3h-2zm-2 0h-2v8h2v-3c0-1 1-2 2-2s2 1 2 2v3h2v-3c0-2-1-4-4-4s-4 2-4 4z" fill="#f7df1e"/></svg>',
|
||||
|
||||
'exe': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#424242"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 2 2h12c1.11 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/></svg>',
|
||||
|
||||
'_': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#9e9e9e"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 2 2h12c1.11 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/></svg>'
|
||||
};
|
||||
|
||||
return iconMap[this.getFileExt(filename)] || iconMap['_'];
|
||||
},
|
||||
|
||||
getFileExt(value) {
|
||||
return `${value}`.split('.').pop()?.toLowerCase();
|
||||
},
|
||||
|
||||
formatBytes(bytes) {
|
||||
if (!bytes || bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
},
|
||||
|
||||
formatTime(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleString(this.lang?.locale || "zh-CN", {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
},
|
||||
|
||||
showToast(message, type = 'success') {
|
||||
if (this.toast.timer) {
|
||||
clearTimeout(this.toast.timer);
|
||||
this.toast.timer = null;
|
||||
}
|
||||
|
||||
if (this.toast.show) {
|
||||
this.toast.show = false;
|
||||
setTimeout(() => {
|
||||
this.displayToast(message, type);
|
||||
}, 100);
|
||||
} else {
|
||||
this.displayToast(message, type);
|
||||
}
|
||||
},
|
||||
|
||||
errorToast(message) {
|
||||
this.showToast(message, 'error');
|
||||
},
|
||||
|
||||
displayToast(message, type) {
|
||||
this.toast.message = message;
|
||||
this.toast.type = type;
|
||||
this.toast.show = true;
|
||||
|
||||
this.toast.timer = setTimeout(() => {
|
||||
this.toast.show = false;
|
||||
this.toast.timer = null;
|
||||
}, 4000);
|
||||
}
|
||||
}
|
||||
}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
623
electron/render/download/style.css
vendored
Normal file
623
electron/render/download/style.css
vendored
Normal file
@@ -0,0 +1,623 @@
|
||||
/* 下载管理器样式 - Chrome 风格 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-bg: #fff;
|
||||
--color-text: #202124;
|
||||
|
||||
--color-toolbar-bg: #fff;
|
||||
--color-toolbar-border: #dadce0;
|
||||
|
||||
--color-input-bg: #efefef;
|
||||
--color-input-text: #202124;
|
||||
--color-input-placeholder: #5f6368;
|
||||
--color-input-focus-border: #1a73e8;
|
||||
--color-input-focus-ring: rgba(26, 115, 232, .2);
|
||||
|
||||
--color-action-btn-bg: #fff;
|
||||
--color-action-btn-border: #dadce0;
|
||||
--color-action-btn-text: #1a73e8;
|
||||
--color-action-btn-hover-bg: #f8f9fa;
|
||||
--color-action-btn-hover-border: #c8c9ca;
|
||||
--color-action-btn-danger-text: #d93025;
|
||||
--color-action-btn-danger-hover-bg: #fce8e6;
|
||||
|
||||
--color-icon-btn: #5f6368;
|
||||
--color-icon-btn-hover-bg: #f8f9fa;
|
||||
--color-icon-btn-hover-color: #202124;
|
||||
--color-icon-btn-danger: #d93025;
|
||||
--color-icon-btn-danger-hover-bg: #fce8e6;
|
||||
--color-icon-btn-danger-hover-color: #d93025;
|
||||
|
||||
--color-content-bg: #fff;
|
||||
--color-task-item-bg: #fff;
|
||||
--color-task-item-border: #e8eaed;
|
||||
--color-task-item-hover-bg: #f8f9fa;
|
||||
--color-task-name: #202124;
|
||||
--color-task-name-clickable: #1a73e8;
|
||||
|
||||
--color-progress-bar: #e8eaed;
|
||||
--color-progress-fill: #1a73e8;
|
||||
--color-progress-text: #5f6368;
|
||||
--color-task-meta: #5f6368;
|
||||
--color-speed: #1a73e8;
|
||||
|
||||
--color-empty-state: #5f6368;
|
||||
--color-empty-text: #5f6368;
|
||||
--color-state-completed-bg: #e8f5e8;
|
||||
--color-state-completed-text: #137333;
|
||||
--color-state-failed-bg: #fce8e6;
|
||||
--color-state-failed-text: #d93025;
|
||||
--color-state-cancelled-bg: #e8eaed;
|
||||
--color-state-cancelled-text: #5f6368;
|
||||
--color-state-paused-bg: #fff3e0;
|
||||
--color-state-paused-text: #f57c00;
|
||||
|
||||
--scrollbar-thumb: rgba(0, 0, 0, 0.2);
|
||||
--scrollbar-thumb-hover: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-bg: #202124;
|
||||
--color-text: #e8eaed;
|
||||
|
||||
--color-toolbar-bg: #2d2e30;
|
||||
--color-toolbar-border: #3c4043;
|
||||
|
||||
--color-input-bg: #282828;
|
||||
--color-input-text: #e8eaed;
|
||||
--color-input-placeholder: #9aa0a6;
|
||||
--color-input-focus-border: #8ab4f8;
|
||||
--color-input-focus-ring: rgba(138, 180, 248, .12);
|
||||
|
||||
--color-action-btn-bg: #2d2e30;
|
||||
--color-action-btn-border: #5f6368;
|
||||
--color-action-btn-text: #8ab4f8;
|
||||
--color-action-btn-hover-bg: #35363a;
|
||||
--color-action-btn-hover-border: #70757a;
|
||||
--color-action-btn-danger-text: #f28b82;
|
||||
--color-action-btn-danger-hover-bg: #35363a;
|
||||
|
||||
--color-icon-btn: #9aa0a6;
|
||||
--color-icon-btn-hover-bg: #35363a;
|
||||
--color-icon-btn-hover-color: #e8eaed;
|
||||
--color-icon-btn-danger: #f28b82;
|
||||
--color-icon-btn-danger-hover-bg: #3d1a1a;
|
||||
--color-icon-btn-danger-hover-color: #f28b82;
|
||||
|
||||
--color-content-bg: #2d2e30;
|
||||
--color-task-item-bg: #2d2e30;
|
||||
--color-task-item-border: #3c4043;
|
||||
--color-task-item-hover-bg: #35363a;
|
||||
--color-task-name: #e8eaed;
|
||||
--color-task-name-clickable: #8ab4f8;
|
||||
|
||||
--color-progress-bar: #3c4043;
|
||||
--color-progress-fill: #8ab4f8;
|
||||
--color-progress-text: #9aa0a6;
|
||||
--color-task-meta: #9aa0a6;
|
||||
--color-speed: #8ab4f8;
|
||||
|
||||
--color-empty-state: #9aa0a6;
|
||||
--color-empty-text: #9aa0a6;
|
||||
--color-state-completed-bg: #1e3a1e;
|
||||
--color-state-completed-text: #81c995;
|
||||
--color-state-failed-bg: #3d1a1a;
|
||||
--color-state-failed-text: #f28b82;
|
||||
--color-state-cancelled-bg: #3c4043;
|
||||
--color-state-cancelled-text: #9aa0a6;
|
||||
--color-state-paused-bg: #522f2f;
|
||||
--color-state-paused-text: #f57c00;
|
||||
|
||||
--scrollbar-thumb: rgba(255, 255, 255, 0.2);
|
||||
--scrollbar-thumb-hover: rgba(255, 255, 255, 0.3);
|
||||
|
||||
body {
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.download-manager {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* 工具栏 */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
padding: 0 24px;
|
||||
background: var(--color-toolbar-bg);
|
||||
border-bottom: 1px solid var(--color-toolbar-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search {
|
||||
flex: 1;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
height: 32px;
|
||||
padding: 0 12px 0 32px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
margin: 1px 0;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
color: var(--color-input-text);
|
||||
background: var(--color-input-bg) url('data:image/svg+xml;utf8,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"%235f6368\"><path d=\"M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z\"/></svg>') no-repeat 8px center;
|
||||
background-size: 18px 18px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--color-input-placeholder);
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: var(--color-input-focus-border);
|
||||
box-shadow: 0 0 0 2px var(--color-input-focus-ring);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-action-btn-border);
|
||||
background: var(--color-action-btn-bg);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--color-action-btn-text);
|
||||
transition: all 0.15s;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-btn:hover:not(:disabled) {
|
||||
background: var(--color-action-btn-hover-bg);
|
||||
border-color: var(--color-action-btn-hover-border);
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.38;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.action-btn.small {
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.action-btn.danger {
|
||||
color: var(--color-action-btn-danger-text);
|
||||
}
|
||||
|
||||
.action-btn.danger:hover:not(:disabled) {
|
||||
background: var(--color-action-btn-danger-hover-bg);
|
||||
}
|
||||
|
||||
/* 图标按钮样式 */
|
||||
.icon-btn {
|
||||
padding: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
color: var(--color-icon-btn);
|
||||
transition: all 0.15s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--color-icon-btn-hover-bg);
|
||||
color: var(--color-icon-btn-hover-color);
|
||||
}
|
||||
|
||||
.icon-btn.danger {
|
||||
color: var(--color-icon-btn-danger);
|
||||
}
|
||||
|
||||
.icon-btn.danger:hover {
|
||||
background: var(--color-icon-btn-danger-hover-bg);
|
||||
color: var(--color-icon-btn-danger-hover-color);
|
||||
}
|
||||
|
||||
/* 内容区 */
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
background: var(--color-content-bg);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 48px 24px;
|
||||
color: var(--color-empty-state);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
margin-bottom: 20px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: var(--color-empty-text);
|
||||
}
|
||||
|
||||
/* 骨架屏样式 */
|
||||
.skeleton-item {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.skeleton-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* 骨架元素基础动画 */
|
||||
@keyframes skeleton-shimmer {
|
||||
0% {
|
||||
background-position: -200px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: calc(200px + 100%) 0;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton-file-icon,
|
||||
.skeleton-name,
|
||||
.skeleton-size,
|
||||
.skeleton-time,
|
||||
.skeleton-status,
|
||||
.skeleton-btn {
|
||||
background: linear-gradient(90deg, var(--color-task-item-border) 25%, var(--color-task-item-hover-bg) 50%, var(--color-task-item-border) 75%);
|
||||
background-size: 200px 100%;
|
||||
animation: skeleton-shimmer 1.5s infinite linear;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 骨架文件图标 */
|
||||
.skeleton-file-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin: 0 4px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 骨架文件名 */
|
||||
.skeleton-name {
|
||||
height: 13px;
|
||||
width: 60%;
|
||||
margin: 4px 0 6px;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
/* 骨架元信息 */
|
||||
.skeleton-size {
|
||||
height: 12px;
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.skeleton-time {
|
||||
height: 12px;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.skeleton-status {
|
||||
height: 12px;
|
||||
width: 60px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* 骨架操作按钮 */
|
||||
.skeleton-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* 任务列表 */
|
||||
.task-list {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 72px;
|
||||
padding: 0 24px;
|
||||
background: var(--color-task-item-bg);
|
||||
border-bottom: 1px solid var(--color-task-item-border);
|
||||
transition: background-color 0.15s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-item > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.task-item:hover {
|
||||
background: var(--color-task-item-hover-bg);
|
||||
}
|
||||
|
||||
.task-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* 整块背景进度条(下载中样式) */
|
||||
.task-item.progressing-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: var(--progress, 0%);
|
||||
background: var(--color-progress-fill);
|
||||
opacity: 0.12;
|
||||
pointer-events: none;
|
||||
transition: width 0.3s ease;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.task-icon {
|
||||
margin-right: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.file-icon svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.task-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-name {
|
||||
min-width: 0;
|
||||
width: auto;
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
color: var(--color-task-name);
|
||||
}
|
||||
|
||||
.task-name-clickable {
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
color: var(--color-task-name-clickable);
|
||||
text-decoration: none;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.task-name-clickable:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.task-progress {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: var(--color-progress-bar);
|
||||
border-radius: 2px;
|
||||
margin-bottom: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--color-progress-fill);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 11px;
|
||||
color: var(--color-progress-text);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.speed {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-speed);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 20px;
|
||||
font-size: 12px;
|
||||
color: var(--color-task-meta);
|
||||
}
|
||||
|
||||
.task-meta > span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.state {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.state.completed {
|
||||
background: var(--color-state-completed-bg);
|
||||
color: var(--color-state-completed-text);
|
||||
}
|
||||
|
||||
.state.cancelled {
|
||||
background: var(--color-state-cancelled-bg);
|
||||
color: var(--color-state-cancelled-text);
|
||||
}
|
||||
|
||||
.state.interrupted {
|
||||
background: var(--color-state-failed-bg);
|
||||
color: var(--color-state-failed-text);
|
||||
}
|
||||
|
||||
.state.paused {
|
||||
background: var(--color-state-paused-bg);
|
||||
color: var(--color-state-paused-text);
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-left: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
/* 平台特定样式 */
|
||||
body.darwin {
|
||||
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
body.win32 {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.tab-content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.tab-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tab-content::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tab-content::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
/* 深色模式通过 .dark 类启用变量,不再依赖系统配色偏好 */
|
||||
|
||||
/* Toast 提示框样式 */
|
||||
.toast {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 20px;
|
||||
z-index: 1000;
|
||||
animation: toast-slide-up 0.3s ease-out;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toast-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: #323232;
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
font-size: 13px;
|
||||
min-width: 200px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.toast.success .toast-content {
|
||||
background: #4caf50;
|
||||
}
|
||||
|
||||
.toast.error .toast-content {
|
||||
background: #f44336;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
flex: 1;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@keyframes toast-slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
95
electron/render/tabs/assets/css/style.css
vendored
95
electron/render/tabs/assets/css/style.css
vendored
@@ -2,7 +2,7 @@
|
||||
--tab-font-family: -apple-system, 'Segoe UI', roboto, oxygen-sans, ubuntu, cantarell, 'Helvetica Neue', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
|
||||
--tab-font-size: 12px;
|
||||
--tab-transition: background-color 200ms ease-out, color 200ms ease-out;
|
||||
--tab-cursor: pointer; /* 设置鼠标指针为手型 */
|
||||
--tab-cursor: pointer;
|
||||
--tab-color: #7f8792;
|
||||
--tab-background: #EFF0F4;
|
||||
--tab-active-color: #222529;
|
||||
@@ -15,7 +15,8 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Roboto', sans-serif;
|
||||
@@ -39,8 +40,43 @@ html, body {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.nav ul {
|
||||
/* 导航按钮 */
|
||||
.nav-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 12px;
|
||||
-webkit-app-region: none;
|
||||
}
|
||||
|
||||
.nav-controls div {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-controls svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--tab-active-color);
|
||||
}
|
||||
|
||||
.nav-controls .disabled {
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
.nav-controls .disabled svg {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* 标签 */
|
||||
.nav-tabs {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
height: 35px;
|
||||
margin-top: 5px;
|
||||
user-select: none;
|
||||
@@ -48,18 +84,17 @@ html, body {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.nav ul::-webkit-scrollbar {
|
||||
.nav-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav ul li {
|
||||
.nav-tabs li {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
height: calc(100% - 5px);
|
||||
padding: 7px 8px;
|
||||
margin: 0 8px 0 0;
|
||||
min-width: 100px;
|
||||
max-width: 240px;
|
||||
scroll-margin: 12px;
|
||||
@@ -70,24 +105,23 @@ html, body {
|
||||
-webkit-app-region: none;
|
||||
}
|
||||
|
||||
.nav ul li:first-child {
|
||||
margin-left: 8px;
|
||||
.nav-tabs li:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
|
||||
.nav ul li.active {
|
||||
.nav-tabs li.active {
|
||||
color: var(--tab-active-color);
|
||||
background: var(--tab-active-background);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.nav ul li.active .tab-icon.background {
|
||||
.nav-tabs li.active .tab-icon.background {
|
||||
background-image: url(../image/link_normal_selected_icon.png);
|
||||
}
|
||||
|
||||
|
||||
.nav ul li:not(.active)::after {
|
||||
.nav-tabs li:not(.active)::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
width: 1px;
|
||||
@@ -96,22 +130,24 @@ html, body {
|
||||
content: '';
|
||||
}
|
||||
|
||||
.nav ul li:not(.active):last-child::after {
|
||||
.nav-tabs li:not(.active):last-child::after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
/* 浏览器打开 */
|
||||
.browser {
|
||||
.nav-browser {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
padding: 0 14px;
|
||||
margin: 0 2px;
|
||||
cursor: pointer;
|
||||
background-color: var(--tab-background);
|
||||
-webkit-app-region: none;
|
||||
}
|
||||
.browser span {
|
||||
|
||||
.nav-browser span {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
@@ -123,8 +159,8 @@ html, body {
|
||||
.tab-icon {
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
@@ -161,6 +197,7 @@ html, body {
|
||||
0% {
|
||||
transform: scale(0.8) rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(0.8) rotate(360deg);
|
||||
}
|
||||
@@ -170,8 +207,7 @@ html, body {
|
||||
.tab-title {
|
||||
display: inline-block;
|
||||
flex: 1;
|
||||
margin-right: 8px;
|
||||
margin-left: 6px;
|
||||
margin: 0 8px;
|
||||
overflow: hidden;
|
||||
line-height: 150%;
|
||||
text-overflow: ellipsis;
|
||||
@@ -205,18 +241,17 @@ html, body {
|
||||
}
|
||||
|
||||
/* 不同平台样式 */
|
||||
body.win32 .nav ul {
|
||||
margin-left: 8px;
|
||||
margin-right: 186px;
|
||||
body.win32 .nav {
|
||||
padding-left: 8px;
|
||||
padding-right: 140px;
|
||||
}
|
||||
body.win32 .browser {
|
||||
right: 140px;
|
||||
|
||||
body.darwin .nav {
|
||||
padding-left: 76px;
|
||||
}
|
||||
body.darwin .nav ul {
|
||||
margin-left: 76px;
|
||||
}
|
||||
body.darwin.full-screen .nav ul {
|
||||
margin-left: 8px;
|
||||
|
||||
body.darwin.full-screen .nav {
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
/* 暗黑模式 */
|
||||
@@ -229,11 +264,11 @@ body.darwin.full-screen .nav ul {
|
||||
--tab-close-color: #E3E3E3;
|
||||
}
|
||||
|
||||
.nav ul li.active .tab-icon.background {
|
||||
.nav-tabs li.active .tab-icon.background {
|
||||
background-image: url(../image/dark/link_normal_selected_icon.png);
|
||||
}
|
||||
|
||||
.browser span {
|
||||
.nav-browser span {
|
||||
background-image: url(../image/dark/link_normal_selected_icon.png);
|
||||
}
|
||||
|
||||
|
||||
157
electron/render/tabs/error.html
Normal file
157
electron/render/tabs/error.html
Normal file
@@ -0,0 +1,157 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>LOAD FAILED</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--fg: #1f2328;
|
||||
--muted: #6a737d;
|
||||
--border: #e1e4e8;
|
||||
--btn: #84c56a;
|
||||
--btn-fg: #ffffff;
|
||||
--btn-outline: #d0e2ff;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0D0D0D;
|
||||
--fg: #e6edf3;
|
||||
--muted: #9aa7b2;
|
||||
--border: #30363d;
|
||||
--btn: #84c56a;
|
||||
--btn-fg: #ffffff;
|
||||
--btn-outline: #84c56a44;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: 14px/1.5 -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: min(680px, calc(100% - 32px));
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
code {
|
||||
background: var(--btn-outline);
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
margin-bottom: 8px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.url {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
button {
|
||||
appearance: none;
|
||||
border: 1px solid var(--btn);
|
||||
background: var(--btn);
|
||||
color: var(--btn-fg);
|
||||
padding: 8px 24px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
border-color: var(--border);
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
function qs(key) {
|
||||
return new URLSearchParams(location.search).get(key) || ''
|
||||
}
|
||||
|
||||
function setText(id, text) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.textContent = text
|
||||
}
|
||||
}
|
||||
|
||||
function retry() {
|
||||
var u = qs('url');
|
||||
if (u) {
|
||||
location.href = u
|
||||
}
|
||||
}
|
||||
|
||||
function closeTab() {
|
||||
window.close()
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
setText('url', qs('url'))
|
||||
setText('code', qs('code'))
|
||||
setText('desc', qs('desc'))
|
||||
})
|
||||
</script>
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline';">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<meta name="robots" content="noindex">
|
||||
<meta name="format-detection" content="telephone=no,email=no,address=no">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="theme-color" content="#00000000">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Load Error">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>LOAD FAILED</h1>
|
||||
<div class="row">URL: <span id="url" class="url"></span></div>
|
||||
<div class="row">Error code: <code id="code"></code></div>
|
||||
<p id="desc"></p>
|
||||
<div class="actions">
|
||||
<button onclick="retry()">Retry</button>
|
||||
<button class="secondary" onclick="closeTab()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -9,7 +9,19 @@
|
||||
<body>
|
||||
<div id="app" class="app">
|
||||
<div class="nav">
|
||||
<ul>
|
||||
<div class="nav-controls">
|
||||
<div class="nav-back" :class="{disabled: !canGoBack}" @click="goBack">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>
|
||||
</div>
|
||||
<div class="nav-forward" :class="{disabled: !canGoForward}" @click="goForward">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>
|
||||
</div>
|
||||
<div class="nav-refresh" @click="loadingState ? stop() : refresh()">
|
||||
<svg v-if="loadingState" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" style="transform:scale(0.99)" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="nav-tabs">
|
||||
<li v-for="item in tabs" :data-id="item.id" :class="{active: activeId === item.id}" @click="onSwitch(item)">
|
||||
<div v-if="item.state === 'loading'" class="tab-icon loading">
|
||||
<div class="tab-icon-loading"></div>
|
||||
@@ -19,9 +31,9 @@
|
||||
<div class="tab-close" @click.stop="onClose(item)"></div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="canBrowser" class="browser" @click="onBrowser">
|
||||
<span></span>
|
||||
<div v-if="canBrowser" class="nav-browser" @click="onBrowser">
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,10 +41,20 @@
|
||||
const App = {
|
||||
data() {
|
||||
return {
|
||||
// 当前激活的标签页ID
|
||||
activeId: 0,
|
||||
|
||||
// 标签页列表
|
||||
tabs: [],
|
||||
|
||||
// 停止定时器
|
||||
stopTimer: null,
|
||||
|
||||
// 是否可以后退
|
||||
canGoBack: false,
|
||||
|
||||
// 是否可以前进
|
||||
canGoForward: false,
|
||||
}
|
||||
},
|
||||
beforeCreate() {
|
||||
@@ -42,6 +64,7 @@
|
||||
window.__onDispatchEvent = (detail) => {
|
||||
const {id, event} = detail
|
||||
switch (event) {
|
||||
// 创建标签页
|
||||
case 'create':
|
||||
this.tabs.push(Object.assign({
|
||||
id,
|
||||
@@ -52,6 +75,7 @@
|
||||
}, detail))
|
||||
break
|
||||
|
||||
// 关闭标签页
|
||||
case 'close':
|
||||
const closeIndex = this.tabs.findIndex(item => item.id === id)
|
||||
if (closeIndex > -1) {
|
||||
@@ -59,11 +83,14 @@
|
||||
}
|
||||
break
|
||||
|
||||
// 切换标签页
|
||||
case 'switch':
|
||||
this.activeId = id
|
||||
this.scrollTabActive()
|
||||
this.updateNavigationState()
|
||||
break
|
||||
|
||||
// 页面标题
|
||||
case 'title':
|
||||
if (["HitoseaTask", "DooTask", "about:blank"].includes(detail.title)) {
|
||||
return
|
||||
@@ -75,6 +102,7 @@
|
||||
}
|
||||
break
|
||||
|
||||
// 页面图标
|
||||
case 'favicon':
|
||||
const faviconItem = this.tabs.find(item => item.id === id)
|
||||
if (faviconItem) {
|
||||
@@ -88,6 +116,7 @@
|
||||
}
|
||||
break
|
||||
|
||||
// 开始加载
|
||||
case 'start-loading':
|
||||
const startItem = this.tabs.find(item => item.id === id)
|
||||
if (startItem) {
|
||||
@@ -96,19 +125,33 @@
|
||||
}
|
||||
break
|
||||
|
||||
// 停止加载
|
||||
case 'stop-loading':
|
||||
this.stopTimer = setTimeout(_ => {
|
||||
const stopItem = this.tabs.find(item => item.id === id)
|
||||
if (stopItem) {
|
||||
stopItem.state = 'loaded'
|
||||
}
|
||||
if (id === this.activeId) {
|
||||
this.updateNavigationState()
|
||||
}
|
||||
}, 300)
|
||||
break
|
||||
|
||||
// 导航状态
|
||||
case 'navigation-state':
|
||||
if (id === this.activeId) {
|
||||
this.canGoBack = detail.canGoBack
|
||||
this.canGoForward = detail.canGoForward
|
||||
}
|
||||
break
|
||||
|
||||
// 进入全屏
|
||||
case 'enter-full-screen':
|
||||
document.body.classList.add('full-screen')
|
||||
break
|
||||
|
||||
// 离开全屏
|
||||
case 'leave-full-screen':
|
||||
document.body.classList.remove('full-screen')
|
||||
break
|
||||
@@ -119,41 +162,85 @@
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
/**
|
||||
* 获取当前激活的标签页
|
||||
* @returns {object|null}
|
||||
*/
|
||||
activeItem() {
|
||||
if (this.tabs.length === 0) {
|
||||
return null
|
||||
}
|
||||
return this.tabs.find(item => item.id === this.activeId)
|
||||
},
|
||||
/**
|
||||
* 获取页面标题
|
||||
* @returns {string}
|
||||
*/
|
||||
pageTitle() {
|
||||
return this.activeItem ? this.activeItem.title : 'Untitled'
|
||||
},
|
||||
/**
|
||||
* 是否可以打开浏览器
|
||||
* @returns {boolean}
|
||||
*/
|
||||
canBrowser() {
|
||||
return !(this.activeItem && this.isLocalHost(this.activeItem.url))
|
||||
},
|
||||
/**
|
||||
* 获取加载状态
|
||||
* @returns {boolean}
|
||||
*/
|
||||
loadingState() {
|
||||
return this.activeItem ? this.activeItem.state === 'loading' : false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
/**
|
||||
* 监听页面标题
|
||||
* @param title
|
||||
*/
|
||||
pageTitle(title) {
|
||||
document.title = title;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* 切换标签页
|
||||
* @param item
|
||||
*/
|
||||
onSwitch(item) {
|
||||
this.sendMessage('webTabActivate', item.id)
|
||||
},
|
||||
|
||||
/**
|
||||
* 关闭标签页
|
||||
* @param item
|
||||
*/
|
||||
onClose(item) {
|
||||
this.sendMessage('webTabClose', item.id);
|
||||
},
|
||||
|
||||
/**
|
||||
* 打开浏览器
|
||||
*/
|
||||
onBrowser() {
|
||||
this.sendMessage('webTabExternal')
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取标签页图标样式
|
||||
* @param item
|
||||
* @returns {string}
|
||||
*/
|
||||
iconStyle(item) {
|
||||
return item.icon ? `background-image: url(${item.icon})` : ''
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取标签页标题
|
||||
* @param item
|
||||
* @returns {string}
|
||||
*/
|
||||
tabTitle(item) {
|
||||
if (item.title) {
|
||||
return item.title
|
||||
@@ -162,14 +249,20 @@
|
||||
return 'Loading...'
|
||||
}
|
||||
if (item.url) {
|
||||
if (/localhost:/.test(item.url)) {
|
||||
return 'Loading...'
|
||||
}
|
||||
return `${item.url}`.replace(/^https?:\/\//, '')
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 滚动到当前激活的标签页
|
||||
*/
|
||||
scrollTabActive() {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const child = document.querySelector(`.nav ul li[data-id=${this.activeId}]`)
|
||||
const child = document.querySelector(`.nav-tabs li[data-id="${this.activeId}"]`)
|
||||
if (child) {
|
||||
child.scrollIntoView({behavior: 'smooth', block: 'nearest'})
|
||||
}
|
||||
@@ -179,10 +272,52 @@
|
||||
}, 0)
|
||||
},
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
* @param event
|
||||
* @param args
|
||||
*/
|
||||
sendMessage(event, args) {
|
||||
electron?.sendMessage(event, args)
|
||||
},
|
||||
|
||||
/**
|
||||
* 后退
|
||||
*/
|
||||
goBack() {
|
||||
if (!this.canGoBack) return
|
||||
this.sendMessage('webTabGoBack')
|
||||
},
|
||||
|
||||
/**
|
||||
* 前进
|
||||
*/
|
||||
goForward() {
|
||||
if (!this.canGoForward) return
|
||||
this.sendMessage('webTabGoForward')
|
||||
},
|
||||
|
||||
/**
|
||||
* 停止
|
||||
*/
|
||||
stop() {
|
||||
this.sendMessage('webTabStop')
|
||||
},
|
||||
|
||||
/**
|
||||
* 刷新
|
||||
*/
|
||||
refresh() {
|
||||
this.sendMessage('webTabReload')
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新导航状态
|
||||
*/
|
||||
updateNavigationState() {
|
||||
this.sendMessage('webTabGetNavigationState')
|
||||
},
|
||||
|
||||
/**
|
||||
* 判断是否是本地URL
|
||||
* @param url
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<script>window.location.href=window.location.href.replace(/:\d+/, ':' + 2222)</script>
|
||||
37
jsconfig.json
Normal file
37
jsconfig.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~element-sea/*": ["node_modules/element-sea/*"],
|
||||
"~quill-hi/*": ["node_modules/quill-hi/*"],
|
||||
"~quill-mention-hi/*": ["node_modules/quill-mention-hi/*"]
|
||||
},
|
||||
"moduleResolution": "node",
|
||||
"module": "ESNext",
|
||||
"target": "ES2019",
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"resources/assets/js/**/*",
|
||||
"resources/assets/**/*.vue",
|
||||
"resources/assets/sass/**/*",
|
||||
"types/**/*.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"vendor",
|
||||
"storage",
|
||||
"public",
|
||||
"tests",
|
||||
"docker",
|
||||
"language",
|
||||
"database",
|
||||
"bin"
|
||||
]
|
||||
}
|
||||
29
language/README.md
Normal file
29
language/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# 语言翻译工具说明
|
||||
|
||||
`language/translate.php` 脚本用于根据 `original-web.txt` 和 `original-api.txt` 中的内容,自动生成/更新 `translate.json` 以及前端使用的多语言文件。
|
||||
|
||||
## 使用步骤
|
||||
|
||||
1. 在项目根目录 `.env` 文件中配置:
|
||||
|
||||
```dotenv
|
||||
OPENAI_API_KEY=你的OpenAI密钥
|
||||
OPENAI_PROXY_URL=可选的代理地址
|
||||
```
|
||||
|
||||
2. 在 `language` 目录下执行:
|
||||
|
||||
```bash
|
||||
php translate.php
|
||||
```
|
||||
|
||||
3. 查看生成的翻译结果:
|
||||
|
||||
- 翻译详情:`language/translate.json`
|
||||
- API 文件:`public/language/api/*.json`
|
||||
- Web 文件:`public/language/web/*.js`
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 若 `.env` 未设置 `OPENAI_API_KEY`,脚本会直接退出。
|
||||
- `OPENAI_PROXY_URL` 可选,留空时不会设置代理。
|
||||
@@ -87,6 +87,13 @@
|
||||
没有任何数据
|
||||
导出失败,(*)!
|
||||
打包失败,请稍后再试...
|
||||
正在导出任务统计,请稍等...
|
||||
导出超期任务已完成
|
||||
正在导出超期任务,请稍等...
|
||||
导出审批数据已完成
|
||||
正在导出审批数据,请稍等...
|
||||
导出签到数据已完成
|
||||
正在导出签到数据,请稍等...
|
||||
任务列表不存在或已被删除
|
||||
主任务已完成无法添加子任务
|
||||
子任务不支持此功能
|
||||
@@ -396,16 +403,11 @@ LICENSE 无效
|
||||
数据库连接失败
|
||||
文件名称不能包含这些字符:(*)
|
||||
新帐号
|
||||
IT资讯
|
||||
36氪
|
||||
60s读世界
|
||||
我的机器人
|
||||
API接口文档
|
||||
帮助指令
|
||||
使用说明
|
||||
隐私说明
|
||||
开心笑话
|
||||
心灵鸡汤
|
||||
请填写基本配置
|
||||
终端SN与License不匹配
|
||||
终端MAC与License不匹配
|
||||
@@ -444,7 +446,7 @@ API接口文档
|
||||
会议已结束
|
||||
请选择举报类型
|
||||
请填写举报原因
|
||||
开启语音转文字功能需要在应用中开启 ChatGPT AI 机器人。
|
||||
开启语音转文字功能需要先设置 AI 助理。
|
||||
语音转文字功能未开启
|
||||
语音文件不存在
|
||||
语音转文字失败
|
||||
@@ -474,6 +476,7 @@ OKR提醒
|
||||
机器人不存在。
|
||||
超过最大创建数量。
|
||||
机器人名称由2-20个字符组成。
|
||||
机器人类型由6-20个字符组成。
|
||||
创建失败。
|
||||
创建成功。
|
||||
webhook地址最长仅支持255个字符。
|
||||
@@ -612,7 +615,7 @@ webhook地址最长仅支持255个字符。
|
||||
消息不存在或已被删除
|
||||
此消息不支持翻译
|
||||
消息内容为空
|
||||
开启翻译功能需要在应用中开启 ChatGPT AI 机器人。
|
||||
开启翻译功能需要先设置 AI 助理。
|
||||
翻译功能未开启
|
||||
翻译失败
|
||||
|
||||
@@ -725,6 +728,8 @@ webhook地址最长仅支持255个字符。
|
||||
位置名称不能为空
|
||||
位置类型错误
|
||||
请填写百度地图AK
|
||||
请填写高德地图Key
|
||||
请填写腾讯地图Key
|
||||
请选择允许签到位置
|
||||
请选择有效的签到位置
|
||||
暂未开放定位签到。
|
||||
@@ -795,7 +800,9 @@ webhook地址最长仅支持255个字符。
|
||||
标签不存在或已被删除
|
||||
每个项目最多添加(*)个标签
|
||||
|
||||
添加标签【(*)】
|
||||
添加标签
|
||||
删除标签
|
||||
修改标签
|
||||
删除任务标签
|
||||
新增任务标签
|
||||
更新任务标签
|
||||
@@ -873,4 +880,48 @@ URL格式不正确
|
||||
更新失败:(*)
|
||||
应用列表正在更新中,请稍后再试
|
||||
应用正在下载中,请稍后再试
|
||||
应用「*」未安装
|
||||
应用「(*)」未安装
|
||||
|
||||
没有权限修改标签
|
||||
没有权限删除标签
|
||||
标签已存在
|
||||
|
||||
工作流状态创建失败
|
||||
排序已保存
|
||||
|
||||
同步完成,子部门中没有成员需要同步
|
||||
同步完成,共同步(*)个成员
|
||||
同步完成,共同步(*)个成员,其中(*)个成员已在当前部门
|
||||
|
||||
无效的收藏类型
|
||||
收藏成功
|
||||
取消收藏成功
|
||||
清理(*)收藏成功
|
||||
清理全部收藏成功
|
||||
|
||||
消息需求描述不能为空
|
||||
生成消息失败
|
||||
生成消息成功
|
||||
生成任务失败
|
||||
生成任务成功
|
||||
项目需求描述不能为空
|
||||
生成项目失败
|
||||
生成项目成功
|
||||
清理完成
|
||||
|
||||
项目提示词不能为空
|
||||
消息提示词不能为空
|
||||
|
||||
重命名成功
|
||||
请输入会话名称
|
||||
复制任务
|
||||
调整模板排序
|
||||
调整标签排序
|
||||
收藏记录不存在
|
||||
修改备注成功
|
||||
请输入修改备注
|
||||
备注最多支持(*)个字符
|
||||
|
||||
当前任务已是主任务
|
||||
子任务升级为主任务
|
||||
升级为主任务
|
||||
@@ -1500,7 +1500,7 @@ License Key
|
||||
转文字
|
||||
语音转文字
|
||||
长按语音消息可转换成文字。
|
||||
需要在应用中开启 ChatGPT AI 机器人
|
||||
需要先设置 AI 助理
|
||||
关闭语音转文字功能。
|
||||
你确定要删除文件【(*)】吗?
|
||||
查看附件
|
||||
@@ -1564,18 +1564,14 @@ API接口文档
|
||||
搜索关键词
|
||||
查看会话ID
|
||||
查看接口列表
|
||||
ID | 名称 | 清理时间 | Webhook
|
||||
你可以通过执行以下命令来请求我
|
||||
发送文本消息
|
||||
对话ID
|
||||
消息内容
|
||||
回复指定消息ID
|
||||
Webhook说明
|
||||
机器人收到消息后会将消息POST推送到Webhook地址,请求超时为10秒,请求参数如下
|
||||
消息文本
|
||||
对话类型
|
||||
消息ID
|
||||
消息发送人ID
|
||||
消息发送人信息
|
||||
是否被@到
|
||||
机器人ID
|
||||
系统版本
|
||||
@@ -1583,9 +1579,7 @@ Webhook说明
|
||||
保留消息时间
|
||||
最后一次清理时间
|
||||
Webhook地址
|
||||
Webhook请求次数
|
||||
已加入的会话
|
||||
会话ID | 会话名称
|
||||
Token
|
||||
清理周期
|
||||
下次清理
|
||||
@@ -1639,6 +1633,13 @@ Token
|
||||
导出任务统计已完成
|
||||
没有任何数据
|
||||
打包失败,请稍后再试...
|
||||
正在导出任务统计,请稍等...
|
||||
导出超期任务已完成
|
||||
正在导出超期任务,请稍等...
|
||||
导出审批数据已完成
|
||||
正在导出审批数据,请稍等...
|
||||
导出签到数据已完成
|
||||
正在导出签到数据,请稍等...
|
||||
(*)查看了(*)的联系电话
|
||||
标记任务未完成
|
||||
标记任务已完成
|
||||
@@ -1690,7 +1691,6 @@ WiFi签到延迟时长为±1分钟。
|
||||
群外成员
|
||||
|
||||
该任务尚未被领取,点击这里
|
||||
搜索词 (留空自动生成)
|
||||
考勤机
|
||||
手动签到
|
||||
签到备注
|
||||
@@ -1768,14 +1768,31 @@ WiFi签到延迟时长为±1分钟。
|
||||
通过在签到打卡机器人发送位置签到
|
||||
签到备注
|
||||
百度地图AK
|
||||
腾讯地图Key
|
||||
高德地图Key
|
||||
获取AK流程
|
||||
允许签到位置
|
||||
点击修改
|
||||
经度:(*),纬度:(*),半径:(*)米
|
||||
点击设置
|
||||
签到半径(*)米
|
||||
请点击地图选择签到位置
|
||||
请先填写百度地图AK
|
||||
请先填写高德地图Key
|
||||
请先填写腾讯地图Key
|
||||
请选择地图类型
|
||||
|
||||
签到半径设置
|
||||
半径
|
||||
米
|
||||
经度
|
||||
纬度
|
||||
地图类型
|
||||
百度地图
|
||||
高德地图
|
||||
腾讯地图
|
||||
获取Key流程
|
||||
点击修改允许签到位置
|
||||
点击地图选择中心位置,拖拽圆形边缘调整半径,或在上方输入框直接设置半径值
|
||||
点击地图选择中心位置,在上方输入框中设置签到半径值
|
||||
|
||||
你当前是负责人,确定要转为协助人员吗?
|
||||
仅支持移动端App
|
||||
@@ -1885,6 +1902,7 @@ WiFi签到延迟时长为±1分钟。
|
||||
新建标签
|
||||
当前项目暂无任务标签
|
||||
请输入标签名称
|
||||
请选择标签颜色
|
||||
标签名称
|
||||
标签描述
|
||||
请输入标签描述
|
||||
@@ -2027,7 +2045,6 @@ API请求的URL路径
|
||||
请输入备注原因
|
||||
删除机器人:(*)
|
||||
|
||||
回复/引用消息文本
|
||||
默认:90天
|
||||
机器人名称
|
||||
|
||||
@@ -2073,3 +2090,157 @@ AI开启新会话失败
|
||||
OKR群组
|
||||
|
||||
周期任务的子任务时间将被重置,是否继续?
|
||||
|
||||
加入项目
|
||||
已加入
|
||||
邀请地址不存在或已被删除!
|
||||
|
||||
API 使用说明
|
||||
Webhook 消息推送
|
||||
html 或 md
|
||||
yes 或 no
|
||||
会话名称
|
||||
值
|
||||
结果
|
||||
参数名
|
||||
名称
|
||||
命令
|
||||
回复/引用的消息文本
|
||||
开发者可以通过此接口调用机器人向指定对话发送文本消息。
|
||||
必填
|
||||
接口信息
|
||||
接口地址
|
||||
推送参数
|
||||
搜索词
|
||||
文本类型
|
||||
机器人收到消息后会自动POST推送到配置的Webhook地址,请求超时为10秒。
|
||||
消息文本内容
|
||||
清理时间
|
||||
留空自动生成
|
||||
示例值
|
||||
类型
|
||||
该机器人不支持
|
||||
说明
|
||||
请求参数
|
||||
请求头
|
||||
请求方式
|
||||
通过机器人向指定对话发送文本消息
|
||||
静默模式
|
||||
属性
|
||||
会话ID
|
||||
|
||||
列表视图
|
||||
部门视图
|
||||
部门成员
|
||||
|
||||
AI 助手
|
||||
此功能并非聊天机器人,而是用于辅助工作。比如:语音转文字、聊天翻译等。
|
||||
如果需要聊天机器人,请在「应用」中使用「AI 机器人」插件。
|
||||
AI 提供商
|
||||
支持:OpenAI
|
||||
API 密钥
|
||||
请输入 API 密钥
|
||||
请输入 API 密钥,留空表示不启用 AI 助手
|
||||
API URL
|
||||
请输入 API URL
|
||||
选填,请输入 API URL
|
||||
代理
|
||||
请输入代理
|
||||
选填,支持 http、https、socks5 协议
|
||||
需要先设置 AI 助理
|
||||
|
||||
打开签到机器人
|
||||
下载内容
|
||||
|
||||
退出排序
|
||||
调整排序
|
||||
|
||||
解散
|
||||
你确定要解散【(*)】群组吗?
|
||||
|
||||
允许游客访问此链接
|
||||
警告:任何人都可通过此链接访问文件
|
||||
|
||||
同步部门成员
|
||||
当前部门没有子部门,无需同步
|
||||
你确定要同步部门成员吗?
|
||||
注:此操作会同步子部门成员到当前部门
|
||||
|
||||
我的收藏
|
||||
收藏类型
|
||||
全部类型
|
||||
搜索收藏名称
|
||||
所属项目
|
||||
收藏时间
|
||||
确定要取消收藏"(*)"吗?
|
||||
取消收藏
|
||||
收藏
|
||||
没有相关的收藏
|
||||
取消收藏成功
|
||||
收藏项目
|
||||
操作失败
|
||||
|
||||
最近打开
|
||||
最近访问时间
|
||||
任务文件
|
||||
聊天文件
|
||||
文件库
|
||||
|
||||
AI 生成
|
||||
请简要描述项目目标、范围或关键里程碑,AI 将生成名称和任务列表
|
||||
请输入项目需求
|
||||
请简要描述消息的主题、语气或要点,AI 将生成完整消息
|
||||
请输入消息需求
|
||||
当前未选择会话
|
||||
AI 未生成内容
|
||||
请简要描述任务目标、背景或预期交付,AI 将生成标题、详细说明和子任务
|
||||
请输入任务需求
|
||||
数据导出
|
||||
|
||||
调整模板排序
|
||||
调整标签排序
|
||||
|
||||
请输入会话名称
|
||||
重命名会话
|
||||
完成排序
|
||||
拖拽调整排序
|
||||
排序保存失败
|
||||
复制前
|
||||
复制后
|
||||
复制任务
|
||||
可选,留空则不执行迁移
|
||||
修改备注
|
||||
共同群组
|
||||
暂无共同群组
|
||||
(*)个
|
||||
查看更多...
|
||||
|
||||
子任务升级为主任务
|
||||
升级为主任务
|
||||
升主任务
|
||||
你确定要将子任务【(*)】升级为主任务吗?
|
||||
|
||||
桌面 MCP 服务器
|
||||
启用桌面 MCP 服务器
|
||||
关闭 MCP 服务器
|
||||
MCP 服务器已启动成功!
|
||||
服务地址
|
||||
接入配置
|
||||
以接入 Claude 为例,在配置文件中添加以下配置
|
||||
复制配置
|
||||
使用示例
|
||||
配置生效后,即可通过自然语言使用 MCP 服务
|
||||
查看我未完成的任务
|
||||
搜索包含'报告'的任务
|
||||
在项目1中创建任务:完成用户手册
|
||||
把任务789的截止时间改为下周五
|
||||
我有哪些项目?
|
||||
查看项目5的详情,包括所有列和成员
|
||||
|
||||
我知道了
|
||||
橙色
|
||||
青色
|
||||
深蓝
|
||||
深绿
|
||||
金色
|
||||
湖蓝
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,76 @@ require __DIR__ . '/vendor/autoload.php';
|
||||
|
||||
use Orhanerday\OpenAi\OpenAi;
|
||||
|
||||
require_once("config.php");
|
||||
// 读取 .env 文件的简单工具函数
|
||||
function language_parse_env_file(string $path): array
|
||||
{
|
||||
$env = [];
|
||||
$lines = @file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
if ($lines === false) {
|
||||
return $env;
|
||||
}
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || $line[0] === '#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$delimiterPosition = strpos($line, '=');
|
||||
if ($delimiterPosition === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = trim(substr($line, 0, $delimiterPosition));
|
||||
if (strpos($name, 'export ') === 0) {
|
||||
$name = trim(substr($name, 7));
|
||||
}
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = trim(substr($line, $delimiterPosition + 1));
|
||||
$length = strlen($value);
|
||||
if ($length >= 2) {
|
||||
$first = $value[0];
|
||||
$last = $value[$length - 1];
|
||||
if (($first === '"' && $last === '"') || ($first === "'" && $last === "'")) {
|
||||
$value = substr($value, 1, $length - 2);
|
||||
}
|
||||
}
|
||||
|
||||
$env[$name] = $value;
|
||||
}
|
||||
|
||||
return $env;
|
||||
}
|
||||
|
||||
// 获取环境变量值的简单工具函数
|
||||
function language_env_value(string $key, array $env): ?string
|
||||
{
|
||||
if (array_key_exists($key, $env)) {
|
||||
return $env[$key];
|
||||
}
|
||||
|
||||
$value = getenv($key);
|
||||
if ($value !== false) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 读取语言环境配置
|
||||
$languageEnvFile = dirname(__DIR__) . '/.env';
|
||||
$languageEnv = is_readable($languageEnvFile) ? language_parse_env_file($languageEnvFile) : [];
|
||||
|
||||
// 优先从 .env 读取 OPENAI 配置,未找到时再次尝试 getenv 覆盖
|
||||
$openAiKey = trim(language_env_value('OPENAI_API_KEY', $languageEnv) ?? '');
|
||||
if ($openAiKey === '') {
|
||||
fwrite(STDERR, "OPENAI_API_KEY 未设置,请在项目根目录的 .env 中配置。\n");
|
||||
exit(1);
|
||||
}
|
||||
$openAiProxy = trim(language_env_value('OPENAI_PROXY_URL', $languageEnv) ?? '');
|
||||
|
||||
// 读取所有要翻译的内容
|
||||
$originals = [];
|
||||
@@ -41,7 +109,7 @@ foreach ($tmps as $obj) {
|
||||
$translations[$originalKey] = $obj;
|
||||
|
||||
if (!in_array($originalKey, $originals)) {
|
||||
// 多余的数据
|
||||
unset($translations[$originalKey]);
|
||||
$redundants[$originalKey] = $obj;
|
||||
continue;
|
||||
}
|
||||
@@ -73,8 +141,7 @@ if (count($regrror) > 0) {
|
||||
}
|
||||
if (count($redundants) > 0) {
|
||||
print_r("多余的数据:\n");
|
||||
print_r($redundants);
|
||||
exit();
|
||||
print_r(implode(", ", array_keys($redundants)) . "\n\n");
|
||||
}
|
||||
|
||||
// 需要翻译的数据
|
||||
@@ -102,10 +169,13 @@ if (count($needs) > 0) {
|
||||
|
||||
// 开始翻译
|
||||
print_r("正在翻译:" . (count($keys) + $done) . "/" . count($needs) . "...\n");
|
||||
$openAi = new OpenAi(OPEN_AI_KEY);
|
||||
$openAi->setProxy(OPEN_AI_PROXY);
|
||||
$openAi = new OpenAi($openAiKey);
|
||||
if ($openAiProxy !== '') {
|
||||
$openAi->setProxy($openAiProxy);
|
||||
}
|
||||
$result = $openAi->chat([
|
||||
'model' => 'gpt-4o',
|
||||
"model" => "gpt-5",
|
||||
"reasoning_effort" => "minimal",
|
||||
'messages' => [
|
||||
[
|
||||
"role" => "system",
|
||||
@@ -152,11 +222,7 @@ if (count($needs) > 0) {
|
||||
"role" => "user",
|
||||
"content" => $content,
|
||||
],
|
||||
],
|
||||
'temperature' => 1.0,
|
||||
'max_tokens' => 4000,
|
||||
'frequency_penalty' => 0,
|
||||
'presence_penalty' => 0,
|
||||
]
|
||||
]);
|
||||
|
||||
// 处理结果
|
||||
|
||||
10
package.json
10
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "DooTask",
|
||||
"version": "1.0.45",
|
||||
"codeVerson": 196,
|
||||
"version": "1.3.55",
|
||||
"codeVerson": 215,
|
||||
"description": "DooTask is task management system.",
|
||||
"scripts": {
|
||||
"start": "./cmd dev",
|
||||
@@ -53,7 +53,7 @@
|
||||
"postcss": "^8.4.5",
|
||||
"prismjs": "^1.29.0",
|
||||
"quill-hi": "^2.0.0-rc1",
|
||||
"quill-mention-hi": "^4.0.0-8",
|
||||
"quill-mention-hi": "^4.0.0-10",
|
||||
"resolve-url-loader": "^4.0.0",
|
||||
"sass": "1.77.4",
|
||||
"sass-loader": "14.2.1",
|
||||
@@ -61,7 +61,7 @@
|
||||
"stylus-loader": "^7.1.0",
|
||||
"tinymce": "^5.10.3",
|
||||
"tui-calendar-hi": "^2.1.3-6",
|
||||
"view-design-hi": "^4.7.0-76",
|
||||
"view-design-hi": "^4.7.0-78",
|
||||
"vite": "^2.9.15",
|
||||
"vite-plugin-file-copy": "^1.0.0",
|
||||
"vite-plugin-require": "^1.1.10",
|
||||
@@ -73,7 +73,7 @@
|
||||
"vue-resize-observer": "^2.0.16",
|
||||
"vue-router": "^3.6.5",
|
||||
"vue-template-compiler": "~2.6.14",
|
||||
"vue-virtual-scroll-list-hi": "^2.3.5-17",
|
||||
"vue-virtual-scroll-list-hi": "^2.3.5-18",
|
||||
"vuedraggable": "^2.24.3",
|
||||
"vuex": "^3.6.2"
|
||||
},
|
||||
|
||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user