feat(工作报告、加班申请团队视角): 工作报告、加班申请现在可以查看团队视角了(查看下属)。
fix(工作报告): 修复周报在新增/编辑时,不能展示工作日志。
This commit is contained in:
108
src/components/custom/subordinate-selector.vue
Normal file
108
src/components/custom/subordinate-selector.vue
Normal 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>
|
||||
163
src/components/custom/team-context-panel.vue
Normal file
163
src/components/custom/team-context-panel.vue
Normal 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>
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户管理链路详情
|
||||
*
|
||||
|
||||
@@ -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>>({
|
||||
|
||||
13
src/typings/api/overtime-application.d.ts
vendored
13
src/typings/api/overtime-application.d.ts
vendored
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
18
src/typings/api/system-manage.d.ts
vendored
18
src/typings/api/system-manage.d.ts
vendored
@@ -386,6 +386,24 @@ declare namespace Api {
|
||||
children?: UserManagementRelationTreeRespVO[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前登录用户的下属树
|
||||
*
|
||||
* 用于团队视角选择器;根节点代表“全部下属范围”
|
||||
*/
|
||||
interface MySubordinateTreeNode {
|
||||
/** 用户 ID */
|
||||
userId: string;
|
||||
/** 用户昵称 */
|
||||
userNickname: string;
|
||||
/** 是否为当前登录用户根节点 */
|
||||
isRoot: boolean;
|
||||
/** 全链路下属人数 */
|
||||
subordinateCount: number;
|
||||
/** 下级用户列表 */
|
||||
children?: MySubordinateTreeNode[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户管理链路保存参数
|
||||
*
|
||||
|
||||
34
src/typings/api/work-report.d.ts
vendored
34
src/typings/api/work-report.d.ts
vendored
@@ -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;
|
||||
};
|
||||
|
||||
2
src/typings/components.d.ts
vendored
2
src/typings/components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -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>
|
||||
|
||||
70
src/views/personal-center/shared/team-dashboard.ts
Normal file
70
src/views/personal-center/shared/team-dashboard.ts
Normal 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;
|
||||
}
|
||||
@@ -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