Files
cn-rdms-web/src/views/workbench/modules/workbench-my-week-worklog.vue
2026-06-17 19:27:17 +08:00

680 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { computed, nextTick, onActivated, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import dayjs from 'dayjs';
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
import { fetchGetMyWorklogWeek, fetchGetTeamWorklogWeek } from '@/service/api';
import { type ECOption, useEcharts } from '@/hooks/common/echarts';
import { getWorkbenchItemColor } from '../composables/use-workbench-colors';
import {
type WorkbenchWorklogDistributionItem,
buildWorkbenchTeamWorklogView,
buildWorkbenchWeekWorklogView
} from '../homepage';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchMyWeekWorklog' });
interface Props {
editing?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false });
defineEmits<{ (e: 'hide'): void }>();
const router = useRouter();
const myWeekData = ref<Api.Project.MyWorklogWeekResult | null>(null);
const teamWeekData = ref<Api.Project.TeamWorklogWeekResult | null>(null);
// EP type='week' 默认 firstDayOfWeek=7从日历点选时返回当周"周日"。
// 我们按 ISO 周(周一-周日)存储;遇到周日 +1 天再 startOf('isoWeek'),避免回退到上一周。
function resolveIsoWeekStart(weekDate: Date | null) {
if (!weekDate) return null;
const picked = dayjs(weekDate);
if (!picked.isValid()) return null;
const aligned = picked.isoWeekday() === 7 ? picked.add(1, 'day') : picked;
return aligned.startOf('isoWeek');
}
const weekDateShortcuts = [
{ text: '本周', value: () => dayjs().startOf('isoWeek').toDate() },
{ text: '上周', value: () => dayjs().subtract(1, 'week').startOf('isoWeek').toDate() }
];
const selectedWeekDate = ref<Date | null>(dayjs().startOf('isoWeek').toDate());
const selectedWeekStart = computed(() => {
const aligned = resolveIsoWeekStart(selectedWeekDate.value);
return aligned ? aligned.format('YYYY-MM-DD') : '';
});
// 周切换必须重拉,不能被"并发守卫"拦掉,故不走 useWorkbenchRefresh
// 自管 loading + 请求序号,旧响应(慢请求落后于新一次切周)直接丢弃
const loading = ref(false);
let requestSeq = 0;
async function loadWorklogWeek() {
const weekStart = selectedWeekStart.value;
if (!weekStart) return;
requestSeq += 1;
const seq = requestSeq;
loading.value = true;
try {
const [myResult, teamResult] = await Promise.all([
fetchGetMyWorklogWeek({ weekStart }),
fetchGetTeamWorklogWeek({ weekStart })
]);
if (seq !== requestSeq) return;
myWeekData.value = myResult.error || !myResult.data ? null : myResult.data;
teamWeekData.value = teamResult.error || !teamResult.data ? null : teamResult.data;
} finally {
if (seq === requestSeq) {
loading.value = false;
}
}
}
function refresh() {
loadWorklogWeek();
}
type TabKey = 'my' | 'team';
const activeTab = ref<TabKey>('my');
// 周切换(含初始)拉取两 tab 数据;竞态由 loadWorklogWeek 内请求序号兜底
watch(selectedWeekStart, loadWorklogWeek, { immediate: true });
// 工作台路由 keepAlive切回时组件不重挂载immediate watch 不再触发。
// 每次激活归位到当前周并重拉;首次激活与挂载同拍(上面 immediate 已拉过),跳过避免双发
let activatedOnce = false;
onActivated(() => {
if (!activatedOnce) {
activatedOnce = true;
return;
}
const currentWeekDate = dayjs().startOf('isoWeek');
if (selectedWeekStart.value === currentWeekDate.format('YYYY-MM-DD')) {
// 周未变时 watch 不会触发,手动重拉取最新填报
loadWorklogWeek();
} else {
selectedWeekDate.value = currentWeekDate.toDate();
}
});
// ============ 我的工时 ============
const myView = computed(() => (myWeekData.value ? buildWorkbenchWeekWorklogView(myWeekData.value) : null));
const isCurrentWeek = computed(() => selectedWeekStart.value === dayjs().startOf('isoWeek').format('YYYY-MM-DD'));
const totalLabel = computed(() => (isCurrentWeek.value ? '累计' : '该周累计'));
const deltaInfo = computed(() => {
if (!myView.value) return null;
const { delta, completionRate } = myView.value;
if (isCurrentWeek.value) {
if (delta >= 0) return { text: `领先目标 +${delta}h`, tone: 'success' as const };
return { text: `落后目标 ${delta}h`, tone: 'danger' as const };
}
return { text: `达成 ${completionRate}%`, tone: completionRate >= 100 ? ('success' as const) : ('muted' as const) };
});
const DAY_BAR_COLOR = '#409EFF';
interface DistributionRow extends WorkbenchWorklogDistributionItem {
color: string;
}
const distributionRows = computed<DistributionRow[]>(() => {
const list = myView.value?.distribution ?? [];
return list.map(item => ({ ...item, color: getWorkbenchItemColor(item.key, item.kind) }));
});
function handleDistributionClick(item: WorkbenchWorklogDistributionItem) {
if (item.kind === 'project' && item.projectId) {
router.push({
path: '/project/project/execution',
query: { [OBJECT_CONTEXT_QUERY_KEY]: item.projectId }
});
return;
}
if (item.kind === 'personal') {
router.push({ path: '/personal-center/my-item' });
}
}
function buildPieOption(): ECOption {
const list = distributionRows.value;
return {
tooltip: { trigger: 'item', formatter: '{b}: {c}h ({d}%)' },
series: [
{
type: 'pie',
radius: ['38%', '58%'],
center: ['50%', '52%'],
avoidLabelOverlap: true,
minAngle: 6,
label: {
show: true,
formatter: (params: any) => `${params.name}\n${params.percent}%`,
color: '#475569',
fontSize: 11,
lineHeight: 14
},
labelLine: {
show: true,
length: 6,
length2: 8,
smooth: false
},
emphasis: {
scale: true,
scaleSize: 4,
label: { fontWeight: 700 }
},
data: list.map(item => ({
name: item.label,
value: item.hours,
itemStyle: { color: item.color },
cursor: item.kind === 'other' ? 'default' : 'pointer'
}))
}
]
};
}
function buildMyBarOption(): ECOption {
const v = myView.value;
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (rawParams: any) => {
const params = Array.isArray(rawParams) ? rawParams : [rawParams];
const dayName = params[0]?.axisValue ?? '';
const total = Number(params[0]?.value ?? 0);
return `${dayName}${total}h`;
}
},
grid: { left: 28, right: 8, top: 16, bottom: 24, containLabel: false },
xAxis: {
type: 'category',
data: ['一', '二', '三', '四', '五'],
axisTick: { show: false },
axisLine: { lineStyle: { color: '#e5e7eb' } },
axisLabel: { color: '#6b7280', fontSize: 11 }
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: '#f3f4f6' } },
axisLabel: { color: '#9ca3af', fontSize: 10 }
},
series: [
{
name: '每日工时',
type: 'bar',
barWidth: 18,
data: v?.dailyHours ?? [],
itemStyle: { color: DAY_BAR_COLOR, borderRadius: [2, 2, 0, 0] }
}
]
};
}
const { domRef: pieRef, updateOptions: updatePie } = useEcharts(buildPieOption, {
onRender(chart) {
chart.on('click', (params: any) => {
const item = myView.value?.distribution[params.dataIndex];
if (item) handleDistributionClick(item);
});
}
});
const { domRef: myBarRef, updateOptions: updateMyBar } = useEcharts(buildMyBarOption, { onRender() {} });
// ============ 团队工时 ============
const teamView = computed(() => (teamWeekData.value ? buildWorkbenchTeamWorklogView(teamWeekData.value) : null));
const teamSeriesWithColor = computed(() =>
(teamView.value?.seriesMatrix ?? []).map(s => ({ ...s, color: getWorkbenchItemColor(s.key, s.kind) }))
);
function buildTeamBarOption(): ECOption {
const v = teamView.value;
if (!v) return {};
const colored = teamSeriesWithColor.value;
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (rawParams: any) => {
const params = Array.isArray(rawParams) ? rawParams : [rawParams];
if (!params.length) return '';
const name = params[0].axisValue as string;
const total = params.reduce((s: number, p: any) => s + (Number(p.value) || 0), 0);
const lines = params
.filter((p: any) => Number(p.value) > 0)
.map((p: any) => `${p.marker}${p.seriesName} <b style="margin-left:6px">${p.value}h</b>`)
.join('<br/>');
return `<div style="font-weight:600;margin-bottom:4px">${name} · ${total}h</div>${lines}`;
}
},
legend: {
type: 'scroll',
bottom: 0,
itemWidth: 10,
itemHeight: 10,
textStyle: { color: '#6b7280', fontSize: 11 }
},
grid: { left: 32, right: 12, top: 16, bottom: 40, containLabel: false },
xAxis: {
type: 'category',
data: v.members.map(m => m.memberName),
axisTick: { show: false },
axisLine: { lineStyle: { color: '#e5e7eb' } },
axisLabel: { color: '#6b7280', fontSize: 11 }
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: '#f3f4f6' } },
axisLabel: { color: '#9ca3af', fontSize: 10, formatter: '{value}h' }
},
series: colored.map((s, i) => ({
name: s.label,
type: 'bar',
stack: 'member',
barWidth: 22,
data: s.data,
itemStyle: {
color: s.color,
borderRadius: i === colored.length - 1 ? [3, 3, 0, 0] : 0
}
}))
};
}
const { domRef: teamBarRef, updateOptions: updateTeamBar } = useEcharts(buildTeamBarOption, { onRender() {} });
// ============ 数据 / 切换变化时刷新 echarts ============
watch(myView, () => {
updatePie((_, factory) => factory());
updateMyBar((_, factory) => factory());
});
watch(teamView, () => {
updateTeamBar((_, factory) => factory());
});
// 切 tab 后等 v-show 容器渲染再触发 echarts 重画(避免 display:none → block 切换时尺寸残留为 0
watch(activeTab, async tab => {
await nextTick();
if (tab === 'my') {
updatePie((_, factory) => factory());
updateMyBar((_, factory) => factory());
} else {
updateTeamBar((_, factory) => factory());
}
});
</script>
<template>
<WorkbenchModuleCard
v-loading="loading"
title="工时"
icon="mdi:timer-outline"
:editing="editing"
@hide="$emit('hide')"
@refresh="refresh"
>
<div class="ww-tabbar">
<ElTabs v-model="activeTab" class="ww-tabs">
<ElTabPane label="我的工时" name="my" />
<ElTabPane label="团队工时" name="team" />
</ElTabs>
<ElDatePicker
v-model="selectedWeekDate"
type="week"
format="YYYY[年第]ww[周]"
placeholder="选择周次"
:shortcuts="weekDateShortcuts"
:clearable="false"
size="small"
class="ww-week-picker"
/>
</div>
<!-- ============ 我的工时 tab ============ -->
<div v-show="activeTab === 'my'" class="ww-tab-content">
<template v-if="myView">
<div class="ww-headline">
<div class="ww-section-title">
<SvgIcon icon="mdi:chart-donut" class="ww-section-icon" />
<span>工时分布</span>
</div>
<div class="ww-section-title">
<SvgIcon icon="mdi:calendar-week" class="ww-section-icon" />
<span>每日工时</span>
<ElTooltip content="系统按填报日期段均摊到工作日的推算值(周末份额计入周五),非逐日实填" placement="top">
<span class="ww-section-info-wrap">
<SvgIcon icon="mdi:information-outline" class="ww-section-info" />
</span>
</ElTooltip>
</div>
</div>
<div class="ww-grid">
<div class="ww-block">
<div class="ww-pie-wrap">
<div ref="pieRef" class="ww-pie" />
</div>
</div>
<div class="ww-block">
<div ref="myBarRef" class="ww-bar" />
</div>
</div>
<div class="ww-footer">
<span>
{{ totalLabel }}
<b>{{ myView.totalHours }}h</b>
/ {{ myView.target }}h
</span>
<span v-if="deltaInfo" :class="`ww-footer__delta is-${deltaInfo.tone}`">{{ deltaInfo.text }}</span>
</div>
</template>
<ElEmpty v-else description="该周无工时数据" :image-size="60" />
</div>
<!-- ============ 团队工时 tab ============ -->
<div v-show="activeTab === 'team'" class="ww-tab-content">
<template v-if="teamView">
<div class="tw-kpis">
<div class="tw-kpi">
<span class="tw-kpi__label">填报率</span>
<span class="tw-kpi__value">
{{ teamView.fillRate }}
<span class="tw-kpi__unit">%</span>
</span>
<span class="tw-kpi__sub">{{ teamView.totalHours }}h / {{ teamView.expectedTotalHours }}h</span>
</div>
<div class="tw-kpi">
<span class="tw-kpi__label">团队均值</span>
<span class="tw-kpi__value">
{{ teamView.averageHours }}
<span class="tw-kpi__unit">h</span>
</span>
<span class="tw-kpi__sub">{{ teamView.members.length }} </span>
</div>
<div class="tw-kpi">
<span class="tw-kpi__label">偏低</span>
<span class="tw-kpi__value" :class="{ 'is-danger': teamView.lowCount > 0 }">
{{ teamView.lowCount }}
<span class="tw-kpi__unit"></span>
</span>
<span class="tw-kpi__sub">低于均值 80%</span>
</div>
<div class="tw-kpi">
<span class="tw-kpi__label">加班</span>
<span class="tw-kpi__value" :class="{ 'is-warn': teamView.highCount > 0 }">
{{ teamView.highCount }}
<span class="tw-kpi__unit"></span>
</span>
<span class="tw-kpi__sub"> {{ teamView.overtimeThreshold }}h</span>
</div>
</div>
<div ref="teamBarRef" class="tw-bar" />
<div v-if="teamView.lowest && teamView.highest" class="tw-footer">
<span>
<SvgIcon icon="mdi:arrow-down-bold-circle-outline" class="tw-footer__icon is-danger" />
最低
<b>{{ teamView.lowest.memberName }}</b>
{{ teamView.lowest.hours }}h
</span>
<span class="tw-footer__sep">·</span>
<span>
<SvgIcon icon="mdi:arrow-up-bold-circle-outline" class="tw-footer__icon is-warn" />
最高
<b>{{ teamView.highest.memberName }}</b>
{{ teamView.highest.hours }}h
</span>
</div>
</template>
<ElEmpty v-else description="该周无团队工时数据" :image-size="60" />
</div>
</WorkbenchModuleCard>
</template>
<style scoped>
/* ============ 顶部 tab + 周选择器 ============ */
.ww-tabbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
border-bottom: 1px solid var(--el-border-color-lighter);
flex-shrink: 0;
}
/* tab 内容区填充剩余高度flex 列布局,图表区自适应撑满,不写死高度、不内部滚动 */
.ww-tab-content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.ww-tab-content :deep(.el-empty) {
margin: auto;
}
.ww-tabs {
flex: 1;
min-width: 0;
}
.ww-tabs :deep(.el-tabs__header) {
margin: 0;
}
.ww-tabs :deep(.el-tabs__nav-wrap::after) {
display: none;
}
.ww-week-picker {
width: 180px;
flex-shrink: 0;
margin-bottom: 8px;
}
/* ============ 我的工时 ============ */
.ww-headline {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
align-items: center;
gap: 16px;
margin-bottom: 10px;
flex-shrink: 0;
}
.ww-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px;
flex: 1;
min-height: 0;
}
@media (width <= 520px) {
.ww-grid {
grid-template-columns: 1fr;
}
.ww-headline {
grid-template-columns: 1fr;
}
}
.ww-block {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.ww-section-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--el-text-color-secondary);
min-width: 0;
}
.ww-section-icon {
font-size: 14px;
color: var(--el-text-color-placeholder);
flex-shrink: 0;
}
.ww-section-info-wrap {
display: inline-flex;
align-items: center;
flex-shrink: 0;
cursor: help;
}
.ww-section-info {
font-size: 13px;
color: var(--el-text-color-placeholder);
}
.ww-pie-wrap {
position: relative;
width: 100%;
flex: 1;
min-height: 0;
}
.ww-pie {
width: 100%;
height: 100%;
}
.ww-bar {
width: 100%;
flex: 1;
min-height: 0;
}
.ww-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 14px;
padding-top: 10px;
border-top: 1px solid var(--el-border-color-lighter);
font-size: 13px;
flex-shrink: 0;
}
.ww-footer b {
font-weight: 700;
}
.ww-footer__delta {
font-weight: 600;
}
.ww-footer__delta.is-success {
color: var(--el-color-success);
}
.ww-footer__delta.is-danger {
color: var(--el-color-danger);
}
.ww-footer__delta.is-muted {
color: var(--el-text-color-secondary);
}
/* ============ 团队工时 ============ */
.tw-kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-bottom: 12px;
flex-shrink: 0;
}
.tw-kpi {
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 12px;
background: var(--el-fill-color-lighter);
border-radius: 8px;
min-width: 0;
}
.tw-kpi__label {
font-size: 11px;
color: var(--el-text-color-secondary);
}
.tw-kpi__value {
font-size: 20px;
font-weight: 700;
color: var(--el-text-color-primary);
line-height: 1.2;
}
.tw-kpi__value.is-danger {
color: var(--el-color-danger);
}
.tw-kpi__value.is-warn {
color: var(--el-color-warning);
}
.tw-kpi__unit {
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-secondary);
margin-left: 2px;
}
.tw-kpi__sub {
font-size: 11px;
color: var(--el-text-color-placeholder);
}
.tw-bar {
width: 100%;
flex: 1;
min-height: 0;
}
.tw-footer {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
flex-shrink: 0;
}
.tw-footer b {
color: var(--el-text-color-primary);
font-weight: 600;
margin: 0 2px;
}
.tw-footer__icon {
vertical-align: -2px;
margin-right: 2px;
font-size: 13px;
}
.tw-footer__icon.is-danger {
color: var(--el-color-danger);
}
.tw-footer__icon.is-warn {
color: var(--el-color-warning);
}
.tw-footer__sep {
color: var(--el-text-color-placeholder);
}
@media (width <= 520px) {
.tw-kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>