feat: 添加任务关联功能

This commit is contained in:
kuaifan 2025-09-27 15:53:58 +08:00
parent aba31eda83
commit 3cf7055122
10 changed files with 623 additions and 61 deletions

View File

@ -44,6 +44,7 @@ use App\Models\ProjectTaskFlowChange;
use App\Models\ProjectTaskVisibilityUser;
use App\Models\ProjectTaskTemplate;
use App\Models\ProjectTag;
use App\Models\ProjectTaskRelation;
/**
* @apiDefine project
@ -1752,6 +1753,112 @@ class ProjectController extends AbstractController
return Base::retSuccess('success', $data);
}
/**
* @api {get} api/project/task/related 25.1 获取任务关联任务列表
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup project
* @apiName task__related
*
* @apiParam {Number} task_id 任务ID
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function task__related()
{
User::auth();
$task_id = intval(Request::input('task_id'));
if ($task_id <= 0) {
return Base::retError('参数错误', ['task_id' => $task_id]);
}
$task = ProjectTask::userTask($task_id, null);
$relations = ProjectTaskRelation::whereTaskId($task->id)
->orderByDesc('updated_at')
->limit(100)
->get();
if ($relations->isEmpty()) {
return Base::retSuccess('success', [
'task_id' => $task->id,
'list' => [],
]);
}
$relatedTaskIds = $relations->pluck('related_task_id')->unique()->values();
$relatedTasks = [];
foreach ($relatedTaskIds as $relatedId) {
try {
$relatedTask = ProjectTask::userTask($relatedId, null, true, ['project', 'projectColumn']);
$flowItemParts = explode('|', $relatedTask->flow_item_name ?: '');
$flowItemStatus = $flowItemParts[0] ?? '';
$flowItemName = $flowItemParts[1] ?? $relatedTask->flow_item_name;
$flowItemColor = $flowItemParts[2] ?? '';
$relatedTask->flow_item_status = $flowItemStatus;
$relatedTask->flow_item_name = $flowItemName;
$relatedTask->flow_item_color = $flowItemColor;
$relatedTasks[$relatedTask->id] = $relatedTask;
} catch (\Throwable $e) {
continue;
}
}
$list = [];
foreach ($relations as $relation) {
$relatedTask = $relatedTasks[$relation->related_task_id] ?? null;
if (!$relatedTask) {
continue;
}
if (!isset($list[$relation->related_task_id])) {
$list[$relation->related_task_id] = [
'task_id' => $relation->task_id,
'related_task_id' => $relation->related_task_id,
'mention' => false,
'mentioned_by' => false,
'latest_msg_id' => $relation->msg_id,
'latest_at' => $relation->updated_at?->toDateTimeString(),
'task' => [
'id' => $relatedTask->id,
'name' => $relatedTask->name,
'project_id' => $relatedTask->project_id,
'project_name' => $relatedTask->project?->name,
'column_id' => $relatedTask->column_id,
'column_name' => $relatedTask->projectColumn?->name,
'complete_at' => $relatedTask->complete_at?->toDateTimeString(),
'archived_at' => $relatedTask->archived_at?->toDateTimeString(),
'flow_item_name' => $relatedTask->flow_item_name,
'flow_item_status' => $relatedTask->flow_item_status,
'flow_item_color' => $relatedTask->flow_item_color,
],
];
}
if ($relation->direction === ProjectTaskRelation::DIRECTION_MENTION) {
$list[$relation->related_task_id]['mention'] = true;
} elseif ($relation->direction === ProjectTaskRelation::DIRECTION_MENTIONED_BY) {
$list[$relation->related_task_id]['mentioned_by'] = true;
}
if ($relation->updated_at && ($list[$relation->related_task_id]['latest_at'] === null || Carbon::parse($list[$relation->related_task_id]['latest_at'])->lt($relation->updated_at))) {
$list[$relation->related_task_id]['latest_at'] = $relation->updated_at->toDateTimeString();
$list[$relation->related_task_id]['latest_msg_id'] = $relation->msg_id;
}
}
return Base::retSuccess('success', [
'task_id' => $task->id,
'list' => array_values($list),
]);
}
/**
* @api {get} api/project/task/content 25. 获取任务详细描述
*

View File

@ -1560,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;
@ -1573,6 +1574,7 @@ 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();
@ -1583,67 +1585,75 @@ class ProjectTask extends AbstractModel
} 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;
}
//
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();
$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' => 1,
])
];
}
if ($assistUsers) {
$array[] = [
'userid' => $assistUsers,
'data' => array_merge($data, [
'owner' => 0,
'assist' => 1,
])
];
}
$otherUsers = [];
switch ($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;
default:
$otherUsers = array_diff($userids, $ownerUsers, $assistUsers);
break;
}
if ($otherUsers) {
$array[] = [
'userid' => array_values($otherUsers),
'data' => $data
];
}
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',

View File

@ -0,0 +1,120 @@
<?php
namespace App\Models;
use App\Module\Base;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProjectTaskRelation extends AbstractModel
{
public const DIRECTION_MENTION = 'mention';
public const DIRECTION_MENTIONED_BY = 'mentioned_by';
protected $fillable = [
'task_id',
'related_task_id',
'direction',
'dialog_id',
'msg_id',
'userid',
];
public function task(): BelongsTo
{
return $this->belongsTo(ProjectTask::class, 'task_id');
}
public function relatedTask(): BelongsTo
{
return $this->belongsTo(ProjectTask::class, 'related_task_id');
}
public static function recordMentionsFromMessage(WebSocketDialogMsg $msg): void
{
if ($msg->type !== 'text') {
return;
}
$payload = $msg->msg;
if (!is_array($payload)) {
$payload = Base::json2array($msg->getRawOriginal('msg'));
}
$text = $payload['text'] ?? '';
if (!$text || !preg_match_all('/<span class="mention task" data-id="(\d+)">#?(.*?)<\/span>/i', $text, $matches)) {
return;
}
$targetIds = array_values(array_unique(array_filter(array_map('intval', $matches[1] ?? []))));
if (empty($targetIds)) {
return;
}
$sourceTasks = ProjectTask::with('project')->whereDialogId($msg->dialog_id)->get();
if ($sourceTasks->isEmpty()) {
return;
}
$targetTasks = ProjectTask::with('project')->whereIn('id', $targetIds)->get()->keyBy('id');
if ($targetTasks->isEmpty()) {
return;
}
$pushTasks = [];
foreach ($sourceTasks as $sourceTask) {
foreach ($targetIds as $targetId) {
if ($targetId === $sourceTask->id) {
continue;
}
$targetTask = $targetTasks->get($targetId);
if (!$targetTask) {
continue;
}
$mentionRelation = static::updateOrCreate(
[
'task_id' => $sourceTask->id,
'related_task_id' => $targetTask->id,
'direction' => self::DIRECTION_MENTION,
],
[
'dialog_id' => $msg->dialog_id,
'msg_id' => $msg->id,
'userid' => $msg->userid,
]
);
if ($mentionRelation->wasRecentlyCreated || $mentionRelation->wasChanged()) {
$pushTasks[$sourceTask->id] = $sourceTask;
}
$reverseRelation = static::updateOrCreate(
[
'task_id' => $targetTask->id,
'related_task_id' => $sourceTask->id,
'direction' => self::DIRECTION_MENTIONED_BY,
],
[
'dialog_id' => $msg->dialog_id,
'msg_id' => $msg->id,
'userid' => $msg->userid,
]
);
if ($reverseRelation->wasRecentlyCreated || $reverseRelation->wasChanged()) {
$pushTasks[$targetTask->id] = $targetTask;
}
}
}
foreach ($pushTasks as $task) {
$task->loadMissing('project');
if (!$task->project) {
continue;
}
$task->pushMsg('relation', null, null, false);
}
}
}

View File

@ -8,6 +8,7 @@ use App\Module\Base;
use App\Module\Doo;
use App\Module\Image;
use App\Tasks\PushTask;
use App\Models\ProjectTaskRelation;
use App\Exceptions\ApiException;
use App\Tasks\WebSocketDialogMsgTask;
use Hhxsv5\LaravelS\Swoole\Task\Task;
@ -1332,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, // 修改消息时,显示会话(仅自己)
@ -1398,6 +1400,7 @@ class WebSocketDialogMsg extends AbstractModel
'updated_at' => Carbon::now()->toDateTimeString('millisecond'),
]);
});
ProjectTaskRelation::recordMentionsFromMessage($dialogMsg);
//
$task = new WebSocketDialogMsgTask($dialogMsg->id);
if ($push_self) {

View File

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

View File

@ -334,6 +334,51 @@
</li>
</ul>
</FormItem>
<FormItem v-if="relatedTasks.length > 0" className="item-related-task">
<div class="item-label" slot="label">
<i class="taskfont">&#xe7d6;</i>{{$L('关联任务')}}
</div>
<ul class="item-content related-task">
<li
v-for="item in relatedTasks"
:key="item.related_task_id"
class="related-item"
@click="openRelatedTask(item)">
<span class="related-direction" :class="{
inbound: item.mentioned_by,
outbound: item.mention,
mutual: item.mention && item.mentioned_by
}">
<Icon v-if="item.mention && item.mentioned_by" type="md-swap"/>
<Icon v-else-if="item.mentioned_by" type="md-arrow-round-back"/>
<Icon v-else type="md-arrow-round-forward"/>
</span>
<span class="related-main">
<span class="related-id">#{{item.related_task_id}}</span>
<span class="related-title">{{item.task.name}}</span>
</span>
<span v-if="item.task.project_name && item.task.project_id != taskDetail.project_id" class="related-project">{{item.task.project_name}}</span>
<span v-if="item.task.column_name" class="related-column">{{item.task.column_name}}</span>
<span
v-if="item.task.flow_item_name"
class="related-status"
:class="item.task.flow_item_status"
:style="$A.generateColorVarStyle(item.task.flow_item_color, [10], 'flow-item-custom-color')">
{{item.task.flow_item_name}}
</span>
<span
v-else-if="item.task.complete_at"
class="related-status end">
{{$L('已完成')}}
</span>
<span
v-else-if="item.task.archived_at"
class="related-status archived">
{{$L('已归档')}}
</span>
</li>
</ul>
</FormItem>
</Form>
<div v-if="menuList.length > 0" class="add">
<div class="add-wrap">
@ -597,6 +642,9 @@ export default {
loopForce: false,
relatedTasks: [],
relatedRequestKey: 0,
keepInterval: null,
keepIntoTimer: null,
keepUnix: $A.dayjs().unix(),
@ -668,12 +716,14 @@ export default {
}, 1000);
//
emitter.on('receiveTask', this.onReceiveShow);
emitter.on('taskRelationUpdate', this.onTaskRelationUpdate);
},
destroyed() {
clearInterval(this.keepInterval);
//
emitter.off('receiveTask', this.onReceiveShow);
emitter.off('taskRelationUpdate', this.onTaskRelationUpdate);
},
computed: {
@ -967,6 +1017,7 @@ export default {
handler(id) {
if (id > 0) {
this.ready = true;
this.loadRelatedTasks();
} else {
$A.eeuiAppKeyboardHide()
this.timeOpen = false;
@ -978,6 +1029,8 @@ export default {
this.addsubForce = false;
this.receiveShow = false;
this.$refs.chatInput?.hidePopover();
this.relatedRequestKey++;
this.relatedTasks = [];
}
},
immediate: true
@ -1508,6 +1561,48 @@ export default {
this.$refs.log.getLists(true);
},
async loadRelatedTasks() {
if (!this.taskId) {
this.relatedTasks = [];
return;
}
const cacheMap = this.$store.state.taskRelatedCache || {};
const cached = cacheMap[this.taskId];
if (cached?.list) {
this.relatedTasks = cached.list;
}
const requestKey = ++this.relatedRequestKey;
try {
const data = await this.$store.dispatch('getTaskRelated', this.taskId);
if (requestKey !== this.relatedRequestKey) {
return;
}
this.relatedTasks = data;
} catch (e) {
if (requestKey === this.relatedRequestKey) {
this.relatedTasks = [];
}
console.warn(e);
}
},
openRelatedTask(item) {
if (!item || !item.related_task_id) {
return;
}
if (item.related_task_id === this.taskId) {
return;
}
this.$store.dispatch('openTask', item.related_task_id);
},
onTaskRelationUpdate(taskId) {
if (!taskId || taskId !== this.taskId) {
return;
}
this.loadRelatedTasks();
},
logLoadChange(load) {
this.logLoadIng = load
},

View File

@ -1138,7 +1138,8 @@ export default {
'microAppsMenus',
],
json: [
'userInfo'
'userInfo',
'taskRelatedCache',
]
};
@ -2458,6 +2459,39 @@ export default {
});
},
/**
* 获取任务关联列表
* @param state
* @param dispatch
* @param commit
* @param taskId
* @returns {Promise<unknown>}
*/
getTaskRelated({state, commit, dispatch}, taskId) {
taskId = parseInt(taskId, 10);
if (!taskId) {
return Promise.resolve([]);
}
return new Promise((resolve, reject) => {
dispatch("call", {
url: 'project/task/related',
data: {task_id: taskId},
}).then(({data}) => {
const list = (data.list || []).map(item => ({
...item,
mention: !!item.mention,
mentioned_by: !!item.mentioned_by,
}));
commit('task/related/save', {
taskId,
list,
updatedAt: Date.now(),
});
resolve(list);
}).catch(reject);
});
},
/**
* 添加子任务
* @param dispatch
@ -4575,6 +4609,11 @@ 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(() => {})

View File

@ -78,6 +78,27 @@ export default {
}
},
'task/related/save': function(state, {taskId, list, updatedAt = Date.now()}) {
const cache = Object.assign({}, state.taskRelatedCache);
cache[taskId] = {
list,
updated_at: updatedAt,
};
state.taskRelatedCache = cache;
$A.IDBSave("taskRelatedCache", state.taskRelatedCache, 600)
},
'task/related/clear': function(state, taskId) {
if (typeof taskId === 'number' || typeof taskId === 'string') {
const cache = Object.assign({}, state.taskRelatedCache);
delete cache[taskId];
state.taskRelatedCache = cache;
} else {
state.taskRelatedCache = {};
}
$A.IDBSave("taskRelatedCache", state.taskRelatedCache, 600)
},
// 对话管理
'dialog/push': function(state, data) {
state.cacheDialogs.push(data)

View File

@ -172,6 +172,7 @@ export default {
taskFiles: [],
taskLogs: [],
taskOperation: {},
taskRelatedCache: {},
taskArchiveView: 0,
taskTemplates: [],
taskLatestId: 0,

View File

@ -547,6 +547,138 @@
}
}
}
&.related-task {
margin-top: 2px;
> li {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px 8px;
padding: 4px 0;
cursor: pointer;
color: $primary-text-color;
transition: color 0.2s ease;
&:hover {
color: $primary-title-color;
.related-title {
color: $primary-title-color;
}
}
.related-direction {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background-color: #f4f5f5;
font-size: 12px;
color: #a0a0a0;
.ivu-icon {
font-size: 14px;
}
&.outbound {
color: $primary-color;
}
&.inbound {
color: #fa8c16;
}
&.mutual {
color: #19be6b;
}
}
.related-main {
display: flex;
align-items: center;
flex: 1;
min-width: 120px;
.related-id {
display: none;
margin-right: 6px;
font-size: 12px;
color: #9aa0a6;
}
.related-title {
color: $primary-text-color;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.related-project,
.related-column {
flex-shrink: 0;
font-size: 12px;
color: #9aa0a6;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.related-status {
margin-left: 6px;
padding: 0 6px;
height: 22px;
line-height: 22px;
border-radius: 4px;
font-size: 12px;
color: var(--flow-item-custom-color-100, $primary-color);
border: 1px solid var(--flow-item-custom-color-10, rgba($primary-color, 0.2));
background-color: var(--flow-item-custom-color-10, rgba($primary-color, 0.1));
display: inline-flex;
align-items: center;
justify-content: center;
&.start {
color: var(--flow-item-custom-color-100, $flow-status-start-color);
border-color: var(--flow-item-custom-color-10, rgba($flow-status-start-color, 0.2));
background-color: var(--flow-item-custom-color-10, rgba($flow-status-start-color, 0.1));
}
&.progress {
color: var(--flow-item-custom-color-100, $flow-status-progress-color);
border-color: var(--flow-item-custom-color-10, rgba($flow-status-progress-color, 0.2));
background-color: var(--flow-item-custom-color-10, rgba($flow-status-progress-color, 0.1));
}
&.test {
color: var(--flow-item-custom-color-100, $flow-status-test-color);
border-color: var(--flow-item-custom-color-10, rgba($flow-status-test-color, 0.2));
background-color: var(--flow-item-custom-color-10, rgba($flow-status-test-color, 0.1));
}
&.end {
color: $primary-color;
border-color: rgba($primary-color, 0.2);
background-color: rgba($primary-color, 0.08);
}
&.archived {
color: $flow-status-archived-color;
border-color: rgba($flow-status-archived-color, 0.2);
background-color: rgba($flow-status-archived-color, 0.1);
}
}
.ivu-tag {
margin-left: 8px;
}
}
}
}
.visibility-text {
@ -974,7 +1106,8 @@ body.window-portrait {
}
.items {
.ivu-form-item {
&.item-subtask {
&.item-subtask,
&.item-related-task {
display: flex;
flex-direction: column;
.ivu-form-item-content {