mirror of
https://github.com/kuaifan/dootask.git
synced 2026-06-11 01:42:36 +00:00
feat(user): 会员卡片支持查看该会员参与的项目和任务
- 新增权限闸门 UserDepartment::userWorksContext(本人/管理员/部门负责人只读,排除机器人与系统账号) - 新增接口 project/user/projects、project/user/tasks、project/user/counts - users/extra 返回 works_visible 标记控制入口显隐 - 会员卡片新增「项目与任务」入口,弹出 UserWorksModal(项目/待办/已完成三 Tab、角标计数、工作流状态徽章、懒加载) - 部门只读视角下任务仅展示全员可见(visibility=1),与 findForDepartmentView 对齐 - 补充 i18n 文案与暗色样式 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
84a90b7760
commit
fd6a8a3650
@ -1490,6 +1490,214 @@ class ProjectController extends AbstractController
|
||||
return Base::retSuccess('success', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/project/user/projects 会员参与的项目列表
|
||||
*
|
||||
* @apiDescription 需要token身份。用于会员卡片查看「该会员参与的项目」。
|
||||
* 权限:本人 / 系统管理员 / 对该会员具有部门负责人只读视角。
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup project
|
||||
* @apiName user__projects
|
||||
*
|
||||
* @apiParam {Number} userid 目标会员ID
|
||||
* @apiParam {String} [archived] 是否归档(all/yes/no),默认no
|
||||
* @apiParam {Object} [keys] 搜索条件(keys.name 项目名称)
|
||||
* @apiParam {Number} [page] 当前页,默认1
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function user__projects()
|
||||
{
|
||||
$viewer = User::auth();
|
||||
$targetId = intval(Request::input('userid'));
|
||||
$context = UserDepartment::userWorksContext($viewer, $targetId);
|
||||
if (!$context['allowed']) {
|
||||
return Base::retError('没有查看权限');
|
||||
}
|
||||
$readonly = !$context['is_self'] && !$context['is_admin'];
|
||||
//
|
||||
$archived = Request::input('archived', 'no');
|
||||
$keys = Request::input('keys');
|
||||
//
|
||||
$builder = Project::select(['projects.*', 'project_users.owner', 'project_users.top_at', 'project_users.sort'])
|
||||
->join('project_users', function ($join) use ($targetId) {
|
||||
$join->on('projects.id', '=', 'project_users.project_id')
|
||||
->where('project_users.userid', '=', $targetId);
|
||||
});
|
||||
// 部门负责人视角:限定在允许可见的项目集合内
|
||||
if ($readonly) {
|
||||
$builder->whereIn('projects.id', $context['project_ids'] ?: [0]);
|
||||
}
|
||||
//
|
||||
if ($archived == 'yes') {
|
||||
$builder->whereNotNull('projects.archived_at');
|
||||
} elseif ($archived == 'no') {
|
||||
$builder->whereNull('projects.archived_at');
|
||||
}
|
||||
if (is_array($keys) && !empty($keys['name'])) {
|
||||
$builder->where('projects.name', 'like', "%{$keys['name']}%");
|
||||
}
|
||||
//
|
||||
$list = $builder
|
||||
->orderByDesc('project_users.top_at')
|
||||
->orderBy('project_users.sort')
|
||||
->orderByDesc('projects.id')
|
||||
->paginate(Base::getPaginate(100, 50));
|
||||
$list->transform(function (Project $project) use ($targetId, $readonly) {
|
||||
$array = $project->toArray();
|
||||
$array['department_readonly'] = $readonly;
|
||||
$array = array_merge($array, $project->getTaskStatistics($targetId));
|
||||
return $array;
|
||||
});
|
||||
//
|
||||
return Base::retSuccess('success', $list);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/project/user/tasks 会员参与的任务列表
|
||||
*
|
||||
* @apiDescription 需要token身份。用于会员卡片查看「该会员参与的任务」(负责的 / 协作的)。
|
||||
* 权限:本人 / 系统管理员 / 对该会员具有部门负责人只读视角。
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup project
|
||||
* @apiName user__tasks
|
||||
*
|
||||
* @apiParam {Number} userid 目标会员ID
|
||||
* @apiParam {Number} [owner] 任务身份筛选:1=负责的,0=协作的,不传=全部
|
||||
* @apiParam {Number} [project_id] 仅查询指定项目
|
||||
* @apiParam {Object} [keys] 搜索条件(keys.name 任务名称,keys.status completed/uncompleted)
|
||||
* @apiParam {Number} [page] 当前页,默认1
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function user__tasks()
|
||||
{
|
||||
$viewer = User::auth();
|
||||
$targetId = intval(Request::input('userid'));
|
||||
$context = UserDepartment::userWorksContext($viewer, $targetId);
|
||||
if (!$context['allowed']) {
|
||||
return Base::retError('没有查看权限');
|
||||
}
|
||||
$readonly = !$context['is_self'] && !$context['is_admin'];
|
||||
//
|
||||
$owner = Request::input('owner');
|
||||
$owner = is_numeric($owner) ? intval($owner) : null;
|
||||
$project_id = intval(Request::input('project_id'));
|
||||
$keys = Request::input('keys');
|
||||
$keys = is_array($keys) ? $keys : [];
|
||||
//
|
||||
$builder = ProjectTask::with(['taskUser', 'taskTag', 'project:id,name'])
|
||||
->select(['project_tasks.*', 'project_task_users.owner'])
|
||||
->join('project_task_users', function ($join) use ($targetId) {
|
||||
$join->on('project_tasks.id', '=', 'project_task_users.task_id')
|
||||
->where('project_task_users.userid', '=', $targetId);
|
||||
});
|
||||
if ($owner !== null) {
|
||||
$builder->where('project_task_users.owner', $owner);
|
||||
}
|
||||
// 部门负责人视角:限定可见项目集合,且仅"全员可见"(visibility=1)的任务(与 findForDepartmentView 一致,避免列出打不开的任务)
|
||||
if ($readonly) {
|
||||
$builder->whereIn('project_tasks.project_id', $context['project_ids'] ?: [0]);
|
||||
$builder->where('project_tasks.visibility', 1);
|
||||
}
|
||||
if ($project_id > 0) {
|
||||
$builder->where('project_tasks.project_id', $project_id);
|
||||
}
|
||||
if (!empty($keys['name'])) {
|
||||
$builder->where(function ($query) use ($keys) {
|
||||
$query->where('project_tasks.name', 'like', "%{$keys['name']}%")
|
||||
->orWhere('project_tasks.desc', 'like', "%{$keys['name']}%");
|
||||
});
|
||||
}
|
||||
if (!empty($keys['status'])) {
|
||||
if ($keys['status'] == 'completed') {
|
||||
$builder->whereNotNull('project_tasks.complete_at');
|
||||
} elseif ($keys['status'] == 'uncompleted') {
|
||||
$builder->whereNull('project_tasks.complete_at');
|
||||
}
|
||||
}
|
||||
$builder->whereNull('project_tasks.archived_at');
|
||||
//
|
||||
$list = $builder->orderByDesc('project_tasks.id')->paginate(Base::getPaginate(100, 50));
|
||||
$list->transform(function (ProjectTask $task) use ($readonly) {
|
||||
$task->setAppends(['today', 'overdue']);
|
||||
$array = $task->toArray();
|
||||
$array['project_name'] = $array['project']['name'] ?? '';
|
||||
$array['department_readonly'] = $readonly;
|
||||
unset($array['project']);
|
||||
return $array;
|
||||
});
|
||||
//
|
||||
return Base::retSuccess('success', $list);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/project/user/counts 会员参与的项目/任务数量
|
||||
*
|
||||
* @apiDescription 需要token身份。用于会员卡片「项目与任务」弹窗的 Tab 角标,仅返回数量(轻量)。
|
||||
* 权限:本人 / 系统管理员 / 对该会员具有部门负责人只读视角。
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup project
|
||||
* @apiName user__counts
|
||||
*
|
||||
* @apiParam {Number} userid 目标会员ID
|
||||
* @apiParam {Number} [owner] 任务身份筛选:1=负责的,0=协作的,不传=全部(仅影响任务数量)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data {project, todo, done}
|
||||
*/
|
||||
public function user__counts()
|
||||
{
|
||||
$viewer = User::auth();
|
||||
$targetId = intval(Request::input('userid'));
|
||||
$context = UserDepartment::userWorksContext($viewer, $targetId);
|
||||
if (!$context['allowed']) {
|
||||
return Base::retError('没有查看权限');
|
||||
}
|
||||
$readonly = !$context['is_self'] && !$context['is_admin'];
|
||||
$owner = Request::input('owner');
|
||||
$owner = is_numeric($owner) ? intval($owner) : null;
|
||||
//
|
||||
$projectBuilder = Project::join('project_users', function ($join) use ($targetId) {
|
||||
$join->on('projects.id', '=', 'project_users.project_id')
|
||||
->where('project_users.userid', '=', $targetId);
|
||||
})
|
||||
->whereNull('projects.archived_at');
|
||||
if ($readonly) {
|
||||
$projectBuilder->whereIn('projects.id', $context['project_ids'] ?: [0]);
|
||||
}
|
||||
$projectCount = $projectBuilder->distinct()->count('projects.id');
|
||||
//
|
||||
$taskBuilder = function () use ($targetId, $owner, $readonly, $context) {
|
||||
$builder = ProjectTask::join('project_task_users', function ($join) use ($targetId) {
|
||||
$join->on('project_tasks.id', '=', 'project_task_users.task_id')
|
||||
->where('project_task_users.userid', '=', $targetId);
|
||||
})
|
||||
->whereNull('project_tasks.archived_at');
|
||||
if ($owner !== null) {
|
||||
$builder->where('project_task_users.owner', $owner);
|
||||
}
|
||||
if ($readonly) {
|
||||
$builder->whereIn('project_tasks.project_id', $context['project_ids'] ?: [0]);
|
||||
$builder->where('project_tasks.visibility', 1);
|
||||
}
|
||||
return $builder;
|
||||
};
|
||||
$todoCount = $taskBuilder()->whereNull('project_tasks.complete_at')->count();
|
||||
$doneCount = $taskBuilder()->whereNotNull('project_tasks.complete_at')->count();
|
||||
//
|
||||
return Base::retSuccess('success', [
|
||||
'project' => $projectCount,
|
||||
'todo' => $todoCount,
|
||||
'done' => $doneCount,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/project/task/easylists 任务列表-简单的
|
||||
*
|
||||
|
||||
@ -884,7 +884,8 @@ class UsersController extends AbstractController
|
||||
*/
|
||||
public function extra()
|
||||
{
|
||||
$user = User::auth();
|
||||
$viewer = User::auth();
|
||||
$user = $viewer;
|
||||
//
|
||||
$userid = intval(Request::input('userid'));
|
||||
if ($userid <= 0) {
|
||||
@ -919,6 +920,8 @@ class UsersController extends AbstractController
|
||||
|
||||
$tagMeta = UserTag::listWithMeta($userid, $user);
|
||||
|
||||
$worksContext = UserDepartment::userWorksContext($viewer, $userid);
|
||||
|
||||
$data = [
|
||||
'userid' => $userid,
|
||||
'birthday' => $birthday,
|
||||
@ -926,6 +929,7 @@ class UsersController extends AbstractController
|
||||
'introduction' => $introduction,
|
||||
'personal_tags' => $tagMeta['top'],
|
||||
'personal_tags_total' => $tagMeta['total'],
|
||||
'works_visible' => $worksContext['allowed'],
|
||||
];
|
||||
|
||||
return Base::retSuccess('success', $data);
|
||||
|
||||
@ -615,4 +615,69 @@ class UserDepartment extends AbstractModel
|
||||
return $project;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会员卡片「查看该会员项目/任务」的权限上下文。
|
||||
* 允许条件:本人 / 系统管理员 / 对该会员具有部门负责人只读视角。
|
||||
* @param User $viewer 当前登录用户
|
||||
* @param int $targetUserid 目标会员
|
||||
* @return array ['allowed'=>bool, 'is_self'=>bool, 'is_admin'=>bool, 'project_ids'=>int[]]
|
||||
* project_ids 仅在部门负责人视角下有意义(限定可见项目集合);本人/管理员为空数组表示不限制
|
||||
*/
|
||||
public static function userWorksContext(User $viewer, int $targetUserid): array
|
||||
{
|
||||
$result = [
|
||||
'allowed' => false,
|
||||
'is_self' => false,
|
||||
'is_admin' => false,
|
||||
'project_ids' => [],
|
||||
];
|
||||
if ($targetUserid <= 0) {
|
||||
return $result;
|
||||
}
|
||||
// 机器人/系统账号(或不存在)不展示项目与任务
|
||||
$target = User::select(['userid', 'bot'])->whereUserid($targetUserid)->first();
|
||||
if (empty($target) || $target->bot) {
|
||||
return $result;
|
||||
}
|
||||
// 本人
|
||||
if ($viewer->userid === $targetUserid) {
|
||||
$result['allowed'] = true;
|
||||
$result['is_self'] = true;
|
||||
return $result;
|
||||
}
|
||||
// 系统管理员
|
||||
if ($viewer->isAdmin()) {
|
||||
$result['allowed'] = true;
|
||||
$result['is_admin'] = true;
|
||||
return $result;
|
||||
}
|
||||
// 部门负责人只读视角
|
||||
if (Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') {
|
||||
return $result;
|
||||
}
|
||||
$memberUserids = self::getManagedMemberUserids($viewer->userid, 'all');
|
||||
if (!in_array($targetUserid, $memberUserids, true)) {
|
||||
return $result;
|
||||
}
|
||||
// 目标会员参与、且未关闭「部门负责人视角可见」的项目
|
||||
$projectIds = ProjectUser::where('project_users.userid', $targetUserid)
|
||||
->join('projects', 'projects.id', '=', 'project_users.project_id')
|
||||
->whereNull('projects.deleted_at')
|
||||
->where(function ($query) {
|
||||
$query->where('projects.department_owner_view', '<>', 'close')
|
||||
->orWhereNull('projects.department_owner_view');
|
||||
})
|
||||
->distinct()
|
||||
->pluck('projects.id')
|
||||
->map(fn($v) => intval($v))
|
||||
->values()
|
||||
->toArray();
|
||||
if (empty($projectIds)) {
|
||||
return $result;
|
||||
}
|
||||
$result['allowed'] = true;
|
||||
$result['project_ids'] = $projectIds;
|
||||
return $result;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -994,3 +994,4 @@ LDAP 用户缺少邮箱属性,请联系管理员配置
|
||||
你有一条待办到提醒时间啦
|
||||
发送者昵称最多不能超过20字
|
||||
AI 助手
|
||||
没有查看权限
|
||||
|
||||
@ -2440,3 +2440,12 @@ AI任务分析
|
||||
暂无完成
|
||||
取消提醒
|
||||
确定取消该成员的提醒时间吗?
|
||||
项目与任务
|
||||
暂无项目
|
||||
暂无任务
|
||||
负责
|
||||
协作
|
||||
成员
|
||||
(*)分钟前
|
||||
(*)小时前
|
||||
(*)天前
|
||||
|
||||
@ -26,8 +26,12 @@
|
||||
</h1>
|
||||
<div class="meta">
|
||||
<span @click="commonDialogShow = true" class="common-dialog">{{ $L(userId == userData.userid ? "我的群组" : "共同群组") }}:<em>{{ $L("(*)个", commonDialog.total) }}</em></span>
|
||||
<template v-if="worksVisible">
|
||||
<span class="separator">|</span>
|
||||
<span @click="worksModalShow = true" class="common-dialog works-entry">{{ $L("项目与任务") }}</span>
|
||||
</template>
|
||||
<span class="separator">|</span>
|
||||
<span>{{ $L("最后在线") }}: {{$A.newDateString( userData.line_at, "YYYY-MM-DD HH:mm") || "-"}}</span>
|
||||
<span :title="lineAtDisplay.title">{{ $L("最后在线") }}: {{ lineAtDisplay.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -115,6 +119,13 @@
|
||||
:total-count="commonDialog.total || 0"
|
||||
@open-chat="onOpenCommonDialogChat"
|
||||
/>
|
||||
|
||||
<UserWorksModal
|
||||
v-if="worksVisible && userData.userid"
|
||||
v-model="worksModalShow"
|
||||
:target-user-id="userData.userid"
|
||||
@navigate="onHide"
|
||||
/>
|
||||
</ModalAlive>
|
||||
</template>
|
||||
|
||||
@ -124,11 +135,12 @@ import { mapState } from "vuex";
|
||||
import transformEmojiToHtml from "../../../utils/emoji";
|
||||
import UserTagsModal from "./UserTagsModal.vue";
|
||||
import CommonDialogModal from "./CommonDialogModal.vue";
|
||||
import UserWorksModal from "./UserWorksModal.vue";
|
||||
|
||||
export default {
|
||||
name: "UserDetail",
|
||||
|
||||
components: { UserTagsModal, CommonDialogModal },
|
||||
components: { UserTagsModal, CommonDialogModal, UserWorksModal },
|
||||
|
||||
data() {
|
||||
return {
|
||||
@ -146,6 +158,7 @@ export default {
|
||||
},
|
||||
commonDialogShow: false,
|
||||
commonDialogLoading: 0,
|
||||
worksModalShow: false,
|
||||
};
|
||||
},
|
||||
|
||||
@ -189,6 +202,36 @@ export default {
|
||||
commonDialogList() {
|
||||
return this.commonDialog.list || [];
|
||||
},
|
||||
|
||||
worksVisible() {
|
||||
return !!this.userData.works_visible;
|
||||
},
|
||||
|
||||
lineAtDisplay({ userData }) {
|
||||
const value = userData.line_at;
|
||||
if (!value) {
|
||||
return { text: "-", title: "" };
|
||||
}
|
||||
const now = $A.daytz();
|
||||
const line = $A.dayjs(value);
|
||||
const title = line.format("YYYY-MM-DD HH:mm");
|
||||
const seconds = now.unix() - line.unix();
|
||||
let text;
|
||||
if (seconds < 60) {
|
||||
text = this.$L("刚刚");
|
||||
} else if (seconds < 3600) {
|
||||
text = this.$L("(*)分钟前", Math.floor(seconds / 60));
|
||||
} else if (seconds < 3600 * 24) {
|
||||
text = this.$L("(*)小时前", Math.floor(seconds / 3600));
|
||||
} else if (seconds < 3600 * 24 * 7) {
|
||||
text = this.$L("(*)天前", Math.floor(seconds / 86400));
|
||||
} else if (line.isAfter(now.clone().subtract(1, "month"))) {
|
||||
text = line.format("MM-DD HH:mm");
|
||||
} else {
|
||||
text = line.format("YYYY-MM-DD");
|
||||
}
|
||||
return { text, title };
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
@ -219,6 +262,7 @@ export default {
|
||||
this.showModal = false;
|
||||
this.tagModalVisible = false;
|
||||
this.commonDialogShow = false;
|
||||
this.worksModalShow = false;
|
||||
},
|
||||
|
||||
onOpenAvatar() {
|
||||
|
||||
328
resources/assets/js/pages/manage/components/UserWorksModal.vue
Normal file
328
resources/assets/js/pages/manage/components/UserWorksModal.vue
Normal file
@ -0,0 +1,328 @@
|
||||
<template>
|
||||
<Modal
|
||||
v-model="visibleProxy"
|
||||
class-name="user-works-modal"
|
||||
:title="$L('项目与任务')"
|
||||
:footer-hide="true"
|
||||
width="560">
|
||||
|
||||
<RadioGroup v-if="activeTab !== 'projects'" v-model="taskOwner" type="button" size="small" class="task-owner-filter" @on-change="onOwnerChange">
|
||||
<Radio label="all">{{$L('全部')}}</Radio>
|
||||
<Radio label="1">{{$L('负责')}}</Radio>
|
||||
<Radio label="0">{{$L('协作')}}</Radio>
|
||||
</RadioGroup>
|
||||
|
||||
<Tabs v-model="activeTab" class="user-works-tabs">
|
||||
<!-- 项目 -->
|
||||
<TabPane :label="projectsTabLabel" name="projects">
|
||||
<div class="user-works-content">
|
||||
<div v-if="projects.loading > 0 && projects.list.length === 0" class="loading-wrapper">
|
||||
<Loading/>
|
||||
</div>
|
||||
<div v-else-if="projects.list.length === 0" class="empty-wrapper">
|
||||
<div class="empty-content">
|
||||
<Icon type="ios-folder-outline" size="48"/>
|
||||
<p>{{$L('暂无项目')}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="works-list">
|
||||
<div
|
||||
v-for="project in projects.list"
|
||||
:key="project.id"
|
||||
class="works-item"
|
||||
@click="onOpenProject(project)">
|
||||
<div class="works-icon project">
|
||||
<i class="taskfont"></i>
|
||||
</div>
|
||||
<div class="works-info">
|
||||
<div class="works-name" v-html="transformEmojiToHtml(project.name)"></div>
|
||||
<div class="works-meta">
|
||||
<span class="role-badge" :class="ownerClass(project.owner)">{{ownerText(project.owner)}}</span>
|
||||
<span class="works-stat">{{$L('任务')}} {{project.task_complete || 0}}/{{project.task_num || 0}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Icon class="enter-icon" type="ios-arrow-forward"/>
|
||||
</div>
|
||||
<div v-if="projects.hasMore" class="load-more-wrapper">
|
||||
<Button type="primary" @click="loadProjects(true)" :loading="projects.loading > 0">{{$L('加载更多')}}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabPane>
|
||||
<!-- 待办任务 / 已完成任务 -->
|
||||
<TabPane v-for="tab in taskTabs" :key="tab.key" :label="taskTabLabel(tab.key)" :name="tab.key">
|
||||
<div class="user-works-content tasks">
|
||||
<div v-if="tabState(tab.key).loading > 0 && tabState(tab.key).list.length === 0" class="loading-wrapper">
|
||||
<Loading/>
|
||||
</div>
|
||||
<div v-else-if="tabState(tab.key).list.length === 0" class="empty-wrapper">
|
||||
<div class="empty-content">
|
||||
<Icon type="ios-list-box-outline" size="48"/>
|
||||
<p>{{$L('暂无任务')}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="works-list">
|
||||
<div
|
||||
v-for="task in tabState(tab.key).list"
|
||||
:key="task.id"
|
||||
class="works-item"
|
||||
@click="onOpenTask(task)">
|
||||
<div class="works-icon task" :class="{completed: !!task.complete_at}">
|
||||
<i class="taskfont"></i>
|
||||
</div>
|
||||
<div class="works-info">
|
||||
<div class="works-name" :class="{completed: !!task.complete_at}" v-html="transformEmojiToHtml(task.name)"></div>
|
||||
<div class="works-meta">
|
||||
<span
|
||||
v-if="task.flow_item_name"
|
||||
class="flow-name"
|
||||
:class="task.flow_item_status"
|
||||
:style="$A.generateColorVarStyle(task.flow_item_color, [10], 'flow-item-custom-color')">{{task.flow_item_name}}</span>
|
||||
<span v-else-if="task.complete_at" class="flow-name end">{{$L('已完成')}}</span>
|
||||
<span class="role-badge" :class="task.owner ? 'owner' : 'assist'">{{task.owner ? $L('负责') : $L('协作')}}</span>
|
||||
<span v-if="task.project_name" class="works-project">{{task.project_name}}</span>
|
||||
<span v-if="task.end_at" class="works-time" :class="{overdue: task.overdue}">{{$A.timeFormat(task.end_at)}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Icon class="enter-icon" type="ios-arrow-forward"/>
|
||||
</div>
|
||||
<div v-if="tabState(tab.key).hasMore" class="load-more-wrapper">
|
||||
<Button type="primary" @click="loadTasks(tab.key, true)" :loading="tabState(tab.key).loading > 0">{{$L('加载更多')}}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import transformEmojiToHtml from "../../../utils/emoji";
|
||||
|
||||
const emptyState = () => ({list: [], page: 1, hasMore: false, loading: 0, total: null});
|
||||
|
||||
export default {
|
||||
name: 'UserWorksModal',
|
||||
|
||||
props: {
|
||||
value: { // v-model
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
targetUserId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
activeTab: 'projects',
|
||||
taskOwner: 'all',
|
||||
taskTabs: [
|
||||
{key: 'todo', status: 'uncompleted'},
|
||||
{key: 'done', status: 'completed'},
|
||||
],
|
||||
counts: {project: null, todo: null, done: null},
|
||||
projects: emptyState(),
|
||||
todo: emptyState(),
|
||||
done: emptyState(),
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
visibleProxy: {
|
||||
get() { return this.value; },
|
||||
set(v) { this.$emit('input', v); }
|
||||
},
|
||||
|
||||
projectsTabLabel() {
|
||||
return this.withCount(this.$L('项目'), this.counts.project);
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
visibleProxy(val) {
|
||||
if (val) {
|
||||
this.loadInitial();
|
||||
}
|
||||
},
|
||||
activeTab(key) {
|
||||
// 切换到尚未加载的任务 Tab 时才加载(懒加载)
|
||||
if (key !== 'projects' && this.tabState(key).list.length === 0 && this.tabState(key).loading === 0) {
|
||||
this.loadTasks(key, false);
|
||||
}
|
||||
},
|
||||
targetUserId() {
|
||||
this.resetState();
|
||||
if (this.visibleProxy) {
|
||||
this.loadInitial();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
transformEmojiToHtml,
|
||||
|
||||
tabState(key) {
|
||||
return this[key];
|
||||
},
|
||||
|
||||
withCount(text, total) {
|
||||
return total == null ? text : `${text} (${total})`;
|
||||
},
|
||||
|
||||
taskTabLabel(key) {
|
||||
const text = key === 'todo' ? this.$L('待办') : this.$L('已完成');
|
||||
return this.withCount(text, this.counts[key]);
|
||||
},
|
||||
|
||||
resetState() {
|
||||
this.counts = {project: null, todo: null, done: null};
|
||||
this.projects = emptyState();
|
||||
this.todo = emptyState();
|
||||
this.done = emptyState();
|
||||
},
|
||||
|
||||
// 打开时:拉取轻量计数(用于 Tab 角标)+ 仅加载当前激活 Tab 的列表,其余 Tab 首次激活再加载
|
||||
loadInitial() {
|
||||
this.loadCounts();
|
||||
this.loadActive();
|
||||
},
|
||||
|
||||
loadActive() {
|
||||
if (this.activeTab === 'projects') {
|
||||
if (this.projects.list.length === 0 && this.projects.loading === 0) {
|
||||
this.loadProjects(false);
|
||||
}
|
||||
} else {
|
||||
const state = this.tabState(this.activeTab);
|
||||
if (state.list.length === 0 && state.loading === 0) {
|
||||
this.loadTasks(this.activeTab, false);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
loadCounts() {
|
||||
if (!this.targetUserId) return;
|
||||
const params = {userid: this.targetUserId};
|
||||
if (this.taskOwner !== 'all') {
|
||||
params.owner = this.taskOwner;
|
||||
}
|
||||
this.$store.dispatch('call', {
|
||||
url: 'project/user/counts',
|
||||
data: params,
|
||||
}).then(({data}) => {
|
||||
this.counts = {
|
||||
project: typeof data.project === 'number' ? data.project : 0,
|
||||
todo: typeof data.todo === 'number' ? data.todo : 0,
|
||||
done: typeof data.done === 'number' ? data.done : 0,
|
||||
};
|
||||
}).catch(() => {
|
||||
// 计数失败不打断列表展示,静默处理
|
||||
});
|
||||
},
|
||||
|
||||
loadProjects(loadMore = false) {
|
||||
if (!this.targetUserId) return;
|
||||
this.projects.loading++;
|
||||
const page = loadMore ? this.projects.page + 1 : 1;
|
||||
this.$store.dispatch('call', {
|
||||
url: 'project/user/projects',
|
||||
data: {
|
||||
userid: this.targetUserId,
|
||||
page,
|
||||
}
|
||||
}).then(({data}) => {
|
||||
const list = Array.isArray(data.data) ? data.data : [];
|
||||
this.projects.list = loadMore ? [...this.projects.list, ...list] : list;
|
||||
this.projects.page = data.current_page || page;
|
||||
this.projects.hasMore = !!data.next_page_url;
|
||||
this.projects.total = typeof data.total === 'number' ? data.total : list.length;
|
||||
this.counts.project = this.projects.total;
|
||||
}).catch(({msg}) => {
|
||||
$A.modalError(msg || this.$L('加载失败'));
|
||||
}).finally(() => {
|
||||
this.projects.loading--;
|
||||
});
|
||||
},
|
||||
|
||||
loadTasks(key, loadMore = false) {
|
||||
if (!this.targetUserId) return;
|
||||
const state = this.tabState(key);
|
||||
const tab = this.taskTabs.find(t => t.key === key);
|
||||
state.loading++;
|
||||
const page = loadMore ? state.page + 1 : 1;
|
||||
const params = {
|
||||
userid: this.targetUserId,
|
||||
page,
|
||||
keys: {status: tab.status},
|
||||
};
|
||||
if (this.taskOwner !== 'all') {
|
||||
params.owner = this.taskOwner;
|
||||
}
|
||||
this.$store.dispatch('call', {
|
||||
url: 'project/user/tasks',
|
||||
data: params,
|
||||
}).then(({data}) => {
|
||||
const list = (Array.isArray(data.data) ? data.data : []).map(t => this.normalizeFlow(t));
|
||||
state.list = loadMore ? [...state.list, ...list] : list;
|
||||
state.page = data.current_page || page;
|
||||
state.hasMore = !!data.next_page_url;
|
||||
state.total = typeof data.total === 'number' ? data.total : list.length;
|
||||
this.counts[key] = state.total;
|
||||
}).catch(({msg}) => {
|
||||
$A.modalError(msg || this.$L('加载失败'));
|
||||
}).finally(() => {
|
||||
state.loading--;
|
||||
});
|
||||
},
|
||||
|
||||
onOwnerChange() {
|
||||
// 负责/协作筛选影响任务数量:重置两个任务 Tab 列表、刷新计数,仅重新加载当前激活的任务 Tab,另一个首次激活再加载
|
||||
this.taskTabs.forEach(tab => {
|
||||
this[tab.key] = emptyState();
|
||||
});
|
||||
this.loadCounts();
|
||||
this.loadActive();
|
||||
},
|
||||
|
||||
// 解析工作流状态(flow_item_name 原始格式为 status|name|color)
|
||||
normalizeFlow(task) {
|
||||
if (task.flow_item_name && task.flow_item_name.indexOf('|') !== -1) {
|
||||
const info = $A.convertWorkflow(task.flow_item_name);
|
||||
task.flow_item_status = info.status;
|
||||
task.flow_item_name = info.name;
|
||||
task.flow_item_color = info.color;
|
||||
}
|
||||
return task;
|
||||
},
|
||||
|
||||
ownerText(owner) {
|
||||
if (owner === 1) return this.$L('负责人');
|
||||
if (owner === 2) return this.$L('管理员');
|
||||
return this.$L('成员');
|
||||
},
|
||||
|
||||
ownerClass(owner) {
|
||||
if (owner === 1) return 'owner';
|
||||
if (owner === 2) return 'deputy';
|
||||
return 'member';
|
||||
},
|
||||
|
||||
onOpenProject(project) {
|
||||
$A.goForward({name: 'manage-project', params: {projectId: project.id}});
|
||||
this.$emit('navigate');
|
||||
},
|
||||
|
||||
onOpenTask(task) {
|
||||
// 打开任务在全局任务窗口中展示,保持本弹窗不关闭
|
||||
this.$store.dispatch('openTask', task);
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 组件自身不引入额外样式,复用全局样式类名 */
|
||||
</style>
|
||||
14
resources/assets/sass/dark.scss
vendored
14
resources/assets/sass/dark.scss
vendored
@ -813,4 +813,18 @@ body.dark-mode-reverse {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-works-modal {
|
||||
.user-works-content {
|
||||
.works-list {
|
||||
.works-item {
|
||||
.works-icon {
|
||||
> i {
|
||||
color: #000000;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -360,3 +360,229 @@
|
||||
padding: 12px 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 会员项目与任务弹窗样式
|
||||
.user-works-modal {
|
||||
.ivu-modal-body {
|
||||
padding: 0 !important;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
.task-owner-filter {
|
||||
position: absolute;
|
||||
top: 48px;
|
||||
right: 12px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.user-works-tabs {
|
||||
.ivu-tabs-bar {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.user-works-content {
|
||||
|
||||
&.tasks {
|
||||
.works-list {
|
||||
> div:first-child {
|
||||
margin-top: 28px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding-top: 60px;
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
|
||||
.empty-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding-top: 40px;
|
||||
padding-bottom: 80px;
|
||||
|
||||
.empty-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
color: #999;
|
||||
> i {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.works-list {
|
||||
overflow-y: auto;
|
||||
max-height: calc(var(--window-height) - var(--status-bar-height) - var(--navigation-bar-height) - 310px);
|
||||
|
||||
@media (height <= 900px) {
|
||||
max-height: calc(var(--window-height) - var(--status-bar-height) - var(--navigation-bar-height) - 180px);
|
||||
}
|
||||
|
||||
.works-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
margin: 4px 0;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.works-icon {
|
||||
flex-shrink: 0;
|
||||
margin-right: 12px;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
color: #ffffff;
|
||||
|
||||
> i {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
&.project {
|
||||
background-color: #6E99EB;
|
||||
}
|
||||
|
||||
&.task {
|
||||
background-color: #9B96DF;
|
||||
|
||||
&.completed {
|
||||
background-color: #c5c8ce;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.works-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.works-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #17233d;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 4px;
|
||||
|
||||
&.completed {
|
||||
color: #808695;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
.works-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
|
||||
.role-badge {
|
||||
flex-shrink: 0;
|
||||
padding: 0 6px;
|
||||
line-height: 18px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
background-color: #f0f2f5;
|
||||
color: #808695;
|
||||
|
||||
&.owner {
|
||||
background-color: #e8f4ff;
|
||||
color: #2d8cf0;
|
||||
}
|
||||
|
||||
&.deputy {
|
||||
background-color: #fff3e0;
|
||||
color: #ff9900;
|
||||
}
|
||||
}
|
||||
|
||||
// 工作流状态徽章(与项目面板/收藏列表保持一致)
|
||||
.flow-name {
|
||||
flex-shrink: 0;
|
||||
padding: 0 6px;
|
||||
line-height: 18px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
border: 1px solid transparent;
|
||||
|
||||
&.start {
|
||||
background-color: var(--flow-item-custom-color-10, rgba($flow-status-start-color, 0.1));
|
||||
border-color: var(--flow-item-custom-color-10, rgba($flow-status-start-color, 0.1));
|
||||
color: var(--flow-item-custom-color-100, $flow-status-start-color);
|
||||
}
|
||||
&.progress {
|
||||
background-color: var(--flow-item-custom-color-10, rgba($flow-status-progress-color, 0.1));
|
||||
border-color: var(--flow-item-custom-color-10, rgba($flow-status-progress-color, 0.1));
|
||||
color: var(--flow-item-custom-color-100, $flow-status-progress-color);
|
||||
}
|
||||
&.test {
|
||||
background-color: var(--flow-item-custom-color-10, rgba($flow-status-test-color, 0.1));
|
||||
border-color: var(--flow-item-custom-color-10, rgba($flow-status-test-color, 0.1));
|
||||
color: var(--flow-item-custom-color-100, $flow-status-test-color);
|
||||
}
|
||||
&.end {
|
||||
background-color: var(--flow-item-custom-color-10, rgba($flow-status-end-color, 0.1));
|
||||
border-color: var(--flow-item-custom-color-10, rgba($flow-status-end-color, 0.1));
|
||||
color: var(--flow-item-custom-color-100, $flow-status-end-color);
|
||||
}
|
||||
&.archived {
|
||||
background-color: var(--flow-item-custom-color-10, rgba($flow-status-archived-color, 0.1));
|
||||
border-color: var(--flow-item-custom-color-10, rgba($flow-status-archived-color, 0.1));
|
||||
color: var(--flow-item-custom-color-100, $flow-status-archived-color);
|
||||
}
|
||||
}
|
||||
|
||||
.works-project,
|
||||
.works-stat {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.works-time {
|
||||
flex-shrink: 0;
|
||||
|
||||
&.overdue {
|
||||
color: #ed4014;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.enter-icon {
|
||||
flex-shrink: 0;
|
||||
color: #c5c8ce;
|
||||
font-size: 16px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.load-more-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user