Files
cn-rdms-web/src/views/workbench/modules/workbench-my-week-worklog.vue
dk 7f31bed6f6 feat(personal-center): 将加班申请功能重构为业务学习功能。
fix(week-report): 修复周报中工作日志的显示问题。
feat(work-report): 点击编辑时,自动刷新出最新的内容。
2026-07-19 14:04:51 +08:00

677 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 { useWorkbenchWorklogSignal } from '../composables/use-workbench-worklog-signal';
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 });
// 同一工作台内(如 todo 面板)弹层填报工时不切路由、不触发 onActivated靠信号驱动重拉当前周
const { revision: worklogRevision } = useWorkbenchWorklogSignal();
watch(worklogRevision, loadWorklogWeek);
// 工作台路由 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: v?.visibleDayLabels ?? ['一', '二', '三', '四', '五'],
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?.visibleDailyHours ?? [],
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));
function buildTeamBarOption(): ECOption {
const v = teamView.value;
if (!v) return {};
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 totalHours = Number(params.find((p: any) => p.seriesName === '总工时')?.value ?? 0);
const overtimeHours = Number(params.find((p: any) => p.seriesName === '学习工时')?.value ?? 0);
return [
`<div style="font-weight:600;margin-bottom:4px">${name}</div>`,
`总工时:${totalHours}h`,
`学习工时:${overtimeHours}h`
].join('<br/>');
}
},
grid: { left: 32, right: 12, top: 16, bottom: 24, 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: [
{
name: '总工时',
type: 'bar',
barWidth: 16,
data: v.members.map(member => member.totalHours),
itemStyle: {
color: '#409EFF',
borderRadius: [3, 3, 0, 0]
}
},
{
name: '学习工时',
type: 'bar',
barWidth: 16,
data: v.members.map(member => member.overtimeHours),
itemStyle: {
color: '#F59E0B',
borderRadius: [3, 3, 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>
<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(3, 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__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>