Files
cn-rdms-web/src/views/workbench/modules/workbench-my-week-worklog.vue

680 lines
19 KiB
Vue
Raw Normal View History

<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">
2026-06-17 19:27:17 +08:00
<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;
}
2026-06-17 19:27:17 +08:00
.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>