510 lines
16 KiB
Vue
510 lines
16 KiB
Vue
<script setup lang="tsx">
|
||
/* eslint-disable no-void */
|
||
import { computed, markRaw, reactive, ref } from 'vue';
|
||
import { ElMessageBox, ElTag } from 'element-plus';
|
||
import dayjs from 'dayjs';
|
||
import {
|
||
fetchDeleteProjectReport,
|
||
fetchExportProjectReportContent,
|
||
fetchGetProjectReportPage,
|
||
fetchGetTeamReportSummary,
|
||
fetchSubmitProjectReport
|
||
} from '@/service/api';
|
||
import { useAuth } from '@/hooks/business/auth';
|
||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
|
||
import { type TeamViewContext, resolveTeamQueryUserIds } from '@/views/personal-center/shared/team-dashboard';
|
||
import {
|
||
type WorkReportRow,
|
||
createProjectSearchParams,
|
||
createWorkReportContentExportFallbackName,
|
||
downloadBlob,
|
||
formatDateTime,
|
||
formatEmptyText,
|
||
formatPeriod,
|
||
getWorkReportStatusLabel,
|
||
getWorkReportTypeDisplayLabel,
|
||
resolveExportFilename,
|
||
resolveWorkReportStatusTagType,
|
||
transformWorkReportPage
|
||
} from '../shared/types';
|
||
import { buildProjectPeriodFromMonth, resolveWorkReportSummaryPeriod } from '../shared/utils';
|
||
import ProjectReportSearch from './modules/search-panel.vue';
|
||
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
|
||
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
|
||
import IconMdiFileDocumentCheckOutline from '~icons/mdi/file-document-check-outline';
|
||
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
|
||
import IconMdiSendOutline from '~icons/mdi/send-outline';
|
||
import IconMdiDownloadOutline from '~icons/mdi/download-outline';
|
||
|
||
defineOptions({ name: 'ProjectWorkReportIndex' });
|
||
|
||
const props = defineProps<{
|
||
teamContext?: TeamViewContext | null;
|
||
projectOptions: Api.WorkReport.Project.ProjectReportOwnerProjectOption[];
|
||
projectOptionsLoaded: boolean;
|
||
}>();
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'create'): void;
|
||
(e: 'edit', row: WorkReportRow): void;
|
||
(e: 'detail', row: WorkReportRow): void;
|
||
(e: 'approvalRecord', row: WorkReportRow): void;
|
||
}>();
|
||
|
||
const { hasAuth } = useAuth();
|
||
const exporting = ref(false);
|
||
const selectedRows = ref<Api.WorkReport.Project.ProjectReport[]>([]);
|
||
const searchParams = reactive(createProjectSearchParams());
|
||
const teamSummaryLoading = ref(false);
|
||
const teamSummary = ref<Api.WorkReport.Common.TeamReportSummary | null>(null);
|
||
|
||
const ACTION_ICON_MAP = {
|
||
detail: markRaw(IconMdiEyeOutline),
|
||
edit: markRaw(IconMdiPencilOutline),
|
||
submit: markRaw(IconMdiSendOutline),
|
||
delete: markRaw(IconMdiDeleteOutline),
|
||
approvalRecord: markRaw(IconMdiFileDocumentCheckOutline),
|
||
export: markRaw(IconMdiDownloadOutline)
|
||
};
|
||
|
||
const isTeamMode = computed(() => props.teamContext?.mode === 'team');
|
||
const isTeamRootSelected = computed(() => Boolean(isTeamMode.value && props.teamContext?.isRootSelected));
|
||
const currentProjectOwnerIds = computed(() => resolveTeamQueryUserIds(props.teamContext));
|
||
const reportTitle = computed(() => getWorkReportTypeDisplayLabel('project', isTeamMode.value));
|
||
const normalizedPeriodRange = computed(() => {
|
||
const periodRange = searchParams.periodStartDate;
|
||
if (!periodRange?.length) {
|
||
return periodRange;
|
||
}
|
||
|
||
const [startDate, endDate] = periodRange;
|
||
const start = dayjs(startDate);
|
||
const end = dayjs(endDate || startDate);
|
||
|
||
if (!start.isValid() || !end.isValid()) {
|
||
return periodRange;
|
||
}
|
||
|
||
return [start.startOf('month').format('YYYY-MM-DD'), end.endOf('month').format('YYYY-MM-DD')];
|
||
});
|
||
|
||
const table = useUIPaginatedTable<
|
||
Awaited<ReturnType<typeof fetchGetProjectReportPage>>,
|
||
Api.WorkReport.Project.ProjectReport
|
||
>({
|
||
paginationProps: { currentPage: searchParams.pageNo, pageSize: searchParams.pageSize },
|
||
api: () =>
|
||
fetchGetProjectReportPage({
|
||
...searchParams,
|
||
periodStartDate: normalizedPeriodRange.value,
|
||
projectOwnerIds: currentProjectOwnerIds.value
|
||
}),
|
||
transform: response => transformWorkReportPage(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
|
||
onPaginationParamsChange: params => {
|
||
searchParams.pageNo = params.currentPage ?? 1;
|
||
searchParams.pageSize = params.pageSize ?? 10;
|
||
},
|
||
columns: () => [
|
||
{ prop: 'index', type: 'index', label: '序号', width: 64 },
|
||
...(isTeamMode.value
|
||
? [{ prop: 'projectOwnerName', label: '提交人', minWidth: 100, showOverflowTooltip: true }]
|
||
: []),
|
||
{ prop: 'projectName', label: '项目名称', minWidth: 200, showOverflowTooltip: true },
|
||
{ prop: 'periodLabel', label: '半月周期', minWidth: 120, formatter: row => formatPeriod(row) },
|
||
{ prop: 'projectOwnerName', label: '项目负责人', minWidth: 80 },
|
||
{
|
||
prop: 'technicalOwnerName',
|
||
label: '技术负责人',
|
||
minWidth: 80,
|
||
formatter: row => row.technicalOwnerName || '--'
|
||
},
|
||
{ prop: 'supervisorName', label: '直属上级', minWidth: 80 },
|
||
{ prop: 'totalWorkHours', label: '总工时', minWidth: 60, formatter: row => formatEmptyText(row.totalWorkHours) },
|
||
{
|
||
prop: 'statusCode',
|
||
label: '状态',
|
||
minWidth: 60,
|
||
align: 'center',
|
||
formatter: row => (
|
||
<ElTag type={resolveWorkReportStatusTagType(row.statusCode)}>
|
||
{getWorkReportStatusLabel(row.statusCode, row.statusName)}
|
||
</ElTag>
|
||
)
|
||
},
|
||
{ prop: 'submitTime', label: '提交时间', minWidth: 100, formatter: row => formatDateTime(row.submitTime) },
|
||
{ prop: 'approvalTime', label: '审批时间', minWidth: 100, formatter: row => formatDateTime(row.approvalTime) },
|
||
{
|
||
prop: 'operate',
|
||
label: '操作',
|
||
width: isTeamMode.value ? 140 : 180,
|
||
align: 'center',
|
||
fixed: 'right',
|
||
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
|
||
}
|
||
]
|
||
});
|
||
|
||
// 团队统计始终使用当前周期(当前半月),不跟随列表第一条数据的周期
|
||
const summaryPeriod = computed(() =>
|
||
resolveWorkReportSummaryPeriod('project', {
|
||
periodRange: normalizedPeriodRange.value
|
||
})
|
||
);
|
||
const summaryPeriodKeys = computed(() => {
|
||
const dateRange = normalizedPeriodRange.value;
|
||
const fallbackKey = summaryPeriod.value.periodKey;
|
||
|
||
if (!dateRange?.length) {
|
||
return fallbackKey ? [fallbackKey] : [];
|
||
}
|
||
|
||
const [startDate, endDate] = dateRange;
|
||
const start = dayjs(startDate);
|
||
const end = dayjs(endDate || startDate);
|
||
|
||
if (!start.isValid() || !end.isValid()) {
|
||
return fallbackKey ? [fallbackKey] : [];
|
||
}
|
||
|
||
const keys: string[] = [];
|
||
const endBoundary = end.endOf('month');
|
||
|
||
for (
|
||
let cursor = start.startOf('month');
|
||
cursor.isBefore(endBoundary, 'month') || cursor.isSame(endBoundary, 'month');
|
||
cursor = cursor.add(1, 'month')
|
||
) {
|
||
keys.push(buildProjectPeriodFromMonth(cursor, 1).periodKey);
|
||
keys.push(buildProjectPeriodFromMonth(cursor, 2).periodKey);
|
||
}
|
||
|
||
return keys;
|
||
});
|
||
const hasSearchedDateRange = computed(() => searchParams.periodStartDate?.length === 2);
|
||
|
||
function getRowActions(row: Api.WorkReport.Project.ProjectReport): BusinessTableAction[] {
|
||
const actions: BusinessTableAction[] = [
|
||
{
|
||
key: 'detail',
|
||
label: '查看',
|
||
buttonType: 'primary',
|
||
icon: ACTION_ICON_MAP.detail,
|
||
onClick: () => emit('detail', row)
|
||
}
|
||
];
|
||
|
||
if (isTeamMode.value) {
|
||
actions.push({
|
||
key: 'export',
|
||
label: '导出',
|
||
buttonType: 'success',
|
||
icon: ACTION_ICON_MAP.export,
|
||
onClick: () =>
|
||
exportReportContent(
|
||
{
|
||
exportAll: false,
|
||
ids: [row.id]
|
||
},
|
||
1
|
||
)
|
||
});
|
||
|
||
if (['approved', 'rejected'].includes(row.statusCode)) {
|
||
actions.push({
|
||
key: 'approval-record',
|
||
label: '审批记录',
|
||
buttonType: 'info',
|
||
icon: ACTION_ICON_MAP.approvalRecord,
|
||
onClick: () => emit('approvalRecord', row)
|
||
});
|
||
}
|
||
|
||
return actions;
|
||
}
|
||
|
||
if (['draft', 'rejected'].includes(row.statusCode) && row.allowEdit && hasAuth('project:work-report:update')) {
|
||
actions.push({
|
||
key: 'edit',
|
||
label: '编辑',
|
||
buttonType: 'primary',
|
||
icon: ACTION_ICON_MAP.edit,
|
||
onClick: () => emit('edit', row)
|
||
});
|
||
actions.push({
|
||
key: 'submit',
|
||
label: row.statusCode === 'draft' ? '提交' : '重新提交',
|
||
buttonType: 'success',
|
||
icon: ACTION_ICON_MAP.submit,
|
||
onClick: () => handleSubmitReport(row)
|
||
});
|
||
}
|
||
|
||
if (row.statusCode === 'draft' && hasAuth('project:work-report:delete')) {
|
||
actions.push({
|
||
key: 'delete',
|
||
label: '删除',
|
||
buttonType: 'danger',
|
||
icon: ACTION_ICON_MAP.delete,
|
||
onClick: () => handleDelete(row)
|
||
});
|
||
}
|
||
|
||
if (['approved', 'rejected'].includes(row.statusCode)) {
|
||
actions.push({
|
||
key: 'approval-record',
|
||
label: '审批记录',
|
||
buttonType: 'info',
|
||
icon: ACTION_ICON_MAP.approvalRecord,
|
||
onClick: () => emit('approvalRecord', row)
|
||
});
|
||
}
|
||
|
||
return actions;
|
||
}
|
||
|
||
async function reload(page?: number) {
|
||
await table.getDataByPage(page ?? searchParams.pageNo ?? 1);
|
||
await loadTeamSummary();
|
||
}
|
||
|
||
function resetSearchParams() {
|
||
const pageSize = searchParams.pageSize ?? 10;
|
||
Object.assign(searchParams, createProjectSearchParams(), { pageSize });
|
||
reload(1);
|
||
}
|
||
|
||
function handleSearch() {
|
||
reload(1);
|
||
}
|
||
|
||
async function handleSubmitReport(row: Api.WorkReport.Project.ProjectReport) {
|
||
try {
|
||
await ElMessageBox.confirm('确认提交该报告吗?', '提交确认', {
|
||
type: 'warning',
|
||
confirmButtonText: row.statusCode === 'draft' ? '确认提交' : '确认重新提交',
|
||
cancelButtonText: '取消'
|
||
});
|
||
} catch {
|
||
return;
|
||
}
|
||
|
||
const result = await fetchSubmitProjectReport(row.id);
|
||
|
||
if (result.error) return;
|
||
window.$message?.success('工作报告已提交');
|
||
await reload();
|
||
}
|
||
|
||
async function handleDelete(row: Api.WorkReport.Project.ProjectReport) {
|
||
try {
|
||
await ElMessageBox.confirm(`确认删除 ${formatPeriod(row)} 吗?`, '删除确认', {
|
||
type: 'warning',
|
||
confirmButtonText: '确认',
|
||
cancelButtonText: '取消'
|
||
});
|
||
} catch {
|
||
return;
|
||
}
|
||
|
||
const result = await fetchDeleteProjectReport(row.id);
|
||
|
||
if (result.error) return;
|
||
|
||
window.$message?.success('工作报告已删除');
|
||
await reload();
|
||
}
|
||
|
||
function handleSelectionChange(rows: Api.WorkReport.Project.ProjectReport[]) {
|
||
selectedRows.value = rows;
|
||
}
|
||
|
||
function createExportSearchParams() {
|
||
const { pageNo: _pageNo, pageSize: _pageSize, ...params } = searchParams;
|
||
return {
|
||
...params,
|
||
periodStartDate: normalizedPeriodRange.value,
|
||
projectOwnerIds: currentProjectOwnerIds.value
|
||
};
|
||
}
|
||
|
||
async function exportReportContent(
|
||
params: Api.WorkReport.Common.ContentExportParams<Api.WorkReport.Project.ProjectReportSearchParams>,
|
||
reportCount: number
|
||
) {
|
||
exporting.value = true;
|
||
const result = await fetchExportProjectReportContent(params);
|
||
exporting.value = false;
|
||
|
||
if (result.error || !result.data) return;
|
||
|
||
const fallbackName = createWorkReportContentExportFallbackName('project', reportCount);
|
||
downloadBlob(result.data, resolveExportFilename(result, fallbackName));
|
||
}
|
||
|
||
async function handleExportSelected() {
|
||
if (!selectedRows.value.length) {
|
||
window.$message?.warning('请选择要导出的报告');
|
||
return;
|
||
}
|
||
|
||
await exportReportContent(
|
||
{
|
||
exportAll: false,
|
||
ids: selectedRows.value.map(item => item.id)
|
||
},
|
||
selectedRows.value.length
|
||
);
|
||
}
|
||
|
||
async function handleExportAll() {
|
||
const total = table.mobilePagination.value.total || 0;
|
||
if (!total) {
|
||
window.$message?.warning('暂无可导出的报告');
|
||
return;
|
||
}
|
||
|
||
await exportReportContent(
|
||
{
|
||
...createExportSearchParams(),
|
||
exportAll: true,
|
||
ids: []
|
||
},
|
||
total
|
||
);
|
||
}
|
||
|
||
async function handleExportCommand(command: 'selected' | 'all') {
|
||
if (command === 'selected') {
|
||
await handleExportSelected();
|
||
return;
|
||
}
|
||
|
||
await handleExportAll();
|
||
}
|
||
|
||
async function loadTeamSummary() {
|
||
if (!isTeamRootSelected.value) {
|
||
teamSummaryLoading.value = false;
|
||
teamSummary.value = null;
|
||
return;
|
||
}
|
||
|
||
const dateRange = normalizedPeriodRange.value;
|
||
const summaryParams: Api.WorkReport.Common.TeamReportSummaryParams = { reportType: 'project' };
|
||
|
||
if (dateRange?.length === 2) {
|
||
summaryParams.periodStartDate = dateRange[0];
|
||
summaryParams.periodEndDate = dateRange[1];
|
||
} else {
|
||
summaryParams.periodKey = summaryPeriod.value.periodKey;
|
||
}
|
||
|
||
teamSummaryLoading.value = true;
|
||
const { error, data } = await fetchGetTeamReportSummary(summaryParams);
|
||
teamSummaryLoading.value = false;
|
||
|
||
teamSummary.value = error || !data ? null : data;
|
||
}
|
||
|
||
defineExpose({
|
||
reload,
|
||
teamSummary,
|
||
teamSummaryLoading,
|
||
summaryPeriod,
|
||
summaryPeriodKeys,
|
||
hasSearchedDateRange,
|
||
loadTeamSummary
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div class="flex-col-stretch gap-16px overflow-hidden">
|
||
<!-- 项目选项加载失败时的提示 -->
|
||
<ElAlert v-if="!projectOptionsLoaded" type="warning" :closable="false" show-icon>
|
||
项目数据加载失败,部分功能可能不可用,请刷新页面重试
|
||
</ElAlert>
|
||
|
||
<ProjectReportSearch
|
||
v-model:model="searchParams"
|
||
:project-options="projectOptions"
|
||
@reset="resetSearchParams"
|
||
@search="handleSearch"
|
||
/>
|
||
|
||
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
|
||
<template #header>
|
||
<div class="flex flex-wrap items-center justify-between gap-12px">
|
||
<div class="flex items-center gap-10px">
|
||
<p class="text-16px font-600">{{ reportTitle }}</p>
|
||
<ElTag effect="plain">{{ table.mobilePagination.value.total || 0 }}</ElTag>
|
||
</div>
|
||
|
||
<TableHeaderOperation
|
||
v-model:columns="table.columnChecks.value"
|
||
:loading="table.loading.value"
|
||
@refresh="reload()"
|
||
>
|
||
<template #default>
|
||
<ElDropdown v-auth="'project:work-report:export'" trigger="click" @command="handleExportCommand">
|
||
<ElButton plain :loading="exporting">
|
||
<template #icon>
|
||
<icon-mdi-download class="text-icon" />
|
||
</template>
|
||
导出
|
||
</ElButton>
|
||
<template #dropdown>
|
||
<ElDropdownMenu>
|
||
<ElDropdownItem command="selected" :disabled="exporting || !selectedRows.length">
|
||
导出选中
|
||
</ElDropdownItem>
|
||
<ElDropdownItem command="all" :disabled="exporting">导出全部</ElDropdownItem>
|
||
</ElDropdownMenu>
|
||
</template>
|
||
</ElDropdown>
|
||
<ElButton
|
||
v-if="!isTeamMode"
|
||
v-auth="'project:work-report:create'"
|
||
plain
|
||
type="primary"
|
||
@click="emit('create')"
|
||
>
|
||
<template #icon>
|
||
<icon-ic-round-plus class="text-icon" />
|
||
</template>
|
||
新增
|
||
</ElButton>
|
||
</template>
|
||
</TableHeaderOperation>
|
||
</div>
|
||
</template>
|
||
|
||
<div class="flex-1">
|
||
<ElTable
|
||
v-loading="table.loading.value"
|
||
height="100%"
|
||
border
|
||
row-key="id"
|
||
:data="table.data.value"
|
||
@selection-change="handleSelectionChange"
|
||
>
|
||
<ElTableColumn type="selection" width="48" />
|
||
<template v-for="col in table.columns.value" :key="String(col.prop)">
|
||
<ElTableColumn v-bind="col" />
|
||
</template>
|
||
</ElTable>
|
||
</div>
|
||
|
||
<div class="mt-20px flex justify-end">
|
||
<ElPagination
|
||
v-if="table.mobilePagination.value.total"
|
||
layout="total,prev,pager,next,sizes"
|
||
v-bind="table.mobilePagination.value"
|
||
@current-change="table.mobilePagination.value['current-change']"
|
||
@size-change="table.mobilePagination.value['size-change']"
|
||
/>
|
||
</div>
|
||
</ElCard>
|
||
</div>
|
||
</template>
|