feat(dashboard): 完善负责人视角界面与范围切换

This commit is contained in:
kuaifan 2026-07-18 23:43:10 +00:00
parent 7ad27e3326
commit 71ca6ba249
14 changed files with 831 additions and 502 deletions

View File

@ -357,6 +357,21 @@ SMTP服务器
任务提醒
职位/职称
验收/测试
(*) 超期
未来 3 天内暂无到期任务
暂无高优先级任务
所有任务均已分配负责人
当前筛选条件下暂无任务
可以切换上方条件查看其他任务
数据更新说明
页面统计数据在 60 秒内复用,重点关注任务列表除外。
上次更新:(*)
立即刷新
刷新成功
查看全部 (*) 个优先级
任务概况
流程阶段
未完成总数
上传图片
上传失败
上传成功
@ -2599,3 +2614,9 @@ AI任务分析
本周 (*) 项
当前没有待处理任务
可以新建一项任务,或等待新的工作安排
选择需要统计的部门,包含所选部门及其所有下级部门。
部门范围与负责人仪表盘保持一致;开启后,可只读查看范围内成员参与的项目和任务。
项目负责人视角
开启后在项目列表中显示额外的只读项目
选择团队范围
在项目列表中启用

View File

@ -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() {

View File

@ -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>

View File

@ -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">&#xe75c;</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">&#xe75c;</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;

View File

@ -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>

View File

@ -16,7 +16,7 @@
class="owner-view-button"
@click="departmentOwnerViewShow=true">
<i class="taskfont">&#xe75c;</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() {

View File

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

View File

@ -1,242 +0,0 @@
/**
* 团队概览 Mock 数据仅接口对接前使用确认交互后整体删除本文件
*
* 结构与规划中的接口返回保持一致便于无缝替换
* - fetchTeamStats() api/dashboard/team/stats
* - fetchTeamTasks() api/dashboard/team/tasks?type=xxx&page=N20/
*
* 口径说明与后端实现约定一致
* - 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
* 超期 123 日内到期 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);
});
}

View File

@ -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 : '';

View File

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

View File

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

View File

@ -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) {

View File

@ -105,6 +105,7 @@ export default {
cacheTasks: [],
cacheProjectParameter: [],
cacheDepartmentOwnerIds: [],
departmentOwnerProjectViewEnabled: false,
departmentOwnerViewRestored: false,
departmentOwnerProjectsRefreshing: false,
departmentOwnerReadonlyUrls: [

View File

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