mirror of
https://github.com/kuaifan/dootask.git
synced 2026-07-22 22:18:10 +00:00
feat(dashboard): 完善负责人视角界面与范围切换
This commit is contained in:
parent
7ad27e3326
commit
71ca6ba249
@ -357,6 +357,21 @@ SMTP服务器
|
||||
任务提醒
|
||||
职位/职称
|
||||
验收/测试
|
||||
(*) 超期
|
||||
未来 3 天内暂无到期任务
|
||||
暂无高优先级任务
|
||||
所有任务均已分配负责人
|
||||
当前筛选条件下暂无任务
|
||||
可以切换上方条件查看其他任务
|
||||
数据更新说明
|
||||
页面统计数据在 60 秒内复用,重点关注任务列表除外。
|
||||
上次更新:(*)
|
||||
立即刷新
|
||||
刷新成功
|
||||
查看全部 (*) 个优先级
|
||||
任务概况
|
||||
流程阶段
|
||||
未完成总数
|
||||
上传图片
|
||||
上传失败
|
||||
上传成功
|
||||
@ -2599,3 +2614,9 @@ AI任务分析
|
||||
本周 (*) 项
|
||||
当前没有待处理任务
|
||||
可以新建一项任务,或等待新的工作安排
|
||||
选择需要统计的部门,包含所选部门及其所有下级部门。
|
||||
部门范围与负责人仪表盘保持一致;开启后,可只读查看范围内成员参与的项目和任务。
|
||||
项目负责人视角
|
||||
开启后在项目列表中显示额外的只读项目
|
||||
选择团队范围
|
||||
在项目列表中启用
|
||||
|
||||
@ -649,6 +649,7 @@ export default {
|
||||
'mobileTabbar',
|
||||
'longpressData',
|
||||
'departmentOwnerProjectsRefreshing',
|
||||
'departmentOwnerProjectViewEnabled',
|
||||
|
||||
'mcpServerStatus',
|
||||
'microAppsIds'
|
||||
@ -823,8 +824,8 @@ export default {
|
||||
name: '负责人视角',
|
||||
divided: !userIsAdmin,
|
||||
visible: true,
|
||||
selected: this.cacheDepartmentOwnerIds.length > 0,
|
||||
selectedCount: this.cacheDepartmentOwnerIds.length,
|
||||
selected: this.departmentOwnerProjectViewEnabled,
|
||||
selectedCount: this.departmentOwnerProjectViewEnabled ? this.cacheDepartmentOwnerIds.length : 0,
|
||||
});
|
||||
}
|
||||
array.push(...[
|
||||
@ -872,7 +873,7 @@ export default {
|
||||
},
|
||||
|
||||
ownerProjectTabsVisible() {
|
||||
return this.departmentOwnerViewAvailable && this.cacheDepartmentOwnerIds.length > 0;
|
||||
return this.departmentOwnerViewAvailable && this.departmentOwnerProjectViewEnabled;
|
||||
},
|
||||
|
||||
ownerProjectTabs() {
|
||||
|
||||
@ -40,48 +40,73 @@
|
||||
<div class="card-head">
|
||||
<span class="head-title">{{$L('成员任务分配')}}</span>
|
||||
<span class="head-legend">
|
||||
<span><i style="background:#1e9e55"></i>{{$L('进行中')}}</span>
|
||||
<span><i style="background:#bfe3cd"></i>{{$L('待处理')}}</span>
|
||||
<span v-if="hasTestSegment"><i style="background:#6ba7d8"></i>{{$L('待测试')}}</span>
|
||||
<span><i style="background:#d94f46"></i>{{$L('已超期')}}</span>
|
||||
<span><i style="background:#1e9e55"></i>{{$L('进行中')}}</span>
|
||||
<span><i style="background:#6ba7d8"></i>{{$L('验收/测试')}}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ul class="member-list">
|
||||
<li v-for="member in visibleMembers" :key="member.nickname" class="member-row" @click="onMember(member)">
|
||||
<span class="mock-avatar" :style="{backgroundColor: member.color}">{{member.nickname.substring(0, 1)}}</span>
|
||||
<span class="member-name">{{member.nickname}}</span>
|
||||
<span class="member-track">
|
||||
<span class="member-bar" :style="{width: memberBarWidth(member)}">
|
||||
<i v-if="member.segments.progress > 0" :style="{flex: member.segments.progress, background: '#1e9e55'}"></i>
|
||||
<i v-if="member.segments.start > 0" :style="{flex: member.segments.start, background: '#bfe3cd'}"></i>
|
||||
<i v-if="member.segments.test > 0" :style="{flex: member.segments.test, background: '#6ba7d8'}"></i>
|
||||
<i v-if="member.overdue > 0" :style="{flex: member.overdue, background: '#d94f46'}"></i>
|
||||
<div v-if="showStatsLoading" class="team-stats-loading"><Loading/></div>
|
||||
<template v-else>
|
||||
<ul class="member-list">
|
||||
<li v-for="member in visibleMembers" :key="member.userid" class="member-row" @click="onMember(member)">
|
||||
<UserAvatar class="team-avatar" :userid="member.userid" :size="26"/>
|
||||
<span class="member-name">{{member.nickname}}</span>
|
||||
<ETooltip
|
||||
:disabled="$isEEUIApp || $store.state.windowTouch || member.total === 0"
|
||||
:open-delay="300"
|
||||
effect="light"
|
||||
placement="top"
|
||||
popper-class="dashboard-member-progress-popper">
|
||||
<span class="member-track">
|
||||
<span class="member-bar" :style="{width: memberBarWidth(member)}">
|
||||
<i v-if="member.segments.start > 0" :style="{flex: member.segments.start, background: '#bfe3cd'}"></i>
|
||||
<i v-if="member.segments.progress > 0" :style="{flex: member.segments.progress, background: '#1e9e55'}"></i>
|
||||
<i v-if="member.segments.test > 0" :style="{flex: member.segments.test, background: '#6ba7d8'}"></i>
|
||||
</span>
|
||||
</span>
|
||||
<div slot="content" class="member-progress-tooltip">
|
||||
<span class="progress-tooltip-title">{{$L('任务概况')}}</span>
|
||||
<div class="progress-tooltip-row">
|
||||
<span class="progress-tooltip-label">{{$L('未完成总数')}}</span>
|
||||
<strong>{{$L('(*) 项', member.total)}}</strong>
|
||||
</div>
|
||||
<div class="progress-tooltip-row row-overdue">
|
||||
<span class="progress-tooltip-label">{{$L('已超期')}}</span>
|
||||
<strong>{{$L('(*) 项', member.overdue)}} · {{memberOverduePercent(member)}}%</strong>
|
||||
</div>
|
||||
<span class="progress-tooltip-title title-stage">{{$L('流程阶段')}}</span>
|
||||
<div v-for="segment in memberProgressDetails(member)" :key="segment.type" class="progress-tooltip-row">
|
||||
<span class="progress-tooltip-label"><i :style="{background: segment.color}"></i>{{segment.label}}</span>
|
||||
<strong>{{$L('(*) 项', segment.count)}} · {{segment.percent}}%</strong>
|
||||
</div>
|
||||
</div>
|
||||
</ETooltip>
|
||||
<span class="member-num" :title="memberSummary(member)">
|
||||
<em v-if="member.overdue > 0" class="num-overdue">{{$L('(*) 超期', member.overdue)}} · </em>{{$L('(*) 项', member.total)}}
|
||||
</span>
|
||||
</span>
|
||||
<span class="member-num">
|
||||
<em v-if="member.overdue > 0" class="num-overdue">{{member.overdue}} {{$L('超期')}} · </em>{{$L('(*) 项', member.total)}}
|
||||
</span>
|
||||
</li>
|
||||
<li v-if="blocks.no_owner > 0" class="member-row member-noowner" @click="onBlock('noowner')">
|
||||
<span class="mock-avatar avatar-none">?</span>
|
||||
<span class="member-name">{{$L('未分配')}}</span>
|
||||
<span class="member-track">
|
||||
<span class="member-bar bar-hatched" :style="{width: memberBarWidth({total: blocks.no_owner})}"></span>
|
||||
</span>
|
||||
<span class="member-num num-noowner">{{$L('(*) 项', blocks.no_owner)}}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="members.length > memberLimit" class="card-more" @click="memberExpand = !memberExpand">
|
||||
{{memberExpand ? $L('收起') : $L('查看全部 (*) 人', members.length) + ' →'}}
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="blocks.no_owner > 0" class="member-row member-noowner" @click="onBlock('noowner')">
|
||||
<span class="mock-avatar avatar-none">?</span>
|
||||
<span class="member-name">{{$L('未分配')}}</span>
|
||||
<span class="member-track">
|
||||
<span class="member-bar bar-hatched" :style="{width: memberBarWidth({total: blocks.no_owner})}"></span>
|
||||
</span>
|
||||
<span class="member-num num-noowner">{{$L('(*) 项', blocks.no_owner)}}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="members.length > memberLimit" class="card-more" @click="memberExpand = !memberExpand">
|
||||
{{memberExpand ? $L('收起') : $L('查看全部 (*) 人', members.length) + ' →'}}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="dashboard-card team-priority">
|
||||
<div class="card-head">
|
||||
<span class="head-title">{{$L('优先级分布')}}</span>
|
||||
<span class="head-note">{{$L('未完成 (*) 项', blocks.uncompleted || 0)}}</span>
|
||||
<span v-if="!showStatsLoading" class="head-note">{{$L('未完成 (*) 项', blocks.uncompleted || 0)}}</span>
|
||||
</div>
|
||||
<div class="priority-list">
|
||||
<div v-for="item in priorityList" :key="item.level" class="priority-row" @click="onPriority(item)">
|
||||
<div v-if="showStatsLoading" class="team-stats-loading"><Loading/></div>
|
||||
<div v-else class="priority-list">
|
||||
<div v-for="item in visiblePriorityList" :key="item.level" class="priority-row" @click="onPriority(item)">
|
||||
<div class="priority-head">
|
||||
<span class="priority-name">{{item.name}}</span>
|
||||
<span class="priority-num">{{$L('(*) 项', item.num)}} · {{item.percent}}%</span>
|
||||
@ -90,66 +115,79 @@
|
||||
<i :style="{width: item.percent + '%', background: item.color}"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="priority-tip">{{$L('点击任一档位可下钻到任务列表')}}</div>
|
||||
<div
|
||||
v-if="priorityList.length > priorityLimit"
|
||||
class="priority-tip priority-more"
|
||||
@click="priorityExpand = !priorityExpand">
|
||||
{{priorityExpand ? $L('收起') : $L('查看全部 (*) 个优先级', priorityList.length) + ' →'}}
|
||||
</div>
|
||||
<div v-else class="priority-tip">{{$L('点击任一档位可下钻到任务列表')}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!--重点关注任务-->
|
||||
<div ref="focusCard" class="dashboard-card team-focus">
|
||||
<div class="card-head focus-head">
|
||||
<span class="head-title">{{$L('重点关注任务')}}</span>
|
||||
<span
|
||||
v-for="chip in chips"
|
||||
:key="chip.type"
|
||||
class="focus-chip"
|
||||
:class="[`chip-${chip.type}`, {'chip-active': !extraFilter && focus === chip.type}]"
|
||||
@click="setFocus(chip.type)">{{chip.label}} {{chip.num}}</span>
|
||||
<span v-if="extraFilter" class="focus-chip chip-extra chip-active" @click="clearFilter">
|
||||
{{extraFilter.label}}<i>×</i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="focus-table">
|
||||
<div class="focus-row focus-thead">
|
||||
<span>{{$L('任务')}}</span>
|
||||
<span class="f-project">{{$L('项目')}}</span>
|
||||
<span class="f-owner">{{$L('负责人')}}</span>
|
||||
<span class="f-priority">{{$L('优先级')}}</span>
|
||||
<span class="f-status">{{$L('状态')}}</span>
|
||||
<span class="f-end">{{$L('截止时间')}}</span>
|
||||
</div>
|
||||
<div v-for="item in currentList.list" :key="item.id" class="focus-row" @click="onTask(item)">
|
||||
<span class="f-name">{{item.name}}</span>
|
||||
<span class="f-project">{{item.project_name}}</span>
|
||||
<span class="f-owner">
|
||||
<template v-if="item.owner">
|
||||
<span class="mock-avatar" :style="{backgroundColor: item.owner.color}">{{item.owner.nickname.substring(0, 1)}}</span>
|
||||
<span class="owner-name">{{item.owner.nickname}}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="mock-avatar avatar-none">?</span><em class="owner-none">{{$L('待分配')}}</em>
|
||||
</template>
|
||||
<div class="team-focus-section" :class="{'focus-scroll-reserve': focusScrollReserved}">
|
||||
<div ref="focusCard" class="dashboard-card team-focus">
|
||||
<div class="card-head focus-head">
|
||||
<span class="head-title">{{$L('重点关注任务')}}</span>
|
||||
<span
|
||||
v-for="chip in chips"
|
||||
:key="chip.type"
|
||||
class="focus-chip"
|
||||
:class="[`chip-${chip.type}`, {'chip-active': !extraFilter && focus === chip.type}]"
|
||||
@click="setFocus(chip.type)">{{chip.label}} {{chip.num}}</span>
|
||||
<span v-if="extraFilter" class="focus-chip chip-extra chip-active" @click="clearFilter">
|
||||
{{extraFilter.label}}<i>×</i>
|
||||
</span>
|
||||
<span class="f-priority">
|
||||
<template v-if="item.p_name">
|
||||
<i class="priority-dot" :style="{backgroundColor: item.p_color}"></i>
|
||||
<span>{{item.p_name}}</span>
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
</span>
|
||||
<span class="f-status"><em class="status-pill" :class="item.flow_item_name ? item.flow_item_status : ''">{{item.flow_item_name || $L('未完成')}}</em></span>
|
||||
<span class="f-end" :class="deadlineClass(item.end_at)">{{deadlineText(item.end_at) || '—'}}</span>
|
||||
</div>
|
||||
<div v-if="currentList.loading" class="focus-loading"><Loading/></div>
|
||||
<div v-else-if="currentList.list.length === 0" class="focus-empty">{{$L('暂无任务')}}</div>
|
||||
<div v-else-if="currentList.hasMore" class="card-more" @click="loadMore">{{$L('加载更多')}}({{$L('还有 (*) 项', currentList.total - currentList.list.length)}})</div>
|
||||
<div v-else-if="currentList.total > pageSize" class="card-more more-done">{{$L('全部显示完毕')}}</div>
|
||||
<div class="focus-table">
|
||||
<div class="focus-row focus-thead">
|
||||
<span>{{$L('任务')}}</span>
|
||||
<span class="f-project">{{$L('项目')}}</span>
|
||||
<span class="f-owner">{{$L('负责人')}}</span>
|
||||
<span class="f-priority">{{$L('优先级')}}</span>
|
||||
<span class="f-status">{{$L('状态')}}</span>
|
||||
<span class="f-end">{{$L('截止时间')}}</span>
|
||||
</div>
|
||||
<div v-for="item in currentList.list" :key="item.id" class="focus-row" @click="onTask(item)">
|
||||
<span class="f-name">{{item.name}}</span>
|
||||
<span class="f-project">{{item.project_name}}</span>
|
||||
<span class="f-owner">
|
||||
<template v-if="item.owner">
|
||||
<UserAvatar class="team-avatar" :userid="item.owner.userid" :size="20"/>
|
||||
<span class="owner-name">{{item.owner.nickname}}</span>
|
||||
<em v-if="item.owners.length > 1" class="owner-more">+{{item.owners.length - 1}}</em>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="mock-avatar avatar-none">?</span><em class="owner-none">{{$L('待分配')}}</em>
|
||||
</template>
|
||||
</span>
|
||||
<span class="f-priority">
|
||||
<template v-if="item.p_name">
|
||||
<i class="priority-dot" :style="{backgroundColor: item.p_color}"></i>
|
||||
<span>{{item.p_name}}</span>
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
</span>
|
||||
<span class="f-status"><em class="status-pill" :class="item.flow_item_name ? item.flow_item_status : ''">{{item.flow_item_name || $L('未完成')}}</em></span>
|
||||
<span class="f-end" :class="deadlineClass(item.end_at)">{{deadlineText(item.end_at) || '—'}}</span>
|
||||
</div>
|
||||
<div v-if="currentList.loading && !currentList.silent" class="focus-loading"><Loading/></div>
|
||||
<div v-else-if="currentList.list.length === 0" class="focus-empty">
|
||||
<span class="focus-empty-icon"><Icon type="md-checkmark"/></span>
|
||||
<strong>{{focusEmptyTitle}}</strong>
|
||||
<span>{{$L('可以切换上方条件查看其他任务')}}</span>
|
||||
</div>
|
||||
<div v-else-if="currentList.hasMore" class="card-more" @click="loadMore">{{$L('加载更多')}}({{$L('还有 (*) 项', currentList.total - currentList.list.length)}})</div>
|
||||
<div v-else-if="currentList.total > pageSize" class="card-more more-done">{{$L('全部显示完毕')}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {fetchTeamStats, fetchTeamTasks} from "./dashboard-team-mock";
|
||||
import {mapState} from "vuex";
|
||||
import dashboardTimeMixin from "./dashboard-time-mixin";
|
||||
|
||||
export default {
|
||||
@ -169,29 +207,40 @@ export default {
|
||||
priority: [],
|
||||
members: [],
|
||||
},
|
||||
statsLoading: false,
|
||||
statsLoadedAt: 0,
|
||||
statsToken: 0,
|
||||
|
||||
focus: ['overdue', 'soon', 'hi', 'noowner'].includes(this.initialFocus) ? this.initialFocus : 'overdue',
|
||||
extraFilter: null,
|
||||
|
||||
lists: {},
|
||||
listTokens: {},
|
||||
pageSize: 20,
|
||||
refreshSeconds: 60,
|
||||
|
||||
memberLimit: 6,
|
||||
memberExpand: false,
|
||||
priorityLimit: 6,
|
||||
priorityExpand: false,
|
||||
focusScrollReserved: false,
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.loadStats()
|
||||
this.loadList()
|
||||
this.loadAll()
|
||||
},
|
||||
|
||||
activated() {
|
||||
this.loadInterval(true)
|
||||
if (this.statsLoadedAt > 0 && $A.dayjs().unix() - this.statsLoadedAt >= this.refreshSeconds) {
|
||||
this.loadAll(true)
|
||||
}
|
||||
},
|
||||
|
||||
deactivated() {
|
||||
this.loadInterval(false)
|
||||
this.focusScrollReserved = false
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
@ -199,6 +248,13 @@ export default {
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['cacheDepartmentOwnerIds']),
|
||||
|
||||
scopeKey({cacheDepartmentOwnerIds}) {
|
||||
const ids = (cacheDepartmentOwnerIds || []).map(id => parseInt(id)).filter(id => id > 0).sort((a, b) => a - b)
|
||||
return ids.length > 0 ? ids.join(',') : 'all'
|
||||
},
|
||||
|
||||
blocks({stats}) {
|
||||
return stats.blocks || {}
|
||||
},
|
||||
@ -211,8 +267,8 @@ export default {
|
||||
return memberExpand ? members : members.slice(0, memberLimit)
|
||||
},
|
||||
|
||||
hasTestSegment({members}) {
|
||||
return members.some(member => (member.segments?.test || 0) > 0)
|
||||
showStatsLoading({statsLoading, statsLoadedAt}) {
|
||||
return statsLoading && statsLoadedAt === 0
|
||||
},
|
||||
|
||||
memberMax({members, blocks}) {
|
||||
@ -230,6 +286,10 @@ export default {
|
||||
})
|
||||
},
|
||||
|
||||
visiblePriorityList({priorityList, priorityExpand, priorityLimit}) {
|
||||
return priorityExpand ? priorityList : priorityList.slice(0, priorityLimit)
|
||||
},
|
||||
|
||||
highPriorityChip({stats, blocks}) {
|
||||
// 与后端口径一致:系统优先级前 2 档视为高优先级
|
||||
const tops = (stats.priority || []).filter(item => item.level > 0).slice(0, 2)
|
||||
@ -262,64 +322,179 @@ export default {
|
||||
return this.$L('与上周持平')
|
||||
},
|
||||
|
||||
currentKey({focus, extraFilter}) {
|
||||
currentKey({scopeKey, focus, extraFilter}) {
|
||||
if (extraFilter) {
|
||||
return `${extraFilter.kind}:${extraFilter.value}`
|
||||
return `${scopeKey}|${extraFilter.kind}:${extraFilter.value}`
|
||||
}
|
||||
return `type:${focus}`
|
||||
return `${scopeKey}|type:${focus}`
|
||||
},
|
||||
|
||||
currentList({lists, currentKey}) {
|
||||
return lists[currentKey] || {list: [], page: 1, total: 0, hasMore: false, loading: true}
|
||||
return lists[currentKey] || {list: [], page: 1, total: 0, hasMore: false, loading: true, silent: false}
|
||||
},
|
||||
|
||||
focusEmptyTitle({extraFilter, focus}) {
|
||||
if (extraFilter) {
|
||||
return this.$L('当前筛选条件下暂无任务')
|
||||
}
|
||||
switch (focus) {
|
||||
case 'overdue':
|
||||
return this.$L('暂无已超期任务')
|
||||
case 'soon':
|
||||
return this.$L('未来 3 天内暂无到期任务')
|
||||
case 'hi':
|
||||
return this.$L('暂无高优先级任务')
|
||||
case 'noowner':
|
||||
return this.$L('所有任务均已分配负责人')
|
||||
default:
|
||||
return this.$L('暂无任务')
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
scopeKey() {
|
||||
this.statsToken++
|
||||
this.statsLoading = false
|
||||
this.$emit('stats-loading', false)
|
||||
this.stats = {member_count: 0, blocks: {}, priority: [], members: []}
|
||||
this.statsLoadedAt = 0
|
||||
this.lists = {}
|
||||
this.listTokens = {}
|
||||
this.extraFilter = null
|
||||
this.memberExpand = false
|
||||
this.priorityExpand = false
|
||||
this.$emit('stats', this.stats)
|
||||
this.loadAll(true)
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
loadStats() {
|
||||
fetchTeamStats().then(stats => {
|
||||
this.stats = stats
|
||||
this.$emit('stats', stats)
|
||||
deptParams() {
|
||||
const ids = (this.cacheDepartmentOwnerIds || []).map(id => parseInt(id)).filter(id => id > 0)
|
||||
return ids.length > 0 ? {department_owner_ids: ids.join(',')} : {}
|
||||
},
|
||||
|
||||
loadAll(force = false) {
|
||||
const statsRequest = this.loadStats(force)
|
||||
this.loadList(1, force)
|
||||
return statsRequest
|
||||
},
|
||||
|
||||
loadStats(force = false) {
|
||||
if (this.statsLoading) {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
if (!force && this.statsLoadedAt > 0 && $A.dayjs().unix() - this.statsLoadedAt < this.refreshSeconds) {
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
const scopeKey = this.scopeKey
|
||||
const token = ++this.statsToken
|
||||
this.statsLoading = true
|
||||
this.$emit('stats-loading', true)
|
||||
return this.$store.dispatch("call", {
|
||||
url: 'dashboard/team/stats',
|
||||
data: Object.assign(this.deptParams(), force ? {refresh: 1} : {}),
|
||||
}).then(({data}) => {
|
||||
if (scopeKey !== this.scopeKey || token !== this.statsToken) {
|
||||
return false
|
||||
}
|
||||
this.stats = Object.assign({member_count: 0, blocks: {}, priority: [], members: []}, data)
|
||||
this.statsLoadedAt = $A.dayjs().unix()
|
||||
this.$emit('stats', this.stats)
|
||||
return true
|
||||
}).catch(({msg}) => {
|
||||
if (scopeKey === this.scopeKey && token === this.statsToken) {
|
||||
this.statsLoadedAt = 0
|
||||
msg && $A.messageWarning(msg)
|
||||
}
|
||||
return false
|
||||
}).finally(() => {
|
||||
if (token === this.statsToken) {
|
||||
this.statsLoading = false
|
||||
this.$emit('stats-loading', false)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
refreshAll() {
|
||||
return this.loadAll(true)
|
||||
},
|
||||
|
||||
listParams(page) {
|
||||
const params = {page}
|
||||
const params = Object.assign(this.deptParams(), {page, pagesize: this.pageSize})
|
||||
if (this.extraFilter?.kind === 'member') {
|
||||
params.member = this.extraFilter.value
|
||||
params.member_id = this.extraFilter.value
|
||||
} else if (this.extraFilter?.kind === 'level') {
|
||||
params.level = this.extraFilter.value
|
||||
} else if (this.extraFilter?.kind === 'type') {
|
||||
params.type = this.extraFilter.value
|
||||
} else {
|
||||
params.type = this.focus
|
||||
}
|
||||
return params
|
||||
},
|
||||
|
||||
loadList(page = 1) {
|
||||
loadList(page = 1, force = false) {
|
||||
const key = this.currentKey
|
||||
if (page === 1 && this.lists[key] && !this.lists[key].loading) {
|
||||
const current = this.lists[key]
|
||||
if (current?.loading) {
|
||||
return
|
||||
}
|
||||
const prev = page > 1 ? this.lists[key] : null
|
||||
if (page === 1 && !force && current) {
|
||||
return
|
||||
}
|
||||
const prev = current || null
|
||||
const previousList = page > 1 || (force && prev) ? (prev?.list || []) : []
|
||||
const scopeKey = this.scopeKey
|
||||
const token = (this.listTokens[key] || 0) + 1
|
||||
this.$set(this.listTokens, key, token)
|
||||
this.$set(this.lists, key, {
|
||||
list: prev ? prev.list : [],
|
||||
list: previousList,
|
||||
page,
|
||||
total: prev ? prev.total : 0,
|
||||
hasMore: false,
|
||||
loading: true,
|
||||
silent: force && previousList.length > 0,
|
||||
loadedAt: prev ? prev.loadedAt : 0,
|
||||
})
|
||||
fetchTeamTasks(this.listParams(page)).then(data => {
|
||||
this.$store.dispatch("call", {
|
||||
url: 'dashboard/team/tasks',
|
||||
data: this.listParams(page),
|
||||
}).then(({data}) => {
|
||||
if (scopeKey !== this.scopeKey || token !== this.listTokens[key]) {
|
||||
return
|
||||
}
|
||||
const rows = data.data || []
|
||||
this.$set(this.lists, key, {
|
||||
list: prev ? [...prev.list, ...data.list] : data.list,
|
||||
page: data.page,
|
||||
total: data.total,
|
||||
hasMore: data.hasMore,
|
||||
list: page > 1 ? [...previousList, ...rows] : rows,
|
||||
page: data.current_page || page,
|
||||
total: data.total || 0,
|
||||
hasMore: (data.current_page || page) < (data.last_page || 1),
|
||||
loading: false,
|
||||
silent: false,
|
||||
loadedAt: $A.dayjs().unix(),
|
||||
})
|
||||
}).catch(({msg}) => {
|
||||
if (scopeKey === this.scopeKey && token === this.listTokens[key]) {
|
||||
this.$set(this.lists, key, {
|
||||
list: previousList,
|
||||
page: prev ? prev.page : 1,
|
||||
total: prev ? prev.total : 0,
|
||||
hasMore: prev ? prev.hasMore : false,
|
||||
loading: false,
|
||||
silent: false,
|
||||
loadedAt: prev ? prev.loadedAt : 0,
|
||||
})
|
||||
msg && previousList.length === 0 && $A.messageWarning(msg)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
loadMore() {
|
||||
this.loadList(this.currentList.page + 1)
|
||||
if (!this.currentList.loading && this.currentList.hasMore) {
|
||||
this.loadList(this.currentList.page + 1)
|
||||
}
|
||||
},
|
||||
|
||||
setFocus(type) {
|
||||
@ -339,11 +514,12 @@ export default {
|
||||
|
||||
clearFilter() {
|
||||
this.extraFilter = null
|
||||
this.$nextTick(() => this.loadList())
|
||||
},
|
||||
|
||||
onBlock(type) {
|
||||
if (type === 'uncompleted') {
|
||||
this.scrollToFocus()
|
||||
this.setFilter('type', 'uncompleted', this.$L('未完成'))
|
||||
return
|
||||
}
|
||||
this.setFocus(type)
|
||||
@ -351,18 +527,19 @@ export default {
|
||||
},
|
||||
|
||||
onMember(member) {
|
||||
this.setFilter('member', member.nickname, `${this.$L('成员')}: ${member.nickname}`)
|
||||
this.setFilter('member', member.userid, `${this.$L('成员')}: ${member.nickname}`)
|
||||
},
|
||||
|
||||
onPriority(item) {
|
||||
this.setFilter('level', item.level, `${this.$L('优先级')}: ${item.name}`)
|
||||
},
|
||||
|
||||
onTask() {
|
||||
$A.messageInfo("演示数据,接口对接后可打开任务详情")
|
||||
onTask(task) {
|
||||
this.$store.dispatch("openTask", task)
|
||||
},
|
||||
|
||||
scrollToFocus() {
|
||||
this.focusScrollReserved = true
|
||||
this.$nextTick(() => {
|
||||
$A.scrollToView(this.$refs.focusCard, {
|
||||
behavior: 'smooth',
|
||||
@ -374,6 +551,29 @@ export default {
|
||||
memberBarWidth(member) {
|
||||
return `${Math.max(Math.round(member.total / this.memberMax * 100), 4)}%`
|
||||
},
|
||||
|
||||
memberProgressDetails(member) {
|
||||
const total = Math.max(member.total || 0, 1)
|
||||
return [
|
||||
{type: 'start', label: this.$L('待处理'), color: '#bfe3cd', count: member.segments.start || 0},
|
||||
{type: 'progress', label: this.$L('进行中'), color: '#1e9e55', count: member.segments.progress || 0},
|
||||
{type: 'test', label: this.$L('验收/测试'), color: '#6ba7d8', count: member.segments.test || 0},
|
||||
].filter(item => item.count > 0).map(item => {
|
||||
return {
|
||||
...item,
|
||||
percent: Math.round(item.count / total * 1000) / 10,
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
memberOverduePercent(member) {
|
||||
return member.total > 0 ? Math.round(member.overdue / member.total * 1000) / 10 : 0
|
||||
},
|
||||
|
||||
memberSummary(member) {
|
||||
const total = this.$L('(*) 项', member.total)
|
||||
return member.overdue > 0 ? `${this.$L('(*) 超期', member.overdue)} · ${total}` : total
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -1,36 +1,51 @@
|
||||
<template>
|
||||
<Modal
|
||||
:value="value"
|
||||
:title="$L('负责人视角')"
|
||||
:title="modalTitle"
|
||||
:mask-closable="false"
|
||||
width="520"
|
||||
@input="$emit('input', $event)">
|
||||
<div class="department-owner-view-modal">
|
||||
<Alert type="info" show-icon>
|
||||
{{$L('可查看所选部门及所有下级部门成员参与的项目和任务,仅支持只读查看。')}}
|
||||
<template v-if="scopeOnly">{{$L('选择需要统计的部门,包含所选部门及其所有下级部门。')}}</template>
|
||||
<template v-else>{{$L('部门范围与负责人仪表盘保持一致;开启后,可只读查看范围内成员参与的项目和任务。')}}</template>
|
||||
</Alert>
|
||||
<div v-if="managedDepartments.length > 1" class="department-owner-view-actions">
|
||||
<a href="javascript:void(0)" @click="draftIds=[]">{{$L('清空')}}</a>
|
||||
<a href="javascript:void(0)" @click="draftIds=managedDepartments.map(item => item.id)">{{$L('全选')}}</a>
|
||||
<a href="javascript:void(0)" @click="reverseDraft">{{$L('反选')}}</a>
|
||||
</div>
|
||||
<CheckboxGroup v-model="draftIds" class="department-owner-view-list">
|
||||
<div
|
||||
v-for="dept in managedDepartments"
|
||||
:key="dept.id"
|
||||
:class="['department-owner-view-item', draftIds.includes(dept.id) ? 'active' : '']"
|
||||
@click="toggleDraft(dept.id)">
|
||||
<div class="department-owner-view-icon">
|
||||
<i class="taskfont"></i>
|
||||
</div>
|
||||
<div class="department-owner-view-name">{{dept.name}}</div>
|
||||
<Checkbox class="department-owner-view-checkbox" :label="dept.id" @click.native.stop><span></span></Checkbox>
|
||||
<div v-if="!scopeOnly" class="department-owner-view-switch">
|
||||
<div>
|
||||
<strong>{{$L('在项目列表中启用')}}</strong>
|
||||
<span class="department-owner-view-switch-desc">{{$L('开启后在项目列表中显示额外的只读项目')}}</span>
|
||||
</div>
|
||||
</CheckboxGroup>
|
||||
<iSwitch v-model="draftProjectViewEnabled"/>
|
||||
</div>
|
||||
<template v-if="scopeOnly || draftProjectViewEnabled">
|
||||
<div v-if="managedDepartments.length > 1" class="department-owner-view-actions">
|
||||
<a href="javascript:void(0)" @click="draftIds=[]">{{$L('清空')}}</a>
|
||||
<a
|
||||
href="javascript:void(0)"
|
||||
@click="draftIds=managedDepartments.map(item => item.id)">{{$L('全选')}}</a>
|
||||
<a href="javascript:void(0)" @click="reverseDraft">{{$L('反选')}}</a>
|
||||
</div>
|
||||
<CheckboxGroup v-model="draftIds" class="department-owner-view-list">
|
||||
<div
|
||||
v-for="dept in managedDepartments"
|
||||
:key="dept.id"
|
||||
:class="['department-owner-view-item', draftIds.includes(dept.id) ? 'active' : '']"
|
||||
@click="toggleDraft(dept.id)">
|
||||
<div class="department-owner-view-icon">
|
||||
<i class="taskfont"></i>
|
||||
</div>
|
||||
<div class="department-owner-view-name">{{dept.name}}</div>
|
||||
<Checkbox
|
||||
class="department-owner-view-checkbox"
|
||||
:label="dept.id"
|
||||
@click.native.stop><span></span></Checkbox>
|
||||
</div>
|
||||
</CheckboxGroup>
|
||||
</template>
|
||||
</div>
|
||||
<div slot="footer" class="adaption">
|
||||
<Button type="default" :disabled="applyLoading" @click="$emit('input', false)">{{$L('取消')}}</Button>
|
||||
<Button type="primary" :loading="applyLoading" @click="apply">{{$L('确定')}}</Button>
|
||||
<Button type="primary" :disabled="draftIds.length === 0" :loading="applyLoading" @click="apply">{{$L('确定')}}</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
@ -42,15 +57,20 @@ export default {
|
||||
name: "DepartmentOwnerView",
|
||||
props: {
|
||||
value: Boolean,
|
||||
scopeOnly: Boolean,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
draftIds: [],
|
||||
draftProjectViewEnabled: false,
|
||||
applyLoading: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState(['userInfo', 'cacheDepartmentOwnerIds']),
|
||||
...mapState(['userInfo', 'cacheDepartmentOwnerIds', 'departmentOwnerProjectViewEnabled']),
|
||||
modalTitle() {
|
||||
return this.scopeOnly ? this.$L('选择团队范围') : this.$L('项目负责人视角');
|
||||
},
|
||||
managedDepartments() {
|
||||
return (this.userInfo.managed_departments || []).map(item => ({
|
||||
...item,
|
||||
@ -63,7 +83,11 @@ export default {
|
||||
immediate: true,
|
||||
handler(show) {
|
||||
if (show) {
|
||||
this.draftIds = (this.cacheDepartmentOwnerIds || []).slice();
|
||||
const cachedIds = (this.cacheDepartmentOwnerIds || []).slice();
|
||||
this.draftIds = cachedIds.length > 0
|
||||
? cachedIds
|
||||
: this.managedDepartments.map(item => item.id);
|
||||
this.draftProjectViewEnabled = this.departmentOwnerProjectViewEnabled;
|
||||
} else {
|
||||
this.applyLoading = false;
|
||||
}
|
||||
@ -92,7 +116,10 @@ export default {
|
||||
}
|
||||
this.applyLoading = true;
|
||||
try {
|
||||
await this.$store.dispatch("setDepartmentOwnerIds", this.draftIds);
|
||||
await this.$store.dispatch("setDepartmentOwnerIds", this.scopeOnly ? this.draftIds : {
|
||||
ids: this.draftIds,
|
||||
projectViewEnabled: this.draftProjectViewEnabled,
|
||||
});
|
||||
this.$emit('input', false);
|
||||
} catch (e) {
|
||||
$A.modalError(e?.msg || this.$L('切换失败'));
|
||||
@ -112,6 +139,27 @@ export default {
|
||||
gap: 14px;
|
||||
margin: 12px 8px 0;
|
||||
}
|
||||
.department-owner-view-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin: 14px 8px 4px;
|
||||
> div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
strong {
|
||||
font-weight: 500;
|
||||
}
|
||||
.department-owner-view-switch-desc {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
.department-owner-view-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
@on-click="onClick"
|
||||
:readonly="readonly">
|
||||
<template #titleTool>
|
||||
<Dropdown class="project-gstc-dropdown-filtr" trigger="click" @on-click="onSwitchColumn">
|
||||
<Dropdown class="project-gstc-dropdown-filtr" trigger="click" @on-click="onSwitchColumn" transfer>
|
||||
<Icon class="project-gstc-dropdown-icon" :class="{filtr:filtrProjectId > 0}" type="md-funnel" />
|
||||
<DropdownMenu slot="list">
|
||||
<DropdownItem :name="0" :class="{'dropdown-active':filtrProjectId == 0}">{{ $L('全部') }}</DropdownItem>
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
class="owner-view-button"
|
||||
@click="departmentOwnerViewShow=true">
|
||||
<i class="taskfont"></i>
|
||||
<em v-if="ownerDepartmentIds.length > 0">{{ownerDepartmentIds.length}}</em>
|
||||
<em v-if="departmentOwnerProjectViewEnabled">{{ownerDepartmentIds.length}}</em>
|
||||
</div>
|
||||
</div>
|
||||
<div class="owner-project-wrapper">
|
||||
@ -155,7 +155,7 @@ export default {
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['cacheProjects', 'loadProjects', 'longpressData', 'userInfo', 'systemConfig', 'cacheDepartmentOwnerIds', 'departmentOwnerProjectsRefreshing']),
|
||||
...mapState(['cacheProjects', 'loadProjects', 'longpressData', 'userInfo', 'systemConfig', 'cacheDepartmentOwnerIds', 'departmentOwnerProjectsRefreshing', 'departmentOwnerProjectViewEnabled']),
|
||||
|
||||
managedDepartments() {
|
||||
return (this.userInfo.managed_departments || []).map(item => ({
|
||||
@ -193,7 +193,7 @@ export default {
|
||||
},
|
||||
|
||||
ownerProjectTabsVisible() {
|
||||
return this.ownerViewAvailable && this.ownerDepartmentIds.length > 0;
|
||||
return this.ownerViewAvailable && this.departmentOwnerProjectViewEnabled;
|
||||
},
|
||||
|
||||
ownerProjectTabs() {
|
||||
|
||||
@ -2141,7 +2141,9 @@ export default {
|
||||
okText: this.$L('立即下载'),
|
||||
content: `${file.name} (${$A.bytesToSize(file.size)})`,
|
||||
onOk: () => {
|
||||
const departmentOwnerIds = (this.$store.state.cacheDepartmentOwnerIds || []).join(',')
|
||||
const departmentOwnerIds = this.$store.state.departmentOwnerProjectViewEnabled
|
||||
? (this.$store.state.cacheDepartmentOwnerIds || []).join(',')
|
||||
: ''
|
||||
const url = $A.urlAddParams(`project/task/filedown?file_id=${file.id}`, departmentOwnerIds ? {department_owner_ids: departmentOwnerIds} : {})
|
||||
this.$store.dispatch('downUrl', $A.apiUrl(url))
|
||||
}
|
||||
|
||||
@ -1,242 +0,0 @@
|
||||
/**
|
||||
* 团队概览 Mock 数据(仅接口对接前使用,确认交互后整体删除本文件)
|
||||
*
|
||||
* 结构与规划中的接口返回保持一致,便于无缝替换:
|
||||
* - fetchTeamStats() → api/dashboard/team/stats
|
||||
* - fetchTeamTasks() → api/dashboard/team/tasks?type=xxx&page=N(20条/页)
|
||||
*
|
||||
* 口径说明(与后端实现约定一致):
|
||||
* - members[].segments 按任务表 flow_item_name 的状态前缀(start/progress/test)聚合,
|
||||
* 已超期的任务不论状态归入 overdue 段
|
||||
* - priority 按系统优先级设置动态分档,未设置优先级的任务归入「未设置」
|
||||
*/
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
// 成员池(nickname/color 用于 Mock 字母头像,真实接口返回 userid 后换 UserAvatar)
|
||||
const MEMBERS = [
|
||||
{userid: 0, nickname: "林晓雨", color: "#3a7bd5", total: 14, overdue: 2, segments: {progress: 7, start: 4, test: 1}},
|
||||
{userid: 0, nickname: "王志强", color: "#8a63d2", total: 13, overdue: 3, segments: {progress: 5, start: 4, test: 1}},
|
||||
{userid: 0, nickname: "陈嘉明", color: "#1e9e55", total: 11, overdue: 3, segments: {progress: 4, start: 3, test: 1}},
|
||||
{userid: 0, nickname: "赵倩", color: "#d9822b", total: 9, overdue: 0, segments: {progress: 5, start: 4, test: 0}},
|
||||
{userid: 0, nickname: "孙浩然", color: "#5f9ea0", total: 7, overdue: 2, segments: {progress: 3, start: 2, test: 0}},
|
||||
{userid: 0, nickname: "李雪", color: "#c2554f", total: 6, overdue: 1, segments: {progress: 2, start: 3, test: 0}},
|
||||
{userid: 0, nickname: "周明", color: "#4f7a5b", total: 5, overdue: 0, segments: {progress: 2, start: 3, test: 0}},
|
||||
{userid: 0, nickname: "吴婷", color: "#9a6ec2", total: 4, overdue: 1, segments: {progress: 1, start: 2, test: 0}},
|
||||
{userid: 0, nickname: "郑凯", color: "#3f8fa8", total: 3, overdue: 0, segments: {progress: 1, start: 2, test: 0}},
|
||||
{userid: 0, nickname: "冯丽", color: "#b8863d", total: 3, overdue: 0, segments: {progress: 2, start: 1, test: 0}},
|
||||
{userid: 0, nickname: "韩磊", color: "#6b7ac2", total: 2, overdue: 0, segments: {progress: 1, start: 1, test: 0}},
|
||||
{userid: 0, nickname: "杨帆", color: "#52a06e", total: 1, overdue: 0, segments: {progress: 0, start: 1, test: 0}},
|
||||
];
|
||||
|
||||
const PROJECTS = ["DooTask v2", "点餐系统", "TTPOS-MENU", "财务管理", "项目收支"];
|
||||
|
||||
const PRIORITIES = [
|
||||
{level: 1, name: "紧急", color: "#d94f46"},
|
||||
{level: 2, name: "高", color: "#d9822b"},
|
||||
{level: 3, name: "中", color: "#3a7bd5"},
|
||||
{level: 4, name: "低", color: "#9aa096"},
|
||||
];
|
||||
|
||||
const FLOWS = [
|
||||
{status: "start", name: "待处理"},
|
||||
{status: "progress", name: "进行中"},
|
||||
{status: "test", name: "审核中"},
|
||||
];
|
||||
|
||||
const TASK_NAMES = [
|
||||
"建议放开部门群组的自定义头像权限", "【收款】佳博 WiFi 打印机联调", "点餐系统会员余额对账异常排查",
|
||||
"电子菜单需开通 Stripe 账户的申请", "公开分享链接的选项没有做初始化记录", "建议增加任务外链访问功能",
|
||||
"客户端登录页视觉走查", "周报数据汇总脚本修复", "移动端消息推送延迟优化方案评估",
|
||||
"建议将任务模板和任务描述的内容编辑框保持一致", "仪表盘统计口径梳理与确认", "客户反馈工单归类规则更新",
|
||||
"灰度发布检查清单补充", "新版工作流状态配置文档评审", "财务管理报表导出字段确认",
|
||||
"点餐系统打印模板多语言适配", "API 文档站点信息架构调整", "财务管理季度结算流程自动化",
|
||||
"项目收支月度盘点模板整理", "移动端首页白屏问题排查", "消息中心已读状态同步异常",
|
||||
"新员工入职引导流程梳理", "数据备份策略评审与演练", "第三方登录接口升级适配",
|
||||
"文件预览服务内存占用优化", "会议室预定模块需求评审", "客户端自动更新失败重试机制",
|
||||
"通知推送模板文案统一", "任务导出 Excel 字段缺失修复", "项目归档流程权限确认",
|
||||
"服务端日志切割脚本调整", "看板拖拽卡顿性能分析", "多语言翻译缺失项补齐",
|
||||
"接口限流阈值评估与调整", "运营后台数据大盘需求确认", "旧版附件迁移方案评审",
|
||||
"扫码登录二维码过期时间调整", "子任务批量操作交互优化", "网盘分享链接权限梳理",
|
||||
"审批流转节点通知缺失排查", "版本发布公告文案准备", "工时统计报表口径确认",
|
||||
];
|
||||
|
||||
// —— 以下为确定性生成逻辑(不用随机数,保证每次渲染一致)——
|
||||
// 时间一律基于 $A.daytz()(服务器时区),与真实接口返回口径一致
|
||||
|
||||
function fmt(day) {
|
||||
return day.format("YYYY-MM-DD HH:mm:00");
|
||||
}
|
||||
|
||||
function dayOffset(days, hour) {
|
||||
return $A.daytz().add(days, "day").hour(hour).minute(0).second(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成未完成任务池:84 条 = 成员 78 条 + 未分配 6 条
|
||||
* 超期 12、3 日内到期 18、紧急/高 29、未分配 6,与 stats 数字咬合
|
||||
*/
|
||||
function buildPool() {
|
||||
const pool = [];
|
||||
const owners = [];
|
||||
MEMBERS.forEach(member => {
|
||||
for (let i = 0; i < member.total; i++) {
|
||||
owners.push({member, isOverdue: i < member.overdue});
|
||||
}
|
||||
});
|
||||
for (let i = 0; i < 6; i++) {
|
||||
owners.push({member: null, isOverdue: false});
|
||||
}
|
||||
// 优先级配额:紧急 8、高 21、中 31、低 16、未设置 8(合计 84,紧急+高=29)
|
||||
// 用互质数打散分配顺序,避免同一成员的任务优先级扎堆
|
||||
const priorityRank = owners.map((_, i) => i).sort((a, b) => ((a * 53) % owners.length) - ((b * 53) % owners.length));
|
||||
const priorityMap = {};
|
||||
priorityRank.forEach((ownerIndex, rank) => {
|
||||
if (rank < 8) {
|
||||
priorityMap[ownerIndex] = PRIORITIES[0];
|
||||
} else if (rank < 29) {
|
||||
priorityMap[ownerIndex] = PRIORITIES[1];
|
||||
} else if (rank < 60) {
|
||||
priorityMap[ownerIndex] = PRIORITIES[2];
|
||||
} else if (rank < 76) {
|
||||
priorityMap[ownerIndex] = PRIORITIES[3];
|
||||
} else {
|
||||
priorityMap[ownerIndex] = null;
|
||||
}
|
||||
});
|
||||
// 非超期任务里:18 条 3 日内到期(时间保证在未来)、30 条较远截止、其余无截止
|
||||
const nonOverdue = owners.map((item, i) => item.isOverdue ? -1 : i).filter(i => i >= 0);
|
||||
const soonRank = [...nonOverdue].sort((a, b) => ((a * 37) % owners.length) - ((b * 37) % owners.length));
|
||||
const soonSet = new Set(soonRank.slice(0, 18));
|
||||
const farSet = new Set(soonRank.slice(18, 48));
|
||||
owners.forEach((item, index) => {
|
||||
const priority = priorityMap[index];
|
||||
//
|
||||
let endAt = null;
|
||||
if (item.isOverdue) {
|
||||
endAt = dayOffset(-((index % 6) + 1), 10 + index % 8);
|
||||
} else if (soonSet.has(index)) {
|
||||
// 现在起 2 小时 ~ 2.5 天内,保证不会因当天时间已过而变成逾期
|
||||
endAt = $A.daytz().add(2 + (index % 3) * 20, "hour").minute(0).second(0);
|
||||
} else if (farSet.has(index)) {
|
||||
endAt = dayOffset(4 + index % 20, 9 + index % 9);
|
||||
}
|
||||
const flow = FLOWS[index % FLOWS.length];
|
||||
const name = TASK_NAMES[index % TASK_NAMES.length] + (index >= TASK_NAMES.length * 2 ? "(二期)" : index >= TASK_NAMES.length ? "(跟进)" : "");
|
||||
pool.push({
|
||||
id: 900000 + index,
|
||||
name,
|
||||
project_name: PROJECTS[index % PROJECTS.length],
|
||||
owner: item.member ? {userid: item.member.userid, nickname: item.member.nickname, color: item.member.color} : null,
|
||||
p_level: priority ? priority.level : 0,
|
||||
p_name: priority ? priority.name : "",
|
||||
p_color: priority ? priority.color : "",
|
||||
flow_item_status: flow.status,
|
||||
flow_item_name: flow.name,
|
||||
end_at: endAt ? fmt(endAt) : "",
|
||||
});
|
||||
});
|
||||
return pool;
|
||||
}
|
||||
|
||||
let POOL = null;
|
||||
|
||||
function getPool() {
|
||||
if (POOL === null) {
|
||||
POOL = buildPool();
|
||||
}
|
||||
return POOL;
|
||||
}
|
||||
|
||||
function isOverdue(task) {
|
||||
return task.end_at && $A.dayjs(task.end_at) <= $A.daytz();
|
||||
}
|
||||
|
||||
function isSoon(task) {
|
||||
if (!task.end_at || isOverdue(task)) {
|
||||
return false;
|
||||
}
|
||||
return $A.dayjs(task.end_at) <= $A.daytz().add(3, "day").endOf("day");
|
||||
}
|
||||
|
||||
function filterPool({type, member, level}) {
|
||||
let list = getPool();
|
||||
if (member) {
|
||||
list = list.filter(task => task.owner && task.owner.nickname === member);
|
||||
} else if (level > 0) {
|
||||
list = list.filter(task => task.p_level === level);
|
||||
} else if (level === -1) {
|
||||
list = list.filter(task => task.p_level === 0);
|
||||
} else {
|
||||
switch (type) {
|
||||
case "overdue":
|
||||
list = list.filter(task => isOverdue(task));
|
||||
break;
|
||||
case "soon":
|
||||
list = list.filter(task => isSoon(task));
|
||||
break;
|
||||
case "hi":
|
||||
list = list.filter(task => task.p_level > 0 && task.p_level <= 2);
|
||||
break;
|
||||
case "noowner":
|
||||
list = list.filter(task => !task.owner);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 排序:超期最久优先,其次截止时间近的优先,无截止最后
|
||||
return list.slice().sort((a, b) => {
|
||||
if (!a.end_at && !b.end_at) return 0;
|
||||
if (!a.end_at) return 1;
|
||||
if (!b.end_at) return -1;
|
||||
return a.end_at.localeCompare(b.end_at);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 团队统计(对应 api/dashboard/team/stats)
|
||||
*/
|
||||
export function fetchTeamStats() {
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
member_count: MEMBERS.length,
|
||||
blocks: {
|
||||
uncompleted: 84,
|
||||
overdue: 12,
|
||||
overdue_owner_count: 5,
|
||||
due_soon: 18,
|
||||
week_completed: 27,
|
||||
last_week_completed: 21,
|
||||
no_owner: 6,
|
||||
},
|
||||
priority: [
|
||||
{level: 1, name: "紧急", color: "#d94f46", num: 8},
|
||||
{level: 2, name: "高", color: "#d9822b", num: 21},
|
||||
{level: 3, name: "中", color: "#3a7bd5", num: 31},
|
||||
{level: 4, name: "低", color: "#9aa096", num: 16},
|
||||
{level: -1, name: "", color: "#c5c8ce", num: 8},
|
||||
],
|
||||
members: MEMBERS,
|
||||
});
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 团队任务列表(对应 api/dashboard/team/tasks)
|
||||
* @param {Object} params {type, page, member, level}
|
||||
*/
|
||||
export function fetchTeamTasks(params) {
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
const page = Math.max(1, params.page || 1);
|
||||
const list = filterPool(params);
|
||||
resolve({
|
||||
page,
|
||||
total: list.length,
|
||||
list: list.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
||||
hasMore: page * PAGE_SIZE < list.length,
|
||||
});
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
@ -21,7 +21,26 @@
|
||||
<template v-else>
|
||||
<h2 class="header-hello">{{$L('部门任务总览')}}</h2>
|
||||
<div class="header-sub">
|
||||
<span>{{todayText}}<template v-if="teamSummary"> · {{teamSummary}}</template></span>
|
||||
<span>
|
||||
{{todayText}}<template v-if="teamSummary"> · {{teamSummary}}</template>
|
||||
<EPopover
|
||||
v-model="teamCachePopoverShow"
|
||||
placement="bottom-start"
|
||||
popper-class="dashboard-cache-popper"
|
||||
trigger="click">
|
||||
<div class="dashboard-cache-popover">
|
||||
<strong>{{$L('数据更新说明')}}</strong>
|
||||
<p>{{$L('页面统计数据在 60 秒内复用,重点关注任务列表除外。')}}</p>
|
||||
<span>{{$L('上次更新:(*)', teamStatsUpdatedTime)}}</span>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="teamStatsLoading"
|
||||
@click="refreshTeamDashboard">{{$L('立即刷新')}}</Button>
|
||||
</div>
|
||||
<Icon slot="reference" class="team-cache-help" type="ios-information-circle-outline"/>
|
||||
</EPopover>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="systemConfig.timezoneDifference" class="header-servertime">
|
||||
@ -146,56 +165,59 @@
|
||||
<span class="empty-icon"><Icon type="md-checkmark"/></span>
|
||||
<span>{{listEmptyText(group.type)}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="item in groupList(group, listLimit(group))"
|
||||
:key="`${group.type}-${item.id}`"
|
||||
class="table-row"
|
||||
:class="{complete: item.complete_at}"
|
||||
:style="$A.generateColorVarStyle(item.flow_item_color, [10], 'flow-item-custom-color', item.color ? {backgroundColor: item.color} : {})"
|
||||
@click="openTask(item)">
|
||||
<span class="cell-check" @click.stop>
|
||||
<UserAvatar v-if="group.type === 'assist' && ownerUserid(item)" :userid="ownerUserid(item)" :size="22"/>
|
||||
<TaskMenu v-else :task="item"/>
|
||||
</span>
|
||||
<span class="cell-name">
|
||||
<em
|
||||
class="status-pill name-status"
|
||||
:class="item.flow_item_name ? item.flow_item_status : {end: !!item.complete_at}"
|
||||
@click.stop="openMenu($event, item)">{{item.flow_item_name || (item.complete_at ? $L('已完成') : $L('未完成'))}}</em>
|
||||
<em v-if="item.sub_top === true" class="name-tag">{{$L('子任务')}}</em>
|
||||
<em v-if="item.sub_my && item.sub_my.length > 0" class="name-tag">+{{item.sub_my.length}}</em>
|
||||
{{item.name}}
|
||||
</span>
|
||||
<span class="t-project">{{projectName(item)}}</span>
|
||||
<span class="t-status">
|
||||
<em
|
||||
class="status-pill"
|
||||
:class="item.flow_item_name ? item.flow_item_status : {end: !!item.complete_at}"
|
||||
@click.stop="openMenu($event, item)">{{item.flow_item_name || (item.complete_at ? $L('已完成') : $L('未完成'))}}</em>
|
||||
</span>
|
||||
<span class="t-priority">
|
||||
<template v-if="item.p_name">
|
||||
<i class="priority-dot" :style="{backgroundColor: item.p_color}"></i>
|
||||
<span>{{item.p_name}}</span>
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
</span>
|
||||
<span class="t-sub">
|
||||
<template v-if="item.sub_num > 0">
|
||||
<i class="sub-bar"><b :style="{width: `${Math.min(item.sub_complete / item.sub_num * 100, 100)}%`}"></b></i>
|
||||
<em>{{item.sub_complete}}/{{item.sub_num}}</em>
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
</span>
|
||||
<ETooltip :disabled="$isEEUIApp || windowTouch || !item.end_at" :content="item.end_at" placement="right">
|
||||
<span class="t-end" :class="deadlineClass(item.end_at)">{{deadlineText(item.end_at) || '—'}}</span>
|
||||
</ETooltip>
|
||||
<div class="table-body" :key="`list-${group.type}`">
|
||||
<div
|
||||
v-for="item in groupList(group, listLimit(group))"
|
||||
:key="`${group.type}-${item.id}`"
|
||||
class="table-row"
|
||||
:class="{complete: item.complete_at}"
|
||||
:style="$A.generateColorVarStyle(item.flow_item_color, [10], 'flow-item-custom-color', item.color ? {backgroundColor: item.color} : {})"
|
||||
@click="openTask(item)">
|
||||
<span class="cell-check" @click.stop>
|
||||
<UserAvatar v-if="group.type === 'assist' && ownerUserid(item)" :userid="ownerUserid(item)" :size="22"/>
|
||||
<TaskMenu v-else :task="item"/>
|
||||
</span>
|
||||
<span class="cell-name">
|
||||
<em
|
||||
class="status-pill name-status"
|
||||
:class="item.flow_item_name ? item.flow_item_status : {end: !!item.complete_at}"
|
||||
@click.stop="openMenu($event, item)">{{item.flow_item_name || (item.complete_at ? $L('已完成') : $L('未完成'))}}</em>
|
||||
<em v-if="item.sub_top === true" class="name-tag">{{$L('子任务')}}</em>
|
||||
<em v-if="item.sub_my && item.sub_my.length > 0" class="name-tag">+{{item.sub_my.length}}</em>
|
||||
{{item.name}}
|
||||
</span>
|
||||
<span class="t-project">{{projectName(item)}}</span>
|
||||
<span class="t-status">
|
||||
<em
|
||||
class="status-pill"
|
||||
:class="item.flow_item_name ? item.flow_item_status : {end: !!item.complete_at}"
|
||||
@click.stop="openMenu($event, item)">{{item.flow_item_name || (item.complete_at ? $L('已完成') : $L('未完成'))}}</em>
|
||||
</span>
|
||||
<span class="t-priority">
|
||||
<template v-if="item.p_name">
|
||||
<i class="priority-dot" :style="{backgroundColor: item.p_color}"></i>
|
||||
<span>{{item.p_name}}</span>
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
</span>
|
||||
<span class="t-sub">
|
||||
<template v-if="item.sub_num > 0">
|
||||
<i class="sub-bar"><b :style="{width: `${Math.min(item.sub_complete / item.sub_num * 100, 100)}%`}"></b></i>
|
||||
<em>{{item.sub_complete}}/{{item.sub_num}}</em>
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
</span>
|
||||
<span
|
||||
class="t-end"
|
||||
:class="deadlineClass(item.end_at)"
|
||||
:title="item.end_at">{{deadlineText(item.end_at) || '—'}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="group.list.length > listLimit(group) && !expandedGroups.includes(group.type)"
|
||||
:key="`more-${group.type}`"
|
||||
class="card-more"
|
||||
@click="expandedGroups.push(group.type)">{{$L('还有 (*) 项', group.list.length - listLimit(group))}} →</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="group.list.length > listLimit(group) && !expandedGroups.includes(group.type)"
|
||||
:key="`more-${group.type}`"
|
||||
class="card-more"
|
||||
@click="expandedGroups.push(group.type)">{{$L('还有 (*) 项', group.list.length - listLimit(group))}} →</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
@ -252,12 +274,14 @@
|
||||
<keep-alive>
|
||||
<DashboardTeam
|
||||
v-if="currentView === 'team'"
|
||||
ref="dashboardTeam"
|
||||
:initial-focus="teamFocusPref"
|
||||
@stats="teamStats = $event"/>
|
||||
@stats="teamStats = $event"
|
||||
@stats-loading="teamStatsLoading = $event"/>
|
||||
</keep-alive>
|
||||
</div>
|
||||
</Scrollbar>
|
||||
<DepartmentOwnerView v-model="deptViewShow"/>
|
||||
<DepartmentOwnerView v-model="deptViewShow" scope-only/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -299,6 +323,8 @@ export default {
|
||||
|
||||
deptViewShow: false,
|
||||
teamStats: {member_count: 0, blocks: {}},
|
||||
teamStatsLoading: false,
|
||||
teamCachePopoverShow: false,
|
||||
|
||||
mateName: /macintosh|mac os x/i.test(navigator.userAgent) ? '⌘' : 'Ctrl',
|
||||
|
||||
@ -541,6 +567,10 @@ export default {
|
||||
return '';
|
||||
},
|
||||
|
||||
teamStatsUpdatedTime({teamStats}) {
|
||||
return teamStats.generated_at ? $A.dayjs(teamStats.generated_at).format('HH:mm:ss') : '--'
|
||||
},
|
||||
|
||||
deptLabel({userInfo, cacheDepartmentOwnerIds}) {
|
||||
const managed = (userInfo.managed_departments || []).map(item => ({...item, id: parseInt(item.id)}));
|
||||
const ids = (cacheDepartmentOwnerIds || []).map(id => parseInt(id));
|
||||
@ -608,6 +638,9 @@ export default {
|
||||
|
||||
setView(view) {
|
||||
this.view = view;
|
||||
if (view !== 'team') {
|
||||
this.teamCachePopoverShow = false;
|
||||
}
|
||||
prefsCache.view = view;
|
||||
$A.IDBSave("dashboardView", view);
|
||||
},
|
||||
@ -733,6 +766,15 @@ export default {
|
||||
})
|
||||
},
|
||||
|
||||
refreshTeamDashboard() {
|
||||
this.$refs.dashboardTeam?.refreshAll().then(success => {
|
||||
if (success) {
|
||||
this.teamCachePopoverShow = false
|
||||
$A.messageSuccess("刷新成功")
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
projectName(task) {
|
||||
const project = this.cacheProjects.find(({id}) => id == task.project_id);
|
||||
return project ? project.name : '';
|
||||
|
||||
@ -44,7 +44,10 @@ export default {
|
||||
|
||||
methods: {
|
||||
getProjectData() {
|
||||
if (this.projectId <= 0) return;
|
||||
if (this.projectId <= 0) {
|
||||
this.$store.state.projectId = 0;
|
||||
return;
|
||||
}
|
||||
const projectId = this.projectId;
|
||||
this.$nextTick(() => {
|
||||
this.$store.state.projectId = projectId;
|
||||
|
||||
162
resources/assets/js/store/actions.js
vendored
162
resources/assets/js/store/actions.js
vendored
@ -249,6 +249,7 @@ export default {
|
||||
}
|
||||
if (params.departmentOwner !== false
|
||||
&& state.systemConfig.department_owner_project_view === 'open'
|
||||
&& state.departmentOwnerProjectViewEnabled
|
||||
&& state.departmentOwnerReadonlyUrls.includes(params.url)
|
||||
&& (state.cacheDepartmentOwnerIds || []).length > 0) {
|
||||
if (!$A.isJson(params.data)) params.data = {}
|
||||
@ -1203,11 +1204,13 @@ export default {
|
||||
};
|
||||
|
||||
// 批量获取数据
|
||||
const departmentOwnerProjectViewEnabledPromise = $A.IDBValue('departmentOwnerProjectViewEnabled');
|
||||
const data = await Promise.all([
|
||||
...dataMap.string.map(key => $A.IDBString(key)),
|
||||
...dataMap.array.map(key => $A.IDBArray(key)),
|
||||
...dataMap.json.map(key => $A.IDBJson(key))
|
||||
]);
|
||||
const cachedDepartmentOwnerProjectViewEnabled = await departmentOwnerProjectViewEnabledPromise;
|
||||
|
||||
// 更新 state
|
||||
[...dataMap.string, ...dataMap.array, ...dataMap.json].forEach((key, index) => {
|
||||
@ -1217,6 +1220,10 @@ export default {
|
||||
state[key] = data[index];
|
||||
}
|
||||
});
|
||||
const departmentOwnerProjectViewEnabled = typeof cachedDepartmentOwnerProjectViewEnabled === 'boolean'
|
||||
? cachedDepartmentOwnerProjectViewEnabled
|
||||
: (state.cacheDepartmentOwnerIds || []).length > 0;
|
||||
commit('department/owner/enabled/save', departmentOwnerProjectViewEnabled);
|
||||
|
||||
// 特殊处理 cacheDialogs
|
||||
state.cacheDialogs = state.cacheDialogs.map(item => ({
|
||||
@ -1251,6 +1258,21 @@ export default {
|
||||
state.userIsAdmin = $A.inArray("admin", state.userInfo.identity);
|
||||
}
|
||||
|
||||
// 负责人部门范围始终保留明确选择;旧缓存为空时默认使用全部可管理部门。
|
||||
const managedDepartmentIds = (state.userInfo.managed_departments || [])
|
||||
.map(item => parseInt(item.id))
|
||||
.filter(id => id > 0);
|
||||
let departmentOwnerIds = (state.cacheDepartmentOwnerIds || [])
|
||||
.map(id => parseInt(id))
|
||||
.filter(id => managedDepartmentIds.includes(id));
|
||||
if (departmentOwnerIds.length === 0) {
|
||||
departmentOwnerIds = managedDepartmentIds;
|
||||
}
|
||||
commit('department/owner/ids/save', [...new Set(departmentOwnerIds)]);
|
||||
if (departmentOwnerIds.length === 0) {
|
||||
commit('department/owner/enabled/save', false);
|
||||
}
|
||||
|
||||
// 处理 ServerUrl
|
||||
if (state.cacheServerUrl) {
|
||||
window.systemInfo.apiUrl = state.cacheServerUrl
|
||||
@ -1593,9 +1615,9 @@ export default {
|
||||
normalizeDepartmentOwnerIds({state}, ids) {
|
||||
const validIds = (state.userInfo.managed_departments || []).map(item => parseInt(item.id));
|
||||
if (!$A.isArray(ids)) ids = [];
|
||||
return ids
|
||||
return [...new Set(ids
|
||||
.map(id => parseInt(id))
|
||||
.filter(id => id > 0 && (validIds.length === 0 || validIds.includes(id)));
|
||||
.filter(id => id > 0 && validIds.includes(id)))];
|
||||
},
|
||||
|
||||
/**
|
||||
@ -1608,16 +1630,16 @@ export default {
|
||||
await dispatch("systemSetting").catch(() => {});
|
||||
if (state.systemConfig.department_owner_project_view !== 'open') {
|
||||
commit('department/owner/ids/save', []);
|
||||
commit('department/owner/enabled/save', false);
|
||||
dispatch("getProjectByQueue");
|
||||
return;
|
||||
}
|
||||
const restoredDepartmentOwnerIds = await dispatch("restoreDepartmentOwnerView");
|
||||
if ((restoredDepartmentOwnerIds || []).length > 0) {
|
||||
const restored = await dispatch("restoreDepartmentOwnerView");
|
||||
if (restored.enabled && restored.ids.length > 0) {
|
||||
await dispatch("getProjects", {
|
||||
__replace: true,
|
||||
department_owner_ids: restoredDepartmentOwnerIds.join(',')
|
||||
department_owner_ids: restored.ids.join(',')
|
||||
});
|
||||
commit('department/owner/ids/save', restoredDepartmentOwnerIds);
|
||||
return;
|
||||
}
|
||||
dispatch("getProjectByQueue");
|
||||
@ -1631,45 +1653,78 @@ export default {
|
||||
*/
|
||||
async restoreDepartmentOwnerView({state, dispatch, commit}) {
|
||||
if (state.departmentOwnerViewRestored) {
|
||||
return [];
|
||||
return {
|
||||
enabled: state.departmentOwnerProjectViewEnabled,
|
||||
ids: state.cacheDepartmentOwnerIds || [],
|
||||
};
|
||||
}
|
||||
if (state.systemConfig.department_owner_project_view !== 'open') {
|
||||
commit('department/owner/ids/save', []);
|
||||
return [];
|
||||
commit('department/owner/enabled/save', false);
|
||||
return {enabled: false, ids: []};
|
||||
}
|
||||
state.departmentOwnerViewRestored = true;
|
||||
const ids = await $A.IDBArray("cacheDepartmentOwnerIds", []);
|
||||
if (!ids.length) {
|
||||
return [];
|
||||
const managedIds = (state.userInfo.managed_departments || [])
|
||||
.map(item => parseInt(item.id))
|
||||
.filter(id => id > 0);
|
||||
let restored = await dispatch("normalizeDepartmentOwnerIds", state.cacheDepartmentOwnerIds);
|
||||
if (restored.length === 0) {
|
||||
restored = [...new Set(managedIds)];
|
||||
}
|
||||
const restored = await dispatch("normalizeDepartmentOwnerIds", ids);
|
||||
if (restored.length > 0) {
|
||||
const enabled = state.departmentOwnerProjectViewEnabled && restored.length > 0;
|
||||
commit('department/owner/ids/save', restored);
|
||||
commit('department/owner/enabled/save', enabled);
|
||||
if (enabled) {
|
||||
state.departmentOwnerProjectsRefreshing = true;
|
||||
}
|
||||
commit('department/owner/ids/save', restored);
|
||||
return restored;
|
||||
return {enabled, ids: restored};
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置部门负责人视角
|
||||
* @param state
|
||||
* @param dispatch
|
||||
* @param ids
|
||||
* @param payload
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async setDepartmentOwnerIds({state, dispatch, commit}, ids) {
|
||||
async setDepartmentOwnerIds({state, dispatch, commit}, payload) {
|
||||
const hasOptions = $A.isJson(payload);
|
||||
let ids = hasOptions ? payload.ids : payload;
|
||||
let enabled = hasOptions && typeof payload.projectViewEnabled === 'boolean'
|
||||
? payload.projectViewEnabled
|
||||
: state.departmentOwnerProjectViewEnabled;
|
||||
if (state.systemConfig.department_owner_project_view !== 'open') {
|
||||
ids = [];
|
||||
enabled = false;
|
||||
}
|
||||
const normalized = await dispatch("normalizeDepartmentOwnerIds", ids);
|
||||
let normalized = await dispatch("normalizeDepartmentOwnerIds", ids);
|
||||
if (state.systemConfig.department_owner_project_view === 'open' && normalized.length === 0) {
|
||||
normalized = (state.userInfo.managed_departments || [])
|
||||
.map(item => parseInt(item.id))
|
||||
.filter(id => id > 0);
|
||||
}
|
||||
normalized = [...new Set(normalized)];
|
||||
enabled = enabled && normalized.length > 0;
|
||||
const oldValue = (state.cacheDepartmentOwnerIds || []).slice().sort().join(',');
|
||||
const newValue = normalized.slice().sort().join(',');
|
||||
if (oldValue === newValue) {
|
||||
const scopeChanged = oldValue !== newValue;
|
||||
const enabledChanged = state.departmentOwnerProjectViewEnabled !== enabled;
|
||||
if (!scopeChanged && !enabledChanged) {
|
||||
return;
|
||||
}
|
||||
state.departmentOwnerProjectsRefreshing = true;
|
||||
await dispatch("refreshDepartmentOwnerProjects", normalized);
|
||||
let refreshResult = null;
|
||||
if (enabledChanged || (enabled && scopeChanged)) {
|
||||
state.departmentOwnerProjectsRefreshing = true;
|
||||
refreshResult = await dispatch("refreshDepartmentOwnerProjects", {
|
||||
ownerIds: normalized,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
commit('department/owner/ids/save', normalized);
|
||||
commit('department/owner/enabled/save', enabled);
|
||||
if (refreshResult?.route) {
|
||||
$A.goForward(refreshResult.route);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@ -1678,9 +1733,14 @@ export default {
|
||||
* @param dispatch
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async refreshDepartmentOwnerProjects({state, dispatch}, ownerIds = state.cacheDepartmentOwnerIds) {
|
||||
const currentProjectId = state.projectId;
|
||||
async refreshDepartmentOwnerProjects({state, dispatch}, options = {}) {
|
||||
const currentProjectId = state.routeName === 'manage-project' ? state.projectId : 0;
|
||||
const enabled = typeof options.enabled === 'boolean'
|
||||
? options.enabled
|
||||
: state.departmentOwnerProjectViewEnabled;
|
||||
let ownerIds = options.ownerIds || state.cacheDepartmentOwnerIds;
|
||||
ownerIds = (ownerIds || []).map(id => parseInt(id)).filter(id => id > 0);
|
||||
const departmentOwnerIds = enabled ? ownerIds.join(',') : '';
|
||||
state.departmentOwnerProjectsRefreshing = true;
|
||||
state.callAt = state.callAt.filter(item => {
|
||||
const key = String(item.key);
|
||||
@ -1689,7 +1749,7 @@ export default {
|
||||
try {
|
||||
await dispatch("getProjects", {
|
||||
__replace: true,
|
||||
department_owner_ids: ownerIds.join(',')
|
||||
department_owner_ids: departmentOwnerIds
|
||||
});
|
||||
if (currentProjectId > 0) {
|
||||
const exists = state.cacheProjects.find(({id}) => id == currentProjectId);
|
||||
@ -1700,19 +1760,20 @@ export default {
|
||||
}
|
||||
return b.id - a.id;
|
||||
}).find(({id}) => id);
|
||||
if (project) {
|
||||
$A.goForward({name: 'manage-project', params: {projectId: project.id}});
|
||||
} else {
|
||||
$A.goForward({name: 'manage-dashboard'});
|
||||
}
|
||||
return;
|
||||
return {
|
||||
route: project
|
||||
? {name: 'manage-project', params: {projectId: project.id}}
|
||||
: {name: 'manage-dashboard'},
|
||||
};
|
||||
}
|
||||
await dispatch("getProjectOne", currentProjectId).catch(() => {});
|
||||
await dispatch("getTaskForProject", currentProjectId).catch(() => {});
|
||||
}
|
||||
} catch (e) {
|
||||
if ((state.cacheDepartmentOwnerIds || []).length === 0 && currentProjectId > 0) {
|
||||
$A.goForward({name: 'manage-dashboard'});
|
||||
await dispatch("getProjectOne", {
|
||||
project_id: currentProjectId,
|
||||
department_owner_ids: departmentOwnerIds,
|
||||
}).catch(() => {});
|
||||
await dispatch("getTaskForProject", {
|
||||
project_id: currentProjectId,
|
||||
department_owner_ids: departmentOwnerIds,
|
||||
}).catch(() => {});
|
||||
}
|
||||
} finally {
|
||||
state.departmentOwnerProjectsRefreshing = false;
|
||||
@ -1818,7 +1879,7 @@ export default {
|
||||
if (state.systemConfig.department_owner_project_view !== 'open') {
|
||||
delete requestData.department_owner_ids
|
||||
} else if (requestData.department_owner_ids === undefined) {
|
||||
if ((state.cacheDepartmentOwnerIds || []).length > 0) {
|
||||
if (state.departmentOwnerProjectViewEnabled && (state.cacheDepartmentOwnerIds || []).length > 0) {
|
||||
requestData.department_owner_ids = state.cacheDepartmentOwnerIds.join(',')
|
||||
} else {
|
||||
delete requestData.department_owner_ids
|
||||
@ -1881,11 +1942,13 @@ export default {
|
||||
* 获取单个项目
|
||||
* @param state
|
||||
* @param dispatch
|
||||
* @param project_id
|
||||
* @param project
|
||||
* @returns {Promise<unknown>}
|
||||
*/
|
||||
getProjectOne({state, dispatch}, project_id) {
|
||||
getProjectOne({state, dispatch}, project) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
const requestData = $A.isJson(project) ? {...project} : {project_id: project};
|
||||
const project_id = requestData.project_id;
|
||||
if ($A.runNum(project_id) === 0) {
|
||||
reject({msg: 'Parameter error'});
|
||||
return;
|
||||
@ -1893,9 +1956,7 @@ export default {
|
||||
state.projectLoad++;
|
||||
dispatch("call", {
|
||||
url: 'project/one',
|
||||
data: {
|
||||
project_id
|
||||
},
|
||||
data: requestData,
|
||||
}).then(result => {
|
||||
setTimeout(() => {
|
||||
state.projectLoad--;
|
||||
@ -2328,10 +2389,12 @@ export default {
|
||||
if (!$A.isJson(requestData)) {
|
||||
requestData = {}
|
||||
}
|
||||
if ((state.cacheDepartmentOwnerIds || []).length > 0) {
|
||||
requestData.department_owner_ids = state.cacheDepartmentOwnerIds.join(',')
|
||||
} else {
|
||||
delete requestData.department_owner_ids
|
||||
if (requestData.department_owner_ids === undefined) {
|
||||
if (state.departmentOwnerProjectViewEnabled && (state.cacheDepartmentOwnerIds || []).length > 0) {
|
||||
requestData.department_owner_ids = state.cacheDepartmentOwnerIds.join(',')
|
||||
} else {
|
||||
delete requestData.department_owner_ids
|
||||
}
|
||||
}
|
||||
const callData = $callData('tasks', requestData, state)
|
||||
//
|
||||
@ -2482,12 +2545,13 @@ export default {
|
||||
* 获取项目任务
|
||||
* @param state
|
||||
* @param dispatch
|
||||
* @param project_id
|
||||
* @param project
|
||||
* @returns {Promise<unknown>}
|
||||
*/
|
||||
getTaskForProject({state, dispatch}, project_id) {
|
||||
getTaskForProject({state, dispatch}, project) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
dispatch("getTasks", {project_id}).then(resolve).catch(reject)
|
||||
const requestData = $A.isJson(project) ? {...project} : {project_id: project};
|
||||
dispatch("getTasks", requestData).then(resolve).catch(reject)
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
4
resources/assets/js/store/mutations.js
vendored
4
resources/assets/js/store/mutations.js
vendored
@ -253,6 +253,10 @@ export default {
|
||||
state.cacheDepartmentOwnerIds = $A.normalizeIntArray(data)
|
||||
$A.IDBSet("cacheDepartmentOwnerIds", state.cacheDepartmentOwnerIds).catch(() => {});
|
||||
},
|
||||
'department/owner/enabled/save': function(state, enabled) {
|
||||
state.departmentOwnerProjectViewEnabled = enabled === true
|
||||
$A.IDBSet("departmentOwnerProjectViewEnabled", state.departmentOwnerProjectViewEnabled).catch(() => {});
|
||||
},
|
||||
|
||||
// 文件管理
|
||||
'file/push': function(state, data) {
|
||||
|
||||
1
resources/assets/js/store/state.js
vendored
1
resources/assets/js/store/state.js
vendored
@ -105,6 +105,7 @@ export default {
|
||||
cacheTasks: [],
|
||||
cacheProjectParameter: [],
|
||||
cacheDepartmentOwnerIds: [],
|
||||
departmentOwnerProjectViewEnabled: false,
|
||||
departmentOwnerViewRestored: false,
|
||||
departmentOwnerProjectsRefreshing: false,
|
||||
departmentOwnerReadonlyUrls: [
|
||||
|
||||
221
resources/assets/sass/pages/page-dashboard.scss
vendored
221
resources/assets/sass/pages/page-dashboard.scss
vendored
@ -62,6 +62,19 @@
|
||||
font-size: 13px;
|
||||
color: #7a8078;
|
||||
|
||||
.team-cache-help {
|
||||
display: inline-flex;
|
||||
padding-left: 6px;
|
||||
color: #a2a89f;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #1e9e55;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-load {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -417,6 +430,9 @@
|
||||
|
||||
// ==================== 个人视角:列表 ====================
|
||||
.my-table {
|
||||
.table-body {
|
||||
margin: 4px 0;
|
||||
}
|
||||
.table-row {
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr) 140px 90px 90px 76px 96px;
|
||||
@ -1014,7 +1030,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
padding: 36px 20px 44px;
|
||||
color: #a2a89f;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
@ -1031,6 +1047,10 @@
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
|
||||
.team-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -1090,18 +1110,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
.team-stats-loading {
|
||||
min-height: 220px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.member-list {
|
||||
padding: 2px 0 6px;
|
||||
--member-grid-columns: 26px 60px minmax(80px, 1fr) fit-content(220px);
|
||||
display: grid;
|
||||
grid-template-columns: var(--member-grid-columns);
|
||||
column-gap: 12px;
|
||||
padding: 2px 20px 6px;
|
||||
|
||||
> li {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.member-row {
|
||||
display: flex;
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 9px 20px;
|
||||
column-gap: 12px;
|
||||
padding: 9px 0;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
@ -1109,8 +1142,7 @@
|
||||
}
|
||||
|
||||
.member-name {
|
||||
width: 60px;
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
color: #22261f;
|
||||
overflow: hidden;
|
||||
@ -1119,7 +1151,7 @@
|
||||
}
|
||||
|
||||
.member-track {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
background: #f0efea;
|
||||
@ -1141,13 +1173,15 @@
|
||||
}
|
||||
|
||||
.member-num {
|
||||
width: 110px;
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
max-width: 220px;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
color: #7a8078;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
> em {
|
||||
font-style: normal;
|
||||
@ -1223,6 +1257,23 @@
|
||||
margin-top: 6px;
|
||||
font-size: 11.5px;
|
||||
color: #a2a89f;
|
||||
|
||||
&.priority-more {
|
||||
color: #7a8078;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #177f44;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.team-focus-section {
|
||||
&.focus-scroll-reserve {
|
||||
// 点击下钻后预留一个可视区高度,离开负责人视角时恢复正常布局
|
||||
min-height: calc(var(--window-height) - var(--status-bar-height) - var(--navigation-bar-height) - 52px);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1335,6 +1386,13 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.owner-more {
|
||||
flex-shrink: 0;
|
||||
color: #a2a89f;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.f-priority {
|
||||
@ -1389,14 +1447,139 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.focus-empty {
|
||||
padding: 30px 20px;
|
||||
text-align: center;
|
||||
min-height: 150px;
|
||||
padding: 24px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
color: #a2a89f;
|
||||
font-size: 13px;
|
||||
font-size: 12.5px;
|
||||
|
||||
.focus-empty-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 3px;
|
||||
border-radius: 50%;
|
||||
background: #e7f5ec;
|
||||
color: #1e9e55;
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
> strong {
|
||||
color: #555c53;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-cache-popper {
|
||||
.dashboard-cache-popover {
|
||||
width: 260px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
|
||||
> strong {
|
||||
color: #22261f;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
> p {
|
||||
color: #7a8078;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
> span {
|
||||
color: #a2a89f;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
> button {
|
||||
align-self: flex-end;
|
||||
margin-top: 2px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@supports not (grid-template-columns: subgrid) {
|
||||
.page-dashboard {
|
||||
.dashboard-team {
|
||||
.member-list {
|
||||
display: block;
|
||||
|
||||
.member-row {
|
||||
grid-template-columns: var(--member-grid-columns);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-member-progress-popper {
|
||||
.member-progress-tooltip {
|
||||
min-width: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
|
||||
.progress-tooltip-title {
|
||||
color: #a2a89f;
|
||||
font-size: 11px;
|
||||
|
||||
&.title-stage {
|
||||
margin-top: 2px;
|
||||
padding-top: 7px;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
.progress-tooltip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
font-size: 12px;
|
||||
|
||||
.progress-tooltip-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
> i {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
> strong {
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&.row-overdue {
|
||||
.progress-tooltip-label,
|
||||
> strong {
|
||||
color: #d94f46;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1620,16 +1803,18 @@ body.window-portrait {
|
||||
gap: 14px;
|
||||
|
||||
.member-list {
|
||||
--member-grid-columns: 26px 48px minmax(48px, 1fr) fit-content(180px);
|
||||
padding: 2px 14px 6px;
|
||||
|
||||
.member-row {
|
||||
padding: 9px 14px;
|
||||
padding: 9px 0;
|
||||
|
||||
.member-name {
|
||||
width: 48px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.member-num {
|
||||
width: auto;
|
||||
min-width: 64px;
|
||||
max-width: 180px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user