feat(工作报告、加班申请团队视角): 工作报告、加班申请现在可以查看团队视角了(查看下属)。
fix(工作报告): 修复周报在新增/编辑时,不能展示工作日志。
This commit is contained in:
@@ -1,16 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import { onBeforeRouteLeave } from 'vue-router';
|
||||
import { fetchGetProjectReportOwnerProjectOptions } from '@/service/api';
|
||||
import { fetchGetMySubordinateTree, fetchGetProjectReportOwnerProjectOptions } from '@/service/api';
|
||||
import { useAuth } from '@/hooks/business/auth';
|
||||
import SubordinateSelector from '@/components/custom/subordinate-selector.vue';
|
||||
import TeamContextPanel from '@/components/custom/team-context-panel.vue';
|
||||
import {
|
||||
type TeamViewContext,
|
||||
type TeamViewMode,
|
||||
collectSubordinateUserIds,
|
||||
findSubordinateNode
|
||||
} from '../shared/team-dashboard';
|
||||
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
|
||||
type WorkReportType,
|
||||
getWorkReportTypeDisplayLabel
|
||||
} from './shared/types';
|
||||
import WeeklyReportIndex from './weekly/index.vue';
|
||||
import WeeklyReportApprovalRecordDialog from './weekly/modules/approval-record-dialog.vue';
|
||||
@@ -29,6 +37,10 @@ type ReportListExpose = {
|
||||
const { hasAuth } = useAuth();
|
||||
|
||||
const activeTab = ref<WorkReportType>('weekly');
|
||||
const teamViewMode = ref<TeamViewMode>('self');
|
||||
const subordinateTreeLoading = ref(false);
|
||||
const subordinateTree = ref<Api.SystemManage.MySubordinateTreeNode | null>(null);
|
||||
const selectedSubordinateUserId = ref<string | null>(null);
|
||||
const createVisible = ref(false);
|
||||
const pageDialogVisible = ref(false);
|
||||
const pageDialogMode = ref<PageDialogMode>('detail');
|
||||
@@ -50,18 +62,45 @@ const monthlyRef = ref<ReportListExpose | null>(null);
|
||||
const projectRef = ref<ReportListExpose | null>(null);
|
||||
|
||||
const canShowProjectTab = computed(() => hasAuth(WORK_REPORT_PROJECT_OWNER_PERMISSION));
|
||||
const canUseTeamDashboard = computed(() => hasAuth('project:work-report:team-dashboard'));
|
||||
const allSubordinateUserIds = computed(() => collectSubordinateUserIds(subordinateTree.value));
|
||||
const selectedSubordinateNode = computed(() =>
|
||||
findSubordinateNode(subordinateTree.value, selectedSubordinateUserId.value)
|
||||
);
|
||||
const isRootSelected = computed(() => Boolean(selectedSubordinateNode.value?.isRoot));
|
||||
const selectedTeamLabel = computed(() => {
|
||||
if (teamViewMode.value === 'self') return '我自己';
|
||||
if (!selectedSubordinateNode.value) return '--';
|
||||
return selectedSubordinateNode.value.isRoot ? '全部下属' : selectedSubordinateNode.value.userNickname;
|
||||
});
|
||||
const teamContext = computed<TeamViewContext | null>(() => {
|
||||
if (!canUseTeamDashboard.value) return null;
|
||||
|
||||
return {
|
||||
mode: teamViewMode.value,
|
||||
selectedUserId: selectedSubordinateUserId.value,
|
||||
selectedUserIds:
|
||||
teamViewMode.value === 'team' && selectedSubordinateUserId.value && !isRootSelected.value
|
||||
? [selectedSubordinateUserId.value]
|
||||
: [],
|
||||
isRootSelected: teamViewMode.value === 'team' && isRootSelected.value,
|
||||
allSubordinateUserIds: allSubordinateUserIds.value,
|
||||
selectedLabel: selectedTeamLabel.value
|
||||
};
|
||||
});
|
||||
|
||||
/** 项目选项是否加载成功(用于项目半月报列表内部判断) */
|
||||
const projectOptionsLoaded = ref(false);
|
||||
|
||||
const visibleTabs = computed<Array<{ label: string; name: WorkReportType }>>(() => {
|
||||
const isTeamReportMode = teamViewMode.value === 'team';
|
||||
const tabs: Array<{ label: string; name: WorkReportType }> = [
|
||||
{ label: WORK_REPORT_TYPE_LABEL.weekly, name: 'weekly' },
|
||||
{ label: WORK_REPORT_TYPE_LABEL.monthly, name: 'monthly' }
|
||||
{ label: getWorkReportTypeDisplayLabel('weekly', isTeamReportMode), name: 'weekly' },
|
||||
{ label: getWorkReportTypeDisplayLabel('monthly', isTeamReportMode), name: 'monthly' }
|
||||
];
|
||||
|
||||
if (canShowProjectTab.value) {
|
||||
tabs.push({ label: WORK_REPORT_TYPE_LABEL.project, name: 'project' });
|
||||
tabs.push({ label: getWorkReportTypeDisplayLabel('project', isTeamReportMode), name: 'project' });
|
||||
}
|
||||
|
||||
return tabs;
|
||||
@@ -87,6 +126,17 @@ async function loadProjectOptions() {
|
||||
projectOptionsLoaded.value = !error;
|
||||
}
|
||||
|
||||
async function loadSubordinateTree() {
|
||||
if (!canUseTeamDashboard.value) return;
|
||||
|
||||
subordinateTreeLoading.value = true;
|
||||
const { error, data } = await fetchGetMySubordinateTree();
|
||||
subordinateTreeLoading.value = false;
|
||||
|
||||
subordinateTree.value = error || !data ? null : data;
|
||||
selectedSubordinateUserId.value = data?.userId || null;
|
||||
}
|
||||
|
||||
function openCreate(reportType: WorkReportType) {
|
||||
currentReportType.value = reportType;
|
||||
createVisible.value = true;
|
||||
@@ -133,9 +183,10 @@ function openApprovalRecord(reportType: WorkReportType, row: WorkReportRow) {
|
||||
approvalRecordVisible.value = true;
|
||||
}
|
||||
|
||||
function handleTabChange(tab: WorkReportType) {
|
||||
async function handleTabChange(tab: WorkReportType) {
|
||||
activeTab.value = tab;
|
||||
getListRef(tab)?.reload(1);
|
||||
await nextTick();
|
||||
await getListRef(tab)?.reload(1);
|
||||
}
|
||||
|
||||
async function reloadReport(reportType = currentReportType.value) {
|
||||
@@ -153,8 +204,35 @@ function closeFloatingPanels() {
|
||||
approvalRecordVisible.value = false;
|
||||
}
|
||||
|
||||
async function handleTeamViewModeChange(mode: TeamViewMode) {
|
||||
teamViewMode.value = mode;
|
||||
|
||||
if (mode === 'team') {
|
||||
if (!subordinateTree.value) {
|
||||
await loadSubordinateTree();
|
||||
}
|
||||
if (!selectedSubordinateUserId.value) {
|
||||
selectedSubordinateUserId.value = subordinateTree.value?.userId || null;
|
||||
}
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
await getListRef(activeTab.value)?.reload(1);
|
||||
}
|
||||
|
||||
watch(selectedSubordinateUserId, async (currentUserId, previousUserId) => {
|
||||
if (!canUseTeamDashboard.value || teamViewMode.value !== 'team') return;
|
||||
if (!currentUserId || currentUserId === previousUserId) return;
|
||||
|
||||
await nextTick();
|
||||
await getListRef(activeTab.value)?.reload(1);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await loadProjectOptions();
|
||||
if (canUseTeamDashboard.value) {
|
||||
await loadSubordinateTree();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeRouteLeave(() => {
|
||||
@@ -167,16 +245,41 @@ onBeforeRouteLeave(() => {
|
||||
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
|
||||
class="work-report-page-shell__sidebar"
|
||||
:class="{ 'work-report-page-shell__sidebar--team': canUseTeamDashboard && teamViewMode === 'team' }"
|
||||
>
|
||||
<WorkReportTabs
|
||||
class="work-report-page-shell__sidebar-card"
|
||||
:active-tab="activeTab"
|
||||
:tabs="visibleTabs"
|
||||
@update:active-tab="handleTabChange"
|
||||
/>
|
||||
<SubordinateSelector
|
||||
v-if="canUseTeamDashboard && teamViewMode === 'team'"
|
||||
v-model:selected-user-id="selectedSubordinateUserId"
|
||||
class="work-report-page-shell__sidebar-card"
|
||||
:loading="subordinateTreeLoading"
|
||||
:data="subordinateTree"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:搜索区 + 列表区 -->
|
||||
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
||||
<TeamContextPanel
|
||||
v-if="canUseTeamDashboard"
|
||||
v-model:mode="teamViewMode"
|
||||
:loading="subordinateTreeLoading"
|
||||
:selected-label="selectedTeamLabel"
|
||||
:subordinate-count="subordinateTree?.subordinateCount || 0"
|
||||
@update:mode="handleTeamViewModeChange"
|
||||
/>
|
||||
|
||||
<WeeklyReportIndex
|
||||
v-show="activeTab === 'weekly'"
|
||||
ref="weeklyRef"
|
||||
class="flex-1-hidden"
|
||||
:team-context="teamContext"
|
||||
@create="openCreate('weekly')"
|
||||
@edit="openEdit('weekly', $event)"
|
||||
@detail="openDetail('weekly', $event)"
|
||||
@@ -187,6 +290,7 @@ onBeforeRouteLeave(() => {
|
||||
v-show="activeTab === 'monthly'"
|
||||
ref="monthlyRef"
|
||||
class="flex-1-hidden"
|
||||
:team-context="teamContext"
|
||||
@create="openCreate('monthly')"
|
||||
@edit="openEdit('monthly', $event)"
|
||||
@detail="openDetail('monthly', $event)"
|
||||
@@ -198,6 +302,7 @@ onBeforeRouteLeave(() => {
|
||||
v-show="activeTab === 'project'"
|
||||
ref="projectRef"
|
||||
class="flex-1-hidden"
|
||||
:team-context="teamContext"
|
||||
:project-options="projectOptions"
|
||||
:project-options-loaded="projectOptionsLoaded"
|
||||
@create="openCreate('project')"
|
||||
@@ -238,4 +343,25 @@ onBeforeRouteLeave(() => {
|
||||
.work-report-page-shell {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.work-report-page-shell__sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.work-report-page-shell__sidebar {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.work-report-page-shell__sidebar--team {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.work-report-page-shell__sidebar-card {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
<script setup lang="tsx">
|
||||
/* eslint-disable no-void */
|
||||
import { markRaw, reactive, ref } from 'vue';
|
||||
import { computed, markRaw, reactive, ref } from 'vue';
|
||||
import { ElMessageBox, ElTag } from 'element-plus';
|
||||
import {
|
||||
fetchDeleteMonthlyReport,
|
||||
fetchExportMonthlyReportContent,
|
||||
fetchGetMonthlyReportPage,
|
||||
fetchGetTeamReportSummary,
|
||||
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 { type TeamViewContext, resolveTeamQueryUserIds } from '@/views/personal-center/shared/team-dashboard';
|
||||
import {
|
||||
WORK_REPORT_TYPE_LABEL,
|
||||
type WorkReportRow,
|
||||
createMonthlySearchParams,
|
||||
createWorkReportContentExportFallbackName,
|
||||
@@ -21,19 +22,27 @@ import {
|
||||
formatEmptyText,
|
||||
formatPeriod,
|
||||
getWorkReportStatusLabel,
|
||||
getWorkReportTypeDisplayLabel,
|
||||
resolveExportFilename,
|
||||
resolveWorkReportStatusTagType,
|
||||
transformWorkReportPage
|
||||
} from '../shared/types';
|
||||
import { resolveWorkReportSummaryPeriod } from '../shared/utils';
|
||||
import TeamReportSummary from '../shared/components/team-report-summary.vue';
|
||||
import 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';
|
||||
import IconMdiDownloadOutline from '~icons/mdi/download-outline';
|
||||
|
||||
defineOptions({ name: 'MonthlyWorkReportIndex' });
|
||||
|
||||
const props = defineProps<{
|
||||
teamContext?: TeamViewContext | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'create'): void;
|
||||
(e: 'edit', row: WorkReportRow): void;
|
||||
@@ -45,21 +54,33 @@ const { hasAuth } = useAuth();
|
||||
const exporting = ref(false);
|
||||
const selectedRows = ref<Api.WorkReport.Monthly.MonthlyReport[]>([]);
|
||||
const searchParams = reactive(createMonthlySearchParams());
|
||||
const teamSummaryLoading = ref(false);
|
||||
const teamSummary = ref<Api.WorkReport.Common.TeamReportSummary | null>(null);
|
||||
|
||||
const ACTION_ICON_MAP = {
|
||||
detail: markRaw(IconMdiEyeOutline),
|
||||
edit: markRaw(IconMdiPencilOutline),
|
||||
submit: markRaw(IconMdiSendOutline),
|
||||
delete: markRaw(IconMdiDeleteOutline),
|
||||
approvalRecord: markRaw(IconMdiFileDocumentCheckOutline)
|
||||
approvalRecord: markRaw(IconMdiFileDocumentCheckOutline),
|
||||
export: markRaw(IconMdiDownloadOutline)
|
||||
};
|
||||
|
||||
const isTeamMode = computed(() => props.teamContext?.mode === 'team');
|
||||
const isTeamRootSelected = computed(() => Boolean(isTeamMode.value && props.teamContext?.isRootSelected));
|
||||
const currentTeamReporterIds = computed(() => resolveTeamQueryUserIds(props.teamContext));
|
||||
const reportTitle = computed(() => getWorkReportTypeDisplayLabel('monthly', isTeamMode.value));
|
||||
|
||||
const table = useUIPaginatedTable<
|
||||
Awaited<ReturnType<typeof fetchGetMonthlyReportPage>>,
|
||||
Api.WorkReport.Monthly.MonthlyReport
|
||||
>({
|
||||
paginationProps: { currentPage: searchParams.pageNo, pageSize: searchParams.pageSize },
|
||||
api: () => fetchGetMonthlyReportPage(searchParams),
|
||||
api: () =>
|
||||
fetchGetMonthlyReportPage({
|
||||
...searchParams,
|
||||
reporterIds: currentTeamReporterIds.value
|
||||
}),
|
||||
transform: response => transformWorkReportPage(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
|
||||
onPaginationParamsChange: params => {
|
||||
searchParams.pageNo = params.currentPage ?? 1;
|
||||
@@ -67,6 +88,7 @@ const table = useUIPaginatedTable<
|
||||
},
|
||||
columns: () => [
|
||||
{ prop: 'index', type: 'index', label: '序号', width: 64 },
|
||||
...(isTeamMode.value ? [{ prop: 'reporterName', label: '提交人', minWidth: 100, showOverflowTooltip: true }] : []),
|
||||
{ prop: 'periodLabel', label: '月份', minWidth: 80, formatter: row => formatPeriod(row) },
|
||||
{
|
||||
prop: 'reporterDeptName',
|
||||
@@ -93,7 +115,7 @@ const table = useUIPaginatedTable<
|
||||
{
|
||||
prop: 'operate',
|
||||
label: '操作',
|
||||
width: 180,
|
||||
width: isTeamMode.value ? 140 : 180,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
|
||||
@@ -101,17 +123,53 @@ const table = useUIPaginatedTable<
|
||||
]
|
||||
});
|
||||
|
||||
const summaryPeriod = computed(() =>
|
||||
resolveWorkReportSummaryPeriod('monthly', {
|
||||
currentRow: table.data.value[0],
|
||||
periodRange: searchParams.periodStartDate
|
||||
})
|
||||
);
|
||||
|
||||
function getRowActions(row: Api.WorkReport.Monthly.MonthlyReport): BusinessTableAction[] {
|
||||
const actions: BusinessTableAction[] = [
|
||||
{
|
||||
key: 'detail',
|
||||
label: '详情',
|
||||
label: '查看',
|
||||
buttonType: 'primary',
|
||||
icon: ACTION_ICON_MAP.detail,
|
||||
onClick: () => emit('detail', row)
|
||||
}
|
||||
];
|
||||
|
||||
if (isTeamMode.value) {
|
||||
actions.push({
|
||||
key: 'export',
|
||||
label: '导出',
|
||||
buttonType: 'success',
|
||||
icon: ACTION_ICON_MAP.export,
|
||||
onClick: () =>
|
||||
exportReportContent(
|
||||
{
|
||||
exportAll: false,
|
||||
ids: [row.id]
|
||||
},
|
||||
1
|
||||
)
|
||||
});
|
||||
|
||||
if (['approved', 'rejected'].includes(row.statusCode)) {
|
||||
actions.push({
|
||||
key: 'approval-record',
|
||||
label: '审批记录',
|
||||
buttonType: 'info',
|
||||
icon: ACTION_ICON_MAP.approvalRecord,
|
||||
onClick: () => emit('approvalRecord', row)
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
if (['draft', 'rejected'].includes(row.statusCode) && row.allowEdit && hasAuth('project:work-report:update')) {
|
||||
actions.push({
|
||||
key: 'edit',
|
||||
@@ -154,6 +212,7 @@ function getRowActions(row: Api.WorkReport.Monthly.MonthlyReport): BusinessTable
|
||||
|
||||
async function reload(page?: number) {
|
||||
await table.getDataByPage(page ?? searchParams.pageNo ?? 1);
|
||||
await loadTeamSummary();
|
||||
}
|
||||
|
||||
function resetSearchParams() {
|
||||
@@ -209,7 +268,10 @@ function handleSelectionChange(rows: Api.WorkReport.Monthly.MonthlyReport[]) {
|
||||
|
||||
function createExportSearchParams() {
|
||||
const { pageNo: _pageNo, pageSize: _pageSize, ...params } = searchParams;
|
||||
return params;
|
||||
return {
|
||||
...params,
|
||||
reporterIds: currentTeamReporterIds.value
|
||||
};
|
||||
}
|
||||
|
||||
async function exportReportContent(
|
||||
@@ -267,6 +329,23 @@ async function handleExportCommand(command: 'selected' | 'all') {
|
||||
await handleExportAll();
|
||||
}
|
||||
|
||||
async function loadTeamSummary() {
|
||||
if (!isTeamRootSelected.value) {
|
||||
teamSummaryLoading.value = false;
|
||||
teamSummary.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
teamSummaryLoading.value = true;
|
||||
const { error, data } = await fetchGetTeamReportSummary({
|
||||
reportType: 'monthly',
|
||||
periodKey: summaryPeriod.value.periodKey
|
||||
});
|
||||
teamSummaryLoading.value = false;
|
||||
|
||||
teamSummary.value = error || !data ? null : data;
|
||||
}
|
||||
|
||||
defineExpose({ reload });
|
||||
</script>
|
||||
|
||||
@@ -274,11 +353,21 @@ defineExpose({ reload });
|
||||
<div class="flex-col-stretch gap-16px overflow-hidden">
|
||||
<MonthlyReportSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
|
||||
|
||||
<TeamReportSummary
|
||||
v-if="isTeamRootSelected"
|
||||
report-type="monthly"
|
||||
:period-key="summaryPeriod.periodKey"
|
||||
:period-label="formatPeriod(summaryPeriod)"
|
||||
:loading="teamSummaryLoading"
|
||||
:summary="teamSummary"
|
||||
@reminded="loadTeamSummary"
|
||||
/>
|
||||
|
||||
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
|
||||
<template #header>
|
||||
<div class="flex flex-wrap items-center justify-between gap-12px">
|
||||
<div class="flex items-center gap-10px">
|
||||
<p class="text-16px font-600">{{ WORK_REPORT_TYPE_LABEL.monthly }}</p>
|
||||
<p class="text-16px font-600">{{ reportTitle }}</p>
|
||||
<ElTag effect="plain">{{ table.mobilePagination.value.total || 0 }}</ElTag>
|
||||
</div>
|
||||
|
||||
@@ -304,7 +393,13 @@ defineExpose({ reload });
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
<ElButton v-auth="'project:work-report:create'" plain type="primary" @click="emit('create')">
|
||||
<ElButton
|
||||
v-if="!isTeamMode"
|
||||
v-auth="'project:work-report:create'"
|
||||
plain
|
||||
type="primary"
|
||||
@click="emit('create')"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
<script setup lang="tsx">
|
||||
/* eslint-disable no-void */
|
||||
import { markRaw, reactive, ref } from 'vue';
|
||||
import { computed, markRaw, reactive, ref } from 'vue';
|
||||
import { ElMessageBox, ElTag } from 'element-plus';
|
||||
import {
|
||||
fetchDeleteProjectReport,
|
||||
fetchExportProjectReportContent,
|
||||
fetchGetProjectReportPage,
|
||||
fetchGetTeamReportSummary,
|
||||
fetchSubmitProjectReport
|
||||
} from '@/service/api';
|
||||
import { useAuth } from '@/hooks/business/auth';
|
||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
|
||||
import { type TeamViewContext, resolveTeamQueryUserIds } from '@/views/personal-center/shared/team-dashboard';
|
||||
import {
|
||||
WORK_REPORT_TYPE_LABEL,
|
||||
type WorkReportRow,
|
||||
createProjectSearchParams,
|
||||
createWorkReportContentExportFallbackName,
|
||||
@@ -20,22 +21,26 @@ import {
|
||||
formatDateTime,
|
||||
formatEmptyText,
|
||||
formatPeriod,
|
||||
getProjectReportFlagLabel,
|
||||
getWorkReportStatusLabel,
|
||||
getWorkReportTypeDisplayLabel,
|
||||
resolveExportFilename,
|
||||
resolveWorkReportStatusTagType,
|
||||
transformWorkReportPage
|
||||
} from '../shared/types';
|
||||
import { resolveWorkReportSummaryPeriod } from '../shared/utils';
|
||||
import TeamReportSummary from '../shared/components/team-report-summary.vue';
|
||||
import ProjectReportSearch from './modules/search-panel.vue';
|
||||
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
|
||||
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
|
||||
import IconMdiFileDocumentCheckOutline from '~icons/mdi/file-document-check-outline';
|
||||
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
|
||||
import IconMdiSendOutline from '~icons/mdi/send-outline';
|
||||
import IconMdiDownloadOutline from '~icons/mdi/download-outline';
|
||||
|
||||
defineOptions({ name: 'ProjectWorkReportIndex' });
|
||||
|
||||
const props = defineProps<{
|
||||
teamContext?: TeamViewContext | null;
|
||||
projectOptions: Api.WorkReport.Project.ProjectReportOwnerProjectOption[];
|
||||
projectOptionsLoaded: boolean;
|
||||
}>();
|
||||
@@ -51,21 +56,33 @@ const { hasAuth } = useAuth();
|
||||
const exporting = ref(false);
|
||||
const selectedRows = ref<Api.WorkReport.Project.ProjectReport[]>([]);
|
||||
const searchParams = reactive(createProjectSearchParams());
|
||||
const teamSummaryLoading = ref(false);
|
||||
const teamSummary = ref<Api.WorkReport.Common.TeamReportSummary | null>(null);
|
||||
|
||||
const ACTION_ICON_MAP = {
|
||||
detail: markRaw(IconMdiEyeOutline),
|
||||
edit: markRaw(IconMdiPencilOutline),
|
||||
submit: markRaw(IconMdiSendOutline),
|
||||
delete: markRaw(IconMdiDeleteOutline),
|
||||
approvalRecord: markRaw(IconMdiFileDocumentCheckOutline)
|
||||
approvalRecord: markRaw(IconMdiFileDocumentCheckOutline),
|
||||
export: markRaw(IconMdiDownloadOutline)
|
||||
};
|
||||
|
||||
const isTeamMode = computed(() => props.teamContext?.mode === 'team');
|
||||
const isTeamRootSelected = computed(() => Boolean(isTeamMode.value && props.teamContext?.isRootSelected));
|
||||
const currentProjectOwnerIds = computed(() => resolveTeamQueryUserIds(props.teamContext));
|
||||
const reportTitle = computed(() => getWorkReportTypeDisplayLabel('project', isTeamMode.value));
|
||||
|
||||
const table = useUIPaginatedTable<
|
||||
Awaited<ReturnType<typeof fetchGetProjectReportPage>>,
|
||||
Api.WorkReport.Project.ProjectReport
|
||||
>({
|
||||
paginationProps: { currentPage: searchParams.pageNo, pageSize: searchParams.pageSize },
|
||||
api: () => fetchGetProjectReportPage(searchParams),
|
||||
api: () =>
|
||||
fetchGetProjectReportPage({
|
||||
...searchParams,
|
||||
projectOwnerIds: currentProjectOwnerIds.value
|
||||
}),
|
||||
transform: response => transformWorkReportPage(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
|
||||
onPaginationParamsChange: params => {
|
||||
searchParams.pageNo = params.currentPage ?? 1;
|
||||
@@ -73,9 +90,11 @@ const table = useUIPaginatedTable<
|
||||
},
|
||||
columns: () => [
|
||||
{ prop: 'index', type: 'index', label: '序号', width: 64 },
|
||||
...(isTeamMode.value
|
||||
? [{ prop: 'projectOwnerName', label: '提交人', minWidth: 100, showOverflowTooltip: true }]
|
||||
: []),
|
||||
{ prop: 'projectName', label: '项目名称', minWidth: 200, showOverflowTooltip: true },
|
||||
{ prop: 'periodLabel', label: '半月周期', minWidth: 120, formatter: row => formatPeriod(row) },
|
||||
// { prop: 'flag', label: '半月', width: 90, formatter: row => getProjectReportFlagLabel(row.flag) },
|
||||
{ prop: 'projectOwnerName', label: '项目负责人', minWidth: 80 },
|
||||
{
|
||||
prop: 'technicalOwnerName',
|
||||
@@ -101,7 +120,7 @@ const table = useUIPaginatedTable<
|
||||
{
|
||||
prop: 'operate',
|
||||
label: '操作',
|
||||
width: 180,
|
||||
width: isTeamMode.value ? 140 : 180,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
|
||||
@@ -109,17 +128,54 @@ const table = useUIPaginatedTable<
|
||||
]
|
||||
});
|
||||
|
||||
const summaryPeriod = computed(() =>
|
||||
resolveWorkReportSummaryPeriod('project', {
|
||||
currentRow: table.data.value[0],
|
||||
periodRange: searchParams.periodStartDate,
|
||||
flag: searchParams.flag
|
||||
})
|
||||
);
|
||||
|
||||
function getRowActions(row: Api.WorkReport.Project.ProjectReport): BusinessTableAction[] {
|
||||
const actions: BusinessTableAction[] = [
|
||||
{
|
||||
key: 'detail',
|
||||
label: '详情',
|
||||
label: '查看',
|
||||
buttonType: 'primary',
|
||||
icon: ACTION_ICON_MAP.detail,
|
||||
onClick: () => emit('detail', row)
|
||||
}
|
||||
];
|
||||
|
||||
if (isTeamMode.value) {
|
||||
actions.push({
|
||||
key: 'export',
|
||||
label: '导出',
|
||||
buttonType: 'success',
|
||||
icon: ACTION_ICON_MAP.export,
|
||||
onClick: () =>
|
||||
exportReportContent(
|
||||
{
|
||||
exportAll: false,
|
||||
ids: [row.id]
|
||||
},
|
||||
1
|
||||
)
|
||||
});
|
||||
|
||||
if (['approved', 'rejected'].includes(row.statusCode)) {
|
||||
actions.push({
|
||||
key: 'approval-record',
|
||||
label: '审批记录',
|
||||
buttonType: 'info',
|
||||
icon: ACTION_ICON_MAP.approvalRecord,
|
||||
onClick: () => emit('approvalRecord', row)
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
if (['draft', 'rejected'].includes(row.statusCode) && row.allowEdit && hasAuth('project:work-report:update')) {
|
||||
actions.push({
|
||||
key: 'edit',
|
||||
@@ -162,6 +218,7 @@ function getRowActions(row: Api.WorkReport.Project.ProjectReport): BusinessTable
|
||||
|
||||
async function reload(page?: number) {
|
||||
await table.getDataByPage(page ?? searchParams.pageNo ?? 1);
|
||||
await loadTeamSummary();
|
||||
}
|
||||
|
||||
function resetSearchParams() {
|
||||
@@ -217,7 +274,10 @@ function handleSelectionChange(rows: Api.WorkReport.Project.ProjectReport[]) {
|
||||
|
||||
function createExportSearchParams() {
|
||||
const { pageNo: _pageNo, pageSize: _pageSize, ...params } = searchParams;
|
||||
return params;
|
||||
return {
|
||||
...params,
|
||||
projectOwnerIds: currentProjectOwnerIds.value
|
||||
};
|
||||
}
|
||||
|
||||
async function exportReportContent(
|
||||
@@ -275,6 +335,23 @@ async function handleExportCommand(command: 'selected' | 'all') {
|
||||
await handleExportAll();
|
||||
}
|
||||
|
||||
async function loadTeamSummary() {
|
||||
if (!isTeamRootSelected.value) {
|
||||
teamSummaryLoading.value = false;
|
||||
teamSummary.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
teamSummaryLoading.value = true;
|
||||
const { error, data } = await fetchGetTeamReportSummary({
|
||||
reportType: 'project',
|
||||
periodKey: summaryPeriod.value.periodKey
|
||||
});
|
||||
teamSummaryLoading.value = false;
|
||||
|
||||
teamSummary.value = error || !data ? null : data;
|
||||
}
|
||||
|
||||
defineExpose({ reload });
|
||||
</script>
|
||||
|
||||
@@ -292,11 +369,21 @@ defineExpose({ reload });
|
||||
@search="handleSearch"
|
||||
/>
|
||||
|
||||
<TeamReportSummary
|
||||
v-if="isTeamRootSelected"
|
||||
report-type="project"
|
||||
:period-key="summaryPeriod.periodKey"
|
||||
:period-label="formatPeriod(summaryPeriod)"
|
||||
:loading="teamSummaryLoading"
|
||||
:summary="teamSummary"
|
||||
@reminded="loadTeamSummary"
|
||||
/>
|
||||
|
||||
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
|
||||
<template #header>
|
||||
<div class="flex flex-wrap items-center justify-between gap-12px">
|
||||
<div class="flex items-center gap-10px">
|
||||
<p class="text-16px font-600">{{ WORK_REPORT_TYPE_LABEL.project }}</p>
|
||||
<p class="text-16px font-600">{{ reportTitle }}</p>
|
||||
<ElTag effect="plain">{{ table.mobilePagination.value.total || 0 }}</ElTag>
|
||||
</div>
|
||||
|
||||
@@ -322,7 +409,13 @@ defineExpose({ reload });
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
<ElButton v-auth="'project:work-report:create'" plain type="primary" @click="emit('create')">
|
||||
<ElButton
|
||||
v-if="!isTeamMode"
|
||||
v-auth="'project:work-report:create'"
|
||||
plain
|
||||
type="primary"
|
||||
@click="emit('create')"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { fetchRemindTeamReport } from '@/service/api';
|
||||
|
||||
defineOptions({ name: 'TeamReportSummary' });
|
||||
|
||||
interface Props {
|
||||
reportType: Api.WorkReport.Common.ReportType;
|
||||
periodKey: string;
|
||||
periodLabel?: string;
|
||||
loading?: boolean;
|
||||
summary?: Api.WorkReport.Common.TeamReportSummary | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
periodLabel: '',
|
||||
loading: false,
|
||||
summary: null
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
reminded: [];
|
||||
}>();
|
||||
|
||||
const remindingAll = ref(false);
|
||||
const remindingUserId = ref('');
|
||||
|
||||
const cards = computed(() => [
|
||||
{ label: '应填人数', value: props.summary?.totalShouldSubmit ?? 0 },
|
||||
{ label: '已提交', value: props.summary?.submittedCount ?? 0 },
|
||||
{ label: '待提交', value: props.summary?.unsubmittedCount ?? 0, key: 'unsubmitted' as const },
|
||||
{ label: '待审批', value: props.summary?.pendingApprovalCount ?? 0 }
|
||||
]);
|
||||
|
||||
async function handleRemind(userIds?: string[]) {
|
||||
const targetUserId = userIds?.length === 1 ? userIds[0] : '';
|
||||
|
||||
if (targetUserId) {
|
||||
remindingUserId.value = targetUserId;
|
||||
} else {
|
||||
remindingAll.value = true;
|
||||
}
|
||||
|
||||
const { error, data } = await fetchRemindTeamReport({
|
||||
reportType: props.reportType,
|
||||
periodKey: props.periodKey,
|
||||
userIds
|
||||
});
|
||||
|
||||
if (!targetUserId) {
|
||||
remindingAll.value = false;
|
||||
}
|
||||
remindingUserId.value = '';
|
||||
|
||||
if (error) return;
|
||||
|
||||
window.$message?.success(`已催办 ${data?.remindedCount ?? 0} 人`);
|
||||
emit('reminded');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="props.loading" class="team-report-summary">
|
||||
<div v-if="props.periodLabel" class="team-report-summary__period">{{ props.periodLabel }}</div>
|
||||
|
||||
<div class="team-report-summary__grid">
|
||||
<div v-for="card in cards" :key="card.label" class="team-report-summary__item">
|
||||
<div class="team-report-summary__label">{{ card.label }}</div>
|
||||
<div class="team-report-summary__value">
|
||||
<template v-if="card.key === 'unsubmitted'">
|
||||
<ElPopover placement="bottom" :width="300" trigger="hover">
|
||||
<template #reference>
|
||||
<button type="button" class="team-report-summary__link-button">{{ card.value }}</button>
|
||||
</template>
|
||||
|
||||
<div class="team-report-summary__popover">
|
||||
<div class="team-report-summary__popover-title">未提交人员</div>
|
||||
<div v-if="props.summary?.unsubmittedUsers?.length" class="team-report-summary__user-list">
|
||||
<div
|
||||
v-for="user in props.summary.unsubmittedUsers"
|
||||
:key="user.userId"
|
||||
class="team-report-summary__user-item"
|
||||
>
|
||||
<span class="team-report-summary__user-name">{{ user.userNickname }}</span>
|
||||
<ElButton
|
||||
link
|
||||
type="primary"
|
||||
:loading="remindingUserId === user.userId"
|
||||
@click="handleRemind([user.userId])"
|
||||
>
|
||||
催办
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
<ElEmpty v-else :image-size="60" description="暂无待提交人员" />
|
||||
|
||||
<div class="team-report-summary__popover-footer">
|
||||
<ElButton
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
:loading="remindingAll"
|
||||
:disabled="!props.summary?.unsubmittedUsers?.length"
|
||||
@click="handleRemind()"
|
||||
>
|
||||
一键催办全部
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</ElPopover>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ card.value }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.team-report-summary {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.team-report-summary__period {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.team-report-summary__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.team-report-summary__item {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-blank);
|
||||
}
|
||||
|
||||
.team-report-summary__label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.team-report-summary__value {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.team-report-summary__link-button {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--el-color-primary);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.team-report-summary__popover {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.team-report-summary__popover-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.team-report-summary__user-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.team-report-summary__user-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.team-report-summary__user-name {
|
||||
min-width: 0;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.team-report-summary__popover-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (width <= 1200px) {
|
||||
.team-report-summary__grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.team-report-summary__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -41,6 +41,16 @@ export const WORK_REPORT_TYPE_LABEL: Record<WorkReportType, string> = {
|
||||
project: '项目半月报'
|
||||
};
|
||||
|
||||
export const TEAM_WORK_REPORT_TYPE_LABEL: Record<WorkReportType, string> = {
|
||||
weekly: '团队周报',
|
||||
monthly: '团队月报',
|
||||
project: '团队项目半月报'
|
||||
};
|
||||
|
||||
export function getWorkReportTypeDisplayLabel(reportType: WorkReportType, isTeamMode = false) {
|
||||
return isTeamMode ? TEAM_WORK_REPORT_TYPE_LABEL[reportType] : WORK_REPORT_TYPE_LABEL[reportType];
|
||||
}
|
||||
|
||||
export const WORK_REPORT_STATUS_LABEL: Record<string, string> = {
|
||||
draft: '待提交',
|
||||
pending_approval: '待审批',
|
||||
|
||||
@@ -20,6 +20,14 @@ export interface WorkReportPeriodOption {
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkReportResolvedPeriod {
|
||||
periodKey: string;
|
||||
periodLabel: string;
|
||||
periodStartDate: string;
|
||||
periodEndDate: string;
|
||||
flag?: number;
|
||||
}
|
||||
|
||||
function formatRangeLabel(start: dayjs.Dayjs, end: dayjs.Dayjs) {
|
||||
return `${start.format('YYYY-MM-DD')} 至 ${end.format('YYYY-MM-DD')}`;
|
||||
}
|
||||
@@ -192,3 +200,72 @@ export function getReportTypePeriodOptions(now = dayjs()) {
|
||||
project: getProjectPeriodOptions(now)
|
||||
} as const;
|
||||
}
|
||||
|
||||
type PeriodRange = string[] | null | undefined;
|
||||
|
||||
interface ResolveWorkReportSummaryPeriodOptions {
|
||||
currentRow?: Partial<WorkReportResolvedPeriod> | null;
|
||||
periodRange?: PeriodRange;
|
||||
flag?: number | null;
|
||||
}
|
||||
|
||||
function getRangeReferenceDate(periodRange?: PeriodRange) {
|
||||
const [, endDate] = periodRange || [];
|
||||
return endDate || periodRange?.[0] || '';
|
||||
}
|
||||
|
||||
function inferProjectSummaryFlag(referenceDate: string, explicitFlag?: number | null) {
|
||||
if (explicitFlag === 1 || explicitFlag === 2) {
|
||||
return explicitFlag;
|
||||
}
|
||||
|
||||
const selectedDate = dayjs(referenceDate);
|
||||
if (selectedDate.isValid()) {
|
||||
return selectedDate.date() > 15 ? 2 : 1;
|
||||
}
|
||||
|
||||
return getProjectPeriodOptions()[0]?.flag || 1;
|
||||
}
|
||||
|
||||
export function resolveWorkReportSummaryPeriod(
|
||||
reportType: WorkReportType,
|
||||
options: ResolveWorkReportSummaryPeriodOptions = {}
|
||||
): WorkReportResolvedPeriod {
|
||||
const { currentRow, periodRange, flag } = options;
|
||||
|
||||
if (currentRow?.periodKey && currentRow.periodStartDate && currentRow.periodEndDate) {
|
||||
return {
|
||||
periodKey: currentRow.periodKey,
|
||||
periodLabel: currentRow.periodLabel || '',
|
||||
periodStartDate: currentRow.periodStartDate,
|
||||
periodEndDate: currentRow.periodEndDate,
|
||||
flag: currentRow.flag
|
||||
};
|
||||
}
|
||||
|
||||
const referenceDate = getRangeReferenceDate(periodRange);
|
||||
const fallbackNow = dayjs();
|
||||
|
||||
if (reportType === 'weekly') {
|
||||
return referenceDate ? buildWeeklyPeriodFromDate(referenceDate) : buildWeeklyPeriodFromDate(fallbackNow);
|
||||
}
|
||||
|
||||
if (reportType === 'monthly') {
|
||||
return referenceDate ? buildMonthlyPeriodFromMonth(referenceDate) : buildMonthlyPeriodFromMonth(fallbackNow);
|
||||
}
|
||||
|
||||
if (referenceDate) {
|
||||
const resolvedFlag = inferProjectSummaryFlag(referenceDate, flag);
|
||||
return {
|
||||
...buildProjectPeriodFromMonth(referenceDate, resolvedFlag),
|
||||
flag: resolvedFlag
|
||||
};
|
||||
}
|
||||
|
||||
const fallbackOption = getProjectPeriodOptions(fallbackNow)[0];
|
||||
|
||||
return {
|
||||
...fallbackOption.period,
|
||||
flag: fallbackOption.flag
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
<script setup lang="tsx">
|
||||
/* eslint-disable no-void */
|
||||
import { markRaw, reactive, ref } from 'vue';
|
||||
import { computed, markRaw, reactive, ref } from 'vue';
|
||||
import { ElMessageBox, ElTag, ElTooltip } from 'element-plus';
|
||||
import {
|
||||
fetchDeleteWeeklyReport,
|
||||
fetchExportWeeklyReportContent,
|
||||
fetchGetTeamReportSummary,
|
||||
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 { type TeamViewContext, resolveTeamQueryUserIds } from '@/views/personal-center/shared/team-dashboard';
|
||||
import {
|
||||
WORK_REPORT_TYPE_LABEL,
|
||||
type WorkReportRow,
|
||||
createWeeklySearchParams,
|
||||
createWorkReportContentExportFallbackName,
|
||||
@@ -23,19 +24,27 @@ import {
|
||||
formatPeriodDateRange,
|
||||
formatWeeklyPeriodLabel,
|
||||
getWorkReportStatusLabel,
|
||||
getWorkReportTypeDisplayLabel,
|
||||
resolveExportFilename,
|
||||
resolveWorkReportStatusTagType,
|
||||
transformWorkReportPage
|
||||
} from '../shared/types';
|
||||
import { resolveWorkReportSummaryPeriod } from '../shared/utils';
|
||||
import TeamReportSummary from '../shared/components/team-report-summary.vue';
|
||||
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';
|
||||
import IconMdiDownloadOutline from '~icons/mdi/download-outline';
|
||||
|
||||
defineOptions({ name: 'WeeklyWorkReportIndex' });
|
||||
|
||||
const props = defineProps<{
|
||||
teamContext?: TeamViewContext | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'create'): void;
|
||||
(e: 'edit', row: WorkReportRow): void;
|
||||
@@ -47,21 +56,33 @@ const { hasAuth } = useAuth();
|
||||
const exporting = ref(false);
|
||||
const selectedRows = ref<Api.WorkReport.Weekly.WeeklyReport[]>([]);
|
||||
const searchParams = reactive(createWeeklySearchParams());
|
||||
const teamSummaryLoading = ref(false);
|
||||
const teamSummary = ref<Api.WorkReport.Common.TeamReportSummary | null>(null);
|
||||
|
||||
const ACTION_ICON_MAP = {
|
||||
detail: markRaw(IconMdiEyeOutline),
|
||||
edit: markRaw(IconMdiPencilOutline),
|
||||
submit: markRaw(IconMdiSendOutline),
|
||||
delete: markRaw(IconMdiDeleteOutline),
|
||||
approvalRecord: markRaw(IconMdiFileDocumentCheckOutline)
|
||||
approvalRecord: markRaw(IconMdiFileDocumentCheckOutline),
|
||||
export: markRaw(IconMdiDownloadOutline)
|
||||
};
|
||||
|
||||
const isTeamMode = computed(() => props.teamContext?.mode === 'team');
|
||||
const isTeamRootSelected = computed(() => Boolean(isTeamMode.value && props.teamContext?.isRootSelected));
|
||||
const currentTeamReporterIds = computed(() => resolveTeamQueryUserIds(props.teamContext));
|
||||
const reportTitle = computed(() => getWorkReportTypeDisplayLabel('weekly', isTeamMode.value));
|
||||
|
||||
const table = useUIPaginatedTable<
|
||||
Awaited<ReturnType<typeof fetchGetWeeklyReportPage>>,
|
||||
Api.WorkReport.Weekly.WeeklyReport
|
||||
>({
|
||||
paginationProps: { currentPage: searchParams.pageNo, pageSize: searchParams.pageSize },
|
||||
api: () => fetchGetWeeklyReportPage(searchParams),
|
||||
api: () =>
|
||||
fetchGetWeeklyReportPage({
|
||||
...searchParams,
|
||||
reporterIds: currentTeamReporterIds.value
|
||||
}),
|
||||
transform: response => transformWorkReportPage(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
|
||||
onPaginationParamsChange: params => {
|
||||
searchParams.pageNo = params.currentPage ?? 1;
|
||||
@@ -69,6 +90,7 @@ const table = useUIPaginatedTable<
|
||||
},
|
||||
columns: () => [
|
||||
{ prop: 'index', type: 'index', label: '序号', width: 64 },
|
||||
...(isTeamMode.value ? [{ prop: 'reporterName', label: '提交人', minWidth: 100, showOverflowTooltip: true }] : []),
|
||||
{
|
||||
prop: 'periodLabel',
|
||||
label: '周期',
|
||||
@@ -122,7 +144,7 @@ const table = useUIPaginatedTable<
|
||||
{
|
||||
prop: 'operate',
|
||||
label: '操作',
|
||||
width: 180,
|
||||
width: isTeamMode.value ? 140 : 180,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
|
||||
@@ -130,17 +152,53 @@ const table = useUIPaginatedTable<
|
||||
]
|
||||
});
|
||||
|
||||
const summaryPeriod = computed(() =>
|
||||
resolveWorkReportSummaryPeriod('weekly', {
|
||||
currentRow: table.data.value[0],
|
||||
periodRange: searchParams.periodStartDate
|
||||
})
|
||||
);
|
||||
|
||||
function getRowActions(row: Api.WorkReport.Weekly.WeeklyReport): BusinessTableAction[] {
|
||||
const actions: BusinessTableAction[] = [
|
||||
{
|
||||
key: 'detail',
|
||||
label: '详情',
|
||||
label: '查看',
|
||||
buttonType: 'primary',
|
||||
icon: ACTION_ICON_MAP.detail,
|
||||
onClick: () => emit('detail', row)
|
||||
}
|
||||
];
|
||||
|
||||
if (isTeamMode.value) {
|
||||
actions.push({
|
||||
key: 'export',
|
||||
label: '导出',
|
||||
buttonType: 'success',
|
||||
icon: ACTION_ICON_MAP.export,
|
||||
onClick: () =>
|
||||
exportReportContent(
|
||||
{
|
||||
exportAll: false,
|
||||
ids: [row.id]
|
||||
},
|
||||
1
|
||||
)
|
||||
});
|
||||
|
||||
if (['approved', 'rejected'].includes(row.statusCode)) {
|
||||
actions.push({
|
||||
key: 'approval-record',
|
||||
label: '审批记录',
|
||||
buttonType: 'info',
|
||||
icon: ACTION_ICON_MAP.approvalRecord,
|
||||
onClick: () => emit('approvalRecord', row)
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
if (['draft', 'rejected'].includes(row.statusCode) && row.allowEdit && hasAuth('project:work-report:update')) {
|
||||
actions.push({
|
||||
key: 'edit',
|
||||
@@ -183,6 +241,7 @@ function getRowActions(row: Api.WorkReport.Weekly.WeeklyReport): BusinessTableAc
|
||||
|
||||
async function reload(page?: number) {
|
||||
await table.getDataByPage(page ?? searchParams.pageNo ?? 1);
|
||||
await loadTeamSummary();
|
||||
}
|
||||
|
||||
function resetSearchParams() {
|
||||
@@ -238,7 +297,10 @@ function handleSelectionChange(rows: Api.WorkReport.Weekly.WeeklyReport[]) {
|
||||
|
||||
function createExportSearchParams() {
|
||||
const { pageNo: _pageNo, pageSize: _pageSize, ...params } = searchParams;
|
||||
return params;
|
||||
return {
|
||||
...params,
|
||||
reporterIds: currentTeamReporterIds.value
|
||||
};
|
||||
}
|
||||
|
||||
async function exportReportContent(
|
||||
@@ -296,6 +358,23 @@ async function handleExportCommand(command: 'selected' | 'all') {
|
||||
await handleExportAll();
|
||||
}
|
||||
|
||||
async function loadTeamSummary() {
|
||||
if (!isTeamRootSelected.value) {
|
||||
teamSummaryLoading.value = false;
|
||||
teamSummary.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
teamSummaryLoading.value = true;
|
||||
const { error, data } = await fetchGetTeamReportSummary({
|
||||
reportType: 'weekly',
|
||||
periodKey: summaryPeriod.value.periodKey
|
||||
});
|
||||
teamSummaryLoading.value = false;
|
||||
|
||||
teamSummary.value = error || !data ? null : data;
|
||||
}
|
||||
|
||||
defineExpose({ reload });
|
||||
</script>
|
||||
|
||||
@@ -303,11 +382,21 @@ defineExpose({ reload });
|
||||
<div class="flex-col-stretch gap-16px overflow-hidden">
|
||||
<WeeklyReportSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
|
||||
|
||||
<TeamReportSummary
|
||||
v-if="isTeamRootSelected"
|
||||
report-type="weekly"
|
||||
:period-key="summaryPeriod.periodKey"
|
||||
:period-label="formatWeeklyPeriodLabel(summaryPeriod)"
|
||||
:loading="teamSummaryLoading"
|
||||
:summary="teamSummary"
|
||||
@reminded="loadTeamSummary"
|
||||
/>
|
||||
|
||||
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
|
||||
<template #header>
|
||||
<div class="flex flex-wrap items-center justify-between gap-12px">
|
||||
<div class="flex items-center gap-10px">
|
||||
<p class="text-16px font-600">{{ WORK_REPORT_TYPE_LABEL.weekly }}</p>
|
||||
<p class="text-16px font-600">{{ reportTitle }}</p>
|
||||
<ElTag effect="plain">{{ table.mobilePagination.value.total || 0 }}</ElTag>
|
||||
</div>
|
||||
|
||||
@@ -333,7 +422,13 @@ defineExpose({ reload });
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
<ElButton v-auth="'project:work-report:create'" plain type="primary" @click="emit('create')">
|
||||
<ElButton
|
||||
v-if="!isTeamMode"
|
||||
v-auth="'project:work-report:create'"
|
||||
plain
|
||||
type="primary"
|
||||
@click="emit('create')"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
/* eslint-disable vue/no-mutating-props, unicorn/prefer-dom-node-text-content, no-useless-escape, no-nested-ternary */
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { Plus } from '@element-plus/icons-vue';
|
||||
import { RDMS_REQ_PRIORITY_DICT_CODE, RDMS_TASK_ITEM_TYPE_DICT_CODE } from '@/constants/dict';
|
||||
import { fetchGetMyParticipatedProjectPage } from '@/service/api';
|
||||
@@ -1065,6 +1065,34 @@ function blurEditField(key: string) {
|
||||
if (activeEditField.value === key) activeEditField.value = '';
|
||||
}
|
||||
|
||||
/** 编辑态下是否显示"具体工作内容"的结构化预览(含 ElPopover 工作日志) */
|
||||
function showContentStructuredView(index: number) {
|
||||
const item = reviewItems.value[index];
|
||||
if (!item?.contentSections?.length) return false;
|
||||
if (isReadonly.value) return true;
|
||||
// 编辑/新增模式下,仅在该字段未聚焦时显示结构化预览
|
||||
return activeEditField.value !== `content-${index}`;
|
||||
}
|
||||
|
||||
/** 编辑态下是否显示"具体目标"的结构化预览(含 ElPopover 工作日志) */
|
||||
function showTargetStructuredView(index: number) {
|
||||
const item = nextPlans.value[index];
|
||||
if (!item?.targetSections?.length) return false;
|
||||
if (isReadonly.value) return true;
|
||||
// 编辑/新增模式下,仅在该字段未聚焦时显示结构化预览
|
||||
return activeEditField.value !== `target-${index}`;
|
||||
}
|
||||
|
||||
/** 点击结构化预览区域时切换到编辑态并聚焦 */
|
||||
function handleStructuredViewClick(fieldKey: string) {
|
||||
if (isReadonly.value) return;
|
||||
activeEditField.value = fieldKey;
|
||||
nextTick(() => {
|
||||
const editor = document.querySelector(`[data-field-key="${fieldKey}"]`) as HTMLElement;
|
||||
editor?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function syncRichContent(item: ReviewItem, event: Event) {
|
||||
const target = event.currentTarget as HTMLElement;
|
||||
if (!item.source) return;
|
||||
@@ -1183,7 +1211,12 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
<div class="review-editor-grid">
|
||||
<div class="field">
|
||||
<label>具体工作内容及成果描述</label>
|
||||
<div v-if="isReadonly && item.contentSections?.length" class="rich-editor">
|
||||
<div
|
||||
v-if="showContentStructuredView(index)"
|
||||
class="rich-editor"
|
||||
:class="{ 'rich-editor--preview': !isReadonly }"
|
||||
@click="handleStructuredViewClick(`content-${index}`)"
|
||||
>
|
||||
<div
|
||||
v-for="(section, sectionIndex) in item.contentSections"
|
||||
:key="`${index}-${sectionIndex}`"
|
||||
@@ -1214,6 +1247,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
class="rich-editor"
|
||||
:contenteditable="!isReadonly"
|
||||
spellcheck="false"
|
||||
:data-field-key="`content-${index}`"
|
||||
:data-placeholder="isReadonly ? undefined : '请输入具体工作内容及成果描述'"
|
||||
@focus="focusEditField(`content-${index}`)"
|
||||
@blur="
|
||||
@@ -1282,7 +1316,12 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
<div class="plan-editor-grid">
|
||||
<div class="field">
|
||||
<label>具体目标</label>
|
||||
<div v-if="isReadonly && item.targetSections?.length" class="rich-editor">
|
||||
<div
|
||||
v-if="showTargetStructuredView(index)"
|
||||
class="rich-editor"
|
||||
:class="{ 'rich-editor--preview': !isReadonly }"
|
||||
@click="handleStructuredViewClick(`target-${index}`)"
|
||||
>
|
||||
<div
|
||||
v-for="(section, sectionIndex) in item.targetSections"
|
||||
:key="`${index}-${sectionIndex}`"
|
||||
@@ -1313,6 +1352,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
class="rich-editor"
|
||||
:contenteditable="!isReadonly"
|
||||
spellcheck="false"
|
||||
:data-field-key="`target-${index}`"
|
||||
:data-placeholder="isReadonly ? undefined : '请输入具体目标'"
|
||||
@focus="focusEditField(`target-${index}`)"
|
||||
@blur="
|
||||
@@ -2107,6 +2147,15 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 编辑态下结构化预览区域:点击可切换到编辑模式 */
|
||||
.rich-editor--preview {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.rich-editor--preview:hover {
|
||||
border-color: #0f766e;
|
||||
}
|
||||
|
||||
.structured-preview__popover {
|
||||
max-width: 100%;
|
||||
color: #334155;
|
||||
|
||||
Reference in New Issue
Block a user