fix(加班申请): 去掉撤销相关的状态和动作。

feat(工作报告): 开发工作报告功能
This commit is contained in:
dk
2026-06-11 10:56:24 +08:00
parent 2e369b23a9
commit d53a8dfae5
56 changed files with 14312 additions and 2910 deletions

View File

@@ -0,0 +1,241 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { onBeforeRouteLeave } from 'vue-router';
import { fetchGetProjectReportOwnerProjectOptions } from '@/service/api';
import { useAuth } from '@/hooks/business/auth';
import WorkReportCreateDialog from './shared/components/create-dialog.vue';
import WorkReportPrototypePageDialog from './shared/components/prototype-page-dialog.vue';
import WorkReportTabs from './shared/components/tabs.vue';
import {
WORK_REPORT_PROJECT_OWNER_PERMISSION,
WORK_REPORT_TYPE_LABEL,
type WorkReportRow,
type WorkReportType
} from './shared/types';
import WeeklyReportIndex from './weekly/index.vue';
import WeeklyReportApprovalRecordDialog from './weekly/modules/approval-record-dialog.vue';
import MonthlyReportIndex from './monthly/index.vue';
import MonthlyReportApprovalRecordDialog from './monthly/modules/approval-record-dialog.vue';
import ProjectReportIndex from './project/index.vue';
import ProjectReportApprovalRecordDialog from './project/modules/approval-record-dialog.vue';
defineOptions({ name: 'PersonalCenterWorkReport' });
type PageDialogMode = 'add' | 'edit' | 'detail';
type ReportListExpose = {
reload: (page?: number) => Promise<void>;
};
const { hasAuth } = useAuth();
const activeTab = ref<WorkReportType>('weekly');
const createVisible = ref(false);
const pageDialogVisible = ref(false);
const pageDialogMode = ref<PageDialogMode>('detail');
const approvalRecordVisible = ref(false);
const currentReportType = ref<WorkReportType>('weekly');
const currentRow = ref<WorkReportRow | null>(null);
const initialPeriod = ref<{
periodKey: string;
periodLabel: string;
periodStartDate: string;
periodEndDate: string;
} | null>(null);
const initialProjectId = ref('');
const initialFlag = ref(1);
const projectOptions = ref<Api.WorkReport.Project.ProjectReportOwnerProjectOption[]>([]);
const weeklyRef = ref<ReportListExpose | null>(null);
const monthlyRef = ref<ReportListExpose | null>(null);
const projectRef = ref<ReportListExpose | null>(null);
const canShowProjectTab = computed(() => hasAuth(WORK_REPORT_PROJECT_OWNER_PERMISSION));
/** 项目选项是否加载成功(用于项目半月报列表内部判断) */
const projectOptionsLoaded = ref(false);
const visibleTabs = computed<Array<{ label: string; name: WorkReportType }>>(() => {
const tabs: Array<{ label: string; name: WorkReportType }> = [
{ label: WORK_REPORT_TYPE_LABEL.weekly, name: 'weekly' },
{ label: WORK_REPORT_TYPE_LABEL.monthly, name: 'monthly' }
];
if (canShowProjectTab.value) {
tabs.push({ label: WORK_REPORT_TYPE_LABEL.project, name: 'project' });
}
return tabs;
});
const currentApprovalRecordDialogComponent = computed(() => {
if (currentReportType.value === 'monthly') return MonthlyReportApprovalRecordDialog;
if (currentReportType.value === 'project') return ProjectReportApprovalRecordDialog;
return WeeklyReportApprovalRecordDialog;
});
function getListRef(reportType: WorkReportType) {
if (reportType === 'monthly') return monthlyRef.value;
if (reportType === 'project') return projectRef.value;
return weeklyRef.value;
}
async function loadProjectOptions() {
if (!canShowProjectTab.value) return;
const { error, data } = await fetchGetProjectReportOwnerProjectOptions();
projectOptions.value = error || !data ? [] : data;
projectOptionsLoaded.value = !error;
}
function openCreate(reportType: WorkReportType) {
currentReportType.value = reportType;
createVisible.value = true;
}
function handleCreateConfirm(
payload:
| { reportType: 'weekly' | 'monthly'; period: typeof initialPeriod.value extends infer T ? T : never }
| {
reportType: 'project';
projectId: string;
flag: number;
period: typeof initialPeriod.value extends infer T ? T : never;
}
) {
currentReportType.value = payload.reportType;
pageDialogMode.value = 'add';
currentRow.value = null;
initialPeriod.value = payload.period as typeof initialPeriod.value;
initialProjectId.value = 'projectId' in payload ? payload.projectId : '';
initialFlag.value = 'flag' in payload ? payload.flag : 1;
pageDialogVisible.value = true;
}
function openEdit(reportType: WorkReportType, row: WorkReportRow) {
currentReportType.value = reportType;
pageDialogMode.value = 'edit';
currentRow.value = row;
initialPeriod.value = null;
pageDialogVisible.value = true;
}
function openDetail(reportType: WorkReportType, row: WorkReportRow) {
currentReportType.value = reportType;
pageDialogMode.value = 'detail';
currentRow.value = row;
initialPeriod.value = null;
pageDialogVisible.value = true;
}
function openApprovalRecord(reportType: WorkReportType, row: WorkReportRow) {
currentReportType.value = reportType;
currentRow.value = row;
approvalRecordVisible.value = true;
}
function handleTabChange(tab: WorkReportType) {
activeTab.value = tab;
getListRef(tab)?.reload(1);
}
async function reloadReport(reportType = currentReportType.value) {
await getListRef(reportType)?.reload();
}
function handleSubmitted() {
pageDialogVisible.value = false;
reloadReport(currentReportType.value);
}
function closeFloatingPanels() {
createVisible.value = false;
pageDialogVisible.value = false;
approvalRecordVisible.value = false;
}
onMounted(async () => {
await loadProjectOptions();
});
onBeforeRouteLeave(() => {
closeFloatingPanels();
});
</script>
<template>
<div
class="work-report-page-shell min-h-560px gap-16px overflow-hidden xl:grid xl:grid-cols-[240px_minmax(0,1fr)] lt-xl:flex lt-xl:flex-col lt-xl:overflow-auto"
>
<!-- 左侧报告类型导航 -->
<div class="flex-col-stretch gap-16px xl:min-h-0">
<WorkReportTabs :active-tab="activeTab" :tabs="visibleTabs" @update:active-tab="handleTabChange" />
</div>
<!-- 右侧搜索区 + 列表区 -->
<div class="flex-col-stretch gap-16px xl:min-h-0">
<WeeklyReportIndex
v-show="activeTab === 'weekly'"
ref="weeklyRef"
class="flex-1-hidden"
@create="openCreate('weekly')"
@edit="openEdit('weekly', $event)"
@detail="openDetail('weekly', $event)"
@approval-record="openApprovalRecord('weekly', $event)"
/>
<MonthlyReportIndex
v-show="activeTab === 'monthly'"
ref="monthlyRef"
class="flex-1-hidden"
@create="openCreate('monthly')"
@edit="openEdit('monthly', $event)"
@detail="openDetail('monthly', $event)"
@approval-record="openApprovalRecord('monthly', $event)"
/>
<ProjectReportIndex
v-if="canShowProjectTab"
v-show="activeTab === 'project'"
ref="projectRef"
class="flex-1-hidden"
:project-options="projectOptions"
:project-options-loaded="projectOptionsLoaded"
@create="openCreate('project')"
@edit="openEdit('project', $event)"
@detail="openDetail('project', $event)"
@approval-record="openApprovalRecord('project', $event)"
/>
</div>
<WorkReportCreateDialog
v-model:visible="createVisible"
:default-report-type="currentReportType"
:project-visible="canShowProjectTab"
:project-options="projectOptions"
@confirm="handleCreateConfirm"
/>
<WorkReportPrototypePageDialog
v-model:visible="pageDialogVisible"
:mode="pageDialogMode"
scene="fill"
:report-type="currentReportType"
:row-data="currentRow"
:initial-period="initialPeriod"
:initial-project-id="initialProjectId"
:initial-flag="initialFlag"
@submitted="handleSubmitted"
/>
<component
:is="currentApprovalRecordDialogComponent"
v-model:visible="approvalRecordVisible"
:row-data="currentRow"
/>
</div>
</template>
<style scoped>
.work-report-page-shell {
height: 100%;
}
</style>

View File

@@ -0,0 +1,345 @@
<script setup lang="tsx">
/* eslint-disable no-void */
import { markRaw, reactive, ref } from 'vue';
import { ElMessageBox, ElTag } from 'element-plus';
import {
fetchDeleteMonthlyReport,
fetchExportMonthlyReportContent,
fetchGetMonthlyReportPage,
fetchSubmitMonthlyReport
} 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 {
WORK_REPORT_TYPE_LABEL,
type WorkReportRow,
createMonthlySearchParams,
createWorkReportContentExportFallbackName,
downloadBlob,
formatDateTime,
formatEmptyText,
formatPeriod,
getWorkReportStatusLabel,
resolveExportFilename,
resolveWorkReportStatusTagType,
transformWorkReportPage
} from '../shared/types';
import MonthlyReportSearch 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';
defineOptions({ name: 'MonthlyWorkReportIndex' });
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.Monthly.MonthlyReport[]>([]);
const searchParams = reactive(createMonthlySearchParams());
const ACTION_ICON_MAP = {
detail: markRaw(IconMdiEyeOutline),
edit: markRaw(IconMdiPencilOutline),
submit: markRaw(IconMdiSendOutline),
delete: markRaw(IconMdiDeleteOutline),
approvalRecord: markRaw(IconMdiFileDocumentCheckOutline)
};
const table = useUIPaginatedTable<
Awaited<ReturnType<typeof fetchGetMonthlyReportPage>>,
Api.WorkReport.Monthly.MonthlyReport
>({
paginationProps: { currentPage: searchParams.pageNo, pageSize: searchParams.pageSize },
api: () => fetchGetMonthlyReportPage(searchParams),
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 },
{ prop: 'periodLabel', label: '月份', minWidth: 80, formatter: row => formatPeriod(row) },
{
prop: 'reporterDeptName',
label: '部门/方向',
minWidth: 80,
showOverflowTooltip: true,
formatter: row => row.reporterDeptName || '--'
},
{ prop: 'supervisorName', label: '直属上级', minWidth: 80 },
{ prop: 'totalWorkHours', label: '总工时', minWidth: 80, formatter: row => formatEmptyText(row.totalWorkHours) },
{
prop: 'statusCode',
label: '状态',
minWidth: 80,
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: 180,
align: 'center',
fixed: 'right',
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
}
]
});
function getRowActions(row: Api.WorkReport.Monthly.MonthlyReport): BusinessTableAction[] {
const actions: BusinessTableAction[] = [
{
key: 'detail',
label: '详情',
buttonType: 'primary',
icon: ACTION_ICON_MAP.detail,
onClick: () => emit('detail', row)
}
];
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);
}
function resetSearchParams() {
const pageSize = searchParams.pageSize ?? 10;
Object.assign(searchParams, createMonthlySearchParams(), { pageSize });
reload(1);
}
function handleSearch() {
reload(1);
}
async function handleSubmitReport(row: Api.WorkReport.Monthly.MonthlyReport) {
try {
await ElMessageBox.confirm('确认提交该报告吗?', '提交确认', {
type: 'warning',
confirmButtonText: row.statusCode === 'draft' ? '确认提交' : '确认重新提交',
cancelButtonText: '取消'
});
} catch {
return;
}
const result = await fetchSubmitMonthlyReport(row.id);
if (result.error) return;
window.$message?.success('工作报告已提交');
await reload();
}
async function handleDelete(row: Api.WorkReport.Monthly.MonthlyReport) {
try {
await ElMessageBox.confirm(`确认删除 ${formatPeriod(row)} 吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消'
});
} catch {
return;
}
const result = await fetchDeleteMonthlyReport(row.id);
if (result.error) return;
window.$message?.success('工作报告已删除');
await reload();
}
function handleSelectionChange(rows: Api.WorkReport.Monthly.MonthlyReport[]) {
selectedRows.value = rows;
}
function createExportSearchParams() {
const { pageNo: _pageNo, pageSize: _pageSize, ...params } = searchParams;
return params;
}
async function exportReportContent(
params: Api.WorkReport.Common.ContentExportParams<Api.WorkReport.Monthly.MonthlyReportSearchParams>,
reportCount: number
) {
exporting.value = true;
const result = await fetchExportMonthlyReportContent(params);
exporting.value = false;
if (result.error || !result.data) return;
const fallbackName = createWorkReportContentExportFallbackName('monthly', 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();
}
defineExpose({ reload });
</script>
<template>
<div class="flex-col-stretch gap-16px overflow-hidden">
<MonthlyReportSearch v-model:model="searchParams" @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">{{ WORK_REPORT_TYPE_LABEL.monthly }}</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-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>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import SharedWorkReportApprovalRecordDialog from '../../shared/components/approval-record-dialog.vue';
import type { WorkReportRow } from '../../shared/types';
defineOptions({ name: 'MonthlyReportApprovalRecordDialog' });
const visible = defineModel<boolean>('visible', { default: false });
defineProps<{
rowData?: WorkReportRow | null;
}>();
</script>
<template>
<SharedWorkReportApprovalRecordDialog v-model:visible="visible" report-type="monthly" :row-data="rowData" />
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import SharedWorkReportDetailDialog from '../../shared/components/detail-dialog.vue';
import type { WorkReportRow } from '../../shared/types';
defineOptions({ name: 'MonthlyReportDetailPage' });
const visible = defineModel<boolean>('visible', { default: false });
defineProps<{
rowData?: WorkReportRow | null;
}>();
</script>
<template>
<SharedWorkReportDetailDialog v-model:visible="visible" report-type="monthly" :row-data="rowData" />
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import SharedWorkReportSearch from '../../shared/components/search-panel.vue';
defineOptions({ name: 'MonthlyReportSearch' });
defineProps<{
projectOptions?: Api.WorkReport.Project.ProjectReportOwnerProjectOption[];
}>();
const model = defineModel<Api.WorkReport.Monthly.MonthlyReportSearchParams>('model', { required: true });
const emit = defineEmits<{
(e: 'reset'): void;
(e: 'search'): void;
}>();
</script>
<template>
<SharedWorkReportSearch
v-model:model="model"
report-type="monthly"
:project-options="projectOptions"
@reset="emit('reset')"
@search="emit('search')"
/>
</template>

View File

@@ -0,0 +1,363 @@
<script setup lang="tsx">
/* eslint-disable no-void */
import { markRaw, reactive, ref } from 'vue';
import { ElMessageBox, ElTag } from 'element-plus';
import {
fetchDeleteProjectReport,
fetchExportProjectReportContent,
fetchGetProjectReportPage,
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 {
WORK_REPORT_TYPE_LABEL,
type WorkReportRow,
createProjectSearchParams,
createWorkReportContentExportFallbackName,
downloadBlob,
formatDateTime,
formatEmptyText,
formatPeriod,
getProjectReportFlagLabel,
getWorkReportStatusLabel,
resolveExportFilename,
resolveWorkReportStatusTagType,
transformWorkReportPage
} from '../shared/types';
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';
defineOptions({ name: 'ProjectWorkReportIndex' });
const props = defineProps<{
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 ACTION_ICON_MAP = {
detail: markRaw(IconMdiEyeOutline),
edit: markRaw(IconMdiPencilOutline),
submit: markRaw(IconMdiSendOutline),
delete: markRaw(IconMdiDeleteOutline),
approvalRecord: markRaw(IconMdiFileDocumentCheckOutline)
};
const table = useUIPaginatedTable<
Awaited<ReturnType<typeof fetchGetProjectReportPage>>,
Api.WorkReport.Project.ProjectReport
>({
paginationProps: { currentPage: searchParams.pageNo, pageSize: searchParams.pageSize },
api: () => fetchGetProjectReportPage(searchParams),
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 },
{ prop: 'projectName', label: '项目名称', minWidth: 200, showOverflowTooltip: true },
{ prop: 'periodLabel', label: '半月周期', minWidth: 120, formatter: row => formatPeriod(row) },
// { prop: 'flag', label: '半月', width: 90, formatter: row => getProjectReportFlagLabel(row.flag) },
{ 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: 180,
align: 'center',
fixed: 'right',
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
}
]
});
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 (['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);
}
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;
}
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();
}
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"
/>
<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">{{ WORK_REPORT_TYPE_LABEL.project }}</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-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>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import SharedWorkReportApprovalRecordDialog from '../../shared/components/approval-record-dialog.vue';
import type { WorkReportRow } from '../../shared/types';
defineOptions({ name: 'ProjectReportApprovalRecordDialog' });
const visible = defineModel<boolean>('visible', { default: false });
defineProps<{
rowData?: WorkReportRow | null;
}>();
</script>
<template>
<SharedWorkReportApprovalRecordDialog v-model:visible="visible" report-type="project" :row-data="rowData" />
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import SharedWorkReportDetailDialog from '../../shared/components/detail-dialog.vue';
import type { WorkReportRow } from '../../shared/types';
defineOptions({ name: 'ProjectReportDetailPage' });
const visible = defineModel<boolean>('visible', { default: false });
defineProps<{
rowData?: WorkReportRow | null;
}>();
</script>
<template>
<SharedWorkReportDetailDialog v-model:visible="visible" report-type="project" :row-data="rowData" />
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import SharedWorkReportSearch from '../../shared/components/search-panel.vue';
defineOptions({ name: 'ProjectReportSearch' });
defineProps<{
projectOptions?: Api.WorkReport.Project.ProjectReportOwnerProjectOption[];
}>();
const model = defineModel<Api.WorkReport.Project.ProjectReportSearchParams>('model', { required: true });
const emit = defineEmits<{
(e: 'reset'): void;
(e: 'search'): void;
}>();
</script>
<template>
<SharedWorkReportSearch
v-model:model="model"
report-type="project"
:project-options="projectOptions"
@reset="emit('reset')"
@search="emit('search')"
/>
</template>

View File

@@ -0,0 +1,341 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue';
import dayjs from 'dayjs';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import { WORK_REPORT_TYPE_LABEL, type WorkReportType } from '../types';
defineOptions({ name: 'WorkReportActionDialog' });
type ActionType = 'approve' | 'reject';
type ApprovalConclusion = 'approve' | 'reject';
const visible = defineModel<boolean>('visible', { default: false });
const props = withDefaults(
defineProps<{
reportType: WorkReportType;
actionType: ActionType;
initialMonthlyApproveData?: Partial<Api.WorkReport.Monthly.MonthlyReportApproveParams> | null;
loading?: boolean;
}>(),
{
initialMonthlyApproveData: null,
loading: false
}
);
const emit = defineEmits<{
(
e: 'submit',
payload: Api.WorkReport.Common.StatusActionParams | Api.WorkReport.Monthly.MonthlyReportApproveParams,
actionType?: ActionType
): void;
}>();
const reasonModel = reactive<Api.WorkReport.Common.StatusActionParams>({
reason: ''
});
const commonApprovalModel = reactive<{
conclusion: ApprovalConclusion | '';
opinion: string;
}>({
conclusion: 'approve',
opinion: ''
});
const monthlyModel = reactive<Api.WorkReport.Monthly.MonthlyReportApproveParams>({
reason: '',
meetingDate: '',
strengthDesc: '',
strengthExample: '',
weaknessDesc: '',
weaknessExample: '',
improvementSuggestion: '',
performanceResult: '',
employeeSignName: '',
employeeSignedDate: '',
supervisorSignName: '',
supervisorSignedDate: ''
});
const isMonthlyApprove = computed(() => props.reportType === 'monthly' && props.actionType === 'approve');
const isCommonApprove = computed(() => props.reportType !== 'monthly' && props.actionType === 'approve');
const title = computed(() => {
if (isCommonApprove.value) {
return `审批${WORK_REPORT_TYPE_LABEL[props.reportType]}`;
}
const actionLabel = props.actionType === 'approve' ? '审批通过' : '退回';
return `${actionLabel}${WORK_REPORT_TYPE_LABEL[props.reportType]}`;
});
const preset = computed(() => (isMonthlyApprove.value ? 'lg' : 'sm'));
const confirmText = computed(() => {
if (isCommonApprove.value) return '确认提交';
if (props.actionType === 'approve') return '通过';
return '退回';
});
const confirmDisabled = computed(() => isCommonApprove.value && !commonApprovalModel.conclusion);
watch(visible, isVisible => {
if (!isVisible) return;
reasonModel.reason = '';
Object.assign(commonApprovalModel, {
conclusion: 'approve',
opinion: ''
});
Object.assign(monthlyModel, {
reason: '',
meetingDate: dayjs().format('YYYY-MM-DD'),
strengthDesc: '',
strengthExample: '',
weaknessDesc: '',
weaknessExample: '',
improvementSuggestion: '',
performanceResult: '',
employeeSignName: '',
employeeSignedDate: dayjs().format('YYYY-MM-DD'),
supervisorSignName: '',
supervisorSignedDate: dayjs().format('YYYY-MM-DD')
});
if (props.initialMonthlyApproveData) {
Object.assign(monthlyModel, props.initialMonthlyApproveData);
}
});
function handleSubmit() {
if (isCommonApprove.value) {
if (!commonApprovalModel.conclusion) {
window.$message?.warning('请选择审批结论');
return;
}
emit(
'submit',
{
reason: commonApprovalModel.opinion || (commonApprovalModel.conclusion === 'approve' ? '通过' : '不通过')
},
commonApprovalModel.conclusion
);
return;
}
emit('submit', isMonthlyApprove.value ? { ...monthlyModel } : { ...reasonModel });
}
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="title"
:preset="preset"
:confirm-loading="loading"
:confirm-disabled="confirmDisabled"
:confirm-text="confirmText"
max-body-height="76vh"
@confirm="handleSubmit"
>
<template v-if="isCommonApprove">
<div class="audit-form">
<div class="audit-field">
<label>审批结论</label>
<div class="audit-conclusion">
<button
type="button"
class="conclusion-btn"
:class="{
active: commonApprovalModel.conclusion === 'approve',
pass: commonApprovalModel.conclusion === 'approve'
}"
@click="commonApprovalModel.conclusion = 'approve'"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5" />
<path
d="M5 8.5L7 10.5L11 6"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
通过
</button>
<button
type="button"
class="conclusion-btn"
:class="{
active: commonApprovalModel.conclusion === 'reject',
reject: commonApprovalModel.conclusion === 'reject'
}"
@click="commonApprovalModel.conclusion = 'reject'"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5" />
<path d="M6 6L10 10M10 6L6 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
不通过
</button>
</div>
</div>
<div class="audit-field">
<label>审批意见</label>
<ElInput v-model="commonApprovalModel.opinion" type="textarea" :rows="3" placeholder="请输入审批意见" />
</div>
</div>
</template>
<template v-else-if="isMonthlyApprove">
<BusinessFormSection title="当期工作反馈">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="面谈时间">
<ElDatePicker v-model="monthlyModel.meetingDate" class="w-full" type="date" value-format="YYYY-MM-DD" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="绩效考核结果">
<ElInput v-model="monthlyModel.performanceResult" placeholder="请输入绩效结果" />
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="审批意见">
<ElInput v-model="monthlyModel.reason" type="textarea" :rows="3" />
</ElFormItem>
</ElCol>
</ElRow>
</BusinessFormSection>
<BusinessFormSection title="优势与不足">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="优势描述">
<ElInput v-model="monthlyModel.strengthDesc" type="textarea" :rows="3" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="优势行为事例">
<ElInput v-model="monthlyModel.strengthExample" type="textarea" :rows="3" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="劣势描述">
<ElInput v-model="monthlyModel.weaknessDesc" type="textarea" :rows="3" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="劣势行为事例">
<ElInput v-model="monthlyModel.weaknessExample" type="textarea" :rows="3" />
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="改进建议">
<ElInput v-model="monthlyModel.improvementSuggestion" type="textarea" :rows="3" />
</ElFormItem>
</ElCol>
</ElRow>
</BusinessFormSection>
<BusinessFormSection title="签字区">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="被考核人签名">
<ElInput v-model="monthlyModel.employeeSignName" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="被考核人签字日期">
<ElDatePicker
v-model="monthlyModel.employeeSignedDate"
class="w-full"
type="date"
value-format="YYYY-MM-DD"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="上级签名">
<ElInput v-model="monthlyModel.supervisorSignName" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="上级签字日期">
<ElDatePicker
v-model="monthlyModel.supervisorSignedDate"
class="w-full"
type="date"
value-format="YYYY-MM-DD"
/>
</ElFormItem>
</ElCol>
</ElRow>
</BusinessFormSection>
</template>
<ElForm v-else label-position="top">
<ElFormItem :label="actionType === 'approve' ? '审批意见' : '原因'">
<ElInput v-model="reasonModel.reason" type="textarea" :rows="5" placeholder="请输入原因或意见" />
</ElFormItem>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped>
.audit-form {
display: grid;
gap: 18px;
}
.audit-field {
display: grid;
gap: 8px;
}
.audit-field label {
color: #475467;
font-size: 13px;
font-weight: 800;
}
.audit-conclusion {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.conclusion-btn {
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid #d8e0e8;
border-radius: 8px;
background: #fff;
color: #475467;
font: inherit;
font-size: 14px;
font-weight: 800;
cursor: pointer;
transition: all 0.18s ease;
}
.conclusion-btn:hover {
border-color: #0f766e;
color: #0f766e;
}
.conclusion-btn.active.pass {
border-color: #0f766e;
background: #f0fdfa;
color: #0f766e;
}
.conclusion-btn.active.reject {
border-color: #dc2626;
background: #fef2f2;
color: #dc2626;
}
</style>

View File

@@ -0,0 +1,129 @@
<script setup lang="ts">
/* eslint-disable no-void */
import { computed, ref, watch } from 'vue';
import {
fetchGetMonthlyReportApprovalRecords,
fetchGetProjectReportApprovalRecords,
fetchGetWeeklyReportApprovalRecords
} from '@/service/api';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import {
WORK_REPORT_TYPE_LABEL,
type WorkReportRow,
type WorkReportType,
formatDate,
formatDateTime,
getWorkReportStatusLabel
} from '../types';
/** 格式化文本,空值显示 -- */
function formatTextOrDash(value?: string | number | null) {
if (value === null || value === undefined || value === '') {
return '--';
}
return String(value);
}
defineOptions({ name: 'WorkReportApprovalRecordDialog' });
const visible = defineModel<boolean>('visible', { default: false });
const props = defineProps<{
reportType: WorkReportType;
rowData?: WorkReportRow | null;
}>();
const loading = ref(false);
const records = ref<
Array<Api.WorkReport.Common.WorkReportApprovalRecord | Api.WorkReport.Monthly.MonthlyReportApprovalRecord>
>([]);
const title = computed(() => `${WORK_REPORT_TYPE_LABEL[props.reportType]}审批记录`);
const monthlyRecords = computed(() => records.value as Api.WorkReport.Monthly.MonthlyReportApprovalRecord[]);
watch(
[visible, () => props.rowData?.id, () => props.reportType],
([isVisible, currentId]) => {
if (!isVisible) return;
// visible 为 true首次打开、换行、换报告类型时都重新加载记录
if (currentId) {
loadRecords();
}
},
{ immediate: true }
);
async function loadRecords() {
if (!props.rowData?.id) return;
loading.value = true;
let result;
if (props.reportType === 'weekly') {
result = await fetchGetWeeklyReportApprovalRecords(props.rowData.id);
} else if (props.reportType === 'monthly') {
result = await fetchGetMonthlyReportApprovalRecords(props.rowData.id);
} else {
result = await fetchGetProjectReportApprovalRecords(props.rowData.id);
}
loading.value = false;
records.value = !result.error && result.data ? result.data : [];
}
</script>
<template>
<BusinessFormDialog v-model="visible" :title="title" preset="lg" :loading="loading" :show-footer="false">
<ElTable v-if="reportType !== 'monthly'" border :data="records">
<ElTableColumn prop="approvalRound" label="轮次" width="80" />
<ElTableColumn label="结论" width="100">
<template #default="{ row }">
{{ getWorkReportStatusLabel(row.conclusion) }}
</template>
</ElTableColumn>
<ElTableColumn prop="opinion" label="审批意见" min-width="220" show-overflow-tooltip />
<ElTableColumn prop="auditorName" label="审批人" width="120" />
<ElTableColumn label="审批时间" width="170">
<template #default="{ row }">
{{ formatDateTime(row.createTime) }}
</template>
</ElTableColumn>
</ElTable>
<div v-else class="work-report-approval-records">
<ElCard v-for="item in monthlyRecords" :key="item.id">
<template #header>
<div class="flex items-center justify-between gap-12px">
<span> {{ item.approvalRound }} · {{ getWorkReportStatusLabel(item.conclusion) }}</span>
<span class="text-12px text-#64748b">{{ item.auditorName }} · {{ formatDateTime(item.createTime) }}</span>
</div>
</template>
<ElDescriptions :column="2" border size="small">
<ElDescriptionsItem label="审批意见" :span="2">{{ formatTextOrDash(item.opinion) }}</ElDescriptionsItem>
<ElDescriptionsItem label="面谈时间">{{ formatDate(item.meetingDate) }}</ElDescriptionsItem>
<ElDescriptionsItem label="绩效结果">{{ formatTextOrDash(item.performanceResult) }}</ElDescriptionsItem>
<ElDescriptionsItem label="优势描述">{{ formatTextOrDash(item.strengthDesc) }}</ElDescriptionsItem>
<ElDescriptionsItem label="优势事例">{{ formatTextOrDash(item.strengthExample) }}</ElDescriptionsItem>
<ElDescriptionsItem label="劣势描述">{{ formatTextOrDash(item.weaknessDesc) }}</ElDescriptionsItem>
<ElDescriptionsItem label="劣势事例">{{ formatTextOrDash(item.weaknessExample) }}</ElDescriptionsItem>
<ElDescriptionsItem label="改进建议" :span="2">
{{ formatTextOrDash(item.improvementSuggestion) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="被考核人签字">
{{ formatTextOrDash(item.employeeSignName) }} / {{ formatDate(item.employeeSignedDate) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="上级签字">
{{ formatTextOrDash(item.supervisorSignName) }} / {{ formatDate(item.supervisorSignedDate) }}
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
</div>
</BusinessFormDialog>
</template>
<style scoped>
.work-report-approval-records {
display: flex;
flex-direction: column;
gap: 12px;
}
</style>

View File

@@ -0,0 +1,534 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Calendar } from '@element-plus/icons-vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import {
type WorkReportPeriodOption,
buildMonthlyPeriodFromMonth,
buildProjectPeriodFromMonth,
buildWeeklyPeriodFromDate,
formatPeriodDisplayLabel,
getReportTypePeriodOptions
} from '../utils';
import { WORK_REPORT_TYPE_LABEL, type WorkReportType } from '../types';
defineOptions({ name: 'WorkReportCreateDialog' });
interface Props {
defaultReportType?: WorkReportType;
projectVisible?: boolean;
projectOptions?: Api.WorkReport.Project.ProjectReportOwnerProjectOption[];
}
const props = withDefaults(defineProps<Props>(), {
defaultReportType: 'weekly',
projectVisible: false,
projectOptions: () => []
});
const visible = defineModel<boolean>('visible', { default: false });
const emit = defineEmits<{
(
e: 'confirm',
payload:
| { reportType: 'weekly' | 'monthly'; period: WorkReportPeriodOption['period'] }
| {
reportType: 'project';
projectId: string;
flag: number;
period: WorkReportPeriodOption['period'];
}
): void;
}>();
const selectedPeriodKey = ref('');
const selectedProjectId = ref('');
const customWeekDate = ref('');
const customMonth = ref('');
const customProjectMonth = ref('');
const customProjectFlag = ref(1);
const selectedReportType = computed<WorkReportType>(() => {
if (props.defaultReportType === 'project' && !props.projectVisible) return 'weekly';
return props.defaultReportType;
});
const periodOptionMap = computed(() => getReportTypePeriodOptions());
const activePeriodOptions = computed(() => periodOptionMap.value[selectedReportType.value]);
const dialogTitle = computed(() => `新增${WORK_REPORT_TYPE_LABEL[selectedReportType.value]}`);
const projectHalfOptions = [
{ label: '上半月', value: 1 },
{ label: '下半月', value: 2 }
];
const defaultCustomMonth = computed(() => {
const period = activePeriodOptions.value[0]?.period;
return period?.periodStartDate.slice(0, 7) || '';
});
const customPeriod = computed<WorkReportPeriodOption['period'] | null>(() => {
if (selectedPeriodKey.value !== 'custom') return null;
if (selectedReportType.value === 'weekly') {
if (!customWeekDate.value) return null;
return buildWeeklyPeriodFromDate(customWeekDate.value);
}
if (selectedReportType.value === 'monthly') {
if (!customMonth.value) return null;
return buildMonthlyPeriodFromMonth(customMonth.value);
}
if (!customProjectMonth.value) return null;
return buildProjectPeriodFromMonth(customProjectMonth.value, customProjectFlag.value);
});
const selectedPeriod = computed(
() => activePeriodOptions.value.find(item => item.key === selectedPeriodKey.value) ?? activePeriodOptions.value[0]
);
const selectedPeriodValue = computed(() =>
selectedPeriodKey.value === 'custom' ? customPeriod.value : selectedPeriod.value?.period
);
const customPeriodPreviewLabel = computed(() =>
customPeriod.value ? formatPeriodDisplayLabel(customPeriod.value.periodLabel) : ''
);
const confirmDisabled = computed(() => {
if (!selectedPeriodValue.value) return true;
if (selectedReportType.value === 'project' && !selectedProjectId.value) return true;
return false;
});
watch(
selectedReportType,
type => {
selectedPeriodKey.value = periodOptionMap.value[type][0]?.key || '';
if (type === 'project' && !selectedProjectId.value) {
selectedProjectId.value = props.projectOptions[0]?.id || '';
}
},
{ immediate: true }
);
watch(visible, isVisible => {
if (!isVisible) return;
selectedProjectId.value = props.projectOptions[0]?.id || '';
selectedPeriodKey.value = periodOptionMap.value[selectedReportType.value][0]?.key || '';
customWeekDate.value = activePeriodOptions.value[0]?.period.periodStartDate || '';
customMonth.value = defaultCustomMonth.value;
customProjectMonth.value = defaultCustomMonth.value;
customProjectFlag.value = activePeriodOptions.value[0]?.flag || 1;
});
function handleConfirm() {
const period = selectedPeriodValue.value;
if (!period) return;
if (selectedReportType.value === 'project') {
emit('confirm', {
reportType: 'project',
projectId: selectedProjectId.value,
flag: selectedPeriodKey.value === 'custom' ? customProjectFlag.value : selectedPeriod.value.flag || 1,
period
});
} else {
emit('confirm', {
reportType: selectedReportType.value,
period
});
}
visible.value = false;
}
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="dialogTitle"
class="work-report-create-dialog"
preset="md"
confirm-text="确认新增"
append-to-body
:close-on-click-modal="false"
@confirm="handleConfirm"
>
<div v-if="selectedReportType === 'project'" class="work-report-create-dialog__project-select">
<label class="work-report-create-dialog__label">项目</label>
<ElSelect v-model="selectedProjectId" class="w-full" placeholder="请选择项目" filterable>
<ElOption
v-for="item in props.projectOptions"
:key="item.id"
:label="item.projectCode ? `${item.projectName}${item.projectCode}` : item.projectName"
:value="item.id"
/>
</ElSelect>
</div>
<div class="work-report-create-dialog__section">
<div class="work-report-create-dialog__grid is-period">
<button
v-for="item in activePeriodOptions"
:key="item.key"
type="button"
class="work-report-create-dialog__choice"
:class="{ 'is-active': selectedPeriodKey === item.key }"
@click="selectedPeriodKey = item.key"
>
<div class="work-report-create-dialog__choice-title">{{ item.label }}</div>
<div class="work-report-create-dialog__choice-desc">{{ item.description }}</div>
</button>
<button
type="button"
class="work-report-create-dialog__choice"
:class="{ 'is-active': selectedPeriodKey === 'custom' }"
@click="selectedPeriodKey = 'custom'"
>
<div class="work-report-create-dialog__choice-title">自定义周期</div>
<div class="work-report-create-dialog__choice-desc">
{{
selectedReportType === 'weekly'
? '选择某一周作为周报周期。'
: selectedReportType === 'monthly'
? '选择某一月作为月报周期。'
: '选择某个月的上半月或下半月。'
}}
</div>
</button>
</div>
<div v-if="selectedPeriodKey === 'custom'" class="work-report-create-dialog__custom-period">
<div v-if="selectedReportType === 'weekly'" class="work-report-create-dialog__custom-row">
<div class="work-report-create-dialog__field work-report-create-dialog__field--inline">
<label class="work-report-create-dialog__label">周报周期</label>
<ElDatePicker
v-model="customWeekDate"
type="date"
format="YYYY[年第]ww[周]"
value-format="YYYY-MM-DD"
popper-class="work-report-create-date-popper"
placeholder="请选择周报周期"
/>
<div v-if="customPeriodPreviewLabel" class="work-report-create-dialog__period-preview">
{{ customPeriodPreviewLabel }}
</div>
</div>
</div>
<div v-else-if="selectedReportType === 'monthly'" class="work-report-create-dialog__custom-row">
<div class="work-report-create-dialog__field work-report-create-dialog__field--inline">
<label class="work-report-create-dialog__label">月报周期</label>
<ElDatePicker
v-model="customMonth"
type="month"
value-format="YYYY-MM"
popper-class="work-report-create-date-popper"
placeholder="请选择月份"
/>
<div v-if="customPeriodPreviewLabel" class="work-report-create-dialog__period-preview">
{{ customPeriodPreviewLabel }}
</div>
</div>
</div>
<div v-else class="work-report-create-dialog__custom-project">
<div class="work-report-create-dialog__custom-project-grid">
<div class="work-report-create-dialog__custom-project-item">
<div class="work-report-create-dialog__custom-project-item-label">选择月份</div>
<ElDatePicker
v-model="customProjectMonth"
class="w-full"
type="month"
value-format="YYYY-MM"
popper-class="work-report-create-date-popper"
placeholder="请选择月份"
/>
</div>
<div class="work-report-create-dialog__custom-project-item">
<div class="work-report-create-dialog__custom-project-item-label">选择半月</div>
<ElSegmented
v-model="customProjectFlag"
:options="projectHalfOptions"
class="work-report-create-dialog__half-segmented"
/>
</div>
</div>
<div v-if="customPeriodPreviewLabel" class="work-report-create-dialog__period-preview">
<ElIcon class="work-report-create-dialog__period-preview-icon"><Calendar /></ElIcon>
<span class="work-report-create-dialog__period-preview-text">已选周期</span>
<span class="work-report-create-dialog__period-preview-value">{{ customPeriodPreviewLabel }}</span>
</div>
</div>
</div>
</div>
<template #footer="{ close }">
<div class="work-report-create-dialog__footer">
<ElButton @click="close">取消</ElButton>
<ElButton type="primary" :disabled="confirmDisabled" @click="handleConfirm">确认新增</ElButton>
</div>
</template>
</BusinessFormDialog>
</template>
<style scoped>
.work-report-create-dialog__header {
padding: 0 0 14px;
}
.work-report-create-dialog__title {
margin: 0;
font-size: 18px;
font-weight: 900;
}
.work-report-create-dialog__subtitle {
margin-top: 5px;
color: #667085;
font-size: 12px;
}
.work-report-create-dialog__section + .work-report-create-dialog__section {
margin-top: 18px;
}
.work-report-create-dialog__grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.work-report-create-dialog__grid.is-period {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.work-report-create-dialog__choice {
padding: 16px;
border: 2px solid #e5edf1;
border-radius: 16px;
background: #fbfdfe;
text-align: left;
cursor: pointer;
transition:
border-color 0.16s ease,
background 0.16s ease,
box-shadow 0.16s ease;
}
.work-report-create-dialog__choice:hover {
border-color: rgba(15, 118, 110, 0.28);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
}
.work-report-create-dialog__choice.is-active {
border-color: #0f766e;
background: #ecfdf5;
}
.work-report-create-dialog__choice-title {
font-weight: 900;
color: #14213d;
}
.work-report-create-dialog__choice-desc {
margin-top: 7px;
color: #667085;
font-size: 12px;
line-height: 1.5;
}
.work-report-create-dialog__project-select {
margin: 4px 0 18px;
display: grid;
gap: 6px;
}
.work-report-create-dialog__field {
display: grid;
gap: 6px;
}
/** 行内字段label 和控件在同一行,绿色 label 紧贴日期选择器右边 */
.work-report-create-dialog__field--inline {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.work-report-create-dialog__field--inline .work-report-create-dialog__label {
flex-shrink: 0;
white-space: nowrap;
}
.work-report-create-dialog__field--inline :deep(.el-date-editor) {
width: auto;
min-width: 160px;
max-width: 240px;
}
.work-report-create-dialog__label {
color: #667085;
font-size: 12px;
font-weight: 800;
}
.work-report-create-dialog__custom-period {
margin-top: 14px;
padding: 16px;
border: 1px solid rgba(15, 118, 110, 0.18);
border-radius: 14px;
background: linear-gradient(180deg, #f8fffd 0%, #ffffff 100%);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
}
.work-report-create-dialog__custom-row {
display: flex;
gap: 12px;
align-items: flex-end;
}
.work-report-create-dialog__custom-row > .work-report-create-dialog__field--inline {
flex: 1;
min-width: 0;
}
.work-report-create-dialog__custom-project {
display: grid;
gap: 14px;
}
.work-report-create-dialog__custom-project-grid {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
gap: 14px;
align-items: stretch;
}
.work-report-create-dialog__custom-project-item {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 14px;
border: 1px solid #e5edf1;
border-radius: 10px;
background: #fff;
transition: border-color 0.18s ease;
}
.work-report-create-dialog__custom-project-item:hover {
border-color: rgba(15, 118, 110, 0.4);
}
.work-report-create-dialog__custom-project-item-label {
color: #475467;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.2px;
}
.work-report-create-dialog__custom-project-item :deep(.el-date-editor) {
width: 100%;
}
.work-report-create-dialog__half-segmented {
width: 100%;
display: flex;
}
.work-report-create-dialog__half-segmented :deep(.el-segmented__group) {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
width: 100%;
gap: 0;
}
.work-report-create-dialog__half-segmented :deep(.el-segmented__item) {
flex: 1;
min-width: 0;
justify-content: center;
}
.work-report-create-dialog__period-preview {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 32px;
padding: 0 14px;
border: 1px solid rgba(15, 118, 110, 0.18);
border-radius: 999px;
background: #ecfdf5;
color: #0f766e;
font-size: 13px;
font-weight: 700;
white-space: nowrap;
width: fit-content;
}
.work-report-create-dialog__period-preview-icon {
font-size: 14px;
color: #0f766e;
}
.work-report-create-dialog__period-preview-text {
color: #475467;
font-weight: 600;
}
.work-report-create-dialog__period-preview-value {
color: #0f766e;
font-weight: 800;
}
.work-report-create-dialog__footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
@media (width <= 900px) {
.work-report-create-dialog__grid,
.work-report-create-dialog__grid.is-period {
grid-template-columns: 1fr;
}
.work-report-create-dialog__custom-row,
.work-report-create-dialog__custom-project-grid {
flex-direction: column;
grid-template-columns: 1fr;
}
.work-report-create-dialog__field--inline {
flex-wrap: wrap;
}
.work-report-create-dialog__field--inline :deep(.el-date-editor) {
max-width: 100%;
flex: 1;
}
.work-report-create-dialog__period-preview {
justify-content: center;
width: 100%;
}
}
:global(.work-report-create-date-popper) {
border-radius: 12px;
overflow: hidden;
}
:global(.work-report-create-date-popper .el-picker-panel__body-wrapper) {
background: #fff;
}
:global(.work-report-create-date-popper .el-date-table td.current:not(.disabled) .el-date-table-cell__text),
:global(.work-report-create-date-popper .el-month-table td.current:not(.disabled) .cell) {
background-color: #0f766e;
}
</style>

View File

@@ -0,0 +1,173 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { fetchGetMonthlyReportDetail, fetchGetProjectReportDetail, fetchGetWeeklyReportDetail } from '@/service/api';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import {
WORK_REPORT_TYPE_LABEL,
type WorkReportRow,
type WorkReportType,
formatDate,
formatEmptyText,
formatPeriod,
getProjectReportFlagLabel,
getWorkReportStatusLabel
} from '../types';
defineOptions({ name: 'WorkReportDetailDialog' });
const visible = defineModel<boolean>('visible', { default: false });
const props = defineProps<{
reportType: WorkReportType;
rowData?: WorkReportRow | null;
}>();
const loading = ref(false);
const detail = ref<WorkReportRow | null>(null);
const title = computed(() => `${WORK_REPORT_TYPE_LABEL[props.reportType]}详情`);
const weeklyDetail = computed(() =>
props.reportType === 'weekly' ? (detail.value as Api.WorkReport.Weekly.WeeklyReport | null) : null
);
watch(visible, isVisible => {
if (isVisible) loadDetail();
});
async function loadDetail() {
if (!props.rowData?.id) return;
loading.value = true;
let result;
if (props.reportType === 'weekly') {
result = await fetchGetWeeklyReportDetail(props.rowData.id);
} else if (props.reportType === 'monthly') {
result = await fetchGetMonthlyReportDetail(props.rowData.id);
} else {
result = await fetchGetProjectReportDetail(props.rowData.id);
}
loading.value = false;
if (!result.error && result.data) {
detail.value = result.data;
}
}
function getProjectDetail() {
return detail.value as Api.WorkReport.Project.ProjectReport | null;
}
function getPersonalDetail() {
return detail.value as Api.WorkReport.Weekly.WeeklyReport | Api.WorkReport.Monthly.MonthlyReport | null;
}
</script>
<template>
<BusinessFormDialog v-model="visible" :title="title" preset="lg" :loading="loading" :show-footer="false">
<div v-if="detail" class="work-report-detail">
<BusinessFormSection title="基础信息">
<ElDescriptions :column="3" border size="small">
<ElDescriptionsItem label="报告周期">{{ formatPeriod(detail) }}</ElDescriptionsItem>
<ElDescriptionsItem label="状态">
{{ getWorkReportStatusLabel(detail.statusCode, detail.statusName) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="直属上级">{{ detail.supervisorName }}</ElDescriptionsItem>
<ElDescriptionsItem label="开始日期">{{ formatDate(detail.periodStartDate) }}</ElDescriptionsItem>
<ElDescriptionsItem label="结束日期">{{ formatDate(detail.periodEndDate) }}</ElDescriptionsItem>
<ElDescriptionsItem label="总工时">{{ formatEmptyText(detail.totalWorkHours) }}</ElDescriptionsItem>
<ElDescriptionsItem label="提交时间">{{ formatEmptyText(detail.submitTime) }}</ElDescriptionsItem>
<ElDescriptionsItem label="审批时间">{{ formatEmptyText(detail.approvalTime) }}</ElDescriptionsItem>
<ElDescriptionsItem label="审批意见">{{ formatEmptyText(detail.approvalComment) }}</ElDescriptionsItem>
</ElDescriptions>
</BusinessFormSection>
<template v-if="reportType === 'project'">
<BusinessFormSection title="项目信息">
<ElDescriptions :column="2" border size="small">
<ElDescriptionsItem label="项目名称">{{ getProjectDetail()?.projectName }}</ElDescriptionsItem>
<ElDescriptionsItem label="半月周期">
{{ getProjectReportFlagLabel(getProjectDetail()?.flag) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="项目负责人">{{ getProjectDetail()?.projectOwnerName }}</ElDescriptionsItem>
<ElDescriptionsItem label="技术负责人">
{{ formatEmptyText(getProjectDetail()?.technicalOwnerName) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="项目状态" :span="2">
{{ formatEmptyText(getProjectDetail()?.projectStatusDesc) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="整体计划进度" :span="2">
{{ formatEmptyText(getProjectDetail()?.projectProgressPlan) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="要点描述" :span="2">
{{ formatEmptyText(getProjectDetail()?.projectKeyPoints) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="项目问题" :span="2">
{{ formatEmptyText(getProjectDetail()?.projectProblems) }}
</ElDescriptionsItem>
</ElDescriptions>
</BusinessFormSection>
<BusinessFormSection title="本期工作内容">
<ElTable border :data="getProjectDetail()?.currentItems || []">
<ElTableColumn prop="itemTitle" label="工作内容" min-width="180" />
<ElTableColumn prop="workHours" label="工时" width="100" />
<ElTableColumn prop="priorityCode" label="优先级" width="120" />
<ElTableColumn prop="progressRate" label="进度" width="100" />
</ElTable>
</BusinessFormSection>
<BusinessFormSection title="下期计划工作内容">
<ElTable border :data="getProjectDetail()?.nextItems || []">
<ElTableColumn prop="itemTitle" label="工作内容" min-width="180" />
<ElTableColumn prop="workHours" label="工时" width="100" />
<ElTableColumn prop="priorityCode" label="优先级" width="120" />
<ElTableColumn prop="progressRate" label="进度" width="100" />
</ElTable>
</BusinessFormSection>
</template>
<template v-else>
<BusinessFormSection title="当期重点工作回顾">
<ElTable border :data="getPersonalDetail()?.reviewItems || []">
<ElTableColumn prop="itemTitle" label="事项" min-width="160" />
<ElTableColumn prop="workHours" label="工时" width="100" />
<ElTableColumn prop="contentText" label="工作内容" min-width="220" show-overflow-tooltip />
<ElTableColumn prop="reflectionText" label="复盘反思" min-width="220" show-overflow-tooltip />
</ElTable>
</BusinessFormSection>
<BusinessFormSection title="下周期重点工作计划">
<ElTable border :data="getPersonalDetail()?.planItems || []">
<ElTableColumn prop="itemTitle" label="事项" min-width="160" />
<ElTableColumn prop="targetText" label="目标" min-width="220" show-overflow-tooltip />
<ElTableColumn prop="supportNeed" label="支持需求" min-width="220" show-overflow-tooltip />
</ElTable>
</BusinessFormSection>
<BusinessFormSection v-if="reportType === 'weekly'" title="出差信息">
<ElDescriptions :column="3" border size="small">
<ElDescriptionsItem label="是否出差">
{{ weeklyDetail?.isBusinessTrip ? '是' : '否' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="出差天数">
{{ formatEmptyText(weeklyDetail?.totalTravelDays) }}
</ElDescriptionsItem>
</ElDescriptions>
<ElTable class="mt-12px" border :data="weeklyDetail?.travelSegments || []">
<ElTableColumn prop="startDate" label="开始日期" width="120" />
<ElTableColumn prop="endDate" label="结束日期" width="120" />
<ElTableColumn prop="travelDays" label="天数" width="100" />
<ElTableColumn prop="location" label="地点" min-width="160" />
</ElTable>
</BusinessFormSection>
</template>
</div>
</BusinessFormDialog>
</template>
<style scoped>
.work-report-detail {
min-width: 0;
}
</style>

View File

@@ -0,0 +1,564 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessageBox } from 'element-plus';
import {
fetchCreateMonthlyReport,
fetchCreateProjectReport,
fetchCreateWeeklyReport,
fetchGetMonthlyReportDetail,
fetchGetProjectReportDetail,
fetchGetWeeklyReportDetail,
fetchInitMonthlyReport,
fetchInitProjectReport,
fetchInitWeeklyReport,
fetchPreviewMonthlyReportDefaultDraft,
fetchPreviewProjectReportDefaultDraft,
fetchPreviewWeeklyReportDefaultDraft,
fetchUpdateMonthlyReport,
fetchUpdateProjectReport,
fetchUpdateWeeklyReport
} from '@/service/api';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import {
WORK_REPORT_TYPE_LABEL,
type WorkReportRow,
type WorkReportType,
createBlankPlanItem,
createBlankProjectItem,
createBlankReviewItem,
createMonthlySaveParams,
createProjectSaveParams,
createWeeklySaveParams,
normalizePlanItems,
normalizeProjectItems,
normalizeReviewItems
} from '../types';
defineOptions({ name: 'WorkReportOperateDialog' });
interface PeriodPayload {
periodKey: string;
periodLabel: string;
periodStartDate: string;
periodEndDate: string;
}
interface Props {
operateType: 'add' | 'edit';
reportType: WorkReportType;
rowData?: WorkReportRow | null;
initialPeriod?: PeriodPayload | null;
initialProjectId?: string;
initialFlag?: number;
}
const props = withDefaults(defineProps<Props>(), {
rowData: null,
initialPeriod: null,
initialProjectId: '',
initialFlag: 1
});
const visible = defineModel<boolean>('visible', { default: false });
const emit = defineEmits<{
(e: 'submitted'): void;
}>();
const loading = ref(false);
const submitting = ref(false);
const baseInfo = ref<WorkReportRow | null>(null);
const weeklyModel = reactive<Api.WorkReport.Weekly.WeeklyReportSaveParams>(createWeeklySaveParams());
const monthlyModel = reactive<Api.WorkReport.Monthly.MonthlyReportSaveParams>(createMonthlySaveParams());
const projectModel = reactive<Api.WorkReport.Project.ProjectReportSaveParams>(createProjectSaveParams());
const title = computed(
() => `${props.operateType === 'add' ? '新增' : '编辑'}${WORK_REPORT_TYPE_LABEL[props.reportType]}`
);
const dialogPreset = computed(() => (props.reportType === 'weekly' ? 'md' : 'lg'));
const activeModel = computed(() => {
if (props.reportType === 'monthly') return monthlyModel;
if (props.reportType === 'project') return projectModel;
return weeklyModel;
});
const baseReporterName = computed(() => {
if (!baseInfo.value) return '--';
if ('projectOwnerName' in baseInfo.value) return baseInfo.value.projectOwnerName || '--';
return baseInfo.value.reporterName || '--';
});
const baseDeptName = computed(() => {
if (!baseInfo.value || 'projectOwnerName' in baseInfo.value) return '--';
return baseInfo.value.reporterDeptName || '--';
});
const basePostName = computed(() => {
if (!baseInfo.value || 'projectOwnerName' in baseInfo.value) return '--';
return baseInfo.value.reporterPostName || '--';
});
function patchPeriod(target: {
periodKey: string;
periodLabel: string;
periodStartDate: string;
periodEndDate: string;
}) {
if (!props.initialPeriod) return;
Object.assign(target, props.initialPeriod);
}
function patchWeekly(report?: Partial<Api.WorkReport.Weekly.WeeklyReport>) {
Object.assign(
weeklyModel,
createWeeklySaveParams({
...report,
reviewItems: report?.reviewItems,
planItems: report?.planItems,
travelSegments: report?.travelSegments
})
);
patchPeriod(weeklyModel);
}
function patchMonthly(report?: Partial<Api.WorkReport.Monthly.MonthlyReport>) {
Object.assign(
monthlyModel,
createMonthlySaveParams({
...report,
reviewItems: report?.reviewItems,
planItems: report?.planItems
})
);
patchPeriod(monthlyModel);
}
function patchProject(report?: Partial<Api.WorkReport.Project.ProjectReport>) {
Object.assign(
projectModel,
createProjectSaveParams({
...report,
projectId: report?.projectId || props.initialProjectId,
flag: report?.flag ?? props.initialFlag,
currentItems: report?.currentItems,
nextItems: report?.nextItems
})
);
patchPeriod(projectModel);
}
async function loadDetail() {
if (!props.rowData?.id) return;
loading.value = true;
let result;
if (props.reportType === 'weekly') {
result = await fetchGetWeeklyReportDetail(props.rowData.id);
} else if (props.reportType === 'monthly') {
result = await fetchGetMonthlyReportDetail(props.rowData.id);
} else {
result = await fetchGetProjectReportDetail(props.rowData.id);
}
loading.value = false;
if (result.error || !result.data) return;
baseInfo.value = result.data;
if (props.reportType === 'weekly') patchWeekly(result.data as Api.WorkReport.Weekly.WeeklyReport);
if (props.reportType === 'monthly') patchMonthly(result.data as Api.WorkReport.Monthly.MonthlyReport);
if (props.reportType === 'project') patchProject(result.data as Api.WorkReport.Project.ProjectReport);
}
async function loadInitAndDraft() {
loading.value = true;
let initResult;
if (props.reportType === 'weekly') {
initResult = await fetchInitWeeklyReport();
} else if (props.reportType === 'monthly') {
initResult = await fetchInitMonthlyReport();
} else {
initResult = await fetchInitProjectReport(props.initialProjectId);
}
if (!initResult.error && initResult.data) {
baseInfo.value = initResult.data;
if (props.reportType === 'weekly') patchWeekly(initResult.data as Api.WorkReport.Weekly.WeeklyReport);
if (props.reportType === 'monthly') patchMonthly(initResult.data as Api.WorkReport.Monthly.MonthlyReport);
if (props.reportType === 'project') patchProject(initResult.data as Api.WorkReport.Project.ProjectReport);
}
await pullDefaultDraft(false);
loading.value = false;
}
async function pullDefaultDraft(confirmOverwrite = true) {
if (confirmOverwrite) {
try {
await ElMessageBox.confirm('重新拉取默认稿会覆盖当前已编辑内容,是否继续?', '覆盖确认', {
type: 'warning',
confirmButtonText: '继续',
cancelButtonText: '取消'
});
} catch {
return;
}
}
const period = {
periodKey: activeModel.value.periodKey,
periodLabel: activeModel.value.periodLabel,
periodStartDate: activeModel.value.periodStartDate,
periodEndDate: activeModel.value.periodEndDate
};
let result;
if (props.reportType === 'weekly') {
result = await fetchPreviewWeeklyReportDefaultDraft(period);
} else if (props.reportType === 'monthly') {
result = await fetchPreviewMonthlyReportDefaultDraft(period);
} else {
result = await fetchPreviewProjectReportDefaultDraft(projectModel.projectId, {
...period,
flag: projectModel.flag
});
}
if (result.error || !result.data) return;
if (props.reportType === 'weekly') {
weeklyModel.reviewItems = normalizeReviewItems((result.data as Api.WorkReport.Weekly.WeeklyReport).reviewItems);
weeklyModel.planItems = normalizePlanItems((result.data as Api.WorkReport.Weekly.WeeklyReport).planItems);
}
if (props.reportType === 'monthly') {
monthlyModel.reviewItems = normalizeReviewItems((result.data as Api.WorkReport.Monthly.MonthlyReport).reviewItems);
monthlyModel.planItems = normalizePlanItems((result.data as Api.WorkReport.Monthly.MonthlyReport).planItems);
}
if (props.reportType === 'project') {
projectModel.currentItems = normalizeProjectItems(
(result.data as Api.WorkReport.Project.ProjectReport).currentItems
);
projectModel.nextItems = normalizeProjectItems((result.data as Api.WorkReport.Project.ProjectReport).nextItems);
}
}
watch(visible, isVisible => {
if (!isVisible) return;
baseInfo.value = null;
if (props.operateType === 'edit') {
loadDetail();
} else {
loadInitAndDraft();
}
});
function addReviewItem(items: Api.WorkReport.Common.PersonalReportReviewItem[]) {
items.push(createBlankReviewItem(items.length));
}
function addPlanItem(items: Api.WorkReport.Common.PersonalReportPlanItem[]) {
items.push(createBlankPlanItem(items.length));
}
function addProjectItem(items: Api.WorkReport.Project.ProjectReportItem[]) {
items.push(createBlankProjectItem());
}
function removeItem<T>(items: T[], index: number) {
if (items.length <= 1) return;
items.splice(index, 1);
}
function validateBase() {
if (!activeModel.value.periodKey || !activeModel.value.periodStartDate || !activeModel.value.periodEndDate) {
window.$message?.warning('请先选择报告周期');
return false;
}
if (props.reportType === 'project' && !projectModel.projectId) {
window.$message?.warning('请选择项目');
return false;
}
return true;
}
async function handleSubmit() {
if (!validateBase()) return;
submitting.value = true;
let result;
if (props.reportType === 'weekly') {
result =
props.operateType === 'add'
? await fetchCreateWeeklyReport(weeklyModel)
: await fetchUpdateWeeklyReport(props.rowData!.id, weeklyModel);
} else if (props.reportType === 'monthly') {
result =
props.operateType === 'add'
? await fetchCreateMonthlyReport(monthlyModel)
: await fetchUpdateMonthlyReport(props.rowData!.id, monthlyModel);
} else {
result =
props.operateType === 'add'
? await fetchCreateProjectReport(projectModel)
: await fetchUpdateProjectReport(props.rowData!.id, projectModel);
}
submitting.value = false;
if (result.error) return;
window.$message?.success(props.operateType === 'add' ? '工作报告已创建' : '工作报告已保存');
visible.value = false;
emit('submitted');
}
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="title"
:preset="dialogPreset"
:loading="loading"
:confirm-loading="submitting"
max-body-height="76vh"
@confirm="handleSubmit"
>
<div class="work-report-operate">
<BusinessFormSection title="基础信息">
<ElDescriptions :column="3" border size="small">
<ElDescriptionsItem label="填报人">
{{ baseReporterName }}
</ElDescriptionsItem>
<ElDescriptionsItem label="部门/方向">{{ baseDeptName }}</ElDescriptionsItem>
<ElDescriptionsItem label="岗位">{{ basePostName }}</ElDescriptionsItem>
<ElDescriptionsItem label="直属上级">{{ baseInfo?.supervisorName || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="周期" :span="2">{{ activeModel.periodLabel || '--' }}</ElDescriptionsItem>
</ElDescriptions>
<div class="mt-12px flex justify-end">
<ElButton plain type="primary" @click="pullDefaultDraft(true)">
<template #icon>
<icon-mdi-refresh class="text-icon" />
</template>
重新拉取默认稿
</ElButton>
</div>
</BusinessFormSection>
<template v-if="reportType === 'project'">
<BusinessFormSection title="项目状况">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="项目状态">
<ElInput v-model="projectModel.projectStatusDesc" type="textarea" :rows="3" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="整体计划进度">
<ElInput v-model="projectModel.projectProgressPlan" type="textarea" :rows="3" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="要点描述">
<ElInput v-model="projectModel.projectKeyPoints" type="textarea" :rows="3" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="项目问题">
<ElInput v-model="projectModel.projectProblems" type="textarea" :rows="3" />
</ElFormItem>
</ElCol>
</ElRow>
</BusinessFormSection>
<BusinessFormSection title="本期工作内容">
<div class="work-report-operate__items">
<div v-for="(item, index) in projectModel.currentItems" :key="index" class="work-report-operate__item">
<ElInput v-model="item.itemTitle" placeholder="工作内容" />
<ElInputNumber v-model="item.workHours" :min="0" :precision="1" placeholder="工时" />
<ElInput v-model="item.priorityCode" placeholder="优先级" />
<ElInputNumber v-model="item.progressRate" :min="0" :max="100" :precision="0" placeholder="进度" />
<ElButton link type="danger" @click="removeItem(projectModel.currentItems, index)">删除</ElButton>
</div>
<ElButton plain @click="addProjectItem(projectModel.currentItems)">新增本期工作</ElButton>
</div>
</BusinessFormSection>
<BusinessFormSection title="下期计划工作内容">
<div class="work-report-operate__items">
<div v-for="(item, index) in projectModel.nextItems" :key="index" class="work-report-operate__item">
<ElInput v-model="item.itemTitle" placeholder="工作内容" />
<ElInputNumber v-model="item.workHours" :min="0" :precision="1" placeholder="工时" />
<ElInput v-model="item.priorityCode" placeholder="优先级" />
<ElInputNumber v-model="item.progressRate" :min="0" :max="100" :precision="0" placeholder="进度" />
<ElButton link type="danger" @click="removeItem(projectModel.nextItems, index)">删除</ElButton>
</div>
<ElButton plain @click="addProjectItem(projectModel.nextItems)">新增下期工作</ElButton>
</div>
</BusinessFormSection>
</template>
<template v-else>
<BusinessFormSection title="当期重点工作回顾">
<div class="work-report-operate__cards">
<div
v-for="(item, index) in reportType === 'weekly' ? weeklyModel.reviewItems : monthlyModel.reviewItems"
:key="index"
class="work-report-operate__card"
>
<ElRow :gutter="16">
<ElCol :span="14">
<ElFormItem label="事项标题">
<ElInput v-model="item.itemTitle" />
</ElFormItem>
</ElCol>
<ElCol :span="6">
<ElFormItem label="工时">
<ElInputNumber v-model="item.workHours" class="w-full" :min="0" :precision="1" />
</ElFormItem>
</ElCol>
<ElCol :span="4" class="flex items-end justify-end pb-16px">
<ElButton
link
type="danger"
@click="
removeItem(reportType === 'weekly' ? weeklyModel.reviewItems : monthlyModel.reviewItems, index)
"
>
删除
</ElButton>
</ElCol>
<ElCol :span="12">
<ElFormItem label="工作内容">
<ElInput v-model="item.contentText" type="textarea" :rows="3" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="复盘反思">
<ElInput v-model="item.reflectionText" type="textarea" :rows="3" />
</ElFormItem>
</ElCol>
</ElRow>
</div>
<ElButton
plain
@click="addReviewItem(reportType === 'weekly' ? weeklyModel.reviewItems : monthlyModel.reviewItems)"
>
新增回顾项
</ElButton>
</div>
</BusinessFormSection>
<BusinessFormSection title="下周期重点工作计划">
<div class="work-report-operate__cards">
<div
v-for="(item, index) in reportType === 'weekly' ? weeklyModel.planItems : monthlyModel.planItems"
:key="index"
class="work-report-operate__card"
>
<ElRow :gutter="16">
<ElCol :span="20">
<ElFormItem label="计划标题">
<ElInput v-model="item.itemTitle" />
</ElFormItem>
</ElCol>
<ElCol :span="4" class="flex items-end justify-end pb-16px">
<ElButton
link
type="danger"
@click="removeItem(reportType === 'weekly' ? weeklyModel.planItems : monthlyModel.planItems, index)"
>
删除
</ElButton>
</ElCol>
<ElCol :span="12">
<ElFormItem label="目标">
<ElInput v-model="item.targetText" type="textarea" :rows="3" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="支持需求">
<ElInput v-model="item.supportNeed" type="textarea" :rows="3" />
</ElFormItem>
</ElCol>
</ElRow>
</div>
<ElButton
plain
@click="addPlanItem(reportType === 'weekly' ? weeklyModel.planItems : monthlyModel.planItems)"
>
新增计划项
</ElButton>
</div>
</BusinessFormSection>
<BusinessFormSection v-if="reportType === 'weekly'" title="出差信息">
<ElFormItem label="是否出差">
<ElSwitch v-model="weeklyModel.isBusinessTrip" />
</ElFormItem>
<div v-if="weeklyModel.isBusinessTrip" class="work-report-operate__items">
<div v-for="(item, index) in weeklyModel.travelSegments" :key="index" class="work-report-operate__item">
<ElDatePicker v-model="item.startDate" type="date" value-format="YYYY-MM-DD" placeholder="开始日期" />
<ElDatePicker v-model="item.endDate" type="date" value-format="YYYY-MM-DD" placeholder="结束日期" />
<ElInputNumber v-model="item.travelDays" :min="0" :precision="1" placeholder="天数" />
<ElInput v-model="item.location" placeholder="地点" />
<ElButton link type="danger" @click="removeItem(weeklyModel.travelSegments, index)">删除</ElButton>
</div>
<ElButton
plain
@click="
weeklyModel.travelSegments.push({
sort: weeklyModel.travelSegments.length + 1,
travelDays: 0,
location: ''
})
"
>
新增出差分段
</ElButton>
</div>
</BusinessFormSection>
</template>
</div>
</BusinessFormDialog>
</template>
<style scoped>
.work-report-operate {
min-width: 0;
}
.work-report-operate__cards,
.work-report-operate__items {
display: flex;
flex-direction: column;
gap: 12px;
}
.work-report-operate__card,
.work-report-operate__item {
padding: 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
background-color: var(--el-fill-color-extra-light);
}
.work-report-operate__item {
display: grid;
grid-template-columns: minmax(0, 1fr) 120px 120px 120px auto;
gap: 10px;
align-items: center;
}
@media (width <= 900px) {
.work-report-operate__item {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,123 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
defineOptions({
name: 'WorkReportPageDialog',
inheritAttrs: false
});
interface Props {
title?: string;
loading?: boolean;
showFooter?: boolean;
approvalMode?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
title: '',
loading: false,
showFooter: false,
approvalMode: false
});
const visible = defineModel<boolean>('visible', { default: false });
const route = useRoute();
const viewportWidth = ref(typeof window === 'undefined' ? 1920 : window.innerWidth);
const emit = defineEmits<{
(e: 'close'): void;
}>();
const drawerSize = computed(() => (viewportWidth.value >= 2560 ? '60%' : '75%'));
function handleClose() {
visible.value = false;
}
function syncViewportWidth() {
viewportWidth.value = window.innerWidth;
}
/** 抽屉关闭动画结束后触发 close 事件 */
function onDrawerClosed() {
emit('close');
}
const drawerBodyClass = props.approvalMode
? 'work-report-page-drawer__body work-report-page-drawer__body--approval'
: 'work-report-page-drawer__body';
watch(
() => route.fullPath,
() => {
if (visible.value) {
visible.value = false;
}
}
);
onMounted(() => {
syncViewportWidth();
window.addEventListener('resize', syncViewportWidth);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', syncViewportWidth);
});
</script>
<template>
<ElDrawer
v-model="visible"
class="work-report-page-drawer"
:class="{ 'work-report-page-drawer--approval': props.approvalMode }"
:body-class="drawerBodyClass"
:title="props.title"
:size="drawerSize"
:close-on-click-modal="false"
@closed="onDrawerClosed"
>
<div v-loading="props.loading" class="work-report-page-drawer__content">
<slot />
</div>
<div v-if="props.showFooter" class="work-report-page-drawer__footer">
<slot name="footer" :close="handleClose" />
</div>
</ElDrawer>
</template>
<style scoped>
:global(.work-report-page-drawer__body) {
display: flex;
flex-direction: column;
min-height: 0;
}
:global(.work-report-page-drawer__body--approval) {
padding-bottom: 0;
}
.work-report-page-drawer__content {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.work-report-page-drawer__content :deep(.form-page) {
flex: 1 0 auto;
min-height: 100%;
box-sizing: border-box;
}
.work-report-page-drawer__footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 12px 20px;
border-top: 1px solid var(--el-border-color-lighter);
background: var(--el-bg-color);
}
</style>

View File

@@ -0,0 +1,774 @@
<script setup lang="ts">
/* eslint-disable complexity, no-nested-ternary, no-void, vue/no-deprecated-filter */
import { computed, reactive, ref, watch } from 'vue';
import { ElMessageBox } from 'element-plus';
import dayjs from 'dayjs';
import {
fetchApproveMonthlyReport,
fetchApproveProjectReport,
fetchApproveWeeklyReport,
fetchCreateMonthlyReport,
fetchCreateProjectReport,
fetchCreateWeeklyReport,
fetchGetMonthlyReportDetail,
fetchGetProjectReportDetail,
fetchGetWeeklyReportDetail,
fetchInitMonthlyReport,
fetchInitProjectReport,
fetchInitWeeklyReport,
fetchPreviewMonthlyReportDefaultDraft,
fetchPreviewProjectReportDefaultDraft,
fetchPreviewWeeklyReportDefaultDraft,
fetchRejectMonthlyReport,
fetchRejectProjectReport,
fetchRejectWeeklyReport,
fetchSubmitMonthlyReport,
fetchSubmitProjectReport,
fetchSubmitWeeklyReport,
fetchUpdateMonthlyReport,
fetchUpdateProjectReport,
fetchUpdateWeeklyReport
} from '@/service/api';
import type { WorkReportRow, WorkReportType } from '../types';
import {
createMonthlySaveParams,
createProjectSaveParams,
createWeeklySaveParams,
formatPeriodLabel,
normalizePlanItems,
normalizeProjectItems,
normalizeReviewItems
} from '../types';
import WeeklyReportPage from '../../weekly/modules/fill-page.vue';
import MonthlyReportPage from '../../monthly/modules/fill-page.vue';
import ProjectReportPage from '../../project/modules/fill-page.vue';
import MonthlyReportApprovalPage from '../../monthly/modules/approval-page.vue';
import WorkReportActionDialog from './action-dialog.vue';
import WorkReportPageDialog from './page-dialog.vue';
defineOptions({ name: 'WorkReportPrototypePageDialog' });
interface PeriodPayload {
periodKey: string;
periodLabel: string;
periodStartDate: string;
periodEndDate: string;
}
interface Props {
reportType: WorkReportType;
mode?: 'add' | 'edit' | 'detail';
scene?: 'fill' | 'detail' | 'approval';
rowData?: WorkReportRow | null;
initialPeriod?: PeriodPayload | null;
initialProjectId?: string;
initialFlag?: number;
}
const props = withDefaults(defineProps<Props>(), {
mode: 'detail',
scene: 'detail',
rowData: null,
initialPeriod: null,
initialProjectId: '',
initialFlag: 1
});
const visible = defineModel<boolean>('visible', { default: false });
const emit = defineEmits<{
(e: 'submitted'): void;
}>();
const REPORT_TYPE_TEXT: Record<WorkReportType, string> = {
weekly: '个人周报',
monthly: '个人月报',
project: '项目半月报'
};
const loading = ref(false);
const actionVisible = ref(false);
const actionSubmitting = ref(false);
const currentActionType = ref<'approve' | 'reject'>('approve');
const currentStage = ref<'form' | 'approval'>('form');
const currentReportId = ref('');
const baseInfo = ref<WorkReportRow | null>(null);
const monthlyApprovalDraft = reactive<Api.WorkReport.Monthly.MonthlyReportApproveParams>({
reason: '',
meetingDate: '',
strengthDesc: '',
strengthExample: '',
weaknessDesc: '',
weaknessExample: '',
improvementSuggestion: '',
performanceResult: '',
employeeSignName: '',
employeeSignedDate: '',
supervisorSignName: '',
supervisorSignedDate: ''
});
const weeklyModel = reactive<Api.WorkReport.Weekly.WeeklyReportSaveParams>(createWeeklySaveParams());
const monthlyModel = reactive<Api.WorkReport.Monthly.MonthlyReportSaveParams>(createMonthlySaveParams());
const projectModel = reactive<Api.WorkReport.Project.ProjectReportSaveParams>(createProjectSaveParams());
const currentModel = computed(() => {
if (props.reportType === 'monthly') return monthlyModel;
if (props.reportType === 'project') return projectModel;
return weeklyModel;
});
const currentScene = computed(() => {
if (props.reportType === 'monthly' && currentStage.value === 'approval') {
return 'approval';
}
return props.scene;
});
const dialogTitle = computed(() => {
if (props.reportType === 'monthly' && currentStage.value === 'approval') {
return `${REPORT_TYPE_TEXT.monthly}审批页`;
}
if (currentScene.value === 'approval') {
return `${REPORT_TYPE_TEXT[props.reportType]}审批`;
}
if (props.mode === 'add') return `${REPORT_TYPE_TEXT[props.reportType]}填报页`;
if (props.mode === 'edit') return `${REPORT_TYPE_TEXT[props.reportType]}编辑页`;
return `${REPORT_TYPE_TEXT[props.reportType]}查看页`;
});
const periodText = computed(() => {
const label = currentModel.value.periodLabel || props.rowData?.periodLabel || props.initialPeriod?.periodLabel;
return formatPeriodLabel(label) || '当前周期';
});
function resetModels() {
Object.assign(weeklyModel, createWeeklySaveParams());
Object.assign(monthlyModel, createMonthlySaveParams());
Object.assign(projectModel, createProjectSaveParams());
Object.assign(monthlyApprovalDraft, {
reason: '',
meetingDate: '',
strengthDesc: '',
strengthExample: '',
weaknessDesc: '',
weaknessExample: '',
improvementSuggestion: '',
performanceResult: '',
employeeSignName: '',
employeeSignedDate: '',
supervisorSignName: '',
supervisorSignedDate: ''
});
}
function patchMonthlyApprovalDefaults(report?: Partial<Api.WorkReport.Monthly.MonthlyReport> | null) {
const today = dayjs().format('YYYY-MM-DD');
Object.assign(monthlyApprovalDraft, {
employeeSignName: monthlyApprovalDraft.employeeSignName || report?.reporterName || '',
employeeSignedDate: monthlyApprovalDraft.employeeSignedDate || today,
supervisorSignName: monthlyApprovalDraft.supervisorSignName || report?.supervisorName || '',
supervisorSignedDate: monthlyApprovalDraft.supervisorSignedDate || today
});
}
function patchPeriod(target: {
periodKey: string;
periodLabel: string;
periodStartDate: string;
periodEndDate: string;
}) {
if (!props.initialPeriod) return;
target.periodKey = props.initialPeriod.periodKey;
target.periodLabel = props.initialPeriod.periodLabel;
target.periodStartDate = props.initialPeriod.periodStartDate;
target.periodEndDate = props.initialPeriod.periodEndDate;
}
function patchWeekly(report?: Partial<Api.WorkReport.Weekly.WeeklyReport>) {
Object.assign(
weeklyModel,
createWeeklySaveParams({
...report,
reviewItems: report?.reviewItems,
planItems: report?.planItems,
travelSegments: report?.travelSegments
})
);
if (report && Array.isArray(report.reviewItems) && !report.reviewItems.length) weeklyModel.reviewItems = [];
if (report && Array.isArray(report.planItems) && !report.planItems.length) weeklyModel.planItems = [];
if (report && Array.isArray(report.travelSegments) && !report.travelSegments.length) weeklyModel.travelSegments = [];
if (props.mode === 'add') patchPeriod(weeklyModel);
}
function patchMonthly(report?: Partial<Api.WorkReport.Monthly.MonthlyReport>) {
Object.assign(
monthlyModel,
createMonthlySaveParams({
...report,
reviewItems: report?.reviewItems,
planItems: report?.planItems
})
);
if (report && Array.isArray(report.reviewItems) && !report.reviewItems.length) monthlyModel.reviewItems = [];
if (report && Array.isArray(report.planItems) && !report.planItems.length) monthlyModel.planItems = [];
if (props.mode === 'add') patchPeriod(monthlyModel);
patchMonthlyApprovalDefaults(report);
}
function patchProject(report?: Partial<Api.WorkReport.Project.ProjectReport>) {
Object.assign(
projectModel,
createProjectSaveParams({
...report,
projectId: report?.projectId || props.initialProjectId,
flag: report?.flag ?? props.initialFlag,
currentItems: report?.currentItems,
nextItems: report?.nextItems
})
);
if (report && Array.isArray(report.currentItems) && !report.currentItems.length) projectModel.currentItems = [];
if (report && Array.isArray(report.nextItems) && !report.nextItems.length) projectModel.nextItems = [];
if (props.mode === 'add') patchPeriod(projectModel);
}
function firstMeaningfulValue<T>(...values: Array<T | null | undefined | ''>) {
return values.find(value => value !== null && value !== undefined && value !== '') as T | undefined;
}
function firstPositiveWorkHours(...values: Array<string | number | null | undefined>) {
const matchedValue = values.find(value => {
const numberValue = Number(value ?? 0);
return Number.isFinite(numberValue) && numberValue > 0;
});
return matchedValue ?? firstMeaningfulValue(...values);
}
function mergePersonalDetailBaseInfo<
T extends Api.WorkReport.Weekly.WeeklyReport | Api.WorkReport.Monthly.MonthlyReport
>(detail: T) {
const rowData = props.rowData as Partial<T> | null;
if (!rowData) return detail;
return {
...rowData,
...detail,
reporterDeptName: firstMeaningfulValue(detail.reporterDeptName, rowData.reporterDeptName) ?? null,
reporterPostName: firstMeaningfulValue(detail.reporterPostName, rowData.reporterPostName) ?? null,
submitTime: firstMeaningfulValue(detail.submitTime, rowData.submitTime) ?? null,
totalWorkHours: firstPositiveWorkHours(detail.totalWorkHours, rowData.totalWorkHours)
} as T;
}
function mergeProjectDetailBaseInfo(detail: Api.WorkReport.Project.ProjectReport) {
const rowData = props.rowData as Partial<Api.WorkReport.Project.ProjectReport> | null;
if (!rowData) return detail;
return {
...rowData,
...detail,
submitTime: firstMeaningfulValue(detail.submitTime, rowData.submitTime) ?? null,
totalWorkHours: firstPositiveWorkHours(detail.totalWorkHours, rowData.totalWorkHours)
};
}
async function loadDetail(id: string) {
loading.value = true;
let result;
if (props.reportType === 'weekly') {
result = await fetchGetWeeklyReportDetail(id);
} else if (props.reportType === 'monthly') {
result = await fetchGetMonthlyReportDetail(id);
} else {
result = await fetchGetProjectReportDetail(id);
}
loading.value = false;
if (result.error || !result.data) return;
const detail =
props.reportType === 'weekly'
? mergePersonalDetailBaseInfo(result.data as Api.WorkReport.Weekly.WeeklyReport)
: props.reportType === 'monthly'
? mergePersonalDetailBaseInfo(result.data as Api.WorkReport.Monthly.MonthlyReport)
: mergeProjectDetailBaseInfo(result.data as Api.WorkReport.Project.ProjectReport);
currentReportId.value = detail.id;
baseInfo.value = detail;
if (props.reportType === 'weekly') patchWeekly(detail as Api.WorkReport.Weekly.WeeklyReport);
if (props.reportType === 'monthly') patchMonthly(detail as Api.WorkReport.Monthly.MonthlyReport);
if (props.reportType === 'project') patchProject(detail as Api.WorkReport.Project.ProjectReport);
}
async function pullDefaultDraft(confirmOverwrite = false) {
const period = {
periodKey: currentModel.value.periodKey,
periodLabel: currentModel.value.periodLabel,
periodStartDate: currentModel.value.periodStartDate,
periodEndDate: currentModel.value.periodEndDate
};
loading.value = true;
let result;
if (props.reportType === 'weekly') {
result = await fetchPreviewWeeklyReportDefaultDraft(period);
} else if (props.reportType === 'monthly') {
result = await fetchPreviewMonthlyReportDefaultDraft(period);
} else {
result = await fetchPreviewProjectReportDefaultDraft(projectModel.projectId, {
...period,
flag: projectModel.flag
});
}
loading.value = false;
if (result.error || !result.data) return;
baseInfo.value = {
...(baseInfo.value || {}),
...result.data
} as WorkReportRow;
if (props.reportType === 'weekly') {
const data = result.data as Api.WorkReport.Weekly.WeeklyReport;
weeklyModel.reviewItems = data.reviewItems?.length ? normalizeReviewItems(data.reviewItems) : [];
weeklyModel.planItems = data.planItems?.length ? normalizePlanItems(data.planItems) : [];
if (confirmOverwrite) {
weeklyModel.travelSegments = data.travelSegments || [];
}
}
if (props.reportType === 'monthly') {
const data = result.data as Api.WorkReport.Monthly.MonthlyReport;
monthlyModel.reviewItems = data.reviewItems?.length ? normalizeReviewItems(data.reviewItems) : [];
monthlyModel.planItems = data.planItems?.length ? normalizePlanItems(data.planItems) : [];
}
if (props.reportType === 'project') {
const data = result.data as Api.WorkReport.Project.ProjectReport;
projectModel.currentItems = data.currentItems?.length ? normalizeProjectItems(data.currentItems) : [];
projectModel.nextItems = data.nextItems?.length ? normalizeProjectItems(data.nextItems) : [];
}
}
async function loadInitData() {
loading.value = true;
let result;
if (props.reportType === 'weekly') {
result = await fetchInitWeeklyReport();
} else if (props.reportType === 'monthly') {
result = await fetchInitMonthlyReport();
} else {
result = await fetchInitProjectReport(props.initialProjectId);
}
if (!result.error && result.data) {
baseInfo.value = result.data;
if (props.reportType === 'weekly') patchWeekly(result.data as Api.WorkReport.Weekly.WeeklyReport);
if (props.reportType === 'monthly') patchMonthly(result.data as Api.WorkReport.Monthly.MonthlyReport);
if (props.reportType === 'project') patchProject(result.data as Api.WorkReport.Project.ProjectReport);
}
loading.value = false;
await pullDefaultDraft();
}
watch(visible, async isVisible => {
if (!isVisible) return;
currentStage.value = props.reportType === 'monthly' && props.scene === 'approval' ? 'approval' : 'form';
currentReportId.value = props.rowData?.id || '';
baseInfo.value = null;
resetModels();
if (props.mode === 'add') {
if (props.reportType === 'project') {
projectModel.projectId = props.initialProjectId;
projectModel.flag = props.initialFlag;
}
patchPeriod(currentModel.value);
await loadInitData();
return;
}
if (props.rowData?.id) {
await loadDetail(props.rowData.id);
}
});
function hasTextValue(value: unknown) {
const text = String(value ?? '')
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/g, ' ')
.trim();
return text.length > 0;
}
function hasMeaningfulStructuredValue(value: unknown): boolean {
if (value === null || value === undefined) return false;
if (typeof value === 'string') {
const text = value.trim();
if (!text || text === '{}' || text === '[]') return false;
try {
return hasMeaningfulStructuredValue(JSON.parse(text));
} catch {
return hasTextValue(text);
}
}
if (Array.isArray(value)) {
return value.some(item => hasMeaningfulStructuredValue(item));
}
if (typeof value === 'object') {
return Object.values(value as Record<string, unknown>).some(item => hasMeaningfulStructuredValue(item));
}
return false;
}
function hasReviewContent(item: Api.WorkReport.Common.PersonalReportReviewItem) {
return hasTextValue(item.contentText) || hasMeaningfulStructuredValue(item.contentJson);
}
function hasPlanTarget(item: Api.WorkReport.Common.PersonalReportPlanItem) {
return hasTextValue(item.targetText) || hasMeaningfulStructuredValue(item.targetJson);
}
function isCompleteWeeklyTravelSegment(segment: Api.WorkReport.Weekly.WeeklyReportTravelSegment) {
const travelDays = Number(segment.travelDays);
return Boolean(
segment.startDate &&
segment.endDate &&
segment.location?.trim() &&
Number.isFinite(travelDays) &&
travelDays >= 0.5 &&
Number.isInteger(travelDays * 2)
);
}
function hasCompleteWeeklyTravelSegment(items: Api.WorkReport.Weekly.WeeklyReportTravelSegment[]) {
return items.some(isCompleteWeeklyTravelSegment);
}
function hasCompletePersonalReviewItem(items: Api.WorkReport.Common.PersonalReportReviewItem[]) {
return items.some(item => hasTextValue(item.itemTitle) && hasReviewContent(item));
}
function hasCompletePersonalPlanItem(items: Api.WorkReport.Common.PersonalReportPlanItem[]) {
return items.some(item => hasTextValue(item.itemTitle) && hasPlanTarget(item));
}
function getPersonalReviewValidationMessage(label: string, items: Api.WorkReport.Common.PersonalReportReviewItem[]) {
if (!items.length) return `至少要有一项${label}`;
if (!hasCompletePersonalReviewItem(items)) {
return `请完善${label},项目名或我的事项、具体工作内容及成果描述不能为空`;
}
const hasIncompleteItem = items.some(item => !hasTextValue(item.itemTitle) || !hasReviewContent(item));
return hasIncompleteItem ? `请完善${label},项目名或我的事项、具体工作内容及成果描述不能为空` : '';
}
function getPersonalPlanValidationMessage(label: string, items: Api.WorkReport.Common.PersonalReportPlanItem[]) {
if (!items.length) return `至少要有一项${label}`;
if (!hasCompletePersonalPlanItem(items)) {
return `请完善${label},项目名或我的事项、具体目标不能为空`;
}
const hasIncompleteItem = items.some(item => !hasTextValue(item.itemTitle) || !hasPlanTarget(item));
return hasIncompleteItem ? `请完善${label},项目名或我的事项、具体目标不能为空` : '';
}
function hasProjectItem(items: Api.WorkReport.Project.ProjectReportItem[]) {
return items.some(item => hasTextValue(item.itemTitle));
}
function validateRequiredReportItems() {
const messages: string[] = [];
if (props.reportType === 'weekly') {
const hasTravelReview = weeklyModel.isBusinessTrip && hasCompleteWeeklyTravelSegment(weeklyModel.travelSegments);
const reviewMessage = hasTravelReview
? weeklyModel.reviewItems.length
? getPersonalReviewValidationMessage('当期重点工作回顾', weeklyModel.reviewItems)
: ''
: getPersonalReviewValidationMessage('当期重点工作回顾', weeklyModel.reviewItems);
const planMessage = getPersonalPlanValidationMessage('下周期重点工作计划', weeklyModel.planItems);
if (weeklyModel.isBusinessTrip && !hasTravelReview) messages.push('请至少新增一条完整的出差分段');
if (reviewMessage) messages.push(reviewMessage);
if (planMessage) messages.push(planMessage);
} else if (props.reportType === 'monthly') {
const reviewMessage = getPersonalReviewValidationMessage('当期重点工作回顾', monthlyModel.reviewItems);
const planMessage = getPersonalPlanValidationMessage('下周期重点工作计划', monthlyModel.planItems);
if (reviewMessage) messages.push(reviewMessage);
if (planMessage) messages.push(planMessage);
} else {
const missingLabels: string[] = [];
if (!hasProjectItem(projectModel.currentItems)) {
missingLabels.push('本期工作内容');
}
if (!hasProjectItem(projectModel.nextItems)) {
missingLabels.push('下期计划工作内容');
}
if (missingLabels.length) messages.push(`至少要有一项${missingLabels.join('、')}`);
}
if (!messages.length) return true;
window.$message?.warning(messages.join(''));
return false;
}
async function persistReport(submitAfterSave: boolean) {
if (!validateRequiredReportItems()) return false;
let result;
if (props.reportType === 'weekly') {
if (currentReportId.value) {
result = await fetchUpdateWeeklyReport(currentReportId.value, weeklyModel);
} else {
result = await fetchCreateWeeklyReport(weeklyModel);
if (!result.error && result.data) currentReportId.value = result.data;
}
} else if (props.reportType === 'monthly') {
if (currentReportId.value) {
result = await fetchUpdateMonthlyReport(currentReportId.value, monthlyModel);
} else {
result = await fetchCreateMonthlyReport(monthlyModel);
if (!result.error && result.data) currentReportId.value = result.data;
}
} else if (currentReportId.value) {
result = await fetchUpdateProjectReport(currentReportId.value, projectModel);
} else {
result = await fetchCreateProjectReport(projectModel);
if (!result.error && result.data) currentReportId.value = result.data;
}
if (result.error) return false;
if (submitAfterSave) {
if (props.reportType === 'weekly') {
const submitResult = await fetchSubmitWeeklyReport(currentReportId.value);
if (submitResult.error) return false;
} else if (props.reportType === 'monthly') {
const submitResult = await fetchSubmitMonthlyReport(currentReportId.value);
if (submitResult.error) return false;
} else {
const submitResult = await fetchSubmitProjectReport(currentReportId.value);
if (submitResult.error) return false;
}
}
return true;
}
async function handleSaveDraft() {
loading.value = true;
const success = await persistReport(false);
loading.value = false;
if (!success) return;
window.$message?.success('工作报告已保存');
visible.value = false;
emit('submitted');
}
async function handleSubmitReport() {
try {
await ElMessageBox.confirm('确认提交当前工作报告吗?', '提交确认', {
type: 'warning',
confirmButtonText: '确认提交',
cancelButtonText: '取消'
});
} catch {
return;
}
loading.value = true;
const success = await persistReport(true);
loading.value = false;
if (!success) return;
window.$message?.success('工作报告已提交');
visible.value = false;
emit('submitted');
}
function handleBack() {
visible.value = false;
}
function handleViewApproval() {
currentStage.value = 'approval';
}
function handleRequestApprove() {
if (props.reportType === 'monthly') {
handleActionSubmit({ ...monthlyApprovalDraft }, 'approve');
return;
}
currentActionType.value = 'approve';
actionVisible.value = true;
}
function handleRequestReject() {
if (props.reportType === 'monthly') {
handleActionSubmit({ reason: monthlyApprovalDraft.reason }, 'reject');
return;
}
currentActionType.value = 'reject';
actionVisible.value = true;
}
function handlePullDefaultDraft() {
pullDefaultDraft(true);
}
function handleMonthlyApprovalChange(payload: Api.WorkReport.Monthly.MonthlyReportApproveParams) {
Object.assign(monthlyApprovalDraft, payload);
}
async function handleActionSubmit(
payload: Api.WorkReport.Common.StatusActionParams | Api.WorkReport.Monthly.MonthlyReportApproveParams,
actionTypeOverride?: 'approve' | 'reject'
) {
if (!currentReportId.value) return;
const actionType = actionTypeOverride || currentActionType.value;
actionSubmitting.value = true;
let result;
if (props.reportType === 'weekly') {
result =
actionType === 'approve'
? await fetchApproveWeeklyReport(currentReportId.value, payload)
: await fetchRejectWeeklyReport(currentReportId.value, payload);
} else if (props.reportType === 'monthly') {
result =
actionType === 'approve'
? await fetchApproveMonthlyReport(
currentReportId.value,
payload as Api.WorkReport.Monthly.MonthlyReportApproveParams
)
: await fetchRejectMonthlyReport(currentReportId.value, payload);
} else {
result =
actionType === 'approve'
? await fetchApproveProjectReport(currentReportId.value, payload)
: await fetchRejectProjectReport(currentReportId.value, payload);
}
actionSubmitting.value = false;
if (result.error) return;
actionVisible.value = false;
window.$message?.success(actionType === 'approve' ? '审批已通过' : '工作报告已退回');
visible.value = false;
emit('submitted');
}
</script>
<template>
<WorkReportPageDialog
v-model:visible="visible"
:title="dialogTitle"
:loading="loading"
:approval-mode="currentScene === 'approval'"
@close="currentStage = 'form'"
>
<WeeklyReportPage
v-if="reportType === 'weekly'"
:report-type="REPORT_TYPE_TEXT[reportType]"
:period="periodText"
:mode="mode"
:scene="currentScene"
:base-info="baseInfo as Api.WorkReport.Weekly.WeeklyReport | null"
:model="weeklyModel"
@back="handleBack"
@save="handleSaveDraft"
@submit="handleSubmitReport"
@request-approve="handleRequestApprove"
@request-reject="handleRequestReject"
@pull-default-draft="handlePullDefaultDraft"
/>
<MonthlyReportApprovalPage
v-else-if="reportType === 'monthly' && currentStage === 'approval'"
:report-type="REPORT_TYPE_TEXT[reportType]"
:period="periodText"
:mode="mode"
scene="approval"
:base-info="baseInfo as Api.WorkReport.Monthly.MonthlyReport | null"
:model="monthlyModel"
:approval-model="monthlyApprovalDraft"
@back="handleBack"
@change-approval="handleMonthlyApprovalChange"
@request-approve="handleRequestApprove"
@request-reject="handleRequestReject"
/>
<MonthlyReportPage
v-else-if="reportType === 'monthly'"
:report-type="REPORT_TYPE_TEXT[reportType]"
:period="periodText"
:mode="mode"
:scene="currentScene"
:base-info="baseInfo as Api.WorkReport.Monthly.MonthlyReport | null"
:model="monthlyModel"
@back="handleBack"
@save="handleSaveDraft"
@submit="handleSubmitReport"
@view-approval="handleViewApproval"
@pull-default-draft="handlePullDefaultDraft"
/>
<ProjectReportPage
v-else
:report-type="REPORT_TYPE_TEXT[reportType]"
:period="periodText"
:mode="mode"
:scene="currentScene"
:base-info="baseInfo as Api.WorkReport.Project.ProjectReport | null"
:model="projectModel"
@back="handleBack"
@save="handleSaveDraft"
@submit="handleSubmitReport"
@request-approve="handleRequestApprove"
@request-reject="handleRequestReject"
@pull-default-draft="handlePullDefaultDraft"
/>
</WorkReportPageDialog>
<WorkReportActionDialog
v-model:visible="actionVisible"
:report-type="reportType"
:action-type="currentActionType"
:initial-monthly-approve-data="reportType === 'monthly' ? monthlyApprovalDraft : null"
:loading="actionSubmitting"
@submit="handleActionSubmit"
/>
</template>

View File

@@ -0,0 +1,102 @@
<script setup lang="ts">
/* eslint-disable no-void */
import { computed, onMounted, ref } from 'vue';
import { fetchGetWorkReportStatusDict } from '@/service/api';
import type { SearchField } from '@/components/custom/table-search-fields.vue';
import TableSearchFields from '@/components/custom/table-search-fields.vue';
import { BOOLEAN_TRUE_FALSE_OPTIONS, type WorkReportSearchParams, type WorkReportType } from '../types';
defineOptions({ name: 'WorkReportSearch' });
interface Props {
reportType: WorkReportType;
projectOptions?: Api.WorkReport.Project.ProjectReportOwnerProjectOption[];
}
const props = withDefaults(defineProps<Props>(), {
projectOptions: () => []
});
const model = defineModel<WorkReportSearchParams>('model', { required: true });
const emit = defineEmits<{
(e: 'reset'): void;
(e: 'search'): void;
}>();
const statusDict = ref<Api.WorkReport.Common.WorkReportStatusDict[]>([]);
const statusOptions = computed(() =>
[...statusDict.value]
.sort((a, b) => Number(a.sort || 0) - Number(b.sort || 0))
.map(item => ({
label: item.statusName || item.statusCode,
value: item.statusCode
}))
);
const fields = computed<SearchField[]>(() => {
const baseFields: SearchField[] = [
{ key: 'statusCode', label: '状态', type: 'select', options: statusOptions.value, placeholder: '请选择状态' },
{ key: 'periodStartDate', label: '周期', type: 'dateRange', placeholder: '请选择周期' }
];
const monthPeriodField: SearchField = {
key: 'periodStartDate',
label: props.reportType === 'project' ? '月份' : '月份',
type: 'dateRange',
dateRangeType: 'monthrange',
valueFormat: 'YYYY-MM-DD',
placeholder: '请选择月份'
};
if (props.reportType === 'weekly') {
return [
...baseFields,
{
key: 'isBusinessTrip',
label: '是否出差',
type: 'select',
options: BOOLEAN_TRUE_FALSE_OPTIONS,
placeholder: '请选择'
}
];
}
if (props.reportType === 'project') {
return [
baseFields[0],
monthPeriodField,
{
key: 'projectId',
label: '项目',
type: 'select',
options: props.projectOptions.map(item => ({
label: item.projectCode ? `${item.projectName}${item.projectCode}` : item.projectName,
value: item.id
})),
placeholder: '请选择项目'
}
];
}
if (props.reportType === 'monthly') {
return [baseFields[0], monthPeriodField];
}
return baseFields;
});
async function loadStatusDict() {
const { error, data } = await fetchGetWorkReportStatusDict();
statusDict.value = error || !data ? [] : data;
}
onMounted(() => {
loadStatusDict();
});
</script>
<template>
<TableSearchFields v-model="model" :columns="4" :fields="fields" @reset="emit('reset')" @search="emit('search')" />
</template>

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
import { WORK_REPORT_TYPE_LABEL, type WorkReportType } from '../types';
defineOptions({ name: 'WorkReportTabs' });
interface TabOption {
label: string;
name: WorkReportType;
}
const props = defineProps<{
tabs: TabOption[];
}>();
const activeTab = defineModel<WorkReportType>('activeTab', { required: true });
</script>
<template>
<ElCard class="work-report-sidebar" body-class="work-report-sidebar__body">
<div class="work-report-sidebar__header">报告类型</div>
<div class="work-report-sidebar__list">
<div
v-for="tab in tabs"
:key="tab.name"
class="work-report-sidebar__item"
:class="{ 'work-report-sidebar__item--active': activeTab === tab.name }"
@click="activeTab = tab.name"
>
<span class="work-report-sidebar__label">{{ tab.label || WORK_REPORT_TYPE_LABEL[tab.name] }}</span>
</div>
</div>
</ElCard>
</template>
<style scoped>
.work-report-sidebar {
height: 100%;
border: 1px solid var(--el-border-color-light);
box-shadow: none;
}
.work-report-sidebar :deep(.work-report-sidebar__body) {
padding: 0;
display: flex;
flex-direction: column;
height: 100%;
}
.work-report-sidebar__header {
padding: 20px 20px 12px;
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-secondary);
letter-spacing: 0.5px;
}
.work-report-sidebar__list {
display: flex;
flex-direction: column;
gap: 4px;
padding: 0 12px;
}
.work-report-sidebar__item {
display: flex;
align-items: center;
padding: 12px 16px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
}
.work-report-sidebar__item:hover {
background: var(--el-fill-color-light);
}
.work-report-sidebar__item--active {
background: var(--el-color-primary-light-9);
}
.work-report-sidebar__item--active .work-report-sidebar__label {
color: var(--el-color-primary);
font-weight: 600;
}
.work-report-sidebar__label {
font-size: 14px;
color: var(--el-text-color-regular);
line-height: 1;
}
</style>

View File

@@ -0,0 +1,521 @@
import dayjs from 'dayjs';
import type { PaginationData } from '@sa/hooks';
import { getStatusTagType } from '@/constants/status-tag';
export type WorkReportType = Api.WorkReport.Common.ReportType;
export type WorkReportRow =
| Api.WorkReport.Weekly.WeeklyReport
| Api.WorkReport.Monthly.MonthlyReport
| Api.WorkReport.Project.ProjectReport;
export type WorkReportSearchParams =
| Api.WorkReport.Weekly.WeeklyReportSearchParams
| Api.WorkReport.Monthly.MonthlyReportSearchParams
| Api.WorkReport.Project.ProjectReportSearchParams;
export type WorkReportSaveParams =
| Api.WorkReport.Weekly.WeeklyReportSaveParams
| Api.WorkReport.Monthly.MonthlyReportSaveParams
| Api.WorkReport.Project.ProjectReportSaveParams;
export interface WorkReportStructuredTask {
title: string;
detail?: string;
priority?: string | null;
progress?: number | null;
hours?: number | null;
kind?: string | null;
}
export interface WorkReportStructuredSection {
category: string;
tasks: WorkReportStructuredTask[];
}
export const WORK_REPORT_PROJECT_OWNER_PERMISSION = 'project:work-report:project-owner';
export const WORK_REPORT_TYPE_LABEL: Record<WorkReportType, string> = {
weekly: '个人周报',
monthly: '个人月报',
project: '项目半月报'
};
export const WORK_REPORT_STATUS_LABEL: Record<string, string> = {
draft: '待提交',
pending_approval: '待审批',
approved: '已通过',
rejected: '已退回'
};
export const PROJECT_REPORT_FLAG_OPTIONS = [
{ label: '上半月', value: 1 },
{ label: '下半月', value: 2 }
];
export const BOOLEAN_TRUE_FALSE_OPTIONS = [
{ label: '是', value: 'true' },
{ label: '否', value: 'false' }
];
export function getProjectReportFlagLabel(flag?: number | null) {
return PROJECT_REPORT_FLAG_OPTIONS.find(item => item.value === flag)?.label || '--';
}
export function getWorkReportStatusLabel(statusCode?: string | null, statusName?: string | null) {
return statusName || WORK_REPORT_STATUS_LABEL[statusCode || ''] || statusCode || '--';
}
export function resolveWorkReportStatusTagType(statusCode?: string | null) {
return getStatusTagType('workReport', statusCode);
}
export function formatEmptyText(value?: string | number | null) {
if (value === null || value === undefined || value === '') {
return '0';
}
return String(value);
}
export function formatDate(value?: string | null) {
return value ? dayjs(value).format('YYYY-MM-DD') : '--';
}
export function formatDateTime(value?: string | null) {
return value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '--';
}
export function formatPeriodLabel(value?: string | null) {
return String(value || '')
.trim()
.replace(/\s*(|||)\s*$/u, '');
}
export function formatPeriod(row: Pick<WorkReportRow, 'periodLabel' | 'periodStartDate' | 'periodEndDate'>) {
return formatPeriodLabel(row.periodLabel) || `${formatDate(row.periodStartDate)}${formatDate(row.periodEndDate)}`;
}
export function createInitBaseSearchParams() {
return {
pageNo: 1,
pageSize: 10,
keyword: undefined,
statusCode: undefined,
periodStartDate: undefined,
submitTime: undefined,
supervisorName: undefined
};
}
export function createWeeklySearchParams(): Api.WorkReport.Weekly.WeeklyReportSearchParams {
return {
...createInitBaseSearchParams(),
isBusinessTrip: undefined
};
}
export function createMonthlySearchParams(): Api.WorkReport.Monthly.MonthlyReportSearchParams {
return createInitBaseSearchParams();
}
export function createProjectSearchParams(): Api.WorkReport.Project.ProjectReportSearchParams {
return {
...createInitBaseSearchParams(),
projectId: undefined,
flag: undefined
};
}
export function transformWorkReportPage<T>(
response: { data: Api.WorkReport.Common.PageResult<T> | null; error: unknown },
pageNo: number,
pageSize: number
): PaginationData<T> {
if (!response.error && response.data) {
return {
data: response.data.list,
pageNum: pageNo,
pageSize,
total: response.data.total
};
}
return {
data: [],
pageNum: 1,
pageSize,
total: 0
};
}
export function createBlankReviewItem(index = 0): Api.WorkReport.Common.PersonalReportReviewItem {
return {
itemNumber: index + 1,
itemTitle: '',
workHours: 0,
contentText: '',
contentJson: null,
reflectionText: ''
};
}
export function createBlankPlanItem(index = 0): Api.WorkReport.Common.PersonalReportPlanItem {
return {
itemNumber: index + 1,
itemTitle: '',
targetText: '',
targetJson: null,
supportNeed: ''
};
}
export function createBlankProjectItem(): Api.WorkReport.Project.ProjectReportItem {
return {
itemTitle: '',
workHours: 0,
priorityCode: undefined,
progressRate: 0
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function normalizeNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim()) {
const numberValue = Number(value);
return Number.isFinite(numberValue) ? numberValue : null;
}
return null;
}
function normalizeStructuredTask(value: unknown): WorkReportStructuredTask | null {
if (!isRecord(value)) return null;
const title = String(value.title ?? value.name ?? '').trim();
if (!title) return null;
return {
title,
detail: String(value.detail ?? value.content ?? '').trim(),
priority: value.priority === null || value.priority === undefined ? null : String(value.priority),
progress: normalizeNumber(value.progress),
hours: normalizeNumber(value.hours),
kind: value.kind === null || value.kind === undefined ? null : String(value.kind)
};
}
function normalizeStructuredSection(value: unknown): WorkReportStructuredSection | null {
if (!isRecord(value)) return null;
const category = String(value.category ?? value.title ?? value.name ?? '').trim();
const rawTasks = Array.isArray(value.tasks) ? value.tasks : [];
const tasks = rawTasks.map(normalizeStructuredTask).filter(Boolean) as WorkReportStructuredTask[];
if (!category && !tasks.length) return null;
return {
category: category || '工作内容',
tasks
};
}
function parseJsonLike(value: unknown): unknown {
if (typeof value !== 'string') return value;
try {
return JSON.parse(value);
} catch {
return null;
}
}
export function getStructuredTasks(value: unknown): WorkReportStructuredTask[] {
const parsed = parseJsonLike(value);
if (Array.isArray(parsed)) {
return parsed.map(normalizeStructuredTask).filter(Boolean) as WorkReportStructuredTask[];
}
if (isRecord(parsed) && Array.isArray(parsed.tasks)) {
return parsed.tasks.map(normalizeStructuredTask).filter(Boolean) as WorkReportStructuredTask[];
}
return [];
}
export function getStructuredSections(value: unknown): WorkReportStructuredSection[] {
const parsed = parseJsonLike(value);
if (Array.isArray(parsed)) {
return parsed.map(normalizeStructuredSection).filter(Boolean) as WorkReportStructuredSection[];
}
if (isRecord(parsed) && Array.isArray(parsed.sections)) {
return parsed.sections.map(normalizeStructuredSection).filter(Boolean) as WorkReportStructuredSection[];
}
return [];
}
function joinTextValues(...values: Array<string | null | undefined>) {
return values
.map(value => value?.trim())
.filter(Boolean)
.join('\n');
}
function mergeStructuredSections(sections: WorkReportStructuredSection[]) {
const sectionMap = new Map<string, WorkReportStructuredSection>();
sections.forEach(section => {
const category = section.category || '工作内容';
const existing = sectionMap.get(category);
if (existing) {
existing.tasks.push(...section.tasks);
} else {
sectionMap.set(category, { category, tasks: [...section.tasks] });
}
});
return Array.from(sectionMap.values());
}
function mergeStructuredJson(current: unknown, next: unknown) {
const currentSections = getStructuredSections(current);
const nextSections = getStructuredSections(next);
const sections = mergeStructuredSections([...currentSections, ...nextSections]);
if (sections.length) return JSON.stringify({ sections });
const tasks = [...getStructuredTasks(current), ...getStructuredTasks(next)];
if (tasks.length) return JSON.stringify({ tasks });
return current ?? next ?? null;
}
function groupPersonalReportItemsByTitle<T extends { itemTitle: string }>(
items: T[],
merge: (target: T, source: T) => void
) {
const groupedItems: T[] = [];
const itemMap = new Map<string, T>();
items.forEach((item, index) => {
const title = item.itemTitle.trim();
const key = title || `__blank_${index}`;
const existing = itemMap.get(key);
if (existing) {
merge(existing, item);
return;
}
itemMap.set(key, item);
groupedItems.push(item);
});
return groupedItems.map((item, index) => ({
...item,
itemNumber: index + 1
}));
}
export function normalizeReviewItems(items?: Api.WorkReport.Common.PersonalReportReviewItem[] | null) {
const list = items?.length ? items : [createBlankReviewItem()];
const normalizedItems = list.map((item, index) => ({
...item,
itemNumber: item.itemNumber ?? index + 1,
itemTitle: item.itemTitle || '',
workHours: item.workHours ?? 0,
contentText: item.contentText || '',
contentJson: item.contentJson ?? null,
reflectionText: item.reflectionText || ''
}));
return groupPersonalReportItemsByTitle(normalizedItems, (target, source) => {
target.workHours = Number(target.workHours || 0) + Number(source.workHours || 0);
target.contentText = joinTextValues(target.contentText, source.contentText);
target.contentJson = mergeStructuredJson(target.contentJson, source.contentJson);
target.reflectionText = joinTextValues(target.reflectionText, source.reflectionText);
});
}
export function normalizePlanItems(items?: Api.WorkReport.Common.PersonalReportPlanItem[] | null) {
const list = items?.length ? items : [createBlankPlanItem()];
const normalizedItems = list.map((item, index) => ({
...item,
itemNumber: item.itemNumber ?? index + 1,
itemTitle: item.itemTitle || '',
targetText: item.targetText || '',
targetJson: item.targetJson ?? null,
supportNeed: item.supportNeed || ''
}));
return groupPersonalReportItemsByTitle(normalizedItems, (target, source) => {
target.targetText = joinTextValues(target.targetText, source.targetText);
target.targetJson = mergeStructuredJson(target.targetJson, source.targetJson);
target.supportNeed = joinTextValues(target.supportNeed, source.supportNeed);
});
}
export function normalizeProjectItems(items?: Api.WorkReport.Project.ProjectReportItem[] | null) {
const list = items?.length ? items : [createBlankProjectItem()];
return list.map(item => ({
...item,
itemTitle: item.itemTitle || '',
workHours: item.workHours ?? 0,
priorityCode: item.priorityCode || undefined,
progressRate: item.progressRate ?? 0
}));
}
export function createWeeklySaveParams(
base?: Partial<Api.WorkReport.Weekly.WeeklyReportSaveParams>
): Api.WorkReport.Weekly.WeeklyReportSaveParams {
return {
periodKey: base?.periodKey || '',
periodLabel: base?.periodLabel || '',
periodStartDate: base?.periodStartDate || '',
periodEndDate: base?.periodEndDate || '',
isBusinessTrip: base?.isBusinessTrip ?? false,
reviewItems: normalizeReviewItems(base?.reviewItems),
planItems: normalizePlanItems(base?.planItems),
travelSegments: base?.travelSegments?.length ? base.travelSegments : []
};
}
export function createMonthlySaveParams(
base?: Partial<Api.WorkReport.Monthly.MonthlyReportSaveParams>
): Api.WorkReport.Monthly.MonthlyReportSaveParams {
return {
periodKey: base?.periodKey || '',
periodLabel: base?.periodLabel || '',
periodStartDate: base?.periodStartDate || '',
periodEndDate: base?.periodEndDate || '',
reviewItems: normalizeReviewItems(base?.reviewItems),
planItems: normalizePlanItems(base?.planItems)
};
}
export function createProjectSaveParams(
base?: Partial<Api.WorkReport.Project.ProjectReportSaveParams>
): Api.WorkReport.Project.ProjectReportSaveParams {
const defaultParams: Api.WorkReport.Project.ProjectReportSaveParams = {
projectId: '',
periodKey: '',
periodLabel: '',
periodStartDate: '',
periodEndDate: '',
flag: 1,
projectStatusDesc: '',
projectProgressPlan: '',
projectKeyPoints: '',
projectProblems: '',
currentItems: [createBlankProjectItem()],
nextItems: [createBlankProjectItem()]
};
return {
...Object.assign(defaultParams, base),
currentItems: normalizeProjectItems(base?.currentItems),
nextItems: normalizeProjectItems(base?.nextItems)
};
}
type NavigatorWithLegacySave = Navigator & {
msSaveOrOpenBlob?: (blob: Blob, defaultName?: string) => boolean;
};
export function downloadBlob(blob: Blob, filename: string) {
if (!(blob instanceof Blob)) {
window.$message?.error(getBlobErrorMessage(blob) || '导出失败:接口未返回文件流');
return;
}
const downloadFile =
blob instanceof File ? blob : new File([blob], filename, { type: blob.type || 'application/octet-stream' });
const legacyNavigator = window.navigator as NavigatorWithLegacySave;
if (typeof legacyNavigator.msSaveOrOpenBlob === 'function') {
legacyNavigator.msSaveOrOpenBlob(downloadFile, filename);
window.$message?.success('导出文件已开始下载');
return;
}
const url = window.URL.createObjectURL(downloadFile);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.style.display = 'none';
link.rel = 'noopener';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.setTimeout(() => {
window.URL.revokeObjectURL(url);
}, 1000);
window.$message?.success('导出文件已开始下载');
}
function getBlobErrorMessage(value: unknown) {
if (!isRecord(value)) return '';
const message = value.msg || value.message || value.error;
return typeof message === 'string' && message.trim() ? message.trim() : '';
}
function safeDecodeFilename(value: string) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function getResponseHeader(headers: unknown, headerName: string) {
if (!headers) return '';
if (typeof (headers as { get?: unknown }).get === 'function') {
const value = (headers as { get: (name: string) => unknown }).get(headerName);
return value === null || value === undefined ? '' : String(value);
}
if (!isRecord(headers)) return '';
const matchedKey = Object.keys(headers).find(key => key.toLowerCase() === headerName.toLowerCase());
if (!matchedKey) return '';
const value = headers[matchedKey];
return value === null || value === undefined ? '' : String(value);
}
export function getFilenameFromDisposition(disposition?: string | null) {
if (!disposition) return '';
const filenameStarMatch = disposition.match(/filename\*=UTF-8''([^;]+)/i);
if (filenameStarMatch?.[1]) {
return safeDecodeFilename(filenameStarMatch[1].replace(/^"|"$/g, ''));
}
const filenameMatch = disposition.match(/filename="?([^";]+)"?/i);
if (filenameMatch?.[1]) {
return safeDecodeFilename(filenameMatch[1]);
}
return '';
}
export function resolveExportFilename(result: { response?: { headers?: unknown } }, fallbackName: string) {
const disposition = getResponseHeader(result.response?.headers, 'content-disposition');
return getFilenameFromDisposition(disposition) || fallbackName;
}
export function createWorkReportContentExportFallbackName(reportType: WorkReportType, reportCount: number) {
const extension = reportCount === 1 ? 'docx' : 'zip';
return `${WORK_REPORT_TYPE_LABEL[reportType]}_${dayjs().format('YYYY-MM-DD')}.${extension}`;
}

View File

@@ -0,0 +1,194 @@
import dayjs from 'dayjs';
import isoWeek from 'dayjs/plugin/isoWeek';
import weekday from 'dayjs/plugin/weekday';
import type { WorkReportType } from './types';
dayjs.extend(isoWeek);
dayjs.extend(weekday);
export interface WorkReportPeriodOption {
key: string;
label: string;
description: string;
reportType: WorkReportType;
flag?: number;
period: {
periodKey: string;
periodLabel: string;
periodStartDate: string;
periodEndDate: string;
};
}
function formatRangeLabel(start: dayjs.Dayjs, end: dayjs.Dayjs) {
return `${start.format('YYYY-MM-DD')}${end.format('YYYY-MM-DD')}`;
}
export function formatPeriodDisplayLabel(label?: string | null) {
return String(label || '')
.trim()
.replace(/\s*(|||)\s*$/u, '');
}
export function getIsoWeekDisplay(date: string | dayjs.Dayjs) {
const selectedDate = dayjs(date);
if (!selectedDate.isValid()) return '';
return `${selectedDate.format('YYYY')}${String(selectedDate.isoWeek()).padStart(2, '0')}`;
}
/* eslint-disable-next-line max-params */
function buildPeriod(reportType: WorkReportType, start: dayjs.Dayjs, end: dayjs.Dayjs, label: string, flag?: number) {
const startText = start.format('YYYY-MM-DD');
const endText = end.format('YYYY-MM-DD');
return {
periodKey: flag ? `${reportType}-${startText}-${endText}-${flag}` : `${reportType}-${startText}-${endText}`,
periodLabel: label,
periodStartDate: startText,
periodEndDate: endText
};
}
export function buildWeeklyPeriodFromDate(date: string | dayjs.Dayjs) {
const selectedDate = dayjs(date);
const start = selectedDate.startOf('isoWeek');
const end = selectedDate.endOf('isoWeek');
return buildPeriod('weekly', start, end, `${formatRangeLabel(start, end)} 周报`);
}
export function buildMonthlyPeriodFromMonth(month: string | dayjs.Dayjs) {
const selectedMonth = dayjs(month);
const start = selectedMonth.startOf('month');
const end = selectedMonth.endOf('month');
return buildPeriod('monthly', start, end, `${selectedMonth.format('YYYY-MM')} 月报`);
}
export function buildProjectPeriodFromMonth(month: string | dayjs.Dayjs, flag: number) {
const selectedMonth = dayjs(month);
const monthStart = selectedMonth.startOf('month');
if (flag === 2) {
const start = monthStart.date(16);
const end = selectedMonth.endOf('month');
return buildPeriod('project', start, end, `${selectedMonth.format('YYYY-MM')} 下半月`, 2);
}
const start = monthStart.startOf('month');
const end = monthStart.date(15);
return buildPeriod('project', start, end, `${selectedMonth.format('YYYY-MM')} 上半月`, 1);
}
export function getWeeklyPeriodOptions(now = dayjs()): WorkReportPeriodOption[] {
const thisWeekStart = now.startOf('isoWeek');
const thisWeekEnd = now.endOf('isoWeek');
const lastWeekStart = thisWeekStart.subtract(1, 'week');
const lastWeekEnd = thisWeekEnd.subtract(1, 'week');
return [
{
key: 'current-week',
label: '本周',
description: formatRangeLabel(thisWeekStart, thisWeekEnd),
reportType: 'weekly',
period: buildPeriod('weekly', thisWeekStart, thisWeekEnd, `${formatRangeLabel(thisWeekStart, thisWeekEnd)} 周报`)
},
{
key: 'last-week',
label: '上周',
description: formatRangeLabel(lastWeekStart, lastWeekEnd),
reportType: 'weekly',
period: buildPeriod('weekly', lastWeekStart, lastWeekEnd, `${formatRangeLabel(lastWeekStart, lastWeekEnd)} 周报`)
}
];
}
export function getMonthlyPeriodOptions(now = dayjs()): WorkReportPeriodOption[] {
const thisMonthStart = now.startOf('month');
const thisMonthEnd = now.endOf('month');
const lastMonth = now.subtract(1, 'month');
const lastMonthStart = lastMonth.startOf('month');
const lastMonthEnd = lastMonth.endOf('month');
return [
{
key: 'current-month',
label: '本月',
description: thisMonthStart.format('YYYY-MM'),
reportType: 'monthly',
period: buildPeriod('monthly', thisMonthStart, thisMonthEnd, `${thisMonthStart.format('YYYY-MM')} 月报`)
},
{
key: 'last-month',
label: '上月',
description: lastMonthStart.format('YYYY-MM'),
reportType: 'monthly',
period: buildPeriod('monthly', lastMonthStart, lastMonthEnd, `${lastMonthStart.format('YYYY-MM')} 月报`)
}
];
}
export function getProjectPeriodOptions(now = dayjs()): WorkReportPeriodOption[] {
const currentMonthStart = now.startOf('month');
const currentMonthEnd = now.endOf('month');
const currentFirstHalfEnd = currentMonthStart.date(15);
const currentSecondHalfStart = currentMonthStart.date(16);
const previousMonth = now.subtract(1, 'month');
const previousMonthStart = previousMonth.startOf('month');
const previousMonthEnd = previousMonth.endOf('month');
const previousSecondHalfStart = previousMonthStart.date(16);
const isCurrentFirstHalf = now.date() <= 15;
const currentOption: WorkReportPeriodOption = isCurrentFirstHalf
? {
key: 'current-first-half',
label: '上半月',
description: `${now.format('YYYY-MM')} 上半月`,
reportType: 'project',
flag: 1,
period: buildPeriod('project', currentMonthStart, currentFirstHalfEnd, `${now.format('YYYY-MM')} 上半月`, 1)
}
: {
key: 'current-second-half',
label: '下半月',
description: `${now.format('YYYY-MM')} 下半月`,
reportType: 'project',
flag: 2,
period: buildPeriod('project', currentSecondHalfStart, currentMonthEnd, `${now.format('YYYY-MM')} 下半月`, 2)
};
const previousOption: WorkReportPeriodOption = isCurrentFirstHalf
? {
key: 'previous-second-half',
label: '下半月',
description: `${previousMonth.format('YYYY-MM')} 下半月`,
reportType: 'project',
flag: 2,
period: buildPeriod(
'project',
previousSecondHalfStart,
previousMonthEnd,
`${previousMonth.format('YYYY-MM')} 下半月`,
2
)
}
: {
key: 'previous-first-half',
label: '上半月',
description: `${now.format('YYYY-MM')} 上半月`,
reportType: 'project',
flag: 1,
period: buildPeriod('project', currentMonthStart, currentFirstHalfEnd, `${now.format('YYYY-MM')} 上半月`, 1)
};
return [currentOption, previousOption];
}
export function getReportTypePeriodOptions(now = dayjs()) {
return {
weekly: getWeeklyPeriodOptions(now),
monthly: getMonthlyPeriodOptions(now),
project: getProjectPeriodOptions(now)
} as const;
}

View File

@@ -0,0 +1,373 @@
<script setup lang="tsx">
/* eslint-disable no-void */
import { markRaw, reactive, ref } from 'vue';
import { ElMessageBox, ElTag, ElTooltip } from 'element-plus';
import {
fetchDeleteWeeklyReport,
fetchExportWeeklyReportContent,
fetchGetWeeklyReportPage,
fetchSubmitWeeklyReport
} 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 {
WORK_REPORT_TYPE_LABEL,
type WorkReportRow,
createWeeklySearchParams,
createWorkReportContentExportFallbackName,
downloadBlob,
formatDateTime,
formatEmptyText,
formatPeriod,
getWorkReportStatusLabel,
resolveExportFilename,
resolveWorkReportStatusTagType,
transformWorkReportPage
} from '../shared/types';
import { getIsoWeekDisplay } from '../shared/utils';
import WeeklyReportSearch 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';
defineOptions({ name: 'WeeklyWorkReportIndex' });
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.Weekly.WeeklyReport[]>([]);
const searchParams = reactive(createWeeklySearchParams());
const ACTION_ICON_MAP = {
detail: markRaw(IconMdiEyeOutline),
edit: markRaw(IconMdiPencilOutline),
submit: markRaw(IconMdiSendOutline),
delete: markRaw(IconMdiDeleteOutline),
approvalRecord: markRaw(IconMdiFileDocumentCheckOutline)
};
const table = useUIPaginatedTable<
Awaited<ReturnType<typeof fetchGetWeeklyReportPage>>,
Api.WorkReport.Weekly.WeeklyReport
>({
paginationProps: { currentPage: searchParams.pageNo, pageSize: searchParams.pageSize },
api: () => fetchGetWeeklyReportPage(searchParams),
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 },
{
prop: 'periodLabel',
label: '周期',
minWidth: 150,
formatter: row => {
const periodText = formatPeriod(row);
const weekLabel = getIsoWeekDisplay(row.periodStartDate);
if (!weekLabel) return periodText;
return (
<ElTooltip content={weekLabel} placement="top">
<span>{periodText}</span>
</ElTooltip>
);
}
},
{
prop: 'reporterDeptName',
label: '部门/方向',
minWidth: 80,
showOverflowTooltip: true,
formatter: row => row.reporterDeptName || '--'
},
{ prop: 'supervisorName', label: '直属上级', minWidth: 80 },
{ prop: 'totalWorkHours', label: '总工时', minWidth: 80, formatter: row => formatEmptyText(row.totalWorkHours) },
{
prop: 'isBusinessTrip',
label: '出差',
minWidth: 80,
align: 'center',
formatter: row => (row.isBusinessTrip ? '是' : '否')
},
{
prop: 'totalTravelDays',
label: '出差天数',
minWidth: 90,
formatter: row => formatEmptyText(row.totalTravelDays)
},
{
prop: 'statusCode',
label: '状态',
minWidth: 80,
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: 180,
align: 'center',
fixed: 'right',
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
}
]
});
function getRowActions(row: Api.WorkReport.Weekly.WeeklyReport): BusinessTableAction[] {
const actions: BusinessTableAction[] = [
{
key: 'detail',
label: '详情',
buttonType: 'primary',
icon: ACTION_ICON_MAP.detail,
onClick: () => emit('detail', row)
}
];
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);
}
function resetSearchParams() {
const pageSize = searchParams.pageSize ?? 10;
Object.assign(searchParams, createWeeklySearchParams(), { pageSize });
reload(1);
}
function handleSearch() {
reload(1);
}
async function handleSubmitReport(row: Api.WorkReport.Weekly.WeeklyReport) {
try {
await ElMessageBox.confirm('确认提交该报告吗?', '提交确认', {
type: 'warning',
confirmButtonText: row.statusCode === 'draft' ? '确认提交' : '确认重新提交',
cancelButtonText: '取消'
});
} catch {
return;
}
const result = await fetchSubmitWeeklyReport(row.id);
if (result.error) return;
window.$message?.success('工作报告已提交');
await reload();
}
async function handleDelete(row: Api.WorkReport.Weekly.WeeklyReport) {
try {
await ElMessageBox.confirm(`确认删除 ${formatPeriod(row)} 吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消'
});
} catch {
return;
}
const result = await fetchDeleteWeeklyReport(row.id);
if (result.error) return;
window.$message?.success('工作报告已删除');
await reload();
}
function handleSelectionChange(rows: Api.WorkReport.Weekly.WeeklyReport[]) {
selectedRows.value = rows;
}
function createExportSearchParams() {
const { pageNo: _pageNo, pageSize: _pageSize, ...params } = searchParams;
return params;
}
async function exportReportContent(
params: Api.WorkReport.Common.ContentExportParams<Api.WorkReport.Weekly.WeeklyReportSearchParams>,
reportCount: number
) {
exporting.value = true;
const result = await fetchExportWeeklyReportContent(params);
exporting.value = false;
if (result.error || !result.data) return;
const fallbackName = createWorkReportContentExportFallbackName('weekly', 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();
}
defineExpose({ reload });
</script>
<template>
<div class="flex-col-stretch gap-16px overflow-hidden">
<WeeklyReportSearch v-model:model="searchParams" @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">{{ WORK_REPORT_TYPE_LABEL.weekly }}</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-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>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import SharedWorkReportApprovalRecordDialog from '../../shared/components/approval-record-dialog.vue';
import type { WorkReportRow } from '../../shared/types';
defineOptions({ name: 'WeeklyReportApprovalRecordDialog' });
const visible = defineModel<boolean>('visible', { default: false });
defineProps<{
rowData?: WorkReportRow | null;
}>();
</script>
<template>
<SharedWorkReportApprovalRecordDialog v-model:visible="visible" report-type="weekly" :row-data="rowData" />
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import SharedWorkReportDetailDialog from '../../shared/components/detail-dialog.vue';
import type { WorkReportRow } from '../../shared/types';
defineOptions({ name: 'WeeklyReportDetailPage' });
const visible = defineModel<boolean>('visible', { default: false });
defineProps<{
rowData?: WorkReportRow | null;
}>();
</script>
<template>
<SharedWorkReportDetailDialog v-model:visible="visible" report-type="weekly" :row-data="rowData" />
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import SharedWorkReportSearch from '../../shared/components/search-panel.vue';
defineOptions({ name: 'WeeklyReportSearch' });
defineProps<{
projectOptions?: Api.WorkReport.Project.ProjectReportOwnerProjectOption[];
}>();
const model = defineModel<Api.WorkReport.Weekly.WeeklyReportSearchParams>('model', { required: true });
const emit = defineEmits<{
(e: 'reset'): void;
(e: 'search'): void;
}>();
</script>
<template>
<SharedWorkReportSearch
v-model:model="model"
report-type="weekly"
:project-options="projectOptions"
@reset="emit('reset')"
@search="emit('search')"
/>
</template>