mirror of
https://github.com/kuaifan/dootask.git
synced 2026-09-11 14:38:41 +00:00
feat(task): 支持任务流转与部门指派
This commit is contained in:
parent
ffbb45708d
commit
519e8dbd54
91
app/Http/Controllers/Api/ProjectTaskHandoffController.php
Normal file
91
app/Http/Controllers/Api/ProjectTaskHandoffController.php
Normal file
@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Models\ProjectTaskHandoff;
|
||||
use App\Models\User;
|
||||
use App\Module\Base;
|
||||
use App\Module\ProjectTaskHandoffService;
|
||||
use Request;
|
||||
|
||||
/**
|
||||
* @apiDefine projecttaskhandoff 任务流转
|
||||
*/
|
||||
class ProjectTaskHandoffController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @api {get} api/projecttaskhandoff/lists 任务流转记录
|
||||
* @apiGroup projecttaskhandoff
|
||||
* @apiParam {Number} task_id 任务ID
|
||||
* @apiParam {Number} [before_id] 上一页最后一条记录ID
|
||||
* @apiParam {String} [department_owner_ids] 所选管理部门ID
|
||||
*/
|
||||
public function lists()
|
||||
{
|
||||
User::auth();
|
||||
$task = ProjectTaskHandoffService::task((int)Request::input('task_id'));
|
||||
$query = ProjectTaskHandoff::where('task_id', $task->id);
|
||||
$beforeId = (int)Request::input('before_id');
|
||||
if ($beforeId > 0) {
|
||||
$query->where('id', '<', $beforeId);
|
||||
}
|
||||
$rows = $query->orderByDesc('id')->limit(21)->get();
|
||||
try {
|
||||
ProjectTaskHandoffService::policy($task);
|
||||
$canAssign = true;
|
||||
} catch (ApiException $e) {
|
||||
$canAssign = false;
|
||||
}
|
||||
return Base::retSuccess('success', [
|
||||
'lists' => $rows->take(20)->values(),
|
||||
'has_more' => $rows->count() > 20,
|
||||
'can_assign' => $canAssign,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/projecttaskhandoff/options 任务指派人员与权限
|
||||
* @apiGroup projecttaskhandoff
|
||||
* @apiParam {Number} task_id 任务ID
|
||||
* @apiParam {String} [department_owner_ids] 所选管理部门ID
|
||||
*/
|
||||
public function options()
|
||||
{
|
||||
User::auth();
|
||||
$task = ProjectTaskHandoffService::task((int)Request::input('task_id'));
|
||||
return Base::retSuccess('success', ProjectTaskHandoffService::options($task));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/projecttaskhandoff/assign 指派任务负责人并附带留言
|
||||
* @apiGroup projecttaskhandoff
|
||||
* @apiParam {Number} task_id 任务ID
|
||||
* @apiParam {Array} owners 完整的目标负责人ID列表,包含受保护的负责人
|
||||
* @apiParam {String} version options接口返回的并发校验值
|
||||
* @apiParam {String} [note] 指派留言,最多1000字
|
||||
* @apiParam {String} [department_owner_ids] 所选管理部门ID
|
||||
*/
|
||||
public function assign()
|
||||
{
|
||||
User::auth();
|
||||
$owners = Request::input('owners', []);
|
||||
$note = Request::input('note') ?? '';
|
||||
$version = Request::input('version');
|
||||
if (!Request::isMethod('post')
|
||||
|| !is_array($owners)
|
||||
|| count($owners) > 10
|
||||
|| !is_string($note)
|
||||
|| mb_strlen($note) > 1000
|
||||
|| !is_string($version)) {
|
||||
return Base::retError('指派参数无效');
|
||||
}
|
||||
foreach ($owners as $id) {
|
||||
if ((!is_int($id) && !is_string($id)) || !ctype_digit((string)$id) || (int)$id <= 0) {
|
||||
return Base::retError('指派参数无效');
|
||||
}
|
||||
}
|
||||
$task = ProjectTaskHandoffService::task((int)Request::input('task_id'));
|
||||
return Base::retSuccess('指派成功', ProjectTaskHandoffService::assign($task, $owners, $version, trim($note)));
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Module\ProjectTaskHandoffSettings;
|
||||
use App\Models\UserDevice;
|
||||
use App\Models\WebSocketDialog;
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
@ -61,6 +62,11 @@ class SystemController extends AbstractController
|
||||
Base::checkClientVersion('0.41.11');
|
||||
User::auth('admin');
|
||||
$all = Request::input();
|
||||
foreach (ProjectTaskHandoffSettings::OPTIONS as $key => $options) {
|
||||
if (array_key_exists($key, $all) && !in_array($all[$key], $options, true)) {
|
||||
return Base::retError('流转设置选项无效');
|
||||
}
|
||||
}
|
||||
foreach ($all AS $key => $value) {
|
||||
if (!in_array($key, [
|
||||
'reg',
|
||||
@ -99,6 +105,11 @@ class SystemController extends AbstractController
|
||||
'unclaimed_task_reminder_time',
|
||||
'task_ai_auto_analyze',
|
||||
'department_owner_project_view',
|
||||
'project_task_handoff',
|
||||
'project_task_handoff_role',
|
||||
'project_task_handoff_candidates',
|
||||
'project_task_handoff_adjust',
|
||||
'project_task_handoff_note',
|
||||
'todo_set_permission',
|
||||
])) {
|
||||
unset($all[$key]);
|
||||
@ -162,6 +173,7 @@ class SystemController extends AbstractController
|
||||
$setting['unclaimed_task_reminder_time'] = $setting['unclaimed_task_reminder_time'] ?: '';
|
||||
$setting['task_ai_auto_analyze'] = $setting['task_ai_auto_analyze'] ?: 'open';
|
||||
$setting['department_owner_project_view'] = $setting['department_owner_project_view'] ?: 'open';
|
||||
$setting = ProjectTaskHandoffSettings::normalize($setting);
|
||||
$setting['app_ai_hidden'] = config('dootask.app_ai_hidden');
|
||||
$setting['server_timezone'] = config('app.timezone');
|
||||
$setting['server_version'] = Base::getVersion();
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Module\ProjectTaskHandoffRecord;
|
||||
use App\Module\Timer;
|
||||
use DB;
|
||||
use Arr;
|
||||
@ -631,6 +632,7 @@ class ProjectTask extends AbstractModel
|
||||
}
|
||||
}
|
||||
$task->addLog("创建{任务}");
|
||||
ProjectTaskHandoffRecord::created($task);
|
||||
return $task;
|
||||
});
|
||||
}
|
||||
@ -648,7 +650,7 @@ class ProjectTask extends AbstractModel
|
||||
public function updateTask($data, &$updateMarking = [])
|
||||
{
|
||||
//
|
||||
AbstractModel::transaction(function () use ($data, &$updateMarking) {
|
||||
ProjectTaskHandoffRecord::track($this, isset($data['flow_item_id']) ? 'flow' : 'update', function () use ($data, &$updateMarking) {
|
||||
// 主任务
|
||||
$mainTask = $this->parent_id > 0 ? self::find($this->parent_id) : null;
|
||||
// 工作流
|
||||
@ -1267,6 +1269,7 @@ class ProjectTask extends AbstractModel
|
||||
$tmp->save();
|
||||
}
|
||||
//
|
||||
ProjectTaskHandoffRecord::created($task, 'copy');
|
||||
return $task;
|
||||
});
|
||||
}
|
||||
@ -1600,7 +1603,7 @@ class ProjectTask extends AbstractModel
|
||||
*/
|
||||
public function completeTask($complete_at, $complete_name = null)
|
||||
{
|
||||
AbstractModel::transaction(function () use ($complete_at, $complete_name) {
|
||||
ProjectTaskHandoffRecord::track($this, 'complete', function () use ($complete_at, $complete_name) {
|
||||
$addMsg = $this->parent_id == 0 && $this->dialog_id > 0;
|
||||
if ($complete_at === null) {
|
||||
// 标记未完成
|
||||
@ -1672,7 +1675,7 @@ class ProjectTask extends AbstractModel
|
||||
}
|
||||
throw new ApiException('仅限【' . $flowItems . '】状态的任务归档');
|
||||
}
|
||||
AbstractModel::transaction(function () use ($isAuto, $archived_at) {
|
||||
ProjectTaskHandoffRecord::track($this, $isAuto ? 'auto_archive' : 'archive', function () use ($isAuto, $archived_at) {
|
||||
if ($archived_at === null) {
|
||||
// 还原任务栏
|
||||
if (!$this->projectColumn) {
|
||||
@ -2123,7 +2126,7 @@ class ProjectTask extends AbstractModel
|
||||
*/
|
||||
public function moveTask(int $projectId, int $columnId, int $flowItemId = 0, array $owner = [], array $assist = [], ?string $completed = null)
|
||||
{
|
||||
AbstractModel::transaction(function () use ($projectId, $columnId, $flowItemId, $owner, $assist, $completed) {
|
||||
ProjectTaskHandoffRecord::track($this, 'move', function () use ($projectId, $columnId, $flowItemId, $owner, $assist, $completed) {
|
||||
$newTaskUser = array_merge($owner, $assist);
|
||||
//
|
||||
$oldProject = Project::find($this->project_id);
|
||||
|
||||
12
app/Models/ProjectTaskHandoff.php
Normal file
12
app/Models/ProjectTaskHandoff.php
Normal file
@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class ProjectTaskHandoff extends AbstractModel
|
||||
{
|
||||
protected $table = 'project_task_handoffs';
|
||||
|
||||
protected $casts = [
|
||||
'record' => 'array',
|
||||
];
|
||||
}
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Module\ProjectTaskHandoffRecord;
|
||||
|
||||
/**
|
||||
* App\Models\ProjectTaskUser
|
||||
*
|
||||
@ -56,34 +58,42 @@ class ProjectTaskUser extends AbstractModel
|
||||
$tastIds = [];
|
||||
/** @var self $item */
|
||||
foreach ($list as $item) {
|
||||
$row = self::whereTaskId($item->task_id)->whereUserid($newUserid)->first();
|
||||
if ($row) {
|
||||
// 已存在则删除原数据,判断改变已存在的数据
|
||||
$row->owner = max($row->owner, $item->owner);
|
||||
$row->save();
|
||||
$item->delete();
|
||||
} else {
|
||||
// 不存在则改变原数据
|
||||
$item->userid = $newUserid;
|
||||
$item->save();
|
||||
}
|
||||
if ($item->projectTask) {
|
||||
$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();
|
||||
$transfer = function () use ($item, $originalUserid, $newUserid, &$tastIds) {
|
||||
$row = self::whereTaskId($item->task_id)->whereUserid($newUserid)->first();
|
||||
if ($row) {
|
||||
// 已存在则删除原数据,判断改变已存在的数据
|
||||
$row->owner = max($row->owner, $item->owner);
|
||||
$row->save();
|
||||
$item->delete();
|
||||
} else {
|
||||
// 不存在则改变原数据
|
||||
$item->userid = $newUserid;
|
||||
$item->save();
|
||||
}
|
||||
if ($item->projectTask) {
|
||||
$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();
|
||||
}
|
||||
}
|
||||
};
|
||||
$task = ProjectTask::withTrashed()->find($item->task_id);
|
||||
if ($task) {
|
||||
ProjectTaskHandoffRecord::track($task, 'transfer', $transfer);
|
||||
} else {
|
||||
$transfer();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Module\ProjectTaskHandoffRecord;
|
||||
use App\Module\Base;
|
||||
|
||||
/**
|
||||
@ -156,7 +157,13 @@ class ProjectUser extends AbstractModel
|
||||
$tastIds = [];
|
||||
/** @var ProjectTaskUser $item */
|
||||
foreach ($list as $item) {
|
||||
$item->delete();
|
||||
if ($item->projectTask) {
|
||||
ProjectTaskHandoffRecord::track($item->projectTask, 'member_exit', function () use ($item) {
|
||||
$item->delete();
|
||||
});
|
||||
} else {
|
||||
$item->delete();
|
||||
}
|
||||
if (!in_array($item->task_pid, $tastIds)) {
|
||||
$tastIds[] = $item->task_pid;
|
||||
$item->projectTask?->syncDialogUser();
|
||||
|
||||
106
app/Module/ProjectTaskHandoffRecord.php
Normal file
106
app/Module/ProjectTaskHandoffRecord.php
Normal file
@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module;
|
||||
|
||||
use App\Models\AbstractModel;
|
||||
use App\Models\ProjectTask;
|
||||
use App\Models\ProjectTaskHandoff;
|
||||
use App\Models\ProjectTaskUser;
|
||||
use App\Services\RequestContext;
|
||||
use Closure;
|
||||
|
||||
class ProjectTaskHandoffRecord
|
||||
{
|
||||
public const FIELDS = [
|
||||
'flow_item_id',
|
||||
'flow_item_name',
|
||||
'complete_at',
|
||||
'archived_at',
|
||||
];
|
||||
|
||||
public static function owners(int $taskId): array
|
||||
{
|
||||
return ProjectTaskUser::whereTaskId($taskId)
|
||||
->whereOwner(1)
|
||||
->orderBy('userid')
|
||||
->pluck('userid')
|
||||
->map(fn ($id) => (int)$id)
|
||||
->all();
|
||||
}
|
||||
|
||||
public static function snapshot(ProjectTask $task): array
|
||||
{
|
||||
$state = [];
|
||||
foreach (self::FIELDS as $field) {
|
||||
$state[$field] = $task->getRawOriginal($field);
|
||||
}
|
||||
$state['owners'] = self::owners($task->id);
|
||||
return $state;
|
||||
}
|
||||
|
||||
public static function active(int $taskId): bool
|
||||
{
|
||||
return (bool)RequestContext::get('project_task_handoff_' . $taskId, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一请求、同一任务的嵌套状态与负责人变更合并为一条流转记录。
|
||||
*/
|
||||
public static function track(ProjectTask $task, string $source, Closure $callback, string $note = '')
|
||||
{
|
||||
if (self::active($task->id)) {
|
||||
return $callback();
|
||||
}
|
||||
return AbstractModel::transaction(function () use ($task, $source, $callback, $note) {
|
||||
$locked = ProjectTask::withTrashed()->whereKey($task->id)->lockForUpdate()->firstOrFail();
|
||||
$before = self::snapshot($locked);
|
||||
$task->setRawAttributes($locked->getAttributes(), true);
|
||||
$task->unsetRelations();
|
||||
$key = 'project_task_handoff_' . $task->id;
|
||||
RequestContext::save($key, true);
|
||||
try {
|
||||
$result = $callback();
|
||||
self::write($task->id, $source, $before, self::snapshot($task->fresh()), $note);
|
||||
return $result;
|
||||
} finally {
|
||||
RequestContext::save($key, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static function created(ProjectTask $task, string $source = 'create'): void
|
||||
{
|
||||
self::write($task->id, $source, null, self::snapshot($task->fresh()));
|
||||
}
|
||||
|
||||
public static function updated(ProjectTask $task): void
|
||||
{
|
||||
if (self::active($task->id) || !$task->isDirty(self::FIELDS)) {
|
||||
return;
|
||||
}
|
||||
$before = self::snapshot($task);
|
||||
$after = $before;
|
||||
foreach (self::FIELDS as $field) {
|
||||
$after[$field] = $task->getAttributes()[$field] ?? null;
|
||||
}
|
||||
self::write($task->id, 'update', $before, $after);
|
||||
}
|
||||
|
||||
public static function write(int $taskId, string $source, ?array $before, array $after, string $note = ''): void
|
||||
{
|
||||
if ($before === $after) {
|
||||
return;
|
||||
}
|
||||
$event = ProjectTaskHandoff::createInstance([
|
||||
'task_id' => $taskId,
|
||||
'userid' => Doo::userId(),
|
||||
'source' => $source,
|
||||
]);
|
||||
$event->record = [
|
||||
'before' => $before,
|
||||
'after' => $after,
|
||||
'note' => $note,
|
||||
];
|
||||
$event->save();
|
||||
}
|
||||
}
|
||||
209
app/Module/ProjectTaskHandoffService.php
Normal file
209
app/Module/ProjectTaskHandoffService.php
Normal file
@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectPermission;
|
||||
use App\Models\ProjectTask;
|
||||
use App\Models\ProjectTaskHandoff;
|
||||
use App\Models\ProjectTaskUser;
|
||||
use App\Models\ProjectTaskVisibilityUser;
|
||||
use App\Models\ProjectUser;
|
||||
use App\Models\User;
|
||||
use App\Models\UserDepartment;
|
||||
|
||||
class ProjectTaskHandoffService
|
||||
{
|
||||
/**
|
||||
* 按任务详情的可见范围获取任务。
|
||||
*/
|
||||
public static function task(int $taskId): ProjectTask
|
||||
{
|
||||
if (ProjectTaskHandoffSettings::get()['project_task_handoff'] !== 'open') {
|
||||
throw new ApiException('任务流转未开启');
|
||||
}
|
||||
$task = ProjectTask::findForDepartmentView($taskId, null);
|
||||
$userid = Doo::userId();
|
||||
// 私密任务还需校验任务身份,不能仅凭项目成员身份访问。
|
||||
if ((int)$task->visibility !== 1) {
|
||||
$projectOwner = ProjectUser::whereProjectId($task->project_id)->whereUserid($userid)
|
||||
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])->exists();
|
||||
$participant = ProjectTaskUser::whereUserid($userid)->where(function ($query) use ($taskId) {
|
||||
$query->where('task_id', $taskId)->orWhere('task_pid', $taskId);
|
||||
})->exists();
|
||||
if (!$projectOwner && !$participant && !ProjectTaskVisibilityUser::whereTaskId($taskId)->whereUserid($userid)->exists()) {
|
||||
throw new ApiException('无任务权限');
|
||||
}
|
||||
}
|
||||
return $task;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验指派权限,计算候选人员和不可移除的负责人。
|
||||
*/
|
||||
public static function policy(ProjectTask $task): array
|
||||
{
|
||||
$settings = ProjectTaskHandoffSettings::get();
|
||||
$project = Project::find($task->project_id);
|
||||
if ($settings['project_task_handoff'] !== 'open' || !$project || $project->archived_at || $task->archived_at) {
|
||||
throw new ApiException('当前任务不可指派');
|
||||
}
|
||||
$userid = Doo::userId();
|
||||
$owners = ProjectTaskHandoffRecord::owners($task->id);
|
||||
$members = ProjectUser::whereProjectId($task->project_id)
|
||||
->pluck('userid')
|
||||
->map(fn ($id) => (int)$id)
|
||||
->all();
|
||||
$ordinary = false;
|
||||
if (in_array($userid, $members, true)) {
|
||||
$authProject = Project::userProject($project->id);
|
||||
if ($owners) {
|
||||
ProjectPermission::userTaskPermission($authProject, ProjectPermission::TASK_UPDATE, $task);
|
||||
}
|
||||
$ordinary = true;
|
||||
}
|
||||
$protected = [];
|
||||
$candidates = $members;
|
||||
if (!$ordinary) {
|
||||
$context = UserDepartment::ownerViewContext(User::auth(), true);
|
||||
if ($settings['project_task_handoff_role'] === 'close'
|
||||
|| !UserDepartment::isDepartmentReadonlyProject($context, $project->id)
|
||||
|| (int)$task->visibility !== 1) {
|
||||
throw new ApiException('无指派权限');
|
||||
}
|
||||
$departments = UserDepartment::getManagedDepartments($userid);
|
||||
if ($settings['project_task_handoff_role'] === 'owner') {
|
||||
$departments = $departments->where('owner_userid', $userid);
|
||||
}
|
||||
$selectedScope = UserDepartment::getManagedDepartmentScopeIds(
|
||||
$userid,
|
||||
request()->input('department_owner_ids', request()->input('department_ids'))
|
||||
);
|
||||
$roots = $departments->pluck('id')->map(fn ($id) => (int)$id)->all();
|
||||
$scope = $roots ? array_values(array_intersect(
|
||||
UserDepartment::getManagedDepartmentScopeIds($userid, $roots),
|
||||
$selectedScope
|
||||
)) : [];
|
||||
if (!$scope) {
|
||||
throw new ApiException('无指派权限');
|
||||
}
|
||||
$managed = User::where(function ($query) use ($scope) {
|
||||
foreach ($scope as $id) {
|
||||
$query->orWhere('department', 'like', "%,{$id},%");
|
||||
}
|
||||
})->pluck('userid')->map(fn ($id) => (int)$id)->all();
|
||||
// 项目访问权限必须来自符合指派设置的部门,不能借用其他部门的管理员身份。
|
||||
if (!array_intersect($managed, $members)) {
|
||||
throw new ApiException('无指派权限');
|
||||
}
|
||||
if ($settings['project_task_handoff_candidates'] === 'department') {
|
||||
$candidates = array_values(array_intersect($members, $managed));
|
||||
}
|
||||
if ($settings['project_task_handoff_adjust'] === 'department') {
|
||||
$protected = array_values(array_diff($owners, $managed));
|
||||
}
|
||||
}
|
||||
$candidates = User::whereIn('userid', $candidates)
|
||||
->whereNull('disable_at')
|
||||
->where('bot', 0)
|
||||
->pluck('userid')
|
||||
->map(fn ($id) => (int)$id)
|
||||
->all();
|
||||
return [
|
||||
'owners' => $owners,
|
||||
'protected' => $protected,
|
||||
'candidates' => $candidates,
|
||||
'note_required' => $settings['project_task_handoff_note'] === 'required',
|
||||
'version' => self::version($task),
|
||||
];
|
||||
}
|
||||
|
||||
public static function version(ProjectTask $task): string
|
||||
{
|
||||
return hash('sha256', json_encode([
|
||||
$task->project_id,
|
||||
ProjectTaskHandoffRecord::owners($task->id),
|
||||
ProjectTaskHandoff::where('task_id', $task->id)->max('id'),
|
||||
]));
|
||||
}
|
||||
|
||||
public static function options(ProjectTask $task): array
|
||||
{
|
||||
$policy = self::policy($task);
|
||||
$policy['users'] = User::select(['userid', 'email', 'nickname', 'userimg'])
|
||||
->whereIn('userid', array_unique(array_merge($policy['candidates'], $policy['owners'])))
|
||||
->orderBy('nickname')
|
||||
->get()
|
||||
->makeHidden(['email'])
|
||||
->toArray();
|
||||
return $policy;
|
||||
}
|
||||
|
||||
public static function validateOwners(array $owners, array $policy, string $version, string $note): array
|
||||
{
|
||||
$owners = array_values(array_unique(array_map('intval', $owners)));
|
||||
sort($owners);
|
||||
if (!hash_equals($policy['version'], $version)) {
|
||||
throw new ApiException('负责人已发生变化,请刷新后重试');
|
||||
}
|
||||
if (count($owners) > 10) {
|
||||
throw new ApiException('任务负责人最多不能超过10个');
|
||||
}
|
||||
if (array_diff($policy['protected'], $owners)) {
|
||||
throw new ApiException('不能移除管理范围外的负责人');
|
||||
}
|
||||
$added = array_diff($owners, $policy['owners']);
|
||||
if (array_diff($added, $policy['candidates'])) {
|
||||
throw new ApiException('所选负责人不在可指派范围内');
|
||||
}
|
||||
if ($owners === $policy['owners']) {
|
||||
throw new ApiException('负责人未发生变化');
|
||||
}
|
||||
if ($policy['note_required'] && $note === '') {
|
||||
throw new ApiException('请填写指派留言');
|
||||
}
|
||||
return $owners;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在同一事务中更新负责人、记录留言,并复用任务通知。
|
||||
*/
|
||||
public static function assign(ProjectTask $task, array $owners, string $version, string $note): array
|
||||
{
|
||||
return ProjectTaskHandoffRecord::track($task, 'assign', function () use ($task, $owners, $version, $note) {
|
||||
$authorized = self::task($task->id);
|
||||
$policy = self::policy($authorized);
|
||||
$owners = self::validateOwners($owners, $policy, $version, $note);
|
||||
$projectMembers = ProjectUser::whereProjectId($task->project_id)->whereIn('userid', $owners)->pluck('userid')->all();
|
||||
if (array_diff($owners, $projectMembers)) {
|
||||
throw new ApiException('所选负责人不在可指派范围内');
|
||||
}
|
||||
$marking = [];
|
||||
$task->updateTask(['owner' => $owners], $marking);
|
||||
$data = ProjectTask::oneTask($task->id)->toArray();
|
||||
$data['update_marking'] = $marking;
|
||||
$data['visibility_appointor'] = ProjectTaskVisibilityUser::whereTaskId($task->id)->pluck('userid')->all();
|
||||
$task->pushMsg('update', $data);
|
||||
$removed = array_values(array_diff($policy['owners'], $owners));
|
||||
$visibilityTasks = [$task];
|
||||
if ($task->parent_id && ($parent = ProjectTask::find($task->parent_id))) {
|
||||
$visibilityTasks[] = $parent;
|
||||
}
|
||||
foreach ($visibilityTasks as $visibleTask) {
|
||||
if (!$removed || (int)$visibleTask->visibility === 1) {
|
||||
continue;
|
||||
}
|
||||
$remaining = ProjectTaskUser::where(function ($query) use ($visibleTask) {
|
||||
$query->where('task_id', $visibleTask->id)->orWhere('task_pid', $visibleTask->id);
|
||||
})->pluck('userid')->all();
|
||||
$appointed = ProjectTaskVisibilityUser::whereTaskId($visibleTask->id)->pluck('userid')->all();
|
||||
$lostAccess = array_values(array_diff($removed, $remaining, $appointed));
|
||||
if ($lostAccess) {
|
||||
$visibleTask->pushMsgVisibleRemove($lostAccess);
|
||||
}
|
||||
}
|
||||
return $data;
|
||||
}, $note);
|
||||
}
|
||||
}
|
||||
30
app/Module/ProjectTaskHandoffSettings.php
Normal file
30
app/Module/ProjectTaskHandoffSettings.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module;
|
||||
|
||||
class ProjectTaskHandoffSettings
|
||||
{
|
||||
// 每项的第一个值为默认值,未配置的旧系统也使用该值。
|
||||
public const OPTIONS = [
|
||||
'project_task_handoff' => ['close', 'open'],
|
||||
'project_task_handoff_role' => ['owner', 'close', 'managers'],
|
||||
'project_task_handoff_candidates' => ['department', 'project'],
|
||||
'project_task_handoff_adjust' => ['department', 'all'],
|
||||
'project_task_handoff_note' => ['optional', 'required'],
|
||||
];
|
||||
|
||||
public static function normalize(array $settings): array
|
||||
{
|
||||
foreach (self::OPTIONS as $key => $options) {
|
||||
if (!in_array($settings[$key] ?? null, $options, true)) {
|
||||
$settings[$key] = $options[0];
|
||||
}
|
||||
}
|
||||
return $settings;
|
||||
}
|
||||
|
||||
public static function get(): array
|
||||
{
|
||||
return self::normalize(Base::setting('system'));
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Module\ProjectTaskHandoffRecord;
|
||||
use App\Models\Deleted;
|
||||
use App\Models\ProjectTask;
|
||||
use App\Models\ProjectTaskUser;
|
||||
@ -30,6 +31,7 @@ class ProjectTaskObserver extends AbstractObserver
|
||||
*/
|
||||
public function updated(ProjectTask $projectTask)
|
||||
{
|
||||
ProjectTaskHandoffRecord::updated($projectTask);
|
||||
if ($projectTask->isDirty('visibility')) {
|
||||
self::visibilityUpdate($projectTask);
|
||||
}
|
||||
|
||||
@ -46,7 +46,7 @@ class RequestContext
|
||||
|
||||
// 尝试从当前请求获取
|
||||
$request = request();
|
||||
if ($request && method_exists($request, 'attributes') && $request->attributes) {
|
||||
if ($request && isset($request->attributes)) {
|
||||
if (!$request->attributes->has(static::CONTEXT_KEY)) {
|
||||
$request->attributes->set(static::CONTEXT_KEY, self::generateRequestId());
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Tasks;
|
||||
|
||||
use App\Module\ProjectTaskHandoffRecord;
|
||||
use App\Models\ProjectFlow;
|
||||
use App\Models\ProjectFlowItem;
|
||||
use App\Models\ProjectTask;
|
||||
@ -35,46 +36,48 @@ class LoopTask extends AbstractTask
|
||||
}
|
||||
try {
|
||||
$task = $item->copyTask();
|
||||
// 工作流
|
||||
$projectFlow = ProjectFlow::whereProjectId($task->project_id)->orderByDesc('id')->first();
|
||||
if ($projectFlow) {
|
||||
$projectFlowItem = ProjectFlowItem::whereFlowId($projectFlow->id)->orderBy('sort')->get();
|
||||
// 赋一个开始状态
|
||||
foreach ($projectFlowItem as $flowItem) {
|
||||
if ($flowItem->status == 'start') {
|
||||
$task->flow_item_id = $flowItem->id;
|
||||
$task->flow_item_name = $flowItem->status . "|" . $flowItem->name . "|" . $flowItem->color;
|
||||
if ($flowItem->userids) {
|
||||
$userids = array_values(array_unique($flowItem->userids));
|
||||
foreach ($userids as $uid) {
|
||||
ProjectTaskUser::updateInsert([
|
||||
'task_id' => $task->id,
|
||||
'userid' => $uid,
|
||||
], [
|
||||
'project_id' => $task->project_id,
|
||||
'task_pid' => $task->id,
|
||||
'owner' => 1,
|
||||
]);
|
||||
ProjectTaskHandoffRecord::track($task, 'recurring', function () use ($task, $item) {
|
||||
// 工作流
|
||||
$projectFlow = ProjectFlow::whereProjectId($task->project_id)->orderByDesc('id')->first();
|
||||
if ($projectFlow) {
|
||||
$projectFlowItem = ProjectFlowItem::whereFlowId($projectFlow->id)->orderBy('sort')->get();
|
||||
// 赋一个开始状态
|
||||
foreach ($projectFlowItem as $flowItem) {
|
||||
if ($flowItem->status == 'start') {
|
||||
$task->flow_item_id = $flowItem->id;
|
||||
$task->flow_item_name = $flowItem->status . "|" . $flowItem->name . "|" . $flowItem->color;
|
||||
if ($flowItem->userids) {
|
||||
$userids = array_values(array_unique($flowItem->userids));
|
||||
foreach ($userids as $uid) {
|
||||
ProjectTaskUser::updateInsert([
|
||||
'task_id' => $task->id,
|
||||
'userid' => $uid,
|
||||
], [
|
||||
'project_id' => $task->project_id,
|
||||
'task_pid' => $task->id,
|
||||
'owner' => 1,
|
||||
]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 新任务时间、周期
|
||||
if ($task->start_at) {
|
||||
$diffSecond = (int)Carbon::parse($task->start_at)->diffInSeconds(Carbon::parse($task->end_at), true);
|
||||
$task->start_at = Carbon::parse($task->loop_at);
|
||||
$task->end_at = $task->start_at->clone()->addSeconds($diffSecond);
|
||||
}
|
||||
// 处理子任务
|
||||
$item->copySubTasks($task, [
|
||||
'reset_complete' => true,
|
||||
'sync_time' => true,
|
||||
]);
|
||||
//
|
||||
$task->refreshLoop(true);
|
||||
$task->addLog("创建任务来自周期任务ID:{$item->id}", [], $task->userid);
|
||||
// 新任务时间、周期
|
||||
if ($task->start_at) {
|
||||
$diffSecond = (int)Carbon::parse($task->start_at)->diffInSeconds(Carbon::parse($task->end_at), true);
|
||||
$task->start_at = Carbon::parse($task->loop_at);
|
||||
$task->end_at = $task->start_at->clone()->addSeconds($diffSecond);
|
||||
}
|
||||
// 处理子任务
|
||||
$item->copySubTasks($task, [
|
||||
'reset_complete' => true,
|
||||
'sync_time' => true,
|
||||
]);
|
||||
//
|
||||
$task->refreshLoop(true);
|
||||
$task->addLog("创建任务来自周期任务ID:{$item->id}", [], $task->userid);
|
||||
});
|
||||
// 清空旧周期
|
||||
$item->loop = '';
|
||||
$item->loop_at = null;
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
<?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_handoffs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedInteger('task_id');
|
||||
$table->unsignedInteger('userid')->default(0);
|
||||
$table->string('source', 32);
|
||||
$table->json('record');
|
||||
$table->timestamps();
|
||||
$table->index(['task_id', 'id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('project_task_handoffs');
|
||||
}
|
||||
};
|
||||
@ -1069,3 +1069,15 @@ WebDAV 未启用或你没有使用权限
|
||||
目标不是文件夹
|
||||
请输入设备名称
|
||||
复制的文件和文件夹数量超过限制
|
||||
任务流转未开启
|
||||
当前任务不可指派
|
||||
无指派权限
|
||||
负责人已发生变化,请刷新后重试
|
||||
不能移除管理范围外的负责人
|
||||
所选负责人不在可指派范围内
|
||||
负责人未发生变化
|
||||
请填写指派留言
|
||||
指派参数无效
|
||||
流转设置选项无效
|
||||
指派成功
|
||||
任务负责人最多不能超过10个
|
||||
|
||||
@ -2747,3 +2747,38 @@ WebDAV 路径冲突
|
||||
下周排期
|
||||
已逾期
|
||||
未排期
|
||||
保留
|
||||
新增
|
||||
移除
|
||||
无
|
||||
标记已完成
|
||||
任务归档
|
||||
任务取消归档
|
||||
指派负责人
|
||||
状态流转
|
||||
移交任务身份
|
||||
重复任务
|
||||
任务自动归档
|
||||
流转记录
|
||||
暂无流转记录
|
||||
历史动态
|
||||
保留负责人
|
||||
管理范围外,不可移除
|
||||
指派留言
|
||||
流转
|
||||
流转设置
|
||||
任务流转
|
||||
部门视角指派权限
|
||||
仅部门负责人
|
||||
部门负责人及部门管理员
|
||||
可指派人员范围
|
||||
管理部门范围内的项目成员
|
||||
项目内全部成员
|
||||
原负责人调整范围
|
||||
仅调整管理范围内负责人
|
||||
允许调整全部负责人
|
||||
指派留言要求
|
||||
指派成功
|
||||
负责人未发生变化
|
||||
请填写指派留言
|
||||
任务负责人最多不能超过10个
|
||||
|
||||
@ -1,4 +1,472 @@
|
||||
[
|
||||
{
|
||||
"key": "保留",
|
||||
"zh": "",
|
||||
"zh-CHT": "保留",
|
||||
"en": "Keep",
|
||||
"ko": "유지",
|
||||
"ja": "保持",
|
||||
"de": "Beibehalten",
|
||||
"fr": "Conserver",
|
||||
"id": "Pertahankan",
|
||||
"ru": "Сохранить"
|
||||
},
|
||||
{
|
||||
"key": "新增",
|
||||
"zh": "",
|
||||
"zh-CHT": "新增",
|
||||
"en": "Add",
|
||||
"ko": "추가",
|
||||
"ja": "追加",
|
||||
"de": "Hinzufügen",
|
||||
"fr": "Ajouter",
|
||||
"id": "Tambah",
|
||||
"ru": "Добавить"
|
||||
},
|
||||
{
|
||||
"key": "移除",
|
||||
"zh": "",
|
||||
"zh-CHT": "移除",
|
||||
"en": "Remove",
|
||||
"ko": "제거",
|
||||
"ja": "削除",
|
||||
"de": "Entfernen",
|
||||
"fr": "Retirer",
|
||||
"id": "Hapus",
|
||||
"ru": "Удалить"
|
||||
},
|
||||
{
|
||||
"key": "无",
|
||||
"zh": "",
|
||||
"zh-CHT": "無",
|
||||
"en": "None",
|
||||
"ko": "없음",
|
||||
"ja": "なし",
|
||||
"de": "Keine",
|
||||
"fr": "Aucun",
|
||||
"id": "Tidak ada",
|
||||
"ru": "Нет"
|
||||
},
|
||||
{
|
||||
"key": "标记已完成",
|
||||
"zh": "",
|
||||
"zh-CHT": "標記已完成",
|
||||
"en": "Mark complete",
|
||||
"ko": "완료로 표시",
|
||||
"ja": "完了にする",
|
||||
"de": "Als erledigt markieren",
|
||||
"fr": "Marquer comme terminé",
|
||||
"id": "Tandai selesai",
|
||||
"ru": "Отметить выполненной"
|
||||
},
|
||||
{
|
||||
"key": "指派负责人",
|
||||
"zh": "",
|
||||
"zh-CHT": "指派負責人",
|
||||
"en": "Assign owners",
|
||||
"ko": "담당자 지정",
|
||||
"ja": "担当者を割り当て",
|
||||
"de": "Verantwortliche zuweisen",
|
||||
"fr": "Attribuer des responsables",
|
||||
"id": "Tetapkan penanggung jawab",
|
||||
"ru": "Назначить ответственных"
|
||||
},
|
||||
{
|
||||
"key": "状态流转",
|
||||
"zh": "",
|
||||
"zh-CHT": "狀態流轉",
|
||||
"en": "Status transition",
|
||||
"ko": "상태 전환",
|
||||
"ja": "ステータス遷移",
|
||||
"de": "Statuswechsel",
|
||||
"fr": "Transition de statut",
|
||||
"id": "Perubahan status",
|
||||
"ru": "Переход статуса"
|
||||
},
|
||||
{
|
||||
"key": "重复任务",
|
||||
"zh": "",
|
||||
"zh-CHT": "重複任務",
|
||||
"en": "Recurring task",
|
||||
"ko": "반복 작업",
|
||||
"ja": "繰り返しタスク",
|
||||
"de": "Wiederkehrende Aufgabe",
|
||||
"fr": "Tâche récurrente",
|
||||
"id": "Tugas berulang",
|
||||
"ru": "Повторяющаяся задача"
|
||||
},
|
||||
{
|
||||
"key": "流转记录",
|
||||
"zh": "",
|
||||
"zh-CHT": "流轉記錄",
|
||||
"en": "Handoff history",
|
||||
"ko": "인계 기록",
|
||||
"ja": "引き継ぎ履歴",
|
||||
"de": "Übergabeverlauf",
|
||||
"fr": "Historique des transmissions",
|
||||
"id": "Riwayat serah terima",
|
||||
"ru": "История передачи"
|
||||
},
|
||||
{
|
||||
"key": "暂无流转记录",
|
||||
"zh": "",
|
||||
"zh-CHT": "暫無流轉記錄",
|
||||
"en": "No handoff records",
|
||||
"ko": "인계 기록 없음",
|
||||
"ja": "引き継ぎ履歴はありません",
|
||||
"de": "Keine Übergaben vorhanden",
|
||||
"fr": "Aucune transmission",
|
||||
"id": "Belum ada riwayat serah terima",
|
||||
"ru": "Записей о передаче пока нет"
|
||||
},
|
||||
{
|
||||
"key": "历史动态",
|
||||
"zh": "",
|
||||
"zh-CHT": "歷史動態",
|
||||
"en": "Past activity",
|
||||
"ko": "이전 활동",
|
||||
"ja": "過去のアクティビティ",
|
||||
"de": "Bisherige Aktivitäten",
|
||||
"fr": "Activité passée",
|
||||
"id": "Aktivitas sebelumnya",
|
||||
"ru": "Предыдущие действия"
|
||||
},
|
||||
{
|
||||
"key": "保留负责人",
|
||||
"zh": "",
|
||||
"zh-CHT": "保留負責人",
|
||||
"en": "Retained owners",
|
||||
"ko": "유지되는 담당자",
|
||||
"ja": "継続する担当者",
|
||||
"de": "Beibehaltene Verantwortliche",
|
||||
"fr": "Responsables conservés",
|
||||
"id": "Penanggung jawab dipertahankan",
|
||||
"ru": "Сохраняемые ответственные"
|
||||
},
|
||||
{
|
||||
"key": "管理范围外,不可移除",
|
||||
"zh": "",
|
||||
"zh-CHT": "管理範圍外,不可移除",
|
||||
"en": "Outside your management scope; cannot be removed",
|
||||
"ko": "관리 범위 밖이므로 제거할 수 없습니다",
|
||||
"ja": "管理範囲外のため削除できません",
|
||||
"de": "Außerhalb Ihres Zuständigkeitsbereichs; nicht entfernbar",
|
||||
"fr": "Hors de votre périmètre de gestion ; retrait impossible",
|
||||
"id": "Di luar cakupan pengelolaan; tidak dapat dihapus",
|
||||
"ru": "Вне вашей области управления; удаление запрещено"
|
||||
},
|
||||
{
|
||||
"key": "指派留言",
|
||||
"zh": "",
|
||||
"zh-CHT": "指派留言",
|
||||
"en": "Assignment note",
|
||||
"ko": "배정 메시지",
|
||||
"ja": "割り当てメモ",
|
||||
"de": "Zuweisungsnotiz",
|
||||
"fr": "Note d’attribution",
|
||||
"id": "Catatan penugasan",
|
||||
"ru": "Комментарий к назначению"
|
||||
},
|
||||
{
|
||||
"key": "流转",
|
||||
"zh": "",
|
||||
"zh-CHT": "流轉",
|
||||
"en": "Handoffs",
|
||||
"ko": "인계",
|
||||
"ja": "引き継ぎ",
|
||||
"de": "Übergaben",
|
||||
"fr": "Transmissions",
|
||||
"id": "Serah terima",
|
||||
"ru": "Передачи"
|
||||
},
|
||||
{
|
||||
"key": "流转设置",
|
||||
"zh": "",
|
||||
"zh-CHT": "流轉設定",
|
||||
"en": "Handoff settings",
|
||||
"ko": "인계 설정",
|
||||
"ja": "引き継ぎ設定",
|
||||
"de": "Übergabeeinstellungen",
|
||||
"fr": "Paramètres de transmission",
|
||||
"id": "Pengaturan serah terima",
|
||||
"ru": "Настройки передачи"
|
||||
},
|
||||
{
|
||||
"key": "任务流转",
|
||||
"zh": "",
|
||||
"zh-CHT": "任務流轉",
|
||||
"en": "Task handoffs",
|
||||
"ko": "작업 인계",
|
||||
"ja": "タスクの引き継ぎ",
|
||||
"de": "Aufgabenübergaben",
|
||||
"fr": "Transmission des tâches",
|
||||
"id": "Serah terima tugas",
|
||||
"ru": "Передача задач"
|
||||
},
|
||||
{
|
||||
"key": "部门视角指派权限",
|
||||
"zh": "",
|
||||
"zh-CHT": "部門視角指派權限",
|
||||
"en": "Assignment permission in department view",
|
||||
"ko": "부서 보기 배정 권한",
|
||||
"ja": "部門ビューでの割り当て権限",
|
||||
"de": "Zuweisungsrecht in der Abteilungsansicht",
|
||||
"fr": "Droit d’attribution en vue département",
|
||||
"id": "Izin penugasan pada tampilan departemen",
|
||||
"ru": "Право назначения в режиме отдела"
|
||||
},
|
||||
{
|
||||
"key": "仅部门负责人",
|
||||
"zh": "",
|
||||
"zh-CHT": "僅部門負責人",
|
||||
"en": "Department heads only",
|
||||
"ko": "부서 책임자만",
|
||||
"ja": "部門責任者のみ",
|
||||
"de": "Nur Abteilungsleiter",
|
||||
"fr": "Responsables de département uniquement",
|
||||
"id": "Hanya kepala departemen",
|
||||
"ru": "Только руководители отделов"
|
||||
},
|
||||
{
|
||||
"key": "部门负责人及部门管理员",
|
||||
"zh": "",
|
||||
"zh-CHT": "部門負責人及部門管理員",
|
||||
"en": "Department heads and administrators",
|
||||
"ko": "부서 책임자 및 관리자",
|
||||
"ja": "部門責任者と部門管理者",
|
||||
"de": "Abteilungsleiter und -administratoren",
|
||||
"fr": "Responsables et administrateurs de département",
|
||||
"id": "Kepala dan administrator departemen",
|
||||
"ru": "Руководители и администраторы отделов"
|
||||
},
|
||||
{
|
||||
"key": "可指派人员范围",
|
||||
"zh": "",
|
||||
"zh-CHT": "可指派人員範圍",
|
||||
"en": "Assignable people",
|
||||
"ko": "배정 가능한 인원 범위",
|
||||
"ja": "割り当て可能なメンバー",
|
||||
"de": "Zuweisbare Personen",
|
||||
"fr": "Personnes pouvant être désignées",
|
||||
"id": "Orang yang dapat ditugaskan",
|
||||
"ru": "Круг назначаемых сотрудников"
|
||||
},
|
||||
{
|
||||
"key": "管理部门范围内的项目成员",
|
||||
"zh": "",
|
||||
"zh-CHT": "管理部門範圍內的專案成員",
|
||||
"en": "Project members in managed departments",
|
||||
"ko": "관리 부서 내 프로젝트 구성원",
|
||||
"ja": "管理対象部門のプロジェクトメンバー",
|
||||
"de": "Projektmitglieder aus verwalteten Abteilungen",
|
||||
"fr": "Membres du projet des départements gérés",
|
||||
"id": "Anggota proyek di departemen yang dikelola",
|
||||
"ru": "Участники проекта из управляемых отделов"
|
||||
},
|
||||
{
|
||||
"key": "项目内全部成员",
|
||||
"zh": "",
|
||||
"zh-CHT": "專案內全部成員",
|
||||
"en": "All project members",
|
||||
"ko": "모든 프로젝트 구성원",
|
||||
"ja": "プロジェクトの全メンバー",
|
||||
"de": "Alle Projektmitglieder",
|
||||
"fr": "Tous les membres du projet",
|
||||
"id": "Semua anggota proyek",
|
||||
"ru": "Все участники проекта"
|
||||
},
|
||||
{
|
||||
"key": "原负责人调整范围",
|
||||
"zh": "",
|
||||
"zh-CHT": "原負責人調整範圍",
|
||||
"en": "Existing owner adjustment scope",
|
||||
"ko": "기존 담당자 변경 범위",
|
||||
"ja": "既存担当者の変更範囲",
|
||||
"de": "Änderungsbereich bestehender Verantwortlicher",
|
||||
"fr": "Périmètre de modification des responsables actuels",
|
||||
"id": "Cakupan perubahan penanggung jawab saat ini",
|
||||
"ru": "Область изменения текущих ответственных"
|
||||
},
|
||||
{
|
||||
"key": "仅调整管理范围内负责人",
|
||||
"zh": "",
|
||||
"zh-CHT": "僅調整管理範圍內負責人",
|
||||
"en": "Only change owners within management scope",
|
||||
"ko": "관리 범위 내 담당자만 변경",
|
||||
"ja": "管理範囲内の担当者のみ変更",
|
||||
"de": "Nur Verantwortliche im Zuständigkeitsbereich ändern",
|
||||
"fr": "Modifier uniquement les responsables du périmètre géré",
|
||||
"id": "Hanya ubah penanggung jawab dalam cakupan pengelolaan",
|
||||
"ru": "Изменять только ответственных в области управления"
|
||||
},
|
||||
{
|
||||
"key": "允许调整全部负责人",
|
||||
"zh": "",
|
||||
"zh-CHT": "允許調整全部負責人",
|
||||
"en": "Allow changing all owners",
|
||||
"ko": "모든 담당자 변경 허용",
|
||||
"ja": "すべての担当者の変更を許可",
|
||||
"de": "Änderung aller Verantwortlichen erlauben",
|
||||
"fr": "Autoriser la modification de tous les responsables",
|
||||
"id": "Izinkan perubahan semua penanggung jawab",
|
||||
"ru": "Разрешить изменение всех ответственных"
|
||||
},
|
||||
{
|
||||
"key": "指派留言要求",
|
||||
"zh": "",
|
||||
"zh-CHT": "指派留言要求",
|
||||
"en": "Assignment note requirement",
|
||||
"ko": "배정 메시지 필수 여부",
|
||||
"ja": "割り当てメモの必須設定",
|
||||
"de": "Pflicht für Zuweisungsnotizen",
|
||||
"fr": "Exigence de note d’attribution",
|
||||
"id": "Persyaratan catatan penugasan",
|
||||
"ru": "Обязательность комментария к назначению"
|
||||
},
|
||||
{
|
||||
"key": "指派成功",
|
||||
"zh": "",
|
||||
"zh-CHT": "指派成功",
|
||||
"en": "Assignment saved",
|
||||
"ko": "배정 완료",
|
||||
"ja": "割り当てました",
|
||||
"de": "Zuweisung gespeichert",
|
||||
"fr": "Attribution enregistrée",
|
||||
"id": "Penugasan berhasil",
|
||||
"ru": "Назначение сохранено"
|
||||
},
|
||||
{
|
||||
"key": "负责人未发生变化",
|
||||
"zh": "",
|
||||
"zh-CHT": "負責人未發生變化",
|
||||
"en": "Owners have not changed",
|
||||
"ko": "담당자가 변경되지 않았습니다",
|
||||
"ja": "担当者は変更されていません",
|
||||
"de": "Verantwortliche wurden nicht geändert",
|
||||
"fr": "Les responsables n’ont pas changé",
|
||||
"id": "Penanggung jawab tidak berubah",
|
||||
"ru": "Ответственные не изменились"
|
||||
},
|
||||
{
|
||||
"key": "请填写指派留言",
|
||||
"zh": "",
|
||||
"zh-CHT": "請填寫指派留言",
|
||||
"en": "Enter an assignment note",
|
||||
"ko": "배정 메시지를 입력하세요",
|
||||
"ja": "割り当てメモを入力してください",
|
||||
"de": "Bitte eine Zuweisungsnotiz eingeben",
|
||||
"fr": "Saisissez une note d’attribution",
|
||||
"id": "Masukkan catatan penugasan",
|
||||
"ru": "Введите комментарий к назначению"
|
||||
},
|
||||
{
|
||||
"key": "任务负责人最多不能超过10个",
|
||||
"zh": "",
|
||||
"zh-CHT": "任務負責人最多不能超過10個",
|
||||
"en": "A task can have at most 10 owners",
|
||||
"ko": "작업 담당자는 최대 10명입니다",
|
||||
"ja": "タスクの担当者は最大10人です",
|
||||
"de": "Eine Aufgabe darf höchstens 10 Verantwortliche haben",
|
||||
"fr": "Une tâche peut avoir au maximum 10 responsables",
|
||||
"id": "Tugas dapat memiliki maksimal 10 penanggung jawab",
|
||||
"ru": "У задачи может быть не более 10 ответственных"
|
||||
},
|
||||
{
|
||||
"key": "任务流转未开启",
|
||||
"zh": "",
|
||||
"zh-CHT": "任務流轉未開啟",
|
||||
"en": "Task handoffs are disabled",
|
||||
"ko": "작업 인계가 비활성화되어 있습니다",
|
||||
"ja": "タスクの引き継ぎは無効です",
|
||||
"de": "Aufgabenübergaben sind deaktiviert",
|
||||
"fr": "La transmission des tâches est désactivée",
|
||||
"id": "Serah terima tugas dinonaktifkan",
|
||||
"ru": "Передача задач отключена"
|
||||
},
|
||||
{
|
||||
"key": "当前任务不可指派",
|
||||
"zh": "",
|
||||
"zh-CHT": "目前任務不可指派",
|
||||
"en": "This task cannot be assigned",
|
||||
"ko": "이 작업은 배정할 수 없습니다",
|
||||
"ja": "このタスクは割り当てできません",
|
||||
"de": "Diese Aufgabe kann nicht zugewiesen werden",
|
||||
"fr": "Cette tâche ne peut pas être attribuée",
|
||||
"id": "Tugas ini tidak dapat ditugaskan",
|
||||
"ru": "Эту задачу нельзя назначить"
|
||||
},
|
||||
{
|
||||
"key": "无指派权限",
|
||||
"zh": "",
|
||||
"zh-CHT": "無指派權限",
|
||||
"en": "No assignment permission",
|
||||
"ko": "배정 권한이 없습니다",
|
||||
"ja": "割り当て権限がありません",
|
||||
"de": "Keine Zuweisungsberechtigung",
|
||||
"fr": "Aucun droit d’attribution",
|
||||
"id": "Tidak memiliki izin penugasan",
|
||||
"ru": "Нет прав на назначение"
|
||||
},
|
||||
{
|
||||
"key": "负责人已发生变化,请刷新后重试",
|
||||
"zh": "",
|
||||
"zh-CHT": "負責人已發生變化,請重新整理後重試",
|
||||
"en": "Owners have changed. Refresh and try again",
|
||||
"ko": "담당자가 변경되었습니다. 새로고침 후 다시 시도하세요",
|
||||
"ja": "担当者が変更されました。更新して再試行してください",
|
||||
"de": "Verantwortliche wurden geändert. Bitte aktualisieren und erneut versuchen",
|
||||
"fr": "Les responsables ont changé. Actualisez et réessayez",
|
||||
"id": "Penanggung jawab telah berubah. Muat ulang dan coba lagi",
|
||||
"ru": "Ответственные изменились. Обновите данные и повторите попытку"
|
||||
},
|
||||
{
|
||||
"key": "不能移除管理范围外的负责人",
|
||||
"zh": "",
|
||||
"zh-CHT": "不能移除管理範圍外的負責人",
|
||||
"en": "Cannot remove owners outside management scope",
|
||||
"ko": "관리 범위 밖의 담당자는 제거할 수 없습니다",
|
||||
"ja": "管理範囲外の担当者は削除できません",
|
||||
"de": "Verantwortliche außerhalb des Zuständigkeitsbereichs können nicht entfernt werden",
|
||||
"fr": "Impossible de retirer les responsables hors du périmètre géré",
|
||||
"id": "Tidak dapat menghapus penanggung jawab di luar cakupan pengelolaan",
|
||||
"ru": "Нельзя удалять ответственных вне области управления"
|
||||
},
|
||||
{
|
||||
"key": "所选负责人不在可指派范围内",
|
||||
"zh": "",
|
||||
"zh-CHT": "所選負責人不在可指派範圍內",
|
||||
"en": "Selected owners are outside the assignable scope",
|
||||
"ko": "선택한 담당자가 배정 가능한 범위 밖에 있습니다",
|
||||
"ja": "選択した担当者は割り当て可能な範囲外です",
|
||||
"de": "Ausgewählte Verantwortliche liegen außerhalb des zulässigen Bereichs",
|
||||
"fr": "Les responsables sélectionnés sont hors du périmètre autorisé",
|
||||
"id": "Penanggung jawab yang dipilih di luar cakupan penugasan",
|
||||
"ru": "Выбранные ответственные вне допустимого круга назначения"
|
||||
},
|
||||
{
|
||||
"key": "指派参数无效",
|
||||
"zh": "",
|
||||
"zh-CHT": "指派參數無效",
|
||||
"en": "Invalid assignment parameters",
|
||||
"ko": "배정 매개변수가 유효하지 않습니다",
|
||||
"ja": "割り当てパラメータが無効です",
|
||||
"de": "Ungültige Zuweisungsparameter",
|
||||
"fr": "Paramètres d’attribution invalides",
|
||||
"id": "Parameter penugasan tidak valid",
|
||||
"ru": "Недопустимые параметры назначения"
|
||||
},
|
||||
{
|
||||
"key": "流转设置选项无效",
|
||||
"zh": "",
|
||||
"zh-CHT": "流轉設定選項無效",
|
||||
"en": "Invalid handoff setting",
|
||||
"ko": "인계 설정 옵션이 유효하지 않습니다",
|
||||
"ja": "引き継ぎ設定の選択肢が無効です",
|
||||
"de": "Ungültige Übergabeeinstellung",
|
||||
"fr": "Option de transmission invalide",
|
||||
"id": "Opsi pengaturan serah terima tidak valid",
|
||||
"ru": "Недопустимый параметр передачи"
|
||||
},
|
||||
{
|
||||
"key": "(%T1)是一款轻量级的开源在线项目任务管理工具,提供各类文档协作工具、在线思维导图、在线流程图、项目管理、任务分发、即时IM,文件管理等工具。",
|
||||
"zh": "",
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1 +1 @@
|
||||
["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","",""]
|
||||
["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","",""]
|
||||
2
public/language/web/de.js
vendored
2
public/language/web/de.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/en.js
vendored
2
public/language/web/en.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/fr.js
vendored
2
public/language/web/fr.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/id.js
vendored
2
public/language/web/id.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/ja.js
vendored
2
public/language/web/ja.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/key.js
vendored
2
public/language/web/key.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/ko.js
vendored
2
public/language/web/ko.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/ru.js
vendored
2
public/language/web/ru.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/zh-CHT.js
vendored
2
public/language/web/zh-CHT.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/zh.js
vendored
2
public/language/web/zh.js
vendored
File diff suppressed because one or more lines are too long
@ -211,6 +211,7 @@ features:
|
||||
- task.field.description.concept
|
||||
- task.related.concept
|
||||
- task.flow.concept
|
||||
- task.handoff.howto
|
||||
- task.dialog.concept
|
||||
- task.notify.concept
|
||||
|
||||
@ -584,6 +585,7 @@ features:
|
||||
- system-setting.webdav.howto
|
||||
- system-setting.video-process.howto
|
||||
- system-setting.task-visibility.howto
|
||||
- system-setting.handoff.howto
|
||||
- system-setting.chat-mute.howto
|
||||
- system-setting.todo-permission.howto
|
||||
- system-setting.entry.menu-map
|
||||
|
||||
@ -20,9 +20,9 @@ prerequisites: []
|
||||
negative:
|
||||
- 任务负责人可以是多人(不是只能一个)
|
||||
- 协助人没有改状态 / 删任务的权限,只能改内容和时间
|
||||
- 设置了可见用户的任务,非名单内的项目成员看不到,连项目负责人不在名单也看不到
|
||||
- 私密任务不会因为部门负责人身份而自动可见
|
||||
- 子任务的可见用户继承父任务,不能单独配置
|
||||
last_verified: v1.7.90
|
||||
last_verified: v1.9.18
|
||||
---
|
||||
|
||||
# 任务角色(负责人 / 协助人 / 可见用户)
|
||||
@ -46,14 +46,16 @@ last_verified: v1.7.90
|
||||
|
||||
## 可见用户(visibility)规则
|
||||
- **未设置时**:所有项目成员都可见
|
||||
- **设置后**:只有名单内的用户可见,名单外(包括项目负责人)都看不到
|
||||
- **设置后**:任务相关人员和指定可见用户可见,项目负责人及项目管理员保留可见权限
|
||||
- 设了可见用户的任务才在搜索 / 列表 / 看板里对名单内用户出现
|
||||
- 子任务的可见性继承父任务(不能单独设置)
|
||||
|
||||
## 与项目角色的关系
|
||||
任务角色不依赖项目角色:
|
||||
- 项目成员可以被任命为任务负责人,从而具备改状态 / 删任务的权限
|
||||
- 项目负责人若不在任务可见用户名单内,也看不到这条任务
|
||||
- 部门负责人身份不等于项目负责人身份,不能绕过私密任务的可见范围
|
||||
|
||||
## 系统级身份的影响
|
||||
系统管理员(admin identity)不会自动获得任意任务的权限。详见 [[role-permission.admin.concept]] 和 [[role-permission.project-role.concept]]。
|
||||
|
||||
任务流转默认关闭。开启且授权后,部门负责人(可选含部门管理员)能在已有负责人视角可见的任务内专门指派负责人并附带留言,不获得其他字段编辑权限,也不能因此查看私密任务。默认只能调整管理范围内的负责人,候选人必须同时是管理范围内的项目成员;原有项目角色权限不受此限制。见 [[task.handoff.howto]]。
|
||||
|
||||
@ -15,7 +15,7 @@ related_tools: [update_task, get_task]
|
||||
related_pages: [task_detail]
|
||||
prerequisites: []
|
||||
negative:
|
||||
- 任务暂时没有负责人时只能自己领取,不能指定其他项目成员
|
||||
- 指派候选人必须是项目成员
|
||||
- 协作者不能完成任务,只有负责人能(或有 TASK_UPDATE 权限的项目角色)
|
||||
- 负责人 / 协作者不会自动获得评论权限以外的项目权限
|
||||
last_verified: v1.9.18
|
||||
@ -34,6 +34,7 @@ DooTask 通过 `ProjectTaskUser` 表维护任务参与人:每条记录 `userid
|
||||
- 已有负责人:打开任务详情,在「负责人」字段中增删项目成员
|
||||
- 暂无负责人:既可以通过顶部「领取任务」将自己设为负责人,也可以在详情底部的「添加」菜单选择「负责人」,再指定项目成员
|
||||
- 「负责人」和「协助人员」未设置时都收纳在详情底部的统一「添加」菜单中,设置后显示为独立字段
|
||||
- 系统开启任务流转后,有权限的用户也可在「流转」中指派负责人并附带留言;部门视角使用单独的授权及人员范围,见 [[task.handoff.howto]]。
|
||||
|
||||
## 权限差异
|
||||
|
||||
@ -47,8 +48,8 @@ DooTask 通过 `ProjectTaskUser` 表维护任务参与人:每条记录 `userid
|
||||
| 出现在「我的任务」列表 | ✓ | ✓ |
|
||||
|
||||
## 关系
|
||||
- 添加负责人 / 协作者只能从「项目成员」中选;不在项目的用户会被服务端自动加入项目
|
||||
- 即使用户离开了项目,已分配的任务关系保留(任务详情页仍显示)
|
||||
- 添加负责人 / 协作者只能从「项目成员」中选;不会因此自动加入项目
|
||||
- 用户退出项目时会移除对应任务身份,流转记录保留实际负责人变化
|
||||
- 删除用户时,名下任务的 owner 会被转给操作人或项目负责人
|
||||
|
||||
## 与可见性的关系
|
||||
|
||||
@ -18,11 +18,11 @@ prerequisites:
|
||||
- 当前用户是某部门的部门负责人或部门管理员
|
||||
- 系统管理员未关闭部门负责人视角(新安装默认开启)
|
||||
negative:
|
||||
- 项目负责人视角只能只读查看额外项目,不授予修改权限
|
||||
- 项目负责人视角默认只读;任务流转开启并授权时可专门指派负责人,不开放其他编辑权限
|
||||
- 项目可单独关闭负责人视角可见性,关闭后不会被纳入
|
||||
- 没有选择任何部门时不能保存范围
|
||||
- 部门范围和项目列表开关保存在当前浏览器,不跨设备同步
|
||||
last_verified: v1.8.69
|
||||
last_verified: v1.9.18
|
||||
---
|
||||
|
||||
# 设置项目负责人视角和共用部门范围
|
||||
@ -47,6 +47,6 @@ last_verified: v1.8.69
|
||||
## 可见性限制
|
||||
- 仅包含未归档且允许负责人视角查看的项目。
|
||||
- 私密任务不会因为负责人身份自动可见。
|
||||
- 通过负责人视角看到的项目和任务不允许修改。
|
||||
- 通过负责人视角看到的项目和任务默认不允许修改。系统开启任务流转并授权后,可在任务「流转」中指派负责人并附带留言,其他编辑权限不变,详见 [[task.handoff.howto]]。
|
||||
|
||||
团队统计范围见 [[dashboard.team-scope.howto]]。
|
||||
|
||||
43
resources/ai-kb/zh/howto/system-setting/handoff.md
Normal file
43
resources/ai-kb/zh/howto/system-setting/handoff.md
Normal file
@ -0,0 +1,43 @@
|
||||
---
|
||||
id: system-setting.handoff.howto
|
||||
title: 设置任务流转与部门指派权限
|
||||
type: howto
|
||||
feature: system-setting
|
||||
scope: admin
|
||||
locale: zh
|
||||
aliases:
|
||||
- 怎么开启任务流转
|
||||
- 设置部门负责人指派范围
|
||||
related_tools: []
|
||||
related_pages: []
|
||||
prerequisites:
|
||||
- 当前账号是系统管理员
|
||||
negative:
|
||||
- 不开放非项目成员指派或隐式加入项目
|
||||
last_verified: v1.9.18
|
||||
---
|
||||
|
||||
# 设置任务流转与部门指派权限
|
||||
|
||||
## 入口
|
||||
系统设置 → 任务设置 → 流转设置。配置全局生效,由系统管理员统一维护。
|
||||
|
||||
## 设置项
|
||||
| 设置 | 选项 | 默认 |
|
||||
|---|---|---|
|
||||
| 任务流转 | 开启、关闭 | 关闭 |
|
||||
| 部门视角指派权限 | 关闭、仅部门负责人、部门负责人及部门管理员 | 仅部门负责人 |
|
||||
| 可指派人员范围 | 管理部门范围内的项目成员、项目内全部成员 | 管理部门范围内的项目成员 |
|
||||
| 原负责人调整范围 | 仅调整管理范围内负责人、允许调整全部负责人 | 仅调整管理范围内负责人 |
|
||||
| 指派留言要求 | 选填、必填 | 选填 |
|
||||
|
||||
## 生效范围
|
||||
- 设置键依次为 `project_task_handoff`、`project_task_handoff_role`、`project_task_handoff_candidates`、`project_task_handoff_adjust`、`project_task_handoff_note`,由系统设置接口统一读写。
|
||||
- 总开关开启才显示其余配置及任务详情「流转」标签,提供有权限的指派入口。
|
||||
- 部门权限仍依赖系统及项目允许负责人视角;管理范围包含符合角色授权的部门及其下级部门,并与当前所选部门范围取交集。
|
||||
- 候选人和原负责人调整范围仅约束部门视角新增授权,不收紧原有项目角色权限。
|
||||
- 留言要求用于流转窗口人工指派,不阻断原负责人编辑、领取或自动工作流。
|
||||
- 关闭不删除记录,原有任务操作及变更留痕继续工作。
|
||||
|
||||
## 不支持
|
||||
不支持非项目成员指派、审批、接单或通过部门权限查看私密任务。流转操作见 [[task.handoff.howto]]。
|
||||
48
resources/ai-kb/zh/howto/task/handoff.md
Normal file
48
resources/ai-kb/zh/howto/task/handoff.md
Normal file
@ -0,0 +1,48 @@
|
||||
---
|
||||
id: task.handoff.howto
|
||||
title: 查看任务流转与指派负责人
|
||||
type: howto
|
||||
feature: task
|
||||
scope: end-user
|
||||
locale: zh
|
||||
aliases:
|
||||
- 怎么查看负责人交接记录
|
||||
- 领导指派任务时附带批示
|
||||
related_tools: []
|
||||
related_pages: [task_detail]
|
||||
prerequisites:
|
||||
- 系统管理员已开启任务流转
|
||||
negative:
|
||||
- 不提供接单、审批或独立批示回复功能
|
||||
- 不自动还原旧任务的完整责任链
|
||||
last_verified: v1.9.18
|
||||
---
|
||||
|
||||
# 查看任务流转与指派负责人
|
||||
|
||||
## 入口
|
||||
任务详情右侧「讨论 / 动态 / 流转」中的「流转」。系统默认关闭任务流转,关闭时只显示讨论和动态。开启方法见 [[system-setting.handoff.howto]]。
|
||||
|
||||
## 查看记录
|
||||
- 点击右侧刷新时保留当前列表和指派按钮,成功返回后更新。刷新图标显示加载状态;普通任务详情不显示列表内加载提示,空列表刷新时保留「暂无流转记录」,展开讨论布局仍显示加载动画。切换任务或管理部门范围时清空旧内容。
|
||||
- 按最近发生在前展示负责人变化、工作流状态、完成与重新打开、归档与取消归档。
|
||||
- 原负责人编辑、工作流自动换人、人员移交和退出项目也记录变化;一次工作流操作的状态和负责人变化合并展示。
|
||||
- 指派留言与本次负责人变更绑定,不是聊天消息。历史任务可切换到「动态」选项卡查看已有日志,不推算缺失的交接关系。
|
||||
|
||||
## 指派
|
||||
普通任务详情中,「指派」位于「讨论 / 动态 / 流转」导航右侧,采用与「任务讨论」一致的文字入口;宽度不足时右侧入口自动换行并靠右。展开讨论布局中仍位于流转列表顶部右侧。仅有指派权限时显示。
|
||||
|
||||
1. 有权限时点击「指派」,点击负责人头像或加号,在统一人员选择器中搜索、多选目标负责人;范围外保留负责人不可取消。确定人员后填写指派留言(是否必填由系统设置决定)。
|
||||
2. 确认保留、新增、移除人员后提交;总人数最多10人。
|
||||
3. 默认部门负责人只能选择管理部门及下级部门内的项目成员,并保留范围外负责人。系统可调整此规则。
|
||||
4. 并发修改导致负责人版本变化时,重新打开指派窗口获取最新人员后重试。
|
||||
5. 留言设为选填时可留空;接口接受省略 `note`、空字符串或 `null`。设为必填时,空留言仍提示「请填写指派留言」。
|
||||
|
||||
## 不支持
|
||||
- 不允许指派非项目成员,不自动将人员加入项目。
|
||||
- 部门指派权限不开放其他任务编辑权限,也不扩大私密任务可见性。
|
||||
- 已归档任务或所属项目已归档、删除时不能指派。
|
||||
- 不支持独立批示、接单或审批;只填写留言但不改变负责人不能提交。
|
||||
|
||||
## 接口与链接
|
||||
接口统一为 `api/projecttaskhandoff/lists`、`api/projecttaskhandoff/options`、`api/projecttaskhandoff/assign`。任务详情链接使用 `navActive=handoff`,旧接口和旧标签参数不再使用。
|
||||
@ -20,7 +20,7 @@ negative:
|
||||
- 移动端通常不展示「系统设置」入口,需用桌面端 / 网页后台
|
||||
- 普通成员看不到该页,无入口
|
||||
- 这 6 个 tab 只是「系统设置」一级菜单内的内容;邮件、AI、签到等是同级别的其他左侧菜单项,不在这个页内
|
||||
last_verified: v1.8.89
|
||||
last_verified: v1.9.18
|
||||
---
|
||||
|
||||
# 系统设置页面总览
|
||||
@ -38,7 +38,7 @@ last_verified: v1.8.89
|
||||
| **基础设置** | `general` | 系统别名和仪表盘欢迎语;详见 [[system-setting.general.howto]] |
|
||||
| **帐号与安全** | `account` | 注册方式、临时帐号、登录验证码和密码策略 |
|
||||
| **项目设置** | `project` | 项目创建与邀请权限、部门负责人视角、项目模板;详见 [[system-setting.column-template.howto]] |
|
||||
| **任务设置** | `task` | 任务默认规则、提醒、AI 分析和任务优先级;详见 [[system-setting.priority.howto]] |
|
||||
| **任务设置** | `task` | 任务默认规则、提醒、AI 分析、任务优先级及流转设置;详见 [[system-setting.priority.howto]] 和 [[system-setting.handoff.howto]] |
|
||||
| **消息设置** | `message` | 群聊、私聊、匿名消息、加密、撤回和待办权限 |
|
||||
| **文件与存储** | `file` | 上传与媒体处理、打包下载权限和 WebDAV;详见 [[system-setting.file.howto]] |
|
||||
|
||||
|
||||
@ -213,6 +213,12 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
// 指定完整人员名单;null 保持原有远程搜索,空数组表示无人可选
|
||||
users: {
|
||||
type: Array,
|
||||
default: null
|
||||
},
|
||||
|
||||
// 指定项目ID
|
||||
projectId: {
|
||||
type: Number,
|
||||
@ -420,11 +426,16 @@ export default {
|
||||
return windowWidth < 576
|
||||
},
|
||||
|
||||
isWhole({projectId, noProjectId, dialogId, onlyGroup}) {
|
||||
return projectId === 0 && noProjectId === 0 && dialogId === 0 && !onlyGroup
|
||||
isWhole({projectId, noProjectId, dialogId, onlyGroup, users}) {
|
||||
return users === null && projectId === 0 && noProjectId === 0 && dialogId === 0 && !onlyGroup
|
||||
},
|
||||
|
||||
lists({switchActive, searchKey, recents, contacts, projects}) {
|
||||
lists({switchActive, searchKey, recents, contacts, projects, users}) {
|
||||
if (users !== null) {
|
||||
return users.filter(item => (!this.hideAiBot || !$A.isAiBotUser(item))
|
||||
&& (!searchKey || $A.strExists(`${item.nickname || item.name || ''} ${item.email || ''} ${item.pinyin || ''}`, searchKey)))
|
||||
.map(item => ({...item, type: 'user'}))
|
||||
}
|
||||
switch (switchActive) {
|
||||
case 'recent':
|
||||
if (searchKey) {
|
||||
@ -590,7 +601,7 @@ export default {
|
||||
},
|
||||
|
||||
searchBefore() {
|
||||
if (!this.showModal) {
|
||||
if (!this.showModal || this.users !== null) {
|
||||
return
|
||||
}
|
||||
if (this.switchActive === 'recent') {
|
||||
|
||||
@ -0,0 +1,458 @@
|
||||
<template>
|
||||
<div class="project-task-handoff" :class="[records.length > 0 ? 'has-records' : '']">
|
||||
<div v-if="canAssign && showAssign" class="handoff-actions">
|
||||
<Button size="small" type="primary" icon="md-person-add" :loading="optionsLoading" @click="openAssign">{{$L('指派')}}</Button>
|
||||
</div>
|
||||
<div v-for="(item, index) in records" :key="item.id" class="handoff-record">
|
||||
<div v-if="index === 0 || date(item.created_at) !== date(records[index - 1].created_at)" class="handoff-date">{{date(item.created_at)}}</div>
|
||||
<UserAvatar v-if="item.userid" :userid="item.userid" :size="18" showName/>
|
||||
<div v-else class="handoff-system"><Icon type="ios-contact" :size="18"/> {{$L('系统')}}</div>
|
||||
<div class="handoff-detail">
|
||||
<div>{{sourceLabel(item)}}</div>
|
||||
<div v-if="ownersChanged(item)" class="handoff-people">
|
||||
<template v-if="item.record.before">
|
||||
<UserAvatar v-for="id in item.record.before.owners" :key="'b' + id" :userid="id" :size="18" showName/>
|
||||
<span v-if="!item.record.before.owners.length">{{$L('无')}}</span>
|
||||
<Icon type="ios-arrow-forward"/>
|
||||
</template>
|
||||
<UserAvatar v-for="id in item.record.after.owners" :key="'a' + id" :userid="id" :size="18" showName/>
|
||||
<span v-if="!item.record.after.owners.length">{{$L('无')}}</span>
|
||||
</div>
|
||||
<div v-if="flowChanged(item)" class="handoff-change">
|
||||
{{flowName(item.record.before.flow_item_name)}}
|
||||
<Icon type="ios-arrow-forward"/>
|
||||
{{flowName(item.record.after.flow_item_name)}}
|
||||
</div>
|
||||
<div v-for="(text, i) in stateChanges(item)" :key="i" class="handoff-change">{{text}}</div>
|
||||
<div v-if="item.record.note" class="handoff-note">{{item.record.note}}</div>
|
||||
<div class="handoff-time">{{time(item.created_at)}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loading && showLoad" class="handoff-empty"><Loading/></div>
|
||||
<div v-else-if="error" class="handoff-empty">
|
||||
<span>{{error}}</span>
|
||||
<Button type="text" @click="load(true)">{{$L('重试')}}</Button>
|
||||
</div>
|
||||
<div v-else-if="!records.length && (!loading || !showLoad)" class="handoff-empty">{{$L('暂无流转记录')}}</div>
|
||||
<div v-if="hasMore && !loading" class="handoff-bottom">
|
||||
<Button type="text" @click="load(false)">{{$L('加载更多')}}</Button>
|
||||
</div>
|
||||
<Modal
|
||||
v-model="assignVisible"
|
||||
:title="$L('指派负责人')"
|
||||
width="460"
|
||||
:mask-closable="false"
|
||||
:closable="!saving">
|
||||
<Form v-if="options" label-position="top" class="handoff-form">
|
||||
<FormItem v-if="options.protected.length" :label="$L('保留负责人')">
|
||||
<div class="handoff-people">
|
||||
<UserAvatar v-for="id in options.protected" :key="id" :userid="id" :size="24" showName/>
|
||||
</div>
|
||||
<div class="handoff-help">{{$L('管理范围外,不可移除')}}</div>
|
||||
</FormItem>
|
||||
<FormItem :label="$L('负责人')">
|
||||
<UserSelect
|
||||
v-model="selected"
|
||||
:users="selectableUsers"
|
||||
:uncancelable="options.protected"
|
||||
:multiple-max="10"
|
||||
:disabled="saving"
|
||||
:title="$L('选择任务负责人')"
|
||||
avatar-name/>
|
||||
</FormItem>
|
||||
<FormItem :label="$L('指派留言')" :required="options.note_required">
|
||||
<Input
|
||||
v-model="note"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:maxlength="1000"
|
||||
:disabled="saving"
|
||||
:placeholder="$L(options.note_required ? '必填' : '选填')"/>
|
||||
</FormItem>
|
||||
<div v-for="group in changes" :key="group.label" class="handoff-summary">
|
||||
<span>{{group.label}}:</span>
|
||||
<UserAvatar v-for="id in group.ids" :key="id" :userid="id" :size="18" showName/>
|
||||
<span v-if="!group.ids.length">{{$L('无')}}</span>
|
||||
</div>
|
||||
</Form>
|
||||
<div slot="footer">
|
||||
<Button :disabled="saving" @click="assignVisible = false">{{$L('取消')}}</Button>
|
||||
<Button type="primary" :loading="saving" @click="submit">{{$L('确定')}}</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import UserSelect from "../../../components/UserSelect.vue";
|
||||
|
||||
export default {
|
||||
name: 'ProjectTaskHandoff',
|
||||
components: {UserSelect},
|
||||
props: {
|
||||
task: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
showLoad: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
|
||||
showAssign: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
records: [],
|
||||
loading: false,
|
||||
error: '',
|
||||
hasMore: false,
|
||||
canAssign: false,
|
||||
requestId: 0,
|
||||
|
||||
optionsLoading: false,
|
||||
assignVisible: false,
|
||||
saving: false,
|
||||
options: null,
|
||||
selected: [],
|
||||
note: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
assignState() {
|
||||
return {visible: this.canAssign, loading: this.optionsLoading};
|
||||
},
|
||||
|
||||
departmentIds() {
|
||||
return this.$store.state.departmentOwnerProjectViewEnabled
|
||||
? (this.$store.state.cacheDepartmentOwnerIds || []).join(',') : 'all';
|
||||
},
|
||||
|
||||
selectableUsers() {
|
||||
if (!this.options) {
|
||||
return [];
|
||||
}
|
||||
return this.options.users.filter(user => this.options.candidates.includes(user.userid)
|
||||
|| this.options.owners.includes(user.userid));
|
||||
},
|
||||
|
||||
targetOwners() {
|
||||
return [...new Set([...this.selected, ...(this.options?.protected || [])])].sort((a, b) => a - b);
|
||||
},
|
||||
|
||||
changes() {
|
||||
const before = this.options?.owners || [];
|
||||
const after = this.targetOwners;
|
||||
return [
|
||||
{label: this.$L('保留'), ids: before.filter(id => after.includes(id))},
|
||||
{label: this.$L('新增'), ids: after.filter(id => !before.includes(id))},
|
||||
{label: this.$L('移除'), ids: before.filter(id => !after.includes(id))},
|
||||
];
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
assignState: {
|
||||
immediate: true,
|
||||
handler(state) {
|
||||
this.$emit('on-assign-state', state);
|
||||
},
|
||||
},
|
||||
|
||||
'task.id'() {
|
||||
this.assignVisible = false;
|
||||
this.load(true, true);
|
||||
},
|
||||
|
||||
'task.updated_at'() {
|
||||
this.load(true);
|
||||
},
|
||||
|
||||
departmentIds() {
|
||||
this.assignVisible = false;
|
||||
this.load(true, true);
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.load(true);
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.requestId++;
|
||||
this.$emit('on-assign-state', {visible: false, loading: false});
|
||||
},
|
||||
|
||||
methods: {
|
||||
async call(method, data = {}) {
|
||||
return this.$store.dispatch('call', {
|
||||
url: `projecttaskhandoff/${method}`,
|
||||
method: method === 'assign' ? 'post' : 'get',
|
||||
data: {
|
||||
task_id: this.task.id,
|
||||
department_owner_ids: this.departmentIds,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async load(reset = true, clear = false) {
|
||||
const requestId = ++this.requestId;
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
this.$emit('on-load-change', true);
|
||||
// 切换任务或权限范围时清空,刷新同一任务时保留内容直到响应返回。
|
||||
if (clear) {
|
||||
this.records = [];
|
||||
this.canAssign = false;
|
||||
this.hasMore = false;
|
||||
}
|
||||
try {
|
||||
const {data} = await this.call('lists', {
|
||||
before_id: reset ? 0 : this.records[this.records.length - 1]?.id,
|
||||
});
|
||||
if (requestId !== this.requestId) {
|
||||
return;
|
||||
}
|
||||
this.records = reset ? data.lists : this.records.concat(data.lists);
|
||||
this.hasMore = data.has_more;
|
||||
this.canAssign = data.can_assign;
|
||||
} catch ({msg}) {
|
||||
if (requestId === this.requestId) {
|
||||
this.records = [];
|
||||
this.canAssign = false;
|
||||
this.hasMore = false;
|
||||
this.error = msg || this.$L('加载失败');
|
||||
}
|
||||
} finally {
|
||||
if (requestId === this.requestId) {
|
||||
this.loading = false;
|
||||
this.$emit('on-load-change', false);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async openAssign() {
|
||||
const taskId = this.task.id;
|
||||
const departmentIds = this.departmentIds;
|
||||
this.optionsLoading = true;
|
||||
try {
|
||||
const {data} = await this.call('options');
|
||||
if (taskId !== this.task.id || departmentIds !== this.departmentIds || this._isDestroyed) {
|
||||
return;
|
||||
}
|
||||
this.options = data;
|
||||
this.selected = [...data.owners];
|
||||
this.note = '';
|
||||
this.assignVisible = true;
|
||||
} catch ({msg}) {
|
||||
$A.modalError(msg);
|
||||
} finally {
|
||||
this.optionsLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async submit() {
|
||||
if (!this.options || this.saving) {
|
||||
return;
|
||||
}
|
||||
if (this.targetOwners.length > 10) {
|
||||
return $A.messageError('任务负责人最多不能超过10个');
|
||||
}
|
||||
if (JSON.stringify(this.targetOwners) === JSON.stringify(this.options.owners)) {
|
||||
return $A.messageError('负责人未发生变化');
|
||||
}
|
||||
if (this.options.note_required && !this.note.trim()) {
|
||||
return $A.messageError('请填写指派留言');
|
||||
}
|
||||
this.saving = true;
|
||||
try {
|
||||
const {data} = await this.call('assign', {
|
||||
owners: this.targetOwners,
|
||||
version: this.options.version,
|
||||
note: this.note.trim(),
|
||||
});
|
||||
this.assignVisible = false;
|
||||
await this.$store.dispatch('saveTask', data);
|
||||
this.load(true);
|
||||
$A.messageSuccess('指派成功');
|
||||
} catch ({msg}) {
|
||||
$A.modalError(msg);
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
date(value) {
|
||||
return $A.dayjs(value).format('YYYY-MM-DD');
|
||||
},
|
||||
|
||||
time(value) {
|
||||
return $A.dayjs(value).format('YYYY-MM-DD HH:mm');
|
||||
},
|
||||
|
||||
ownersChanged(item) {
|
||||
return !item.record.before || JSON.stringify(item.record.before.owners) !== JSON.stringify(item.record.after.owners);
|
||||
},
|
||||
|
||||
flowChanged(item) {
|
||||
return item.record.before && item.record.before.flow_item_name !== item.record.after.flow_item_name;
|
||||
},
|
||||
|
||||
flowName(value) {
|
||||
return (value || '').split('|')[1] || this.$L('无');
|
||||
},
|
||||
|
||||
stateChanges(item) {
|
||||
if (!item.record.before) {
|
||||
return [];
|
||||
}
|
||||
const {before, after} = item.record;
|
||||
const changes = [];
|
||||
if (!!before.complete_at !== !!after.complete_at) {
|
||||
changes.push(after.complete_at ? this.$L('标记已完成') : this.$L('标记未完成'));
|
||||
}
|
||||
if (!!before.archived_at !== !!after.archived_at) {
|
||||
changes.push(after.archived_at ? this.$L('任务归档') : this.$L('任务取消归档'));
|
||||
}
|
||||
return changes;
|
||||
},
|
||||
|
||||
sourceLabel(item) {
|
||||
switch (item.source) {
|
||||
case 'create':
|
||||
return this.$L('创建任务');
|
||||
case 'copy':
|
||||
return this.$L('复制任务');
|
||||
case 'assign':
|
||||
return this.$L('指派负责人');
|
||||
case 'flow':
|
||||
return this.$L('状态流转');
|
||||
case 'move':
|
||||
return this.$L('移动任务');
|
||||
case 'transfer':
|
||||
return this.$L('移交任务身份');
|
||||
case 'member_exit':
|
||||
return this.$L('退出项目');
|
||||
case 'recurring':
|
||||
return this.$L('重复任务');
|
||||
case 'auto_archive':
|
||||
return this.$L('任务自动归档');
|
||||
default:
|
||||
return this.ownersChanged(item) ? this.$L('修改负责人') : this.$L('任务状态');
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.project-task-handoff {
|
||||
position: relative;
|
||||
padding: 14px 10px 20px 24px;
|
||||
color: #606266;
|
||||
&.has-records {
|
||||
.handoff-actions {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 14px;
|
||||
+ .handoff-record {
|
||||
.handoff-date,
|
||||
.handoff-system,
|
||||
.avatar-wrapper {
|
||||
max-width: 240px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.handoff-empty,
|
||||
.handoff-bottom {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
.handoff-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
color: #a5a9ae;
|
||||
font-size: 13px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.handoff-record {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.handoff-date {
|
||||
color: #a8aaad;
|
||||
margin-bottom: 15px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.handoff-system {
|
||||
color: #a2a8b1;
|
||||
font-size: 13px;
|
||||
}
|
||||
.handoff-detail {
|
||||
padding: 10px 0 0 35px;
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.handoff-change {
|
||||
margin-top: 5px;
|
||||
}
|
||||
.handoff-note {
|
||||
margin-top: 9px;
|
||||
padding-left: 10px;
|
||||
border-left: 2px solid #e5eadf;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.handoff-time {
|
||||
color: #b5b8bd;
|
||||
font-size: 13px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.handoff-empty,
|
||||
.handoff-bottom {
|
||||
text-align: center;
|
||||
color: #a6a9af;
|
||||
padding: 12px 0;
|
||||
}
|
||||
.handoff-empty {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
.handoff-people, .handoff-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.handoff-form {
|
||||
padding: 4px;
|
||||
.handoff-help {
|
||||
font-size: 13px;
|
||||
color: #a6a9af;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.handoff-summary {
|
||||
font-size: 13px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
.common-user-select > ul > li {
|
||||
padding-right: 8px;
|
||||
}
|
||||
}
|
||||
.task-detail.open-dialog .task-dialog .project-task-handoff {
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
background: #fff;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@ -448,7 +448,8 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="nav-item" :class="{active:navActive=='log'}" @click="navActive='log'">{{$L('动态')}}</div>
|
||||
<div v-if="navActive=='log'" class="refresh">
|
||||
<div v-if="handoffEnabled" class="nav-item" :class="{active:navActive=='handoff'}" @click="navActive='handoff'">{{$L('流转')}}</div>
|
||||
<div v-if="navActive=='log' || navActive=='handoff'" class="refresh">
|
||||
<Loading v-if="logLoadIng"/>
|
||||
<Icon v-else type="ios-refresh" @click="getLogLists"></Icon>
|
||||
</div>
|
||||
@ -456,19 +457,33 @@
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
<ProjectLog v-if="navActive=='log' && taskId > 0" ref="log" :task-id="taskDetail.id" @on-load-change="logLoadChange"/>
|
||||
<ProjectTaskHandoff
|
||||
v-if="handoffEnabled && navActive=='handoff' && taskId > 0"
|
||||
ref="handoff"
|
||||
:task="taskDetail"
|
||||
@on-load-change="logLoadChange"/>
|
||||
</template>
|
||||
<div v-else>
|
||||
<div class="head">
|
||||
<div class="head head-wrap">
|
||||
<Icon class="icon" type="ios-chatbubbles-outline" />
|
||||
<div class="nav">
|
||||
<div class="nav-item" :class="{active:navActive=='dialog'}" @click="navActive='dialog'">{{$L('讨论')}}</div>
|
||||
<div class="nav-item" :class="{active:navActive=='log'}" @click="navActive='log'">{{$L('动态')}}</div>
|
||||
<div v-if="navActive=='log'" class="refresh">
|
||||
<div v-if="handoffEnabled" class="nav-item" :class="{active:navActive=='handoff'}" @click="navActive='handoff'">{{$L('流转')}}</div>
|
||||
<div v-if="navActive=='log' || navActive=='handoff'" class="refresh">
|
||||
<Loading v-if="logLoadIng"/>
|
||||
<Icon v-else type="ios-refresh" @click="getLogLists"></Icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu">
|
||||
<div
|
||||
v-if="handoffEnabled && navActive=='handoff' && handoffAssign.visible"
|
||||
class="menu-item"
|
||||
@click.stop="!handoffAssign.loading && $refs.handoff.openAssign()">
|
||||
<div v-if="handoffAssign.loading" class="menu-load"><Loading/></div>
|
||||
{{$L('指派')}}
|
||||
<i class="taskfont"></i>
|
||||
</div>
|
||||
<div v-if="navActive=='dialog' && taskDetail.msg_num > 0" class="menu-item" @click.stop="onOpen">
|
||||
<div v-if="openLoad > 0" class="menu-load"><Loading/></div>
|
||||
{{$L('任务讨论')}}
|
||||
@ -483,6 +498,14 @@
|
||||
:task-id="taskDetail.id"
|
||||
:show-load="false"
|
||||
@on-load-change="logLoadChange"/>
|
||||
<ProjectTaskHandoff
|
||||
v-else-if="handoffEnabled && navActive=='handoff' && taskId > 0"
|
||||
ref="handoff"
|
||||
:task="taskDetail"
|
||||
:show-load="false"
|
||||
:show-assign="false"
|
||||
@on-assign-state="handoffAssign = $event"
|
||||
@on-load-change="logLoadChange"/>
|
||||
<div
|
||||
v-else
|
||||
class="no-dialog"
|
||||
@ -573,6 +596,7 @@ import TaskPriority from "./TaskPriority";
|
||||
import TaskUpload from "./TaskUpload";
|
||||
import DialogWrapper from "./DialogWrapper";
|
||||
import ProjectLog from "./ProjectLog";
|
||||
import ProjectTaskHandoff from "./ProjectTaskHandoff.vue";
|
||||
import TaskMenu from "./TaskMenu";
|
||||
import ChatInput from "./ChatInput";
|
||||
import UserSelect from "../../../components/UserSelect.vue";
|
||||
@ -600,6 +624,7 @@ export default {
|
||||
ChatInput,
|
||||
TaskMenu,
|
||||
ProjectLog,
|
||||
ProjectTaskHandoff,
|
||||
DialogWrapper,
|
||||
TaskUpload,
|
||||
TaskPriority,
|
||||
@ -683,6 +708,7 @@ export default {
|
||||
msgType: '',
|
||||
navActive: 'dialog',
|
||||
logLoadIng: false,
|
||||
handoffAssign: {visible: false, loading: false},
|
||||
|
||||
sendLoad: 0,
|
||||
openLoad: 0,
|
||||
@ -725,7 +751,7 @@ export default {
|
||||
|
||||
created() {
|
||||
const navActive = $A.getObject(this.$route.query, 'navActive')
|
||||
if (['dialog', 'log'].includes(navActive)) {
|
||||
if (['dialog', 'log'].includes(navActive) || (navActive === 'handoff' && this.handoffEnabled)) {
|
||||
this.navActive = navActive;
|
||||
}
|
||||
$A.IDBJson('delayTaskForm').then(data => {
|
||||
@ -827,6 +853,10 @@ export default {
|
||||
return this.taskDetail.dialog_id > 0 && this.windowLandscape;
|
||||
},
|
||||
|
||||
handoffEnabled() {
|
||||
return this.systemConfig.project_task_handoff === 'open';
|
||||
},
|
||||
|
||||
dialogStyle() {
|
||||
const {windowHeight, taskDialogWidth, hasOpenDialog} = this;
|
||||
const height = Math.min(1100, windowHeight)
|
||||
@ -1050,6 +1080,11 @@ export default {
|
||||
},
|
||||
|
||||
watch: {
|
||||
handoffEnabled(enabled) {
|
||||
if (!enabled && this.navActive === 'handoff') {
|
||||
this.navActive = 'dialog';
|
||||
}
|
||||
},
|
||||
openTask: {
|
||||
handler(data) {
|
||||
this.taskDetail = $A.cloneJSON(data);
|
||||
@ -1668,6 +1703,10 @@ export default {
|
||||
},
|
||||
|
||||
getLogLists() {
|
||||
if (this.navActive === 'handoff') {
|
||||
this.$refs.handoff?.load(true);
|
||||
return;
|
||||
}
|
||||
if (this.navActive != 'log') {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -178,6 +178,44 @@
|
||||
</FormItem>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="scope === 'task'" class="block-setting-box">
|
||||
<h3>{{ $L('流转设置') }}</h3>
|
||||
<div class="form-box">
|
||||
<FormItem :label="$L('任务流转')">
|
||||
<RadioGroup v-model="formDatum.project_task_handoff">
|
||||
<Radio label="open">{{$L('开启')}}</Radio>
|
||||
<Radio label="close">{{$L('关闭')}}</Radio>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
<template v-if="formDatum.project_task_handoff === 'open'">
|
||||
<FormItem :label="$L('部门视角指派权限')">
|
||||
<RadioGroup v-model="formDatum.project_task_handoff_role">
|
||||
<Radio label="close">{{$L('关闭')}}</Radio>
|
||||
<Radio label="owner">{{$L('仅部门负责人')}}</Radio>
|
||||
<Radio label="managers">{{$L('部门负责人及部门管理员')}}</Radio>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
<FormItem :label="$L('可指派人员范围')">
|
||||
<RadioGroup v-model="formDatum.project_task_handoff_candidates">
|
||||
<Radio label="department">{{$L('管理部门范围内的项目成员')}}</Radio>
|
||||
<Radio label="project">{{$L('项目内全部成员')}}</Radio>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
<FormItem :label="$L('原负责人调整范围')">
|
||||
<RadioGroup v-model="formDatum.project_task_handoff_adjust">
|
||||
<Radio label="department">{{$L('仅调整管理范围内负责人')}}</Radio>
|
||||
<Radio label="all">{{$L('允许调整全部负责人')}}</Radio>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
<FormItem :label="$L('指派留言要求')">
|
||||
<RadioGroup v-model="formDatum.project_task_handoff_note">
|
||||
<Radio label="optional">{{$L('选填')}}</Radio>
|
||||
<Radio label="required">{{$L('必填')}}</Radio>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="scope === 'task'" class="block-setting-box">
|
||||
<h3>{{ $L('任务优先级') }}</h3>
|
||||
<div class="form-box">
|
||||
@ -374,7 +412,7 @@ const SCOPE_FIELDS = {
|
||||
general: ['system_alias', 'login_logo', 'system_welcome'],
|
||||
account: ['reg', 'reg_identity', 'reg_invite', 'temp_account_alias', 'login_code', 'password_policy'],
|
||||
project: ['project_invite', 'project_add_permission', 'project_add_userids', 'department_owner_project_view'],
|
||||
task: ['auto_archived', 'archived_day', 'task_visible', 'task_default_time', 'task_user_limit', 'unclaimed_task_reminder', 'unclaimed_task_reminder_time', 'task_ai_auto_analyze'],
|
||||
task: ['auto_archived', 'archived_day', 'task_visible', 'task_default_time', 'task_user_limit', 'unclaimed_task_reminder', 'unclaimed_task_reminder_time', 'task_ai_auto_analyze', 'project_task_handoff', 'project_task_handoff_role', 'project_task_handoff_candidates', 'project_task_handoff_adjust', 'project_task_handoff_note'],
|
||||
message: ['chat_information', 'anon_message', 'e2e_message', 'msg_rev_limit', 'msg_edit_limit', 'all_group_mute', 'all_group_autoin', 'user_private_chat_mute', 'user_group_chat_mute', 'todo_set_permission'],
|
||||
file: ['convert_video', 'compress_video', 'image_compress', 'image_quality', 'image_save_local', 'file_upload_limit'],
|
||||
};
|
||||
|
||||
@ -864,7 +864,7 @@
|
||||
.refresh {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: -18px;
|
||||
margin-left: -8px;
|
||||
> i {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
@ -903,6 +903,33 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
&.head-wrap {
|
||||
flex-wrap: wrap;
|
||||
height: auto !important;
|
||||
min-height: 58px;
|
||||
row-gap: 8px;
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.nav {
|
||||
flex-shrink: 0;
|
||||
flex-basis: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.menu {
|
||||
padding-left: 24px;
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
+ .project-task-handoff {
|
||||
.handoff-empty {
|
||||
padding: 24px 0 0;
|
||||
}
|
||||
.handoff-record {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.no-dialog {
|
||||
flex: 1;
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
> 此文件由 `php artisan doc:api-map` 生成,勿手改。
|
||||
|
||||
接口总数:326
|
||||
接口总数:329
|
||||
|
||||
## 路由规则
|
||||
|
||||
@ -163,6 +163,14 @@ API 使用动态路由(见 `routes/web.php`),URL 段映射为控制器方
|
||||
| api/project/task/ai_apply | task__ai_apply() | post | 采纳AI建议 |
|
||||
| api/project/task/ai_dismiss | task__ai_dismiss() | post | 忽略AI建议 |
|
||||
|
||||
## projecttaskhandoff(ProjectTaskHandoffController)
|
||||
|
||||
| URL | 方法名 | HTTP | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| api/projecttaskhandoff/lists | lists() | get | 任务流转记录 |
|
||||
| api/projecttaskhandoff/options | options() | get | 任务指派人员与权限 |
|
||||
| api/projecttaskhandoff/assign | assign() | post | 指派任务负责人并附带留言 |
|
||||
|
||||
## dashboard(DashboardController)
|
||||
|
||||
| URL | 方法名 | HTTP | 说明 |
|
||||
|
||||
@ -19,6 +19,7 @@ use App\Http\Controllers\Api\SearchController;
|
||||
use App\Http\Controllers\Api\AppsController;
|
||||
use App\Http\Controllers\Api\UploadController;
|
||||
use App\Http\Controllers\Api\DashboardController;
|
||||
use App\Http\Controllers\Api\ProjectTaskHandoffController;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@ -42,6 +43,7 @@ Route::prefix('api')->middleware(['webapi'])->group(function () {
|
||||
// 项目
|
||||
Route::any('project/{method}', ProjectController::class);
|
||||
Route::any('project/{method}/{action}', ProjectController::class);
|
||||
Route::any('projecttaskhandoff/{method}', ProjectTaskHandoffController::class);
|
||||
// 仪表盘
|
||||
Route::any('dashboard/{method}', DashboardController::class);
|
||||
Route::any('dashboard/{method}/{action}', DashboardController::class);
|
||||
|
||||
380
tests/Feature/ProjectTaskHandoffFeatureTest.php
Normal file
380
tests/Feature/ProjectTaskHandoffFeatureTest.php
Normal file
@ -0,0 +1,380 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Http\Controllers\Api\ProjectTaskHandoffController;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectFlow;
|
||||
use App\Models\ProjectFlowItem;
|
||||
use App\Models\ProjectTask;
|
||||
use App\Models\ProjectTaskHandoff;
|
||||
use App\Models\ProjectTaskUser;
|
||||
use App\Models\ProjectUser;
|
||||
use App\Models\User;
|
||||
use App\Models\UserDepartment;
|
||||
use App\Module\Base;
|
||||
use App\Module\Interface\DooSo;
|
||||
use App\Module\ProjectTaskHandoffService;
|
||||
use App\Module\ProjectTaskHandoffRecord;
|
||||
use App\Services\RequestContext;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Events\Dispatcher;
|
||||
use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull;
|
||||
use Illuminate\Foundation\Http\Middleware\TrimStrings;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pipeline\Pipeline;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Request as RequestFacade;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectTaskHandoffFeatureTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
private $originalDispatcher;
|
||||
private User $leader;
|
||||
private User $inside;
|
||||
private User $outside;
|
||||
private User $receiver;
|
||||
private Project $project;
|
||||
private ProjectTask $task;
|
||||
private UserDepartment $department;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
config(['cache.default' => 'array']);
|
||||
// Keep tests transactional and suppress unrelated Swoole/search/external hooks.
|
||||
$this->originalDispatcher = Model::getEventDispatcher();
|
||||
Model::setEventDispatcher(new Dispatcher(app()));
|
||||
RequestContext::clean();
|
||||
$this->leader = $this->user('leader');
|
||||
$this->inside = $this->user('inside');
|
||||
$this->outside = $this->user('outside');
|
||||
$this->receiver = $this->user('receiver');
|
||||
$this->login($this->leader);
|
||||
$this->department = UserDepartment::createInstance(['name' => 'Handoff test', 'parent_id' => 0, 'owner_userid' => $this->leader->userid]);
|
||||
$this->department->save();
|
||||
foreach ([$this->inside, $this->receiver] as $user) {
|
||||
$user->department = ',' . $this->department->id . ',';
|
||||
$user->save();
|
||||
}
|
||||
$this->project = Project::createInstance(['name' => 'Handoff test', 'department_owner_view' => 'open']);
|
||||
$this->project->save();
|
||||
foreach ([$this->inside, $this->outside, $this->receiver] as $user) {
|
||||
ProjectUser::createInstance(['project_id' => $this->project->id, 'userid' => $user->userid, 'owner' => $user === $this->outside ? 1 : 0])->save();
|
||||
}
|
||||
$this->task = ProjectTask::createInstance([
|
||||
'project_id' => $this->project->id, 'parent_id' => 0, 'name' => 'Handoff test',
|
||||
'userid' => $this->outside->userid, 'visibility' => 1, 'dialog_id' => 0,
|
||||
]);
|
||||
$this->task->save();
|
||||
foreach ([$this->inside, $this->outside] as $user) {
|
||||
ProjectTaskUser::createInstance([
|
||||
'project_id' => $this->project->id, 'task_id' => $this->task->id,
|
||||
'task_pid' => $this->task->id, 'userid' => $user->userid, 'owner' => 1,
|
||||
])->save();
|
||||
}
|
||||
ProjectTask::updated(fn ($task) => ProjectTaskHandoffRecord::updated($task));
|
||||
Base::setting('system', [
|
||||
'project_task_handoff' => 'open', 'project_task_handoff_role' => 'owner',
|
||||
'project_task_handoff_candidates' => 'department', 'project_task_handoff_adjust' => 'department',
|
||||
'project_task_handoff_note' => 'optional', 'department_owner_project_view' => 'open',
|
||||
], true);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
RequestContext::clean();
|
||||
Model::setEventDispatcher($this->originalDispatcher);
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function user(string $name): User
|
||||
{
|
||||
$user = User::createInstance(['email' => uniqid('handoff_' . $name) . '@test.local', 'nickname' => $name, 'identity' => ['normal'], 'bot' => 0]);
|
||||
$user->save();
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function login(User $user): void
|
||||
{
|
||||
RequestContext::clean();
|
||||
$doo = Mockery::mock(DooSo::class);
|
||||
$doo->shouldReceive('userId')->andReturn($user->userid);
|
||||
$doo->shouldReceive('translate')->andReturnUsing(fn ($value) => $value);
|
||||
RequestContext::save('doo_instance', $doo);
|
||||
RequestContext::save('auth', $user);
|
||||
}
|
||||
|
||||
public function test_department_owner_candidates_and_protected_people(): void
|
||||
{
|
||||
$policy = ProjectTaskHandoffService::policy(ProjectTaskHandoffService::task($this->task->id));
|
||||
$this->assertEqualsCanonicalizing([$this->inside->userid, $this->receiver->userid], $policy['candidates']);
|
||||
$this->assertSame([$this->outside->userid], $policy['protected']);
|
||||
}
|
||||
|
||||
public function test_project_wide_options_do_not_add_nonmembers(): void
|
||||
{
|
||||
Base::setting('system', ['project_task_handoff_candidates' => 'project', 'project_task_handoff_adjust' => 'all'], true);
|
||||
$policy = ProjectTaskHandoffService::policy(ProjectTaskHandoffService::task($this->task->id));
|
||||
$this->assertCount(3, $policy['candidates']);
|
||||
$this->assertNotContains($this->leader->userid, $policy['candidates']);
|
||||
$this->assertSame([], $policy['protected']);
|
||||
}
|
||||
|
||||
public function test_deputy_requires_explicit_authorization(): void
|
||||
{
|
||||
$deputy = $this->user('deputy');
|
||||
DB::table('user_department_owners')->insert(['department_id' => $this->department->id, 'userid' => $deputy->userid]);
|
||||
$this->login($deputy);
|
||||
$this->expectExceptionMessage('无指派权限');
|
||||
ProjectTaskHandoffService::policy(ProjectTaskHandoffService::task($this->task->id));
|
||||
}
|
||||
|
||||
public function test_authorized_deputy_can_assign(): void
|
||||
{
|
||||
$deputy = $this->user('deputy');
|
||||
DB::table('user_department_owners')->insert(['department_id' => $this->department->id, 'userid' => $deputy->userid]);
|
||||
Base::setting('system', ['project_task_handoff_role' => 'managers'], true);
|
||||
$this->login($deputy);
|
||||
$this->assertContains($this->receiver->userid, ProjectTaskHandoffService::policy(ProjectTaskHandoffService::task($this->task->id))['candidates']);
|
||||
}
|
||||
|
||||
public function test_department_authority_does_not_expose_private_tasks(): void
|
||||
{
|
||||
$this->task->visibility = 2;
|
||||
$this->task->save();
|
||||
$this->expectException(ApiException::class);
|
||||
ProjectTaskHandoffService::task($this->task->id);
|
||||
}
|
||||
|
||||
public function test_switch_off_denies_reads_without_deleting_records(): void
|
||||
{
|
||||
ProjectTaskHandoffRecord::created($this->task);
|
||||
Base::setting('system', ['project_task_handoff' => 'close'], true);
|
||||
$this->assertSame(1, ProjectTaskHandoff::where('task_id', $this->task->id)->count());
|
||||
$this->expectExceptionMessage('任务流转未开启');
|
||||
ProjectTaskHandoffService::task($this->task->id);
|
||||
}
|
||||
|
||||
public function test_project_owner_keeps_original_permission_when_department_assignment_is_off(): void
|
||||
{
|
||||
Base::setting('system', ['project_task_handoff_role' => 'close'], true);
|
||||
$this->login($this->outside);
|
||||
$policy = ProjectTaskHandoffService::policy(ProjectTaskHandoffService::task($this->task->id));
|
||||
$this->assertCount(3, $policy['candidates']);
|
||||
$this->assertSame([], $policy['protected']);
|
||||
}
|
||||
|
||||
public function test_nested_updates_form_one_event_with_note(): void
|
||||
{
|
||||
ProjectTaskHandoffRecord::track($this->task, 'assign', function () {
|
||||
ProjectTaskHandoffRecord::track($this->task, 'flow', function () {
|
||||
ProjectTaskUser::whereTaskId($this->task->id)->whereUserid($this->inside->userid)->update(['userid' => $this->receiver->userid]);
|
||||
$this->task->flow_item_id = 123;
|
||||
$this->task->flow_item_name = 'progress|Processing|';
|
||||
$this->task->save();
|
||||
});
|
||||
}, 'Instruction');
|
||||
$events = ProjectTaskHandoff::where('task_id', $this->task->id)->get();
|
||||
$this->assertCount(1, $events);
|
||||
$record = $events->first()->record;
|
||||
$this->assertSame('Instruction', $record['note']);
|
||||
$this->assertContains($this->inside->userid, $record['before']['owners']);
|
||||
$this->assertContains($this->receiver->userid, $record['after']['owners']);
|
||||
$this->assertSame(123, (int)$record['after']['flow_item_id']);
|
||||
$this->assertFalse(ProjectTaskHandoffRecord::active($this->task->id));
|
||||
}
|
||||
|
||||
public function test_failed_change_rolls_back_and_clears_recording_context(): void
|
||||
{
|
||||
try {
|
||||
ProjectTaskHandoffRecord::track($this->task, 'assign', function () {
|
||||
ProjectTaskUser::whereTaskId($this->task->id)->delete();
|
||||
throw new ApiException('Test rollback');
|
||||
}, 'Instruction');
|
||||
$this->fail('Expected failure');
|
||||
} catch (ApiException $e) {
|
||||
$this->assertSame('Test rollback', $e->getMessage());
|
||||
}
|
||||
$this->assertCount(2, ProjectTaskHandoffRecord::owners($this->task->id));
|
||||
$this->assertSame(0, ProjectTaskHandoff::where('task_id', $this->task->id)->count());
|
||||
$this->assertFalse(ProjectTaskHandoffRecord::active($this->task->id));
|
||||
}
|
||||
|
||||
public function test_unrelated_edits_do_not_create_records_and_bulk_archival_does(): void
|
||||
{
|
||||
$this->task->name = 'Renamed';
|
||||
$this->task->save();
|
||||
$this->assertSame(0, ProjectTaskHandoff::where('task_id', $this->task->id)->count());
|
||||
ProjectTask::whereKey($this->task->id)->change(['archived_at' => now()]);
|
||||
$this->assertSame(1, ProjectTaskHandoff::where('task_id', $this->task->id)->count());
|
||||
}
|
||||
|
||||
public function test_project_exit_records_owner_removal(): void
|
||||
{
|
||||
ProjectUser::whereProjectId($this->project->id)->whereUserid($this->inside->userid)->first()->exitProject();
|
||||
$event = ProjectTaskHandoff::where('task_id', $this->task->id)->first();
|
||||
$this->assertSame('member_exit', $event->source);
|
||||
$this->assertSame([$this->outside->userid], $event->record['after']['owners']);
|
||||
}
|
||||
|
||||
public function test_account_transfer_records_actual_owners(): void
|
||||
{
|
||||
ProjectTaskUser::transfer($this->inside->userid, $this->receiver->userid);
|
||||
$event = ProjectTaskHandoff::where('task_id', $this->task->id)->first();
|
||||
$this->assertSame('transfer', $event->source);
|
||||
$this->assertContains($this->receiver->userid, $event->record['after']['owners']);
|
||||
}
|
||||
|
||||
public function test_new_request_does_not_inherit_context(): void
|
||||
{
|
||||
$request = request();
|
||||
RequestContext::save('handoff-isolation-test', 'first');
|
||||
$this->assertSame('first', RequestContext::get('handoff-isolation-test'));
|
||||
app()->instance('request', Request::create('/api/projecttaskhandoff/lists'));
|
||||
$this->assertNull(RequestContext::get('handoff-isolation-test'));
|
||||
RequestContext::clean();
|
||||
app()->instance('request', $request);
|
||||
$this->assertSame('first', RequestContext::get('handoff-isolation-test'));
|
||||
}
|
||||
|
||||
public function test_assignment_uses_existing_owner_update_and_saves_note_atomically(): void
|
||||
{
|
||||
$task = ProjectTaskHandoffFixture::find($this->task->id);
|
||||
$options = ProjectTaskHandoffService::options(ProjectTaskHandoffService::task($task->id));
|
||||
$data = ProjectTaskHandoffService::assign($task, [$this->outside->userid, $this->receiver->userid], $options['version'], 'Please review');
|
||||
$this->assertSame($task->id, $data['id']);
|
||||
$event = ProjectTaskHandoff::where('task_id', $task->id)->sole();
|
||||
$this->assertSame('assign', $event->source);
|
||||
$this->assertSame('Please review', $event->record['note']);
|
||||
$this->assertEqualsCanonicalizing([$this->outside->userid, $this->receiver->userid], ProjectTaskHandoffRecord::owners($task->id));
|
||||
$this->expectExceptionMessage('负责人已发生变化,请刷新后重试');
|
||||
ProjectTaskHandoffService::assign($task, [$this->outside->userid, $this->inside->userid], $options['version'], 'Stale');
|
||||
}
|
||||
|
||||
public function test_empty_optional_note_passes_controller_validation_after_middleware(): void
|
||||
{
|
||||
foreach ([[], ['note' => ''], ['note' => ' '], ['note' => null]] as $note) {
|
||||
$this->assertAssignmentRequestMessage($note, 'stale', '负责人已发生变化,请刷新后重试');
|
||||
}
|
||||
}
|
||||
|
||||
public function test_empty_required_note_still_requires_instruction(): void
|
||||
{
|
||||
Base::setting('system', ['project_task_handoff_note' => 'required'], true);
|
||||
$version = ProjectTaskHandoffService::version($this->task);
|
||||
$this->assertAssignmentRequestMessage(['note' => ''], $version, '请填写指派留言');
|
||||
}
|
||||
|
||||
public function test_non_string_note_is_still_rejected(): void
|
||||
{
|
||||
foreach ([[], 123, false] as $note) {
|
||||
$this->assertAssignmentRequestMessage(['note' => $note], 'stale', '指派参数无效');
|
||||
}
|
||||
}
|
||||
|
||||
private function assertAssignmentRequestMessage(array $note, string $version, string $message): void
|
||||
{
|
||||
$original = request();
|
||||
$request = Request::create('/api/projecttaskhandoff/assign', 'POST', [], [], [],
|
||||
['CONTENT_TYPE' => 'OPTIONS, application/json'], json_encode(array_merge([
|
||||
'task_id' => $this->task->id,
|
||||
'owners' => [$this->outside->userid, $this->receiver->userid],
|
||||
'version' => $version,
|
||||
], $note)));
|
||||
try {
|
||||
$result = (new Pipeline(app()))->send($request)->through([
|
||||
TrimStrings::class,
|
||||
ConvertEmptyStringsToNull::class,
|
||||
])->then(function ($request) {
|
||||
RequestFacade::swap($request);
|
||||
$this->login($this->leader);
|
||||
return (new ProjectTaskHandoffController())->assign();
|
||||
});
|
||||
$this->assertSame(0, $result['ret']);
|
||||
$this->assertSame($message, $result['msg']);
|
||||
} catch (ApiException $e) {
|
||||
$this->assertSame($message, $e->getMessage());
|
||||
} finally {
|
||||
RequestFacade::swap($original);
|
||||
$this->login($this->leader);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_original_owner_edit_is_recorded_with_feature_disabled(): void
|
||||
{
|
||||
Base::setting('system', ['project_task_handoff' => 'close'], true);
|
||||
$task = ProjectTaskHandoffFixture::find($this->task->id);
|
||||
$task->updateTask(['owner' => [$this->outside->userid, $this->receiver->userid]]);
|
||||
$event = ProjectTaskHandoff::where('task_id', $task->id)->sole();
|
||||
$this->assertSame('update', $event->source);
|
||||
$this->assertSame('', $event->record['note']);
|
||||
}
|
||||
|
||||
public function test_archived_task_cannot_be_assigned(): void
|
||||
{
|
||||
$this->task->archived_at = now();
|
||||
$this->task->save();
|
||||
$this->expectExceptionMessage('当前任务不可指派');
|
||||
ProjectTaskHandoffService::policy(ProjectTaskHandoffService::task($this->task->id));
|
||||
}
|
||||
|
||||
public function test_closing_project_department_view_revokes_access(): void
|
||||
{
|
||||
$this->project->department_owner_view = 'close';
|
||||
$this->project->save();
|
||||
$this->expectException(ApiException::class);
|
||||
ProjectTaskHandoffService::task($this->task->id);
|
||||
}
|
||||
|
||||
public function test_workflow_auto_assignment_is_one_record(): void
|
||||
{
|
||||
$flow = ProjectFlow::createInstance(['project_id' => $this->project->id, 'name' => 'Test flow']);
|
||||
$flow->save();
|
||||
$node = ProjectFlowItem::createInstance([
|
||||
'project_id' => $this->project->id, 'flow_id' => $flow->id,
|
||||
'name' => 'Processing', 'status' => 'progress', 'usertype' => 'replace',
|
||||
'userids' => [$this->receiver->userid],
|
||||
]);
|
||||
$node->save();
|
||||
$task = ProjectTaskHandoffFixture::find($this->task->id);
|
||||
$task->updateTask(['flow_item_id' => $node->id]);
|
||||
$event = ProjectTaskHandoff::where('task_id', $task->id)->sole();
|
||||
$this->assertSame('flow', $event->source);
|
||||
$this->assertSame([$this->receiver->userid], $event->record['after']['owners']);
|
||||
$this->assertSame($node->id, (int)$event->record['after']['flow_item_id']);
|
||||
}
|
||||
|
||||
public function test_copy_starts_its_own_history(): void
|
||||
{
|
||||
ProjectTaskHandoffRecord::created($this->task);
|
||||
$copy = $this->task->copyTask();
|
||||
$event = ProjectTaskHandoff::where('task_id', $copy->id)->sole();
|
||||
$this->assertSame('copy', $event->source);
|
||||
$this->assertNull($event->record['before']);
|
||||
$this->assertSame(ProjectTaskHandoffRecord::owners($this->task->id), $event->record['after']['owners']);
|
||||
}
|
||||
|
||||
public function test_account_transfer_still_handles_deleted_tasks(): void
|
||||
{
|
||||
$this->task->delete();
|
||||
ProjectTaskUser::transfer($this->inside->userid, $this->receiver->userid);
|
||||
$this->assertContains($this->receiver->userid, ProjectTaskHandoffRecord::owners($this->task->id));
|
||||
}
|
||||
}
|
||||
|
||||
// Exercise database changes while keeping notifications outside these transactional tests.
|
||||
class ProjectTaskHandoffFixture extends ProjectTask
|
||||
{
|
||||
protected $table = 'project_tasks';
|
||||
|
||||
public function taskPush($userids, int $type, string $suffix = '') {}
|
||||
public function syncDialogUser() {}
|
||||
public function pushMsg($action, $data = null, $userid = null, $ignoreSelf = true) {}
|
||||
public function pushMsgVisibleRemove(array $userids = []) {}
|
||||
}
|
||||
39
tests/Feature/ProjectTaskHandoffMigrationTest.php
Normal file
39
tests/Feature/ProjectTaskHandoffMigrationTest.php
Normal file
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectTaskHandoffMigrationTest extends TestCase
|
||||
{
|
||||
public function test_migration_creates_and_removes_handoff_table(): void
|
||||
{
|
||||
// 使用独立表前缀,避免迁移测试触及本机已有的流转记录。
|
||||
$connectionName = 'handoff_migration_test';
|
||||
$config = DB::connection()->getConfig();
|
||||
$config['prefix'] = 'ht_' . bin2hex(random_bytes(4)) . '_';
|
||||
config(['database.connections.' . $connectionName => $config]);
|
||||
$schema = DB::connection($connectionName)->getSchemaBuilder();
|
||||
$originalSchema = Schema::getFacadeRoot();
|
||||
$migration = require database_path('migrations/2026_09_08_000001_create_project_task_handoffs_table.php');
|
||||
|
||||
Schema::swap($schema);
|
||||
try {
|
||||
$migration->up();
|
||||
$this->assertTrue($schema->hasTable('project_task_handoffs'));
|
||||
$this->assertTrue($schema->hasColumns('project_task_handoffs', [
|
||||
'id', 'task_id', 'userid', 'source', 'record', 'created_at', 'updated_at',
|
||||
]));
|
||||
$this->assertTrue($schema->hasIndex('project_task_handoffs', ['task_id', 'id']));
|
||||
|
||||
$migration->down();
|
||||
$this->assertFalse($schema->hasTable('project_task_handoffs'));
|
||||
} finally {
|
||||
$schema->dropIfExists('project_task_handoffs');
|
||||
Schema::swap($originalSchema);
|
||||
DB::purge($connectionName);
|
||||
}
|
||||
}
|
||||
}
|
||||
93
tests/Unit/ProjectTaskHandoffTest.php
Normal file
93
tests/Unit/ProjectTaskHandoffTest.php
Normal file
@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Module\ProjectTaskHandoffService;
|
||||
use App\Module\ProjectTaskHandoffSettings;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ProjectTaskHandoffTest extends TestCase
|
||||
{
|
||||
private function policy(array $changes = []): array
|
||||
{
|
||||
return array_replace([
|
||||
'owners' => [1, 2], 'protected' => [2], 'candidates' => [1, 3],
|
||||
'note_required' => false, 'version' => 'v1',
|
||||
], $changes);
|
||||
}
|
||||
|
||||
public function test_defaults_are_disabled_and_department_scoped(): void
|
||||
{
|
||||
$settings = ProjectTaskHandoffSettings::normalize([]);
|
||||
$this->assertSame('close', $settings['project_task_handoff']);
|
||||
$this->assertSame('owner', $settings['project_task_handoff_role']);
|
||||
$this->assertSame('department', $settings['project_task_handoff_candidates']);
|
||||
$this->assertSame('department', $settings['project_task_handoff_adjust']);
|
||||
$this->assertSame('optional', $settings['project_task_handoff_note']);
|
||||
}
|
||||
|
||||
public function test_invalid_options_fall_back_without_losing_other_settings(): void
|
||||
{
|
||||
$settings = ProjectTaskHandoffSettings::normalize(['project_task_handoff' => true, 'project_task_handoff_role' => 'everyone', 'other' => 'kept']);
|
||||
$this->assertSame('close', $settings['project_task_handoff']);
|
||||
$this->assertSame('owner', $settings['project_task_handoff_role']);
|
||||
$this->assertSame('kept', $settings['other']);
|
||||
foreach (ProjectTaskHandoffSettings::OPTIONS as $key => $options) {
|
||||
foreach ($options as $option) {
|
||||
$this->assertSame($option, ProjectTaskHandoffSettings::normalize([$key => $option])[$key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function test_assignment_preserves_outside_owner(): void
|
||||
{
|
||||
$this->assertSame([2, 3], ProjectTaskHandoffService::validateOwners(['3', '2', '3'], $this->policy(), 'v1', ''));
|
||||
}
|
||||
|
||||
public function test_protected_owner_cannot_be_removed(): void
|
||||
{
|
||||
$this->expectExceptionMessage('不能移除管理范围外的负责人');
|
||||
ProjectTaskHandoffService::validateOwners([3], $this->policy(), 'v1', '');
|
||||
}
|
||||
|
||||
public function test_candidate_must_be_authorized(): void
|
||||
{
|
||||
$this->expectExceptionMessage('所选负责人不在可指派范围内');
|
||||
ProjectTaskHandoffService::validateOwners([2, 4], $this->policy(), 'v1', '');
|
||||
}
|
||||
|
||||
public function test_existing_ineligible_owner_can_be_retained(): void
|
||||
{
|
||||
$this->assertSame([1, 2, 3], ProjectTaskHandoffService::validateOwners([1, 2, 3], $this->policy(['candidates' => [3]]), 'v1', ''));
|
||||
}
|
||||
|
||||
public function test_all_adjustment_allows_replacing_outside_owner(): void
|
||||
{
|
||||
$this->assertSame([3], ProjectTaskHandoffService::validateOwners([3], $this->policy(['protected' => []]), 'v1', ''));
|
||||
}
|
||||
|
||||
public function test_stale_assignment_is_rejected(): void
|
||||
{
|
||||
$this->expectExceptionMessage('负责人已发生变化,请刷新后重试');
|
||||
ProjectTaskHandoffService::validateOwners([2, 3], $this->policy(), 'old', '');
|
||||
}
|
||||
|
||||
public function test_comment_alone_is_not_an_assignment(): void
|
||||
{
|
||||
$this->expectExceptionMessage('负责人未发生变化');
|
||||
ProjectTaskHandoffService::validateOwners([2, 1], $this->policy(), 'v1', '留言');
|
||||
}
|
||||
|
||||
public function test_required_note_is_enforced(): void
|
||||
{
|
||||
$this->expectExceptionMessage('请填写指派留言');
|
||||
ProjectTaskHandoffService::validateOwners([2, 3], $this->policy(['note_required' => true]), 'v1', '');
|
||||
}
|
||||
|
||||
public function test_ten_owner_limit_includes_protected_owners(): void
|
||||
{
|
||||
$this->expectException(ApiException::class);
|
||||
ProjectTaskHandoffService::validateOwners(range(1, 11), $this->policy(['candidates' => range(1, 11)]), 'v1', '');
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user