feat(projects): 工作台小组件设计
This commit is contained in:
@@ -1,4 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import dayjs from 'dayjs';
|
||||
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
|
||||
import { type ECOption, useEcharts } from '@/hooks/common/echarts';
|
||||
import { getWorkbenchItemColor } from '../composables/use-workbench-colors';
|
||||
import {
|
||||
type WorkbenchTeamWorklogView,
|
||||
type WorkbenchWeekWorklogView,
|
||||
type WorkbenchWorklogDistributionItem,
|
||||
buildWorkbenchTeamWorklogView,
|
||||
buildWorkbenchWeekWorklogView
|
||||
} from '../homepage';
|
||||
import { workbenchMyWeekWorklogMock, workbenchTeamWorklogMock } from '../mock';
|
||||
import WorkbenchModuleCard from './workbench-module-card.vue';
|
||||
|
||||
defineOptions({ name: 'WorkbenchMyWeekWorklog' });
|
||||
@@ -10,91 +24,625 @@ interface Props {
|
||||
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
|
||||
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
|
||||
|
||||
const days = ['周一', '周二', '周三', '周四', '周五', '今日'];
|
||||
const hoursPerDay = [4, 7, 6, 8, 7.5, 0]; // 今日为 0(未填报)
|
||||
const total = hoursPerDay.reduce((s, h) => s + h, 0);
|
||||
const target = 40;
|
||||
const todayProgress = 32.5; // 截至当前小时数(含历史 + 今日已提交部分)
|
||||
const delta = todayProgress - target * 0.75; // 目标按周 75% 进度
|
||||
const deltaText = delta >= 0 ? `领先目标 +${delta.toFixed(1)}h` : `落后目标 ${delta.toFixed(1)}h`;
|
||||
const deltaClass = delta >= 0 ? 'text-success' : 'text-danger';
|
||||
const router = useRouter();
|
||||
|
||||
// 折线坐标计算(基于 200x80 viewBox)
|
||||
const padX = 10;
|
||||
const padY = 10;
|
||||
const width = 200;
|
||||
const height = 80;
|
||||
const innerW = width - padX * 2;
|
||||
const innerH = height - padY * 2;
|
||||
const maxY = 10;
|
||||
const points = hoursPerDay.map((h, i) => {
|
||||
const x = padX + (i / (hoursPerDay.length - 1)) * innerW;
|
||||
const y = padY + innerH - (h / maxY) * innerH;
|
||||
return { x: Number(x.toFixed(1)), y: Number(y.toFixed(1)) };
|
||||
// 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') : '';
|
||||
});
|
||||
|
||||
type TabKey = 'my' | 'team';
|
||||
const activeTab = ref<TabKey>('my');
|
||||
|
||||
// ============ 我的工时 ============
|
||||
|
||||
const myView = computed<WorkbenchWeekWorklogView | null>(() => {
|
||||
if (!selectedWeekStart.value) return null;
|
||||
if (selectedWeekStart.value === workbenchMyWeekWorklogMock.current.weekStart) {
|
||||
return buildWorkbenchWeekWorklogView(workbenchMyWeekWorklogMock.current);
|
||||
}
|
||||
if (selectedWeekStart.value === workbenchMyWeekWorklogMock.previous.weekStart) {
|
||||
return buildWorkbenchWeekWorklogView(workbenchMyWeekWorklogMock.previous);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const isCurrentWeek = computed(() => selectedWeekStart.value === workbenchMyWeekWorklogMock.current.weekStart);
|
||||
|
||||
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';
|
||||
const WEEK_BAR_COLOR = '#A0CFFF';
|
||||
|
||||
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 dayPart = params.find((p: any) => p.seriesName === '按天填')?.value ?? 0;
|
||||
const weekPart = params.find((p: any) => p.seriesName === '按周均分')?.value ?? 0;
|
||||
const total = Number(dayPart) + Number(weekPart);
|
||||
return `周${dayName}:${total}h<br/>按天填 ${dayPart}h<br/>按周均分 ${weekPart}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',
|
||||
stack: 'total',
|
||||
barWidth: 18,
|
||||
data: v?.dailyByDay ?? [],
|
||||
itemStyle: { color: DAY_BAR_COLOR, borderRadius: [0, 0, 2, 2] }
|
||||
},
|
||||
{
|
||||
name: '按周均分',
|
||||
type: 'bar',
|
||||
stack: 'total',
|
||||
barWidth: 18,
|
||||
data: v?.dailyByWeekAvg ?? [],
|
||||
itemStyle: { color: WEEK_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<WorkbenchTeamWorklogView | null>(() => {
|
||||
if (!selectedWeekStart.value) return null;
|
||||
if (selectedWeekStart.value === workbenchTeamWorklogMock.current.weekStart) {
|
||||
return buildWorkbenchTeamWorklogView(workbenchTeamWorklogMock.current);
|
||||
}
|
||||
if (selectedWeekStart.value === workbenchTeamWorklogMock.previous.weekStart) {
|
||||
return buildWorkbenchTeamWorklogView(workbenchTeamWorklogMock.previous);
|
||||
}
|
||||
return 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());
|
||||
}
|
||||
});
|
||||
const polyline = points.map(p => `${p.x},${p.y}`).join(' ');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WorkbenchModuleCard
|
||||
title="我的本周工时"
|
||||
icon="mdi:chart-line"
|
||||
title="工时"
|
||||
icon="mdi:timer-outline"
|
||||
:editing="editing"
|
||||
:collapsed="collapsed"
|
||||
@hide="$emit('hide')"
|
||||
@toggle-collapse="$emit('toggle-collapse')"
|
||||
>
|
||||
<svg viewBox="0 0 200 80" preserveAspectRatio="none" class="spark">
|
||||
<line x1="0" y1="20" x2="200" y2="20" stroke="var(--el-border-color-lighter)" stroke-dasharray="3,3" />
|
||||
<polyline :points="polyline" fill="none" stroke="var(--el-color-primary)" stroke-width="2" />
|
||||
<circle v-for="(p, i) in points" :key="i" :cx="p.x" :cy="p.y" r="3" fill="var(--el-color-primary)" />
|
||||
</svg>
|
||||
<div class="ww-x">
|
||||
<span v-for="d in days" :key="d">{{ d }}</span>
|
||||
<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>
|
||||
<div class="ww-foot">
|
||||
<span>
|
||||
累计
|
||||
<b>{{ todayProgress }}h</b>
|
||||
/ {{ target }}h
|
||||
</span>
|
||||
<span :class="deltaClass">{{ deltaText }}</span>
|
||||
|
||||
<!-- ============ 我的工时 tab ============ -->
|
||||
<div v-show="activeTab === 'my'">
|
||||
<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>
|
||||
</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 class="ww-bar-legend">
|
||||
<span class="ww-bar-legend__item">
|
||||
<span class="ww-bar-legend__swatch" :style="{ background: DAY_BAR_COLOR }" />
|
||||
按天填
|
||||
</span>
|
||||
<span class="ww-bar-legend__item">
|
||||
<span class="ww-bar-legend__swatch" :style="{ background: WEEK_BAR_COLOR }" />
|
||||
按周均分
|
||||
</span>
|
||||
</div>
|
||||
</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'">
|
||||
<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">超 45h</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>
|
||||
<div class="ww-hint">本周总和(含今日):{{ total.toFixed(1) }}h</div>
|
||||
</WorkbenchModuleCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.spark {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
display: block;
|
||||
}
|
||||
.ww-x {
|
||||
/* ============ 顶部 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);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
.ww-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
@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;
|
||||
}
|
||||
|
||||
.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-pie-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
}
|
||||
.ww-pie {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ww-bar {
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
}
|
||||
.ww-bar-legend {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.ww-foot {
|
||||
.ww-bar-legend__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.ww-bar-legend__swatch {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.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;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.ww-foot b {
|
||||
.ww-footer b {
|
||||
font-weight: 700;
|
||||
}
|
||||
.ww-hint {
|
||||
margin-top: 4px;
|
||||
.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;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.text-success {
|
||||
color: var(--el-color-success);
|
||||
|
||||
.tw-bar {
|
||||
width: 100%;
|
||||
height: 240px;
|
||||
}
|
||||
.text-danger {
|
||||
|
||||
.tw-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user