Compare commits

..

13 Commits

Author SHA1 Message Date
kuaifan
cfa749f4f3 feat: 优化时间范围参数 2025-10-24 23:48:35 +08:00
kuaifan
eeaff08673 feat: 桌面端添加MCP服务 2025-10-24 23:48:18 +08:00
kuaifan
0475e88dc2 feat: 添加任务移动权限检查以增强项目任务管理 2025-10-24 06:35:22 +00:00
kuaifan
e1f73a4639 feat: 为列表项添加最小高度以改善可读性 2025-10-24 05:42:57 +00:00
kuaifan
e2296a6f64 feat: 添加子任务升级为主任务功能 2025-10-24 05:38:54 +00:00
kuaifan
1a6abf4e1b feat: 在安装和更新函数中添加sudo检查 2025-10-24 03:34:22 +00:00
kuaifan
315851eb5f feat: 优化数据库还原功能
- 支持通过编号选择备份文件
2025-10-23 22:55:29 +00:00
kuaifan
0b99b4a9a0 fix: 修复用户选择在输入法预输入时误删已选项 2025-10-23 06:07:24 +00:00
kuaifan
e8235dd0a2 feat: 优化已读消息标记逻辑,提升性能和可读性 2025-10-17 00:41:38 +00:00
kuaifan
123c74de46 feat: 优化开发环境配置 2025-10-16 23:56:48 +00:00
kuaifan
9419ddd174 no message 2025-09-29 09:19:28 +08:00
kuaifan
0666a8f5c2 feat: 优化任务可见性推送逻辑 2025-09-29 09:04:31 +08:00
kuaifan
81c019105c no message 2025-09-28 10:40:48 +08:00
25 changed files with 1279 additions and 480 deletions

View File

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

3
.gitignore vendored
View File

@@ -32,6 +32,9 @@ vars.yaml
Homestead.json
Homestead.yaml
# Development file
/index.html
# Testing
.phpunit.result.cache
test.*

View File

@@ -2196,7 +2196,10 @@ class DialogController extends AbstractController
switch ($type) {
case 'read':
// 标记已读
$builder = WebSocketDialogMsgRead::whereDialogId($dialog_id)->whereUserid($user->userid)->whereReadAt(null);
$builder = WebSocketDialogMsgRead::whereDialogId($dialog_id)
->whereUserid($user->userid)
->whereReadAt(null)
->select(['id', 'msg_id']);
if ($after_msg_id > 0) {
$builder->where('msg_id', '>=', $after_msg_id);
}

View File

@@ -45,6 +45,7 @@ use App\Models\ProjectTaskVisibilityUser;
use App\Models\ProjectTaskTemplate;
use App\Models\ProjectTag;
use App\Models\ProjectTaskRelation;
use App\Observers\ProjectTaskObserver;
/**
* @apiDefine project
@@ -633,6 +634,10 @@ class ProjectController extends AbstractController
if (!is_array($item['task'])) continue;
$index = 0;
foreach ($item['task'] as $task_id) {
$task = ProjectTask::find($task_id);
if ($task && intval($task->column_id) !== intval($item['id'])) {
ProjectPermission::userTaskPermission($project, ProjectPermission::TASK_MOVE, $task);
}
if (ProjectTask::whereId($task_id)->whereProjectId($project->id)->whereCompleteAt(null)->change([
'column_id' => $item['id'],
'sort' => $index
@@ -991,7 +996,12 @@ class ProjectController extends AbstractController
* - 大于0指定主任务下的子任务
* - 等于-1表示仅主任务
*
* @apiParam {Array} [time] 指定时间范围,如:['2020-12-12', '2020-12-30']
* @apiParam {String} [time] 指定时间范围,如:today, week, month, year, 2020-12-12,2020-12-30
* - today: 今天
* - week: 本周
* - month: 本月
* - year: 今年
* - 自定义时间范围,如 (字符串)2020-12-12,2020-12-30 或 (数组)['2020-12-12', '2020-12-30']
* @apiParam {String} [timerange] 时间范围1678248944,1678248944
* - 第一个时间: 读取在这个时间之后更新的数据
* - 第二个时间: 读取在这个时间之后删除的数据ID第1页附加返回数据: deleted_id
@@ -1091,7 +1101,29 @@ class ProjectController extends AbstractController
});
}
//
if (is_array($time)) {
if (is_string($time) && $time) {
switch ($time) {
case 'today':
$builder->betweenTime(Carbon::now()->startOfDay(), Carbon::now()->endOfDay());
break;
case 'week':
$builder->betweenTime(Carbon::now()->startOfWeek(), Carbon::now()->endOfWeek());
break;
case 'month':
$builder->betweenTime(Carbon::now()->startOfMonth(), Carbon::now()->endOfMonth());
break;
case 'year':
$builder->betweenTime(Carbon::now()->startOfYear(), Carbon::now()->endOfYear());
break;
default:
if (str_contains($time, ',')) {
$times = explode(',', $time);
if (Timer::isDateOrTime($times[0]) && Timer::isDateOrTime($times[1])) {
$builder->betweenTime(Carbon::parse($times[0])->startOfDay(), Carbon::parse($times[1])->endOfDay());
}
}
}
} elseif (is_array($time)) {
if (Timer::isDateOrTime($time[0]) && Timer::isDateOrTime($time[1])) {
$builder->betweenTime(Carbon::parse($time[0])->startOfDay(), Carbon::parse($time[1])->endOfDay());
}
@@ -1991,7 +2023,9 @@ class ProjectController extends AbstractController
'path' => $file->getRawOriginal('path'),
'thumb' => $file->getRawOriginal('thumb'),
]);
$task->pushMsg('filedelete', $file);
$task->pushMsg('filedelete', [
'id' => $file->id,
]);
$file->delete();
//
return Base::retSuccess('success', $file);
@@ -2228,6 +2262,131 @@ class ProjectController extends AbstractController
return Base::retSuccess('添加成功', $data);
}
/**
* @api {get} api/project/task/upgrade 36. 子任务升级为主任务
*
* @apiDescription 需要token身份项目、任务负责人
* @apiVersion 1.0.0
* @apiGroup project
* @apiName task__upgrade
*
* @apiParam {Number} task_id 子任务ID
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function task__upgrade()
{
$user = User::auth();
//
$task_id = intval(Request::input('task_id'));
//
$task = ProjectTask::userTask($task_id, true, true, ['taskUser']);
if ($task->parent_id == 0) {
return Base::retError('当前任务已是主任务');
}
//
$project = Project::userProject($task->project_id);
ProjectPermission::userTaskPermission($project, ProjectPermission::TASK_MOVE, $task);
//
$parentTask = ProjectTask::withTrashed()->find($task->parent_id);
$visibilityUserids = [];
if ($task->visibility == 3) {
$visibilityUserids = ProjectTaskVisibilityUser::whereTaskId($task->id)->pluck('userid')->toArray();
if (empty($visibilityUserids) && $parentTask) {
$visibilityUserids = ProjectTaskVisibilityUser::whereTaskId($parentTask->id)->pluck('userid')->toArray();
}
}
//
DB::transaction(function () use ($task, $parentTask, $visibilityUserids) {
$task->lockForUpdate();
$task->parent_id = 0;
if ($parentTask) {
$task->p_level = $parentTask->p_level;
$task->p_name = $parentTask->p_name;
$task->p_color = $parentTask->p_color;
}
$task->save();
ProjectTaskUser::whereTaskId($task->id)->update(['task_pid' => $task->id]);
if ($task->visibility == 3 && !empty($visibilityUserids)) {
ProjectTaskVisibilityUser::whereTaskId($task->id)->delete();
foreach (array_unique($visibilityUserids) as $userid) {
if (!$userid) {
continue;
}
ProjectTaskVisibilityUser::createInstance([
'project_id' => $task->project_id,
'task_id' => $task->id,
'userid' => $userid,
])->save();
}
}
if ($parentTask) {
$parentTask->addLog("子任务升级为主任务", [
'subtask' => [
'id' => $task->id,
'name' => $task->name,
],
]);
}
$task->addLog("升级为主任务");
});
//
$task->refresh()->loadMissing(['project', 'taskUser']);
if ($task->visibility != 1) {
ProjectTaskObserver::visibilityUpdate($task);
}
$taskData = ProjectTask::oneTask($task->id);
$parentData = null;
if ($parentTask && !$parentTask->trashed()) {
$parentTask->refresh()->loadMissing(['project', 'taskUser']);
$parentData = ProjectTask::oneTask($parentTask->id);
}
//
$taskArray = $taskData ? $taskData->toArray() : [];
$parentArray = $parentData ? $parentData->toArray() : null;
if ($taskArray) {
$task->pushMsg('update', $taskArray);
}
if ($parentArray && $parentTask) {
$parentTask->pushMsg('update', $parentArray);
}
if ($parentTask && !$parentTask->trashed()) {
$mentionRelation = ProjectTaskRelation::updateOrCreate(
[
'task_id' => $task->id,
'related_task_id' => $parentTask->id,
'direction' => ProjectTaskRelation::DIRECTION_MENTIONED_BY,
],
[
'userid' => $user->userid ?? null,
]
);
$mentionedByRelation = ProjectTaskRelation::updateOrCreate(
[
'task_id' => $parentTask->id,
'related_task_id' => $task->id,
'direction' => ProjectTaskRelation::DIRECTION_MENTION,
],
[
'userid' => $user->userid ?? null,
]
);
if ($mentionRelation->wasRecentlyCreated || $mentionRelation->wasChanged()) {
$task->pushMsg('relation', null, null, false);
}
if ($mentionedByRelation->wasRecentlyCreated || $mentionedByRelation->wasChanged()) {
$parentTask->pushMsg('relation', null, null, false);
}
}
//
return Base::retSuccess('操作成功', [
'task' => $taskArray,
'parent' => $parentArray,
]);
}
/**
* @api {post} api/project/task/update 35. 修改任务、子任务
*
@@ -2840,7 +2999,7 @@ class ProjectController extends AbstractController
* @apiVersion 1.0.0
* @apiGroup project
* @apiName task__ai_generate
*
*
* @apiParam {String} content 用户输入的任务描述(必填)
* @apiParam {String} [current_title] 当前已有的任务标题(用于优化改进)
* @apiParam {String} [current_content] 当前已有的任务内容HTML格式用于优化改进
@@ -2860,13 +3019,13 @@ class ProjectController extends AbstractController
public function task__ai_generate()
{
User::auth();
// 获取用户输入的任务描述
$content = Request::input('content');
if (empty($content)) {
return Base::retError('任务描述不能为空');
}
// 获取上下文信息
$context = [
'current_title' => Request::input('current_title', ''),
@@ -2877,7 +3036,7 @@ class ProjectController extends AbstractController
'has_time_plan' => boolval(Request::input('has_time_plan', false)),
'priority_level' => Request::input('priority_level', ''),
];
// 如果当前内容是HTML格式转换为markdown
if (!empty($context['current_content'])) {
$context['current_content'] = Base::html2markdown($context['current_content']);
@@ -2885,7 +3044,7 @@ class ProjectController extends AbstractController
if (!empty($context['template_content'])) {
$context['template_content'] = Base::html2markdown($context['template_content']);
}
$result = AI::generateTask($content, $context);
if (Base::isError($result)) {
return Base::retError('生成任务失败', $result);

View File

@@ -61,6 +61,10 @@ class IndexController extends InvokeController
$array = Base::json2array(file_get_contents($hotFile));
$style = null;
$script = preg_replace("/^(\/\/(.*?))(:\d+)?\//i", "$1:" . $array['APP_DEV_PORT'] . "/", asset_main("resources/assets/js/app.js"));
$proxyUri = Base::liveEnv('VSCODE_PROXY_URI');
if (is_string($proxyUri) && preg_match('/^https?:\/\//i', $proxyUri)) {
$script = preg_replace('/^(https?:\/\/|\/\/)[^\/]+/', rtrim($proxyUri, '/'), $script, 1);
}
} else {
$array = Base::json2array(file_get_contents($manifestFile));
$style = asset_main($array['resources/assets/js/app.js']['css'][0]);

View File

@@ -1579,7 +1579,8 @@ class ProjectTask extends AbstractModel
} elseif ($data instanceof self) {
$data = $data->toArray();
}
//
// 获取接收会员
if ($userid === null) {
$userids = $this->project->relationUserids();
} else {
@@ -1590,11 +1591,7 @@ class ProjectTask extends AbstractModel
return;
}
if (!Arr::exists($data, 'visibility')) {
$data['visibility'] = $this->visibility;
}
$visibility = intval($data['visibility']);
// 按可见性分组推送
$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();
@@ -1603,16 +1600,19 @@ class ProjectTask extends AbstractModel
$assistUsers = array_values(array_diff(array_intersect($userids, $assistList), $ownerUsers));
$array = [];
// 负责人
if ($ownerUsers) {
$array[] = [
'userid' => $ownerUsers,
'data' => array_merge($data, [
'owner' => 1,
'assist' => 1,
'assist' => 0,
])
];
}
// 协助人
if ($assistUsers) {
$array[] = [
'userid' => $assistUsers,
@@ -1623,27 +1623,31 @@ class ProjectTask extends AbstractModel
];
}
// 其他人
$otherUsers = [];
switch ($visibility) {
switch (intval($data['visibility'])) {
case 1:
// 项目人员:除了负责人、协助人项目其他人
$otherUsers = array_diff($userids, $ownerUsers, $assistUsers);
break;
case 2:
$otherUsers = [];
// 任务人员:除了负责人、协助人
// $otherUsers = [];
break;
case 3:
// 指定成员
$specifys = ProjectTaskVisibilityUser::select(['userid'])->whereTaskId($data['id'])->pluck('userid')->toArray();
$otherUsers = array_diff(array_intersect($userids, $specifys), $ownerUsers, $assistUsers);
break;
default:
$otherUsers = array_diff($userids, $ownerUsers, $assistUsers);
break;
}
if ($otherUsers) {
$array[] = [
'userid' => array_values($otherUsers),
'data' => $data
'data' => array_merge($data, [
'owner' => 0,
'assist' => 0,
])
];
}
@@ -1651,6 +1655,7 @@ class ProjectTask extends AbstractModel
return;
}
// 推送
foreach ($array as $item) {
$params = [
'ignoreFd' => $ignoreSelf ? Request::header('fd') : null,

View File

@@ -213,6 +213,7 @@ class UmengAlias extends AbstractModel
'policy' => [
'expire_time' => Carbon::now()->addSeconds($seconds)->toDateTimeString(),
],
'category' => 1,
'channel_properties' => [
'oppo_channel_id' => 'dootask',
'vivo_category' => 'IM',

View File

@@ -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);
}
}
}

View File

@@ -3073,4 +3073,61 @@ class Base
return $html;
}
}
/**
* 实时读取 .env 配置(不受配置缓存影响)
* @param string $key 配置键名
* @param mixed $default 默认值
* @return mixed
*/
public static function liveEnv($key, $default = null)
{
$envFile = base_path('.env');
if (!file_exists($envFile)) {
return $default;
}
$envContent = file_get_contents($envFile);
$lines = explode("\n", $envContent);
foreach ($lines as $line) {
$line = trim($line);
// 跳过注释和空行
if (empty($line) || str_starts_with($line, '#')) {
continue;
}
// 解析 KEY=VALUE
if (str_contains($line, '=')) {
[$envKey, $envValue] = explode('=', $line, 2);
$envKey = trim($envKey);
if ($envKey === $key) {
$envValue = trim($envValue);
// 移除引号
if (preg_match('/^(["\'])(.*)\1$/', $envValue, $matches)) {
$envValue = $matches[2];
}
// 处理布尔值
$lowerValue = strtolower($envValue);
if ($lowerValue === 'true') {
return true;
}
if ($lowerValue === 'false') {
return false;
}
if ($lowerValue === 'null' || $lowerValue === '(null)') {
return null;
}
return $envValue;
}
}
}
return $default;
}
}

58
cmd
View File

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

805
composer.lock generated

File diff suppressed because it is too large Load Diff

10
electron/electron.js vendored
View File

@@ -46,6 +46,7 @@ const utils = require('./lib/utils');
const config = require('./package.json');
const electronDown = require("./electron-down");
const electronMenu = require("./electron-menu");
const { startMCPServer } = require("./lib/mcp");
// 实例初始化
const userConf = new electronConf()
@@ -73,6 +74,7 @@ let enableStoreBkp = true,
// 服务器配置
let serverPort = 22223,
mcpPort = 22224,
serverPublicDir = path.join(__dirname, 'public'),
serverUrl = "",
serverTimer = null;
@@ -1141,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;
}
@@ -1157,6 +1159,8 @@ if (!getTheLock) {
preCreateChildWindow()
// 监听主题变化
monitorThemeChanges()
// 启动 MCP 服务器
startMCPServer(mainWindow, mcpPort)
// 创建托盘
if (['darwin', 'win32'].includes(process.platform) && utils.isJson(config.trayIcon)) {
mainTray = new Tray(path.join(__dirname, config.trayIcon[isDevelopMode ? 'dev' : 'prod'][process.platform === 'darwin' ? 'mac' : 'win']));
@@ -1217,7 +1221,7 @@ app.on('before-quit', () => {
willQuitApp = true
})
app.on("will-quit",function(){
app.on("will-quit", () => {
globalShortcut.unregisterAll();
})

View File

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

417
electron/lib/mcp.js vendored Normal file
View File

@@ -0,0 +1,417 @@
/**
* DooTask MCP Server
*
* DooTask 的 Electron 客户端集成了 Model Context Protocol (MCP) 服务,
* 允许 AI 助手(如 Claude)直接与 DooTask 任务进行交互。
*
* 提供的工具:
* 1. list_tasks - 获取任务列表,支持按状态/项目/主任务筛选、搜索、分页
* 2. get_task - 获取任务详情,包含负责人、协助人员、标签等完整信息
* 3. complete_task - 标记任务完成,自动记录完成时间
* 4. uncomplete_task - 取消完成任务,将已完成任务改为未完成
* 5. get_task_content - 获取任务的富文本描述内容
*
* 配置方法:
* 添加 DooTask MCP 服务器配置:
* {
* "mcpServers": {
* "DooTask": {
* "url": "http://localhost:22224/sse"
* }
* }
* }
*
* 使用示例:
* - "请帮我查看目前有哪些未完成的任务"
* - "任务 123 的详细信息是什么?"
* - "帮我把任务 789 标记为已完成"
*/
const { FastMCP } = require('fastmcp');
const { z } = require('zod');
const loger = require("electron-log");
let mcpServer = null;
class DooTaskMCP {
constructor(mainWindow) {
this.mainWindow = mainWindow;
this.mcp = new FastMCP({
name: 'DooTask MCP Server',
version: '1.0.0',
description: 'DooTask 任务管理 MCP 接口',
});
this.setupTools();
}
/**
* 调用接口
*/
async request(method, path, data = {}) {
try {
// 通过主窗口执行前端代码来调用API
if (!this.mainWindow || !this.mainWindow.webContents) {
throw new Error('Main window not available, please open DooTask application first');
}
// 检查 webContents 是否 ready
if (this.mainWindow.webContents.isLoading()) {
loger.warn(`[MCP] WebContents is loading, waiting...`);
await new Promise(resolve => {
this.mainWindow.webContents.once('did-finish-load', resolve);
});
}
// 通过前端已有的API调用机制添加超时处理
const executePromise = this.mainWindow.webContents.executeJavaScript(`
(async () => {
try {
// 检查API是否可用
if (typeof $A === 'undefined') {
return { error: 'API not available - $A is undefined' };
}
if (typeof $A.apiCall !== 'function') {
return { error: 'API not available - $A.apiCall is not a function' };
}
// 调用 API
const result = await $A.apiCall({
url: '${path}',
data: ${JSON.stringify(data)},
method: '${method}'
});
try {
return { data: JSON.parse(JSON.stringify(result.data)) };
} catch (serError) {
return { error: 'Result contains non-serializable data' };
}
} catch (error) {
return { error: error.msg || error.message || String(error) || 'API request failed' };
}
})()
`);
// 添加超时处理30秒
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Request timeout after 30 seconds')), 30000);
});
// 返回结果
const result = await Promise.race([executePromise, timeoutPromise]);
if (result && result.error) {
throw new Error(result.error);
}
return result;
} catch (error) {
throw error;
}
}
// 设置 MCP 工具
setupTools() {
// 1. 获取任务列表
this.mcp.addTool({
name: 'list_tasks',
description: '获取任务列表。可以按状态筛选(已完成/未完成)、搜索任务名称、按时间范围筛选等。',
parameters: z.object({
status: z.enum(['all', 'completed', 'uncompleted'])
.optional()
.describe('任务状态: all(所有), completed(已完成), uncompleted(未完成)'),
search: z.string()
.optional()
.describe('搜索关键词(可搜索任务ID、名称、描述)'),
time: z.string()
.optional()
.describe('时间范围: today(今天), week(本周), month(本月), year(今年), 自定义时间范围,如2025-12-12,2025-12-30'),
project_id: z.number()
.optional()
.describe('项目ID,只获取指定项目的任务'),
parent_id: z.number()
.optional()
.describe('主任务ID。大于0:获取该主任务的子任务; -1:仅获取主任务; 不传:所有任务'),
page: z.number()
.optional()
.describe('页码,默认1'),
pagesize: z.number()
.optional()
.describe('每页数量,默认20,最大100'),
}),
execute: async (params) => {
const requestData = {
page: params.page || 1,
pagesize: params.pagesize || 20,
};
// 构建 keys 对象用于筛选
const keys = {};
if (params.search) {
keys.name = params.search;
}
if (params.status && params.status !== 'all') {
keys.status = params.status;
}
if (Object.keys(keys).length > 0) {
requestData.keys = keys;
}
// 其他筛选参数
if (params.time !== undefined) {
requestData.time = params.time;
}
if (params.project_id !== undefined) {
requestData.project_id = params.project_id;
}
if (params.parent_id !== undefined) {
requestData.parent_id = params.parent_id;
}
const result = await this.request('GET', 'project/task/lists', requestData);
if (result.error) {
throw new Error(result.error);
}
// 格式化返回数据,使其更易读
const tasks = result.data.data.map(task => ({
id: task.id,
name: task.name,
status: task.complete_at ? '已完成' : '未完成',
complete_at: task.complete_at || '未完成',
project_id: task.project_id,
parent_id: task.parent_id,
sub_num: task.sub_num || 0,
sub_complete: task.sub_complete || 0,
percent: task.percent || 0,
created_at: task.created_at,
}));
return {
content: [{
type: 'text',
text: JSON.stringify({
total: result.data.total,
page: result.data.current_page,
pagesize: result.data.per_page,
tasks: tasks,
}, null, 2)
}]
};
}
});
// 2. 获取任务详情
this.mcp.addTool({
name: 'get_task',
description: '获取指定任务的详细信息,包括任务描述、负责人、协助人员、标签、时间等完整信息。',
parameters: z.object({
task_id: z.number()
.describe('任务ID'),
}),
execute: async (params) => {
const result = await this.request('GET', 'project/task/one', {
task_id: params.task_id,
});
if (result.error) {
throw new Error(result.error);
}
const task = result.data;
// 格式化任务详情
const taskDetail = {
id: task.id,
name: task.name,
desc: task.desc || '无描述',
status: task.complete_at ? '已完成' : '未完成',
complete_at: task.complete_at || '未完成',
project_id: task.project_id,
project_name: task.project_name,
column_id: task.column_id,
column_name: task.column_name,
parent_id: task.parent_id,
start_at: task.start_at,
end_at: task.end_at,
flow_item_id: task.flow_item_id,
flow_item_name: task.flow_item_name,
visibility: task.visibility === 1 ? '公开' : '指定人员',
owners: task.taskUser?.filter(u => u.owner === 1).map(u => u.userid) || [],
assistants: task.taskUser?.filter(u => u.owner === 0).map(u => u.userid) || [],
tags: task.taskTag?.map(t => t.name) || [],
created_at: task.created_at,
updated_at: task.updated_at,
};
return {
content: [{
type: 'text',
text: JSON.stringify(taskDetail, null, 2)
}]
};
}
});
// 3. 标记任务完成
this.mcp.addTool({
name: 'complete_task',
description: '将指定任务标记为已完成。注意:主任务必须在所有子任务完成后才能标记完成。',
parameters: z.object({
task_id: z.number()
.describe('要标记完成的任务ID'),
}),
execute: async (params) => {
// 使用当前时间标记完成
const now = new Date().toISOString().slice(0, 19).replace('T', ' ');
const result = await this.request('POST', 'project/task/update', {
task_id: params.task_id,
complete_at: now,
});
if (result.error) {
throw new Error(result.error);
}
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
message: '任务已标记为完成',
task_id: params.task_id,
complete_at: result.data.complete_at,
}, null, 2)
}]
};
}
});
// 4. 取消完成任务
this.mcp.addTool({
name: 'uncomplete_task',
description: '将已完成的任务标记为未完成。',
parameters: z.object({
task_id: z.number()
.describe('要标记为未完成的任务ID'),
}),
execute: async (params) => {
const result = await this.request('POST', 'project/task/update', {
task_id: params.task_id,
complete_at: false,
});
if (result.error) {
throw new Error(result.error);
}
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
message: '任务已标记为未完成',
task_id: params.task_id,
}, null, 2)
}]
};
}
});
// 5. 获取任务内容详情
this.mcp.addTool({
name: 'get_task_content',
description: '获取任务的详细内容描述(富文本内容)',
parameters: z.object({
task_id: z.number()
.describe('任务ID'),
}),
execute: async (params) => {
const result = await this.request('GET', 'project/task/content', {
task_id: params.task_id,
});
if (result.error) {
throw new Error(result.error);
}
return {
content: [{
type: 'text',
text: JSON.stringify({
task_id: params.task_id,
content: result.data.content || '无内容',
}, null, 2)
}]
};
}
});
}
// 启动 MCP 服务器
async start(port = 22224) {
try {
await this.mcp.start({
transportType: 'httpStream',
httpStream: {
port: port
}
});
} catch (error) {
throw error;
}
}
// 停止服务器
async stop() {
if (this.mcp && typeof this.mcp.stop === 'function') {
await this.mcp.stop();
}
}
}
/**
* 启动 MCP 服务器
*/
function startMCPServer(mainWindow, mcpPort) {
if (mcpServer) {
loger.info('MCP server already running');
return;
}
mcpServer = new DooTaskMCP(mainWindow);
mcpServer.start(mcpPort).then(() => {
loger.info(`DooTask MCP Server started on http://localhost:${mcpPort}/sse`);
}).catch((error) => {
loger.error('Failed to start MCP server:', error);
mcpServer = null;
});
}
/**
* 停止 MCP 服务器
*/
function stopMCPServer() {
if (!mcpServer) {
loger.info('MCP server is not running');
return;
}
mcpServer.stop().then(() => {
loger.info('MCP server stopped');
}).catch((error) => {
loger.error('Failed to stop MCP server:', error);
}).finally(() => {
mcpServer = null;
});
}
module.exports = {
DooTaskMCP,
startMCPServer,
stopMCPServer,
};

View File

@@ -53,10 +53,12 @@
"electron-store": "^8.2.0",
"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": {

View File

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

View File

@@ -920,4 +920,8 @@ URL格式不正确
收藏记录不存在
修改备注成功
请输入修改备注
备注最多支持(*)个字符
备注最多支持(*)个字符
当前任务已是主任务
子任务升级为主任务
升级为主任务

View File

@@ -2214,3 +2214,8 @@ AI 未生成内容
暂无共同群组
(*)个
查看更多...
子任务升级为主任务
升级为主任务
升主任务
你确定要将子任务【(*)】升级为主任务吗?

View File

@@ -2,6 +2,8 @@ const isElectron = !!(window && window.process && window.process.type && window.
const isEEUIApp = window && window.navigator && /eeui/i.test(window.navigator.userAgent);
const isSoftware = isElectron || isEEUIApp;
document.getElementById("app")?.setAttribute("data-preload", "false");
import {languageName, switchLanguage as $L} from "./language";
import {isLocalHost} from "./components/Replace/utils";

View File

@@ -901,11 +901,17 @@ export default {
},
onKeydown(event) {
if (event.isComposing || event.key === 'Process') {
return;
}
// 按下删除键时,判断是否符合删除条件
this.backspaceDelete = event.key === 'Backspace' && !this.searchKey && this.selects.length > 0;
},
onKeyup(event) {
if (event.isComposing || event.key === 'Process') {
return;
}
if (event.key === 'Backspace' && this.backspaceDelete) {
// 从最后一个元素开始向前遍历,找到第一个不是不可取消的元素
for (let i = this.selects.length - 1; i >= 0; i--) {

View File

@@ -86,11 +86,18 @@
</EDropdownItem>
</template>
</template>
<EDropdownItem v-else-if="operationShow" command="remove" :divided="turns.length > 0">
<div class="item">
<Icon type="md-trash" />{{$L('删除')}}
</div>
</EDropdownItem>
<template v-else-if="operationShow">
<EDropdownItem command="upgrade" :divided="turns.length > 0">
<div class="item">
<Icon type="md-arrow-round-up" />{{$L('升主任务')}}
</div>
</EDropdownItem>
<EDropdownItem command="remove">
<div class="item hover-del">
<Icon type="md-trash" />{{$L('删除')}}
</div>
</EDropdownItem>
</template>
</ul>
</li>
</EDropdownMenu>
@@ -342,6 +349,10 @@ export default {
this.$refs.forwarder.onSelection()
break;
case 'upgrade':
this.upgradeSubtask();
break;
case 'archived':
case 'remove':
this.archivedOrRemoveTask(command);
@@ -391,6 +402,33 @@ export default {
})
},
upgradeSubtask() {
if (this.loadIng) {
return;
}
$A.modalConfirm({
title: '升级为主任务',
content: `你确定要将子任务【${this.task.name}】升级为主任务吗?`,
loading: true,
onOk: () => {
if (this.loadIng) {
return;
}
return new Promise((resolve, reject) => {
this.$store.dispatch("taskConvertToMain", this.task.id).then(({data, msg}) => {
$A.messageSuccess(msg);
this.hide();
this.$store.dispatch("openTask", data?.task?.id || this.task.id);
resolve();
}).catch(({msg}) => {
$A.modalError(msg);
reject();
});
})
}
});
},
archivedOrRemoveTask(type) {
let typeDispatch = 'removeTask';
let typeName = '删除';

View File

@@ -2276,6 +2276,47 @@ export default {
});
},
/**
* 子任务升级为主任务
* @param dispatch
* @param data Number|JSONObject{task_id}
* @returns {Promise<unknown>}
*/
taskConvertToMain({dispatch}, data) {
return new Promise(function (resolve, reject) {
if (/^\d+$/.test(data)) {
data = {task_id: data}
}
if ($A.runNum(data.task_id) === 0) {
reject({msg: 'Parameter error'});
return;
}
dispatch("setLoad", {
key: `task-${data.task_id}`,
delay: 300
})
dispatch("call", {
url: 'project/task/upgrade',
data,
}).then(result => {
const {task, parent} = result.data || {};
if (task) {
dispatch("saveTask", task);
}
if (parent) {
dispatch("saveTask", parent);
}
resolve(result)
}).catch(e => {
console.warn(e);
dispatch("getTaskOne", data.task_id).catch(() => {})
reject(e)
}).finally(_ => {
dispatch("cancelLoad", `task-${data.task_id}`)
});
});
},
/**
* 获取任务详细描述
* @param state
@@ -4609,11 +4650,6 @@ export default {
case 'recovery': // 恢复(归档)
dispatch("saveTask", data)
break;
case 'relation':
if (data?.id) {
emitter.emit('taskRelationUpdate', data.id)
}
break;
case 'dialog':
dispatch("saveTask", data)
dispatch("getDialogOne", data.dialog_id).catch(() => {})
@@ -4627,6 +4663,9 @@ export default {
case 'delete':
dispatch("forgetTask", data)
break;
case 'relation':
emitter.emit('taskRelationUpdate', data.id)
break;
}
})(msgDetail);
break;

View File

@@ -853,6 +853,7 @@
ol {
li {
min-height: 20px;
counter-reset: list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9;
&::before {

View File

@@ -34,7 +34,7 @@
<body>
@extends('ie')
<div id="app" data-preload="false">
<div id="app" data-preload="init">
<div class="app-view-loading no-dark-content">
<div>
<div>PAGE LOADING</div>

15
vite.config.js vendored
View File

@@ -22,6 +22,7 @@ export default defineConfig(({command, mode}) => {
const env = loadEnv(mode, process.cwd(), '')
const host = "0.0.0.0"
const port = parseInt(env['APP_DEV_PORT'])
const proxy_uri = env['VSCODE_PROXY_URI']
if (command === 'serve') {
const hotFile = path.resolve(__dirname, 'public/hot')
@@ -80,6 +81,17 @@ export default defineConfig(({command, mode}) => {
})
}
const serverHmr = {}
if (/^https?:\/\//i.test(proxy_uri)) {
const proxyUri = new URL(proxy_uri)
if (proxyUri) {
Object.assign(serverHmr, {
host: proxyUri.host,
clientPort: proxyUri.port || (/^https/.test(proxy_uri) ? 443 : 80)
})
}
}
return {
base: basePath,
publicDir: publicPath,
@@ -98,7 +110,8 @@ export default defineConfig(({command, mode}) => {
'**/language/**',
'**/electron/**',
]
}
},
hmr: serverHmr
},
resolve: {
alias: {