Files
cn-rdms-web/src/views/personal-center/work-report/project/index.vue

452 lines
14 KiB
Vue
Raw Normal View History

<script setup lang="tsx">
/* eslint-disable no-void */
import { computed, markRaw, reactive, ref } from 'vue';
import { ElMessageBox, ElTag } from 'element-plus';
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 { resolveWorkReportSummaryPeriod } from '../shared/utils';
import TeamReportSummary from '../shared/components/team-report-summary.vue';
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 table = useUIPaginatedTable<
Awaited<ReturnType<typeof fetchGetProjectReportPage>>,
Api.WorkReport.Project.ProjectReport
>({
paginationProps: { currentPage: searchParams.pageNo, pageSize: searchParams.pageSize },
api: () =>
fetchGetProjectReportPage({
...searchParams,
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'));
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,
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;
}
teamSummaryLoading.value = true;
const { error, data } = await fetchGetTeamReportSummary({
reportType: 'project',
periodKey: summaryPeriod.value.periodKey
});
teamSummaryLoading.value = false;
teamSummary.value = error || !data ? null : data;
}
defineExpose({ reload });
</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"
/>
<TeamReportSummary
v-if="isTeamRootSelected"
report-type="project"
:period-key="summaryPeriod.periodKey"
:period-label="formatPeriod(summaryPeriod)"
:loading="teamSummaryLoading"
:summary="teamSummary"
@reminded="loadTeamSummary"
/>
<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>