feat(工作报告、加班申请团队视角): 工作报告、加班申请现在可以查看团队视角了(查看下属)。

fix(工作报告): 修复周报在新增/编辑时,不能展示工作日志。
This commit is contained in:
dk
2026-06-14 23:57:42 +08:00
parent 17690283f6
commit 3c1cf6c7fa
19 changed files with 1618 additions and 94 deletions

View File

@@ -0,0 +1,108 @@
<script setup lang="ts">
defineOptions({ name: 'SubordinateSelector' });
interface Props {
loading?: boolean;
data?: Api.SystemManage.MySubordinateTreeNode | null;
emptyText?: string;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
data: null,
emptyText: '暂无下属数据'
});
const selectedUserId = defineModel<string | null>('selectedUserId', {
default: null
});
function handleNodeClick(node: Api.SystemManage.MySubordinateTreeNode) {
selectedUserId.value = node.userId;
}
function renderNodeLabel(node: Api.SystemManage.MySubordinateTreeNode) {
const label = node.isRoot ? '全部下属' : node.userNickname;
return `${label}${node.subordinateCount ? `${node.subordinateCount}` : ''}`;
}
</script>
<template>
<ElCard class="subordinate-selector" body-class="subordinate-selector__body">
<template #header>
<div class="flex items-center justify-between gap-12px">
<span class="text-14px font-600">团队成员</span>
<ElTag v-if="props.data" effect="plain">{{ props.data.subordinateCount }}</ElTag>
</div>
</template>
<div v-loading="props.loading" class="subordinate-selector__content">
<ElEmpty v-if="!props.data" :image-size="88" :description="props.emptyText" />
<ElTree
v-else
:data="[props.data]"
node-key="userId"
:current-node-key="selectedUserId || undefined"
:props="{ label: 'userNickname', children: 'children' }"
highlight-current
default-expand-all
expand-on-click-node
class="subordinate-selector__tree"
@node-click="handleNodeClick"
>
<template #default="{ data: node }">
<span class="subordinate-selector__node-label">{{ renderNodeLabel(node) }}</span>
</template>
</ElTree>
</div>
</ElCard>
</template>
<style scoped lang="scss">
.subordinate-selector {
display: flex;
flex-direction: column;
height: 100%;
border: 1px solid var(--el-border-color-light);
box-shadow: none;
}
:deep(.subordinate-selector__body) {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
padding: 12px;
}
.subordinate-selector__content {
flex: 1;
min-height: 240px;
overflow: auto;
}
.subordinate-selector__tree {
height: 100%;
background: transparent;
}
.subordinate-selector__node-label {
display: inline-flex;
align-items: center;
min-width: 0;
color: var(--el-text-color-regular);
}
:deep(.subordinate-selector__tree .el-tree-node__content) {
height: 36px;
border-radius: 8px;
}
:deep(.subordinate-selector__tree .el-tree-node__content:hover) {
background: var(--el-fill-color-light);
}
:deep(.subordinate-selector__tree .el-tree-node.is-current > .el-tree-node__content) {
background: var(--el-color-primary-light-9);
}
</style>

View File

@@ -0,0 +1,163 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { TeamViewMode } from '@/views/personal-center/shared/team-dashboard';
defineOptions({ name: 'TeamContextPanel' });
interface Props {
loading?: boolean;
selectedLabel?: string;
subordinateCount?: number;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
selectedLabel: '',
subordinateCount: 0
});
const mode = defineModel<TeamViewMode>('mode', {
required: true
});
const scopeOptions = computed(() => [
{ label: '个人视角', value: 'self' satisfies TeamViewMode },
{ label: '团队视角', value: 'team' satisfies TeamViewMode }
]);
const contextText = computed(() => {
if (mode.value === 'self') {
return '当前查看我自己的数据。';
}
if (props.selectedLabel) {
return `当前范围:${props.selectedLabel}`;
}
return '当前查看团队数据。';
});
</script>
<template>
<ElCard class="team-context-panel" body-class="team-context-panel__body">
<div v-loading="props.loading" class="team-context-panel__layout">
<div class="team-context-panel__controls">
<ElSegmented v-model="mode" :options="scopeOptions" class="team-context-panel__segmented" />
</div>
<div class="team-context-panel__info">
<div class="team-context-panel__info-main">
<div class="team-context-panel__info-item">
<span class="team-context-panel__info-label">当前范围</span>
<strong class="team-context-panel__info-value">
{{ props.selectedLabel || (mode === 'self' ? '我自己' : '--') }}
</strong>
</div>
<div v-if="mode === 'team'" class="team-context-panel__info-item">
<span class="team-context-panel__info-label">下属人数</span>
<strong class="team-context-panel__info-value">{{ props.subordinateCount }}</strong>
</div>
</div>
<p class="team-context-panel__info-desc">{{ contextText }}</p>
<div v-if="$slots.default" class="team-context-panel__summary">
<slot />
</div>
</div>
</div>
</ElCard>
</template>
<style scoped lang="scss">
.team-context-panel {
border: 1px solid var(--el-border-color-light);
background: var(--el-fill-color-blank);
box-shadow: none;
}
:deep(.team-context-panel__body) {
padding: 16px 18px;
}
.team-context-panel__layout {
display: flex;
align-items: flex-start;
gap: 20px;
}
.team-context-panel__controls {
flex-shrink: 0;
}
:deep(.team-context-panel__segmented) {
padding: 6px;
background: var(--el-fill-color-light);
border-radius: 12px;
}
:deep(.team-context-panel__segmented .el-segmented__item) {
min-width: 96px;
min-height: 40px;
padding: 0 22px;
font-size: 14px;
}
.team-context-panel__info {
flex: 1;
min-width: 0;
padding-left: 20px;
border-left: 1px solid var(--el-border-color-lighter);
}
.team-context-panel__info-main {
display: flex;
flex-wrap: wrap;
gap: 24px;
}
.team-context-panel__info-item {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 180px;
}
.team-context-panel__info-label {
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.5;
}
.team-context-panel__info-value {
color: var(--el-text-color-primary);
font-size: 15px;
font-weight: 600;
line-height: 1.5;
}
.team-context-panel__info-desc {
margin: 10px 0 0;
color: var(--el-text-color-regular);
font-size: 13px;
line-height: 1.6;
}
.team-context-panel__summary {
margin-top: 14px;
}
@media (width <= 1200px) {
.team-context-panel__layout {
flex-direction: column;
align-items: stretch;
}
.team-context-panel__info {
padding-left: 0;
padding-top: 14px;
border-left: none;
border-top: 1px solid var(--el-border-color-lighter);
}
}
</style>

View File

@@ -34,6 +34,8 @@ type OvertimeApplicationApprovalRecordResponse = Omit<
auditorUserId: StringIdResponse;
};
type TeamOvertimeSummaryResponse = Api.OvertimeApplication.TeamOvertimeSummary;
function normalizeBooleanFlag(value: boolean | number | string | null | undefined) {
if (typeof value === 'boolean') {
return value;
@@ -94,6 +96,18 @@ function createPageQuery(params: Api.OvertimeApplication.OvertimeApplicationSear
query.append('pageNo', String(params.pageNo ?? 1));
query.append('pageSize', String(params.pageSize ?? 10));
if (params.applicantIds !== null && params.applicantIds !== undefined) {
if (params.applicantIds.length) {
params.applicantIds.forEach(item => {
if (item) {
query.append('applicantIds', item);
}
});
} else {
query.append('applicantIds', '');
}
}
if (params.keyword) {
query.append('keyword', params.keyword);
}
@@ -287,6 +301,17 @@ export async function fetchGetOvertimeApplicationStatusDict() {
);
}
export async function fetchGetTeamOvertimeSummary(params: Api.OvertimeApplication.TeamOvertimeSummaryParams = {}) {
const result = await request<TeamOvertimeSummaryResponse>({
...safeJsonRequestConfig,
url: `${OVERTIME_APPLICATION_PREFIX}/team/summary`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<TeamOvertimeSummaryResponse>, data => data);
}
export function fetchExportOvertimeApplications(params: Api.OvertimeApplication.OvertimeApplicationSearchParams = {}) {
const query = createPageQuery(params);

View File

@@ -118,6 +118,11 @@ type UserManagementRelationTreeResponse = Omit<
children?: UserManagementRelationTreeResponse[] | null;
};
type MySubordinateTreeNodeResponse = Omit<Api.SystemManage.MySubordinateTreeNode, 'userId' | 'children'> & {
userId: string | number;
children?: MySubordinateTreeNodeResponse[] | null;
};
function normalizeUserSimple(user: UserSimpleResponse): Api.SystemManage.UserSimple {
return {
...user,
@@ -181,6 +186,14 @@ function normalizeUserManagementRelationTree(
};
}
function normalizeMySubordinateTreeNode(node: MySubordinateTreeNodeResponse): Api.SystemManage.MySubordinateTreeNode {
return {
...node,
userId: normalizeStringId(node.userId),
children: node.children?.map(normalizeMySubordinateTreeNode) ?? null
};
}
/** 获取角色分页 */
export async function fetchGetRolePage(params?: Api.SystemManage.RoleSearchParams) {
const query = createRolePageQuery(params);
@@ -712,6 +725,17 @@ export async function fetchGetUserManagementRelationQuery(query: UserManagementR
);
}
/** 获取当前登录用户下属树 */
export async function fetchGetMySubordinateTree() {
return request<MySubordinateTreeNodeResponse>({
...safeJsonRequestConfig,
url: `${USER_MANAGEMENT_RELATION_PREFIX}/my-subordinate-tree`,
method: 'get'
}).then(result =>
mapServiceResult(result as ServiceRequestResult<MySubordinateTreeNodeResponse>, normalizeMySubordinateTreeNode)
);
}
/**
* 获取用户管理链路详情
*

View File

@@ -99,6 +99,14 @@ type ProjectOptionResponse = Omit<Api.WorkReport.Project.ProjectReportOwnerProje
id: StringIdResponse;
};
type TeamReportPendingUserResponse = Omit<Api.WorkReport.Common.TeamReportPendingUser, 'userId'> & {
userId: StringIdResponse;
};
type TeamReportSummaryResponse = Omit<Api.WorkReport.Common.TeamReportSummary, 'unsubmittedUsers'> & {
unsubmittedUsers?: TeamReportPendingUserResponse[] | null;
};
function normalizeBooleanFlag(value: boolean | number | string | null | undefined) {
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value === 1;
@@ -173,6 +181,21 @@ function appendArray(query: URLSearchParams, key: string, values?: Array<string
values?.forEach(value => appendValue(query, key, value));
}
function appendNullableArrayFlag(
query: URLSearchParams,
key: string,
values?: Array<string | null | undefined> | null
) {
if (values === null || values === undefined) return;
if (!values.length) {
query.append(key, '');
return;
}
appendArray(query, key, values);
}
function createBasePageQuery(params: Api.WorkReport.Common.WorkReportBaseSearchParams = {}) {
const query = new URLSearchParams();
@@ -189,16 +212,20 @@ function createBasePageQuery(params: Api.WorkReport.Common.WorkReportBaseSearchP
function createWeeklyPageQuery(params: Api.WorkReport.Weekly.WeeklyReportSearchParams = {}) {
const query = createBasePageQuery(params);
appendNullableArrayFlag(query, 'reporterIds', params.reporterIds);
appendValue(query, 'isBusinessTrip', params.isBusinessTrip);
return query.toString();
}
function createMonthlyPageQuery(params: Api.WorkReport.Monthly.MonthlyReportSearchParams = {}) {
return createBasePageQuery(params).toString();
const query = createBasePageQuery(params);
appendNullableArrayFlag(query, 'reporterIds', params.reporterIds);
return query.toString();
}
function createProjectPageQuery(params: Api.WorkReport.Project.ProjectReportSearchParams = {}) {
const query = createBasePageQuery(params);
appendNullableArrayFlag(query, 'projectOwnerIds', params.projectOwnerIds);
appendValue(query, 'projectId', params.projectId);
appendValue(query, 'flag', params.flag);
return query.toString();
@@ -338,6 +365,17 @@ function normalizeProjectOption(
};
}
function normalizeTeamReportSummary(response: TeamReportSummaryResponse): Api.WorkReport.Common.TeamReportSummary {
return {
...response,
unsubmittedUsers:
response.unsubmittedUsers?.map(item => ({
...item,
userId: normalizeStringId(item.userId)
})) ?? []
};
}
function mapPage<TInput, TOutput>(data: PageResponse<TInput>, mapper: (item: TInput) => TOutput) {
return {
total: normalizeTotal(data.total),
@@ -440,6 +478,34 @@ export async function fetchGetWorkReportStatusDict() {
return mapServiceResult(result as ServiceRequestResult<Api.WorkReport.Common.WorkReportStatusDict[]>, data => data);
}
export async function fetchGetTeamReportSummary(params: Api.WorkReport.Common.TeamReportSummaryParams) {
const result = await request<TeamReportSummaryResponse>({
...safeJsonRequestConfig,
url: `${WORK_REPORT_PREFIX}/team/summary`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<TeamReportSummaryResponse>, normalizeTeamReportSummary);
}
export async function fetchRemindTeamReport(data: Api.WorkReport.Common.TeamReportRemindParams) {
const result = await request<Api.WorkReport.Common.TeamReportRemindResult>({
...safeJsonRequestConfig,
url: `${WORK_REPORT_PREFIX}/team/remind`,
method: 'post',
data: {
...data,
userIds: data.userIds && data.userIds.length ? data.userIds : undefined
}
});
return mapServiceResult(
result as ServiceRequestResult<Api.WorkReport.Common.TeamReportRemindResult>,
payload => payload
);
}
export async function fetchGetWeeklyReportPage(params: Api.WorkReport.Weekly.WeeklyReportSearchParams = {}) {
const query = createWeeklyPageQuery(params);
const result = await request<PageResponse<WeeklyReportResponse>>({

View File

@@ -32,6 +32,7 @@ declare namespace Api {
type OvertimeApplicationSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
applicantIds: string[] | null;
keyword: string;
applicantName: string;
approverId: string;
@@ -95,5 +96,17 @@ declare namespace Api {
terminalFlag: boolean;
allowEdit: boolean;
}
interface TeamOvertimeSummaryParams {
month?: string | null;
}
interface TeamOvertimeSummary {
month: string;
totalApplicationCount: number;
pendingCount: number;
approvedCount: number;
rejectedCount: number;
}
}
}

View File

@@ -386,6 +386,24 @@ declare namespace Api {
children?: UserManagementRelationTreeRespVO[] | null;
}
/**
* 当前登录用户的下属树
*
* 用于团队视角选择器;根节点代表“全部下属范围”
*/
interface MySubordinateTreeNode {
/** 用户 ID */
userId: string;
/** 用户昵称 */
userNickname: string;
/** 是否为当前登录用户根节点 */
isRoot: boolean;
/** 全链路下属人数 */
subordinateCount: number;
/** 下级用户列表 */
children?: MySubordinateTreeNode[] | null;
}
/**
* 用户管理链路保存参数
*

View File

@@ -71,6 +71,34 @@ declare namespace Api {
total: number;
list: T[];
}
interface TeamReportPendingUser {
userId: string;
userNickname: string;
}
interface TeamReportSummary {
totalShouldSubmit: number;
submittedCount: number;
unsubmittedCount: number;
pendingApprovalCount: number;
unsubmittedUsers: TeamReportPendingUser[];
}
interface TeamReportSummaryParams {
reportType: ReportType;
periodKey: string;
}
interface TeamReportRemindParams {
reportType: ReportType;
periodKey: string;
userIds?: string[] | null;
}
interface TeamReportRemindResult {
remindedCount: number;
}
}
namespace Weekly {
@@ -114,6 +142,7 @@ declare namespace Api {
}
type WeeklyReportSearchParams = Common.WorkReportBaseSearchParams & {
reporterIds?: string[] | null;
isBusinessTrip?: boolean | string | null;
};
@@ -164,7 +193,9 @@ declare namespace Api {
planItems: Common.PersonalReportPlanItem[];
}
type MonthlyReportSearchParams = Common.WorkReportBaseSearchParams;
type MonthlyReportSearchParams = Common.WorkReportBaseSearchParams & {
reporterIds?: string[] | null;
};
interface MonthlyReportSaveParams {
periodKey: string;
@@ -266,6 +297,7 @@ declare namespace Api {
}
type ProjectReportSearchParams = Common.WorkReportBaseSearchParams & {
projectOwnerIds?: string[] | null;
projectId?: string | null;
flag?: number | null;
};

View File

@@ -178,12 +178,14 @@ declare module 'vue' {
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SoybeanAvatar: typeof import('./../components/custom/soybean-avatar.vue')['default']
SubordinateSelector: typeof import('./../components/custom/subordinate-selector.vue')['default']
SvgIcon: typeof import('./../components/custom/svg-icon.vue')['default']
SystemLogo: typeof import('./../components/common/system-logo.vue')['default']
TableColumnSetting: typeof import('./../components/advanced/table-column-setting.vue')['default']
TableHeaderOperation: typeof import('./../components/advanced/table-header-operation.vue')['default']
TableSearchFields: typeof import('./../components/custom/table-search-fields.vue')['default']
TableSearchPanel: typeof import('./../components/custom/table-search-panel.vue')['default']
TeamContextPanel: typeof import('./../components/custom/team-context-panel.vue')['default']
ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default']
UserPickerTrigger: typeof import('./../components/custom/business-user-picker/components/user-picker-trigger.vue')['default']
WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']

View File

@@ -1,10 +1,24 @@
<script setup lang="tsx">
import { computed, markRaw, reactive, ref } from 'vue';
import { computed, markRaw, reactive, ref, watch } from 'vue';
import { ElButton, ElTag } from 'element-plus';
import dayjs from 'dayjs';
import { fetchExportOvertimeApplications, fetchGetOvertimeApplicationPage } from '@/service/api';
import {
fetchExportOvertimeApplications,
fetchGetMySubordinateTree,
fetchGetOvertimeApplicationPage,
fetchGetTeamOvertimeSummary
} from '@/service/api';
import { useUIPaginatedTable } from '@/hooks/common/table';
import { useAuth } from '@/hooks/business/auth';
import SubordinateSelector from '@/components/custom/subordinate-selector.vue';
import TeamContextPanel from '@/components/custom/team-context-panel.vue';
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
import {
type TeamViewContext,
type TeamViewMode,
collectSubordinateUserIds,
findSubordinateNode
} from '../shared/team-dashboard';
import OvertimeApplicationApprovalRecordDialog from './modules/overtime-application-approval-record-dialog.vue';
import OvertimeApplicationDetailDialog from './modules/overtime-application-detail-dialog.vue';
import OvertimeApplicationOperateDialog from './modules/overtime-application-operate-dialog.vue';
@@ -20,6 +34,7 @@ import {
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
import IconMdiFileDocumentCheckOutline from '~icons/mdi/file-document-check-outline';
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
import IconMdiDownloadOutline from '~icons/mdi/download-outline';
defineOptions({ name: 'OvertimeApplication' });
@@ -58,6 +73,13 @@ function transformPageResult(response: OvertimeApplicationPageResponse, pageNo:
}
const searchParams = reactive(getInitSearchParams());
const { hasAuth } = useAuth();
const teamViewMode = ref<TeamViewMode>('self');
const subordinateTreeLoading = ref(false);
const subordinateTree = ref<Api.SystemManage.MySubordinateTreeNode | null>(null);
const selectedSubordinateUserId = ref<string | null>(null);
const teamSummaryLoading = ref(false);
const teamSummary = ref<Api.OvertimeApplication.TeamOvertimeSummary | null>(null);
const operateVisible = ref(false);
const detailVisible = ref(false);
const approvalRecordVisible = ref(false);
@@ -71,6 +93,39 @@ const ACTION_ICON_MAP = {
edit: markRaw(IconMdiPencilOutline)
};
const canUseTeamDashboard = computed(() => hasAuth('project:work-report:team-dashboard'));
const allSubordinateUserIds = computed(() => collectSubordinateUserIds(subordinateTree.value));
const selectedSubordinateNode = computed(() =>
findSubordinateNode(subordinateTree.value, selectedSubordinateUserId.value)
);
const isTeamMode = computed(() => teamViewMode.value === 'team');
const isRootSelected = computed(() => Boolean(isTeamMode.value && selectedSubordinateNode.value?.isRoot));
const selectedTeamLabel = computed(() => {
if (!isTeamMode.value) 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:
isTeamMode.value && selectedSubordinateUserId.value && !isRootSelected.value
? [selectedSubordinateUserId.value]
: [],
isRootSelected: isRootSelected.value,
allSubordinateUserIds: allSubordinateUserIds.value,
selectedLabel: selectedTeamLabel.value
};
});
const currentApplicantIds = computed(() => {
if (!isTeamMode.value) return null;
if (isRootSelected.value) return [];
return teamContext.value?.selectedUserIds ?? [];
});
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
OvertimeApplicationPageResponse,
Api.OvertimeApplication.OvertimeApplication
@@ -79,7 +134,11 @@ const { columns, columnChecks, data, loading, getDataByPage, mobilePagination }
currentPage: searchParams.pageNo,
pageSize: searchParams.pageSize
},
api: () => fetchGetOvertimeApplicationPage(searchParams),
api: () =>
fetchGetOvertimeApplicationPage({
...searchParams,
applicantIds: currentApplicantIds.value
}),
transform: response => transformPageResult(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
onPaginationParamsChange: params => {
searchParams.pageNo = params.currentPage ?? 1;
@@ -87,7 +146,7 @@ const { columns, columnChecks, data, loading, getDataByPage, mobilePagination }
},
columns: () => [
{ prop: 'index', type: 'index', label: '序号', width: 64 },
{ prop: 'applicantName', label: '申请人', minWidth: 120, showOverflowTooltip: true },
...(isTeamMode.value ? [{ prop: 'applicantName', label: '申请人', minWidth: 120, showOverflowTooltip: true }] : []),
{
prop: 'overtimeDate',
label: '加班日期',
@@ -136,7 +195,7 @@ const { columns, columnChecks, data, loading, getDataByPage, mobilePagination }
{
prop: 'operate',
label: '操作',
width: 170,
width: isTeamMode.value ? 140 : 170,
align: 'center',
fixed: 'right',
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
@@ -150,13 +209,27 @@ function getRowActions(row: Api.OvertimeApplication.OvertimeApplication): Busine
const actions: BusinessTableAction[] = [
{
key: 'detail',
label: '详情',
label: '查看',
buttonType: 'primary',
icon: ACTION_ICON_MAP.detail,
onClick: () => openDetail(row)
}
];
if (isTeamMode.value) {
if (['approved', 'rejected'].includes(row.statusCode)) {
actions.push({
key: 'approval-record',
label: '审批记录',
buttonType: 'info',
icon: ACTION_ICON_MAP.approvalRecord,
onClick: () => openApprovalRecord(row)
});
}
return actions;
}
if (row.statusCode === 'rejected' && row.allowEdit) {
actions.push({
key: 'edit',
@@ -221,9 +294,20 @@ function handleSubmitted() {
reloadTable(searchParams.pageNo ?? 1);
}
function createExportParams() {
const { pageNo: _pageNo, pageSize: _pageSize, ...params } = searchParams;
return {
...params,
applicantIds: currentApplicantIds.value
};
}
async function handleExport() {
exporting.value = true;
const { error, data: blob } = await fetchExportOvertimeApplications(searchParams);
const { error, data: blob } = await fetchExportOvertimeApplications({
...createExportParams(),
applicantIds: currentApplicantIds.value
});
exporting.value = false;
if (error || !blob) {
@@ -232,56 +316,150 @@ async function handleExport() {
downloadBlob(blob, `加班申请_${dayjs().format('YYYY-MM-DD')}.xls`);
}
async function loadSubordinateTree() {
if (!canUseTeamDashboard.value) return;
subordinateTreeLoading.value = true;
const { error, data: treeData } = await fetchGetMySubordinateTree();
subordinateTreeLoading.value = false;
subordinateTree.value = error || !treeData ? null : treeData;
selectedSubordinateUserId.value = treeData?.userId || null;
}
async function loadTeamSummary() {
if (!isRootSelected.value) {
teamSummary.value = null;
return;
}
teamSummaryLoading.value = true;
const { error, data: summaryData } = await fetchGetTeamOvertimeSummary();
teamSummaryLoading.value = false;
teamSummary.value = error || !summaryData ? null : summaryData;
}
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 reloadTable(1);
}
watch(
() => [teamViewMode.value, selectedSubordinateUserId.value],
async () => {
if (!isTeamMode.value) return;
await reloadTable(1);
}
);
watch(
() => isRootSelected.value,
() => {
loadTeamSummary();
}
);
</script>
<template>
<div class="flex-col-stretch gap-16px overflow-hidden">
<OvertimeApplicationSearch 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">加班申请</p>
<ElTag effect="plain">{{ totalCount }}</ElTag>
</div>
<TableHeaderOperation v-model:columns="columnChecks" :loading="loading" @refresh="reloadTable">
<template #default>
<ElButton plain :loading="exporting" @click="handleExport">
<template #icon>
<icon-mdi-download class="text-icon" />
</template>
导出
</ElButton>
<ElButton plain type="primary" @click="openAdd">
<template #icon>
<icon-ic-round-plus class="text-icon" />
</template>
新增
</ElButton>
</template>
</TableHeaderOperation>
<div class="overtime-application-page">
<TeamContextPanel
v-if="canUseTeamDashboard"
v-model:mode="teamViewMode"
:loading="subordinateTreeLoading"
:selected-label="selectedTeamLabel"
:subordinate-count="subordinateTree?.subordinateCount || 0"
@update:mode="handleTeamViewModeChange"
>
<div v-if="isRootSelected" v-loading="teamSummaryLoading" class="team-overtime-summary">
<div class="team-overtime-summary__item">
<span class="team-overtime-summary__label">本月申请单数</span>
<strong class="team-overtime-summary__value">{{ teamSummary?.totalApplicationCount ?? 0 }}</strong>
</div>
<div class="team-overtime-summary__item">
<span class="team-overtime-summary__label">本月待审批</span>
<strong class="team-overtime-summary__value">{{ teamSummary?.pendingCount ?? 0 }}</strong>
</div>
<div class="team-overtime-summary__item">
<span class="team-overtime-summary__label">本月已通过</span>
<strong class="team-overtime-summary__value">{{ teamSummary?.approvedCount ?? 0 }}</strong>
</div>
<div class="team-overtime-summary__item">
<span class="team-overtime-summary__label">本月已退回</span>
<strong class="team-overtime-summary__value">{{ teamSummary?.rejectedCount ?? 0 }}</strong>
</div>
</template>
<div class="flex-1">
<ElTable v-loading="loading" height="100%" border row-key="id" :data="data">
<template v-for="col in columns" :key="String(col.prop)">
<ElTableColumn v-bind="col" />
</template>
</ElTable>
</div>
</TeamContextPanel>
<div class="mt-20px flex justify-end">
<ElPagination
v-if="mobilePagination.total"
layout="total,prev,pager,next,sizes"
v-bind="mobilePagination"
@current-change="mobilePagination['current-change']"
@size-change="mobilePagination['size-change']"
<div class="overtime-application-page__content" :class="{ 'overtime-application-page__content--team': isTeamMode }">
<div v-if="canUseTeamDashboard && isTeamMode" class="overtime-application-page__sidebar">
<SubordinateSelector
v-model:selected-user-id="selectedSubordinateUserId"
:loading="subordinateTreeLoading"
:data="subordinateTree"
/>
</div>
</ElCard>
<div class="overtime-application-page__main">
<OvertimeApplicationSearch 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">加班申请</p>
<ElTag effect="plain">{{ totalCount }}</ElTag>
</div>
<TableHeaderOperation v-model:columns="columnChecks" :loading="loading" @refresh="reloadTable">
<template #default>
<ElButton plain :loading="exporting" @click="handleExport">
<template #icon>
<icon-mdi-download class="text-icon" />
</template>
导出
</ElButton>
<ElButton v-if="!isTeamMode" plain type="primary" @click="openAdd">
<template #icon>
<icon-ic-round-plus class="text-icon" />
</template>
新增
</ElButton>
</template>
</TableHeaderOperation>
</div>
</template>
<div class="flex-1">
<ElTable v-loading="loading" height="100%" border row-key="id" :data="data">
<template v-for="col in columns" :key="String(col.prop)">
<ElTableColumn v-bind="col" />
</template>
</ElTable>
</div>
<div class="mt-20px flex justify-end">
<ElPagination
v-if="mobilePagination.total"
layout="total,prev,pager,next,sizes"
v-bind="mobilePagination"
@current-change="mobilePagination['current-change']"
@size-change="mobilePagination['size-change']"
/>
</div>
</ElCard>
</div>
</div>
<OvertimeApplicationOperateDialog
v-model:visible="operateVisible"
@@ -297,6 +475,42 @@ async function handleExport() {
</template>
<style scoped>
.overtime-application-page {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
overflow: hidden;
}
.overtime-application-page__content {
display: flex;
flex: 1;
flex-direction: column;
gap: 16px;
min-height: 0;
}
.overtime-application-page__main {
display: flex;
flex: 1;
flex-direction: column;
gap: 16px;
min-width: 0;
min-height: 0;
}
@media (min-width: 1280px) {
.overtime-application-page__content--team {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
}
.overtime-application-page__sidebar {
min-height: 0;
}
}
:deep(.overtime-application__reason-link) {
max-width: 100%;
padding: 0;
@@ -318,4 +532,31 @@ async function handleExport() {
text-overflow: ellipsis;
white-space: nowrap;
}
.team-overtime-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.team-overtime-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-overtime-summary__label {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.team-overtime-summary__value {
color: var(--el-text-color-primary);
font-size: 22px;
font-weight: 600;
line-height: 1.2;
}
</style>

View File

@@ -0,0 +1,70 @@
export type TeamViewMode = 'self' | 'team';
export interface TeamSelectableUser {
userId: string;
userNickname: string;
}
export interface TeamSelectionState {
mode: TeamViewMode;
selectedUserId: string | null;
selectedUserIds: string[] | null;
isRootSelected: boolean;
}
export interface TeamViewContext extends TeamSelectionState {
allSubordinateUserIds: string[];
selectedLabel: string;
}
export function resolveTeamQueryUserIds(context: TeamViewContext | null | undefined): string[] | null {
if (!context || context.mode !== 'team') {
return null;
}
if (context.isRootSelected) {
return [...context.allSubordinateUserIds];
}
return context.selectedUserIds ? [...context.selectedUserIds] : [];
}
export function collectSubordinateUserIds(root: Api.SystemManage.MySubordinateTreeNode | null | undefined): string[] {
if (!root) return [];
const ids: string[] = [];
const walk = (nodes?: Api.SystemManage.MySubordinateTreeNode[] | null) => {
nodes?.forEach(node => {
ids.push(node.userId);
walk(node.children ?? null);
});
};
walk(root.children ?? null);
return ids;
}
export function findSubordinateNode(
root: Api.SystemManage.MySubordinateTreeNode | null | undefined,
userId: string | null
): Api.SystemManage.MySubordinateTreeNode | null {
if (!root || !userId) return null;
if (root.userId === userId) {
return root;
}
const stack = [...(root.children ?? [])];
while (stack.length) {
const current = stack.shift()!;
if (current.userId === userId) {
return current;
}
if (current.children?.length) {
stack.push(...current.children);
}
}
return null;
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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: '待审批',

View File

@@ -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
};
}

View File

@@ -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>

View File

@@ -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;